3 Коммиты 05194511b1 ... 8c5dbeed4d

Автор SHA1 Сообщение Дата
  xsh_1997 8c5dbeed4d 1223 дней назад: 3
  xsh_1997 b141c972e9 Merge branch 'master' of http://192.168.1.25:3000/lbyzx123/huimv-employment дней назад: 3
  xsh_1997 0252b86310 123 дней назад: 4
28 измененных файлов с 3809 добавлено и 420 удалено
  1. 29 0
      huimv-employment/app/api/contract.js
  2. 30 0
      huimv-employment/app/api/notification.js
  3. 80 0
      huimv-employment/app/api/order.js
  4. 26 0
      huimv-employment/app/api/worker-registration.js
  5. 1 0
      huimv-employment/app/common/config.js
  6. 180 0
      huimv-employment/app/common/fe.scss
  7. 11 0
      huimv-employment/app/manifest.json
  8. 25 2
      huimv-employment/app/packageA/chat/index.vue
  9. 104 47
      huimv-employment/app/packageA/components/EnterpriseHubPanel.vue
  10. 178 107
      huimv-employment/app/packageA/components/chat/FeConversationProgressSheet.vue
  11. 139 73
      huimv-employment/app/packageA/components/chat/FeProgressSheet.vue
  12. 138 14
      huimv-employment/app/packageA/components/chat/FeSettlementSheet.vue
  13. 287 0
      huimv-employment/app/packageA/components/chat/FeWorkerReviewModal.vue
  14. 357 12
      huimv-employment/app/packageA/components/home/EnterpriseHome.vue
  15. 812 129
      huimv-employment/app/packageA/components/home/EnterprisePortalHome.vue
  16. 59 8
      huimv-employment/app/packageA/components/home/WorkerHome.vue
  17. 150 12
      huimv-employment/app/packageA/enterprise/messages.vue
  18. 27 7
      huimv-employment/app/packageA/enterprise/mine.vue
  19. 19 2
      huimv-employment/app/packageA/home/index.vue
  20. 363 0
      huimv-employment/app/packageA/worker/contracts.vue
  21. 8 0
      huimv-employment/app/pages.json
  22. 75 0
      huimv-employment/app/utils/contract.js
  23. 76 0
      huimv-employment/app/utils/notification.js
  24. 160 0
      huimv-employment/app/utils/progress-action.js
  25. 206 0
      huimv-employment/app/utils/progress-order-actions.js
  26. 26 7
      huimv-employment/app/utils/registration-batch.js
  27. 51 0
      huimv-employment/app/utils/settlement.js
  28. 192 0
      huimv-employment/app/utils/wechat-si.js

+ 29 - 0
huimv-employment/app/api/contract.js

@@ -0,0 +1,29 @@
1
+import { get, post } from '@/utils/request.js'
2
+
3
+/**
4
+ * 我的电子合同列表
5
+ * GET /api/v1/mp/contracts
6
+ * @param {{ signStatus?: string, sign_status?: string }} [params] pending|signing|signed
7
+ */
8
+export function listMyContracts(params = {}, options = {}) {
9
+	const signStatus = params.signStatus || params.sign_status || ''
10
+	const data = {}
11
+	if (signStatus) data.sign_status = signStatus
12
+	return get('/contracts', data, options)
13
+}
14
+
15
+/**
16
+ * 合同详情
17
+ * GET /api/v1/mp/contracts/{id}
18
+ */
19
+export function getContractDetail(contractId, options = {}) {
20
+	return get(`/contracts/${contractId}`, {}, options)
21
+}
22
+
23
+/**
24
+ * 确认签署完成
25
+ * POST /api/v1/mp/contracts/{id}/confirm-sign
26
+ */
27
+export function confirmContractSign(contractId, options = {}) {
28
+	return post(`/contracts/${contractId}/confirm-sign`, {}, options)
29
+}

+ 30 - 0
huimv-employment/app/api/notification.js

@@ -0,0 +1,30 @@
1
+import { get } from '@/utils/request.js'
2
+
3
+/**
4
+ * 我的消息与待办
5
+ * GET /api/v1/mp/notifications
6
+ * @param {{
7
+ *   notifyType?: string,
8
+ *   category?: string,
9
+ *   readFlag?: boolean,
10
+ *   handledFlag?: boolean,
11
+ *   page?: number,
12
+ *   size?: number
13
+ * }} [params]
14
+ */
15
+export function listMyNotifications(params = {}, options = {}) {
16
+	const data = {}
17
+	if (params.notifyType || params.notify_type) {
18
+		data.notify_type = params.notifyType || params.notify_type
19
+	}
20
+	if (params.category) data.category = params.category
21
+	if (params.readFlag != null || params.read_flag != null) {
22
+		data.read_flag = params.readFlag != null ? params.readFlag : params.read_flag
23
+	}
24
+	if (params.handledFlag != null || params.handled_flag != null) {
25
+		data.handled_flag = params.handledFlag != null ? params.handledFlag : params.handled_flag
26
+	}
27
+	if (params.page != null) data.page = params.page
28
+	if (params.size != null) data.size = params.size
29
+	return get('/notifications', data, options)
30
+}

+ 80 - 0
huimv-employment/app/api/order.js

@@ -0,0 +1,80 @@
1
+import { get, post } from '@/utils/request.js'
2
+
3
+/**
4
+ * 查询开工就绪状态
5
+ * GET /api/v1/mp/orders/{orderId}/start-work-status
6
+ */
7
+export function getStartWorkStatus(orderId, options = {}) {
8
+	return get(`/orders/${orderId}/start-work-status`, {}, options)
9
+}
10
+
11
+/**
12
+ * 确认开工
13
+ * POST /api/v1/mp/orders/{orderId}/start-work
14
+ * @param {{ allowUnderstaffed?: boolean }} [data]
15
+ */
16
+export function startWork(orderId, data = {}, options = {}) {
17
+	const allow = !!(data.allowUnderstaffed || data.allow_understaffed)
18
+	return post(
19
+		`/orders/${orderId}/start-work`,
20
+		{ allow_understaffed: allow, allowUnderstaffed: allow },
21
+		options
22
+	)
23
+}
24
+
25
+/**
26
+ * 发起结算
27
+ * POST /api/v1/mp/orders/{orderId}/start-settlement
28
+ * @param {{ allowBeforeEndDate?: boolean }} [data]
29
+ */
30
+export function startSettlement(orderId, data = {}, options = {}) {
31
+	const allow = !!(data.allowBeforeEndDate || data.allow_before_end_date)
32
+	return post(
33
+		`/orders/${orderId}/start-settlement`,
34
+		{ allow_before_end_date: allow, allowBeforeEndDate: allow },
35
+		options
36
+	)
37
+}
38
+
39
+/**
40
+ * 查询订单结算单
41
+ * GET /api/v1/mp/orders/{orderId}/settlement
42
+ */
43
+export function getOrderSettlement(orderId, options = {}) {
44
+	return get(`/orders/${orderId}/settlement`, {}, options)
45
+}
46
+
47
+/**
48
+ * 确认结算并支付
49
+ * POST /api/v1/mp/orders/{orderId}/confirm-pay-settlement
50
+ * @param {{ paymentChannel?: string }} [data]
51
+ */
52
+export function confirmPaySettlement(orderId, data = {}, options = {}) {
53
+	const channel = data.paymentChannel || data.payment_channel || 'wechat'
54
+	return post(
55
+		`/orders/${orderId}/confirm-pay-settlement`,
56
+		{ payment_channel: channel, paymentChannel: channel },
57
+		options
58
+	)
59
+}
60
+
61
+/**
62
+ * 完税开票办结
63
+ * POST /api/v1/mp/orders/{orderId}/complete
64
+ */
65
+export function completeOrder(orderId, data = {}, options = {}) {
66
+	const body = {
67
+		tax_cleared: data.taxCleared != null ? data.taxCleared : (data.tax_cleared != null ? data.tax_cleared : true),
68
+		invoice_issued: data.invoiceIssued != null ? data.invoiceIssued : (data.invoice_issued != null ? data.invoice_issued : true)
69
+	}
70
+	if (data.invoiceNo || data.invoice_no) {
71
+		body.invoice_no = data.invoiceNo || data.invoice_no
72
+		body.invoiceNo = body.invoice_no
73
+	}
74
+	if (data.taxVoucherNo || data.tax_voucher_no) {
75
+		body.tax_voucher_no = data.taxVoucherNo || data.tax_voucher_no
76
+		body.taxVoucherNo = body.tax_voucher_no
77
+	}
78
+	if (data.remark) body.remark = data.remark
79
+	return post(`/orders/${orderId}/complete`, body, options)
80
+}

+ 26 - 0
huimv-employment/app/api/worker-registration.js

@@ -18,3 +18,29 @@ export function applyWorkerRegistration(data = {}, options = {}) {
18 18
 	}
19 19
 	return post('/worker-registration/apply', body, options)
20 20
 }
21
+
22
+/**
23
+ * 企业审核通过用工登记
24
+ * POST /api/v1/mp/worker-registration/{id}/approve
25
+ * @param {number|string} registrationId fe_worker_registration.id
26
+ */
27
+export function approveWorkerRegistration(registrationId, options = {}) {
28
+	return post(`/worker-registration/${registrationId}/approve`, {}, options)
29
+}
30
+
31
+/**
32
+ * 企业驳回用工登记
33
+ * POST /api/v1/mp/worker-registration/{id}/reject
34
+ * @param {number|string} registrationId
35
+ * @param {{ failReason?: string, fail_reason?: string, reason?: string }} data 驳回原因必填
36
+ */
37
+export function rejectWorkerRegistration(registrationId, data = {}, options = {}) {
38
+	const failReason = String(
39
+		data.failReason || data.fail_reason || data.reason || ''
40
+	).trim()
41
+	return post(
42
+		`/worker-registration/${registrationId}/reject`,
43
+		{ fail_reason: failReason, failReason },
44
+		options
45
+	)
46
+}

+ 1 - 0
huimv-employment/app/common/config.js

@@ -1,5 +1,6 @@
1 1
 /** 小程序直连 API(体验版/开发版统一;有正式 HTTPS 域名后再改 env) */
2 2
 const MP_API = 'http://192.168.1.6:8081/api/v1/mp'
3
+// const MP_API = 'https://img.ifarmcloud.com/deploymentApi/api/v1/mp'
3 4
 
4 5
 /** H5 开发走 devServer 代理,避免浏览器跨域 */
5 6
 const H5_DEV_PROXY_API = '/api/v1/mp'

+ 180 - 0
huimv-employment/app/common/fe.scss

@@ -654,6 +654,39 @@ page {
654 654
 	color: $fe-muted;
655 655
 }
656 656
 
657
+.fe-input-bar__hold {
658
+	flex: 1;
659
+	height: 88rpx;
660
+	border-radius: $fe-radius-full;
661
+	border: 1rpx solid $fe-border;
662
+	background: $fe-surface;
663
+	display: flex;
664
+	align-items: center;
665
+	justify-content: center;
666
+	box-sizing: border-box;
667
+}
668
+
669
+.fe-input-bar__hold.is-recording {
670
+	background: $fe-gray-100;
671
+	border-color: $fe-gray-300;
672
+}
673
+
674
+.fe-input-bar__hold.is-waiting {
675
+	background: $fe-gray-100;
676
+	border-color: $fe-gray-200;
677
+	opacity: 0.7;
678
+}
679
+
680
+.fe-input-bar__hold.is-waiting .fe-input-bar__hold-text {
681
+	color: $fe-gray-400;
682
+}
683
+
684
+.fe-input-bar__hold-text {
685
+	font-size: 30rpx;
686
+	color: $fe-gray-700;
687
+	font-weight: 500;
688
+}
689
+
657 690
 .fe-input-bar__btn--send {
658 691
 	background: $fe-primary;
659 692
 	color: #fff;
@@ -664,6 +697,153 @@ page {
664 697
 	opacity: 1;
665 698
 }
666 699
 
700
+/* ===== VOICE RECORD MASK ===== */
701
+.fe-voice-mask {
702
+	position: fixed;
703
+	top: 0;
704
+	left: 0;
705
+	right: 0;
706
+	bottom: 0;
707
+	z-index: 180;
708
+	background: rgba(0, 0, 0, 0.55);
709
+	pointer-events: none;
710
+	display: flex;
711
+	flex-direction: column;
712
+	align-items: center;
713
+	justify-content: flex-end;
714
+	box-sizing: border-box;
715
+}
716
+
717
+.fe-voice-mask__tip {
718
+	position: absolute;
719
+	left: 50%;
720
+	top: 42%;
721
+	transform: translate(-50%, -50%);
722
+	min-width: 220rpx;
723
+	padding: 36rpx 48rpx 28rpx;
724
+	background: #95ec69;
725
+	border-radius: 24rpx;
726
+	display: flex;
727
+	flex-direction: column;
728
+	align-items: center;
729
+	justify-content: center;
730
+	gap: 16rpx;
731
+	box-sizing: border-box;
732
+}
733
+
734
+.fe-voice-mask__tip::after {
735
+	content: '';
736
+	position: absolute;
737
+	left: 50%;
738
+	bottom: -16rpx;
739
+	transform: translateX(-50%);
740
+	width: 0;
741
+	height: 0;
742
+	border-left: 18rpx solid transparent;
743
+	border-right: 18rpx solid transparent;
744
+	border-top: 18rpx solid #95ec69;
745
+}
746
+
747
+.fe-voice-mask__wave {
748
+	display: flex;
749
+	align-items: center;
750
+	justify-content: center;
751
+	gap: 10rpx;
752
+	height: 64rpx;
753
+}
754
+
755
+.fe-voice-mask__bar {
756
+	width: 8rpx;
757
+	border-radius: 8rpx;
758
+	background: #fff;
759
+	animation: fe-voice-wave 0.9s ease-in-out infinite;
760
+}
761
+
762
+.fe-voice-mask__bar--1 {
763
+	height: 16rpx;
764
+	animation-delay: 0s;
765
+}
766
+
767
+.fe-voice-mask__bar--2 {
768
+	height: 28rpx;
769
+	animation-delay: 0.1s;
770
+}
771
+
772
+.fe-voice-mask__bar--3 {
773
+	height: 44rpx;
774
+	animation-delay: 0.2s;
775
+}
776
+
777
+.fe-voice-mask__bar--4 {
778
+	height: 56rpx;
779
+	animation-delay: 0.15s;
780
+}
781
+
782
+.fe-voice-mask__bar--5 {
783
+	height: 64rpx;
784
+	animation-delay: 0.05s;
785
+}
786
+
787
+.fe-voice-mask__countdown {
788
+	font-size: 28rpx;
789
+	font-weight: 600;
790
+	color: rgba(0, 0, 0, 0.55);
791
+	line-height: 1.2;
792
+}
793
+
794
+.fe-voice-mask__live {
795
+	position: absolute;
796
+	left: 48rpx;
797
+	right: 48rpx;
798
+	bottom: 320rpx;
799
+	max-height: 280rpx;
800
+	padding: 28rpx 32rpx;
801
+	border-radius: 20rpx;
802
+	background: rgba(255, 255, 255, 0.94);
803
+	box-sizing: border-box;
804
+	overflow: hidden;
805
+}
806
+
807
+.fe-voice-mask__live-text {
808
+	display: block;
809
+	font-size: 30rpx;
810
+	line-height: 1.55;
811
+	color: $fe-gray-800;
812
+	word-break: break-word;
813
+}
814
+
815
+.fe-voice-mask__footer {
816
+	width: 100%;
817
+	height: 280rpx;
818
+	padding-bottom: $fe-h-safe-bottom;
819
+	border-radius: 50% 50% 0 0 / 48rpx 48rpx 0 0;
820
+	background: rgba(245, 245, 245, 0.96);
821
+	display: flex;
822
+	align-items: flex-start;
823
+	justify-content: center;
824
+	padding-top: 56rpx;
825
+	box-sizing: border-box;
826
+}
827
+
828
+.fe-voice-mask__footer-text {
829
+	font-size: 32rpx;
830
+	color: $fe-gray-600;
831
+	font-weight: 500;
832
+}
833
+
834
+@keyframes fe-voice-wave {
835
+	0%,
836
+	100% {
837
+		transform: scaleY(0.45);
838
+		opacity: 0.75;
839
+	}
840
+
841
+	50% {
842
+		transform: scaleY(1);
843
+		opacity: 1;
844
+	}
845
+}
846
+
667 847
 /* ===== OVERLAY & SHEET ===== */
668 848
 .fe-overlay {
669 849
 	position: fixed;

+ 11 - 0
huimv-employment/app/manifest.json

@@ -57,6 +57,17 @@
57 57
         "usingComponents" : true,
58 58
         "optimization" : {
59 59
             "subPackages" : false
60
+        },
61
+        "permission" : {
62
+            "scope.record" : {
63
+                "desc" : "需要使用你的麦克风,将语音转为文字以便与智能用工助手对话"
64
+            }
65
+        },
66
+        "plugins": {
67
+            "WechatSI": {
68
+                "provider": "wx069ba97219f66d99",
69
+                "version": "0.3.9"
70
+            }
60 71
         }
61 72
     },
62 73
     "mp-alipay" : {

+ 25 - 2
huimv-employment/app/packageA/chat/index.vue

@@ -17,10 +17,12 @@ export default {
17 17
 	components: { EnterpriseHome },
18 18
 	data() {
19 19
 		return {
20
-			booting: true
20
+			booting: true,
21
+			pendingDeepLink: null
21 22
 		}
22 23
 	},
23
-	onLoad() {
24
+	onLoad(options) {
25
+		this.pendingDeepLink = options || null
24 26
 		this.bootstrap()
25 27
 	},
26 28
 	onShareAppMessage() {
@@ -46,6 +48,27 @@ export default {
46 48
 				return
47 49
 			}
48 50
 			this.booting = false
51
+			this.$nextTick(() => {
52
+				this.applyDeepLink()
53
+			})
54
+		},
55
+		applyDeepLink() {
56
+			const link = this.pendingDeepLink
57
+			if (!link) return
58
+			const home = this.$refs.enterpriseHome
59
+			if (home && typeof home.handleDeepLink === 'function') {
60
+				home.handleDeepLink(link)
61
+				this.pendingDeepLink = null
62
+				return
63
+			}
64
+			// 组件偶发未就绪,再试一次
65
+			setTimeout(() => {
66
+				const h = this.$refs.enterpriseHome
67
+				if (h && typeof h.handleDeepLink === 'function' && this.pendingDeepLink) {
68
+					h.handleDeepLink(this.pendingDeepLink)
69
+					this.pendingDeepLink = null
70
+				}
71
+			}, 200)
49 72
 		}
50 73
 	}
51 74
 }

+ 104 - 47
huimv-employment/app/packageA/components/EnterpriseHubPanel.vue

@@ -1,30 +1,34 @@
1 1
 <template>
2 2
 	<view class="enterprise-hub">
3
-		<view class="fe-profile-card">
4
-			<view class="fe-profile-avatar-lg">{{ user.avatarText }}</view>
5
-			<view class="enterprise-hub__name">{{ enterprise.name }}</view>
6
-			<view class="enterprise-hub__code">{{ maskedCreditCode }}</view>
7
-			<view class="enterprise-hub__badge">✓ 已认证</view>
3
+		<view class="hub-panel hub-panel--profile">
4
+			<view class="fe-profile-card hub-profile">
5
+				<view class="fe-profile-avatar-lg">{{ user.avatarText }}</view>
6
+				<view class="enterprise-hub__name">{{ enterprise.name }}</view>
7
+				<view class="enterprise-hub__code">{{ maskedCreditCode }}</view>
8
+				<view class="enterprise-hub__badge">✓ 已认证</view>
9
+			</view>
8 10
 		</view>
9 11
 
10
-		<view class="fe-section-title">消息与待办中心</view>
11
-		<view class="fe-profile-menu-item menu-card" @tap="toast('待确认 2 条')">
12
-			<view class="fe-profile-menu-icon" style="background:#FEF3C7;">🔔</view>
13
-			<view class="menu-card__main">
14
-				<view class="fe-profile-menu-title">消息与待办中心</view>
15
-				<view class="fe-profile-menu-desc">待确认 2 条</view>
12
+		<view class="hub-panel">
13
+			<text class="hub-panel__title">消息与待办中心</text>
14
+			<view class="fe-profile-menu-item hub-menu-item" @tap="goMessages">
15
+				<view class="fe-profile-menu-icon" style="background:#FEF3C7;">🔔</view>
16
+				<view class="menu-card__main">
17
+					<view class="fe-profile-menu-title">消息与待办中心</view>
18
+					<view class="fe-profile-menu-desc">查看待办与通知</view>
19
+				</view>
20
+				<text class="menu-card__arrow">›</text>
16 21
 			</view>
17
-			<text class="menu-card__arrow">›</text>
18 22
 		</view>
19 23
 
20
-		<view class="fe-section-title">智能偏好设置</view>
21
-		<view class="menu-card">
22
-			<view class="fe-profile-menu-item" @tap="openConfig('workType')">
24
+		<view class="hub-panel">
25
+			<text class="hub-panel__title">智能偏好设置</text>
26
+			<view class="fe-profile-menu-item hub-menu-item" @tap="openConfig('workType')">
23 27
 				<view class="fe-profile-menu-icon" style="background:#DBEAFE;">🔧</view>
24 28
 				<view class="menu-card__main"><view class="fe-profile-menu-title">常用工种与薪资基准</view></view>
25 29
 				<text class="menu-card__arrow">›</text>
26 30
 			</view>
27
-			<view class="fe-profile-menu-item" @tap="openConfig('insurance')">
31
+			<view class="fe-profile-menu-item hub-menu-item" @tap="openConfig('insurance')">
28 32
 				<view class="fe-profile-menu-icon" style="background:#D1FAE5;">🛡</view>
29 33
 				<view class="menu-card__main">
30 34
 					<view class="fe-profile-menu-title">默认保险方案偏好</view>
@@ -32,54 +36,54 @@
32 36
 				</view>
33 37
 				<text class="menu-card__arrow">›</text>
34 38
 			</view>
35
-			<view class="fe-profile-menu-item" @tap="openConfig('payment')">
39
+			<view class="fe-profile-menu-item hub-menu-item hub-menu-item--last" @tap="openConfig('payment')">
36 40
 				<view class="fe-profile-menu-icon" style="background:#FEF3C7;">📅</view>
37 41
 				<view class="menu-card__main"><view class="fe-profile-menu-title">常规发薪日与结算周期</view></view>
38 42
 				<text class="menu-card__arrow">›</text>
39 43
 			</view>
40 44
 		</view>
41 45
 
42
-		<view class="fe-section-title">财务与账户</view>
43
-		<view class="menu-card">
44
-			<view class="fe-profile-menu-item" @tap="toast('对公账户')">
46
+		<view class="hub-panel">
47
+			<text class="hub-panel__title">财务与账户</text>
48
+			<view class="fe-profile-menu-item hub-menu-item" @tap="toast('对公账户')">
45 49
 				<view class="fe-profile-menu-icon" style="background:#FEE2E2;">💳</view>
46 50
 				<view class="menu-card__main"><view class="fe-profile-menu-title">对公账户与打款通道</view></view>
47 51
 				<text class="menu-card__arrow">›</text>
48 52
 			</view>
49
-			<view class="fe-profile-menu-item" @tap="toast('开票抬头')">
53
+			<view class="fe-profile-menu-item hub-menu-item" @tap="toast('开票抬头')">
50 54
 				<view class="fe-profile-menu-icon" style="background:#EDE9FE;">📋</view>
51 55
 				<view class="menu-card__main"><view class="fe-profile-menu-title">企业开票抬头与税控</view></view>
52 56
 				<text class="menu-card__arrow">›</text>
53 57
 			</view>
54
-			<view class="fe-profile-menu-item" @tap="toast('平台余额')">
58
+			<view class="fe-profile-menu-item hub-menu-item hub-menu-item--last" @tap="toast('平台余额')">
55 59
 				<view class="fe-profile-menu-icon" style="background:#F3F4F6;">💼</view>
56 60
 				<view class="menu-card__main"><view class="fe-profile-menu-title">平台余额与充值管理</view></view>
57 61
 				<text class="menu-card__arrow">›</text>
58 62
 			</view>
59 63
 		</view>
60 64
 
61
-		<view class="fe-section-title">合规与安全审计</view>
62
-		<view class="menu-card">
63
-			<view class="fe-profile-menu-item" @tap="toast('合同归档')">
65
+		<view class="hub-panel">
66
+			<text class="hub-panel__title">合规与安全审计</text>
67
+			<view class="fe-profile-menu-item hub-menu-item" @tap="toast('合同归档')">
64 68
 				<view class="fe-profile-menu-icon" style="background:#EDE9FE;">📄</view>
65 69
 				<view class="menu-card__main"><view class="fe-profile-menu-title">历史合同与电子签章归档</view></view>
66 70
 				<text class="menu-card__arrow">›</text>
67 71
 			</view>
68
-			<view class="fe-profile-menu-item" @tap="toast('操作日志')">
72
+			<view class="fe-profile-menu-item hub-menu-item" @tap="toast('操作日志')">
69 73
 				<view class="fe-profile-menu-icon" style="background:#DBEAFE;">🔍</view>
70 74
 				<view class="menu-card__main"><view class="fe-profile-menu-title">操作日志与合规证据链导出</view></view>
71 75
 				<text class="menu-card__arrow">›</text>
72 76
 			</view>
73
-			<view class="fe-profile-menu-item" @tap="toast('通知设置')">
77
+			<view class="fe-profile-menu-item hub-menu-item hub-menu-item--last" @tap="toast('通知设置')">
74 78
 				<view class="fe-profile-menu-icon" style="background:#D1FAE5;">🔔</view>
75 79
 				<view class="menu-card__main"><view class="fe-profile-menu-title">关键节点消息通知设置</view></view>
76 80
 				<text class="menu-card__arrow">›</text>
77 81
 			</view>
78 82
 		</view>
79 83
 
80
-		<view class="fe-section-title">系统设置与帮助</view>
81
-		<view class="menu-card">
82
-			<view class="fe-profile-menu-item" @tap="goPasswordSetting">
84
+		<view class="hub-panel">
85
+			<text class="hub-panel__title">系统设置与帮助</text>
86
+			<view class="fe-profile-menu-item hub-menu-item hub-menu-item--last" @tap="goPasswordSetting">
83 87
 				<view class="fe-profile-menu-icon" style="background:#EDE9FE;">🔐</view>
84 88
 				<view class="menu-card__main">
85 89
 					<view class="fe-profile-menu-title">{{ passwordMenuTitle }}</view>
@@ -88,14 +92,16 @@
88 92
 				</view>
89 93
 				<text class="menu-card__arrow">›</text>
90 94
 			</view>
91
-		</view>
92
-		<view class="enterprise-hub__help-btns">
93
-			<view class="fe-btn fe-btn--secondary fe-btn--sm" @tap="toast('关于我们')">关于我们</view>
94
-			<view class="fe-btn fe-btn--secondary fe-btn--sm" @tap="toast('常见问题')">常见问题</view>
95
-			<view class="fe-btn fe-btn--secondary fe-btn--sm" @tap="toast('联系客服')">联系客服</view>
95
+			<view class="enterprise-hub__help-btns">
96
+				<view class="fe-btn fe-btn--secondary fe-btn--sm" @tap="toast('关于我们')">关于我们</view>
97
+				<view class="fe-btn fe-btn--secondary fe-btn--sm" @tap="toast('常见问题')">常见问题</view>
98
+				<view class="fe-btn fe-btn--secondary fe-btn--sm" @tap="toast('联系客服')">联系客服</view>
99
+			</view>
96 100
 		</view>
97 101
 
98
-		<view v-if="showLogout" class="fe-btn fe-btn--secondary fe-btn--lg fe-btn--full enterprise-hub__logout" @tap="$emit('logout')">退出账号</view>
102
+		<view v-if="showLogout" class="hub-panel hub-panel--logout">
103
+			<view class="fe-btn fe-btn--secondary fe-btn--lg fe-btn--full enterprise-hub__logout" @tap="$emit('logout')">退出账号</view>
104
+		</view>
99 105
 
100 106
 		<!-- Config Sheet -->
101 107
 		<view class="fe-overlay" :class="{ 'fe-overlay--active': configOpen }" @tap="configOpen = false" />
@@ -190,6 +196,9 @@ export default {
190 196
 				url: '/packageA/auth/set-password?from=enterprise'
191 197
 			})
192 198
 		},
