xsh_1997 пре 2 недеља
родитељ
комит
ff8046fb32
28 измењених фајлова са 2485 додато и 189 уклоњено
  1. 20 0
      huimv-employment/app/api/conversation.js
  2. 16 1
      huimv-employment/app/api/draft.js
  3. 10 0
      huimv-employment/app/api/registration-batch.js
  4. 20 0
      huimv-employment/app/api/worker-registration.js
  5. 11 0
      huimv-employment/app/common/config.js
  6. 1 1
      huimv-employment/app/manifest.json
  7. 70 4
      huimv-employment/app/packageA/components/chat/FeBatchQrModal.vue
  8. 27 0
      huimv-employment/app/packageA/components/chat/FeChatMarkdown.vue
  9. 269 0
      huimv-employment/app/packageA/components/chat/FeConversationCostSheet.vue
  10. 336 0
      huimv-employment/app/packageA/components/chat/FeConversationProgressSheet.vue
  11. 200 28
      huimv-employment/app/packageA/components/chat/FeDraftSheet.vue
  12. 90 19
      huimv-employment/app/packageA/components/chat/FeProgressSheet.vue
  13. 640 65
      huimv-employment/app/packageA/components/home/EnterpriseHome.vue
  14. 1 1
      huimv-employment/app/packageA/home/index.vue
  15. 1 1
      huimv-employment/app/pages/index/index.vue
  16. 60 16
      huimv-employment/app/pages/worker/job-offer.vue
  17. 10 12
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/hz-novice-guidance.vue
  18. 7 0
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/index.js
  19. 10 7
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useArrow.js
  20. 1 1
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useGetDomInfo.js
  21. 8 6
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useStepTips.js
  22. 80 8
      huimv-employment/app/utils/draft.js
  23. 116 6
      huimv-employment/app/utils/job-offer.js
  24. 144 0
      huimv-employment/app/utils/markdown.js
  25. 148 0
      huimv-employment/app/utils/registration-batch.js
  26. 2 0
      huimv-employment/app/utils/request.js
  27. 74 2
      huimv-employment/app/utils/sse-parse.js
  28. 113 11
      huimv-employment/app/utils/sse-stream.js

+ 20 - 0
huimv-employment/app/api/conversation.js

@@ -46,6 +46,26 @@ export function listConversationMessages(conversationId, params = {}, options =
46 46
 	return get(`/conversations/${conversationId}/messages`, params, options)
47 47
 }
48 48
 
49
+/**
50
+ * 按会话查询全部草稿及成本测算
51
+ * GET /api/v1/mp/conversations/{id}/drafts/cost-comparison
52
+ * 返回 DraftWithCostComparisonResponse[]:草稿详情 + cost_comparison(可为 null)
53
+ * @param {number} conversationId
54
+ */
55
+export function listConversationDraftCostComparisons(conversationId, options = {}) {
56
+	return get(`/conversations/${conversationId}/drafts/cost-comparison`, {}, options)
57
+}
58
+
59
+/**
60
+ * 按会话查询全部草稿登记进度
61
+ * GET /api/v1/mp/conversations/{id}/drafts/registration-progress
62
+ * @param {number} conversationId
63
+ * @returns {Promise<Array>} RegistrationBatchDetailResponse[]
64
+ */
65
+export function listConversationDraftRegistrationProgress(conversationId, options = {}) {
66
+	return get(`/conversations/${conversationId}/drafts/registration-progress`, {}, options)
67
+}
68
+
49 69
 /**
50 70
  * 会话内 AI 对话(SSE 流式 + 服务端落库)
51 71
  * @param {{

+ 16 - 1
huimv-employment/app/api/draft.js

@@ -1,7 +1,8 @@
1 1
 import { get, put, post } from '@/utils/request.js'
2
+import { PUBLIC_BASE_API } from '@/common/config.js'
2 3
 
3 4
 /**
4
- * 查询用工草稿详情
5
+ * 查询用工草稿详情(登录态)
5 6
  * GET /api/v1/mp/drafts/{id}
6 7
  * @param {number} draftId
7 8
  */
@@ -9,6 +10,20 @@ export function getDraftDetail(draftId, options = {}) {
9 10
 	return get(`/drafts/${draftId}`, {}, options)
10 11
 }
11 12
 
13
+/**
14
+ * 公开查询用工草稿详情(无需登录)
15
+ * GET /api/v1/public/drafts?scene=ord_123
16
+ * @param {string} scene 小程序码 / 分享 scene(订单编号,如 ord_7)
17
+ */
18
+export function getPublicDraftDetail(scene, options = {}) {
19
+	const s = scene == null ? '' : String(scene).trim()
20
+	const base = (PUBLIC_BASE_API || '').replace(/\/$/, '')
21
+	if (!base) {
22
+		return Promise.reject(new Error('PUBLIC_BASE_API is empty'))
23
+	}
24
+	return get(`${base}/drafts`, { scene: s }, { auth: false, ...options })
25
+}
26
+
12 27
 /**
13 28
  * 更新用工草稿(仅 pending 可编辑,传需修改字段)
14 29
  * PUT /api/v1/mp/drafts/{id}

+ 10 - 0
huimv-employment/app/api/registration-batch.js

@@ -0,0 +1,10 @@
1
+import { get } from '@/utils/request.js'
2
+
3
+/**
4
+ * 查询登记批次详情(进度管理)
5
+ * GET /api/v1/mp/registration-batches?draft_id={id}
6
+ * @param {number|string} draftId
7
+ */
8
+export function getRegistrationBatchDetail(draftId, options = {}) {
9
+	return get('/registration-batches', { draft_id: draftId }, options)
10
+}

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

@@ -0,0 +1,20 @@
1
+import { post } from '@/utils/request.js'
2
+
3
+/**
4
+ * 申请加入用工邀请
5
+ * POST /api/v1/mp/worker-registration/apply
6
+ * 需临时工 JWT;body.scene 为小程序码 / qrToken(订单编号)
7
+ * @param {{ scene: string, confirmedWorkType?: string }} data
8
+ */
9
+export function applyWorkerRegistration(data = {}, options = {}) {
10
+	const scene = data.scene == null ? '' : String(data.scene).trim()
11
+	const confirmedWorkType = data.confirmedWorkType == null && data.confirmed_work_type == null
12
+		? ''
13
+		: String(data.confirmedWorkType || data.confirmed_work_type || '').trim()
14
+	const body = { scene }
15
+	if (confirmedWorkType) {
16
+		body.confirmedWorkType = confirmedWorkType
17
+		body.confirmed_work_type = confirmedWorkType
18
+	}
19
+	return post('/worker-registration/apply', body, options)
20
+}

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

@@ -18,6 +18,17 @@ function resolveBaseApi() {
18 18
 /** API 基础路径 */
19 19
 export const BASE_API = resolveBaseApi()
20 20
 
21
+/**
22
+ * 公开 API 前缀:/api/v1/mp → /api/v1/public
23
+ * 公开草稿详情等不走 JWT,也不能拼在 mp 前缀下。
24
+ */
25
+export const PUBLIC_BASE_API = (() => {
26
+	const base = (BASE_API || '').replace(/\/$/, '')
27
+	if (!base) return ''
28
+	if (/\/mp$/i.test(base)) return base.replace(/\/mp$/i, '/public')
29
+	return `${base}/public`
30
+})()
31
+
21 32
 /**
22 33
  * 将后端返回的相对资源路径(如登记二维码)拼成可访问 URL。
23 34
  * 与接口同主机:BASE_API=http://host:port/api/v1/mp → 资源 /api/v1/public/... → http://host:port/api/v1/public/...

+ 1 - 1
huimv-employment/app/manifest.json

@@ -56,7 +56,7 @@
56 56
         },
57 57
         "usingComponents" : true,
58 58
         "optimization" : {
59
-            "subPackages" : true
59
+            "subPackages" : false
60 60
         }
61 61
     },
62 62
     "mp-alipay" : {

+ 70 - 4
huimv-employment/app/packageA/components/chat/FeBatchQrModal.vue

@@ -69,6 +69,8 @@
69 69
 <script>
70 70
 import { BATCH_QR, QR_DEMO_PATTERN, showToast } from '@/common/chat-data.js'
71 71
 import { resolveApiAssetUrl } from '@/common/config.js'
72
+import { getRegistrationBatchDetail } from '@/api/registration-batch.js'
73
+import { mapRegistrationBatchDetail } from '@/utils/registration-batch.js'
72 74
 
73 75
 export default {
74 76
 	name: 'FeBatchQrModal',
@@ -81,11 +83,17 @@ export default {
81 83
 			}
82 84
 		}
83 85
 	},
86
+	data() {
87
+		return {
88
+			progressLoading: false,
89
+			progressCurrent: null,
90
+			progressTotal: null
91
+		}
92
+	},
84 93
 	computed: {
85 94
 		qrImageUrl() {
86 95
 			const raw = this.batch.qrCodeUrl
87 96
 				|| this.batch.qr_code_url
88
-				|| this.batch.ar_code_url
89 97
 				|| ''
90 98
 			return resolveApiAssetUrl(raw)
91 99
 		},
@@ -99,15 +107,73 @@ export default {
99 107
 			if (title && id) return `${title} ${id}`
100 108
 			return title || id || ''
101 109
 		},
110
+		registeredCount() {
111
+			if (this.progressCurrent != null) return this.progressCurrent
112
+			return this.batch.registered != null ? Number(this.batch.registered) : 0
113
+		},
114
+		totalCount() {
115
+			if (this.progressTotal != null) return this.progressTotal
116
+			return this.batch.total != null ? Number(this.batch.total) : 0
117
+		},
102 118
 		progressText() {
103
-			return `${this.batch.registered}/${this.batch.total}`
119
+			if (this.progressLoading && this.progressCurrent == null) return '加载中...'
120
+			return `${this.registeredCount}/${this.totalCount}`
104 121
 		},
105 122
 		progressPercent() {
106
-			if (!this.batch.total) return 0
107
-			return Math.round((this.batch.registered / this.batch.total) * 100)
123
+			if (!this.totalCount) return 0
124
+			return Math.round((this.registeredCount / this.totalCount) * 100)
125
+		},
126
+		draftId() {
127
+			return this.batch.draftId != null ? this.batch.draftId : null
128
+		}
129
+	},
130
+	watch: {
131
+		active(val) {
132
+			if (val) this.fetchProgress()
133
+			else this.resetProgress()
134
+		},
135
+		draftId() {
136
+			if (this.active) this.fetchProgress()
108 137
 		}
109 138
 	},
110 139
 	methods: {
140
+		resetProgress() {
141
+			this.progressLoading = false
142
+			this.progressCurrent = null
143
+			this.progressTotal = null
144
+		},
145
+		async fetchProgress() {
146
+			const draftId = this.draftId
147
+			if (draftId == null || draftId === '') {
148
+				this.resetProgress()
149
+				return
150
+			}
151
+			this.progressLoading = true
152
+			try {
153
+				const raw = await getRegistrationBatchDetail(draftId, { showError: false })
154
+				const detail = mapRegistrationBatchDetail(raw)
155
+				const current = detail.progress && detail.progress.current != null
156
+					? detail.progress.current
157
+					: 0
158
+				const total = detail.progress && detail.progress.total != null
159
+					? detail.progress.total
160
+					: (detail.stats && detail.stats.expected) || 0
161
+				this.progressCurrent = current
162
+				this.progressTotal = total
163
+				this.$emit('progress-loaded', {
164
+					draftId,
165
+					registered: current,
166
+					total,
167
+					detail
168
+				})
169
+			} catch (e) {
170
+				// 保留 batch 兜底数字,不打断弹框
171
+				this.progressCurrent = null
172
+				this.progressTotal = null
173
+			} finally {
174
+				this.progressLoading = false
175
+			}
176
+		},
111 177
 		onSave() {
112 178
 			const url = this.qrImageUrl
113 179
 			if (!url) {

+ 27 - 0
huimv-employment/app/packageA/components/chat/FeChatMarkdown.vue

@@ -0,0 +1,27 @@
1
+<template>
2
+	<rich-text class="fe-chat-md" :nodes="nodes" user-select />
3
+</template>
4
+
5
+<script>
6
+import { markdownToHtml } from '@/utils/markdown.js'
7
+
8
+export default {
9
+	name: 'FeChatMarkdown',
10
+	props: {
11
+		content: { type: String, default: '' }
12
+	},
13
+	computed: {
14
+		nodes() {
15
+			return markdownToHtml(this.content)
16
+		}
17
+	}
18
+}
19
+</script>
20
+
21
+<style lang="scss" scoped>
22
+.fe-chat-md {
23
+	width: 100%;
24
+	max-width: 100%;
25
+	overflow: hidden;
26
+}
27
+</style>

+ 269 - 0
huimv-employment/app/packageA/components/chat/FeConversationCostSheet.vue

@@ -0,0 +1,269 @@
1
+<template>
2
+	<view class="fe-bottom-sheet fe-bottom-sheet--draft" :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
8
+					v-if="currentDraft"
9
+					class="fe-draft-card-status"
10
+					:class="'fe-draft-card-status--' + currentStatusTone"
11
+				>{{ currentStatusBadge }}</text>
12
+				<text v-if="draftItems.length > 1" class="fe-conv-cost-hint">左右滑动切换草稿</text>
13
+			</view>
14
+		</view>
15
+
16
+		<view v-if="loading" class="fe-conv-cost-empty">
17
+			<text class="fe-conv-cost-empty__text">正在加载会话草稿...</text>
18
+		</view>
19
+		<view v-else-if="loadError" class="fe-conv-cost-empty">
20
+			<text class="fe-conv-cost-empty__text">{{ loadError }}</text>
21
+			<view class="fe-biz-btn fe-biz-btn--primary fe-conv-cost-empty__btn" @tap="loadData">重新加载</view>
22
+		</view>
23
+		<view v-else-if="!draftItems.length" class="fe-conv-cost-empty">
24
+			<text class="fe-conv-cost-empty__text">当前会话暂无用工草稿</text>
25
+			<text class="fe-conv-cost-empty__sub">请先通过对话创建用工需求后再试</text>
26
+		</view>
27
+		<block v-else>
28
+			<view class="fe-sheet-source">{{ currentDraftSubtitle }}</view>
29
+			<swiper
30
+				class="fe-conv-cost-swiper"
31
+				:style="{ height: swiperHeight + 'px' }"
32
+				:current="draftIndex"
33
+				:indicator-dots="false"
34
+				@change="onDraftSwiperChange"
35
+			>
36
+				<swiper-item v-for="(item, dIdx) in draftItems" :key="item.msgKey">
37
+					<FeDraftSheet
38
+						embedded
39
+						:active="active && dIdx === draftIndex"
40
+						:draft="item.draft"
41
+						:body-height="panelBodyHeight"
42
+						:pager-count="draftItems.length"
43
+						:pager-index="draftIndex"
44
+						@confirm="onDraftConfirm"
45
+						@updated="onDraftUpdatedByIndex(dIdx, $event)"
46
+						@view-qr="onViewQr"
47
+					/>
48
+				</swiper-item>
49
+			</swiper>
50
+		</block>
51
+	</view>
52
+</template>
53
+
54
+<script>
55
+import { showToast } from '@/common/chat-data.js'
56
+import { listConversationDraftCostComparisons } from '@/api/conversation.js'
57
+import { mapDraftDetailToCard, getDraftStatus } from '@/utils/draft.js'
58
+import FeDraftSheet from '@/packageA/components/chat/FeDraftSheet.vue'
59
+
60
+export default {
61
+	name: 'FeConversationCostSheet',
62
+	components: {
63
+		FeDraftSheet
64
+	},
65
+	props: {
66
+		active: { type: Boolean, default: false },
67
+		conversationId: { type: [Number, String], default: null },
68
+		draftMetas: {
69
+			type: Array,
70
+			default() {
71
+				return []
72
+			}
73
+		}
74
+	},
75
+	data() {
76
+		const winH = (uni.getSystemInfoSync().windowHeight || 600)
77
+		const swiperHeight = Math.floor(winH * 0.72)
78
+		return {
79
+			loading: false,
80
+			loadError: '',
81
+			draftItems: [],
82
+			draftIndex: 0,
83
+			swiperHeight,
84
+			panelBodyHeight: Math.max(280, swiperHeight - uni.upx2px(208))
85
+		}
86
+	},
87
+	computed: {
88
+		currentItem() {
89
+			return this.draftItems[this.draftIndex] || null
90
+		},
91
+		currentDraft() {
92
+			return this.currentItem && this.currentItem.draft
93
+		},
94
+		currentDraftSubtitle() {
95
+			const item = this.currentItem
96
+			if (!item || !item.draft) return ''
97
+			const n = this.draftItems.length
98
+			const pos = n > 1 ? `(${this.draftIndex + 1}/${n})` : ''
99
+			const title = item.draft.title || `用工草稿 #${item.draftId}`
100
+			return `${title}${pos}`
101
+		},
102
+		currentStatusBadge() {
103
+			const status = getDraftStatus(this.currentDraft)
104
+			if (status === 'confirmed') return '已确认'
105
+			if (status === 'cancelled') return '已取消'
106
+			return '未确认'
107
+		},
108
+		currentStatusTone() {
109
+			const status = getDraftStatus(this.currentDraft)
110
+			if (status === 'confirmed') return 'confirmed'
111
+			if (status === 'cancelled') return 'cancelled'
112
+			return 'pending'
113
+		}
114
+	},
115
+	watch: {
116
+		active(val) {
117
+			if (val) {
118
+				this.syncHeights()
119
+				this.loadData()
120
+			}
121
+		},
122
+		conversationId() {
123
+			if (this.active) this.loadData()
124
+		}
125
+	},
126
+	methods: {
127
+		syncHeights() {
128
+			try {
129
+				const winH = uni.getSystemInfoSync().windowHeight || 600
130
+				this.swiperHeight = Math.floor(winH * 0.72)
131
+				this.panelBodyHeight = Math.max(280, this.swiperHeight - uni.upx2px(208))
132
+			} catch (e) {
133
+				this.swiperHeight = 480
134
+				this.panelBodyHeight = 360
135
+			}
136
+		},
137
+		metaByDraftId(draftId) {
138
+			const list = Array.isArray(this.draftMetas) ? this.draftMetas : []
139
+			for (let i = 0; i < list.length; i++) {
140
+				if (list[i] && String(list[i].draftId) === String(draftId)) return list[i]
141
+			}
142
+			return null
143
+		},
144
+		pickCostComparison(raw) {
145
+			if (!raw || typeof raw !== 'object') return null
146
+			return raw.costComparison || raw.cost_comparison || null
147
+		},
148
+		/**
149
+		 * 新格式:DraftDetail + cost_comparison → 与用工草稿相同的 card 结构
150
+		 */
151
+		mapApiList(list) {
152
+			const arr = Array.isArray(list) ? list : []
153
+			const mapped = []
154
+			for (let i = 0; i < arr.length; i++) {
155
+				const raw = arr[i] || {}
156
+				const draftId = raw.id != null ? raw.id : (raw.draftId != null ? raw.draftId : raw.draft_id)
157
+				if (draftId == null) continue
158
+				const meta = this.metaByDraftId(draftId) || {}
159
+				const costComparison = this.pickCostComparison(raw)
160
+				const card = mapDraftDetailToCard(raw)
161
+				if (!card.title && meta.title) card.title = meta.title
162
+				if (card.workerCount == null && meta.workerCount != null) card.workerCount = meta.workerCount
163
+				if (costComparison) {
164
+					card.costComparison = costComparison
165
+					card.cost_comparison = costComparison
166
+				}
167
+				mapped.push({
168
+					msgKey: draftId,
169
+					draftId,
170
+					draft: card
171
+				})
172
+			}
173
+			this.draftItems = mapped
174
+			this.draftIndex = 0
175
+		},
176
+		async loadData() {
177
+			const conversationId = this.conversationId
178
+			if (conversationId == null || conversationId === '') {
179
+				this.draftItems = []
180
+				this.loadError = '请先开启对话'
181
+				return
182
+			}
183
+			this.loading = true
184
+			this.loadError = ''
185
+			try {
186
+				const list = await listConversationDraftCostComparisons(conversationId, { showError: false })
187
+				this.mapApiList(list)
188
+			} catch (e) {
189
+				this.draftItems = []
190
+				this.loadError = (e && (e.msg || e.message)) || '成本测算加载失败'
191
+				showToast(this.loadError)
192
+			} finally {
193
+				this.loading = false
194
+			}
195
+		},
196
+		onDraftSwiperChange(e) {
197
+			const idx = e && e.detail && e.detail.current != null ? e.detail.current : 0
198
+			this.draftIndex = idx
199
+		},
200
+		onDraftUpdatedByIndex(dIdx, draftCard) {
201
+			if (dIdx == null || !draftCard) return
202
+			const item = this.draftItems[dIdx]
203
+			if (!item) return
204
+			this.$set(this.draftItems, dIdx, {
205
+				msgKey: item.msgKey,
206
+				draftId: draftCard.draftId != null ? draftCard.draftId : item.draftId,
207
+				draft: draftCard
208
+			})
209
+			this.$emit('updated', draftCard)
210
+		},
211
+		onDraftConfirm(payload) {
212
+			this.$emit('confirm', payload)
213
+		},
214
+		onViewQr(payload) {
215
+			const item = this.currentItem
216
+			if (item && item.draft) {
217
+				this.$emit('updated', item.draft)
218
+			}
219
+			this.$emit('view-qr', payload)
220
+		}
221
+	}
222
+}
223
+</script>
224
+
225
+<style lang="scss" scoped>
226
+.fe-bottom-sheet--draft {
227
+	max-height: 92vh;
228
+	height: 92vh;
229
+}
230
+
231
+.fe-conv-cost-hint {
232
+	margin-left: 12rpx;
233
+	font-size: 22rpx;
234
+	color: #94A3B8;
235
+	font-weight: 500;
236
+}
237
+
238
+.fe-conv-cost-empty {
239
+	flex: 1;
240
+	padding: 80rpx 40rpx;
241
+	text-align: center;
242
+}
243
+
244
+.fe-conv-cost-empty__text {
245
+	display: block;
246
+	font-size: 28rpx;
247
+	color: #64748B;
248
+	line-height: 1.5;
249
+}
250
+
251
+.fe-conv-cost-empty__sub {
252
+	display: block;
253
+	margin-top: 12rpx;
254
+	font-size: 24rpx;
255
+	color: #94A3B8;
256
+}
257
+
258
+.fe-conv-cost-empty__btn {
259
+	margin: 28rpx auto 0;
260
+	display: inline-flex;
261
+}
262
+
263
+.fe-conv-cost-swiper {
264
+	width: 100%;
265
+	flex: 1;
266
+	min-height: 0;
267
+	box-sizing: border-box;
268
+}
269
+</style>