199
+		goMessages() {
200
+			uni.reLaunch({ url: '/packageA/enterprise/messages' })
201
+		},
193 202
 		openConfig(type) {
194 203
 			const cfg = CONFIG_DATA[type]
195 204
 			this.configTitle = cfg.title
@@ -214,14 +223,55 @@ export default {
214 223
 </script>
215 224
 
216 225
 <style lang="scss" scoped>
226
+.enterprise-hub {
227
+	display: flex;
228
+	flex-direction: column;
229
+	gap: 20rpx;
230
+}
231
+
232
+.hub-panel {
233
+	background: #ffffff;
234
+	border-radius: 20rpx;
235
+	padding: 24rpx 20rpx;
236
+	box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
237
+	border: 1rpx solid rgba(226, 232, 240, 0.9);
238
+	box-sizing: border-box;
239
+}
240
+
241
+.hub-panel--profile {
242
+	padding: 28rpx 24rpx 32rpx;
243
+}
244
+
245
+.hub-panel--logout {
246
+	padding: 16rpx 20rpx;
247
+}
248
+
249
+.hub-panel__title {
250
+	display: block;
251
+	font-size: 26rpx;
252
+	font-weight: 700;
253
+	color: #111827;
254
+	margin-bottom: 12rpx;
255
+	padding: 0 4rpx;
256
+}
257
+
258
+.hub-profile {
259
+	background: transparent !important;
260
+	border: none !important;
261
+	box-shadow: none !important;
262
+	padding: 0 !important;
263
+	margin: 0 !important;
264
+}
265
+
217 266
 .enterprise-hub__name {
218 267
 	font-size: 32rpx;
219 268
 	font-weight: 700;
269
+	color: #111827;
220 270
 }
221 271
 
222 272
 .enterprise-hub__code {
223 273
 	font-size: 26rpx;
224
-	color: #64748B;
274
+	color: #64748b;
225 275
 	margin-top: 4rpx;
226 276
 }
227 277
 
@@ -231,19 +281,23 @@ export default {
231 281
 	gap: 8rpx;
232 282
 	padding: 6rpx 20rpx;
233 283
 	border-radius: 9999rpx;
234
-	background: #D1FAE5;
235
-	color: #065F46;
284
+	background: #d1fae5;
285
+	color: #065f46;
236 286
 	font-size: 24rpx;
237 287
 	font-weight: 600;
238 288
 	margin-top: 16rpx;
239 289
 }
240 290
 
241
-.menu-card {
242
-	background: #fff;
243
-	border-radius: $fe-radius-md;
244
-	margin-bottom: 8rpx;
245
-	border: 1rpx solid $fe-border-light;
246
-	overflow: hidden;
291
+.hub-menu-item {
292
+	border-radius: 14rpx;
293
+	background: #f8fafc;
294
+	border: 1rpx solid #eef2f7;
295
+	margin-bottom: 12rpx;
296
+	padding: 22rpx 18rpx !important;
297
+}
298
+
299
+.hub-menu-item--last {
300
+	margin-bottom: 0;
247 301
 }
248 302
 
249 303
 .menu-card__main {
@@ -252,7 +306,7 @@ export default {
252 306
 }
253 307
 
254 308
 .menu-card__arrow {
255
-	color: #94A3B8;
309
+	color: #94a3b8;
256 310
 	flex-shrink: 0;
257 311
 }
258 312
 
@@ -260,11 +314,14 @@ export default {
260 314
 	display: flex;
261 315
 	flex-wrap: wrap;
262 316
 	gap: 16rpx;
317
+	margin-top: 20rpx;
263 318
 }
264 319
 
265 320
 .enterprise-hub__logout {
266
-	margin-top: 48rpx;
321
+	margin-top: 0;
267 322
 	color: $fe-danger !important;
323
+	border-color: #fecaca !important;
324
+	background: #fff5f5 !important;
268 325
 }
269 326
 
270 327
 .fe-input-bar--inline {

+ 178 - 107
huimv-employment/app/packageA/components/chat/FeConversationProgressSheet.vue

@@ -1,121 +1,139 @@
1 1
 <template>
2
-	<view class="fe-bottom-sheet" :class="{ 'fe-bottom-sheet--active': active }">
3
-		<view class="fe-sheet-handle" />
4
-		<view class="fe-sheet-header">
5
-			<view class="fe-sheet-title">
6
-				📈 进度总览
7
-				<text v-if="items.length > 1" class="fe-conv-progress-hint">左右滑动切换</text>
2
+	<view>
3
+		<view class="fe-bottom-sheet" :class="{ 'fe-bottom-sheet--active': active }">
4
+			<view class="fe-sheet-handle" />
5
+			<view class="fe-sheet-header">
6
+				<view class="fe-sheet-title">
7
+					📈 进度总览
8
+					<text v-if="items.length > 1" class="fe-conv-progress-hint">左右滑动切换</text>
9
+				</view>
8 10
 			</view>
9
-		</view>
10 11
 
11
-		<view v-if="loading" class="fe-progress-empty">
12
-			<text class="fe-progress-empty__text">加载会话进度中...</text>
13
-		</view>
14
-		<view v-else-if="loadError" class="fe-progress-empty">
15
-			<text class="fe-progress-empty__text">{{ loadError }}</text>
16
-			<view class="fe-biz-btn fe-biz-btn--primary fe-conv-progress-retry" @tap="loadData">重新加载</view>
17
-		</view>
18
-		<view v-else-if="!items.length" class="fe-progress-empty">
19
-			<text class="fe-progress-empty__text">当前会话暂无登记进度</text>
20
-			<text class="fe-progress-empty__sub">确认方案并生成登记批次后可在此查看</text>
21
-		</view>
22
-		<block v-else>
23
-			<swiper
24
-				class="fe-progress-swiper"
25
-				:style="{ height: swiperHeight + 'px' }"
26
-				:current="currentIndex"
27
-				:indicator-dots="false"
28
-				@change="onSwiperChange"
29
-			>
30
-				<swiper-item v-for="(item, idx) in items" :key="item.msgKey">
31
-					<scroll-view
32
-						scroll-y
33
-						class="fe-progress-swiper__scroll"
34
-						:style="{ height: swiperHeight + 'px' }"
35
-						:show-scrollbar="false"
36
-					>
37
-						<view class="fe-sheet-body__inner">
38
-							<view class="fe-sheet-source">{{ itemSubtitle(item, idx) }}</view>
39
-							<view class="fe-stat-grid">
40
-								<view class="fe-stat-box">
41
-									<view class="fe-stat-box__num fe-stat-box__num--warning">{{ item.stats.pending }}</view>
42
-									<view class="fe-stat-box__label">待登记</view>
43
-								</view>
44
-								<view class="fe-stat-box">
45
-									<view class="fe-stat-box__num fe-stat-box__num--info">{{ item.stats.inProgress }}</view>
46
-									<view class="fe-stat-box__label">登记中</view>
12
+			<view v-if="loading" class="fe-progress-empty">
13
+				<text class="fe-progress-empty__text">加载会话进度中...</text>
14
+			</view>
15
+			<view v-else-if="loadError" class="fe-progress-empty">
16
+				<text class="fe-progress-empty__text">{{ loadError }}</text>
17
+				<view class="fe-biz-btn fe-biz-btn--primary fe-conv-progress-retry" @tap="loadData">重新加载</view>
18
+			</view>
19
+			<view v-else-if="!items.length" class="fe-progress-empty">
20
+				<text class="fe-progress-empty__text">当前会话暂无登记进度</text>
21
+				<text class="fe-progress-empty__sub">确认方案并生成登记批次后可在此查看</text>
22
+			</view>
23
+			<block v-else>
24
+				<swiper
25
+					class="fe-progress-swiper"
26
+					:style="{ height: swiperHeight + 'px' }"
27
+					:current="currentIndex"
28
+					:indicator-dots="false"
29
+					@change="onSwiperChange"
30
+				>
31
+					<swiper-item v-for="(item, idx) in items" :key="item.msgKey">
32
+						<scroll-view
33
+							scroll-y
34
+							class="fe-progress-swiper__scroll"
35
+							:style="{ height: swiperHeight + 'px' }"
36
+							:show-scrollbar="false"
37
+						>
38
+							<view class="fe-sheet-body__inner">
39
+								<view class="fe-sheet-source">{{ itemSubtitle(item, idx) }}</view>
40
+								<view class="fe-stat-grid">
41
+									<view class="fe-stat-box">
42
+										<view class="fe-stat-box__num fe-stat-box__num--warning">{{ item.stats.pending }}</view>
43
+										<view class="fe-stat-box__label">待登记</view>
44
+									</view>
45
+									<view class="fe-stat-box">
46
+										<view class="fe-stat-box__num fe-stat-box__num--info">{{ item.stats.inProgress }}</view>
47
+										<view class="fe-stat-box__label">登记中</view>
48
+									</view>
49
+									<view class="fe-stat-box">
50
+										<view class="fe-stat-box__num fe-stat-box__num--success">{{ item.stats.completed }}</view>
51
+										<view class="fe-stat-box__label">已完成</view>
52
+									</view>
53
+									<view class="fe-stat-box fe-stat-box--primary">
54
+										<view class="fe-stat-box__num fe-stat-box__num--primary">{{ item.stats.expected }}</view>
55
+										<view class="fe-stat-box__label">总计</view>
56
+									</view>
47 57
 								</view>
48
-								<view class="fe-stat-box">
49
-									<view class="fe-stat-box__num fe-stat-box__num--success">{{ item.stats.completed }}</view>
50
-									<view class="fe-stat-box__label">已完成</view>
58
+								<view class="fe-progress-label">
59
+									<text class="fe-progress-label__text">登记进度</text>
60
+									<text class="fe-progress-label__value">{{ item.progress.text }}</text>
51 61
 								</view>
52
-								<view class="fe-stat-box fe-stat-box--primary">
53
-									<view class="fe-stat-box__num fe-stat-box__num--primary">{{ item.stats.expected }}</view>
54
-									<view class="fe-stat-box__label">总计</view>
62
+								<view class="fe-progress-track">
63
+									<view class="fe-progress-fill" :style="{ width: item.progress.percent + '%' }" />
55 64
 								</view>
56
-							</view>
57
-							<view class="fe-progress-label">
58
-								<text class="fe-progress-label__text">登记进度</text>
59
-								<text class="fe-progress-label__value">{{ item.progress.text }}</text>
60
-							</view>
61
-							<view class="fe-progress-track">
62
-								<view class="fe-progress-fill" :style="{ width: item.progress.percent + '%' }" />
63
-							</view>
64
-							<text class="fe-section-heading fe-section-heading--solo">办理流程</text>
65
-							<view
66
-								v-for="step in item.steps"
67
-								:key="step.key"
68
-								class="fe-timeline-item"
69
-								:class="'fe-timeline-item--' + step.status"
70
-							>
71
-								<view class="fe-timeline-dot" :class="'fe-timeline-dot--' + step.status">
72
-									<text v-if="step.status === 'done'">✓</text>
73
-									<text v-else-if="step.status === 'active'">⏱</text>
65
+								<text class="fe-section-heading fe-section-heading--solo">办理流程</text>
66
+								<view
67
+									v-for="step in item.steps"
68
+									:key="step.key"
69
+									class="fe-timeline-item"
70
+									:class="'fe-timeline-item--' + step.status"
71
+								>
72
+									<view class="fe-timeline-dot" :class="'fe-timeline-dot--' + step.status">
73
+										<text v-if="step.status === 'done'">✓</text>
74
+										<text v-else-if="step.status === 'active'">⏱</text>
75
+									</view>
76
+									<view>
77
+										<view class="fe-timeline-title" :class="{ 'fe-timeline-title--pending': step.status === 'pending' }">
78
+											{{ step.title }}
79
+											<text v-if="step.highlight" class="fe-timeline-highlight">{{ step.highlight }}</text>
80
+										</view>
81
+										<view v-if="step.time" class="fe-timeline-time">{{ step.time }}</view>
82
+									</view>
74 83
 								</view>
75
-								<view>
76
-									<view class="fe-timeline-title" :class="{ 'fe-timeline-title--pending': step.status === 'pending' }">
77
-										{{ step.title }}
78
-										<text v-if="step.highlight" class="fe-timeline-highlight">{{ step.highlight }}</text>
84
+								<view class="fe-section-heading-row">
85
+									<text class="fe-section-heading">人员列表</text>
86
+									<view class="fe-section-refresh" @tap="refreshByIndex(idx)">
87
+										<text class="fe-section-refresh__icon">↻</text>
88
+										<text>刷新</text>
79 89
 									</view>
80
-									<view v-if="step.time" class="fe-timeline-time">{{ step.time }}</view>
81 90
 								</view>
82
-							</view>
83
-							<view class="fe-section-heading-row">
84
-								<text class="fe-section-heading">人员列表</text>
85
-								<view class="fe-section-refresh" @tap="refreshByIndex(idx)">
86
-									<text class="fe-section-refresh__icon">↻</text>
87
-									<text>刷新</text>
91
+								<view v-if="!item.workers.length" class="fe-progress-empty fe-progress-empty--inline">
92
+									<text class="fe-progress-empty__text">暂无登记人员</text>
88 93
 								</view>
89
-							</view>
90
-							<view v-if="!item.workers.length" class="fe-progress-empty fe-progress-empty--inline">
91
-								<text class="fe-progress-empty__text">暂无登记人员</text>
92
-							</view>
93
-							<view v-for="p in item.workers" :key="p.msgKey" class="fe-person-item">
94
-								<view class="fe-person-avatar">{{ p.avatarText }}</view>
95
-								<view class="fe-person-info">
96
-									<view class="fe-person-name">{{ p.name }}</view>
97
-									<view class="fe-person-phone">{{ p.phone }}</view>
94
+								<view
95
+									v-for="(p, wi) in item.workers"
96
+									:key="p.msgKey"
97
+									class="fe-person-item"
98
+									@tap="openWorkerByIndex(idx, wi)"
99
+								>
100
+									<view class="fe-person-avatar">{{ p.avatarText }}</view>
101
+									<view class="fe-person-info">
102
+										<view class="fe-person-name">{{ p.name }}</view>
103
+										<view class="fe-person-phone">{{ p.phone }}</view>
104
+									</view>
105
+									<text class="fe-person-status" :class="'fe-person-status--' + p.status">{{ p.statusText }}</text>
106
+									<text class="fe-person-arrow">›</text>
98 107
 								</view>
99
-								<text class="fe-person-status" :class="'fe-person-status--' + p.status">{{ p.statusText }}</text>
100 108
 							</view>
101
-						</view>
102
-					</scroll-view>
103
-				</swiper-item>
104
-			</swiper>
105
-			<view v-if="items.length > 1" class="fe-progress-dots">
109
+						</scroll-view>
110
+					</swiper-item>
111
+				</swiper>
112
+				<view v-if="items.length > 1" class="fe-progress-dots">
113
+					<view
114
+						v-for="(d, di) in items"
115
+						:key="d.msgKey"
116
+						class="fe-progress-dot"
117
+						:class="{ 'fe-progress-dot--active': di === currentIndex }"
118
+					/>
119
+				</view>
120
+			</block>
121
+
122
+			<view v-if="currentDetail" class="fe-sheet-footer">
106 123
 				<view
107
-					v-for="(d, di) in items"
108
-					:key="d.msgKey"
109
-					class="fe-progress-dot"
110
-					:class="{ 'fe-progress-dot--active': di === currentIndex }"
111
-				/>
124
+					class="fe-btn fe-btn--primary fe-btn--lg fe-btn--full"
125
+					:class="footerBtnClass"
126
+					@tap="onPrimaryFooter"
127
+				>{{ footerBtnLabel }}</view>
112 128
 			</view>
113
-		</block>
114
-
115
-		<view class="fe-sheet-footer">
116
-			<view class="fe-btn fe-btn--secondary fe-btn--lg" @tap="loadData">查看全部</view>
117
-			<view class="fe-btn fe-btn--primary fe-btn--lg" @tap="onUrge">催办未登记</view>
118 129
 		</view>
130
+
131
+		<FeWorkerReviewModal
132
+			:active="reviewActive"
133
+			:worker="reviewWorker"
134
+			@close="closeReview"
135
+			@done="onReviewDone"
136
+		/>
119 137
 	</view>
120 138
 </template>
121 139
 
@@ -123,9 +141,13 @@
123 141
 import { showToast } from '@/common/chat-data.js'
124 142
 import { listConversationDraftRegistrationProgress } from '@/api/conversation.js'
125 143
 import { mapRegistrationBatchDetail } from '@/utils/registration-batch.js'
144
+import FeWorkerReviewModal from '@/packageA/components/chat/FeWorkerReviewModal.vue'
145
+import progressOrderActions from '@/utils/progress-order-actions.js'
126 146
 
127 147
 export default {
128 148
 	name: 'FeConversationProgressSheet',
149
+	components: { FeWorkerReviewModal },
150
+	mixins: [progressOrderActions],
129 151
 	props: {
130 152
 		active: { type: Boolean, default: false },
131 153
 		conversationId: { type: [Number, String], default: null }
@@ -137,7 +159,26 @@ export default {
137 159
 			items: [],
138 160
 			currentIndex: 0,
139 161
 			// 小程序 swiper 必须给明确 px 高度;复用查看进度内容区约 60vh
140
-			swiperHeight: Math.floor((uni.getSystemInfoSync().windowHeight || 600) * 0.6)
162
+			swiperHeight: Math.floor((uni.getSystemInfoSync().windowHeight || 600) * 0.6),
163
+			reviewActive: false,
164
+			reviewWorker: {}
165
+		}
166
+	},
167
+	computed: {
168
+		currentDetail() {
169
+			if (!this.items.length) return null
170
+			return this.items[this.currentIndex] || null
171
+		},
172
+		footerAction() {
173
+			return this.getFooterAction(this.currentDetail)
174
+		},
175
+		footerBtnLabel() {
176
+			if (this.footerActing) return '处理中...'
177
+			return (this.footerAction && this.footerAction.label) || '催办未登记'
178
+		},
179
+		footerBtnClass() {
180
+			const disabled = !!(this.footerActing || (this.footerAction && this.footerAction.disabled))
181
+			return disabled ? 'fe-progress-footer-btn--disabled' : ''
141 182
 		}
142 183
 	},
143 184
 	watch: {
@@ -145,6 +186,8 @@ export default {
145 186
 			if (val) {
146 187
 				this.syncSwiperHeight()
147 188
 				this.loadData()
189
+			} else {
190
+				this.closeReview()
148 191
 			}
149 192
 		},
150 193
 		conversationId() {
@@ -177,6 +220,7 @@ export default {
177 220
 			try {
178 221
 				const list = await listConversationDraftRegistrationProgress(conversationId, { showError: false })
179 222
 				const arr = Array.isArray(list) ? list : []
223
+				const keepIndex = this.currentIndex
180 224
 				this.items = arr.map((raw, i) => {
181 225
 					const detail = mapRegistrationBatchDetail(raw)
182 226
 					const draftId = detail.draftId != null ? detail.draftId : (raw.draft_id != null ? raw.draft_id : i)
@@ -185,7 +229,8 @@ export default {
185 229
 						draftId
186 230
 					})
187 231
 				})
188
-				this.currentIndex = 0
232
+				const max = Math.max(0, this.items.length - 1)
233
+				this.currentIndex = Math.max(0, Math.min(max, keepIndex))
189 234
 			} catch (e) {
190 235
 				this.items = []
191 236
 				this.loadError = (e && (e.msg || e.message)) || '进度加载失败'
@@ -205,8 +250,23 @@ export default {
205 250
 				showToast('已刷新')
206 251
 			})
207 252
 		},
208
-		onUrge() {
209
-			showToast('已发送催办')
253
+		openWorkerByIndex(itemIdx, workerIdx) {
254
+			const item = this.items[itemIdx]
255
+			if (!item || !item.workers) return
256
+			const worker = item.workers[workerIdx]
257
+			if (!worker) return
258
+			this.reviewWorker = Object.assign({}, worker)
259
+			this.reviewActive = true
260
+		},
261
+		closeReview() {
262
+			this.reviewActive = false
263
+		},
264
+		onReviewDone() {
265
+			this.loadData()
266
+		},
267
+		onPrimaryFooter() {
268
+			if (this.footerActing || (this.footerAction && this.footerAction.disabled)) return
269
+			this.onFooterAction(this.currentDetail, () => this.loadData())
210 270
 		}
211 271
 	}
212 272
 }
@@ -289,6 +349,12 @@ export default {
289 349
 .fe-person-info { flex: 1; min-width: 0; }
290 350
 .fe-person-name { font-weight: 600; }
291 351
 .fe-person-phone { font-size: 24rpx; color: #64748B; }
352
+.fe-person-arrow {
353
+	font-size: 32rpx;
354
+	color: #CBD5E1;
355
+	line-height: 1;
356
+	margin-left: 4rpx;
357
+}
292 358
 
293 359
 .fe-progress-dots {
294 360
 	display: flex;
@@ -333,4 +399,9 @@ export default {
333 399
 	font-size: 24rpx;
334 400
 	color: #94A3B8;
335 401
 }
402
+
403
+.fe-progress-footer-btn--disabled {
404
+	opacity: 0.55;
405
+	pointer-events: none;
406
+}
336 407
 </style>

+ 139 - 73
huimv-employment/app/packageA/components/chat/FeProgressSheet.vue

@@ -1,88 +1,106 @@
1 1
 <template>
2
-	<view class="fe-bottom-sheet" :class="{ 'fe-bottom-sheet--active': active, 'fe-bottom-sheet--elevated': elevated }">
3
-		<view class="fe-sheet-handle" />
4
-		<view class="fe-sheet-header">
5
-			<view class="fe-sheet-title">📈 进度管理</view>
6
-		</view>
2
+	<view>
3
+		<view class="fe-bottom-sheet" :class="{ 'fe-bottom-sheet--active': active, 'fe-bottom-sheet--elevated': elevated }">
4
+			<view class="fe-sheet-handle" />
5
+			<view class="fe-sheet-header">
6
+				<view class="fe-sheet-title">📈 进度管理</view>
7
+			</view>
7 8
 
8
-		<view v-if="loading" class="fe-progress-empty">
9
-			<text class="fe-progress-empty__text">加载进度中...</text>
10
-		</view>
11
-		<view v-else-if="!detail" class="fe-progress-empty">
12
-			<text class="fe-progress-empty__text">{{ loadError || '暂无进度数据' }}</text>
13
-		</view>
14
-		<scroll-view v-else scroll-y class="fe-sheet-body" :show-scrollbar="false">
15
-			<view class="fe-sheet-body__inner">
16
-				<view class="fe-sheet-source">{{ detail.subtitle || detail.title }}</view>
17
-				<view class="fe-stat-grid">
18
-					<view class="fe-stat-box">
19
-						<view class="fe-stat-box__num fe-stat-box__num--warning">{{ detail.stats.pending }}</view>
20
-						<view class="fe-stat-box__label">待登记</view>
21
-					</view>
22
-					<view class="fe-stat-box">
23
-						<view class="fe-stat-box__num fe-stat-box__num--info">{{ detail.stats.inProgress }}</view>
24
-						<view class="fe-stat-box__label">登记中</view>
9
+			<view v-if="loading" class="fe-progress-empty">
10
+				<text class="fe-progress-empty__text">加载进度中...</text>
11
+			</view>
12
+			<view v-else-if="!detail" class="fe-progress-empty">
13
+				<text class="fe-progress-empty__text">{{ loadError || '暂无进度数据' }}</text>
14
+			</view>
15
+			<scroll-view v-else scroll-y class="fe-sheet-body" :show-scrollbar="false">
16
+				<view class="fe-sheet-body__inner">
17
+					<view class="fe-sheet-source">{{ detail.subtitle || detail.title }}</view>
18
+					<view class="fe-stat-grid">
19
+						<view class="fe-stat-box">
20
+							<view class="fe-stat-box__num fe-stat-box__num--warning">{{ detail.stats.pending }}</view>
21
+							<view class="fe-stat-box__label">待登记</view>
22
+						</view>
23
+						<view class="fe-stat-box">
24
+							<view class="fe-stat-box__num fe-stat-box__num--info">{{ detail.stats.inProgress }}</view>
25
+							<view class="fe-stat-box__label">登记中</view>
26
+						</view>
27
+						<view class="fe-stat-box">
28
+							<view class="fe-stat-box__num fe-stat-box__num--success">{{ detail.stats.completed }}</view>
29
+							<view class="fe-stat-box__label">已完成</view>
30
+						</view>
31
+						<view class="fe-stat-box fe-stat-box--primary">
32
+							<view class="fe-stat-box__num fe-stat-box__num--primary">{{ detail.stats.expected }}</view>
33
+							<view class="fe-stat-box__label">总计</view>
34
+						</view>
25 35
 					</view>
26
-					<view class="fe-stat-box">
27
-						<view class="fe-stat-box__num fe-stat-box__num--success">{{ detail.stats.completed }}</view>
28
-						<view class="fe-stat-box__label">已完成</view>
36
+					<view class="fe-progress-label">
37
+						<text class="fe-progress-label__text">登记进度</text>
38
+						<text class="fe-progress-label__value">{{ detail.progress.text }}</text>
29 39
 					</view>
30
-					<view class="fe-stat-box fe-stat-box--primary">
31
-						<view class="fe-stat-box__num fe-stat-box__num--primary">{{ detail.stats.expected }}</view>
32
-						<view class="fe-stat-box__label">总计</view>
40
+					<view class="fe-progress-track">
41
+						<view class="fe-progress-fill" :style="{ width: detail.progress.percent + '%' }" />
33 42
 					</view>
34
-				</view>
35
-				<view class="fe-progress-label">
36
-					<text class="fe-progress-label__text">登记进度</text>
37
-					<text class="fe-progress-label__value">{{ detail.progress.text }}</text>
38
-				</view>
39
-				<view class="fe-progress-track">
40
-					<view class="fe-progress-fill" :style="{ width: detail.progress.percent + '%' }" />
41
-				</view>
42
-				<text class="fe-section-heading fe-section-heading--solo">办理流程</text>
43
-				<view
44
-					v-for="step in detail.steps"
45
-					:key="step.key"
46
-					class="fe-timeline-item"
47
-					:class="'fe-timeline-item--' + step.status"
48
-				>
49
-					<view class="fe-timeline-dot" :class="'fe-timeline-dot--' + step.status">
50
-						<text v-if="step.status === 'done'">✓</text>
51
-						<text v-else-if="step.status === 'active'">⏱</text>
43
+					<text class="fe-section-heading fe-section-heading--solo">办理流程</text>
44
+					<view
45
+						v-for="step in detail.steps"
46
+						:key="step.key"
47
+						class="fe-timeline-item"
48
+						:class="'fe-timeline-item--' + step.status"
49
+					>
50
+						<view class="fe-timeline-dot" :class="'fe-timeline-dot--' + step.status">
51
+							<text v-if="step.status === 'done'">✓</text>
52
+							<text v-else-if="step.status === 'active'">⏱</text>
53
+						</view>
54
+						<view>
55
+							<view class="fe-timeline-title" :class="{ 'fe-timeline-title--pending': step.status === 'pending' }">
56
+								{{ step.title }}
57
+								<text v-if="step.highlight" class="fe-timeline-highlight">{{ step.highlight }}</text>
58
+							</view>
59
+							<view v-if="step.time" class="fe-timeline-time">{{ step.time }}</view>
60
+						</view>
52 61
 					</view>
53
-					<view>
54
-						<view class="fe-timeline-title" :class="{ 'fe-timeline-title--pending': step.status === 'pending' }">
55
-							{{ step.title }}
56
-							<text v-if="step.highlight" class="fe-timeline-highlight">{{ step.highlight }}</text>
62
+					<view class="fe-section-heading-row">
63
+						<text class="fe-section-heading">人员列表</text>
64
+						<view class="fe-section-refresh" @tap="loadDetail">
65
+							<text class="fe-section-refresh__icon">↻</text>
66
+							<text>刷新</text>
57 67
 						</view>
58
-						<view v-if="step.time" class="fe-timeline-time">{{ step.time }}</view>
59 68
 					</view>
60
-				</view>
61
-				<view class="fe-section-heading-row">
62
-					<text class="fe-section-heading">人员列表</text>
63
-					<view class="fe-section-refresh" @tap="loadDetail">
64
-						<text class="fe-section-refresh__icon">↻</text>
65
-						<text>刷新</text>
69
+					<view v-if="!detail.workers.length" class="fe-progress-empty fe-progress-empty--inline">
70
+						<text class="fe-progress-empty__text">暂无登记人员</text>
66 71
 					</view>
67
-				</view>
68
-				<view v-if="!detail.workers.length" class="fe-progress-empty fe-progress-empty--inline">
69
-					<text class="fe-progress-empty__text">暂无登记人员</text>
70
-				</view>
71
-				<view v-for="p in detail.workers" :key="p.msgKey" class="fe-person-item">
72
-					<view class="fe-person-avatar">{{ p.avatarText }}</view>
73
-					<view class="fe-person-info">
74
-						<view class="fe-person-name">{{ p.name }}</view>
75
-						<view class="fe-person-phone">{{ p.phone }}</view>
72
+					<view
73
+						v-for="(p, wi) in detail.workers"
74
+						:key="p.msgKey"
75
+						class="fe-person-item"
76
+						@tap="openWorkerByIndex(wi)"
77
+					>
78
+						<view class="fe-person-avatar">{{ p.avatarText }}</view>
79
+						<view class="fe-person-info">
80
+							<view class="fe-person-name">{{ p.name }}</view>
81
+							<view class="fe-person-phone">{{ p.phone }}</view>
82
+						</view>
83
+						<text class="fe-person-status" :class="'fe-person-status--' + p.status">{{ p.statusText }}</text>
84
+						<text class="fe-person-arrow">›</text>
76 85
 					</view>
77
-					<text class="fe-person-status" :class="'fe-person-status--' + p.status">{{ p.statusText }}</text>
78 86
 				</view>
79
-			</view>
80
-		</scroll-view>
87
+			</scroll-view>
81 88
 
82
-		<view class="fe-sheet-footer">
83
-			<view class="fe-btn fe-btn--secondary fe-btn--lg" @tap="toast('已查看全部')">查看全部</view>
84
-			<view class="fe-btn fe-btn--primary fe-btn--lg" @tap="toast('已发送催办')">催办未登记</view>
89
+			<view v-if="detail" class="fe-sheet-footer">
90
+				<view
91
+					class="fe-btn fe-btn--primary fe-btn--lg fe-btn--full"
92
+					:class="footerBtnClass"
93
+					@tap="onPrimaryFooter"
94
+				>{{ footerBtnLabel }}</view>
95
+			</view>
85 96
 		</view>
97
+
98
+		<FeWorkerReviewModal
99
+			:active="reviewActive"
100
+			:worker="reviewWorker"
101
+			@close="closeReview"
102
+			@done="onReviewDone"
103
+		/>
86 104
 	</view>
87 105
 </template>
88 106
 
@@ -90,9 +108,13 @@
90 108
 import { showToast } from '@/common/chat-data.js'
91 109
 import { getRegistrationBatchDetail } from '@/api/registration-batch.js'
92 110
 import { mapRegistrationBatchDetail } from '@/utils/registration-batch.js'
111
+import FeWorkerReviewModal from '@/packageA/components/chat/FeWorkerReviewModal.vue'
112
+import progressOrderActions from '@/utils/progress-order-actions.js'
93 113
 
94 114
 export default {
95 115
 	name: 'FeProgressSheet',
116
+	components: { FeWorkerReviewModal },
117
+	mixins: [progressOrderActions],
96 118
 	props: {
97 119
 		active: { type: Boolean, default: false },
98 120
 		elevated: { type: Boolean, default: false },
@@ -102,12 +124,28 @@ export default {
102 124
 		return {
103 125
 			loading: false,
104 126
 			loadError: '',
105
-			detail: null
127
+			detail: null,
128
+			reviewActive: false,
129
+			reviewWorker: {}
130
+		}
131
+	},
132
+	computed: {
133
+		footerAction() {
134
+			return this.getFooterAction(this.detail)
135
+		},
136
+		footerBtnLabel() {
137
+			if (this.footerActing) return '处理中...'
138
+			return (this.footerAction && this.footerAction.label) || '催办未登记'
139
+		},
140
+		footerBtnClass() {
141
+			const disabled = !!(this.footerActing || (this.footerAction && this.footerAction.disabled))
142
+			return disabled ? 'fe-progress-footer-btn--disabled' : ''
106 143
 		}
107 144
 	},
108 145
 	watch: {
109 146
 		active(val) {
110 147
 			if (val) this.loadDetail()
148
+			else this.closeReview()
111 149
 		},
112 150
 		draftId() {
113 151
 			if (this.active) this.loadDetail()
@@ -133,6 +171,23 @@ export default {
133 171
 			} finally {
134 172
 				this.loading = false
135 173
 			}
174
+		},
175
+		openWorkerByIndex(wi) {
176
+			const list = (this.detail && this.detail.workers) || []
177
+			const worker = list[wi]
178
+			if (!worker) return
179
+			this.reviewWorker = Object.assign({}, worker)
180
+			this.reviewActive = true
181
+		},
182
+		closeReview() {
183
+			this.reviewActive = false
184
+		},
185
+		onReviewDone() {
186
+			this.loadDetail()
187
+		},
188
+		onPrimaryFooter() {
189
+			if (this.footerActing || (this.footerAction && this.footerAction.disabled)) return
190
+			this.onFooterAction(this.detail, () => this.loadDetail())
136 191
 		}
137 192
 	}
138 193
 }
@@ -191,6 +246,12 @@ export default {
191 246
 .fe-person-info { flex: 1; min-width: 0; }
192 247
 .fe-person-name { font-weight: 600; }
193 248
 .fe-person-phone { font-size: 24rpx; color: #64748B; }
249
+.fe-person-arrow {
250
+	font-size: 32rpx;
251
+	color: #CBD5E1;
252
+	line-height: 1;
253
+	margin-left: 4rpx;
254
+}
194 255
 
195 256
 .fe-progress-empty {
196 257
 	padding: 80rpx 32rpx;
@@ -205,4 +266,9 @@ export default {
205 266
 	font-size: 28rpx;
206 267
 	color: #94A3B8;
207 268
 }
269
+
270
+.fe-progress-footer-btn--disabled {
271
+	opacity: 0.55;
272
+	pointer-events: none;
273
+}
208 274
 </style>

+ 138 - 14
huimv-employment/app/packageA/components/chat/FeSettlementSheet.vue

@@ -2,51 +2,150 @@
2 2
 	<view class="fe-bottom-sheet" :class="{ 'fe-bottom-sheet--active': active }">
3 3
 		<view class="fe-sheet-handle" />
4 4
 		<view class="fe-sheet-header">
5
-			<view class="fe-sheet-title">💳 结算确认单 <text class="fe-sheet-badge fe-sheet-badge--danger">待支付</text></view>
5
+			<view class="fe-sheet-title">
6
+				💳 结算确认单
7
+				<text
8
+					v-if="view.statusLabel"
9
+					class="fe-sheet-badge"
10
+					:class="'fe-sheet-badge--' + view.statusClass"
11
+				>{{ view.statusLabel }}</text>
12
+			</view>
13
+		</view>
14
+		<view class="fe-sheet-source">{{ view.sourceTitle || '结算单' }}</view>
15
+
16
+		<view v-if="loading" class="fe-settlement-empty">
17
+			<text class="fe-settlement-empty__text">加载结算单中...</text>
6 18
 		</view>
7
-		<view class="fe-sheet-source">浦东仓库搬运 BATCH-001</view>
8
-		<scroll-view scroll-y class="fe-sheet-body" :show-scrollbar="false">
19
+		<view v-else-if="loadError" class="fe-settlement-empty">
20
+			<text class="fe-settlement-empty__text">{{ loadError }}</text>
21
+			<view class="fe-btn fe-btn--primary fe-btn--sm fe-settlement-empty__btn" @tap="loadDetail">重试</view>
22
+		</view>
23
+		<scroll-view v-else scroll-y class="fe-sheet-body" :show-scrollbar="false">
9 24
 			<view class="fe-sheet-body__inner">
10 25
 				<view class="fe-stat-grid fe-stat-grid--3">
11 26
 					<view class="fe-stat-box">
12
-						<view class="fe-stat-box__num fe-stat-box__num--lg fe-stat-box__num--primary">¥50,400</view>
27
+						<view class="fe-stat-box__num fe-stat-box__num--lg fe-stat-box__num--primary">{{ view.netText }}</view>
13 28
 						<view class="fe-stat-box__label">实发总额</view>
14 29
 					</view>
15 30
 					<view class="fe-stat-box fe-stat-box--danger">
16
-						<view class="fe-stat-box__num fe-stat-box__num--lg fe-stat-box__num--danger">-¥2,400</view>
31
+						<view class="fe-stat-box__num fe-stat-box__num--lg fe-stat-box__num--danger">{{ view.deductionText }}</view>
17 32
 						<view class="fe-stat-box__label">扣款/个税</view>
18 33
 					</view>
19 34
 					<view class="fe-stat-box">
20
-						<view class="fe-stat-box__num fe-stat-box__num--lg fe-stat-box__num--primary">¥52,800</view>
35
+						<view class="fe-stat-box__num fe-stat-box__num--lg fe-stat-box__num--primary">{{ view.grossText }}</view>
21 36
 						<view class="fe-stat-box__label">应发总额</view>
22 37
 					</view>
23 38
 				</view>
24
-				<view class="fe-settlement-row"><text class="fe-info-row__label">劳务费合计</text><text class="fe-info-row__value">¥48,000</text></view>
25
-				<view class="fe-settlement-row"><text class="fe-info-row__label">平台服务费</text><text class="fe-info-row__value">¥3,840</text></view>
26
-				<view class="fe-settlement-row"><text class="fe-info-row__label">保险费用</text><text class="fe-info-row__value">¥960</text></view>
39
+				<view v-for="fee in view.fees" :key="fee.msgKey" class="fe-settlement-row">
40
+					<text class="fe-info-row__label">{{ fee.name }}</text>
41
+					<text class="fe-info-row__value">{{ fee.amountText }}</text>
42
+				</view>
27 43
 				<view class="fe-settlement-row fe-settlement-row--total">
28 44
 					<text>应付总额</text>
29
-					<text class="fe-settlement-row__val--primary">¥52,800</text>
45
+					<text class="fe-settlement-row__val--primary">{{ view.totalText }}</text>
30 46
 				</view>
31 47
 			</view>
32 48
 		</scroll-view>
33 49
 		<view class="fe-sheet-footer">
34
-			<view class="fe-btn fe-btn--secondary fe-btn--lg" @tap="toast('已导出明细')">导出明细</view>
35
-			<view class="fe-btn fe-btn--primary fe-btn--lg" @tap="$emit('pay')">确认支付</view>
50
+			<view class="fe-btn fe-btn--secondary fe-btn--lg" @tap="onClose">关闭</view>
51
+			<view
52
+				v-if="view.canPay"
53
+				class="fe-btn fe-btn--primary fe-btn--lg"
54
+				:class="{ 'fe-settlement-btn--disabled': paying }"
55
+				@tap="onConfirmPay"
56
+			>{{ paying ? '支付中...' : '确认支付' }}</view>
36 57
 		</view>
37 58
 	</view>
38 59
 </template>
39 60
 
40 61
 <script>
41 62
 import { showToast } from '@/common/chat-data.js'
63
+import { getOrderSettlement, confirmPaySettlement } from '@/api/order.js'
64
+import { mapSettlementSummary } from '@/utils/settlement.js'
65
+
66
+const EMPTY_VIEW = {
67
+	sourceTitle: '',
68
+	statusLabel: '待支付',
69
+	statusClass: 'danger',
70
+	netText: '¥0.00',
71
+	deductionText: '-¥0.00',
72
+	grossText: '¥0.00',
73
+	totalText: '¥0.00',
74
+	fees: [],
75
+	canPay: false
76
+}
42 77
 
43 78
 export default {
44 79
 	name: 'FeSettlementSheet',
45 80
 	props: {
46
-		active: { type: Boolean, default: false }
81
+		active: { type: Boolean, default: false },
82
+		orderId: { type: [Number, String], default: null }
83
+	},
84
+	data() {
85
+		return {
86
+			loading: false,
87
+			loadError: '',
88
+			paying: false,
89
+			view: Object.assign({}, EMPTY_VIEW)
90
+		}
91
+	},
92
+	watch: {
93
+		active(val) {
94
+			if (val) this.loadDetail()
95
+		},
96
+		orderId() {
97
+			if (this.active) this.loadDetail()
98
+		}
47 99
 	},
48 100
 	methods: {
49
-		toast: showToast
101
+		toast: showToast,
102
+		onClose() {
103
+			this.$emit('close')
104
+		},
105
+		async loadDetail() {
106
+			if (this.orderId == null || this.orderId === '') {
107
+				this.view = Object.assign({}, EMPTY_VIEW)
108
+				this.loadError = '缺少订单信息'
109
+				return
110
+			}
111
+			this.loading = true
112
+			this.loadError = ''
113
+			try {
114
+				const raw = await getOrderSettlement(this.orderId, { showError: false })
115
+				this.view = mapSettlementSummary(raw || {})
116
+			} catch (e) {
117
+				this.view = Object.assign({}, EMPTY_VIEW)
118
+				this.loadError = (e && (e.msg || e.message)) || '结算单加载失败'
119
+				showToast(this.loadError)
120
+			} finally {
121
+				this.loading = false
122
+			}
123
+		},
124
+		async onConfirmPay() {
125
+			if (this.paying || !this.view.canPay) return
126
+			if (this.orderId == null || this.orderId === '') {
127
+				showToast('缺少订单信息')
128
+				return
129
+			}
130
+			this.paying = true
131
+			try {
132
+				const raw = await confirmPaySettlement(
133
+					this.orderId,
134
+					{ paymentChannel: 'wechat' },
135
+					{ showError: true }
136
+				)
137
+				this.view = mapSettlementSummary(raw || this.view)
138
+				showToast((raw && raw.message) || '支付完成')
139
+				this.$emit('paid', {
140
+					orderId: this.orderId,
141
+					settlementId: this.view.settlementId
142
+				})
143
+			} catch (e) {
144
+				// request 已 toast
145
+			} finally {
146
+				this.paying = false
147
+			}
148
+		}
50 149
 	}
51 150
 }
52 151
 </script>
@@ -56,6 +155,10 @@ export default {
56 155
 	background: #EF4444;
57 156
 }
58 157
 
158
+.fe-sheet-badge--success {
159
+	background: #10B981;
160
+}
161
+
59 162
 .fe-stat-grid--3 {
60 163
 	grid-template-columns: repeat(3, 1fr);
61 164
 }
@@ -75,4 +178,25 @@ export default {
75 178
 .fe-stat-box__num--danger {
76 179
 	color: #EF4444;
77 180
 }
181
+
182
+.fe-settlement-empty {
183
+	padding: 80rpx 32rpx;
184
+	text-align: center;
185
+}
186
+
187
+.fe-settlement-empty__text {
188
+	display: block;
189
+	font-size: 28rpx;
190
+	color: #94A3B8;
191
+}
192
+
193
+.fe-settlement-empty__btn {
194
+	margin: 28rpx auto 0;
195
+	display: inline-flex;
196
+}
197
+
198
+.fe-settlement-btn--disabled {
199
+	opacity: 0.55;
200
+	pointer-events: none;
201
+}
78 202
 </style>

+ 287 - 0
huimv-employment/app/packageA/components/chat/FeWorkerReviewModal.vue

@@ -0,0 +1,287 @@
1
+<template>
2
+	<view>
3
+		<view
4
+			class="fe-modal-overlay fe-worker-review-overlay"
5
+			:class="{ 'fe-modal-overlay--active': active }"
6
+			@tap="onClose"
7
+		/>
8
+		<view class="fe-modal fe-worker-review-modal" :class="{ 'fe-modal--active': active }">
9
+			<view class="fe-worker-review-modal__body">
10
+				<view class="fe-worker-review-modal__head">
11
+					<view class="fe-worker-review-modal__avatar">{{ worker.avatarText || '工' }}</view>
12
+					<view class="fe-worker-review-modal__head-text">
13
+						<text class="fe-worker-review-modal__name" user-select>{{ worker.name || '—' }}</text>
14
+						<text
15
+							class="fe-person-status"
16
+							:class="'fe-person-status--' + (worker.status || 'pending')"
17
+						>{{ worker.statusText || '—' }}</text>
18
+					</view>
19
+				</view>
20
+
21
+				<view class="fe-worker-review-modal__rows">
22
+					<view class="fe-worker-review-modal__row">
23
+						<text class="fe-worker-review-modal__label">手机号</text>
24
+						<text class="fe-worker-review-modal__value" user-select>{{ worker.phone || '—' }}</text>
25
+					</view>
26
+					<view class="fe-worker-review-modal__row">
27
+						<text class="fe-worker-review-modal__label">工种</text>
28
+						<text class="fe-worker-review-modal__value">{{ worker.workType || '未填写' }}</text>
29
+					</view>
30
+					<view class="fe-worker-review-modal__row">
31
+						<text class="fe-worker-review-modal__label">提交时间</text>
32
+						<text class="fe-worker-review-modal__value">{{ worker.submittedAtText || '—' }}</text>
33
+					</view>
34
+					<view v-if="worker.failReason" class="fe-worker-review-modal__row">
35
+						<text class="fe-worker-review-modal__label">失败原因</text>
36
+						<text class="fe-worker-review-modal__value fe-worker-review-modal__value--danger" user-select>
37
+							{{ worker.failReason }}
38
+						</text>
39
+					</view>
40
+				</view>
41
+
42
+				<view v-if="worker.canReview" class="fe-worker-review-modal__reject">
43
+					<text class="fe-worker-review-modal__reject-label">驳回原因(驳回时必填)</text>
44
+					<textarea
45
+						class="fe-worker-review-modal__textarea"
46
+						:value="failReason"
47
+						:disabled="submitting"
48
+						:maxlength="500"
49
+						placeholder="请填写驳回原因"
50
+						@input="onReasonInput"
51
+					/>
52
+				</view>
53
+
54
+				<view v-if="worker.canReview" class="fe-worker-review-modal__actions">
55
+					<view
56
+						class="fe-btn fe-btn--secondary fe-btn--lg fe-worker-review-modal__btn"
57
+						:class="{ 'fe-worker-review-modal__btn--disabled': submitting }"
58
+						@tap="onReject"
59
+					>驳回</view>
60
+					<view
61
+						class="fe-btn fe-btn--primary fe-btn--lg fe-worker-review-modal__btn"
62
+						:class="{ 'fe-worker-review-modal__btn--disabled': submitting }"
63
+						@tap="onApprove"
64
+					>审核通过</view>
65
+				</view>
66
+				<view v-else class="fe-worker-review-modal__actions">
67
+					<view class="fe-btn fe-btn--secondary fe-btn--lg fe-worker-review-modal__btn" @tap="onClose">关闭</view>
68
+				</view>
69
+			</view>
70
+		</view>
71
+	</view>
72
+</template>
73
+
74
+<script>
75
+import { showToast } from '@/common/chat-data.js'
76
+import {
77
+	approveWorkerRegistration,
78
+	rejectWorkerRegistration
79
+} from '@/api/worker-registration.js'
80
+
81
+export default {
82
+	name: 'FeWorkerReviewModal',
83
+	props: {
84
+		active: { type: Boolean, default: false },
85
+		worker: {
86
+			type: Object,
87
+			default() {
88
+				return {}
89
+			}
90
+		}
91
+	},
92
+	data() {
93
+		return {
94
+			failReason: '',
95
+			submitting: false
96
+		}
97
+	},
98
+	watch: {
99
+		active(val) {
100
+			if (val) this.failReason = ''
101
+		}
102
+	},
103
+	methods: {
104
+		onClose() {
105
+			if (this.submitting) return
106
+			this.$emit('close')
107
+		},
108
+		onReasonInput(e) {
109
+			this.failReason = (e && e.detail && e.detail.value) || ''
110
+		},
111
+		async onApprove() {
112
+			if (this.submitting) return
113
+			const id = this.worker && this.worker.registrationId
114
+			if (id == null || id === '') {
115
+				showToast('缺少登记信息')
116
+				return
117
+			}
118
+			this.submitting = true
119
+			try {
120
+				const res = await approveWorkerRegistration(id, { showError: true })
121
+				const msg = (res && (res.message || res.msg)) || '审核通过'
122
+				showToast(msg)
123
+				this.$emit('done', { action: 'approve', registrationId: id })
124
+				this.$emit('close')
125
+			} catch (e) {
126
+				// request 已 toast
127
+			} finally {
128
+				this.submitting = false
129
+			}
130
+		},
131
+		async onReject() {
132
+			if (this.submitting) return
133
+			const id = this.worker && this.worker.registrationId
134
+			if (id == null || id === '') {
135
+				showToast('缺少登记信息')
136
+				return
137
+			}
138
+			const reason = String(this.failReason || '').trim()
139
+			if (!reason) {
140
+				showToast('请填写驳回原因')
141
+				return
142
+			}
143
+			this.submitting = true
144
+			try {
145
+				const res = await rejectWorkerRegistration(id, { failReason: reason }, { showError: true })
146
+				const msg = (res && (res.message || res.msg)) || '已驳回'
147
+				showToast(msg)
148
+				this.$emit('done', { action: 'reject', registrationId: id })
149
+				this.$emit('close')
150
+			} catch (e) {
151
+				// request 已 toast
152
+			} finally {
153
+				this.submitting = false
154
+			}
155
+		}
156
+	}
157
+}
158
+</script>
159
+
160
+<style lang="scss" scoped>
161
+.fe-worker-review-modal__body {
162
+	padding: 40rpx 36rpx 32rpx;
163
+}
164
+
165
+/* 高于 elevated 进度弹层(320),避免审核框被盖住 */
166
+.fe-worker-review-modal.fe-modal {
167
+	z-index: 410;
168
+}
169
+
170
+.fe-modal-overlay.fe-worker-review-overlay {
171
+	z-index: 400;
172
+}
173
+
174
+.fe-worker-review-modal__head {
175
+	display: flex;
176
+	align-items: center;
177
+	gap: 24rpx;
178
+	margin-bottom: 32rpx;
179
+}
180
+
181
+.fe-worker-review-modal__avatar {
182
+	width: 88rpx;
183
+	height: 88rpx;
184
+	border-radius: 50%;
185
+	background: $fe-primary-bg;
186
+	color: $fe-primary;
187
+	font-size: 36rpx;
188
+	font-weight: 700;
189
+	display: flex;
190
+	align-items: center;
191
+	justify-content: center;
192
+	flex-shrink: 0;
193
+}
194
+
195
+.fe-worker-review-modal__head-text {
196
+	flex: 1;
197
+	min-width: 0;
198
+	display: flex;
199
+	flex-direction: column;
200
+	align-items: flex-start;
201
+	gap: 12rpx;
202
+}
203
+
204
+.fe-worker-review-modal__name {
205
+	font-size: 34rpx;
206
+	font-weight: 700;
207
+	color: $fe-gray-800;
208
+	line-height: 1.3;
209
+}
210
+
211
+.fe-worker-review-modal__rows {
212
+	background: $fe-gray-50;
213
+	border-radius: 20rpx;
214
+	padding: 8rpx 24rpx;
215
+	margin-bottom: 28rpx;
216
+}
217
+
218
+.fe-worker-review-modal__row {
219
+	display: flex;
220
+	align-items: flex-start;
221
+	justify-content: space-between;
222
+	gap: 24rpx;
223
+	padding: 20rpx 0;
224
+	border-bottom: 1rpx solid $fe-border-light;
225
+}
226
+
227
+.fe-worker-review-modal__row:last-child {
228
+	border-bottom: none;
229
+}
230
+
231
+.fe-worker-review-modal__label {
232
+	flex-shrink: 0;
233
+	font-size: 26rpx;
234
+	color: $fe-gray-500;
235
+}
236
+
237
+.fe-worker-review-modal__value {
238
+	flex: 1;
239
+	text-align: right;
240
+	font-size: 26rpx;
241
+	color: $fe-gray-700;
242
+	line-height: 1.4;
243
+	word-break: break-all;
244
+}
245
+
246
+.fe-worker-review-modal__value--danger {
247
+	color: #991B1B;
248
+}
249
+
250
+.fe-worker-review-modal__reject {
251
+	margin-bottom: 28rpx;
252
+}
253
+
254
+.fe-worker-review-modal__reject-label {
255
+	display: block;
256
+	font-size: 26rpx;
257
+	color: $fe-gray-600;
258
+	margin-bottom: 12rpx;
259
+}
260
+
261
+.fe-worker-review-modal__textarea {
262
+	width: 100%;
263
+	min-height: 140rpx;
264
+	padding: 20rpx;
265
+	box-sizing: border-box;
266
+	border-radius: 16rpx;
267
+	background: $fe-gray-50;
268
+	border: 1rpx solid $fe-border-light;
269
+	font-size: 26rpx;
270
+	color: $fe-gray-700;
271
+	line-height: 1.5;
272
+}
273
+
274
+.fe-worker-review-modal__actions {
275
+	display: flex;
276
+	gap: 20rpx;
277
+}
278
+
279
+.fe-worker-review-modal__btn {
280
+	flex: 1;
281
+}
282
+
283
+.fe-worker-review-modal__btn--disabled {
284
+	opacity: 0.55;
285
+	pointer-events: none;
286
+}
287
+</style>

+ 357 - 12
huimv-employment/app/packageA/components/home/EnterpriseHome.vue

@@ -257,9 +257,10 @@
257 257
 			>
258 258
 				<view
259 259
 					class="fe-input-bar__btn fe-input-bar__btn--voice"
260
-					@tap="onVoiceTap"
261
-				>🎤</view>
260
+					@tap="toggleVoiceMode"
261
+				>{{ voiceModeIcon }}</view>
262 262
 				<input
263
+					v-if="!voiceMode"
263 264
 					class="fe-input-bar__field"
264 265
 					v-model="inputText"
265 266
 					:disabled="inputDisabled"
@@ -268,6 +269,16 @@
268 269
 					@input="onInput"
269 270
 					@confirm="sendMsg"
270 271
 				/>
272
+				<view
273
+					v-else
274
+					class="fe-input-bar__hold"
275
+					:class="voiceHoldClass"
276
+					@touchstart.prevent="onVoiceTouchStart"
277
+					@touchend.prevent="onVoiceTouchEnd"
278
+					@touchcancel.prevent="onVoiceTouchEnd"
279
+				>
280
+					<text class="fe-input-bar__hold-text">{{ voiceHoldLabel }}</text>
281
+				</view>
271 282
 				<view
272 283
 					class="fe-input-bar__btn fe-input-bar__btn--send"
273 284
 					:class="{ active: canSend }"
@@ -276,6 +287,27 @@
276 287
 			</view>
277 288
 		</view>
278 289
 
290
+		<!-- 语音录音遮罩(无取消 / 转文字按钮) -->
291
+		<view v-if="voicePanelVisible" class="fe-voice-mask">
292
+			<view class="fe-voice-mask__tip">
293
+				<view class="fe-voice-mask__wave">
294
+					<view
295
+						v-for="(bar, barIdx) in voiceWaveBars"
296
+						:key="barIdx"
297
+						class="fe-voice-mask__bar"
298
+						:class="bar.cls"
299
+					/>
300
+				</view>
301
+				<text class="fe-voice-mask__countdown">{{ voiceCountdown }}s</text>
302
+			</view>
303
+			<view class="fe-voice-mask__live">
304
+				<text class="fe-voice-mask__live-text" user-select>{{ voiceLiveDisplay }}</text>
305
+			</view>
306
+			<view class="fe-voice-mask__footer">
307
+				<text class="fe-voice-mask__footer-text">松开 发送</text>
308
+			</view>
309
+		</view>
310
+
279 311
 		<!-- Overlay -->
280 312
 		<view class="fe-overlay" :class="{ 'fe-overlay--active': overlayActive, 'fe-overlay--elevated': sheetElevated }" @tap="closeAll" />
281 313
 
@@ -305,7 +337,12 @@
305 337
 			:elevated="sheetElevated"
306 338
 			:draft-id="progressDraftId"
307 339
 		/>
308
-		<FeSettlementSheet :active="activeSheet === 'settlement'" @pay="showPayment" />
340
+		<FeSettlementSheet
341
+			:active="activeSheet === 'settlement'"
342
+			:order-id="settlementOrderId"
343
+			@close="closeAll"
344
+			@paid="onSettlementPaid"
345
+		/>
309 346
 
310 347
 		<!-- Drawer: Message -->
311 348
 		<view class="fe-side-drawer" :class="{ 'fe-side-drawer--active': activeDrawer === 'message' }">
@@ -428,6 +465,15 @@ import {
428 465
 	buildJobOfferShareMessage
429 466
 } from '@/utils/job-offer.js'
430 467
 import { resolveApiAssetUrl } from '@/common/config.js'
468
+import {
469
+	isWechatSIAvailable,
470
+	ensureRecordAuth,
471
+	initRecordRecognition,
472
+	startRecordRecognition,
473
+	stopRecordRecognition,
474
+	formatSiError,
475
+	WECHAT_SI_MAX_DURATION_MS
476
+} from '@/utils/wechat-si.js'
431 477
 
432 478
 export default {
433 479
 	name: 'EnterpriseHome',
@@ -488,7 +534,35 @@ export default {
488 534
 			reportObserver: null,
489 535
 			_localMsgSeq: 0,
490 536
 			progressDraftId: null,
491
-			costDraftMetas: []
537
+			settlementOrderId: null,
538
+			costDraftMetas: [],
539
+			/** 语音识别:按住说话 */
540
+			voiceMode: false,
541
+			voiceModeIcon: '🎤',
542
+			voiceHoldLabel: '按住说话',
543
+			voiceHoldClass: '',
544
+			voicePanelVisible: false,
545
+			voiceRecording: false,
546
+			voiceRecognizing: false,
547
+			voiceCountdown: 60,
548
+			voiceLiveText: '',
549
+			voiceLiveDisplay: '正在聆听...',
550
+			voiceWaveBars: [
551
+				{ cls: 'fe-voice-mask__bar--1' },
552
+				{ cls: 'fe-voice-mask__bar--2' },
553
+				{ cls: 'fe-voice-mask__bar--3' },
554
+				{ cls: 'fe-voice-mask__bar--4' },
555
+				{ cls: 'fe-voice-mask__bar--5' },
556
+				{ cls: 'fe-voice-mask__bar--4' },
557
+				{ cls: 'fe-voice-mask__bar--3' },
558
+				{ cls: 'fe-voice-mask__bar--2' },
559
+				{ cls: 'fe-voice-mask__bar--1' }
560
+			],
561
+			_voiceTouchActive: false,
562
+			_voiceStarted: false,
563
+			_voiceSiReady: false,
564
+			_voiceCountdownTimer: null,
565
+			_voiceAutoSend: false
492 566
 		}
493 567
 	},
494 568
 	computed: {
@@ -531,7 +605,7 @@ export default {
531 605
 		inputPlaceholder() {
532 606
 			if (!this.currentConversationId) return '请先开启新对话'
533 607
 			if (this.chatStreaming) return 'AI 正在回复...'
534
-			return '输入需求或按住说话...'
608
+			return '输入需求...'
535 609
 		},
536 610
 		newChatGreeting() {
537 611
 			return getNewChatGreeting()
@@ -549,6 +623,7 @@ export default {
549 623
 		this.initHome()
550 624
 	},
551 625
 	mounted() {
626
+		this.initVoiceRecognition()
552 627
 		if (this.active) this.initHome()
553 628
 	},
554 629
 	watch: {
@@ -560,6 +635,7 @@ export default {
560 635
 		this.setupReportObserver()
561 636
 	},
562 637
 	beforeDestroy() {
638
+		this.teardownVoiceRecognition()
563 639
 		this.disconnectReportObserver()
564 640
 		this.stopChatStream()
565 641
 		if (this._scrollTimer) clearTimeout(this._scrollTimer)
@@ -887,14 +963,245 @@ export default {
887 963
 				!this.inputDisabled &&
888 964
 				!!this.currentConversationId &&
889 965
 				this.inputText.trim().length > 0 &&
890
-				!this.chatStreaming
966
+				!this.chatStreaming &&
967
+				!this.voiceRecording &&
968
+				!this.voiceRecognizing &&
969
+				!this.voiceMode
970
+		},
971
+		syncVoiceViewMeta() {
972
+			this.voiceModeIcon = this.voiceMode ? '⌨️' : '🎤'
973
+			if (this.chatStreaming && this.voiceMode) {
974
+				this.voiceHoldLabel = '等待回复中...'
975
+				this.voiceHoldClass = 'is-waiting'
976
+			} else if (this.voiceRecording) {
977
+				this.voiceHoldLabel = '松开 结束'
978
+				this.voiceHoldClass = 'is-recording'
979
+			} else {
980
+				this.voiceHoldLabel = '按住说话'
981
+				this.voiceHoldClass = ''
982
+			}
983
+		},
984
+		clearVoiceCountdown() {
985
+			if (this._voiceCountdownTimer) {
986
+				clearInterval(this._voiceCountdownTimer)
987
+				this._voiceCountdownTimer = null
988
+			}
891 989
 		},
892
-		onVoiceTap() {
990
+		startVoiceCountdown() {
991
+			this.clearVoiceCountdown()
992
+			this.voiceCountdown = 60
993
+			this._voiceCountdownTimer = setInterval(() => {
994
+				if (this.voiceCountdown <= 1) {
995
+					this.voiceCountdown = 0
996
+					this.clearVoiceCountdown()
997
+					this._voiceTouchActive = false
998
+					stopRecordRecognition()
999
+					return
1000
+				}
1001
+				this.voiceCountdown -= 1
1002
+			}, 1000)
1003
+		},
1004
+		setVoicePanel(visible) {
1005
+			this.voicePanelVisible = !!visible
1006
+			if (!visible) {
1007
+				this.clearVoiceCountdown()
1008
+				this.voiceCountdown = 60
1009
+				this.setVoiceLiveText('')
1010
+			}
1011
+		},
1012
+		setVoiceLiveText(text) {
1013
+			const t = (text || '').trim()
1014
+			this.voiceLiveText = t
1015
+			this.voiceLiveDisplay = t || '正在聆听...'
1016
+		},
1017
+		toggleVoiceMode() {
893 1018
 			if (this.inputDisabled) {
894 1019
 				if (!this.currentConversationId) this.toast('请先开启新对话')
1020
+				else if (this.chatStreaming) this.toast('请等待 AI 回复完成')
895 1021
 				return
896 1022
 			}
897
-			this.toast('按住说话...')
1023
+			if (this.voiceRecording || this.voiceRecognizing) return
1024
+			if (!this.voiceMode) {
1025
+				if (!isWechatSIAvailable() || !this._voiceSiReady) {
1026
+					this.toast('语音输入仅支持微信小程序')
1027
+					return
1028
+				}
1029
+				this.voiceMode = true
1030
+			} else {
1031
+				this.voiceMode = false
1032
+			}
1033
+			this.syncVoiceViewMeta()
1034
+			this.onInput()
1035
+		},
1036
+		exitVoiceModeToText() {
1037
+			this.voiceMode = false
1038
+			this.syncVoiceViewMeta()
1039
+			this.onInput()
1040
+		},
1041
+		initVoiceRecognition() {
1042
+			if (!isWechatSIAvailable()) {
1043
+				this._voiceSiReady = false
1044
+				return
1045
+			}
1046
+			const ok = initRecordRecognition({
1047
+				onStart: () => {
1048
+					this.voiceRecording = true
1049
+					this.voiceRecognizing = false
1050
+					this._voiceStarted = true
1051
+					this._voiceAutoSend = true
1052
+					this.setVoicePanel(true)
1053
+					this.startVoiceCountdown()
1054
+					this.syncVoiceViewMeta()
1055
+				},
1056
+				onRecognize: (res) => {
1057
+					const piece = ((res && res.result) || '').trim()
1058
+					if (!piece) return
1059
+					this.setVoiceLiveText(piece)
1060
+				},
1061
+				onStop: (res) => {
1062
+					this.voiceRecording = false
1063
+					this.voiceRecognizing = false
1064
+					this._voiceStarted = false
1065
+					const piece = ((res && res.result) || this.voiceLiveText || '').trim()
1066
+					const shouldSend = this._voiceAutoSend
1067
+					this._voiceAutoSend = false
1068
+					this.setVoicePanel(false)
1069
+					this.syncVoiceViewMeta()
1070
+					if (shouldSend && piece) {
1071
+						// 回复未完成时禁止连续发送
1072
+						if (this.chatStreaming) {
1073
+							this.toast('请等待 AI 回复完成')
1074
+							this.onInput()
1075
+							return
1076
+						}
1077
+						// 发送后保持语音模式,等回复结束后才能再按住
1078
+						this.submitChatMessage(piece)
1079
+						this.syncVoiceViewMeta()
1080
+					} else if (shouldSend) {
1081
+						this.toast('未识别到内容,请重试')
1082
+						this.onInput()
1083
+					} else {
1084
+						this.onInput()
1085
+					}
1086
+				},
1087
+				onError: (err) => {
1088
+					this.voiceRecording = false
1089
+					this.voiceRecognizing = false
1090
+					this._voiceStarted = false
1091
+					this._voiceAutoSend = false
1092
+					this.setVoicePanel(false)
1093
+					this.syncVoiceViewMeta()
1094
+					this.onInput()
1095
+					const code = err && (err.retcode != null ? err.retcode : err.errCode)
1096
+					if (String(code) === '-30012') return
1097
+					this.toast(formatSiError(err))
1098
+				}
1099
+			})
1100
+			this._voiceSiReady = !!ok
1101
+		},
1102
+		teardownVoiceRecognition() {
1103
+			this._voiceTouchActive = false
1104
+			this._voiceAutoSend = false
1105
+			this.clearVoiceCountdown()
1106
+			if (this._voiceStarted || this.voiceRecording) {
1107
+				try {
1108
+					stopRecordRecognition()
1109
+				} catch (e) {
1110
+					/* ignore */
1111
+				}
1112
+			}
1113
+			this.voiceRecording = false
1114
+			this.voiceRecognizing = false
1115
+			this._voiceStarted = false
1116
+			this.setVoicePanel(false)
1117
+			this.syncVoiceViewMeta()
1118
+		},
1119
+		async onVoiceTouchStart() {
1120
+			if (!this.voiceMode) return
1121
+			if (this.chatStreaming) {
1122
+				this.toast('请等待 AI 回复完成')
1123
+				return
1124
+			}
1125
+			if (this.inputDisabled) {
1126
+				if (!this.currentConversationId) this.toast('请先开启新对话')
1127
+				else if (this.chatStreaming) this.toast('请等待 AI 回复完成')
1128
+				return
1129
+			}
1130
+			if (!isWechatSIAvailable() || !this._voiceSiReady) {
1131
+				this.toast('语音输入仅支持微信小程序')
1132
+				return
1133
+			}
1134
+			if (this.voiceRecording || this.voiceRecognizing) return
1135
+
1136
+			this._voiceTouchActive = true
1137
+			this._voiceAutoSend = true
1138
+			this.setVoiceLiveText('')
1139
+			this.setVoicePanel(true)
1140
+			this.voiceCountdown = 60
1141
+			this.voiceRecognizing = true
1142
+			this.syncVoiceViewMeta()
1143
+
1144
+			const authed = await ensureRecordAuth()
1145
+			if (!this._voiceTouchActive) {
1146
+				this.voiceRecognizing = false
1147
+				this._voiceAutoSend = false
1148
+				this.setVoicePanel(false)
1149
+				this.syncVoiceViewMeta()
1150
+				return
1151
+			}
1152
+			// 授权等待期间可能已开始流式回复
1153
+			if (this.chatStreaming) {
1154
+				this.voiceRecognizing = false
1155
+				this._voiceAutoSend = false
1156
+				this.setVoicePanel(false)
1157
+				this.syncVoiceViewMeta()
1158
+				this.toast('请等待 AI 回复完成')
1159
+				return
1160
+			}
1161
+			if (!authed) {
1162
+				this.voiceRecognizing = false
1163
+				this._voiceAutoSend = false
1164
+				this.setVoicePanel(false)
1165
+				this.syncVoiceViewMeta()
1166
+				this.toast('未获得麦克风权限')
1167
+				return
1168
+			}
1169
+
1170
+			try {
1171
+				await startRecordRecognition({
1172
+					duration: WECHAT_SI_MAX_DURATION_MS,
1173
+					lang: 'zh_CN'
1174
+				})
1175
+				if (!this._voiceTouchActive || this.chatStreaming) {
1176
+					stopRecordRecognition()
1177
+					if (this.chatStreaming) {
1178
+						this._voiceAutoSend = false
1179
+						this.toast('请等待 AI 回复完成')
1180
+					}
1181
+				}
1182
+			} catch (e) {
1183
+				this.voiceRecognizing = false
1184
+				this.voiceRecording = false
1185
+				this._voiceStarted = false
1186
+				this._voiceAutoSend = false
1187
+				this.setVoicePanel(false)
1188
+				this.syncVoiceViewMeta()
1189
+				this.toast(formatSiError(e) || '无法开始录音')
1190
+			}
1191
+		},
1192
+		onVoiceTouchEnd() {
1193
+			this._voiceTouchActive = false
1194
+			if (!this._voiceStarted && !this.voiceRecording) {
1195
+				if (this.voiceRecognizing) {
1196
+					this.voiceRecognizing = false
1197
+					this._voiceAutoSend = false
1198
+					this.setVoicePanel(false)
1199
+					this.syncVoiceViewMeta()
1200
+				}
1201
+				return
1202
+			}
1203
+			this.voiceRecognizing = true
1204
+			stopRecordRecognition()
898 1205
 		},
899 1206
 		stopChatStream() {
900 1207
 			if (this.abortChat) {
@@ -902,6 +1209,8 @@ export default {
902 1209
 				this.abortChat = null
903 1210
 			}
904 1211
 			this.chatStreaming = false
1212
+			this.syncVoiceViewMeta()
1213
+			this.onInput()
905 1214
 		},
906 1215
 		abortChatStream() {
907 1216
 			if (this.abortChat) {
@@ -910,16 +1219,23 @@ export default {
910 1219
 			}
911 1220
 		},
912 1221
 		sendMsg() {
913
-			if (this.inputDisabled && !this.chatStreaming) {
914
-				if (!this.currentConversationId) this.toast('请先开启新对话')
1222
+			if (this.chatStreaming) {
1223
+				this.toast('请等待 AI 回复完成')
1224
+				return
1225
+			}
1226
+			this.submitChatMessage(this.inputText)
1227
+		},
1228
+		submitChatMessage(rawText) {
1229
+			if (this.chatStreaming) {
1230
+				this.toast('请等待 AI 回复完成')
915 1231
 				return
916 1232
 			}
917
-			const text = this.inputText.trim()
918
-			if (!text || this.chatStreaming) return
919 1233
 			if (!this.currentConversationId) {
920 1234
 				this.toast('请先开启新对话')
921 1235
 				return
922 1236
 			}
1237
+			const text = (rawText || '').trim()
1238
+			if (!text) return
923 1239
 
924 1240
 			this.abortChatStream()
925 1241
 			this.stickToBottom = true
@@ -935,6 +1251,7 @@ export default {
935 1251
 			this.inputText = ''
936 1252
 			this.canSend = false
937 1253
 			this.chatStreaming = true
1254
+			this.syncVoiceViewMeta()
938 1255
 			this.scrollBottom()
939 1256
 
940 1257
 			this.abortChat = streamConversationChat({
@@ -1830,6 +2147,26 @@ export default {
1830 2147
 			this.progressDraftId = draftId
1831 2148
 			this.openSheet('progress')
1832 2149
 		},
2150
+		/** 待办深链 / 消息跳转:打开指定订单结算确认弹层 */
2151
+		handleDeepLink(options = {}) {
2152
+			const open = options.open || ''
2153
+			const orderId = options.order_id || options.orderId || ''
2154
+			if (open === 'settlement' && orderId !== '' && orderId != null) {
2155
+				this.openSettlementByOrderId(orderId)
2156
+			}
2157
+		},
2158
+		openSettlementByOrderId(orderId) {
2159
+			if (orderId == null || orderId === '') {
2160
+				showToast('缺少订单信息')
2161
+				return
2162
+			}
2163
+			this.settlementOrderId = orderId
2164
+			this.openSheet('settlement')
2165
+		},
2166
+		onSettlementPaid() {
2167
+			this.closeAll()
2168
+			setTimeout(() => { this.modal = 'paySuccess' }, 200)
2169
+		},
1833 2170
 		showPayment() {
1834 2171
 			this.closeAll()
1835 2172
 			setTimeout(() => { this.modal = 'payment' }, 300)
@@ -1892,6 +2229,14 @@ export default {
1892 2229
 	color: $fe-gray-400;
1893 2230
 }
1894 2231
 
2232
+.fe-input-bar--disabled .fe-input-bar__hold {
2233
+	background: $fe-gray-100;
2234
+}
2235
+
2236
+.fe-input-bar--disabled .fe-input-bar__hold-text {
2237
+	color: $fe-gray-400;
2238
+}
2239
+
1895 2240
 .fe-input-bar--disabled .fe-input-bar__btn {
1896 2241
 	opacity: 0.7;
1897 2242
 }

Разница между файлами не показана из-за своего большого размера
+ 812 - 129
huimv-employment/app/packageA/components/home/EnterprisePortalHome.vue


+ 59 - 8
huimv-employment/app/packageA/components/home/WorkerHome.vue

@@ -8,7 +8,7 @@
8 8
 						<view style="font-size:36rpx;font-weight:700;">{{ worker.name }}</view>
9 9
 						<view style="font-size:26rpx;opacity:0.8;margin-top:4rpx;">{{ worker.phone }}</view>
10 10
 					</view>
11
-					<view class="wallet-refresh" @tap="toast('已刷新')">↻</view>
11
+					<view class="wallet-refresh" @tap="onRefresh">↻</view>
12 12
 				</view>
13 13
 				<view style="display:inline-flex;align-items:center;gap:8rpx;padding:6rpx 20rpx;border-radius:9999rpx;background:rgba(255,255,255,0.2);font-size:24rpx;font-weight:600;margin-top:16rpx;">✓ 已实名核验</view>
14 14
 				<view style="margin-top:32rpx;">
@@ -19,10 +19,13 @@
19 19
 
20 20
 			<view class="fe-wallet-section">
21 21
 				<view style="font-size:28rpx;font-weight:700;padding:28rpx 32rpx 16rpx;">💼 我的钱包</view>
22
-				<view class="fe-wallet-menu-item" @tap="toast('电子合同详情')">
22
+				<view class="fe-wallet-menu-item" @tap="goContracts">
23 23
 					<view class="fe-profile-menu-icon" style="background:#DBEAFE;">📄</view>
24
-					<view style="flex:1;"><view class="fe-profile-menu-title">电子合同</view><view class="fe-profile-menu-desc">2份已签署</view></view>
25
-					<text style="font-size:22rpx;font-weight:700;padding:4rpx 16rpx;border-radius:9999rpx;background:#F5F3FF;color:#7C3AED;">2份</text>
24
+					<view style="flex:1;">
25
+						<view class="fe-profile-menu-title">电子合同</view>
26
+						<view class="fe-profile-menu-desc">{{ contractDesc }}</view>
27
+					</view>
28
+					<text class="wallet-badge">{{ contractBadge }}</text>
26 29
 					<text style="color:#94A3B8;">›</text>
27 30
 				</view>
28 31
 				<view class="fe-wallet-menu-item" @tap="toast('薪资明细详情')">
@@ -61,6 +64,8 @@
61 64
 import FeBottomNav from '@/packageA/components/FeBottomNav.vue'
62 65
 import store from '@/common/store.js'
63 66
 import { WALLET_RECORDS, showToast } from '@/common/chat-data.js'
67
+import { listMyContracts } from '@/api/contract.js'
68
+import { summarizeContracts } from '@/utils/contract.js'
64 69
 
65 70
 export default {
66 71
 	name: 'WorkerHome',
@@ -72,12 +77,24 @@ export default {
72 77
 		return {
73 78
 			worker: {},
74 79
 			records: WALLET_RECORDS,
75
-			salaryDisplay: '0'
80
+			salaryDisplay: '0',
81
+			contractTotal: 0,
82
+			contractSigned: 0,
83
+			contractPending: 0,
84
+			contractLoading: false
76 85
 		}
77 86
 	},
78
-	onShow() {
79
-		if (!this.active) return
80
-		this.initHome()
87
+	computed: {
88
+		contractBadge() {
89
+			if (this.contractLoading) return '...'
90
+			return `${this.contractTotal}份`
91
+		},
92
+		contractDesc() {
93
+			if (this.contractLoading) return '加载中...'
94
+			if (this.contractTotal <= 0) return '暂无合同'
95
+			if (this.contractPending > 0) return `${this.contractPending}份待签署`
96
+			return `${this.contractSigned}份已签署`
97
+		}
81 98
 	},
82 99
 	mounted() {
83 100
 		if (this.active) this.initHome()
@@ -92,6 +109,30 @@ export default {
92 109
 		initHome() {
93 110
 			this.worker = store.getState().worker
94 111
 			this.animateSalary()
112
+			this.loadContracts()
113
+		},
114
+		onRefresh() {
115
+			this.loadContracts()
116
+			showToast('已刷新')
117
+		},
118
+		async loadContracts() {
119
+			this.contractLoading = true
120
+			try {
121
+				const list = await listMyContracts({}, { showError: false })
122
+				const summary = summarizeContracts(list)
123
+				this.contractTotal = summary.total
124
+				this.contractSigned = summary.signed
125
+				this.contractPending = summary.pending
126
+			} catch (e) {
127
+				this.contractTotal = 0
128
+				this.contractSigned = 0
129
+				this.contractPending = 0
130
+			} finally {
131
+				this.contractLoading = false
132
+			}
133
+		},
134
+		goContracts() {
135
+			uni.navigateTo({ url: '/packageA/worker/contracts' })
95 136
 		},
96 137
 		animateSalary() {
97 138
 			const target = 3500
@@ -146,4 +187,14 @@ export default {
146 187
 	font-size: 36rpx;
147 188
 	flex-shrink: 0;
148 189
 }
190
+
191
+.wallet-badge {
192
+	font-size: 22rpx;
193
+	font-weight: 700;
194
+	padding: 4rpx 16rpx;
195
+	border-radius: 9999rpx;
196
+	background: #F5F3FF;
197
+	color: #7C3AED;
198
+	flex-shrink: 0;
199
+}
149 200
 </style>

+ 150 - 12
huimv-employment/app/packageA/enterprise/messages.vue

@@ -1,24 +1,42 @@
1 1
 <template>
2 2
 	<view class="fe-page fe-page--tab msg-page">
3
-		<scroll-view scroll-y class="page-scroll" :show-scrollbar="false">
3
+		<scroll-view
4
+			scroll-y
5
+			class="page-scroll"
6
+			:show-scrollbar="false"
7
+			@scrolltolower="loadMore"
8
+		>
4 9
 			<view class="page-body">
5
-				<view v-if="!inbox.length" class="msg-empty">
10
+				<view v-if="loading && !inbox.length" class="msg-empty">
11
+					<text class="msg-empty__text">加载中...</text>
12
+				</view>
13
+				<view v-else-if="loadError && !inbox.length" class="msg-empty">
14
+					<text class="msg-empty__text">{{ loadError }}</text>
15
+					<view class="fe-btn fe-btn--primary fe-btn--sm msg-empty__btn" @tap="reload">重新加载</view>
16
+				</view>
17
+				<view v-else-if="!inbox.length" class="msg-empty">
6 18
 					<text class="msg-empty__text">暂无消息</text>
7 19
 				</view>
8 20
 				<view
9 21
 					v-for="(m, idx) in inbox"
10
-					:key="idx"
22
+					:key="m.msgKey"
11 23
 					class="msg-card"
24
+					:class="m.itemClass"
12 25
 					@tap="onItemByIndex(idx)"
13 26
 				>
14 27
 					<view class="msg-card__top">
15
-						<text class="fe-msg-type" :class="'fe-msg-type--' + m.type">
16
-							{{ m.type === 'pending' ? '待办' : '异常' }}
17
-						</text>
18
-						<text class="msg-card__time">{{ m.time }}</text>
28
+						<text class="fe-msg-type" :class="'fe-msg-type--' + m.typeClass">{{ m.typeLabel }}</text>
29
+						<text class="msg-card__time">{{ m.timeText }}</text>
19 30
 					</view>
20 31
 					<text class="msg-card__title">{{ m.title }}</text>
21 32
 					<text class="msg-card__desc">{{ m.desc }}</text>
33
+					<view v-if="m.handledFlag" class="msg-card__handled">已处理</view>
34
+				</view>
35
+				<view v-if="inbox.length && loadingMore" class="msg-footer-tip">
36
+					<text class="msg-footer-tip__text">加载更多...</text>
37
+				</view>
38
+				<view v-else-if="inbox.length && !hasMore" class="msg-footer-tip">
39
+					<text class="msg-footer-tip__text">没有更多了</text>
22 40
 				</view>
23 41
 			</view>
24 42
 		</scroll-view>
@@ -31,33 +49,106 @@
31 49
 import FeBottomNav from '@/packageA/components/FeBottomNav.vue'
32 50
 import store from '@/common/store.js'
33 51
 import { getToken } from '@/utils/request.js'
34
-import { MESSAGES, showToast } from '@/common/chat-data.js'
52
+import { showToast } from '@/common/chat-data.js'
53
+import { listMyNotifications } from '@/api/notification.js'
54
+import { mapNotificationPage } from '@/utils/notification.js'
55
+
56
+const PAGE_SIZE = 20
35 57
 
36 58
 export default {
37 59
 	components: { FeBottomNav },
38 60
 	data() {
39 61
 		return {
40
-			inbox: MESSAGES
62
+			inbox: [],
63
+			loading: false,
64
+			loadingMore: false,
65
+			loadError: '',
66
+			page: 1,
67
+			hasMore: false,
68
+			pendingCount: 0,
69
+			unreadCount: 0
41 70
 		}
42 71
 	},
43 72
 	onShow() {
44
-		this.ensureEnterpriseAccess()
73
+		if (!this.ensureEnterpriseAccess()) return
74
+		this.reload()
45 75
 	},
46 76
 	methods: {
47 77
 		ensureEnterpriseAccess() {
48 78
 			if (!getToken()) {
49 79
 				uni.reLaunch({ url: '/packageA/auth/login' })
50
-				return
80
+				return false
51 81
 			}
52 82
 			const s = store.getState()
53 83
 			if (s.role === 'worker') {
54 84
 				uni.reLaunch({ url: '/packageA/home/index' })
85
+				return false
86
+			}
87
+			return true
88
+		},
89
+		reload() {
90
+			this.page = 1
91
+			this.hasMore = false
92
+			this.loadError = ''
93
+			this.fetchPage(true)
94
+		},
95
+		loadMore() {
96
+			if (!this.hasMore || this.loading || this.loadingMore) return
97
+			this.page += 1
98
+			this.fetchPage(false)
99
+		},
100
+		async fetchPage(reset) {
101
+			if (reset) this.loading = true
102
+			else this.loadingMore = true
103
+			try {
104
+				const raw = await listMyNotifications(
105
+					{ page: this.page, size: PAGE_SIZE },
106
+					{ showError: false }
107
+				)
108
+				const pageData = mapNotificationPage(raw || {})
109
+				this.pendingCount = pageData.pendingCount
110
+				this.unreadCount = pageData.unreadCount
111
+				this.hasMore = pageData.hasMore
112
+				if (reset) {
113
+					this.inbox = pageData.items
114
+				} else {
115
+					this.inbox = this.inbox.concat(pageData.items)
116
+				}
117
+				this.loadError = ''
118
+			} catch (e) {
119
+				if (reset) {
120
+					this.inbox = []
121
+					this.loadError = (e && (e.msg || e.message)) || '消息加载失败'
122
+					showToast(this.loadError)
123
+				} else {
124
+					this.page = Math.max(1, this.page - 1)
125
+					showToast((e && (e.msg || e.message)) || '加载更多失败')
126
+				}
127
+			} finally {
128
+				this.loading = false
129
+				this.loadingMore = false
55 130
 			}
56 131
 		},
57 132
 		onItemByIndex(idx) {
58 133
 			const item = this.inbox[idx]
59 134
 			if (!item) return
60
-			showToast(item.title)
135
+			const url = item.actionUrl
136
+			if (!url || typeof url !== 'string' || url.indexOf('/packageA/') !== 0) {
137
+				showToast(item.title || '消息详情')
138
+				return
139
+			}
140
+			const path = url.split('?')[0]
141
+			if (path === '/packageA/home/index') {
142
+				uni.reLaunch({ url })
143
+				return
144
+			}
145
+			uni.navigateTo({
146
+				url,
147
+				fail: (err) => {
148
+					console.error('notification navigate fail', url, err)
149
+					uni.reLaunch({ url })
150
+				}
151
+			})
61 152
 		}
62 153
 	}
63 154
 }
@@ -87,15 +178,36 @@ export default {
87 178
 }
88 179
 
89 180
 .msg-empty__text {
181
+	display: block;
90 182
 	font-size: 28rpx;
91 183
 	color: $fe-muted;
92 184
 }
93 185
 
186
+.msg-empty__btn {
187
+	margin: 28rpx auto 0;
188
+	display: inline-flex;
189
+}
190
+
94 191
 .msg-card {
192
+	position: relative;
95 193
 	background: $fe-surface;
96 194
 	border-radius: $fe-radius-lg;
97 195
 	padding: 28rpx 32rpx;
98 196
 	margin-bottom: 20rpx;
197
+	border: 1rpx solid $fe-border-light;
198
+}
199
+
200
+.msg-card--unread {
201
+	border-color: rgba(124, 58, 237, 0.28);
202
+	background: #FBF9FF;
203
+}
204
+
205
+.msg-card--todo {
206
+	box-shadow: inset 6rpx 0 0 $fe-warning;
207
+}
208
+
209
+.msg-card--handled {
210
+	opacity: 0.72;
99 211
 }
100 212
 
101 213
 .msg-card__top {
@@ -124,4 +236,30 @@ export default {
124 236
 	color: $fe-muted;
125 237
 	line-height: 1.5;
126 238
 }
239
+
240
+.msg-card__handled {
241
+	margin-top: 12rpx;
242
+	font-size: 22rpx;
243
+	color: $fe-gray-400;
244
+}
245
+
246
+.msg-footer-tip {
247
+	padding: 16rpx 0 8rpx;
248
+	text-align: center;
249
+}
250
+
251
+.msg-footer-tip__text {
252
+	font-size: 24rpx;
253
+	color: $fe-gray-400;
254
+}
255
+
256
+.fe-msg-type--info {
257
+	background: $fe-info-light;
258
+	color: #1E40AF;
259
+}
260
+
261
+.fe-msg-type--success {
262
+	background: $fe-success-light;
263
+	color: #065F46;
264
+}
127 265
 </style>

+ 27 - 7
huimv-employment/app/packageA/enterprise/mine.vue

@@ -17,8 +17,10 @@
17 17
 						</view>
18 18
 					</view>
19 19
 
20
-					<view class="fe-btn fe-btn--secondary fe-btn--lg fe-btn--full guest-logout" @tap="logout">
21
-						退出账号
20
+					<view class="guest-logout-panel">
21
+						<view class="fe-btn fe-btn--secondary fe-btn--lg fe-btn--full guest-logout" @tap="logout">
22
+							退出账号
23
+						</view>
22 24
 					</view>
23 25
 				</view>
24 26
 			</view>
@@ -94,7 +96,7 @@ export default {
94 96
 	flex-direction: column;
95 97
 	height: 100vh;
96 98
 	overflow: hidden;
97
-	background: $fe-bg;
99
+	background: #f3f5f9;
98 100
 }
99 101
 
100 102
 .page-scroll {
@@ -103,17 +105,26 @@ export default {
103 105
 }
104 106
 
105 107
 .page-body {
106
-	padding: 24rpx 32rpx calc(32rpx + 120rpx + #{$fe-h-safe-bottom});
108
+	padding: 20rpx 24rpx calc(32rpx + 120rpx + #{$fe-h-safe-bottom});
109
+}
110
+
111
+.guest-panel {
112
+	display: flex;
113
+	flex-direction: column;
114
+	gap: 20rpx;
107 115
 }
108 116
 
109 117
 .guest-card {
110
-	background: $fe-surface;
111
-	border-radius: $fe-radius-lg;
118
+	background: #ffffff;
119
+	border-radius: 20rpx;
112 120
 	padding: 48rpx 40rpx 40rpx;
113 121
 	display: flex;
114 122
 	flex-direction: column;
115 123
 	align-items: center;
116 124
 	text-align: center;
125
+	box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
126
+	border: 1rpx solid rgba(226, 232, 240, 0.9);
127
+	box-sizing: border-box;
117 128
 }
118 129
 
119 130
 .guest-card__avatar {
@@ -163,7 +174,16 @@ export default {
163 174
 	margin-top: 36rpx;
164 175
 }
165 176
 
177
+.guest-logout-panel {
178
+	background: #ffffff;
179
+	border-radius: 20rpx;
180
+	padding: 16rpx 20rpx;
181
+	box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
182
+	border: 1rpx solid rgba(226, 232, 240, 0.9);
183
+	box-sizing: border-box;
184
+}
185
+
166 186
 .guest-logout {
167
-	margin-top: 24rpx;
187
+	margin-top: 0;
168 188
 }
169 189
 </style>

+ 19 - 2
huimv-employment/app/packageA/home/index.vue

@@ -15,9 +15,10 @@
15 15
 				</view>
16 16
 				<EnterprisePortalHome
17 17
 					v-else-if="viewRole === 'enterprise'"
18
+					ref="enterpriseHome"
18 19
 					:active="true"
19 20
 				/>
20
-				<WorkerHome v-else :active="true" />
21
+				<WorkerHome v-else ref="workerHome" :active="true" />
21 22
 			</view>
22 23
 
23 24
 			<view v-else class="index-boot">
@@ -68,7 +69,10 @@ export default {
68 69
 		}
69 70
 	},
70 71
 	onShow() {
71
-		if (this.ready) return
72
+		if (this.ready) {
73
+			this.refreshHomePanels()
74
+			return
75
+		}
72 76
 		if (this.needRegister) {
73 77
 			this.registerModalVisible = true
74 78
 			return
@@ -76,6 +80,19 @@ export default {
76 80
 		this.bootstrap()
77 81
 	},
78 82
 	methods: {
83
+		refreshHomePanels() {
84
+			if (this.viewRole === 'worker') {
85
+				const home = this.$refs.workerHome
86
+				if (home && typeof home.loadContracts === 'function') {
87
+					home.loadContracts()
88
+				}
89
+				return
90
+			}
91
+			const portal = this.$refs.enterpriseHome
92
+			if (portal && typeof portal.refreshHome === 'function') {
93
+				portal.refreshHome()
94
+			}
95
+		},
79 96
 		goLogin() {
80 97
 			this.booting = true
81 98
 			this.bootText = '正在前往登录...'

+ 363 - 0
huimv-employment/app/packageA/worker/contracts.vue

@@ -0,0 +1,363 @@
1
+<template>
2
+	<view class="contracts-page">
3
+		<view v-if="loading" class="contracts-empty">
4
+			<text class="contracts-empty__text">加载合同中...</text>
5
+		</view>
6
+		<view v-else-if="loadError" class="contracts-empty">
7
+			<text class="contracts-empty__text">{{ loadError }}</text>
8
+			<view class="fe-btn fe-btn--primary fe-btn--sm contracts-empty__btn" @tap="loadList">重新加载</view>
9
+		</view>
10
+		<view v-else-if="!items.length" class="contracts-empty">
11
+			<text class="contracts-empty__text">暂无电子合同</text>
12
+			<text class="contracts-empty__sub">企业审核通过后,待签合同将显示在这里</text>
13
+		</view>
14
+		<scroll-view v-else scroll-y class="contracts-scroll" :show-scrollbar="false">
15
+			<view class="contracts-list">
16
+				<view
17
+					v-for="(item, idx) in items"
18
+					:key="item.msgKey"
19
+					class="contracts-card"
20
+					@tap="openDetailByIndex(idx)"
21
+				>
22
+					<view class="contracts-card__icon">📄</view>
23
+					<view class="contracts-card__main">
24
+						<view class="contracts-card__title">{{ item.contractTitle }}</view>
25
+						<view class="contracts-card__desc">{{ item.subtitle || item.contractNo || '—' }}</view>
26
+						<view v-if="item.createTimeText" class="contracts-card__time">{{ item.createTimeText }}</view>
27
+					</view>
28
+					<text
29
+						class="fe-person-status"
30
+						:class="'fe-person-status--' + item.signStatusClass"
31
+					>{{ item.signStatusText }}</text>
32
+					<text class="contracts-card__arrow">›</text>
33
+				</view>
34
+			</view>
35
+		</scroll-view>
36
+
37
+		<!-- 合同详情弹框 -->
38
+		<view
39
+			class="fe-modal-overlay"
40
+			:class="{ 'fe-modal-overlay--active': detailActive }"
41
+			@tap="closeDetail"
42
+		/>
43
+		<view class="fe-modal contracts-detail-modal" :class="{ 'fe-modal--active': detailActive }">
44
+			<view class="contracts-detail">
45
+				<view class="contracts-detail__title">合同详情</view>
46
+				<view v-if="detailLoading" class="contracts-detail__loading">加载中...</view>
47
+				<view v-else-if="detail">
48
+					<view class="contracts-detail__rows">
49
+						<view class="contracts-detail__row">
50
+							<text class="contracts-detail__label">合同名称</text>
51
+							<text class="contracts-detail__value" user-select>{{ detail.contractTitle }}</text>
52
+						</view>
53
+						<view class="contracts-detail__row">
54
+							<text class="contracts-detail__label">合同编号</text>
55
+							<text class="contracts-detail__value" user-select>{{ detail.contractNo || '—' }}</text>
56
+						</view>
57
+						<view class="contracts-detail__row">
58
+							<text class="contracts-detail__label">企业</text>
59
+							<text class="contracts-detail__value">{{ detail.enterpriseName || '—' }}</text>
60
+						</view>
61
+						<view class="contracts-detail__row">
62
+							<text class="contracts-detail__label">关联订单</text>
63
+							<text class="contracts-detail__value">{{ detail.orderTitle || '—' }}</text>
64
+						</view>
65
+						<view class="contracts-detail__row">
66
+							<text class="contracts-detail__label">签署状态</text>
67
+							<text
68
+								class="fe-person-status"
69
+								:class="'fe-person-status--' + detail.signStatusClass"
70
+							>{{ detail.signStatusText }}</text>
71
+						</view>
72
+						<view v-if="detail.signedAtText" class="contracts-detail__row">
73
+							<text class="contracts-detail__label">签署时间</text>
74
+							<text class="contracts-detail__value">{{ detail.signedAtText }}</text>
75
+						</view>
76
+						<view v-if="detail.createTimeText" class="contracts-detail__row">
77
+							<text class="contracts-detail__label">创建时间</text>
78
+							<text class="contracts-detail__value">{{ detail.createTimeText }}</text>
79
+						</view>
80
+					</view>
81
+
82
+					<view v-if="detail.canSign" class="contracts-detail__actions">
83
+						<view
84
+							class="fe-btn fe-btn--secondary fe-btn--lg contracts-detail__btn"
85
+							:class="{ 'contracts-detail__btn--disabled': signing }"
86
+							@tap="closeDetail"
87
+						>取消</view>
88
+						<view
89
+							class="fe-btn fe-btn--primary fe-btn--lg contracts-detail__btn"
90
+							:class="{ 'contracts-detail__btn--disabled': signing }"
91
+							@tap="onConfirmSign"
92
+						>{{ signing ? '签署中...' : '确认签署完成' }}</view>
93
+					</view>
94
+					<view v-else class="contracts-detail__actions">
95
+						<view class="fe-btn fe-btn--secondary fe-btn--lg contracts-detail__btn" @tap="closeDetail">关闭</view>
96
+					</view>
97
+				</view>
98
+			</view>
99
+		</view>
100
+	</view>
101
+</template>
102
+
103
+<script>
104
+import { showToast } from '@/common/chat-data.js'
105
+import { listMyContracts, getContractDetail, confirmContractSign } from '@/api/contract.js'
106
+import { mapContractList, mapContractItem } from '@/utils/contract.js'
107
+import store from '@/common/store.js'
108
+
109
+export default {
110
+	data() {
111
+		return {
112
+			loading: false,
113
+			loadError: '',
114
+			items: [],
115
+			detailActive: false,
116
+			detailLoading: false,
117
+			detail: null,
118
+			signing: false
119
+		}
120
+	},
121
+	onShow() {
122
+		if (!store.getState().loggedIn || store.getState().role !== 'worker') {
123
+			uni.redirectTo({ url: '/packageA/auth/login' })
124
+			return
125
+		}
126
+		this.loadList()
127
+	},
128
+	methods: {
129
+		async loadList() {
130
+			this.loading = true
131
+			this.loadError = ''
132
+			try {
133
+				const list = await listMyContracts({}, { showError: false })
134
+				this.items = mapContractList(list)
135
+			} catch (e) {
136
+				this.items = []
137
+				this.loadError = (e && (e.msg || e.message)) || '合同加载失败'
138
+				showToast(this.loadError)
139
+			} finally {
140
+				this.loading = false
141
+			}
142
+		},
143
+		openDetailByIndex(idx) {
144
+			const item = this.items[idx]
145
+			if (!item || item.contractId == null) {
146
+				showToast('合同信息不完整')
147
+				return
148
+			}
149
+			this.detailActive = true
150
+			this.detail = Object.assign({}, item)
151
+			this.fetchDetail(item.contractId)
152
+		},
153
+		async fetchDetail(contractId) {
154
+			this.detailLoading = true
155
+			try {
156
+				const raw = await getContractDetail(contractId, { showError: false })
157
+				if (raw) this.detail = mapContractItem(raw)
158
+			} catch (e) {
159
+				showToast((e && (e.msg || e.message)) || '详情加载失败')
160
+			} finally {
161
+				this.detailLoading = false
162
+			}
163
+		},
164
+		closeDetail() {
165
+			if (this.signing) return
166
+			this.detailActive = false
167
+		},
168
+		async onConfirmSign() {
169
+			if (this.signing || !this.detail || !this.detail.canSign) return
170
+			const id = this.detail.contractId
171
+			if (id == null) {
172
+				showToast('缺少合同信息')
173
+				return
174
+			}
175
+			this.signing = true
176
+			try {
177
+				const raw = await confirmContractSign(id, { showError: true })
178
+				const mapped = mapContractItem(raw || this.detail)
179
+				this.detail = mapped
180
+				showToast('签署完成')
181
+				await this.loadList()
182
+				this.detailActive = false
183
+			} catch (e) {
184
+				// request 已 toast
185
+			} finally {
186
+				this.signing = false
187
+			}
188
+		}
189
+	}
190
+}
191
+</script>
192
+
193
+<style lang="scss" scoped>
194
+.contracts-page {
195
+	min-height: 100vh;
196
+	background: $fe-bg;
197
+	box-sizing: border-box;
198
+}
199
+
200
+.contracts-scroll {
201
+	height: 100vh;
202
+}
203
+
204
+.contracts-list {
205
+	padding: 24rpx 32rpx 48rpx;
206
+}
207
+
208
+.contracts-card {
209
+	display: flex;
210
+	align-items: center;
211
+	gap: 20rpx;
212
+	padding: 28rpx 24rpx;
213
+	margin-bottom: 20rpx;
214
+	background: #fff;
215
+	border-radius: $fe-radius-md;
216
+	border: 1rpx solid $fe-border-light;
217
+}
218
+
219
+.contracts-card__icon {
220
+	width: 72rpx;
221
+	height: 72rpx;
222
+	border-radius: 20rpx;
223
+	background: #DBEAFE;
224
+	display: flex;
225
+	align-items: center;
226
+	justify-content: center;
227
+	font-size: 32rpx;
228
+	flex-shrink: 0;
229
+}
230
+
231
+.contracts-card__main {
232
+	flex: 1;
233
+	min-width: 0;
234
+}
235
+
236
+.contracts-card__title {
237
+	font-size: 28rpx;
238
+	font-weight: 700;
239
+	color: $fe-gray-800;
240
+	line-height: 1.35;
241
+}
242
+
243
+.contracts-card__desc {
244
+	margin-top: 6rpx;
245
+	font-size: 24rpx;
246
+	color: $fe-gray-500;
247
+	overflow: hidden;
248
+	text-overflow: ellipsis;
249
+	white-space: nowrap;
250
+}
251
+
252
+.contracts-card__time {
253
+	margin-top: 6rpx;
254
+	font-size: 22rpx;
255
+	color: $fe-gray-400;
256
+}
257
+
258
+.contracts-card__arrow {
259
+	color: #CBD5E1;
260
+	font-size: 32rpx;
261
+	flex-shrink: 0;
262
+}
263
+
264
+.contracts-empty {
265
+	padding: 160rpx 48rpx;
266
+	text-align: center;
267
+}
268
+
269
+.contracts-empty__text {
270
+	display: block;
271
+	font-size: 28rpx;
272
+	color: $fe-gray-500;
273
+	line-height: 1.5;
274
+}
275
+
276
+.contracts-empty__sub {
277
+	display: block;
278
+	margin-top: 12rpx;
279
+	font-size: 24rpx;
280
+	color: $fe-gray-400;
281
+}
282
+
283
+.contracts-empty__btn {
284
+	margin: 32rpx auto 0;
285
+	display: inline-flex;
286
+}
287
+
288
+.contracts-detail {
289
+	padding: 40rpx 36rpx 32rpx;
290
+}
291
+
292
+.contracts-detail__title {
293
+	font-size: 34rpx;
294
+	font-weight: 700;
295
+	text-align: center;
296
+	margin-bottom: 28rpx;
297
+}
298
+
299
+.contracts-detail__loading {
300
+	padding: 48rpx 0;
301
+	text-align: center;
302
+	color: $fe-gray-500;
303
+	font-size: 28rpx;
304
+}
305
+
306
+.contracts-detail__rows {
307
+	background: $fe-gray-50;
308
+	border-radius: 20rpx;
309
+	padding: 8rpx 24rpx;
310
+	margin-bottom: 28rpx;
311
+}
312
+
313
+.contracts-detail__row {
314
+	display: flex;
315
+	align-items: flex-start;
316
+	justify-content: space-between;
317
+	gap: 24rpx;
318
+	padding: 20rpx 0;
319
+	border-bottom: 1rpx solid $fe-border-light;
320
+}
321
+
322
+.contracts-detail__row:last-child {
323
+	border-bottom: none;
324
+}
325
+
326
+.contracts-detail__label {
327
+	flex-shrink: 0;
328
+	font-size: 26rpx;
329
+	color: $fe-gray-500;
330
+}
331
+
332
+.contracts-detail__value {
333
+	flex: 1;
334
+	text-align: right;
335
+	font-size: 26rpx;
336
+	color: $fe-gray-700;
337
+	line-height: 1.4;
338
+	word-break: break-all;
339
+}
340
+
341
+.contracts-detail__actions {
342
+	display: flex;
343
+	gap: 20rpx;
344
+}
345
+
346
+.contracts-detail__btn {
347
+	flex: 1;
348
+}
349
+
350
+.contracts-detail__btn--disabled {
351
+	opacity: 0.55;
352
+	pointer-events: none;
353
+}
354
+
355
+.contracts-detail-modal.fe-modal {
356
+	z-index: 410;
357
+}
358
+
359
+/* 盖住页面内容 */
360
+.contracts-page .fe-modal-overlay {
361
+	z-index: 400;
362
+}
363
+</style>

+ 8 - 0
huimv-employment/app/pages.json

@@ -27,6 +27,7 @@
27 27
 					"path": "home/index",
28 28
 					"style": {
29 29
 						"navigationBarTitleText": "首页",
30
+						"navigationBarBackgroundColor": "#FFFFFF",
30 31
 						"disableScroll": true
31 32
 					}
32 33
 				},
@@ -91,6 +92,13 @@
91 92
 						"navigationBarTitleText": "首页"
92 93
 					}
93 94
 				},
95
+				{
96
+					"path": "worker/contracts",
97
+					"style": {
98
+						"navigationBarTitleText": "电子合同",
99
+						"disableScroll": true
100
+					}
101
+				},
94 102
 				{
95 103
 					"path": "worker/profile",
96 104
 					"style": {

+ 75 - 0
huimv-employment/app/utils/contract.js

@@ -0,0 +1,75 @@
1
+/**
2
+ * 电子合同字段映射(兼容 snake_case / camelCase)
3
+ */
4
+
5
+export function mapContractItem(raw = {}, index = 0) {
6
+	const contractId = raw.contractId != null ? raw.contractId : raw.contract_id
7
+	const signStatus = raw.signStatus || raw.sign_status || ''
8
+	const signedAt = raw.signedAt || raw.signed_at || ''
9
+	const createTime = raw.createTime || raw.create_time || ''
10
+	const title = raw.contractTitle || raw.contract_title || raw.orderTitle || raw.order_title || '电子合同'
11
+	const enterpriseName = raw.enterpriseName || raw.enterprise_name || ''
12
+	const statusMeta = mapSignStatus(signStatus)
13
+
14
+	return {
15
+		msgKey: contractId != null ? contractId : index,
16
+		contractId,
17
+		contractNo: raw.contractNo || raw.contract_no || '',
18
+		registrationId: raw.registrationId != null ? raw.registrationId : raw.registration_id,
19
+		orderId: raw.orderId != null ? raw.orderId : raw.order_id,
20
+		orderTitle: raw.orderTitle || raw.order_title || '',
21
+		enterpriseId: raw.enterpriseId != null ? raw.enterpriseId : raw.enterprise_id,
22
+		enterpriseName,
23
+		contractType: raw.contractType || raw.contract_type || '',
24
+		contractTitle: title,
25
+		signStatus,
26
+		signStatusText: statusMeta.text,
27
+		signStatusClass: statusMeta.cls,
28
+		canSign: signStatus === 'pending' || signStatus === 'signing',
29
+		signProvider: raw.signProvider || raw.sign_provider || '',
30
+		signUrl: raw.signUrl || raw.sign_url || '',
31
+		fileUrl: raw.fileUrl || raw.file_url || '',
32
+		signedAt,
33
+		signedAtText: formatDateTime(signedAt),
34
+		createTime,
35
+		createTimeText: formatDateTime(createTime),
36
+		subtitle: enterpriseName || (raw.orderTitle || raw.order_title) || ''
37
+	}
38
+}
39
+
40
+export function mapContractList(list) {
41
+	const arr = Array.isArray(list) ? list : []
42
+	return arr.map((item, i) => mapContractItem(item, i))
43
+}
44
+
45
+export function summarizeContracts(list) {
46
+	const items = mapContractList(list)
47
+	let signed = 0
48
+	let pending = 0
49
+	for (let i = 0; i < items.length; i++) {
50
+		if (items[i].signStatus === 'signed') signed++
51
+		else if (items[i].canSign) pending++
52
+	}
53
+	return {
54
+		items,
55
+		total: items.length,
56
+		signed,
57
+		pending
58
+	}
59
+}
60
+
61
+function mapSignStatus(status) {
62
+	const s = status == null ? '' : String(status)
63
+	if (s === 'signed') return { text: '已签署', cls: 'done' }
64
+	if (s === 'signing') return { text: '签署中', cls: 'review' }
65
+	if (s === 'pending') return { text: '待签署', cls: 'pending' }
66
+	if (s === 'rejected') return { text: '已拒签', cls: 'fail' }
67
+	if (s === 'voided' || s === 'expired') return { text: '已失效', cls: 'fail' }
68
+	return { text: s || '未知', cls: 'pending' }
69
+}
70
+
71
+function formatDateTime(value) {
72
+	if (value == null || value === '') return ''
73
+	const s = String(value).replace('T', ' ')
74
+	return s.length >= 16 ? s.slice(0, 16) : s
75
+}

+ 76 - 0
huimv-employment/app/utils/notification.js

@@ -0,0 +1,76 @@
1
+/**
2
+ * 消息与待办字段映射
3
+ */
4
+
5
+const TYPE_META = {
6
+	pending: { label: '待办', cls: 'pending' },
7
+	error: { label: '异常', cls: 'error' },
8
+	info: { label: '通知', cls: 'info' },
9
+	success: { label: '成功', cls: 'success' }
10
+}
11
+
12
+export function mapNotificationItem(raw = {}, index = 0) {
13
+	const id = raw.id != null ? raw.id : index
14
+	const notifyType = raw.notifyType || raw.notify_type || 'info'
15
+	const meta = TYPE_META[notifyType] || TYPE_META.info
16
+	const createTime = raw.createTime || raw.create_time || ''
17
+	const readFlag = raw.readFlag === true || raw.read_flag === true
18
+	const handledFlag = raw.handledFlag === true || raw.handled_flag === true
19
+
20
+	return {
21
+		msgKey: id,
22
+		id,
23
+		enterpriseId: raw.enterpriseId != null ? raw.enterpriseId : raw.enterprise_id,
24
+		notifyType,
25
+		typeLabel: meta.label,
26
+		typeClass: meta.cls,
27
+		category: raw.category || '',
28
+		title: raw.title || '消息',
29
+		desc: raw.content || '',
30
+		bizType: raw.bizType || raw.biz_type || '',
31
+		bizId: raw.bizId != null ? raw.bizId : raw.biz_id,
32
+		bizNo: raw.bizNo || raw.biz_no || '',
33
+		actionUrl: raw.actionUrl || raw.action_url || '',
34
+		readFlag,
35
+		handledFlag,
36
+		createTime,
37
+		timeText: formatRelativeTime(createTime),
38
+		itemClass: buildItemClass(readFlag, handledFlag, notifyType)
39
+	}
40
+}
41
+
42
+export function mapNotificationPage(raw = {}) {
43
+	const items = Array.isArray(raw.items) ? raw.items : []
44
+	return {
45
+		items: items.map((it, i) => mapNotificationItem(it, i)),
46
+		total: Number(raw.total != null ? raw.total : 0),
47
+		page: Number(raw.page != null ? raw.page : 1),
48
+		size: Number(raw.size != null ? raw.size : 20),
49
+		hasMore: !!(raw.hasMore === true || raw.has_more === true),
50
+		unreadCount: Number(raw.unreadCount != null ? raw.unreadCount : (raw.unread_count != null ? raw.unread_count : 0)),
51
+		pendingCount: Number(raw.pendingCount != null ? raw.pendingCount : (raw.pending_count != null ? raw.pending_count : 0))
52
+	}
53
+}
54
+
55
+function buildItemClass(readFlag, handledFlag, notifyType) {
56
+	const parts = []
57
+	if (!readFlag) parts.push('msg-card--unread')
58
+	if (notifyType === 'pending' && !handledFlag) parts.push('msg-card--todo')
59
+	if (handledFlag) parts.push('msg-card--handled')
60
+	return parts.join(' ')
61
+}
62
+
63
+function formatRelativeTime(value) {
64
+	if (value == null || value === '') return ''
65
+	const s = String(value).replace('T', ' ')
66
+	const t = Date.parse(s.replace(/-/g, '/'))
67
+	if (Number.isNaN(t)) {
68
+		return s.length >= 16 ? s.slice(0, 16) : s
69
+	}
70
+	const diff = Date.now() - t
71
+	if (diff < 60 * 1000) return '刚刚'
72
+	if (diff < 60 * 60 * 1000) return `${Math.floor(diff / 60000)}分钟前`
73
+	if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / 3600000)}小时前`
74
+	if (diff < 7 * 24 * 60 * 60 * 1000) return `${Math.floor(diff / 86400000)}天前`
75
+	return s.length >= 16 ? s.slice(0, 16) : s
76
+}

+ 160 - 0
huimv-employment/app/utils/progress-action.js

@@ -0,0 +1,160 @@
1
+/**
2
+ * 根据办理流程步骤 / 当前步骤码,决定进度弹层底部主按钮
3
+ *
4
+ * 优先使用 current_step_code(与订单状态机一致):
5
+ * - contract_signing → 开始用工
6
+ * - work_started / settlement_pending → 发起结算
7
+ * - settlement_paid → 开票办结
8
+ * - completed → 已办结
9
+ *
10
+ * 兜底再用 steps 状态:
11
+ * - 企业审核 done 且尚未到开始用工 → 开始用工
12
+ */
13
+
14
+const STEP_ORDER = [
15
+	'draft_created',
16
+	'plan_confirmed',
17
+	'registration',
18
+	'enterprise_audit',
19
+	'contract_signing',
20
+	'work_started',
21
+	'settlement_pending',
22
+	'settlement_paid',
23
+	'completed'
24
+]
25
+
26
+const AUDIT_CODES = ['enterprise_audit', 'review']
27
+const AUDIT_TITLES = ['企业审核']
28
+const WORK_CODES = ['work_started', 'work']
29
+const WORK_TITLES = ['开始用工']
30
+const CONTRACT_CODES = ['contract_signing']
31
+const CONTRACT_TITLES = ['合同签署', '合同', '签约']
32
+const SETTLE_PENDING_CODES = ['settlement_pending']
33
+const SETTLE_PENDING_TITLES = ['待结算']
34
+const SETTLE_PAID_CODES = ['settlement_paid']
35
+const SETTLE_PAID_TITLES = ['已支付']
36
+const COMPLETED_CODES = ['completed']
37
+const COMPLETED_TITLES = ['已完成', '办结', '完税']
38
+
39
+function matchStep(steps, codes, titles) {
40
+	const list = Array.isArray(steps) ? steps : []
41
+	for (let i = 0; i < list.length; i++) {
42
+		const s = list[i] || {}
43
+		const key = s.key == null ? '' : String(s.key)
44
+		const title = s.title == null ? '' : String(s.title)
45
+		if (codes.indexOf(key) >= 0) return s
46
+		for (let j = 0; j < titles.length; j++) {
47
+			if (title.indexOf(titles[j]) >= 0) return s
48
+		}
49
+	}
50
+	return null
51
+}
52
+
53
+function isDone(step) {
54
+	return !!(step && step.status === 'done')
55
+}
56
+
57
+function isActive(step) {
58
+	return !!(step && step.status === 'active')
59
+}
60
+
61
+function isPending(step) {
62
+	return !!(step && step.status === 'pending')
63
+}
64
+
65
+function stepIndex(code) {
66
+	if (!code) return -1
67
+	return STEP_ORDER.indexOf(String(code))
68
+}
69
+
70
+function findActiveStepCode(steps) {
71
+	const list = Array.isArray(steps) ? steps : []
72
+	for (let i = 0; i < list.length; i++) {
73
+		const s = list[i]
74
+		if (s && s.status === 'active' && s.key) return String(s.key)
75
+	}
76
+	return ''
77
+}
78
+
79
+/**
80
+ * @param {Array} steps
81
+ * @param {string} [currentStepCode] 订单当前步骤(优先)
82
+ * @returns {{ key: string, label: string, disabled?: boolean }}
83
+ */
84
+export function resolveProgressFooterAction(steps, currentStepCode) {
85
+	const code = (currentStepCode && String(currentStepCode)) || findActiveStepCode(steps)
86
+	const idx = stepIndex(code)
87
+
88
+	// —— 优先按订单当前步骤 ——
89
+	if (code === 'completed' || idx === stepIndex('completed')) {
90
+		return { key: 'done', label: '已办结', disabled: true }
91
+	}
92
+	if (code === 'settlement_paid') {
93
+		return { key: 'complete', label: '开票办结' }
94
+	}
95
+	if (code === 'settlement_pending' || code === 'work_started') {
96
+		return { key: 'settle', label: '发起结算' }
97
+	}
98
+	// 企业审核已过后进入合同签署:应显示开始用工
99
+	if (code === 'contract_signing') {
100
+		return { key: 'startWork', label: '开始用工' }
101
+	}
102
+
103
+	// —— steps 兜底(无 current_step_code 或编码不在标准列表时)——
104
+	const audit = matchStep(steps, AUDIT_CODES, AUDIT_TITLES)
105
+	const work = matchStep(steps, WORK_CODES, WORK_TITLES)
106
+	const contract = matchStep(steps, CONTRACT_CODES, CONTRACT_TITLES)
107
+	const settlePending = matchStep(steps, SETTLE_PENDING_CODES, SETTLE_PENDING_TITLES)
108
+	const settlePaid = matchStep(steps, SETTLE_PAID_CODES, SETTLE_PAID_TITLES)
109
+	const completed = matchStep(steps, COMPLETED_CODES, COMPLETED_TITLES)
110
+
111
+	if (isDone(completed) || isActive(completed)) {
112
+		return { key: 'done', label: '已办结', disabled: true }
113
+	}
114
+	if (isActive(settlePaid)) {
115
+		return { key: 'complete', label: '开票办结' }
116
+	}
117
+	if (isActive(settlePending) || isActive(work)) {
118
+		return { key: 'settle', label: '发起结算' }
119
+	}
120
+	if (isDone(work)) {
121
+		return { key: 'settle', label: '发起结算' }
122
+	}
123
+
124
+	// 合同签署进行中 / 已完成但未开工 → 开始用工
125
+	if (isActive(contract) || isDone(contract)) {
126
+		if (!work || isPending(work)) {
127
+			return { key: 'startWork', label: '开始用工' }
128
+		}
129
+	}
130
+
131
+	// 企业审核已完成,且尚未进入开始用工 → 开始用工
132
+	if (isDone(audit)) {
133
+		if (!work || isPending(work)) {
134
+			return { key: 'startWork', label: '开始用工' }
135
+		}
136
+		return { key: 'startWork', label: '开始用工' }
137
+	}
138
+
139
+	// 已过企业审核节点(current 编码在其后)但上面未命中
140
+	if (idx > stepIndex('enterprise_audit') && idx < stepIndex('work_started')) {
141
+		return { key: 'startWork', label: '开始用工' }
142
+	}
143
+
144
+	return { key: 'urge', label: '催办未登记' }
145
+}
146
+
147
+export function formatMoney(value) {
148
+	if (value == null || value === '') return '0.00'
149
+	const n = Number(value)
150
+	if (Number.isNaN(n)) return String(value)
151
+	return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
152
+}
153
+
154
+export function pickSettlementAmount(summary) {
155
+	if (!summary || typeof summary !== 'object') return null
156
+	const total = summary.totalOutflow != null
157
+		? summary.totalOutflow
158
+		: (summary.total_outflow != null ? summary.total_outflow : summary.netAmount || summary.net_amount)
159
+	return total
160
+}

+ 206 - 0
huimv-employment/app/utils/progress-order-actions.js

@@ -0,0 +1,206 @@
1
+/**
2
+ * 进度弹层底部:开工 / 发起结算(含确认支付)/ 开票办结
3
+ * 供 FeProgressSheet / FeConversationProgressSheet 混入使用
4
+ */
5
+import { showToast } from '@/common/chat-data.js'
6
+import {
7
+	getStartWorkStatus,
8
+	startWork,
9
+	startSettlement,
10
+	confirmPaySettlement,
11
+	completeOrder
12
+} from '@/api/order.js'
13
+import {
14
+	resolveProgressFooterAction,
15
+	formatMoney,
16
+	pickSettlementAmount
17
+} from '@/utils/progress-action.js'
18
+
19
+function modalConfirm(title, content, options = {}) {
20
+	return new Promise((resolve) => {
21
+		uni.showModal({
22
+			title: title || '提示',
23
+			content: content || '',
24
+			confirmText: options.confirmText || '确定',
25
+			cancelText: options.cancelText || '取消',
26
+			success(res) {
27
+				resolve(!!(res && res.confirm))
28
+			},
29
+			fail() {
30
+				resolve(false)
31
+			}
32
+		})
33
+	})
34
+}
35
+
36
+/** 是否因「计划结束日未到」被拦截 */
37
+function isBeforeEndDateError(err) {
38
+	const msg = (err && (err.msg || err.message)) || ''
39
+	return /计划结束日|尚未到期|结束日|allow_before_end_date|提前结算/.test(msg)
40
+}
41
+
42
+function extractWorkEndDate(err) {
43
+	const msg = (err && (err.msg || err.message)) || ''
44
+	const m = msg.match(/计划结束日[((]([^))]+)[))]/)
45
+	return m && m[1] ? m[1] : ''
46
+}
47
+
48
+export default {
49
+	data() {
50
+		return {
51
+			footerActing: false
52
+		}
53
+	},
54
+	methods: {
55
+		getFooterAction(detail) {
56
+			if (!detail) {
57
+				return { key: 'urge', label: '催办未登记' }
58
+			}
59
+			return resolveProgressFooterAction(detail.steps, detail.currentStepCode)
60
+		},
61
+		requireOrderId(detail) {
62
+			const orderId = detail && detail.orderId
63
+			if (orderId == null || orderId === '') {
64
+				showToast('缺少订单信息')
65
+				return null
66
+			}
67
+			return orderId
68
+		},
69
+		async onFooterAction(detail, onSuccess) {
70
+			if (this.footerActing) return
71
+			const action = this.getFooterAction(detail)
72
+			if (!action || action.disabled) return
73
+			if (action.key === 'urge') {
74
+				showToast('已发送催办')
75
+				return
76
+			}
77
+			const orderId = this.requireOrderId(detail)
78
+			if (orderId == null) return
79
+
80
+			this.footerActing = true
81
+			try {
82
+				if (action.key === 'startWork') {
83
+					await this.runStartWork(orderId)
84
+				} else if (action.key === 'settle') {
85
+					await this.runSettleAndPay(orderId)
86
+				} else if (action.key === 'complete') {
87
+					await this.runComplete(orderId)
88
+				}
89
+				if (typeof onSuccess === 'function') await onSuccess()
90
+			} catch (e) {
91
+				// API showError 已提示;此处兜底
92
+				if (e && e.__handled) return
93
+			} finally {
94
+				this.footerActing = false
95
+			}
96
+		},
97
+		async runStartWork(orderId) {
98
+			let status = null
99
+			try {
100
+				status = await getStartWorkStatus(orderId, { showError: false })
101
+			} catch (e) {
102
+				// 状态查询失败仍尝试开工
103
+			}
104
+			const allSigned = status && (status.allApprovedSigned === true || status.all_approved_signed === true)
105
+			const understaffed = status && (status.understaffed === true)
106
+			const canStart = status && (status.canStartWork === true || status.can_start_work === true)
107
+			const msg = (status && status.message) || ''
108
+
109
+			if (status && allSigned === false) {
110
+				showToast(msg || '仍有待签合同,暂不可开工')
111
+				const err = new Error(msg || '不可开工')
112
+				err.__handled = true
113
+				throw err
114
+			}
115
+
116
+			let allowUnderstaffed = false
117
+			if (understaffed || (status && !canStart && msg)) {
118
+				const ok = await modalConfirm(
119
+					'确认开工',
120
+					msg || '已签人数少于计划人数,是否缺编开工?'
121
+				)
122
+				if (!ok) {
123
+					const err = new Error('已取消')
124
+					err.__handled = true
125
+					throw err
126
+				}
127
+				allowUnderstaffed = true
128
+			} else {
129
+				const ok = await modalConfirm('确认开工', '确认后订单将进入用工中,是否继续?')
130
+				if (!ok) {
131
+					const err = new Error('已取消')
132
+					err.__handled = true
133
+					throw err
134
+				}
135
+			}
136
+
137
+			const res = await startWork(orderId, { allowUnderstaffed }, { showError: true })
138
+			showToast((res && res.message) || '已确认开工')
139
+		},
140
+		async runSettleAndPay(orderId) {
141
+			// 1) 先按正常规则发起(未到计划结束日会被后端拦截)
142
+			let summary = null
143
+			let allowBeforeEndDate = false
144
+			try {
145
+				summary = await startSettlement(orderId, { allowBeforeEndDate: false }, { showError: false })
146
+			} catch (e) {
147
+				if (!isBeforeEndDateError(e)) {
148
+					const msg = (e && (e.msg || e.message)) || '发起结算失败'
149
+					showToast(msg)
150
+					const err = e || new Error(msg)
151
+					err.__handled = true
152
+					throw err
153
+				}
154
+
155
+				// 2) 计划结束日未到 → 提示是否继续
156
+				const endDate = extractWorkEndDate(e)
157
+				const tip = endDate
158
+					? `计划结束日(${endDate})尚未到,用工还没有结束。是否继续发起结算?`
159
+					: '计划结束日尚未到,用工还没有结束。是否继续发起结算?'
160
+				const ok = await modalConfirm('计划尚未结束', tip, {
161
+					confirmText: '继续结算',
162
+					cancelText: '取消'
163
+				})
164
+				if (!ok) {
165
+					const err = new Error('已取消')
166
+					err.__handled = true
167
+					throw err
168
+				}
169
+
170
+				// 3) 用户确认继续 → allow_before_end_date=true
171
+				allowBeforeEndDate = true
172
+				summary = await startSettlement(
173
+					orderId,
174
+					{ allowBeforeEndDate: true },
175
+					{ showError: true }
176
+				)
177
+			}
178
+
179
+			const amount = pickSettlementAmount(summary)
180
+			const amountText = amount != null ? `应付总额 ¥${formatMoney(amount)}\n` : ''
181
+			const earlyTip = allowBeforeEndDate ? '(提前结算)\n' : ''
182
+			const payOk = await modalConfirm(
183
+				'确认结算并支付',
184
+				`${earlyTip}${amountText}确认后将完成支付,是否继续?`,
185
+				{ confirmText: '确认支付', cancelText: '稍后' }
186
+			)
187
+			if (!payOk) {
188
+				showToast('已发起结算,可稍后确认支付')
189
+				return
190
+			}
191
+
192
+			const paid = await confirmPaySettlement(orderId, { paymentChannel: 'wechat' }, { showError: true })
193
+			showToast((paid && paid.message) || '结算支付完成')
194
+		},
195
+		async runComplete(orderId) {
196
+			const ok = await modalConfirm('开票办结', '确认已完税并开票,办结本订单?')
197
+			if (!ok) {
198
+				const err = new Error('已取消')
199
+				err.__handled = true
200
+				throw err
201
+			}
202
+			const res = await completeOrder(orderId, { taxCleared: true, invoiceIssued: true }, { showError: true })
203
+			showToast((res && res.message) || '已办结')
204
+		}
205
+	}
206
+}

+ 26 - 7
huimv-employment/app/utils/registration-batch.js

@@ -26,6 +26,7 @@ export function mapRegistrationBatchDetail(raw = {}) {
26 26
 		subtitle: raw.subtitle || '',
27 27
 		orderId: raw.orderId != null ? raw.orderId : raw.order_id,
28 28
 		orderNo: raw.orderNo || raw.order_no || '',
29
+		currentStepCode: raw.currentStepCode || raw.current_step_code || '',
29 30
 		status: raw.status || '',
30 31
 		qrToken: raw.qrToken || raw.qr_token || '',
31 32
 		qrCodeUrl: raw.qrCodeUrl || raw.qr_code_url || '',
@@ -51,16 +52,27 @@ export function mapRegistrationBatchDetail(raw = {}) {
51 52
 		})),
52 53
 		workers: workers.map((w, i) => {
53 54
 			const name = w.realName || w.real_name || '—'
55
+			const registrationId = w.registrationId != null
56
+				? w.registrationId
57
+				: (w.registration_id != null ? w.registration_id : null)
58
+			const regStatus = w.regStatus || w.reg_status || ''
59
+			const status = mapWorkerStatusTag(w.statusTag || w.status_tag || regStatus)
60
+			const submittedAt = w.submittedAt || w.submitted_at || ''
54 61
 			return {
55
-				msgKey: w.registrationId != null
56
-					? w.registrationId
57
-					: (w.registration_id != null ? w.registration_id : i),
62
+				msgKey: registrationId != null ? registrationId : i,
63
+				registrationId,
64
+				workerId: w.workerId != null ? w.workerId : w.worker_id,
58 65
 				name,
59 66
 				avatarText: String(name).charAt(0) || '工',
60 67
 				phone: w.mobileMask || w.mobile_mask || '',
61
-				status: mapWorkerStatusTag(w.statusTag || w.status_tag || w.regStatus || w.reg_status),
68
+				workType: w.confirmedWorkType || w.confirmed_work_type || '',
69
+				regStatus,
70
+				status,
62 71
 				statusText: w.statusText || w.status_text || '',
63
-				failReason: w.failReason || w.fail_reason || ''
72
+				failReason: w.failReason || w.fail_reason || '',
73
+				submittedAt,
74
+				submittedAtText: formatSubmittedAt(submittedAt),
75
+				canReview: status === 'review' || regStatus === 'under_review'
64 76
 			}
65 77
 		})
66 78
 	}
@@ -76,12 +88,19 @@ function num(a, b, fallback) {
76 88
 function mapWorkerStatusTag(tag) {
77 89
 	const t = tag == null ? '' : String(tag)
78 90
 	if (t === 'done' || t === 'completed') return 'done'
79
-	if (t === 'fail' || t === 'verify_failed') return 'fail'
91
+	if (t === 'fail' || t === 'verify_failed' || t === 'rejected') return 'fail'
80 92
 	if (t === 'review' || t === 'under_review') return 'review'
81
-	if (t === 'registering' || t === 'in_progress') return 'review'
93
+	if (t === 'contract' || t === 'contract_signing') return 'pending'
94
+	if (t === 'registering' || t === 'in_progress') return 'pending'
82 95
 	return 'pending'
83 96
 }
84 97
 
98
+function formatSubmittedAt(value) {
99
+	if (value == null || value === '') return ''
100
+	const s = String(value).replace('T', ' ')
101
+	return s.length >= 16 ? s.slice(0, 16) : s
102
+}
103
+
85 104
 /**
86 105
  * 会话内草稿进度合集(接口未就绪时的 mock)
87 106
  * @param {Array<{ draftId?: number|string, title?: string, batchNo?: string }>} [seeds]

+ 51 - 0
huimv-employment/app/utils/settlement.js

@@ -0,0 +1,51 @@
1
+/**
2
+ * 结算单摘要映射
3
+ */
4
+import { formatMoney } from '@/utils/progress-action.js'
5
+
6
+export function mapSettlementSummary(raw = {}) {
7
+	const feesRaw = Array.isArray(raw.fees) ? raw.fees : []
8
+	const fees = feesRaw.map((f, i) => ({
9
+		msgKey: f.feeCode || f.fee_code || i,
10
+		name: f.feeName || f.fee_name || '费用',
11
+		amount: f.amount,
12
+		amountText: `¥${formatMoney(f.amount)}`
13
+	}))
14
+
15
+	const net = raw.netAmount != null ? raw.netAmount : raw.net_amount
16
+	const deduction = raw.deductionAmount != null ? raw.deductionAmount : raw.deduction_amount
17
+	const gross = raw.grossAmount != null ? raw.grossAmount : raw.gross_amount
18
+	const total = raw.totalOutflow != null ? raw.totalOutflow : raw.total_outflow
19
+	const status = raw.settlementStatus || raw.settlement_status || ''
20
+
21
+	return {
22
+		settlementId: raw.settlementId != null ? raw.settlementId : raw.settlement_id,
23
+		settlementNo: raw.settlementNo || raw.settlement_no || '',
24
+		orderId: raw.orderId != null ? raw.orderId : raw.order_id,
25
+		orderNo: raw.orderNo || raw.order_no || '',
26
+		currentStepCode: raw.currentStepCode || raw.current_step_code || '',
27
+		settlementStatus: status,
28
+		statusLabel: status === 'paid' ? '已支付' : '待支付',
29
+		statusClass: status === 'paid' ? 'success' : 'danger',
30
+		workerCount: raw.workerCount != null ? raw.workerCount : raw.worker_count,
31
+		netAmount: net,
32
+		deductionAmount: deduction,
33
+		grossAmount: gross,
34
+		totalOutflow: total,
35
+		netText: `¥${formatMoney(net)}`,
36
+		deductionText: `-¥${formatMoney(deduction)}`,
37
+		grossText: `¥${formatMoney(gross)}`,
38
+		totalText: `¥${formatMoney(total)}`,
39
+		sourceTitle: buildSourceTitle(raw),
40
+		fees,
41
+		canPay: status !== 'paid' && status !== 'cancelled',
42
+		message: raw.message || ''
43
+	}
44
+}
45
+
46
+function buildSourceTitle(raw) {
47
+	const no = raw.orderNo || raw.order_no || ''
48
+	const settlementNo = raw.settlementNo || raw.settlement_no || ''
49
+	if (no && settlementNo) return `${no} · ${settlementNo}`
50
+	return no || settlementNo || '结算单'
51
+}

+ 192 - 0
huimv-employment/app/utils/wechat-si.js

@@ -0,0 +1,192 @@
1
+/**
2
+ * 微信同声传译插件(WechatSI)语音识别封装
3
+ * 仅 MP-WEIXIN 可用;需在 manifest.json / app.json 声明插件
4
+ */
5
+
6
+const MAX_DURATION_MS = 60000
7
+const LANG = 'zh_CN'
8
+
9
+let manager = null
10
+let inited = false
11
+
12
+function getPluginManager() {
13
+	// #ifdef MP-WEIXIN
14
+	if (manager) return manager
15
+	try {
16
+		// eslint-disable-next-line no-undef
17
+		const plugin = requirePlugin('WechatSI')
18
+		if (!plugin || typeof plugin.getRecordRecognitionManager !== 'function') {
19
+			return null
20
+		}
21
+		manager = plugin.getRecordRecognitionManager()
22
+		return manager
23
+	} catch (e) {
24
+		console.error('[WechatSI] requirePlugin fail', e)
25
+		return null
26
+	}
27
+	// #endif
28
+	// #ifndef MP-WEIXIN
29
+	return null
30
+	// #endif
31
+}
32
+
33
+/**
34
+ * 当前环境是否可用微信同声传译
35
+ */
36
+export function isWechatSIAvailable() {
37
+	// #ifdef MP-WEIXIN
38
+	return !!getPluginManager()
39
+	// #endif
40
+	// #ifndef MP-WEIXIN
41
+	return false
42
+	// #endif
43
+}
44
+
45
+/**
46
+ * 确保已授权录音
47
+ * @returns {Promise<boolean>}
48
+ */
49
+export function ensureRecordAuth() {
50
+	return new Promise((resolve) => {
51
+		// #ifndef MP-WEIXIN
52
+		resolve(false)
53
+		return
54
+		// #endif
55
+		uni.getSetting({
56
+			success(res) {
57
+				const auth = res.authSetting || {}
58
+				if (auth['scope.record']) {
59
+					resolve(true)
60
+					return
61
+				}
62
+				uni.authorize({
63
+					scope: 'scope.record',
64
+					success() {
65
+						resolve(true)
66
+					},
67
+					fail() {
68
+						uni.showModal({
69
+							title: '需要麦克风权限',
70
+							content: '请在设置中开启麦克风,以便语音转文字',
71
+							confirmText: '去设置',
72
+							success(modalRes) {
73
+								if (modalRes.confirm) {
74
+									uni.openSetting({
75
+										success(settingRes) {
76
+											const ok = !!(settingRes.authSetting && settingRes.authSetting['scope.record'])
77
+											resolve(ok)
78
+										},
79
+										fail() {
80
+											resolve(false)
81
+										}
82
+									})
83
+								} else {
84
+									resolve(false)
85
+								}
86
+							}
87
+						})
88
+					}
89
+				})
90
+			},
91
+			fail() {
92
+				resolve(false)
93
+			}
94
+		})
95
+	})
96
+}
97
+
98
+/**
99
+ * 初始化识别回调(全局只绑一次,由业务侧传入最新 handlers)
100
+ * @param {{
101
+ *   onStart?: Function,
102
+ *   onRecognize?: Function,
103
+ *   onStop?: Function,
104
+ *   onError?: Function
105
+ * }} handlers
106
+ */
107
+export function initRecordRecognition(handlers = {}) {
108
+	const m = getPluginManager()
109
+	if (!m) return false
110
+
111
+	m.onStart = (res) => {
112
+		if (typeof handlers.onStart === 'function') handlers.onStart(res)
113
+	}
114
+	// 部分版本支持中间结果
115
+	m.onRecognize = (res) => {
116
+		if (typeof handlers.onRecognize === 'function') handlers.onRecognize(res)
117
+	}
118
+	m.onStop = (res) => {
119
+		if (typeof handlers.onStop === 'function') handlers.onStop(res)
120
+	}
121
+	m.onError = (res) => {
122
+		if (typeof handlers.onError === 'function') handlers.onError(res)
123
+	}
124
+	inited = true
125
+	return true
126
+}
127
+
128
+/**
129
+ * 开始录音识别,最长 60s
130
+ * @param {{ duration?: number, lang?: string }} [options]
131
+ */
132
+export function startRecordRecognition(options = {}) {
133
+	const m = getPluginManager()
134
+	if (!m) {
135
+		return Promise.reject(new Error('当前环境不支持语音识别'))
136
+	}
137
+	if (!inited) {
138
+		return Promise.reject(new Error('语音识别未初始化'))
139
+	}
140
+	const duration = Math.min(
141
+		MAX_DURATION_MS,
142
+		Math.max(1000, Number(options.duration) || MAX_DURATION_MS)
143
+	)
144
+	const lang = options.lang || LANG
145
+	try {
146
+		m.start({ duration, lang })
147
+		return Promise.resolve(true)
148
+	} catch (e) {
149
+		return Promise.reject(e)
150
+	}
151
+}
152
+
153
+/**
154
+ * 结束录音识别
155
+ */
156
+export function stopRecordRecognition() {
157
+	const m = getPluginManager()
158
+	if (!m) return
159
+	try {
160
+		m.stop()
161
+	} catch (e) {
162
+		console.warn('[WechatSI] stop fail', e)
163
+	}
164
+}
165
+
166
+/**
167
+ * 将插件错误码转为中文提示
168
+ */
169
+export function formatSiError(err) {
170
+	const code = err && (err.retcode != null ? err.retcode : err.errCode)
171
+	const msg = (err && (err.msg || err.errMsg || err.message)) || ''
172
+	const map = {
173
+		'-30001': '录音失败,请检查麦克风权限',
174
+		'-30002': '录音已中断',
175
+		'-30003': '录音数据发送失败',
176
+		'-30004': '未获取到识别结果,请重试',
177
+		'-30005': '语音识别服务异常',
178
+		'-30006': '识别超时,请缩短说话时间后重试',
179
+		'-30007': '启动参数错误',
180
+		'-30008': '网络异常,请稍后重试',
181
+		'-30009': '鉴权失败',
182
+		'-30010': '鉴权网络失败',
183
+		'-30011': '正在识别中,请稍候',
184
+		'-30012': '当前没有进行中的识别',
185
+		'-30013': '识别失败,请重试',
186
+		'-40001': '调用过于频繁,请稍后再试'
187
+	}
188
+	const key = code != null ? String(code) : ''
189
+	return map[key] || msg || '语音识别失败'
190
+}
191
+
192
+export const WECHAT_SI_MAX_DURATION_MS = MAX_DURATION_MS