+ 336 - 0
huimv-employment/app/packageA/components/chat/FeConversationProgressSheet.vue

@@ -0,0 +1,336 @@
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>
8
+			</view>
9
+		</view>
10
+
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>
47
+								</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>
51
+								</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>
55
+								</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>
74
+								</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>
79
+									</view>
80
+									<view v-if="step.time" class="fe-timeline-time">{{ step.time }}</view>
81
+								</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>
88
+								</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>
98
+								</view>
99
+								<text class="fe-person-status" :class="'fe-person-status--' + p.status">{{ p.statusText }}</text>
100
+							</view>
101
+						</view>
102
+					</scroll-view>
103
+				</swiper-item>
104
+			</swiper>
105
+			<view v-if="items.length > 1" class="fe-progress-dots">
106
+				<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
+				/>
112
+			</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
+		</view>
119
+	</view>
120
+</template>
121
+
122
+<script>
123
+import { showToast } from '@/common/chat-data.js'
124
+import { listConversationDraftRegistrationProgress } from '@/api/conversation.js'
125
+import { mapRegistrationBatchDetail } from '@/utils/registration-batch.js'
126
+
127
+export default {
128
+	name: 'FeConversationProgressSheet',
129
+	props: {
130
+		active: { type: Boolean, default: false },
131
+		conversationId: { type: [Number, String], default: null }
132
+	},
133
+	data() {
134
+		return {
135
+			loading: false,
136
+			loadError: '',
137
+			items: [],
138
+			currentIndex: 0,
139
+			// 小程序 swiper 必须给明确 px 高度;复用查看进度内容区约 60vh
140
+			swiperHeight: Math.floor((uni.getSystemInfoSync().windowHeight || 600) * 0.6)
141
+		}
142
+	},
143
+	watch: {
144
+		active(val) {
145
+			if (val) {
146
+				this.syncSwiperHeight()
147
+				this.loadData()
148
+			}
149
+		},
150
+		conversationId() {
151
+			if (this.active) this.loadData()
152
+		}
153
+	},
154
+	methods: {
155
+		syncSwiperHeight() {
156
+			try {
157
+				const h = uni.getSystemInfoSync().windowHeight || 600
158
+				this.swiperHeight = Math.floor(h * 0.6)
159
+			} catch (e) {
160
+				this.swiperHeight = 400
161
+			}
162
+		},
163
+		itemSubtitle(item, idx) {
164
+			const base = (item && (item.subtitle || item.title)) || '用工批次'
165
+			if (this.items.length <= 1) return base
166
+			return `${base}(${idx + 1}/${this.items.length})`
167
+		},
168
+		async loadData() {
169
+			const conversationId = this.conversationId
170
+			if (conversationId == null || conversationId === '') {
171
+				this.items = []
172
+				this.loadError = '请先开启对话'
173
+				return
174
+			}
175
+			this.loading = true
176
+			this.loadError = ''
177
+			try {
178
+				const list = await listConversationDraftRegistrationProgress(conversationId, { showError: false })
179
+				const arr = Array.isArray(list) ? list : []
180
+				this.items = arr.map((raw, i) => {
181
+					const detail = mapRegistrationBatchDetail(raw)
182
+					const draftId = detail.draftId != null ? detail.draftId : (raw.draft_id != null ? raw.draft_id : i)
183
+					return Object.assign({}, detail, {
184
+						msgKey: draftId,
185
+						draftId
186
+					})
187
+				})
188
+				this.currentIndex = 0
189
+			} catch (e) {
190
+				this.items = []
191
+				this.loadError = (e && (e.msg || e.message)) || '进度加载失败'
192
+				showToast(this.loadError)
193
+			} finally {
194
+				this.loading = false
195
+			}
196
+		},
197
+		onSwiperChange(e) {
198
+			const idx = e && e.detail && e.detail.current != null ? e.detail.current : 0
199
+			this.currentIndex = idx
200
+		},
201
+		refreshByIndex(idx) {
202
+			this.loadData().then(() => {
203
+				const max = Math.max(0, this.items.length - 1)
204
+				this.currentIndex = Math.max(0, Math.min(max, idx))
205
+				showToast('已刷新')
206
+			})
207
+		},
208
+		onUrge() {
209
+			showToast('已发送催办')
210
+		}
211
+	}
212
+}
213
+</script>
214
+
215
+<style lang="scss" scoped>
216
+/* swiper 高度由 :style 像素值控制,勿挂 fe-sheet-body(flex:1 会导致高度为 0) */
217
+.fe-progress-swiper {
218
+	width: 100%;
219
+	flex-shrink: 0;
220
+	box-sizing: border-box;
221
+}
222
+
223
+.fe-progress-swiper__scroll {
224
+	width: 100%;
225
+	box-sizing: border-box;
226
+}
227
+
228
+.fe-conv-progress-hint {
229
+	margin-left: 12rpx;
230
+	font-size: 22rpx;
231
+	color: #94A3B8;
232
+	font-weight: 500;
233
+}
234
+
235
+.fe-conv-progress-retry {
236
+	margin: 28rpx auto 0;
237
+	display: inline-flex;
238
+}
239
+
240
+.fe-stat-box__num--warning { color: #F59E0B; }
241
+.fe-stat-box__num--info { color: #6366F1; }
242
+.fe-stat-box__num--success { color: #10B981; }
243
+.fe-stat-box__num--primary { color: #7C3AED; }
244
+
245
+.fe-progress-label {
246
+	display: flex;
247
+	justify-content: space-between;
248
+	margin: 24rpx 0 12rpx;
249
+}
250
+
251
+.fe-progress-label__text { color: #64748B; }
252
+.fe-progress-label__value { font-weight: 700; color: #7C3AED; }
253
+
254
+.fe-section-heading--solo {
255
+	display: block;
256
+	margin: 32rpx 0 20rpx;
257
+}
258
+
259
+.fe-section-heading-row {
260
+	display: flex;
261
+	align-items: center;
262
+	justify-content: space-between;
263
+	margin: 32rpx 0 20rpx;
264
+}
265
+
266
+.fe-section-heading {
267
+	font-size: 28rpx;
268
+	font-weight: 700;
269
+}
270
+
271
+.fe-section-refresh {
272
+	display: inline-flex;
273
+	align-items: center;
274
+	gap: 6rpx;
275
+	font-size: 26rpx;
276
+	font-weight: 600;
277
+	color: $fe-primary;
278
+}
279
+
280
+.fe-section-refresh__icon {
281
+	font-size: 32rpx;
282
+	line-height: 1;
283
+}
284
+
285
+.fe-timeline-title { font-weight: 600; }
286
+.fe-timeline-time { font-size: 24rpx; color: #94A3B8; }
287
+.fe-timeline-highlight { color: #7C3AED; }
288
+
289
+.fe-person-info { flex: 1; min-width: 0; }
290
+.fe-person-name { font-weight: 600; }
291
+.fe-person-phone { font-size: 24rpx; color: #64748B; }
292
+
293
+.fe-progress-dots {
294
+	display: flex;
295
+	justify-content: center;
296
+	align-items: center;
297
+	gap: 12rpx;
298
+	padding: 8rpx 0 4rpx;
299
+}
300
+
301
+.fe-progress-dot {
302
+	width: 12rpx;
303
+	height: 12rpx;
304
+	border-radius: 50%;
305
+	background: #E2E8F0;
306
+}
307
+
308
+.fe-progress-dot--active {
309
+	width: 28rpx;
310
+	border-radius: 999rpx;
311
+	background: $fe-primary;
312
+}
313
+
314
+.fe-progress-empty {
315
+	padding: 80rpx 32rpx;
316
+	text-align: center;
317
+}
318
+
319
+.fe-progress-empty--inline {
320
+	padding: 24rpx 0 40rpx;
321
+}
322
+
323
+.fe-progress-empty__text {
324
+	display: block;
325
+	font-size: 28rpx;
326
+	color: #94A3B8;
327
+	line-height: 1.5;
328
+}
329
+
330
+.fe-progress-empty__sub {
331
+	display: block;
332
+	margin-top: 12rpx;
333
+	font-size: 24rpx;
334
+	color: #94A3B8;
335
+}
336
+</style>

+ 200 - 28
huimv-employment/app/packageA/components/chat/FeDraftSheet.vue

@@ -1,14 +1,20 @@
1 1
 <template>
2
-	<view class="fe-bottom-sheet fe-bottom-sheet--draft" :class="{ 'fe-bottom-sheet--active': active }">
3
-		<view class="fe-sheet-handle" />
4
-		<view class="fe-sheet-header">
2
+	<view :class="sheetRootClass">
3
+		<view v-if="!embedded" class="fe-sheet-handle" />
4
+		<view v-if="!embedded" class="fe-sheet-header">
5 5
 			<view class="fe-sheet-title">
6
-				📄 用工草稿
6
+				{{ sheetTitle }}
7 7
 				<text class="fe-draft-card-status" :class="'fe-draft-card-status--' + statusBadgeTone">{{ statusBadge }}</text>
8 8
 			</view>
9 9
 		</view>
10 10
 		<view class="fe-sheet-source">{{ sourceText }}</view>
11
-		<scroll-view scroll-y class="fe-sheet-body" :show-scrollbar="false">
11
+		<scroll-view
12
+			scroll-y
13
+			class="fe-sheet-body"
14
+			:class="{ 'fe-draft-sheet-body--embedded': embedded }"
15
+			:style="embeddedBodyStyle"
16
+			:show-scrollbar="false"
17
+		>
12 18
 			<view class="fe-sheet-body__inner">
13 19
 				<view class="fe-form-section" :class="{ 'fe-form-section--editing': basicEditing }">
14 20
 					<view class="fe-form-section__title">
@@ -132,10 +138,13 @@
132 138
 						<text class="fe-cost-status__text">{{ isConfirmed ? '方案已确认' : '暂无成本方案' }}</text>
133 139
 					</view>
134 140
 					<template v-else>
141
+						<!-- catchtouchmove:嵌入外层草稿 swiper 时不误触草稿切换;已确认仅一张且禁滑 -->
142
+						<view catchtouchmove="true">
135 143
 						<swiper
136 144
 							class="fe-draft-cost-swiper"
137 145
 							:style="{ height: costSwiperHeight + 'px' }"
138 146
 							:current="currentCostIndex"
147
+							:disable-touch="isConfirmed || costPlans.length <= 1"
139 148
 							@change="onCostSwiperChange"
140 149
 						>
141 150
 							<swiper-item
@@ -143,7 +152,7 @@
143 152
 								:key="plan.id"
144 153
 								class="fe-draft-cost-swiper__item"
145 154
 							>
146
-								<view class="fe-draft-cost-swiper__inner" :id="'draft-cost-panel-' + planIndex">
155
+								<view class="fe-draft-cost-swiper__inner" :id="costPanelId(planIndex)">
147 156
 									<view
148 157
 										class="fe-cost-card"
149 158
 										:class="{ 'fe-cost-card--recommended': plan.recommended || currentCostIndex === planIndex }"
@@ -197,6 +206,7 @@
197 206
 								</view>
198 207
 							</swiper-item>
199 208
 						</swiper>
209
+						</view>
200 210
 
201 211
 						<view v-if="costPlans.length > 1 && !isConfirmed" class="fe-dot-indicator">
202 212
 							<view
@@ -210,6 +220,14 @@
210 220
 				</view>
211 221
 			</view>
212 222
 		</scroll-view>
223
+		<view v-if="embedded && pagerCount > 1" class="fe-progress-dots">
224
+			<view
225
+				v-for="(dot, di) in pagerDots"
226
+				:key="di"
227
+				class="fe-progress-dot"
228
+				:class="{ 'fe-progress-dot--active': di === pagerIndex }"
229
+			/>
230
+		</view>
213 231
 		<view class="fe-sheet-footer">
214 232
 			<view
215 233
 				v-if="isConfirmed"
@@ -233,7 +251,7 @@
233 251
 
234 252
 <script>
235 253
 import { showToast } from '@/common/chat-data.js'
236
-import { updateDraft, getDraftCostComparison, confirmDraft } from '@/api/draft.js'
254
+import { updateDraft, getDraftCostComparison, confirmDraft, getDraftDetail } from '@/api/draft.js'
237 255
 import {
238 256
 	formatSettlementMode,
239 257
 	mapDraftDetailToCard,
@@ -241,7 +259,9 @@ import {
241 259
 	isDraftReadyForCost,
242 260
 	isDraftIncomplete,
243 261
 	getDraftStatus,
244
-	buildConfirmedCostPlans
262
+	buildConfirmedCostPlans,
263
+	pickDraftQrCodeUrl,
264
+	pickDraftQrToken
245 265
 } from '@/utils/draft.js'
246 266
 
247 267
 const DEFAULT_FORM = {
@@ -278,7 +298,15 @@ export default {
278 298
 	name: 'FeDraftSheet',
279 299
 	props: {
280 300
 		active: { type: Boolean, default: false },
281
-		draft: { type: Object, default: null }
301
+		draft: { type: Object, default: null },
302
+		/** 嵌入会话成本列表等场景:不渲染底部弹层外壳 */
303
+		embedded: { type: Boolean, default: false },
304
+		/** 嵌入时由父级指定内容区高度(px) */
305
+		bodyHeight: { type: Number, default: 0 },
306
+		/** 嵌入多草稿切换时展示底部紫色小点 */
307
+		pagerCount: { type: Number, default: 0 },
308
+		pagerIndex: { type: Number, default: 0 },
309
+		sheetTitle: { type: String, default: '📄 用工草稿' }
282 310
 	},
283 311
 	data() {
284 312
 		return {
@@ -298,6 +326,27 @@ export default {
298 326
 		}
299 327
 	},
300 328
 	computed: {
329
+		pagerDots() {
330
+			const n = Number(this.pagerCount) || 0
331
+			const list = []
332
+			for (let i = 0; i < n; i++) list.push(i)
333
+			return list
334
+		},
335
+		/**
336
+		 * 小程序上对象形式 :class 易丢类名,弹层会一直盖在页面上;这里用字符串保证 inactive 时带 translateY(100%)
337
+		 */
338
+		sheetRootClass() {
339
+			if (this.embedded) {
340
+				return 'fe-draft-sheet-root fe-draft-sheet-root--embedded'
341
+			}
342
+			return this.active
343
+				? 'fe-bottom-sheet fe-bottom-sheet--draft fe-bottom-sheet--active'
344
+				: 'fe-bottom-sheet fe-bottom-sheet--draft'
345
+		},
346
+		embeddedBodyStyle() {
347
+			if (!this.embedded || !this.bodyHeight) return {}
348
+			return { height: this.bodyHeight + 'px' }
349
+		},
301 350
 		draftId() {
302 351
 			if (!this.draft) return null
303 352
 			return this.draft.draftId != null ? this.draft.draftId : (this.draft.detail && this.draft.detail.id)
@@ -391,6 +440,7 @@ export default {
391 440
 	},
392 441
 	watch: {
393 442
 		active(val) {
443
+			if (this.embedded) return
394 444
 			if (!val) {
395 445
 				this.basicEditing = false
396 446
 				this.requirementsEditing = false
@@ -402,15 +452,24 @@ export default {
402 452
 		},
403 453
 		draft: {
404 454
 			handler() {
405
-				if (this.active && !this.basicEditing && !this.requirementsEditing && !this.saving && !this.confirming) {
406
-					this.applyDraft()
407
-				}
455
+				const shouldApply = this.embedded
456
+					? (!this.basicEditing && !this.requirementsEditing && !this.saving && !this.confirming)
457
+					: (this.active && !this.basicEditing && !this.requirementsEditing && !this.saving && !this.confirming)
458
+				if (shouldApply) this.applyDraft()
408 459
 			},
409
-			deep: true
460
+			deep: true,
461
+			immediate: false
410 462
 		}
411 463
 	},
464
+	mounted() {
465
+		if (this.embedded && this.draft) this.applyDraft()
466
+	},
412 467
 	methods: {
413 468
 		toast: showToast,
469
+		costPanelId(planIndex) {
470
+			const id = this.draftId != null ? this.draftId : 'x'
471
+			return `draft-cost-panel-${id}-${planIndex}`
472
+		},
414 473
 		displayText(val) {
415 474
 			return val && String(val).trim() ? val : '待补充'
416 475
 		},
@@ -451,6 +510,22 @@ export default {
451 510
 				return
452 511
 			}
453 512
 
513
+			// 会话成本列表接口已内嵌 cost_comparison 时优先使用,避免重复请求
514
+			const inline = this.pickInlineCostComparison(draft)
515
+			if (inline && Array.isArray(inline.schemes)) {
516
+				const workers = draft && draft.workerCount != null
517
+					? draft.workerCount
518
+					: Number(this.form.workers) || 1
519
+				this.costPlans = mapCostSchemesToPlans(inline.schemes, { workerCount: workers })
520
+				const recommendIdx = this.costPlans.findIndex((p) => p.recommended)
521
+				this.currentCostIndex = recommendIdx >= 0 ? recommendIdx : 0
522
+				this.expandedCostPlans = {}
523
+				this.costLoading = false
524
+				this.costError = ''
525
+				this.updateCostSwiperHeight()
526
+				return
527
+			}
528
+
454 529
 			const seq = ++this.costRequestSeq
455 530
 			this.costLoading = true
456 531
 			this.costError = ''
@@ -475,18 +550,56 @@ export default {
475 550
 				if (seq === this.costRequestSeq) this.costLoading = false
476 551
 			}
477 552
 		},
553
+		pickInlineCostComparison(draft) {
554
+			if (!draft || typeof draft !== 'object') return null
555
+			const detail = draft.detail && typeof draft.detail === 'object' ? draft.detail : null
556
+			return draft.costComparison
557
+				|| draft.cost_comparison
558
+				|| (detail && (detail.costComparison || detail.cost_comparison))
559
+				|| null
560
+		},
561
+		pickSelectedSchemeCode(draft) {
562
+			if (!draft || typeof draft !== 'object') return ''
563
+			const detail = draft.detail && typeof draft.detail === 'object' ? draft.detail : null
564
+			return draft.selectedSchemeCode
565
+				|| draft.selected_scheme_code
566
+				|| (detail && (detail.selectedSchemeCode || detail.selected_scheme_code))
567
+				|| (draft.confirmedPlan && draft.confirmedPlan.code)
568
+				|| ''
569
+		},
570
+		/**
571
+		 * 已确认草稿只保留一个方案卡片(不可再左右滑动对比)
572
+		 */
573
+		pickConfirmedDisplayPlans(plans, draft) {
574
+			const list = Array.isArray(plans) ? plans.slice() : []
575
+			if (!list.length) return []
576
+			if (list.length === 1) {
577
+				list[0] = Object.assign({}, list[0], { recommended: true })
578
+				return list
579
+			}
580
+			const selectedCode = this.pickSelectedSchemeCode(draft)
581
+			let matched = []
582
+			if (selectedCode) {
583
+				matched = list.filter((p) => p && p.code === selectedCode)
584
+			}
585
+			if (!matched.length) {
586
+				matched = list.filter((p) => p && p.recommended)
587
+			}
588
+			const one = matched.length ? matched[0] : list[0]
589
+			return [Object.assign({}, one, { recommended: true })]
590
+		},
478 591
 		applyConfirmedCostView(draft) {
479 592
 			this.costLoading = false
480 593
 			this.costError = ''
481
-			let plans = buildConfirmedCostPlans(draft)
482
-			const selectedCode = draft.selectedSchemeCode
483
-				|| (draft.detail && draft.detail.selectedSchemeCode)
484
-				|| (draft.confirmedPlan && draft.confirmedPlan.code)
485
-			if (selectedCode && plans.length > 1) {
486
-				const matched = plans.filter((p) => p.code === selectedCode)
487
-				if (matched.length) plans = matched
594
+			const inline = this.pickInlineCostComparison(draft)
595
+			let plans = []
596
+			if (inline && Array.isArray(inline.schemes) && inline.schemes.length) {
597
+				const workers = draft && draft.workerCount != null ? draft.workerCount : Number(this.form.workers) || 1
598
+				plans = mapCostSchemesToPlans(inline.schemes, { workerCount: workers })
599
+			} else {
600
+				plans = buildConfirmedCostPlans(draft)
488 601
 			}
489
-			this.costPlans = plans
602
+			this.costPlans = this.pickConfirmedDisplayPlans(plans, draft)
490 603
 			this.currentCostIndex = 0
491 604
 			this.expandedCostPlans = {}
492 605
 			this.updateCostSwiperHeight()
@@ -511,13 +624,17 @@ export default {
511 624
 			})
512 625
 		},
513 626
 		onCostSwiperChange(e) {
627
+			if (this.isConfirmed) {
628
+				this.currentCostIndex = 0
629
+				return
630
+			}
514 631
 			this.currentCostIndex = e.detail.current
515 632
 			this.updateCostSwiperHeight()
516 633
 		},
517 634
 		updateCostSwiperHeight() {
518 635
 			this.$nextTick(() => {
519 636
 				setTimeout(() => {
520
-					const selector = `#draft-cost-panel-${this.currentCostIndex}`
637
+					const selector = `#${this.costPanelId(this.currentCostIndex)}`
521 638
 					uni.createSelectorQuery()
522 639
 						.in(this)
523 640
 						.select(selector)
@@ -695,12 +812,21 @@ export default {
695 812
 					compliance: Array.isArray(p.compliance) ? p.compliance.slice() : []
696 813
 				}))
697 814
 				const orderId = (result && (result.orderId || result.order_id)) || ''
698
-				const qrCodeUrl = (result && (
699
-					result.qrCodeUrl
700
-					|| result.qr_code_url
701
-					|| result.ar_code_url
702
-					|| result.arCodeUrl
703
-				)) || ''
815
+				const qrTokenFromResult = pickDraftQrToken(result)
816
+				// 二维码 / qrToken 从草稿详情读取;确认响应作兜底
817
+				let qrCodeUrl = ''
818
+				let qrToken = qrTokenFromResult
819
+				try {
820
+					const detail = await getDraftDetail(this.draftId, { showError: false })
821
+					if (detail) {
822
+						qrCodeUrl = pickDraftQrCodeUrl(detail)
823
+						const fromDetail = pickDraftQrToken(detail)
824
+						if (fromDetail) qrToken = fromDetail
825
+					}
826
+				} catch (e) {
827
+					// 静默:确认成功即可,二维码可在分享时再拉详情
828
+				}
829
+				const scene = qrToken || orderId || ''
704 830
 				const nextDraft = Object.assign({}, this.draft || {}, {
705 831
 					status: draftStatus,
706 832
 					missingFields: [],
@@ -709,6 +835,8 @@ export default {
709 835
 					confirmedPlanTitle: scheme.title,
710 836
 					costPlans: costPlansSnapshot,
711 837
 					orderId,
838
+					qrToken: qrToken || '',
839
+					scene,
712 840
 					qrCodeUrl,
713 841
 					estimatedTotal: scheme.raw && scheme.raw.cost
714 842
 						? (scheme.raw.cost.total_cost || scheme.raw.cost.totalCost)
@@ -719,6 +847,8 @@ export default {
719 847
 						status: draftStatus,
720 848
 						selectedSchemeCode: schemeCode,
721 849
 						orderId,
850
+						qrToken: qrToken || '',
851
+						scene,
722 852
 						qrCodeUrl
723 853
 					})
724 854
 				}
@@ -742,6 +872,48 @@ export default {
742 872
 </script>
743 873
 
744 874
 <style lang="scss" scoped>
875
+.fe-draft-sheet-root--embedded {
876
+	display: flex;
877
+	flex-direction: column;
878
+	height: 100%;
879
+	min-height: 0;
880
+	box-sizing: border-box;
881
+	background: #fff;
882
+}
883
+
884
+.fe-draft-sheet-body--embedded {
885
+	flex: 1;
886
+	min-height: 0;
887
+	height: auto;
888
+	max-height: none;
889
+}
890
+
891
+.fe-draft-sheet-root--embedded .fe-sheet-footer {
892
+	flex-shrink: 0;
893
+}
894
+
895
+.fe-progress-dots {
896
+	display: flex;
897
+	justify-content: center;
898
+	align-items: center;
899
+	gap: 12rpx;
900
+	padding: 8rpx 0 4rpx;
901
+	flex-shrink: 0;
902
+}
903
+
904
+.fe-progress-dot {
905
+	width: 12rpx;
906
+	height: 12rpx;
907
+	border-radius: 50%;
908
+	background: #E2E8F0;
909
+}
910
+
911
+.fe-progress-dot--active {
912
+	width: 28rpx;
913
+	border-radius: 999rpx;
914
+	background: $fe-primary;
915
+}
916
+
745 917
 .fe-bottom-sheet--draft {
746 918
 	max-height: 92vh;
747 919
 	height: 92vh;

+ 90 - 19
huimv-employment/app/packageA/components/chat/FeProgressSheet.vue

@@ -4,22 +4,48 @@
4 4
 		<view class="fe-sheet-header">
5 5
 			<view class="fe-sheet-title">📈 进度管理</view>
6 6
 		</view>
7
-		<view class="fe-sheet-source">浦东仓库搬运 BATCH-001</view>
8
-		<scroll-view scroll-y class="fe-sheet-body" :show-scrollbar="false">
7
+
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">
9 15
 			<view class="fe-sheet-body__inner">
16
+				<view class="fe-sheet-source">{{ detail.subtitle || detail.title }}</view>
10 17
 				<view class="fe-stat-grid">
11
-					<view class="fe-stat-box"><view class="fe-stat-box__num fe-stat-box__num--warning">3</view><view class="fe-stat-box__label">待登记</view></view>
12
-					<view class="fe-stat-box"><view class="fe-stat-box__num fe-stat-box__num--info">4</view><view class="fe-stat-box__label">登记中</view></view>
13
-					<view class="fe-stat-box"><view class="fe-stat-box__num fe-stat-box__num--success">3</view><view class="fe-stat-box__label">已完成</view></view>
14
-					<view class="fe-stat-box fe-stat-box--primary"><view class="fe-stat-box__num fe-stat-box__num--primary">10</view><view class="fe-stat-box__label">总计</view></view>
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>
25
+					</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>
29
+					</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>
33
+					</view>
15 34
 				</view>
16 35
 				<view class="fe-progress-label">
17 36
 					<text class="fe-progress-label__text">登记进度</text>
18
-					<text class="fe-progress-label__value">7/10</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 + '%' }" />
19 41
 				</view>
20
-				<view class="fe-progress-track"><view class="fe-progress-fill" style="width:70%;" /></view>
21 42
 				<text class="fe-section-heading fe-section-heading--solo">办理流程</text>
22
-				<view v-for="step in steps" :key="step.key" class="fe-timeline-item" :class="'fe-timeline-item--' + step.status">
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
+				>
23 49
 					<view class="fe-timeline-dot" :class="'fe-timeline-dot--' + step.status">
24 50
 						<text v-if="step.status === 'done'">✓</text>
25 51
 						<text v-else-if="step.status === 'active'">⏱</text>
@@ -34,13 +60,16 @@
34 60
 				</view>
35 61
 				<view class="fe-section-heading-row">
36 62
 					<text class="fe-section-heading">人员列表</text>
37
-					<view class="fe-section-refresh" @tap="refreshPersons">
63
+					<view class="fe-section-refresh" @tap="loadDetail">
38 64
 						<text class="fe-section-refresh__icon">↻</text>
39 65
 						<text>刷新</text>
40 66
 					</view>
41 67
 				</view>
42
-				<view v-for="p in persons" :key="p.name" class="fe-person-item">
43
-					<view class="fe-person-avatar">{{ p.name[0] }}</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>
44 73
 					<view class="fe-person-info">
45 74
 						<view class="fe-person-name">{{ p.name }}</view>
46 75
 						<view class="fe-person-phone">{{ p.phone }}</view>
@@ -49,6 +78,7 @@
49 78
 				</view>
50 79
 			</view>
51 80
 		</scroll-view>
81
+
52 82
 		<view class="fe-sheet-footer">
53 83
 			<view class="fe-btn fe-btn--secondary fe-btn--lg" @tap="toast('已查看全部')">查看全部</view>
54 84
 			<view class="fe-btn fe-btn--primary fe-btn--lg" @tap="toast('已发送催办')">催办未登记</view>
@@ -57,25 +87,52 @@
57 87
 </template>
58 88
 
59 89
 <script>
60
-import { PROGRESS_PERSONS, PROGRESS_STEPS, showToast } from '@/common/chat-data.js'
90
+import { showToast } from '@/common/chat-data.js'
91
+import { getRegistrationBatchDetail } from '@/api/registration-batch.js'
92
+import { mapRegistrationBatchDetail } from '@/utils/registration-batch.js'
61 93
 
62 94
 export default {
63 95
 	name: 'FeProgressSheet',
64 96
 	props: {
65 97
 		active: { type: Boolean, default: false },
66
-		elevated: { type: Boolean, default: false }
98
+		elevated: { type: Boolean, default: false },
99
+		draftId: { type: [Number, String], default: null }
67 100
 	},
68 101
 	data() {
69 102
 		return {
70
-			persons: PROGRESS_PERSONS,
71
-			steps: PROGRESS_STEPS
103
+			loading: false,
104
+			loadError: '',
105
+			detail: null
106
+		}
107
+	},
108
+	watch: {
109
+		active(val) {
110
+			if (val) this.loadDetail()
111
+		},
112
+		draftId() {
113
+			if (this.active) this.loadDetail()
72 114
 		}
73 115
 	},
74 116
 	methods: {
75 117
 		toast: showToast,
76
-		refreshPersons() {
77
-			this.persons = [...PROGRESS_PERSONS]
78
-			this.toast('已刷新')
118
+		async loadDetail() {
119
+			if (this.draftId == null || this.draftId === '') {
120
+				this.detail = null
121
+				this.loadError = '缺少草稿信息'
122
+				return
123
+			}
124
+			this.loading = true
125
+			this.loadError = ''
126
+			try {
127
+				const raw = await getRegistrationBatchDetail(this.draftId, { showError: false })
128
+				this.detail = mapRegistrationBatchDetail(Object.assign({}, raw, { draft_id: this.draftId }))
129
+			} catch (e) {
130
+				this.detail = null
131
+				this.loadError = (e && (e.msg || e.message)) || '进度加载失败'
132
+				showToast(this.loadError)
133
+			} finally {
134
+				this.loading = false
135
+			}
79 136
 		}
80 137
 	}
81 138
 }
@@ -134,4 +191,18 @@ export default {
134 191
 .fe-person-info { flex: 1; min-width: 0; }
135 192
 .fe-person-name { font-weight: 600; }
136 193
 .fe-person-phone { font-size: 24rpx; color: #64748B; }
194
+
195
+.fe-progress-empty {
196
+	padding: 80rpx 32rpx;
197
+	text-align: center;
198
+}
199
+
200
+.fe-progress-empty--inline {
201
+	padding: 24rpx 0 40rpx;
202
+}
203
+
204
+.fe-progress-empty__text {
205
+	font-size: 28rpx;
206
+	color: #94A3B8;
207
+}
137 208
 </style>

+ 640 - 65
huimv-employment/app/packageA/components/home/EnterpriseHome.vue

@@ -89,7 +89,8 @@
89 89
 								<view class="fe-thinking-dots__dot" />
90 90
 							</view>
91 91
 							<view v-else class="fe-chat-bubble__text-wrap">
92
-								<text class="fe-chat-bubble__text" user-select>{{ msg.content }}</text>
92
+								<FeChatMarkdown v-if="msg.role === 'ai'" :content="msg.content" />
93
+								<text v-else class="fe-chat-bubble__text" user-select>{{ msg.content }}</text>
93 94
 							</view>
94 95
 						</view>
95 96
 					</view>
@@ -105,7 +106,7 @@
105 106
 								<view class="fe-biz-card__row"><text>3. 结算单待确认</text><text class="fe-biz-card__val">¥12,800</text></view>
106 107
 								<view class="fe-biz-card__actions">
107 108
 									<view class="fe-biz-btn" @tap.stop="toast('已发送催办')">一键催办</view>
108
-									<view class="fe-biz-btn" @tap.stop="openSheet('progress')">查看进度</view>
109
+									<view class="fe-biz-btn" @tap.stop="openProgressOverview">查看进度</view>
109 110
 								</view>
110 111
 							</view>
111 112
 						</view>
@@ -147,6 +148,34 @@
147 148
 						</view>
148 149
 					</view>
149 150
 
151
+					<!-- QR card(确认方案后) -->
152
+					<view v-else-if="msg.type === 'qr'" class="fe-chat-msg fe-chat-msg--ai" :id="'msg-' + idx">
153
+						<view class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
154
+						<view class="fe-chat-bubble">
155
+							<view class="fe-biz-card fe-biz-card--qr" @tap="openQrByIndex(idx)">
156
+								<view class="fe-biz-card__header">
157
+									<text class="fe-biz-card__header-title">工人登记二维码</text>
158
+								</view>
159
+								<text v-if="qrSubtitleByIndex(idx)" class="fe-qr-card__subtitle">{{ qrSubtitleByIndex(idx) }}</text>
160
+								<image
161
+									v-if="qrImageUrlByIndex(idx)"
162
+									class="fe-qr-card__img"
163
+									:src="qrImageUrlByIndex(idx)"
164
+									mode="aspectFit"
165
+									@tap.stop="openQrByIndex(idx)"
166
+								/>
167
+								<view v-else class="fe-qr-card__empty">
168
+									<text class="fe-qr-card__empty-text">二维码加载中,可点此刷新</text>
169
+								</view>
170
+								<text class="fe-qr-card__hint">{{ qrHintByIndex(idx) }}</text>
171
+								<view class="fe-biz-card__actions">
172
+									<view class="fe-biz-btn" @tap.stop="openProgressByQrIndex(idx)">查看进度</view>
173
+									<view class="fe-biz-btn fe-biz-btn--primary" @tap.stop="openQrByIndex(idx)">查看 / 分享</view>
174
+								</view>
175
+							</view>
176
+						</view>
177
+					</view>
178
+
150 179
 					<!-- Orders -->
151 180
 					<view v-else-if="msg.type === 'orders'" class="fe-chat-msg fe-chat-msg--ai" :id="'msg-' + idx">
152 181
 						<view class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
@@ -211,15 +240,15 @@
211 240
 		<!-- Footer: Quick Actions + Input -->
212 241
 		<view class="chat-footer">
213 242
 			<view class="fe-quick-actions fe-quick-actions--dock guide-target guide-target--quick-bar">
214
-				<view class="fe-quick-action" @tap="triggerAI('发布需求')">
243
+				<view class="fe-quick-action" @tap="triggerAI('新用工需求')">
215 244
 					<view class="fe-quick-action__icon" style="background:#FEF3C7;">➕</view>
216 245
 					<text class="fe-quick-action__label">发布需求</text>
217 246
 				</view>
218
-				<view class="fe-quick-action" @tap="openSheet('cost')">
247
+				<view class="fe-quick-action" @tap="openConversationCost">
219 248
 					<view class="fe-quick-action__icon" style="background:#D1FAE5;">💰</view>
220 249
 					<text class="fe-quick-action__label">成本测算</text>
221 250
 				</view>
222
-				<view class="fe-quick-action" @tap="openSheet('progress')">
251
+				<view class="fe-quick-action" @tap="openProgressOverview">
223 252
 					<view class="fe-quick-action__icon" style="background:#DBEAFE;">📈</view>
224 253
 					<text class="fe-quick-action__label">进度总览</text>
225 254
 				</view>
@@ -264,9 +293,24 @@
264 293
 			@updated="onDraftUpdated"
265 294
 			@view-qr="openDraftQr"
266 295
 		/>
267
-		<FeCostSheet :active="activeSheet === 'cost'" @confirm="confirmPlan" />
268
-		<FeReportSheet :active="activeSheet === 'report'" @progress="openSheet('progress')" />
269
-		<FeProgressSheet :active="activeSheet === 'progress'" :elevated="sheetElevated" />
296
+		<FeConversationCostSheet
297
+			:active="activeSheet === 'cost'"
298
+			:conversation-id="currentConversationId"
299
+			:draft-metas="costDraftMetas"
300
+			@confirm="onConversationCostConfirm"
301
+			@updated="onDraftUpdated($event, { sinkToBottom: false })"
302
+			@view-qr="openDraftQr"
303
+		/>
304
+		<FeReportSheet :active="activeSheet === 'report'" @progress="openProgressOverview" />
305
+		<FeConversationProgressSheet
306
+			:active="activeSheet === 'progressOverview'"
307
+			:conversation-id="currentConversationId"
308
+		/>
309
+		<FeProgressSheet
310
+			:active="activeSheet === 'progress'"
311
+			:elevated="sheetElevated"
312
+			:draft-id="progressDraftId"
313
+		/>
270 314
 		<FeSettlementSheet :active="activeSheet === 'settlement'" @pay="showPayment" />
271 315
 
272 316
 		<!-- Drawer: Message -->
@@ -313,7 +357,13 @@
313 357
 			</view>
314 358
 		</view>
315 359
 
316
-		<FeBatchQrModal :active="modal === 'qr'" :batch="qrBatch" @close="closeModal" @progress="openQrProgress" />
360
+		<FeBatchQrModal
361
+			:active="modal === 'qr'"
362
+			:batch="qrBatch"
363
+			@close="closeModal"
364
+			@progress="openQrProgress"
365
+			@progress-loaded="onQrProgressLoaded"
366
+		/>
317 367
 
318 368
 		<!-- Modal Payment -->
319 369
 		<view class="fe-modal" :class="{ 'fe-modal--active': modal === 'payment' }">
@@ -344,12 +394,14 @@
344 394
 <script>
345 395
 import EnterpriseHubPanel from '@/packageA/components/EnterpriseHubPanel.vue'
346 396
 import FeDraftSheet from '@/packageA/components/chat/FeDraftSheet.vue'
347
-import FeCostSheet from '@/packageA/components/chat/FeCostSheet.vue'
397
+import FeConversationCostSheet from '@/packageA/components/chat/FeConversationCostSheet.vue'
398
+import FeConversationProgressSheet from '@/packageA/components/chat/FeConversationProgressSheet.vue'
348 399
 import FeProgressSheet from '@/packageA/components/chat/FeProgressSheet.vue'
349 400
 import FeSettlementSheet from '@/packageA/components/chat/FeSettlementSheet.vue'
350 401
 import FeReportSheet from '@/packageA/components/chat/FeReportSheet.vue'
351 402
 import FeReportFloat from '@/packageA/components/chat/FeReportFloat.vue'
352 403
 import FeBatchQrModal from '@/packageA/components/chat/FeBatchQrModal.vue'
404
+import FeChatMarkdown from '@/packageA/components/chat/FeChatMarkdown.vue'
353 405
 import store from '@/common/store.js'
354 406
 import { MESSAGES, BATCH_QR, showToast } from '@/common/chat-data.js'
355 407
 import { listConversations, createConversation, streamConversationChat, listConversationMessages } from '@/api/conversation.js'
@@ -363,9 +415,15 @@ import {
363 415
 } from '@/utils/conversation.js'
364 416
 import {
365 417
 	shouldFetchDraftCard,
418
+	shouldShowOrderQrcode,
419
+	isDraftCardContentType,
366 420
 	buildDraftCardMessage,
367 421
 	mapDraftDetailToCard,
368
-	getDraftStatus
422
+	getDraftStatus,
423
+	pickDraftQrCodeUrl,
424
+	pickDraftQrToken,
425
+	parseChatCardPayload,
426
+	formatDraftMoney
369 427
 } from '@/utils/draft.js'
370 428
 import {
371 429
 	buildJobOfferFromDraft,
@@ -373,6 +431,7 @@ import {
373 431
 	buildJobOfferPageUrl,
374 432
 	buildJobOfferShareMessage
375 433
 } from '@/utils/job-offer.js'
434
+import { resolveApiAssetUrl } from '@/common/config.js'
376 435
 
377 436
 export default {
378 437
 	name: 'EnterpriseHome',
@@ -385,12 +444,14 @@ export default {
385 444
 	components: {
386 445
 		EnterpriseHubPanel,
387 446
 		FeDraftSheet,
388
-		FeCostSheet,
447
+		FeConversationCostSheet,
448
+		FeConversationProgressSheet,
389 449
 		FeProgressSheet,
390 450
 		FeSettlementSheet,
391 451
 		FeReportSheet,
392 452
 		FeReportFloat,
393
-		FeBatchQrModal
453
+		FeBatchQrModal,
454
+		FeChatMarkdown
394 455
 	},
395 456
 	data() {
396 457
 		return {
@@ -426,7 +487,9 @@ export default {
426 487
 			inbox: MESSAGES,
427 488
 			reportCardVisible: true,
428 489
 			reportObserver: null,
429
-			_localMsgSeq: 0
490
+			_localMsgSeq: 0,
491
+			progressDraftId: null,
492
+			costDraftMetas: []
430 493
 		}
431 494
 	},
432 495
 	computed: {
@@ -888,8 +951,13 @@ export default {
888 951
 					} else if (!this.messages[aiIdx].content) {
889 952
 						this.$set(this.messages[aiIdx], 'content', '暂无回复,请稍后重试')
890 953
 					}
954
+					// 仅当本轮 SSE 明确提到某草稿 id(card_draft / card_order)时,才将该草稿卡沉底
891 955
 					if (shouldFetchDraftCard(cardMeta)) {
892
-						await this.appendDraftCardById(cardMeta.relatedDraftId)
956
+						await this.appendDraftCardById(cardMeta.relatedDraftId, { sinkToBottom: true })
957
+					}
958
+					// next_action=show_order_qrcode → 弹出「方案已确认」
959
+					if (shouldShowOrderQrcode(cardMeta)) {
960
+						await this.showOrderConfirmFromChat(cardMeta)
893 961
 					}
894 962
 					this.ensureChatBottom()
895 963
 					this.stopChatStream()
@@ -945,29 +1013,65 @@ export default {
945 1013
 				if (draft.confirmedPlan) card.confirmedPlan = draft.confirmedPlan
946 1014
 				if (draft.confirmedPlanTitle) card.confirmedPlanTitle = draft.confirmedPlanTitle
947 1015
 				if (draft.orderId && !card.orderId) card.orderId = draft.orderId
948
-				if (draft.qrCodeUrl && !card.qrCodeUrl) card.qrCodeUrl = draft.qrCodeUrl
1016
+				if (draft.qrToken && !card.qrToken) card.qrToken = draft.qrToken
1017
+				if (draft.scene && !card.scene) card.scene = draft.scene
1018
+				// qrCodeUrl 以详情接口为准;详情暂无时才回退本地
1019
+				if (!card.qrCodeUrl && draft.qrCodeUrl) card.qrCodeUrl = draft.qrCodeUrl
1020
+				if (card.qrToken && !card.scene) card.scene = card.qrToken
949 1021
 				if (draft.selectedSchemeCode && !card.selectedSchemeCode) {
950 1022
 					card.selectedSchemeCode = draft.selectedSchemeCode
951 1023
 				}
952
-				this.onDraftUpdated(card)
1024
+				this.onDraftUpdated(card, { sinkToBottom: false })
953 1025
 			} catch (e) {
954 1026
 				// 静默失败,沿用本地草稿
955 1027
 			}
956 1028
 		},
957
-		onDraftUpdated(draftCard) {
1029
+		/**
1030
+		 * 移除会话中指定草稿卡片(保留对话文本)
1031
+		 */
1032
+		removeDraftCardById(draftId) {
1033
+			if (draftId == null) return
1034
+			const id = Number(draftId)
1035
+			if (Number.isNaN(id)) return
1036
+			for (let i = this.messages.length - 1; i >= 0; i--) {
1037
+				const msg = this.messages[i]
1038
+				if (msg && msg.type === 'draft' && msg.draftId === id) {
1039
+					this.messages.splice(i, 1)
1040
+				}
1041
+			}
1042
+		},
1043
+		/**
1044
+		 * 更新草稿卡片
1045
+		 * @param {object} draftCard
1046
+		 * @param {{ sinkToBottom?: boolean }} [options] sinkToBottom 默认 true:同 id 旧卡移除后沉底
1047
+		 */
1048
+		onDraftUpdated(draftCard, options = {}) {
958 1049
 			if (!draftCard || draftCard.draftId == null) return
959 1050
 			this.activeDraft = draftCard
960
-			for (let i = 0; i < this.messages.length; i++) {
961
-				const msg = this.messages[i]
962
-				if (msg.type === 'draft' && msg.draftId === draftCard.draftId) {
963
-					this.$set(this.messages[i], 'draft', draftCard)
964
-					break
1051
+			const sinkToBottom = options.sinkToBottom !== false
1052
+			if (!sinkToBottom) {
1053
+				for (let i = 0; i < this.messages.length; i++) {
1054
+					const msg = this.messages[i]
1055
+					if (msg.type === 'draft' && msg.draftId === draftCard.draftId) {
1056
+						this.$set(this.messages[i], 'draft', draftCard)
1057
+						break
1058
+					}
965 1059
 				}
1060
+				return
966 1061
 			}
1062
+			this.removeDraftCardById(draftCard.draftId)
1063
+			this.messages.push({
1064
+				type: 'draft',
1065
+				role: 'ai',
1066
+				msgKey: this.nextLocalMsgKey(),
1067
+				draftId: draftCard.draftId,
1068
+				draft: draftCard
1069
+			})
1070
+			this.ensureChatBottom()
967 1071
 		},
968 1072
 		/**
969
-		 * 历史消息中 content_type=card_draft 且带 related_draft_id 时,拉详情并插入草稿卡片
970
-		 * @param {Array} [sourceMessages] 仅处理给定列表,默认扫描当前全部消息
1073
+		 * 历史消息:每个 related_draft_id 对应一张草稿卡,插到「提到该 id」的文本之后;其它草稿互不影响
1074
+		 * @param {Array} [sourceMessages]
971 1075
 		 */
972 1076
 		async hydrateDraftCardsFromMessages(sourceMessages) {
973 1077
 			const list = Array.isArray(sourceMessages) ? sourceMessages : this.messages
@@ -975,26 +1079,37 @@ export default {
975 1079
 			for (let i = 0; i < list.length; i++) {
976 1080
 				const msg = list[i]
977 1081
 				if (!msg || msg.type !== 'text') continue
978
-				if (msg.contentType !== 'card_draft' || msg.relatedDraftId == null) continue
979
-				if (this.messages.some((m) => m.type === 'draft' && m.draftId === msg.relatedDraftId)) continue
980
-				if (draftIds.indexOf(msg.relatedDraftId) === -1) {
981
-					draftIds.push(msg.relatedDraftId)
982
-				}
1082
+				if (!isDraftCardContentType(msg.contentType) || msg.relatedDraftId == null) continue
1083
+				const id = msg.relatedDraftId
1084
+				const prev = draftIds.indexOf(id)
1085
+				if (prev >= 0) draftIds.splice(prev, 1)
1086
+				draftIds.push(id)
983 1087
 			}
984 1088
 			for (let i = 0; i < draftIds.length; i++) {
985
-				await this.appendDraftCardById(draftIds[i], { afterRelatedText: true })
1089
+				const id = draftIds[i]
1090
+				this.removeDraftCardById(id)
1091
+				await this.appendDraftCardById(id, { afterRelatedText: true })
986 1092
 			}
987 1093
 		},
988 1094
 		/**
989 1095
 		 * 查询草稿详情并插入聊天气泡卡片
990 1096
 		 * @param {number} draftId
991
-		 * @param {{ afterRelatedText?: boolean }} [options]
1097
+		 * @param {{ afterRelatedText?: boolean, sinkToBottom?: boolean }} [options]
1098
+		 *   afterRelatedText:插到提到该草稿 id 的文本后(历史还原)
1099
+		 *   sinkToBottom:本轮对话提到该 id 时,移除同 id 旧卡并沉底(其它草稿卡不动)
992 1100
 		 */
993 1101
 		async appendDraftCardById(draftId, options = {}) {
994 1102
 			if (draftId == null) return null
995 1103
 			const id = Number(draftId)
996 1104
 			if (Number.isNaN(id)) return null
997
-			if (this.messages.some((m) => m.type === 'draft' && m.draftId === id)) return null
1105
+			const exists = this.messages.some((m) => m.type === 'draft' && m.draftId === id)
1106
+			if (exists && options.sinkToBottom) {
1107
+				this.removeDraftCardById(id)
1108
+			} else if (exists && options.afterRelatedText) {
1109
+				return null
1110
+			} else if (exists) {
1111
+				return null
1112
+			}
998 1113
 
999 1114
 			try {
1000 1115
 				const detail = await getDraftDetail(id, { showError: false })
@@ -1005,16 +1120,27 @@ export default {
1005 1120
 					let insertAt = -1
1006 1121
 					for (let i = this.messages.length - 1; i >= 0; i--) {
1007 1122
 						const msg = this.messages[i]
1008
-						if (msg.type === 'text' && msg.relatedDraftId === id) {
1123
+						if (
1124
+							msg.type === 'text'
1125
+							&& msg.relatedDraftId === id
1126
+							&& isDraftCardContentType(msg.contentType)
1127
+						) {
1009 1128
 							insertAt = i + 1
1010 1129
 							break
1011 1130
 						}
1012 1131
 					}
1013
-					if (insertAt >= 0) {
1014
-						this.messages.splice(insertAt, 0, cardMsg)
1015
-					} else {
1016
-						this.messages.push(cardMsg)
1132
+					if (insertAt < 0) {
1133
+						for (let i = this.messages.length - 1; i >= 0; i--) {
1134
+							const msg = this.messages[i]
1135
+							if (msg.type === 'text' && msg.relatedDraftId === id) {
1136
+								insertAt = i + 1
1137
+								break
1138
+							}
1139
+						}
1017 1140
 					}
1141
+					// 只插到「提到该草稿 id」的消息后面,找不到则不误沉底
1142
+					if (insertAt < 0) return null
1143
+					this.messages.splice(insertAt, 0, cardMsg)
1018 1144
 				} else {
1019 1145
 					this.messages.push(cardMsg)
1020 1146
 				}
@@ -1079,15 +1205,21 @@ export default {
1079 1205
 			const subtitle = batchId ? `${sceneTitle} ${batchId}` : sceneTitle
1080 1206
 			const enterpriseName = (this.enterprise && this.enterprise.name) || ''
1081 1207
 			const scheme = options.scheme || (draft && draft.confirmedPlan) || null
1082
-			const qrCodeUrl = options.qrCodeUrl
1083
-				|| (draft && (draft.qrCodeUrl || draft.qr_code_url || draft.ar_code_url))
1208
+			const qrCodeUrl = options.qrCodeUrl || pickDraftQrCodeUrl(draft) || ''
1209
+			const qrToken = options.qrToken
1210
+				|| pickDraftQrToken(draft)
1211
+				|| options.orderId
1212
+				|| (draft && draft.orderId)
1084 1213
 				|| ''
1085 1214
 			const offer = buildJobOfferFromDraft(draft, {
1086 1215
 				batchId,
1087 1216
 				draftId,
1088 1217
 				scheme,
1089 1218
 				schemeTitle: options.schemeTitle || (scheme && scheme.title),
1090
-				enterpriseName
1219
+				enterpriseName,
1220
+				orderId: options.orderId || (draft && draft.orderId) || '',
1221
+				qrToken,
1222
+				scene: qrToken || options.scene || (draft && draft.scene) || ''
1091 1223
 			})
1092 1224
 			saveJobOffer(offer)
1093 1225
 			return Object.assign({}, BATCH_QR, {
@@ -1095,6 +1227,9 @@ export default {
1095 1227
 				title: sceneTitle,
1096 1228
 				batchId,
1097 1229
 				draftId: offer.draftId,
1230
+				orderId: offer.orderId || '',
1231
+				qrToken: offer.qrToken || qrToken || '',
1232
+				scene: offer.scene || offer.qrToken || qrToken || '',
1098 1233
 				subtitle,
1099 1234
 				qrCodeUrl,
1100 1235
 				total: workerCount > 0 ? workerCount : BATCH_QR.total,
@@ -1103,22 +1238,216 @@ export default {
1103 1238
 				shareLink: buildJobOfferPageUrl(offer)
1104 1239
 			})
1105 1240
 		},
1106
-		pickConfirmQrUrl(result) {
1107
-			if (!result || typeof result !== 'object') return ''
1108
-			return result.qrCodeUrl
1109
-				|| result.qr_code_url
1110
-				|| result.ar_code_url
1111
-				|| result.arCodeUrl
1241
+		/**
1242
+		 * 从草稿详情拉取最新 qrCodeUrl / qrToken,并回写 activeDraft
1243
+		 */
1244
+		async refreshDraftQrFromDetail(draftId) {
1245
+			if (draftId == null) return { qrCodeUrl: '', qrToken: '' }
1246
+			try {
1247
+				const detail = await getDraftDetail(draftId, { showError: false })
1248
+				if (!detail || detail.id == null) return { qrCodeUrl: '', qrToken: '' }
1249
+				const qrCodeUrl = pickDraftQrCodeUrl(detail)
1250
+				const qrToken = pickDraftQrToken(detail)
1251
+				const card = mapDraftDetailToCard(detail)
1252
+				const prev = this.activeDraft
1253
+				if (prev && prev.draftId === detail.id) {
1254
+					const merged = Object.assign({}, prev, card, {
1255
+						qrCodeUrl,
1256
+						qrToken,
1257
+						scene: qrToken || prev.scene || '',
1258
+						confirmedPlan: prev.confirmedPlan || card.confirmedPlan,
1259
+						confirmedPlanTitle: prev.confirmedPlanTitle || card.confirmedPlanTitle,
1260
+						costPlans: prev.costPlans || card.costPlans
1261
+					})
1262
+					this.onDraftUpdated(merged, { sinkToBottom: false })
1263
+				}
1264
+				return { qrCodeUrl, qrToken }
1265
+			} catch (e) {
1266
+				return { qrCodeUrl: '', qrToken: '' }
1267
+			}
1268
+		},
1269
+		/**
1270
+		 * 确认方案后在对话框追加二维码卡片(同 draft 只保留一张)
1271
+		 */
1272
+		appendQrCardMessage(batch) {
1273
+			if (!batch) return
1274
+			const draftId = batch.draftId
1275
+			if (draftId != null) {
1276
+				for (let i = this.messages.length - 1; i >= 0; i--) {
1277
+					const m = this.messages[i]
1278
+					if (m && m.type === 'qr' && m.draftId === draftId) {
1279
+						this.messages.splice(i, 1)
1280
+					}
1281
+				}
1282
+			}
1283
+			this.messages.push({
1284
+				type: 'qr',
1285
+				role: 'ai',
1286
+				msgKey: this.nextLocalMsgKey(),
1287
+				draftId: draftId != null ? draftId : null,
1288
+				batch: Object.assign({}, batch)
1289
+			})
1290
+			this.ensureChatBottom()
1291
+		},
1292
+		syncQrCardMessage(batch) {
1293
+			if (!batch || batch.draftId == null) return
1294
+			for (let i = this.messages.length - 1; i >= 0; i--) {
1295
+				const m = this.messages[i]
1296
+				if (m && m.type === 'qr' && m.draftId === batch.draftId) {
1297
+					this.$set(this.messages, i, Object.assign({}, m, {
1298
+						batch: Object.assign({}, batch)
1299
+					}))
1300
+					return
1301
+				}
1302
+			}
1303
+		},
1304
+		qrImageUrlByIndex(idx) {
1305
+			const msg = this.messages[idx]
1306
+			if (!msg || msg.type !== 'qr' || !msg.batch) return ''
1307
+			const raw = msg.batch.qrCodeUrl || msg.batch.qr_code_url || ''
1308
+			return resolveApiAssetUrl(raw)
1309
+		},
1310
+		qrSubtitleByIndex(idx) {
1311
+			const msg = this.messages[idx]
1312
+			if (!msg || !msg.batch) return ''
1313
+			return msg.batch.subtitle || msg.batch.title || ''
1314
+		},
1315
+		qrHintByIndex(idx) {
1316
+			const msg = this.messages[idx]
1317
+			if (!msg || !msg.batch) return '分享给临时工扫码即可登记'
1318
+			return msg.batch.hint || '分享给临时工扫码即可登记'
1319
+		},
1320
+		openQrByIndex(idx) {
1321
+			const msg = this.messages[idx]
1322
+			if (!msg || msg.type !== 'qr' || !msg.batch) return
1323
+			this.qrBatch = Object.assign({}, msg.batch)
1324
+			this.showQR()
1325
+		},
1326
+		openProgressByQrIndex(idx) {
1327
+			const msg = this.messages[idx]
1328
+			if (!msg || msg.type !== 'qr') return
1329
+			const draftId = msg.draftId
1330
+				|| (msg.batch && msg.batch.draftId)
1331
+			this.openProgressDetail(draftId)
1332
+		},
1333
+		/**
1334
+		 * AI 对话 next_action=show_order_qrcode:弹出方案已确认弹框(并可查看二维码)
1335
+		 */
1336
+		async showOrderConfirmFromChat(cardMeta) {
1337
+			const payload = parseChatCardPayload(cardMeta && cardMeta.cardPayload)
1338
+			const draftId = (cardMeta && cardMeta.relatedDraftId != null)
1339
+				? cardMeta.relatedDraftId
1340
+				: (payload && (payload.draft_id != null ? payload.draft_id : payload.draftId))
1341
+			let draft = null
1342
+			if (this.activeDraft && String(this.activeDraft.draftId) === String(draftId)) {
1343
+				draft = this.activeDraft
1344
+			} else if (draftId != null) {
1345
+				for (let i = this.messages.length - 1; i >= 0; i--) {
1346
+					const msg = this.messages[i]
1347
+					if (msg && msg.type === 'draft' && String(msg.draftId) === String(draftId) && msg.draft) {
1348
+						draft = msg.draft
1349
+						break
1350
+					}
1351
+				}
1352
+			}
1353
+			if (!draft && draftId != null) {
1354
+				try {
1355
+					const detail = await getDraftDetail(draftId, { showError: false })
1356
+					if (detail) draft = mapDraftDetailToCard(detail)
1357
+				} catch (e) {
1358
+					// ignore
1359
+				}
1360
+			}
1361
+
1362
+			const orderId = (payload && (payload.order_id != null ? payload.order_id : payload.orderId))
1363
+				|| (draft && draft.orderId)
1364
+				|| ''
1365
+			const title = (payload && payload.title)
1366
+				|| (draft && draft.title)
1367
+				|| (draft && draft.confirmedPlanTitle)
1368
+				|| ''
1369
+			let qrCodeUrl = (payload && (payload.qr_code_url || payload.qrCodeUrl))
1370
+				|| pickDraftQrCodeUrl(draft)
1112 1371
 				|| ''
1372
+			let qrToken = (payload && (payload.qr_token || payload.qrToken))
1373
+				|| pickDraftQrToken(draft)
1374
+				|| ''
1375
+			const totalOutflow = payload && (payload.total_outflow != null ? payload.total_outflow : payload.totalOutflow)
1376
+			const totalText = totalOutflow != null && totalOutflow !== ''
1377
+				? formatDraftMoney(totalOutflow)
1378
+				: ((draft && draft.estimatedTotalText) || '')
1379
+			const workerCount = (payload && (payload.worker_count != null ? payload.worker_count : payload.workerCount))
1380
+				|| (draft && draft.workerCount)
1381
+			let avgCost = ''
1382
+			if (totalOutflow != null && workerCount > 0) {
1383
+				avgCost = `${formatDraftMoney(Number(totalOutflow) / Number(workerCount))}/人`
1384
+			} else if (draft && draft.confirmedPlan && draft.confirmedPlan.avgCost) {
1385
+				avgCost = draft.confirmedPlan.avgCost
1386
+			}
1387
+
1388
+			this.confirmInfo = {
1389
+				title: title || '方案已确认',
1390
+				code: (draft && draft.selectedSchemeCode) || '',
1391
+				total: totalText,
1392
+				avgCost,
1393
+				tagsText: '',
1394
+				orderId,
1395
+				qrToken,
1396
+				qrCodeUrl,
1397
+				draftId
1398
+			}
1399
+
1400
+			const scene = qrToken || orderId || ''
1401
+			this.qrBatch = this.buildQrBatch(draft, {
1402
+				draftId,
1403
+				scheme: draft && draft.confirmedPlan,
1404
+				schemeTitle: title,
1405
+				qrCodeUrl,
1406
+				orderId,
1407
+				qrToken,
1408
+				scene
1409
+			})
1410
+
1411
+			if (draftId != null) {
1412
+				const fromDetail = await this.refreshDraftQrFromDetail(draftId)
1413
+				if (fromDetail && fromDetail.qrCodeUrl) {
1414
+					qrCodeUrl = fromDetail.qrCodeUrl
1415
+					this.$set(this.qrBatch, 'qrCodeUrl', fromDetail.qrCodeUrl)
1416
+					this.$set(this.confirmInfo, 'qrCodeUrl', fromDetail.qrCodeUrl)
1417
+				}
1418
+				if (fromDetail && fromDetail.qrToken) {
1419
+					qrToken = fromDetail.qrToken
1420
+					this.$set(this.qrBatch, 'qrToken', fromDetail.qrToken)
1421
+					this.$set(this.qrBatch, 'scene', fromDetail.qrToken)
1422
+					this.$set(this.confirmInfo, 'qrToken', fromDetail.qrToken)
1423
+				}
1424
+			}
1425
+
1426
+			if (draft && draftId != null) {
1427
+				this.onDraftUpdated(Object.assign({}, draft, {
1428
+					status: 'confirmed',
1429
+					orderId,
1430
+					qrToken,
1431
+					qrCodeUrl,
1432
+					scene: qrToken || orderId || ''
1433
+				}), { sinkToBottom: true })
1434
+			}
1435
+
1436
+			this.appendQrCardMessage(this.qrBatch)
1437
+			this.closeAll()
1438
+			setTimeout(() => { this.modal = 'confirm' }, 300)
1113 1439
 		},
1114
-		confirmPlan(payload) {
1440
+		async confirmPlan(payload) {
1115 1441
 			const scheme = (payload && payload.scheme) || null
1116 1442
 			const result = (payload && payload.result) || {}
1117 1443
 			const draft = this.activeDraft
1118 1444
 			const orderId = result.orderId || result.order_id || ''
1119
-			const qrCodeUrl = this.pickConfirmQrUrl(result)
1445
+			let qrCodeUrl = pickDraftQrCodeUrl(draft)
1446
+			let qrToken = pickDraftQrToken(result) || pickDraftQrToken(draft) || ''
1120 1447
 			const tags = (scheme && Array.isArray(scheme.compliance) ? scheme.compliance : [])
1121 1448
 				.filter(Boolean)
1449
+			const draftId = draft && draft.draftId
1450
+			const scene = qrToken || orderId || ''
1122 1451
 			this.confirmInfo = {
1123 1452
 				title: (scheme && scheme.title) || (payload && payload.schemeCode) || '',
1124 1453
 				code: (payload && payload.schemeCode) || (scheme && scheme.code) || '',
@@ -1126,18 +1455,24 @@ export default {
1126 1455
 				avgCost: (scheme && scheme.avgCost) || '',
1127 1456
 				tagsText: tags.join('、'),
1128 1457
 				orderId,
1129
-				qrCodeUrl
1458
+				qrToken,
1459
+				qrCodeUrl,
1460
+				draftId
1130 1461
 			}
1131 1462
 			this.qrBatch = this.buildQrBatch(draft, {
1132 1463
 				scheme,
1133 1464
 				schemeTitle: scheme && scheme.title,
1134
-				qrCodeUrl
1465
+				qrCodeUrl,
1466
+				orderId,
1467
+				qrToken,
1468
+				scene
1135 1469
 			})
1136
-			// 回写状态到气泡草稿,便于再次分享 / 查看二维码
1137
-			if (draft && draft.draftId != null) {
1470
+			if (draft && draftId != null) {
1138 1471
 				this.onDraftUpdated(Object.assign({}, draft, {
1139 1472
 					orderId,
1473
+					qrToken,
1140 1474
 					qrCodeUrl,
1475
+					scene,
1141 1476
 					status: 'confirmed',
1142 1477
 					confirmedPlan: (scheme && {
1143 1478
 						id: scheme.id || scheme.code,
@@ -1153,35 +1488,228 @@ export default {
1153 1488
 				}))
1154 1489
 			}
1155 1490
 			this.closeAll()
1491
+
1492
+			// 拉最新二维码 / qrToken 后插入对话
1493
+			if (draftId != null) {
1494
+				const fromDetail = await this.refreshDraftQrFromDetail(draftId)
1495
+				if (fromDetail && fromDetail.qrCodeUrl) {
1496
+					qrCodeUrl = fromDetail.qrCodeUrl
1497
+					this.$set(this.qrBatch, 'qrCodeUrl', fromDetail.qrCodeUrl)
1498
+					this.$set(this.confirmInfo, 'qrCodeUrl', fromDetail.qrCodeUrl)
1499
+				}
1500
+				if (fromDetail && fromDetail.qrToken) {
1501
+					qrToken = fromDetail.qrToken
1502
+					this.$set(this.qrBatch, 'qrToken', fromDetail.qrToken)
1503
+					this.$set(this.qrBatch, 'scene', fromDetail.qrToken)
1504
+					this.$set(this.confirmInfo, 'qrToken', fromDetail.qrToken)
1505
+				}
1506
+			}
1507
+			this.messages.push({
1508
+				type: 'text',
1509
+				role: 'ai',
1510
+				msgKey: this.nextLocalMsgKey(),
1511
+				content: '方案已确认。请将下方二维码分享给工人扫码登记。'
1512
+			})
1513
+			this.appendQrCardMessage(this.qrBatch)
1156 1514
 			setTimeout(() => { this.modal = 'confirm' }, 400)
1157 1515
 		},
1158
-		showQR() {
1516
+		async showQR() {
1517
+			const draftId = (this.qrBatch && this.qrBatch.draftId)
1518
+				|| (this.confirmInfo && this.confirmInfo.draftId)
1519
+				|| (this.activeDraft && this.activeDraft.draftId)
1520
+			let qrCodeUrl = pickDraftQrCodeUrl(this.qrBatch)
1521
+				|| pickDraftQrCodeUrl(this.activeDraft)
1522
+				|| (this.confirmInfo && this.confirmInfo.qrCodeUrl)
1523
+				|| ''
1524
+			let qrToken = pickDraftQrToken(this.qrBatch)
1525
+				|| pickDraftQrToken(this.activeDraft)
1526
+				|| (this.confirmInfo && this.confirmInfo.qrToken)
1527
+				|| ''
1528
+			if (draftId != null) {
1529
+				const fromDetail = await this.refreshDraftQrFromDetail(draftId)
1530
+				if (fromDetail && fromDetail.qrCodeUrl) qrCodeUrl = fromDetail.qrCodeUrl
1531
+				if (fromDetail && fromDetail.qrToken) qrToken = fromDetail.qrToken
1532
+			}
1159 1533
 			if (!this.qrBatch || !this.qrBatch.subtitle) {
1160
-				this.qrBatch = this.buildQrBatch(null, {
1534
+				this.qrBatch = this.buildQrBatch(this.activeDraft, {
1161 1535
 					schemeTitle: this.confirmInfo && this.confirmInfo.title,
1162
-					qrCodeUrl: this.confirmInfo && this.confirmInfo.qrCodeUrl
1536
+					draftId,
1537
+					qrCodeUrl,
1538
+					qrToken
1163 1539
 				})
1164
-			} else if (this.confirmInfo && this.confirmInfo.qrCodeUrl && !this.qrBatch.qrCodeUrl) {
1165
-				this.$set(this.qrBatch, 'qrCodeUrl', this.confirmInfo.qrCodeUrl)
1540
+			} else {
1541
+				this.$set(this.qrBatch, 'qrCodeUrl', qrCodeUrl)
1542
+				if (qrToken) {
1543
+					this.$set(this.qrBatch, 'qrToken', qrToken)
1544
+					this.$set(this.qrBatch, 'scene', qrToken)
1545
+				}
1546
+				if (draftId != null && this.qrBatch.draftId == null) {
1547
+					this.$set(this.qrBatch, 'draftId', draftId)
1548
+				}
1166 1549
 			}
1550
+			this.syncQrCardMessage(this.qrBatch)
1167 1551
 			this.modal = 'qr'
1168 1552
 		},
1169
-		openDraftQr() {
1170
-			this.qrBatch = this.buildQrBatch(this.activeDraft, {
1171
-				scheme: this.activeDraft && this.activeDraft.confirmedPlan,
1172
-				schemeTitle: this.activeDraft && this.activeDraft.confirmedPlanTitle,
1173
-				qrCodeUrl: this.activeDraft && (this.activeDraft.qrCodeUrl || this.activeDraft.qr_code_url)
1553
+		async openDraftQr(payload) {
1554
+			let draft = this.activeDraft
1555
+			const payloadDraftId = payload && payload.draftId
1556
+			if (payloadDraftId != null) {
1557
+				if (!draft || draft.draftId !== payloadDraftId) {
1558
+					if (this.activeDraft && this.activeDraft.draftId === payloadDraftId) {
1559
+						draft = this.activeDraft
1560
+					} else {
1561
+						for (let i = this.messages.length - 1; i >= 0; i--) {
1562
+							const msg = this.messages[i]
1563
+							if (msg && msg.type === 'draft' && msg.draftId === payloadDraftId && msg.draft) {
1564
+								draft = msg.draft
1565
+								break
1566
+							}
1567
+						}
1568
+					}
1569
+					if (draft) this.activeDraft = draft
1570
+				}
1571
+			}
1572
+			const draftId = (draft && draft.draftId) || payloadDraftId
1573
+			let qrCodeUrl = pickDraftQrCodeUrl(draft)
1574
+			let qrToken = pickDraftQrToken(draft)
1575
+			if (draftId != null) {
1576
+				const fromDetail = await this.refreshDraftQrFromDetail(draftId)
1577
+				if (fromDetail && fromDetail.qrCodeUrl) qrCodeUrl = fromDetail.qrCodeUrl
1578
+				if (fromDetail && fromDetail.qrToken) qrToken = fromDetail.qrToken
1579
+			}
1580
+			this.qrBatch = this.buildQrBatch(this.activeDraft || draft, {
1581
+				scheme: (this.activeDraft || draft) && (this.activeDraft || draft).confirmedPlan,
1582
+				schemeTitle: (this.activeDraft || draft) && (this.activeDraft || draft).confirmedPlanTitle,
1583
+				qrCodeUrl,
1584
+				qrToken
1174 1585
 			})
1175 1586
 			this.modal = 'qr'
1176 1587
 		},
1177 1588
 		/**
1178 1589
 		 * 供首页 onShareAppMessage 读取
1590
+		 * path 参数名仍为 scene,值为 qrToken
1179 1591
 		 */
1180 1592
 		getShareAppMessage() {
1181
-			const draftId = (this.qrBatch && this.qrBatch.draftId) || ''
1182
-			return buildJobOfferShareMessage({ draftId })
1593
+			const draftId = (this.qrBatch && this.qrBatch.draftId)
1594
+				|| (this.activeDraft && this.activeDraft.draftId)
1595
+				|| ''
1596
+			const orderId = (this.qrBatch && this.qrBatch.orderId)
1597
+				|| (this.activeDraft && this.activeDraft.orderId)
1598
+				|| (this.confirmInfo && this.confirmInfo.orderId)
1599
+				|| ''
1600
+			const qrToken = (this.qrBatch && this.qrBatch.qrToken)
1601
+				|| (this.activeDraft && this.activeDraft.qrToken)
1602
+				|| (this.confirmInfo && this.confirmInfo.qrToken)
1603
+				|| ''
1604
+			const scene = qrToken
1605
+				|| (this.qrBatch && this.qrBatch.scene)
1606
+				|| (this.activeDraft && this.activeDraft.scene)
1607
+				|| orderId
1608
+				|| ''
1609
+			return buildJobOfferShareMessage({ draftId, orderId, scene, qrToken })
1183 1610
 		},
1184 1611
 		openQrProgress() {
1612
+			const draftId = (this.qrBatch && this.qrBatch.draftId)
1613
+				|| (this.activeDraft && this.activeDraft.draftId)
1614
+				|| (this.confirmInfo && this.confirmInfo.draftId)
1615
+			this.openProgressDetail(draftId)
1616
+		},
1617
+		onQrProgressLoaded(payload) {
1618
+			if (!payload || !this.qrBatch) return
1619
+			if (payload.registered != null) {
1620
+				this.$set(this.qrBatch, 'registered', payload.registered)
1621
+			}
1622
+			if (payload.total != null) {
1623
+				this.$set(this.qrBatch, 'total', payload.total)
1624
+			}
1625
+			this.syncQrCardMessage(this.qrBatch)
1626
+		},
1627
+		openProgressOverview() {
1628
+			if (!this.currentConversationId) {
1629
+				this.toast('请先开启新对话')
1630
+				return
1631
+			}
1632
+			this.openSheet('progressOverview')
1633
+		},
1634
+		collectCostDraftMetas() {
1635
+			const metas = []
1636
+			const seen = {}
1637
+			const pushMeta = (draftId, title, workerCount) => {
1638
+				if (draftId == null || draftId === '') return
1639
+				const key = String(draftId)
1640
+				if (seen[key]) return
1641
+				seen[key] = true
1642
+				metas.push({
1643
+					draftId,
1644
+					title: title || `用工草稿 #${draftId}`,
1645
+					workerCount: workerCount != null ? Number(workerCount) : null
1646
+				})
1647
+			}
1648
+			for (let i = 0; i < this.messages.length; i++) {
1649
+				const msg = this.messages[i]
1650
+				if (!msg || msg.type !== 'draft' || msg.draftId == null) continue
1651
+				const d = msg.draft || {}
1652
+				pushMeta(msg.draftId, d.title, d.workerCount)
1653
+			}
1654
+			if (this.activeDraft && this.activeDraft.draftId != null) {
1655
+				pushMeta(
1656
+					this.activeDraft.draftId,
1657
+					this.activeDraft.title,
1658
+					this.activeDraft.workerCount
1659
+				)
1660
+			}
1661
+			return metas
1662
+		},
1663
+		openConversationCost() {
1664
+			if (!this.currentConversationId) {
1665
+				this.toast('请先开启新对话')
1666
+				return
1667
+			}
1668
+			this.costDraftMetas = this.collectCostDraftMetas()
1669
+			this.openSheet('cost')
1670
+		},
1671
+		async onConversationCostConfirm(payload) {
1672
+			const draftId = payload && payload.draftId
1673
+			if (draftId == null) {
1674
+				this.confirmPlan(payload)
1675
+				return
1676
+			}
1677
+			let draft = null
1678
+			if (this.activeDraft && this.activeDraft.draftId === draftId) {
1679
+				draft = this.activeDraft
1680
+			} else {
1681
+				for (let i = this.messages.length - 1; i >= 0; i--) {
1682
+					const msg = this.messages[i]
1683
+					if (msg && msg.type === 'draft' && msg.draftId === draftId && msg.draft) {
1684
+						draft = msg.draft
1685
+						break
1686
+					}
1687
+				}
1688
+			}
1689
+			if (!draft) {
1690
+				try {
1691
+					const detail = await getDraftDetail(draftId, { showError: false })
1692
+					if (detail) draft = mapDraftDetailToCard(detail)
1693
+				} catch (e) {
1694
+					// ignore
1695
+				}
1696
+			}
1697
+			if (draft) {
1698
+				this.activeDraft = draft
1699
+				this.onDraftUpdated(Object.assign({}, draft, {
1700
+					status: 'confirmed',
1701
+					selectedSchemeCode: (payload && payload.schemeCode) || draft.selectedSchemeCode,
1702
+					confirmedPlan: (payload && payload.scheme) || draft.confirmedPlan
1703
+				}), { sinkToBottom: false })
1704
+			}
1705
+			this.confirmPlan(payload)
1706
+		},
1707
+		openProgressDetail(draftId) {
1708
+			if (draftId == null || draftId === '') {
1709
+				showToast('缺少草稿信息,无法查看进度')
1710
+				return
1711
+			}
1712
+			this.progressDraftId = draftId
1185 1713
 			this.openSheet('progress')
1186 1714
 		},
1187 1715
 		showPayment() {
@@ -1455,4 +1983,51 @@ export default {
1455 1983
 	top: calc(96rpx + env(safe-area-inset-top));
1456 1984
 }
1457 1985
 /* #endif */
1986
+
1987
+.fe-biz-card--qr {
1988
+	border-left: 6rpx solid $fe-primary;
1989
+}
1990
+
1991
+.fe-qr-card__subtitle {
1992
+	display: block;
1993
+	font-size: 24rpx;
1994
+	color: $fe-muted;
1995
+	margin-bottom: 20rpx;
1996
+	line-height: 1.4;
1997
+}
1998
+
1999
+.fe-qr-card__img {
2000
+	display: block;
2001
+	width: 360rpx;
2002
+	height: 360rpx;
2003
+	margin: 0 auto 20rpx;
2004
+	background: #fff;
2005
+}
2006
+
2007
+.fe-qr-card__empty {
2008
+	display: flex;
2009
+	align-items: center;
2010
+	justify-content: center;
2011
+	width: 360rpx;
2012
+	height: 360rpx;
2013
+	margin: 0 auto 20rpx;
2014
+	background: $fe-primary-bg;
2015
+	border-radius: 16rpx;
2016
+}
2017
+
2018
+.fe-qr-card__empty-text {
2019
+	font-size: 24rpx;
2020
+	color: $fe-muted;
2021
+	text-align: center;
2022
+	padding: 0 24rpx;
2023
+}
2024
+
2025
+.fe-qr-card__hint {
2026
+	display: block;
2027
+	font-size: 24rpx;
2028
+	color: $fe-muted;
2029
+	text-align: center;
2030
+	margin-bottom: 20rpx;
2031
+	line-height: 1.4;
2032
+}
1458 2033
 </style>

+ 1 - 1
huimv-employment/app/packageA/home/index.vue

@@ -83,7 +83,7 @@ export default {
83 83
 		}
84 84
 		return {
85 85
 			title: '用工邀请',
86
-			path: '/pages/worker/job-offer?draftId=',
86
+			path: '/pages/worker/job-offer?scene=',
87 87
 			imageUrl: '/static/images/job.jpg'
88 88
 		}
89 89
 	},

+ 1 - 1
huimv-employment/app/pages/index/index.vue

@@ -23,7 +23,7 @@ export default {
23 23
 	onShareAppMessage() {
24 24
 		return {
25 25
 			title: '用工邀请',
26
-			path: '/pages/worker/job-offer?draftId=',
26
+			path: '/pages/worker/job-offer?scene=',
27 27
 			imageUrl: '/static/images/job.jpg'
28 28
 		}
29 29
 	}

+ 60 - 16
huimv-employment/app/pages/worker/job-offer.vue

@@ -52,8 +52,8 @@
52 52
 
53 53
 					<view class="offer-tips">
54 54
 						<text class="offer-tips__title">申请说明</text>
55
-						<text class="offer-tips__item">1. 点击下方「申请加入」填写实名与银行卡信息</text>
56
-						<text class="offer-tips__item">2. 企业确认后即可参与本次用工</text>
55
+						<text class="offer-tips__item">1. 需使用临时工账号登录,并完成实名档案登记</text>
56
+						<text class="offer-tips__item">2. 点击「申请加入」后等待企业确认</text>
57 57
 						<text class="offer-tips__item">3. 请确保本人信息真实有效,便于结算到账</text>
58 58
 					</view>
59 59
 				</template>
@@ -74,7 +74,8 @@
74 74
 import FeMpNavBar from '@/components/FeMpNavBar.vue'
75 75
 import store from '@/common/store.js'
76 76
 import { showToast } from '@/common/chat-data.js'
77
-import { getDraftDetail } from '@/api/draft.js'
77
+import { getPublicDraftDetail } from '@/api/draft.js'
78
+import { applyWorkerRegistration } from '@/api/worker-registration.js'
78 79
 import {
79 80
 	loadJobOffer,
80 81
 	parseJobOfferQuery,
@@ -82,8 +83,10 @@ import {
82 83
 	getDemoJobOffer,
83 84
 	saveJobOffer,
84 85
 	resolveDraftIdFromQuery,
86
+	resolveSceneFromQuery,
85 87
 	buildJobOfferFromDraftDetail,
86
-	buildJobOfferShareMessage
88
+	buildJobOfferShareMessage,
89
+	buildJobOfferPageUrl
87 90
 } from '@/utils/job-offer.js'
88 91
 
89 92
 export default {
@@ -105,6 +108,12 @@ export default {
105 108
 		},
106 109
 		dailyWageText() {
107 110
 			return this.offer.dailyWage != null ? `${this.offer.dailyWage} 元/天` : '待确认'
111
+		},
112
+		/** 申请 / 公开查询用的 scene(qrToken) */
113
+		applyScene() {
114
+			const o = this.offer || {}
115
+			const raw = o.qrToken || o.scene || o.orderId || ''
116
+			return raw == null ? '' : String(raw).trim()
108 117
 		}
109 118
 	},
110 119
 	onLoad(options) {
@@ -116,25 +125,34 @@ export default {
116 125
 	},
117 126
 	methods: {
118 127
 		async hydrateOffer(query) {
128
+			const scene = resolveSceneFromQuery(query)
119 129
 			const fromQuery = parseJobOfferQuery(query)
120 130
 			const draftId = resolveDraftIdFromQuery(query)
121 131
 			const cached = loadJobOffer(draftId)
122 132
 			const demo = getDemoJobOffer(draftId)
123
-			this.offer = mergeJobOffer(demo, cached, fromQuery)
133
+			this.offer = mergeJobOffer(demo, cached, fromQuery, scene ? { scene, qrToken: scene } : null)
124 134
 
125
-			if (draftId == null) {
135
+			if (!scene) {
126 136
 				saveJobOffer(this.offer)
127 137
 				return
128 138
 			}
129 139
 
130 140
 			this.loading = true
131 141
 			try {
132
-				const detail = await getDraftDetail(draftId, { showError: false })
142
+				const detail = await getPublicDraftDetail(scene, { showError: false, auth: false })
133 143
 				const fromApi = buildJobOfferFromDraftDetail(detail, {
134
-					enterpriseName: (store.getState().enterprise && store.getState().enterprise.name) || ''
144
+					enterpriseName: (store.getState().enterprise && store.getState().enterprise.name) || '',
145
+					scene,
146
+					qrToken: scene,
147
+					orderId: scene
135 148
 				})
136 149
 				if (fromApi) {
137
-					this.offer = mergeJobOffer(this.offer, fromApi, { draftId })
150
+					this.offer = mergeJobOffer(this.offer, fromApi, {
151
+						draftId: fromApi.draftId != null ? fromApi.draftId : draftId,
152
+						scene,
153
+						qrToken: scene,
154
+						orderId: scene
155
+					})
138 156
 					saveJobOffer(this.offer)
139 157
 				}
140 158
 			} catch (e) {
@@ -152,16 +170,28 @@ export default {
152 170
 			uni.reLaunch({ url: '/packageA/auth/login?role=worker' })
153 171
 		},
154 172
 		buildReturnUrl() {
155
-			const draftId = this.offer.draftId != null ? this.offer.draftId : ''
156
-			return `/pages/worker/job-offer?draftId=${encodeURIComponent(draftId)}`
173
+			return buildJobOfferPageUrl(this.offer)
157 174
 		},
158 175
 		goWorkerLogin(returnUrl) {
159 176
 			uni.navigateTo({
160 177
 				url: `/packageA/auth/login?role=worker&redirect=${encodeURIComponent(returnUrl)}`
161 178
 			})
162 179
 		},
163
-		onApply() {
180
+		goWorkerRegister(returnUrl) {
181
+			const draftId = this.offer.draftId != null ? this.offer.draftId : ''
182
+			uni.navigateTo({
183
+				url: `/packageA/worker/register?draftId=${encodeURIComponent(draftId)}&redirect=${encodeURIComponent(returnUrl)}`
184
+			})
185
+		},
186
+		async onApply() {
164 187
 			if (this.applying || this.loading) return
188
+
189
+			const scene = this.applyScene
190
+			if (!scene) {
191
+				showToast('缺少邀请参数,请重新扫码或从分享链接进入')
192
+				return
193
+			}
194
+
165 195
 			this.applying = true
166 196
 			try {
167 197
 				saveJobOffer(this.offer)
@@ -176,16 +206,30 @@ export default {
176 206
 					return
177 207
 				}
178 208
 				if (!store.isWorkerRegistered()) {
179
-					uni.navigateTo({
180
-						url: `/packageA/worker/register?draftId=${encodeURIComponent(draftId)}&redirect=${encodeURIComponent(returnUrl)}`
181
-					})
209
+					showToast('请先完成档案登记')
210
+					setTimeout(() => this.goWorkerRegister(returnUrl), 300)
182 211
 					return
183 212
 				}
184 213
 
185
-				showToast('申请已提交,等待企业确认')
214
+				const workType = this.offer.workType
215
+				const confirmedWorkType = workType && workType !== '待确认' ? workType : ''
216
+				const res = await applyWorkerRegistration({
217
+					scene,
218
+					confirmedWorkType
219
+				})
220
+				const msg = (res && res.message)
221
+					|| (res && res.status === 'already_applied'
222
+						? '您已申请加入该用工,请等待企业确认'
223
+						: '申请已提交,请等待企业确认')
224
+				showToast(msg)
186 225
 				setTimeout(() => {
187 226
 					uni.reLaunch({ url: store.getHomeUrl() })
188 227
 				}, 600)
228
+			} catch (e) {
229
+				const msg = (e && (e.msg || e.message)) || ''
230
+				if (msg.indexOf('档案登记') >= 0) {
231
+					setTimeout(() => this.goWorkerRegister(this.buildReturnUrl()), 400)
232
+				}
189 233
 			} finally {
190 234
 				this.applying = false
191 235
 			}

+ 10 - 12
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/hz-novice-guidance.vue

@@ -43,13 +43,9 @@
43 43
   新手引导组件
44 44
 -->
45 45
 <script>
46
-	import useArrow from './mixins/useArrow.js'
47
-	import useGetDomInfo from './mixins/useGetDomInfo.js'
48
-	import useOuterBtn from './mixins/useOuterBtn.js'
49
-	import useStepTips from './mixins/useStepTips.js'
50
-	import useGuard from './mixins/useGuard.js'
46
+	import guidanceMixins from './mixins/index.js'
51 47
 	export default {
52
-		mixins: [useGetDomInfo, useArrow, useOuterBtn, useStepTips, useGuard],
48
+		mixins: guidanceMixins,
53 49
 		props: {
54 50
 			// 总体配置
55 51
 			baseConfig: {
@@ -119,10 +115,10 @@
119 115
 
120 116
 				return {
121 117
 					'--bgcolor': 'rgba(0, 0, 0, 0.5)',
122
-					...(this.realBaseConfig?.highligthStyle || {}),
118
+					...((this.realBaseConfig && this.realBaseConfig.highligthStyle) || {}),
123 119
 					...(style || {}),
124
-					top: top + (offset?.top || 0) + 'px',
125
-					left: left + (offset?.left || 0) + 'px',
120
+					top: top + ((offset && offset.top) || 0) + 'px',
121
+					left: left + ((offset && offset.left) || 0) + 'px',
126 122
 					width: width + 'px',
127 123
 					height: height + 'px',
128 124
 				}
@@ -197,14 +193,16 @@
197 193
 			},
198 194
 			// 获取配置
199 195
 			getBaseConfigByKey(key, isBoolean = false) {
200
-				const val = this.currentEleConfig?.[key]
196
+				const stepConf = this.currentEleConfig || {}
197
+				const baseConf = this.realBaseConfig || {}
198
+				const val = stepConf[key]
201 199
 				if (!isBoolean) {
202
-					return val || this.realBaseConfig?.[key] || false
200
+					return val || baseConf[key] || false
203 201
 				}
204 202
 				if (typeof val != 'undefined' && typeof val != null) {
205 203
 					return val
206 204
 				}
207
-				return this.realBaseConfig?.[key] || false
205
+				return baseConf[key] || false
208 206
 			},
209 207
 			// 延迟
210 208
 			sleep(time) {

+ 7 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/index.js

@@ -0,0 +1,7 @@
1
+import useArrow from './useArrow.js'
2
+import useGetDomInfo from './useGetDomInfo.js'
3
+import useOuterBtn from './useOuterBtn.js'
4
+import useStepTips from './useStepTips.js'
5
+import useGuard from './useGuard.js'
6
+
7
+export default [useGetDomInfo, useArrow, useOuterBtn, useStepTips, useGuard]

+ 10 - 7
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useArrow.js

@@ -26,8 +26,9 @@ export default {
26 26
 				width,
27 27
 				height
28 28
 			} = this.currentEleAfterRenderInfo
29
-			const arrowWidth = this.arrowStyleInfo?.width || 0
30
-			const arrowHeight = this.arrowStyleInfo?.height || 0
29
+			const arrowInfo = this.arrowStyleInfo || {}
30
+			const arrowWidth = arrowInfo.width || 0
31
+			const arrowHeight = arrowInfo.height || 0
31 32
 			const arrowTop = this.tipsPosition == 'top' ? 0 - arrowHeight : height
32 33
 			const positionStyle = {
33 34
 				top: top + arrowTop + 'px',
@@ -41,10 +42,12 @@ export default {
41 42
 		},
42 43
 		// 箭头样式配置
43 44
 		arrowStyleConfig() {
45
+			const base = (this.realBaseConfig && this.realBaseConfig.arrowStyle) || {}
46
+			const step = (this.currentEleConfig && this.currentEleConfig.arrowStyle) || {}
44 47
 			return {
45 48
 				arrowStyle: {
46
-					...(this.realBaseConfig?.arrowStyle || {}),
47
-					...(this.currentEleConfig?.arrowStyle || {}),
49
+					...base,
50
+					...step
48 51
 				}
49 52
 			}
50 53
 		},
@@ -61,7 +64,7 @@ export default {
61 64
 				width,
62 65
 				height
63 66
 			} = this.arrowStyleInfo
64
-			const arrowColor = arrowStyle?.color ? arrowStyle.color : '#fff'
67
+			const arrowColor = (arrowStyle && arrowStyle.color) ? arrowStyle.color : '#fff'
65 68
 			const border = {
66 69
 				[this.tipsPosition == 'top' ?
67 70
 					'borderTop' :
@@ -91,8 +94,8 @@ export default {
91 94
 				arrowStyle
92 95
 			} = this.arrowStyleConfig
93 96
 			return {
94
-				width: arrowStyle?.width ? parseFloat(arrowStyle.width) : 18,
95
-				height: arrowStyle?.height ? parseFloat(arrowStyle.height) : 12,
97
+				width: (arrowStyle && arrowStyle.width) ? parseFloat(arrowStyle.width) : 18,
98
+				height: (arrowStyle && arrowStyle.height) ? parseFloat(arrowStyle.height) : 12,
96 99
 			}
97 100
 		}
98 101
 	},

+ 1 - 1
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useGetDomInfo.js

@@ -106,7 +106,7 @@ export default {
106 106
 				// 这里判断是否存在页面标题,如果存在,scrollTop 需要减去标题高度
107 107
 				const pageHeaderQuery = document.querySelector('.uni-page-head')
108 108
 				if (pageHeaderQuery) {
109
-					const pageHeaderRect = pageHeaderQuery?.getBoundingClientRect()
109
+					const pageHeaderRect = pageHeaderQuery.getBoundingClientRect()
110 110
 					scrollTop += pageHeaderRect.height
111 111
 				}
112 112
 				resolve(scrollTop)

+ 8 - 6
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useStepTips.js

@@ -30,8 +30,9 @@ export default {
30 30
 			const {
31 31
 				baseAlign = 'left', tipStyle
32 32
 			} = this.currentEleConfig
33
-			const arrowHeight = this.arrowStyleInfo?.height || 0
34
-			const arrowWidth = this.arrowStyleInfo?.width || 0
33
+			const arrowInfo = this.arrowStyleInfo || {}
34
+			const arrowHeight = arrowInfo.height || 0
35
+			const arrowWidth = arrowInfo.width || 0
35 36
 			const tipsTop = this.tipsPosition == 'top' ? top - arrowHeight - _tipsDomInfo.height : top + height + arrowHeight
36 37
 			let tipsLeft = left
37 38
 
@@ -92,11 +93,11 @@ export default {
92 93
 		// 提示框元素信息
93 94
 		tipsElementInfo() {
94 95
 			const _tipsStyle = this.tipsStyle
95
-			const _tipsDomInfo = this.tipsDomInfo
96
+			const _tipsDomInfo = this.tipsDomInfo || {}
96 97
 			const needFields = ['top', 'left']
97 98
 			const info = {
98
-				width: _tipsDomInfo?.width || 0,
99
-				height: _tipsDomInfo?.height || 0,
99
+				width: _tipsDomInfo.width || 0,
100
+				height: _tipsDomInfo.height || 0,
100 101
 			}
101 102
 			for (const field of needFields) {
102 103
 				info[field] = _tipsStyle[field] ? parseFloat(_tipsStyle[field].split('px')[0]) : 0
@@ -106,8 +107,9 @@ export default {
106 107
 		// 【下一步】按钮文字
107 108
 		nextText() {
108 109
 			const baseNext = this.getBaseConfigByKey('next') || '下一步'
110
+			const stepNext = this.currentEleConfig && this.currentEleConfig.next
109 111
 			return this.currentStep == this.stepList.length - 1 ?
110
-				this.currentEleConfig?.next || '完成' :
112
+				(stepNext || '完成') :
111 113
 				baseNext
112 114
 		},
113 115
 		// 【下一步】按钮样式

+ 80 - 8
huimv-employment/app/utils/draft.js

@@ -10,24 +10,32 @@ const SCHEME_ICONS = {
10 10
 	SCHEME_B: '💡'
11 11
 }
12 12
 
13
+export const NEXT_ACTION_SHOW_ORDER_QRCODE = 'show_order_qrcode'
14
+
13 15
 /**
14 16
  * 从 SSE 事件 JSON 提取卡片元数据(camelCase / snake_case 兼容)
17
+ * - content_type=card_draft|card_order|card_cost
18
+ * - 或 next_action=show_order_qrcode(方案已确认弹框)
15 19
  */
16 20
 export function extractChatCardMeta(data) {
17 21
 	if (!data || typeof data !== 'object') return null
18 22
 
19 23
 	const contentType = data.content_type || data.contentType || null
24
+	const nextAction = data.next_action || data.nextAction || null
25
+	const isOrderQrAction = nextAction === NEXT_ACTION_SHOW_ORDER_QRCODE
26
+	const isKnownCardType = contentType === 'card_draft'
27
+		|| contentType === 'card_order'
28
+		|| contentType === 'card_cost'
29
+
30
+	if (!isKnownCardType && !isOrderQrAction) return null
31
+	if (contentType === 'text' && !isOrderQrAction) return null
32
+
20 33
 	const rawDraftId = data.related_draft_id != null ? data.related_draft_id : data.relatedDraftId
21 34
 	const relatedDraftId = rawDraftId != null && rawDraftId !== '' ? Number(rawDraftId) : null
22
-	const nextAction = data.next_action || data.nextAction || null
23 35
 	const cardPayload = data.card_payload || data.cardPayload || null
24 36
 
25
-	if (!contentType && (relatedDraftId == null || Number.isNaN(relatedDraftId)) && !cardPayload && !nextAction) {
26
-		return null
27
-	}
28
-
29 37
 	return {
30
-		contentType,
38
+		contentType: isKnownCardType ? contentType : (isOrderQrAction ? 'card_order' : contentType),
31 39
 		relatedDraftId: relatedDraftId != null && !Number.isNaN(relatedDraftId) ? relatedDraftId : null,
32 40
 		nextAction,
33 41
 		cardPayload
@@ -48,18 +56,52 @@ export function mergeChatCardMeta(prev, next) {
48 56
 	}
49 57
 }
50 58
 
59
+/**
60
+ * 会触发拉取用工草稿并展示聊天气泡的 content_type
61
+ * card_draft:待确认草稿;card_order:已确认订单(related_draft_id 仍为草稿 id)
62
+ */
63
+export function isDraftCardContentType(contentType) {
64
+	return contentType === 'card_draft' || contentType === 'card_order'
65
+}
66
+
51 67
 /**
52 68
  * 是否应查询用工草稿详情并生成草稿卡片
53 69
  */
54 70
 export function shouldFetchDraftCard(meta) {
55 71
 	return !!(
56 72
 		meta &&
57
-		meta.contentType === 'card_draft' &&
73
+		isDraftCardContentType(meta.contentType) &&
58 74
 		meta.relatedDraftId != null &&
59 75
 		!Number.isNaN(Number(meta.relatedDraftId))
60 76
 	)
61 77
 }
62 78
 
79
+/**
80
+ * AI 对话:下一步为展示订单二维码(方案已确认弹框)
81
+ */
82
+export function shouldShowOrderQrcode(meta) {
83
+	if (!meta) return false
84
+	const action = meta.nextAction || meta.next_action || ''
85
+	return action === NEXT_ACTION_SHOW_ORDER_QRCODE
86
+}
87
+
88
+/**
89
+ * 解析 SSE / 消息中的 card_payload(对象或 JSON 字符串)
90
+ */
91
+export function parseChatCardPayload(raw) {
92
+	if (!raw) return null
93
+	if (typeof raw === 'object') return raw
94
+	if (typeof raw !== 'string') return null
95
+	const text = raw.trim()
96
+	if (!text) return null
97
+	try {
98
+		const parsed = JSON.parse(text)
99
+		return parsed && typeof parsed === 'object' ? parsed : null
100
+	} catch (e) {
101
+		return null
102
+	}
103
+}
104
+
63 105
 /**
64 106
  * 草稿状态
65 107
  */
@@ -211,6 +253,32 @@ export function mapCostSchemesToPlans(schemes = [], ctx = {}) {
211 253
 	})
212 254
 }
213 255
 
256
+/**
257
+ * 从草稿详情 / 卡片结构读取登记小程序码 URL(优先小驼峰 qrCodeUrl)
258
+ */
259
+export function pickDraftQrCodeUrl(source) {
260
+	if (!source || typeof source !== 'object') return ''
261
+	const nested = source.detail && typeof source.detail === 'object' ? source.detail : null
262
+	return source.qrCodeUrl
263
+		|| source.qr_code_url
264
+		|| (nested && (nested.qrCodeUrl || nested.qr_code_url))
265
+		|| ''
266
+}
267
+
268
+/**
269
+ * 从草稿详情 / 确认响应读取登记 token(优先小驼峰 qrToken)
270
+ * 与公开查询 / 小程序码 scene 值一致
271
+ */
272
+export function pickDraftQrToken(source) {
273
+	if (!source || typeof source !== 'object') return ''
274
+	const nested = source.detail && typeof source.detail === 'object' ? source.detail : null
275
+	const raw = source.qrToken
276
+		|| source.qr_token
277
+		|| (nested && (nested.qrToken || nested.qr_token))
278
+		|| ''
279
+	return raw == null ? '' : String(raw).trim()
280
+}
281
+
214 282
 /**
215 283
  * 草稿详情 → 聊天气泡展示结构
216 284
  */
@@ -242,9 +310,13 @@ export function mapDraftDetailToCard(detail = {}) {
242 310
 		estimatedTotalText: formatDraftMoney(detail.estimatedTotal),
243 311
 		estimatedPerCapita: detail.estimatedPerCapita,
244 312
 		selectedPlanId: detail.selectedPlanId,
245
-		selectedSchemeCode: detail.selectedSchemeCode || null,
313
+		selectedSchemeCode: detail.selectedSchemeCode
314
+			|| detail.selected_scheme_code
315
+			|| null,
246 316
 		status: detail.status,
247 317
 		missingFields: detail.missingFields || [],
318
+		qrCodeUrl: pickDraftQrCodeUrl(detail),
319
+		qrToken: pickDraftQrToken(detail),
248 320
 		detail
249 321
 	}
250 322
 }

+ 116 - 6
huimv-employment/app/utils/job-offer.js

@@ -69,6 +69,21 @@ export function buildJobOfferFromDraft(draft, options = {}) {
69 69
 
70 70
 	return {
71 71
 		draftId,
72
+		orderId: options.orderId
73
+			|| (draft && draft.orderId)
74
+			|| detail.orderId
75
+			|| null,
76
+		qrToken: options.qrToken
77
+			|| (draft && draft.qrToken)
78
+			|| detail.qrToken
79
+			|| detail.qr_token
80
+			|| null,
81
+		scene: options.scene
82
+			|| options.qrToken
83
+			|| (draft && (draft.qrToken || draft.scene))
84
+			|| detail.qrToken
85
+			|| detail.qr_token
86
+			|| null,
72 87
 		batchId,
73 88
 		title,
74 89
 		workType: workType && workType !== '待补充' ? workType : '待确认',
@@ -98,7 +113,10 @@ export function buildJobOfferFromDraftDetail(detail, options = {}) {
98 113
 		schemeTitle: options.schemeTitle,
99 114
 		enterpriseName: options.enterpriseName,
100 115
 		netPayText: options.netPayText,
101
-		netPay: options.netPay
116
+		netPay: options.netPay,
117
+		orderId: options.orderId || '',
118
+		qrToken: options.qrToken || card.qrToken || '',
119
+		scene: options.scene || options.qrToken || card.qrToken || options.orderId || ''
102 120
 	})
103 121
 }
104 122
 
@@ -124,10 +142,40 @@ export function loadJobOffer(draftId) {
124 142
 	}
125 143
 }
126 144
 
145
+/**
146
+ * 解析落地页 / 分享用的 scene(优先 URL scene,其次确认后的订单号)
147
+ */
148
+export function resolveSceneFromQuery(query = {}) {
149
+	if (query.scene != null && query.scene !== '') {
150
+		let raw = String(query.scene).trim()
151
+		try {
152
+			raw = decodeURIComponent(raw)
153
+		} catch (e) {
154
+			// ignore
155
+		}
156
+		return raw
157
+	}
158
+	if (query.orderId != null && query.orderId !== '') {
159
+		const oid = String(query.orderId).trim()
160
+		if (oid.indexOf(JOB_OFFER_SCENE_PREFIX) === 0) return oid
161
+		if (/^\d+$/.test(oid)) return buildJobOfferScene(oid)
162
+		return oid
163
+	}
164
+	if (query.draftId != null && query.draftId !== '') {
165
+		return buildJobOfferScene(query.draftId)
166
+	}
167
+	return ''
168
+}
169
+
127 170
 /**
128 171
  * 解析落地页 query 中的草稿 ID
172
+ * 优先 scene=ord_{id},兼容旧 draftId=
129 173
  */
130 174
 export function resolveDraftIdFromQuery(query = {}) {
175
+	if (query.scene != null && query.scene !== '') {
176
+		const fromScene = parseDraftIdFromScene(query.scene)
177
+		if (fromScene != null) return fromScene
178
+	}
131 179
 	if (query.draftId == null || query.draftId === '') return null
132 180
 	const n = Number(query.draftId)
133 181
 	return Number.isNaN(n) ? null : n
@@ -162,6 +210,9 @@ export function parseJobOfferQuery(query = {}) {
162 210
 export function mergeJobOffer(...parts) {
163 211
 	const base = {
164 212
 		draftId: null,
213
+		orderId: null,
214
+		qrToken: '',
215
+		scene: '',
165 216
 		batchId: '',
166 217
 		title: '临时用工邀请',
167 218
 		workType: '待确认',
@@ -180,7 +231,6 @@ export function mergeJobOffer(...parts) {
180 231
 		const part = parts[i]
181 232
 		if (!part || typeof part !== 'object') continue
182 233
 		Object.keys(part).forEach((key) => {
183
-			if (key === 'orderId') return
184 234
 			const val = part[key]
185 235
 			if (val != null && val !== '') base[key] = val
186 236
 		})
@@ -209,17 +259,77 @@ export function getDemoJobOffer(draftId) {
209 259
 }
210 260
 
211 261
 export function buildJobOfferPageUrl(offer) {
212
-	const draftId = offer && offer.draftId != null ? offer.draftId : ''
213
-	if (draftId === '' || draftId == null) return '/pages/worker/job-offer'
214
-	return `/pages/worker/job-offer?draftId=${encodeURIComponent(draftId)}`
262
+	const scene = resolveJobOfferScene(offer)
263
+	if (!scene) return '/pages/worker/job-offer'
264
+	// 参数名仍为 scene,值为 qrToken
265
+	return `/pages/worker/job-offer?scene=${encodeURIComponent(scene)}`
215 266
 }
216 267
 
217 268
 /** 微信分享用工邀请卡片封面 */
218 269
 export const JOB_OFFER_SHARE_IMAGE = '/static/images/job.jpg'
219 270
 
271
+/** 分享 scene 前缀:ord_{id}(仅兜底) */
272
+export const JOB_OFFER_SCENE_PREFIX = 'ord_'
273
+
274
+/**
275
+ * 生成分享 scene,如 id=7 → ord_7
276
+ */
277
+export function buildJobOfferScene(id) {
278
+	if (id === '' || id == null) return ''
279
+	return `${JOB_OFFER_SCENE_PREFIX}${id}`
280
+}
281
+
282
+/**
283
+ * 从 scene 解析数字 ID:ord_7 → 7(仅用于兼容缓存键)
284
+ */
285
+export function parseDraftIdFromScene(scene) {
286
+	if (scene == null || scene === '') return null
287
+	let raw = String(scene)
288
+	try {
289
+		raw = decodeURIComponent(raw)
290
+	} catch (e) {
291
+		// ignore
292
+	}
293
+	raw = raw.trim()
294
+	if (!raw) return null
295
+	if (raw.indexOf(JOB_OFFER_SCENE_PREFIX) === 0) {
296
+		const n = Number(raw.slice(JOB_OFFER_SCENE_PREFIX.length))
297
+		return Number.isNaN(n) ? null : n
298
+	}
299
+	const n = Number(raw)
300
+	return Number.isNaN(n) ? null : n
301
+}
302
+
303
+/**
304
+ * 组装分享 / 公开查询用的 scene 值
305
+ * 优先 qrToken(与小程序码 / 公开接口一致);参数名仍为 scene
306
+ */
307
+export function resolveJobOfferScene(offer) {
308
+	if (!offer || typeof offer !== 'object') return ''
309
+	const candidates = [
310
+		offer.qrToken,
311
+		offer.qr_token,
312
+		offer.scene,
313
+		offer.orderNo,
314
+		offer.orderId
315
+	]
316
+	for (let i = 0; i < candidates.length; i++) {
317
+		const c = candidates[i]
318
+		if (c == null || c === '') continue
319
+		const s = String(c).trim()
320
+		if (!s) continue
321
+		if (/^\d+$/.test(s)) return buildJobOfferScene(s)
322
+		return s
323
+	}
324
+	if (offer.draftId != null && offer.draftId !== '') {
325
+		return buildJobOfferScene(offer.draftId)
326
+	}
327
+	return ''
328
+}
329
+
220 330
 /**
221 331
  * 用工邀请分享给好友的卡片配置
222
- * @param {{ draftId?: string|number }} [offer]
332
+ * @param {{ draftId?: string|number, orderId?: string, scene?: string, qrToken?: string }} [offer]
223 333
  */
224 334
 export function buildJobOfferShareMessage(offer) {
225 335
 	return {

+ 144 - 0
huimv-employment/app/utils/markdown.js

@@ -0,0 +1,144 @@
1
+/**
2
+ * 轻量 Markdown → HTML(供微信小程序 rich-text 使用)
3
+ * 覆盖聊天场景常用语法:粗体、斜体、行内代码、链接、标题、列表、换行
4
+ */
5
+
6
+function escapeHtml(text) {
7
+	return String(text || '')
8
+		.replace(/&/g, '&amp;')
9
+		.replace(/</g, '&lt;')
10
+		.replace(/>/g, '&gt;')
11
+		.replace(/"/g, '&quot;')
12
+}
13
+
14
+function formatInline(text) {
15
+	let s = escapeHtml(text)
16
+	// 链接 [text](url)
17
+	s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
18
+		'<a href="$2" style="color:#7C3AED;text-decoration:underline;">$1</a>')
19
+	// 粗体 ** / __
20
+	s = s.replace(/\*\*(.+?)\*\*/g, '<strong style="font-weight:700;">$1</strong>')
21
+	s = s.replace(/__(.+?)__/g, '<strong style="font-weight:700;">$1</strong>')
22
+	// 行内代码 `
23
+	s = s.replace(/`([^`\n]+)`/g,
24
+		'<code style="font-family:Menlo,Consolas,monospace;font-size:0.92em;padding:0 6px;border-radius:6px;background:#F1F5F9;color:#334155;">$1</code>')
25
+	// 斜体 *text*(避免吃掉已转换的 **)
26
+	s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em style="font-style:italic;">$2</em>')
27
+	s = s.replace(/(^|[^_])_([^_\n]+)_(?!_)/g, '$1<em style="font-style:italic;">$2</em>')
28
+	return s
29
+}
30
+
31
+/**
32
+ * @param {string} markdown
33
+ * @returns {string} HTML string for rich-text :nodes
34
+ */
35
+export function markdownToHtml(markdown) {
36
+	const raw = String(markdown || '')
37
+	if (!raw) return ''
38
+
39
+	const lines = raw.replace(/\r\n/g, '\n').split('\n')
40
+	const html = []
41
+	let inUl = false
42
+	let inOl = false
43
+	let inCode = false
44
+	let codeBuf = []
45
+
46
+	const closeLists = () => {
47
+		if (inUl) {
48
+			html.push('</ul>')
49
+			inUl = false
50
+		}
51
+		if (inOl) {
52
+			html.push('</ol>')
53
+			inOl = false
54
+		}
55
+	}
56
+
57
+	for (let i = 0; i < lines.length; i++) {
58
+		const line = lines[i]
59
+
60
+		if (line.trim().startsWith('```')) {
61
+			if (inCode) {
62
+				html.push(
63
+					'<pre style="margin:8px 0;padding:12px;border-radius:8px;background:#F1F5F9;color:#334155;font-size:12px;white-space:pre-wrap;word-break:break-all;">'
64
+					+ escapeHtml(codeBuf.join('\n'))
65
+					+ '</pre>'
66
+				)
67
+				codeBuf = []
68
+				inCode = false
69
+			} else {
70
+				closeLists()
71
+				inCode = true
72
+			}
73
+			continue
74
+		}
75
+
76
+		if (inCode) {
77
+			codeBuf.push(line)
78
+			continue
79
+		}
80
+
81
+		const heading = line.match(/^(#{1,3})\s+(.+)$/)
82
+		if (heading) {
83
+			closeLists()
84
+			const level = heading[1].length
85
+			const size = level === 1 ? '17px' : (level === 2 ? '16px' : '15px')
86
+			html.push(
87
+				`<div style="margin:8px 0 4px;font-size:${size};font-weight:700;line-height:1.5;">${formatInline(heading[2])}</div>`
88
+			)
89
+			continue
90
+		}
91
+
92
+		const ul = line.match(/^[-*+]\s+(.+)$/)
93
+		if (ul) {
94
+			if (inOl) {
95
+				html.push('</ol>')
96
+				inOl = false
97
+			}
98
+			if (!inUl) {
99
+				html.push('<ul style="margin:6px 0;padding-left:1.2em;">')
100
+				inUl = true
101
+			}
102
+			html.push(`<li style="margin:2px 0;line-height:1.6;">${formatInline(ul[1])}</li>`)
103
+			continue
104
+		}
105
+
106
+		const ol = line.match(/^\d+\.\s+(.+)$/)
107
+		if (ol) {
108
+			if (inUl) {
109
+				html.push('</ul>')
110
+				inUl = false
111
+			}
112
+			if (!inOl) {
113
+				html.push('<ol style="margin:6px 0;padding-left:1.2em;">')
114
+				inOl = true
115
+			}
116
+			html.push(`<li style="margin:2px 0;line-height:1.6;">${formatInline(ol[1])}</li>`)
117
+			continue
118
+		}
119
+
120
+		closeLists()
121
+
122
+		if (!line.trim()) {
123
+			html.push('<div style="height:8px;"></div>')
124
+			continue
125
+		}
126
+
127
+		html.push(`<div style="margin:0;line-height:1.65;">${formatInline(line)}</div>`)
128
+	}
129
+
130
+	if (inCode) {
131
+		html.push(
132
+			'<pre style="margin:8px 0;padding:12px;border-radius:8px;background:#F1F5F9;color:#334155;font-size:12px;white-space:pre-wrap;word-break:break-all;">'
133
+			+ escapeHtml(codeBuf.join('\n'))
134
+			+ '</pre>'
135
+		)
136
+	}
137
+	closeLists()
138
+
139
+	return (
140
+		'<div style="font-size:14px;line-height:1.65;color:#0F172A;word-break:break-word;">'
141
+		+ html.join('')
142
+		+ '</div>'
143
+	)
144
+}

+ 148 - 0
huimv-employment/app/utils/registration-batch.js

@@ -0,0 +1,148 @@
1
+/**
2
+ * 登记批次详情 → 进度弹层展示结构
3
+ * 兼容后端 snake_case(@JsonProperty)与小驼峰
4
+ */
5
+export function mapRegistrationBatchDetail(raw = {}) {
6
+	const stats = raw.stats || {}
7
+	const progress = raw.progress || {}
8
+	const steps = Array.isArray(raw.steps) ? raw.steps : []
9
+	const workers = Array.isArray(raw.workers) ? raw.workers : []
10
+
11
+	const pending = num(stats.pendingCount, stats.pending_count, 0)
12
+	const inProgress = num(stats.inProgressCount, stats.in_progress_count, 0)
13
+	const completed = num(stats.completedCount, stats.completed_count, 0)
14
+	const expected = num(stats.expectedCount, stats.expected_count, 0)
15
+	const registered = num(stats.registeredCount, stats.registered_count, progress.current)
16
+
17
+	const current = num(progress.current, null, registered)
18
+	const total = num(progress.total, null, expected)
19
+	const percent = num(progress.percent, null, total > 0 ? Math.round((current / total) * 100) : 0)
20
+
21
+	return {
22
+		draftId: raw.draftId != null ? raw.draftId : (raw.draft_id != null ? raw.draft_id : null),
23
+		batchId: raw.batchId != null ? raw.batchId : raw.batch_id,
24
+		batchNo: raw.batchNo || raw.batch_no || '',
25
+		title: raw.title || '用工批次',
26
+		subtitle: raw.subtitle || '',
27
+		orderId: raw.orderId != null ? raw.orderId : raw.order_id,
28
+		orderNo: raw.orderNo || raw.order_no || '',
29
+		status: raw.status || '',
30
+		qrToken: raw.qrToken || raw.qr_token || '',
31
+		qrCodeUrl: raw.qrCodeUrl || raw.qr_code_url || '',
32
+		stats: {
33
+			pending,
34
+			inProgress,
35
+			completed,
36
+			expected,
37
+			registered
38
+		},
39
+		progress: {
40
+			current,
41
+			total,
42
+			percent: Math.max(0, Math.min(100, percent)),
43
+			text: `${current}/${total}`
44
+		},
45
+		steps: steps.map((s, i) => ({
46
+			key: s.stepCode || s.step_code || `step-${i}`,
47
+			title: s.title || '',
48
+			status: s.status || 'pending',
49
+			time: s.time || '',
50
+			highlight: s.highlight || ''
51
+		})),
52
+		workers: workers.map((w, i) => {
53
+			const name = w.realName || w.real_name || '—'
54
+			return {
55
+				msgKey: w.registrationId != null
56
+					? w.registrationId
57
+					: (w.registration_id != null ? w.registration_id : i),
58
+				name,
59
+				avatarText: String(name).charAt(0) || '工',
60
+				phone: w.mobileMask || w.mobile_mask || '',
61
+				status: mapWorkerStatusTag(w.statusTag || w.status_tag || w.regStatus || w.reg_status),
62
+				statusText: w.statusText || w.status_text || '',
63
+				failReason: w.failReason || w.fail_reason || ''
64
+			}
65
+		})
66
+	}
67
+}
68
+
69
+function num(a, b, fallback) {
70
+	if (a != null && a !== '' && !Number.isNaN(Number(a))) return Number(a)
71
+	if (b != null && b !== '' && !Number.isNaN(Number(b))) return Number(b)
72
+	if (fallback != null && fallback !== '' && !Number.isNaN(Number(fallback))) return Number(fallback)
73
+	return 0
74
+}
75
+
76
+function mapWorkerStatusTag(tag) {
77
+	const t = tag == null ? '' : String(tag)
78
+	if (t === 'done' || t === 'completed') return 'done'
79
+	if (t === 'fail' || t === 'verify_failed') return 'fail'
80
+	if (t === 'review' || t === 'under_review') return 'review'
81
+	if (t === 'registering' || t === 'in_progress') return 'review'
82
+	return 'pending'
83
+}
84
+
85
+/**
86
+ * 会话内草稿进度合集(接口未就绪时的 mock)
87
+ * @param {Array<{ draftId?: number|string, title?: string, batchNo?: string }>} [seeds]
88
+ */
89
+export function buildMockConversationProgressList(seeds = []) {
90
+	const list = Array.isArray(seeds) && seeds.length
91
+		? seeds
92
+		: [
93
+			{ draftId: 101, title: '浦东仓库搬运', batchNo: 'bat_1' },
94
+			{ draftId: 102, title: '商场促销推广', batchNo: 'bat_2' },
95
+			{ draftId: 103, title: '展会驻场接待', batchNo: 'bat_3' }
96
+		]
97
+
98
+	return list.map((seed, idx) => {
99
+		const total = 8 + (idx % 3) * 2
100
+		const current = Math.min(total, 3 + idx * 2)
101
+		const pending = Math.max(0, total - current)
102
+		const inProgress = Math.min(current, 2 + (idx % 2))
103
+		const completed = Math.max(0, current - inProgress)
104
+		const title = seed.title || `用工批次 ${idx + 1}`
105
+		const batchNo = seed.batchNo || `bat_${idx + 1}`
106
+		const draftId = seed.draftId != null ? seed.draftId : (100 + idx)
107
+		return mapRegistrationBatchDetail({
108
+			draft_id: draftId,
109
+			batch_id: draftId,
110
+			batch_no: batchNo,
111
+			title,
112
+			subtitle: `${title} ${batchNo}`,
113
+			order_no: `ord_${draftId}`,
114
+			status: 'active',
115
+			stats: {
116
+				pending_count: pending,
117
+				in_progress_count: inProgress,
118
+				completed_count: completed,
119
+				expected_count: total,
120
+				registered_count: current
121
+			},
122
+			progress: {
123
+				current,
124
+				total,
125
+				percent: Math.round((current / total) * 100)
126
+			},
127
+			steps: [
128
+				{ step_code: 'draft', title: '草稿创建', status: 'done', time: '01-15 14:30' },
129
+				{ step_code: 'plan', title: '方案确认', status: 'done', time: '01-15 14:35' },
130
+				{
131
+					step_code: 'registration',
132
+					title: '扫码登记',
133
+					status: 'active',
134
+					highlight: `进行中 ${current}/${total}`
135
+				},
136
+				{ step_code: 'review', title: '企业审核', status: 'pending' },
137
+				{ step_code: 'work', title: '开始用工', status: 'pending' }
138
+			],
139
+			workers: [
140
+				{ registration_id: idx * 10 + 1, real_name: '张三', mobile_mask: '138****1234', status_tag: 'done', status_text: '已完成' },
141
+				{ registration_id: idx * 10 + 2, real_name: '李四', mobile_mask: '139****5678', status_tag: 'done', status_text: '已完成' },
142
+				{ registration_id: idx * 10 + 3, real_name: '王五', mobile_mask: '137****9012', status_tag: 'fail', status_text: '核验失败' },
143
+				{ registration_id: idx * 10 + 4, real_name: '赵六', mobile_mask: '136****3456', status_tag: 'pending', status_text: '待登记' },
144
+				{ registration_id: idx * 10 + 5, real_name: '钱七', mobile_mask: '135****7890', status_tag: 'review', status_text: '审核中' }
145
+			]
146
+		})
147
+	})
148
+}

+ 2 - 0
huimv-employment/app/utils/request.js

@@ -23,6 +23,8 @@ export function clearToken() {
23 23
 
24 24
 function buildUrl(url) {
25 25
 	if (/^https?:\/\//.test(url)) return url
26
+	// 已是完整业务路径(如 /api/v1/public/drafts),勿再拼 mp 前缀
27
+	if (typeof url === 'string' && url.indexOf('/api/') === 0) return url
26 28
 	const base = (BASE_API || '').replace(/\/$/, '')
27 29
 	if (!base) return ''
28 30
 	const path = url.startsWith('/') ? url : `/${url}`

+ 74 - 2
huimv-employment/app/utils/sse-parse.js

@@ -71,17 +71,80 @@ export function extractAssistantDelta(data) {
71 71
 		}
72 72
 	}
73 73
 
74
+	// 顶层 assistant message:content[{ type:text, text }]
75
+	if (!parts.length && data.role === 'assistant' && Array.isArray(data.content)) {
76
+		for (const block of data.content) {
77
+			if (block && (block.type === 'text' || !block.type) && block.text) {
78
+				parts.push(block.text)
79
+			}
80
+		}
81
+	}
82
+
74 83
 	if (!parts.length && typeof data.text === 'string') parts.push(data.text)
84
+	// delta 事件:data.delta 可能是布尔;真正增量在 data.text
75 85
 	if (!parts.length && typeof data.delta === 'string') parts.push(data.delta)
76 86
 
77 87
 	return parts.join('')
78 88
 }
79 89
 
90
+function commonPrefixLength(a, b) {
91
+	const n = Math.min(a.length, b.length)
92
+	let i = 0
93
+	while (i < n && a.charAt(i) === b.charAt(i)) i += 1
94
+	return i
95
+}
96
+
97
+function longestSuffixPrefixOverlap(a, b) {
98
+	const max = Math.min(a.length, b.length)
99
+	for (let len = max; len >= 1; len -= 1) {
100
+		if (a.endsWith(b.slice(0, len))) return len
101
+	}
102
+	return 0
103
+}
104
+
105
+/**
106
+ * 识别「流式全文 + 终态快照略有改写」——本应替换,不能再拼接
107
+ */
108
+function isLikelyFullSnapshotRewrite(cur, next) {
109
+	const minLen = Math.min(cur.length, next.length)
110
+	if (minLen < 40) return false
111
+
112
+	const prefix = commonPrefixLength(cur, next)
113
+	if (prefix >= 24 && prefix / minLen >= 0.55) return true
114
+
115
+	const window = Math.min(24, minLen)
116
+	const lenDiffRatio = Math.abs(cur.length - next.length) / minLen
117
+	if (cur.slice(0, window) === next.slice(0, window) && lenDiffRatio < 0.35) {
118
+		return true
119
+	}
120
+
121
+	// 开头 20 字互相靠近出现,且长度接近 → 几乎是同一段答案被再推一次
122
+	if (minLen >= 60) {
123
+		const head = next.slice(0, 20)
124
+		const curHead = cur.slice(0, 20)
125
+		if (cur.indexOf(head) <= 8 && next.indexOf(curHead) <= 8 && lenDiffRatio < 0.4) {
126
+			return true
127
+		}
128
+	}
129
+	return false
130
+}
131
+
132
+/** 流式乱码(UTF-8 截断)后终态全文应优先替换 */
133
+function shouldPreferCleanSnapshot(cur, next) {
134
+	if (!cur || !next) return false
135
+	if (cur.indexOf('\uFFFD') === -1) return false
136
+	const lenDiffRatio = Math.abs(cur.length - next.length) / Math.max(cur.length, next.length)
137
+	if (lenDiffRatio > 0.35) return false
138
+	const prefix = commonPrefixLength(cur.replace(/\uFFFD+/g, ''), next)
139
+	return prefix >= 6 || commonPrefixLength(cur, next) >= 6
140
+}
141
+
80 142
 /**
81 143
  * 合并流式 assistant 文本,兼容:
82 144
  * - 增量 token(直接拼接)
83 145
  * - 累计全文快照(startsWith 则替换)
84
- * - 结束包重复推送整段答案(去重)
146
+ * - 结束包重复推送整段答案(去重,含轻微改写)
147
+ * - 流式解码乱码后用终态干净全文覆盖
85 148
  */
86 149
 export function mergeAssistantStreamText(current, incoming) {
87 150
 	const cur = current || ''
@@ -93,8 +156,17 @@ export function mergeAssistantStreamText(current, incoming) {
93 156
 	if (next.startsWith(cur)) return next
94 157
 	// 迟到的旧快照
95 158
 	if (cur.startsWith(next)) return cur
96
-	// 整段答案被再次推送
159
+	if (shouldPreferCleanSnapshot(cur, next)) return next
160
+	// 整段答案被再次推送(略有改写也按快照替换,避免首尾拼成两段)
161
+	if (isLikelyFullSnapshotRewrite(cur, next)) {
162
+		return next.length >= cur.length ? next : cur
163
+	}
97 164
 	if (next.length >= 8 && cur.endsWith(next)) return cur
98 165
 	if (cur.length >= 8 && next.indexOf(cur) === 0) return next
166
+	// 缝合点去重:cur 尾部与 next 头部重叠
167
+	const overlap = longestSuffixPrefixOverlap(cur, next)
168
+	if (overlap >= 20 || (overlap >= 8 && overlap >= Math.min(cur.length, next.length) * 0.2)) {
169
+		return cur + next.slice(overlap)
170
+	}
99 171
 	return cur + next
100 172
 }

+ 113 - 11
huimv-employment/app/utils/sse-stream.js

@@ -16,23 +16,123 @@ function buildUrl(url) {
16 16
 	return `${base}${path}`
17 17
 }
18 18
 
19
+/**
20
+ * 跨分块 UTF-8 解码,避免中文被切到 chunk 边界时变成 �
21
+ */
22
+function createUtf8StreamDecoder() {
23
+	const native = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8') : null
24
+	let pending = new Uint8Array(0)
25
+
26
+	const concat = (a, b) => {
27
+		if (!a || !a.length) return b
28
+		if (!b || !b.length) return a
29
+		const out = new Uint8Array(a.length + b.length)
30
+		out.set(a, 0)
31
+		out.set(b, a.length)
32
+		return out
33
+	}
34
+
35
+	/** 从末尾回退不完整的 UTF-8 多字节序列 */
36
+	const splitIncomplete = (bytes) => {
37
+		if (!bytes.length) return { complete: bytes, rest: new Uint8Array(0) }
38
+		let i = bytes.length - 1
39
+		let count = 0
40
+		while (i >= 0 && (bytes[i] & 0xc0) === 0x80) {
41
+			i -= 1
42
+			count += 1
43
+			if (count > 3) break
44
+		}
45
+		if (i < 0) {
46
+			return { complete: new Uint8Array(0), rest: bytes }
47
+		}
48
+		const lead = bytes[i]
49
+		let need = 0
50
+		if ((lead & 0x80) === 0) need = 1
51
+		else if ((lead & 0xe0) === 0xc0) need = 2
52
+		else if ((lead & 0xf0) === 0xe0) need = 3
53
+		else if ((lead & 0xf8) === 0xf0) need = 4
54
+		else need = 1
55
+		const have = bytes.length - i
56
+		if (need > 1 && have < need) {
57
+			return {
58
+				complete: bytes.subarray(0, i),
59
+				rest: bytes.subarray(i)
60
+			}
61
+		}
62
+		return { complete: bytes, rest: new Uint8Array(0) }
63
+	}
64
+
65
+	const decodeLatin1Fallback = (bytes) => {
66
+		if (!bytes || !bytes.length) return ''
67
+		let binary = ''
68
+		for (let i = 0; i < bytes.length; i++) {
69
+			binary += String.fromCharCode(bytes[i])
70
+		}
71
+		try {
72
+			return decodeURIComponent(escape(binary))
73
+		} catch (e) {
74
+			return binary
75
+		}
76
+	}
77
+
78
+	const toBytes = (data) => {
79
+		if (data instanceof Uint8Array) return data
80
+		if (data instanceof ArrayBuffer) return new Uint8Array(data)
81
+		return null
82
+	}
83
+
84
+	return {
85
+		decode(data, stream) {
86
+			if (data == null) return ''
87
+			if (typeof data === 'string') return data
88
+			const chunk = toBytes(data)
89
+			if (!chunk) return ''
90
+
91
+			// 优先 TextDecoder(需正确传 stream,结束时再 flush)
92
+			if (native) {
93
+				return native.decode(chunk, { stream: !!stream })
94
+			}
95
+
96
+			const merged = concat(pending, chunk)
97
+			if (stream) {
98
+				const parts = splitIncomplete(merged)
99
+				pending = parts.rest
100
+				return decodeLatin1Fallback(parts.complete)
101
+			}
102
+			pending = new Uint8Array(0)
103
+			return decodeLatin1Fallback(merged)
104
+		},
105
+		flush() {
106
+			if (native) {
107
+				try {
108
+					return native.decode()
109
+				} catch (e) {
110
+					return ''
111
+				}
112
+			}
113
+			if (!pending.length) return ''
114
+			const left = pending
115
+			pending = new Uint8Array(0)
116
+			return decodeLatin1Fallback(left)
117
+		}
118
+	}
119
+}
120
+
19 121
 function decodeChunk(data, decoder, stream) {
20 122
 	if (typeof data === 'string') return data
21
-	if (!(data instanceof ArrayBuffer)) return ''
22
-
123
+	if (!(data instanceof ArrayBuffer) && !(data instanceof Uint8Array)) return ''
23 124
 	if (decoder) {
24
-		return decoder.decode(data, { stream: !!stream })
125
+		return decoder.decode(data, stream)
25 126
 	}
26
-
27
-	const bytes = new Uint8Array(data)
28
-	let text = ''
127
+	const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
128
+	let binary = ''
29 129
 	for (let i = 0; i < bytes.length; i++) {
30
-		text += String.fromCharCode(bytes[i])
130
+		binary += String.fromCharCode(bytes[i])
31 131
 	}
32 132
 	try {
33
-		return decodeURIComponent(escape(text))
133
+		return decodeURIComponent(escape(binary))
34 134
 	} catch (e) {
35
-		return text
135
+		return binary
36 136
 	}
37 137
 }
38 138
 
@@ -98,7 +198,7 @@ export function streamSseRequest(options = {}) {
98 198
 	let chunkReceived = false
99 199
 	let assembledText = ''
100 200
 	let cardMeta = null
101
-	const decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8') : null
201
+	const utf8Decoder = createUtf8StreamDecoder()
102 202
 	const lineBuffer = createSseLineBuffer()
103 203
 
104 204
 	const abort = () => {
@@ -111,6 +211,8 @@ export function streamSseRequest(options = {}) {
111 211
 	const finish = () => {
112 212
 		if (finished || aborted) return
113 213
 		finished = true
214
+		const flushed = utf8Decoder.flush()
215
+		if (flushed) handleText(flushed)
114 216
 		const lastLine = lineBuffer.flushLine()
115 217
 		if (lastLine) processLine(lastLine)
116 218
 		if (onDone) onDone(assembledText, cardMeta)
@@ -196,7 +298,7 @@ export function streamSseRequest(options = {}) {
196 298
 		requestTask.onChunkReceived((res) => {
197 299
 			if (aborted || finished) return
198 300
 			chunkReceived = true
199
-			handleText(decodeChunk(res.data, decoder, true))
301
+			handleText(utf8Decoder.decode(res.data, true))
200 302
 		})
201 303
 	}
202 304