xsh_1997 3 weeks ago
parent
commit
1063ef80d2
22 changed files with 3206 additions and 96 deletions
  1. 19 0
      .gitignore
  2. 1 1
      huimv-employment/app/.env.development
  3. 83 0
      huimv-employment/app/api/conversation.js
  4. 4 1
      huimv-employment/app/common/config.js
  5. 49 1
      huimv-employment/app/common/fe.scss
  6. 31 8
      huimv-employment/app/components/chat/FeCostSheet.vue
  7. 504 64
      huimv-employment/app/components/home/EnterpriseHome.vue
  8. 132 0
      huimv-employment/app/mixins/enterprise-home-guide.js
  9. 239 21
      huimv-employment/app/pages/index/index.vue
  10. 13 0
      huimv-employment/app/uni_modules/hz-novice-guidance/changelog.md
  11. 507 0
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/hz-novice-guidance.vue
  12. 106 0
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useArrow.js
  13. 117 0
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useGetDomInfo.js
  14. 132 0
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useGuard.js
  15. 100 0
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useOuterBtn.js
  16. 138 0
      huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useStepTips.js
  17. 105 0
      huimv-employment/app/uni_modules/hz-novice-guidance/package.json
  18. 448 0
      huimv-employment/app/uni_modules/hz-novice-guidance/readme.md
  19. 94 0
      huimv-employment/app/utils/conversation.js
  20. 117 0
      huimv-employment/app/utils/home-guide.js
  21. 78 0
      huimv-employment/app/utils/sse-parse.js
  22. 189 0
      huimv-employment/app/utils/sse-stream.js

+ 19 - 0
.gitignore

@@ -0,0 +1,19 @@
1
+target/
2
+.idea/
3
+*.iml
4
+*.ipr
5
+*.iws
6
+.project
7
+.classpath
8
+.settings/
9
+.vscode/
10
+*.log
11
+logs/
12
+*.class
13
+.DS_Store
14
+Thumbs.db
15
+node_modules/
16
+dist/
17
+.env
18
+.env.local
19
+.cursor

+ 1 - 1
huimv-employment/app/.env.development

@@ -1,2 +1,2 @@
1 1
 # 开发环境 API 地址
2
-VUE_APP_BASE_API=http://192.168.1.6:8080/api/v1/mp
2
+VUE_APP_BASE_API=http://115.238.57.190:5200/api/v1/mp

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

@@ -0,0 +1,83 @@
1
+import { AI_AGENT_ID } from '@/common/config.js'
2
+import { get, post } from '@/utils/request.js'
3
+import { streamSseRequest } from '@/utils/sse-stream.js'
4
+
5
+/**
6
+ * 查询当前用户的 AI 对话会话列表
7
+ * @param {{
8
+ *   status?: 'active' | 'archived',
9
+ *   chatType?: string,
10
+ *   enterpriseId?: number
11
+ * }} params
12
+ */
13
+export function listConversations(params = {}, options = {}) {
14
+	return get('/conversations', params, options)
15
+}
16
+
17
+/**
18
+ * 新建 AI 对话会话
19
+ * @param {{
20
+ *   title?: string,
21
+ *   chatType?: string,
22
+ *   conversationNo?: string,
23
+ *   enterpriseId?: number
24
+ * }} data
25
+ * @returns {Promise<{
26
+ *   id: number,
27
+ *   conversationNo: string,
28
+ *   enterpriseId: number,
29
+ *   title?: string,
30
+ *   chatType: string,
31
+ *   status: string,
32
+ *   lastMessageAt?: string,
33
+ *   createTime?: string
34
+ * }>}
35
+ */
36
+export function createConversation(data = {}, options = {}) {
37
+	return post('/conversations', data, options)
38
+}
39
+
40
+/**
41
+ * 查询会话消息列表(按 create_time 正序分页,page=1 为最早消息)
42
+ * @param {number} conversationId
43
+ * @param {{ page?: number, size?: number }} params
44
+ */
45
+export function listConversationMessages(conversationId, params = {}, options = {}) {
46
+	return get(`/conversations/${conversationId}/messages`, params, options)
47
+}
48
+
49
+/**
50
+ * 会话内 AI 对话(SSE 流式 + 服务端落库)
51
+ * @param {{
52
+ *   conversationId: number,
53
+ *   message: string,
54
+ *   agentId?: string,
55
+ *   onDelta?: (text: string) => void,
56
+ *   onDone?: () => void,
57
+ *   onError?: (err: Error) => void
58
+ * }} options
59
+ * @returns {() => void} abort
60
+ */
61
+export function streamConversationChat(options = {}) {
62
+	const {
63
+		conversationId,
64
+		message,
65
+		agentId = AI_AGENT_ID,
66
+		onDelta,
67
+		onDone,
68
+		onError
69
+	} = options
70
+
71
+	return streamSseRequest({
72
+		url: `/conversations/${conversationId}/chat`,
73
+		data: { message },
74
+		header: {
75
+			'X-Agent-Id': agentId
76
+		},
77
+		onChunk: (delta) => {
78
+			if (onDelta) onDelta(delta)
79
+		},
80
+		onDone,
81
+		onError
82
+	})
83
+}

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

@@ -1,5 +1,5 @@
1 1
 /** 开发环境兜底地址(HBuilderX 编译时可能未注入 .env) */
2
-const DEV_BASE_API = 'http://192.168.1.6:8080/api/v1/mp'
2
+const DEV_BASE_API = 'http://115.238.57.190:5200/api/v1/mp'
3 3
 
4 4
 /** API 基础路径,优先 .env,开发环境兜底 */
5 5
 export const BASE_API =
@@ -11,3 +11,6 @@ export const TOKEN_KEY = 'fe_token'
11 11
 
12 12
 /** 后端统一响应成功码 */
13 13
 export const SUCCESS_CODE = 200
14
+
15
+/** 智能体 ID,对应请求头 X-Agent-Id */
16
+export const AI_AGENT_ID = process.env.VUE_APP_AI_AGENT_ID || 'default'

+ 49 - 1
huimv-employment/app/common/fe.scss

@@ -267,6 +267,9 @@ page {
267 267
 	align-items: flex-start;
268 268
 	gap: 20rpx;
269 269
 	margin-bottom: 32rpx;
270
+	width: 100%;
271
+	max-width: 100%;
272
+	box-sizing: border-box;
270 273
 }
271 274
 
272 275
 .fe-chat-msg--user {
@@ -274,7 +277,7 @@ page {
274 277
 }
275 278
 
276 279
 .fe-chat-bubble {
277
-	flex: 1;
280
+	flex: 1 1 0;
278 281
 	min-width: 0;
279 282
 	max-width: calc(100% - 88rpx);
280 283
 	padding: 24rpx 32rpx;
@@ -282,6 +285,17 @@ page {
282 285
 	font-size: 28rpx;
283 286
 	line-height: 1.6;
284 287
 	box-sizing: border-box;
288
+	overflow: hidden;
289
+}
290
+
291
+.fe-chat-bubble__text {
292
+	display: block;
293
+	width: 100%;
294
+	max-width: 100%;
295
+	word-wrap: break-word;
296
+	word-break: break-word;
297
+	white-space: pre-wrap;
298
+	box-sizing: border-box;
285 299
 }
286 300
 
287 301
 .fe-chat-msg--ai .fe-chat-bubble {
@@ -482,6 +496,40 @@ page {
482 496
 	height: 14rpx;
483 497
 	border-radius: 50%;
484 498
 	background: $fe-primary-light;
499
+	animation: fe-thinking-bounce 1.4s infinite ease-in-out both;
500
+}
501
+
502
+.fe-thinking-dots__dot:nth-child(2) {
503
+	animation-delay: 0.16s;
504
+}
505
+
506
+.fe-thinking-dots__dot:nth-child(3) {
507
+	animation-delay: 0.32s;
508
+}
509
+
510
+.fe-thinking-dots--inline {
511
+	display: flex;
512
+	align-items: center;
513
+	gap: 10rpx;
514
+	min-height: 40rpx;
515
+	padding: 0;
516
+	background: transparent;
517
+	border: none;
518
+	border-radius: 0;
519
+}
520
+
521
+@keyframes fe-thinking-bounce {
522
+	0%,
523
+	80%,
524
+	100% {
525
+		transform: scale(0.55);
526
+		opacity: 0.35;
527
+	}
528
+
529
+	40% {
530
+		transform: scale(1);
531
+		opacity: 1;
532
+	}
485 533
 }
486 534
 
487 535
 /* ===== INPUT BAR ===== */

+ 31 - 8
huimv-employment/app/components/chat/FeCostSheet.vue

@@ -41,10 +41,10 @@
41 41
 										<text>{{ plan.icon }}</text>
42 42
 										<text>{{ plan.title }}</text>
43 43
 									</view>
44
-									<view class="fe-cost-card__toggle" @tap.stop="toggleExpand(plan.id)">
44
+									<view class="fe-cost-card__toggle" @tap.stop="toggleExpandByIndex(planIndex)">
45 45
 										<view
46 46
 											class="fe-cost-card__chevron"
47
-											:class="{ 'fe-cost-card__chevron--up': isExpanded(plan.id) }"
47
+											:class="{ 'fe-cost-card__chevron--up': isExpandedByIndex(planIndex) }"
48 48
 										/>
49 49
 									</view>
50 50
 								</view>
@@ -53,21 +53,33 @@
53 53
 									<text class="fe-info-row__label">人均成本</text>
54 54
 									<text class="fe-info-row__value">{{ plan.avgCost }}</text>
55 55
 								</view>
56
-								<block v-if="isExpanded(plan.id)">
57
-									<view v-for="(row, i) in plan.rows" :key="'r-' + i" class="fe-info-row">
56
+								<view v-if="isExpandedByIndex(planIndex)" class="fe-cost-card__detail">
57
+									<view
58
+										v-for="(row, rowIdx) in plan.rows"
59
+										:key="rowIdx"
60
+										class="fe-info-row"
61
+									>
58 62
 										<text class="fe-info-row__label">{{ row.label }}</text>
59 63
 										<text class="fe-info-row__value">{{ row.value }}</text>
60 64
 									</view>
61 65
 									<view v-if="plan.breakdown" class="fe-cost-card__breakdown">
62
-										<view v-for="(row, i) in plan.breakdown" :key="'b-' + i" class="fe-info-row">
66
+										<view
67
+											v-for="(row, bdIdx) in plan.breakdown"
68
+											:key="bdIdx"
69
+											class="fe-info-row"
70
+										>
63 71
 											<text class="fe-info-row__label">{{ row.label }}</text>
64 72
 											<text class="fe-info-row__value">{{ row.value }}</text>
65 73
 										</view>
66 74
 									</view>
67 75
 									<view v-if="plan.compliance" class="fe-compliance">
68
-										<text v-for="(badge, i) in plan.compliance" :key="'c-' + i" class="fe-compliance-badge">✓ {{ badge }}</text>
76
+										<text
77
+											v-for="(badge, cmIdx) in plan.compliance"
78
+											:key="cmIdx"
79
+											class="fe-compliance-badge"
80
+										>✓ {{ badge }}</text>
69 81
 									</view>
70
-								</block>
82
+								</view>
71 83
 							</view>
72 84
 
73 85
 							<view class="fe-cost-card fe-cost-card--muted">
@@ -135,7 +147,14 @@ export default {
135 147
 			if (this.expandedPlans[id] === undefined) return true
136 148
 			return !!this.expandedPlans[id]
137 149
 		},
138
-		toggleExpand(id) {
150
+		isExpandedByIndex(index) {
151
+			const plan = this.plans[index]
152
+			return plan ? this.isExpanded(plan.id) : true
153
+		},
154
+		toggleExpandByIndex(index) {
155
+			const plan = this.plans[index]
156
+			if (!plan) return
157
+			const id = plan.id
139 158
 			this.$set(this.expandedPlans, id, !this.expandedPlans[id])
140 159
 		}
141 160
 	}
@@ -227,6 +246,10 @@ export default {
227 246
 	padding-top: 16rpx;
228 247
 }
229 248
 
249
+.fe-cost-card__detail {
250
+	width: 100%;
251
+}
252
+
230 253
 .fe-cost-card--muted {
231 254
 	opacity: 0.65;
232 255
 	margin-bottom: 0;

+ 504 - 64
huimv-employment/app/components/home/EnterpriseHome.vue

@@ -2,19 +2,19 @@
2 2
 	<view class="chat-page">
3 3
 		<view class="chat-header">
4 4
 			<view class="chat-header__row">
5
-				<view class="fe-nav-btn" @tap="openSidebar">
5
+				<view class="fe-nav-btn guide-target guide-target--sidebar" @tap="openSidebar">
6 6
 					<text class="fe-nav-icon">☰</text>
7 7
 				</view>
8 8
 				<text class="chat-header__title">智能用工助手</text>
9 9
 				<view class="chat-header__actions">
10
-					<view class="fe-nav-btn" @tap="newChat">
10
+					<view class="fe-nav-btn guide-target guide-target--new-chat" @tap="newChat">
11 11
 						<text class="fe-nav-icon fe-nav-icon--plus">+</text>
12 12
 					</view>
13
-					<view class="fe-nav-btn" @tap="openDrawer('message')">
13
+					<view class="fe-nav-btn guide-target guide-target--message" @tap="openDrawer('message')">
14 14
 						<text class="fe-nav-icon">🔔</text>
15 15
 						<view class="fe-nav-badge">2</view>
16 16
 					</view>
17
-					<view class="fe-nav-avatar" @tap="openDrawer('profile')">{{ user.avatarText }}</view>
17
+					<view class="fe-nav-avatar guide-target guide-target--avatar" @tap="openDrawer('profile')">{{ user.avatarText }}</view>
18 18
 				</view>
19 19
 			</view>
20 20
 		</view>
@@ -26,16 +26,24 @@
26 26
 				<view class="sidebar-head__add" @tap="newChat">+</view>
27 27
 			</view>
28 28
 			<scroll-view scroll-y class="sidebar-list" :show-scrollbar="false">
29
-				<view
30
-					v-for="s in sessions"
31
-					:key="s.id"
32
-					class="fe-sidebar-item"
33
-					:class="{ 'fe-sidebar-item--active': currentSession === s.id }"
34
-					@tap="switchSession(s.id)"
35
-				>
36
-					<view class="fe-sidebar-item__title">{{ s.title }}</view>
37
-					<view class="fe-sidebar-item__time">{{ s.time }}</view>
29
+				<view v-if="sessionsLoading" class="sidebar-status">
30
+					<text class="sidebar-status__text">加载中...</text>
38 31
 				</view>
32
+				<view v-else-if="!sessions.length" class="sidebar-status sidebar-status--action" @tap="newChat">
33
+					<text class="sidebar-status__text sidebar-status__text--action">开启新对话</text>
34
+				</view>
35
+				<block v-else>
36
+					<view
37
+						v-for="(s, sessionIdx) in sessions"
38
+						:key="s.conversationNo"
39
+						class="fe-sidebar-item"
40
+						:class="{ 'fe-sidebar-item--active': currentSession === s.conversationNo }"
41
+						@tap="switchSessionByIndex(sessionIdx)"
42
+					>
43
+						<view class="fe-sidebar-item__title">{{ s.title }}</view>
44
+						<view class="fe-sidebar-item__time">{{ s.time }}</view>
45
+					</view>
46
+				</block>
39 47
 			</scroll-view>
40 48
 		</view>
41 49
 
@@ -43,18 +51,45 @@
43 51
 		<scroll-view
44 52
 			class="chat-area"
45 53
 			scroll-y
54
+			:scroll-top="scrollTop"
46 55
 			:scroll-into-view="scrollIntoView"
56
+			:scroll-with-animation="scrollWithAnimation"
47 57
 			:show-scrollbar="false"
48 58
 			:enhanced="true"
59
+			:upper-threshold="80"
49 60
 			@scroll="onChatScroll"
61
+			@scrolltoupper="onChatScrollToUpper"
50 62
 		>
51 63
 			<view class="chat-area__inner">
52
-				<block v-for="(msg, idx) in messages" :key="idx">
64
+				<view v-if="showNewChatEmpty" class="chat-empty">
65
+					<view class="chat-empty__icon">💬</view>
66
+					<text class="chat-empty__title">{{ newChatGreeting }}</text>
67
+					<view class="chat-empty__btn" @tap="newChat">开启新对话</view>
68
+				</view>
69
+				<block v-else>
70
+				<view v-if="messagesLoading" class="chat-status">
71
+					<text class="chat-status__text">加载消息中...</text>
72
+				</view>
73
+				<block v-else>
74
+				<view v-if="messagesLoadingMore" class="chat-status chat-status--top">
75
+					<text class="chat-status__text">加载历史消息...</text>
76
+				</view>
77
+				<view v-else-if="!hasMoreHistory && hasMessageHistory" class="chat-status chat-status--top">
78
+					<text class="chat-status__text chat-status__text--muted">没有更多消息了</text>
79
+				</view>
80
+				<view v-for="(msg, idx) in messages" :key="msg.msgKey" class="chat-msg-wrap">
53 81
 					<!-- Plain text -->
54 82
 					<view v-if="msg.type === 'text'" class="fe-chat-msg" :class="'fe-chat-msg--' + msg.role" :id="'msg-' + idx">
55 83
 						<view v-if="msg.role === 'ai'" class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
56 84
 						<view v-if="msg.role === 'user'" class="fe-chat-avatar fe-chat-avatar--user">{{ user.avatarText }}</view>
57
-						<view class="fe-chat-bubble"><text>{{ msg.content }}</text></view>
85
+						<view class="fe-chat-bubble">
86
+							<view v-if="msg.streaming && !msg.content" class="fe-thinking-dots fe-thinking-dots--inline">
87
+								<view class="fe-thinking-dots__dot" />
88
+								<view class="fe-thinking-dots__dot" />
89
+								<view class="fe-thinking-dots__dot" />
90
+							</view>
91
+							<text v-else class="fe-chat-bubble__text" user-select>{{ msg.content }}</text>
92
+						</view>
58 93
 					</view>
59 94
 
60 95
 					<!-- Report card -->
@@ -143,16 +178,10 @@
143 178
 							</view>
144 179
 						</view>
145 180
 					</view>
146
-				</block>
147
-
148
-				<view v-if="thinking" class="fe-chat-msg fe-chat-msg--ai">
149
-					<view class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
150
-					<view class="fe-thinking-dots">
151
-						<view class="fe-thinking-dots__dot" />
152
-						<view class="fe-thinking-dots__dot" />
153
-						<view class="fe-thinking-dots__dot" />
154
-					</view>
155 181
 				</view>
182
+				<view id="chat-bottom-anchor" class="chat-bottom-anchor" />
183
+				</block>
184
+				</block>
156 185
 			</view>
157 186
 		</scroll-view>
158 187
 
@@ -160,7 +189,7 @@
160 189
 
161 190
 		<!-- Footer: Quick Actions + Input -->
162 191
 		<view class="chat-footer">
163
-			<view class="fe-quick-actions fe-quick-actions--dock">
192
+			<view class="fe-quick-actions fe-quick-actions--dock guide-target guide-target--quick-bar">
164 193
 				<view class="fe-quick-action" @tap="triggerAI('发布需求')">
165 194
 					<view class="fe-quick-action__icon" style="background:#FEF3C7;">➕</view>
166 195
 					<text class="fe-quick-action__label">发布需求</text>
@@ -281,10 +310,21 @@ import FeReportSheet from '@/components/chat/FeReportSheet.vue'
281 310
 import FeReportFloat from '@/components/chat/FeReportFloat.vue'
282 311
 import FeBatchQrModal from '@/components/chat/FeBatchQrModal.vue'
283 312
 import store from '@/common/store.js'
284
-import { CHAT_SESSIONS, CHAT_DATA, MESSAGES, showToast } from '@/common/chat-data.js'
313
+import { MESSAGES, showToast } from '@/common/chat-data.js'
314
+import { listConversations, createConversation, streamConversationChat, listConversationMessages } from '@/api/conversation.js'
315
+import {
316
+	getNewChatGreeting,
317
+	mapConversationItem,
318
+	mapConversationMessage,
319
+	calcLastMessagePage,
320
+	CONVERSATION_MESSAGE_PAGE_SIZE
321
+} from '@/utils/conversation.js'
285 322
 
286 323
 export default {
287 324
 	name: 'EnterpriseHome',
325
+	options: {
326
+		styleIsolation: 'shared'
327
+	},
288 328
 	props: {
289 329
 		active: { type: Boolean, default: true }
290 330
 	},
@@ -302,20 +342,34 @@ export default {
302 342
 		return {
303 343
 			user: {},
304 344
 			enterprise: {},
305
-			sessions: CHAT_SESSIONS,
306
-			currentSession: 'employment',
345
+			sessions: [],
346
+			sessionsLoading: false,
347
+			creatingSession: false,
348
+			currentSession: '',
349
+			currentConversationId: null,
307 350
 			messages: [],
351
+			messagesLoading: false,
352
+			messagesLoadingMore: false,
353
+			historyPage: 1,
354
+			hasMoreHistory: false,
355
+			hasMessageHistory: false,
356
+			messagePageSize: CONVERSATION_MESSAGE_PAGE_SIZE,
308 357
 			inputText: '',
309 358
 			canSend: false,
310
-			thinking: false,
359
+			chatStreaming: false,
360
+			abortChat: null,
361
+			scrollTop: 0,
311 362
 			scrollIntoView: '',
363
+			scrollWithAnimation: true,
364
+			stickToBottom: true,
312 365
 			sidebarOpen: false,
313 366
 			activeSheet: '',
314 367
 			activeDrawer: '',
315 368
 			modal: '',
316 369
 			inbox: MESSAGES,
317 370
 			reportCardVisible: true,
318
-			reportObserver: null
371
+			reportObserver: null,
372
+			_localMsgSeq: 0
319 373
 		}
320 374
 	},
321 375
 	computed: {
@@ -328,6 +382,12 @@ export default {
328 382
 		showReportFloat() {
329 383
 			return this.hasReportMessage && !this.reportCardVisible
330 384
 		},
385
+		showNewChatEmpty() {
386
+			return !this.sessionsLoading && !this.currentSession && !this.sessions.length
387
+		},
388
+		newChatGreeting() {
389
+			return getNewChatGreeting()
390
+		},
331 391
 		sheetElevated() {
332 392
 			return this.modal === 'qr' && this.activeSheet === 'progress'
333 393
 		}
@@ -349,44 +409,285 @@ export default {
349 409
 	},
350 410
 	beforeDestroy() {
351 411
 		this.disconnectReportObserver()
412
+		this.stopChatStream()
413
+		if (this._scrollTimer) clearTimeout(this._scrollTimer)
352 414
 	},
353 415
 	methods: {
354 416
 		toast: showToast,
355
-		initHome() {
417
+		async initHome() {
356 418
 			const s = store.getState()
357 419
 			this.user = s.user
358 420
 			this.enterprise = s.enterprise
359
-			if (!this.messages.length) {
360
-				this.loadSession('employment')
421
+			await this.fetchSessions({ autoLoad: true })
422
+		},
423
+		async fetchSessions(options = {}) {
424
+			const { autoLoad = false, showError = false } = options
425
+			this.sessionsLoading = true
426
+			try {
427
+				const list = await listConversations({ status: 'active' }, { showError })
428
+				this.sessions = (list || []).map(mapConversationItem)
429
+				if (autoLoad) {
430
+					if (this.sessions.length) {
431
+						const current = this.sessions.find((item) => item.conversationNo === this.currentSession)
432
+						const target = current || this.sessions[0]
433
+						await this.loadSession(target.conversationNo, target.chatType)
434
+					} else if (!this.currentSession) {
435
+						this.messages = []
436
+					}
437
+				}
438
+			} catch (e) {
439
+				if (autoLoad && !this.currentSession) {
440
+					this.messages = []
441
+				}
442
+			} finally {
443
+				this.sessionsLoading = false
361 444
 			}
362 445
 		},
363
-		loadSession(id) {
364
-			this.currentSession = id
365
-			this.messages = JSON.parse(JSON.stringify(CHAT_DATA[id] || []))
446
+		buildWelcomeMessages() {
447
+			return [{
448
+				type: 'text',
449
+				role: 'ai',
450
+				content: getNewChatGreeting(),
451
+				msgKey: this.nextLocalMsgKey()
452
+			}]
453
+		},
454
+		nextLocalMsgKey() {
455
+			this._localMsgSeq += 1
456
+			return -this._localMsgSeq
457
+		},
458
+		resetMessagePagination() {
459
+			this.historyPage = 1
460
+			this.hasMoreHistory = false
461
+			this.hasMessageHistory = false
462
+			this.messagesLoading = false
463
+			this.messagesLoadingMore = false
464
+		},
465
+		async loadSession(conversationNo, chatType) {
466
+			this.abortChatStream()
467
+			this.currentSession = conversationNo
468
+			const session = this.sessions.find((item) => item.conversationNo === conversationNo)
469
+			this.currentConversationId = session ? session.id : null
470
+			this.resetMessagePagination()
471
+			this.messages = []
366 472
 			this.reportCardVisible = true
367
-			this.scrollBottom()
473
+			await this.fetchLatestMessages({ scrollBottom: true })
368 474
 			this.$nextTick(() => {
369 475
 				this.setupReportObserver()
370 476
 				setTimeout(() => this.checkReportVisibility(), 120)
371 477
 			})
372 478
 		},
373
-		switchSession(id) {
479
+		async fetchLatestMessages(options = {}) {
480
+			const { scrollBottom = false } = options
481
+			if (!this.currentConversationId) {
482
+				this.messages = this.buildWelcomeMessages()
483
+				return
484
+			}
485
+
486
+			this.messagesLoading = true
487
+			try {
488
+				const probe = await listConversationMessages(
489
+					this.currentConversationId,
490
+					{ page: 1, size: this.messagePageSize },
491
+					{ showError: true }
492
+				)
493
+				const total = probe.total || 0
494
+				if (!total) {
495
+					this.messages = this.buildWelcomeMessages()
496
+					this.hasMoreHistory = false
497
+					this.hasMessageHistory = false
498
+					return
499
+				}
500
+
501
+				const lastPage = calcLastMessagePage(total, this.messagePageSize)
502
+				let pageData = probe
503
+				if (lastPage > 1) {
504
+					pageData = await listConversationMessages(
505
+						this.currentConversationId,
506
+						{ page: lastPage, size: this.messagePageSize },
507
+						{ showError: true }
508
+					)
509
+				}
510
+
511
+				this.historyPage = lastPage
512
+				this.hasMoreHistory = lastPage > 1
513
+				this.hasMessageHistory = true
514
+				this.messages = (pageData.items || []).map(mapConversationMessage)
515
+				if (scrollBottom) {
516
+					this.stickToBottom = true
517
+					this.ensureChatBottom()
518
+				}
519
+			} catch (e) {
520
+				this.messages = this.buildWelcomeMessages()
521
+				this.hasMoreHistory = false
522
+				this.hasMessageHistory = false
523
+			} finally {
524
+				this.messagesLoading = false
525
+			}
526
+		},
527
+		measureChatContentHeight() {
528
+			return new Promise((resolve) => {
529
+				const query = uni.createSelectorQuery().in(this)
530
+				query.select('.chat-area__inner').boundingClientRect()
531
+				query.exec((res) => {
532
+					resolve((res[0] && res[0].height) || 0)
533
+				})
534
+			})
535
+		},
536
+		restoreScrollAfterPrepend(beforeHeight, beforeTop) {
537
+			return new Promise((resolve) => {
538
+				this.$nextTick(() => {
539
+					setTimeout(async () => {
540
+						const afterHeight = await this.measureChatContentHeight()
541
+						const delta = afterHeight - beforeHeight
542
+						this._programmaticScroll = true
543
+						this.scrollWithAnimation = false
544
+						this._scrollBump = (this._scrollBump || 0) + 1
545
+						this.scrollTop = Math.max(0, beforeTop + delta) + this._scrollBump
546
+						setTimeout(() => {
547
+							this._programmaticScroll = false
548
+							resolve()
549
+						}, 60)
550
+					}, 50)
551
+				})
552
+			})
553
+		},
554
+		async loadMoreHistory() {
555
+			if (
556
+				!this.hasMoreHistory ||
557
+				this.messagesLoadingMore ||
558
+				this.messagesLoading ||
559
+				this.chatStreaming ||
560
+				!this.currentConversationId
561
+			) {
562
+				return
563
+			}
564
+
565
+			const olderPage = this.historyPage - 1
566
+			if (olderPage < 1) {
567
+				this.hasMoreHistory = false
568
+				return
569
+			}
570
+
571
+			this.messagesLoadingMore = true
572
+			try {
573
+				const beforeHeight = await this.measureChatContentHeight()
574
+				const beforeTop = this._lastScrollTop || 0
575
+				const pageData = await listConversationMessages(
576
+					this.currentConversationId,
577
+					{ page: olderPage, size: this.messagePageSize },
578
+					{ showError: false }
579
+				)
580
+				const older = (pageData.items || []).map(mapConversationMessage)
581
+				if (!older.length) {
582
+					this.hasMoreHistory = false
583
+					return
584
+				}
585
+
586
+				this.messages = [...older, ...this.messages]
587
+				this.historyPage = olderPage
588
+				this.hasMoreHistory = olderPage > 1
589
+				await this.restoreScrollAfterPrepend(beforeHeight, beforeTop)
590
+			} catch (e) {
591
+				// 错误提示由 request.js 统一处理
592
+			} finally {
593
+				this.messagesLoadingMore = false
594
+			}
595
+		},
596
+		onChatScrollToUpper() {
597
+			this.loadMoreHistory()
598
+		},
599
+		switchSession(conversationNo) {
600
+			if (this.currentSession === conversationNo) {
601
+				this.sidebarOpen = false
602
+				return
603
+			}
374 604
 			this.sidebarOpen = false
375
-			this.loadSession(id)
605
+			const session = this.sessions.find((item) => item.conversationNo === conversationNo)
606
+			this.loadSession(conversationNo, session && session.chatType)
607
+		},
608
+		switchSessionByIndex(index) {
609
+			const session = this.sessions[index]
610
+			if (!session) return
611
+			this.switchSession(session.conversationNo)
376 612
 		},
377
-		newChat() {
613
+		async newChat() {
614
+			if (this.creatingSession) return
615
+			this.creatingSession = true
378 616
 			this.sidebarOpen = false
379
-			this.loadSession('employment')
380
-			this.toast('已开启新对话')
617
+			try {
618
+				const created = await createConversation({
619
+					chatType: 'employment',
620
+					title: '新对话'
621
+				})
622
+				const item = mapConversationItem(created)
623
+				this.sessions = [
624
+					item,
625
+					...this.sessions.filter((session) => session.conversationNo !== item.conversationNo)
626
+				]
627
+				this.loadSession(item.conversationNo, item.chatType)
628
+				this.toast('已开启新对话')
629
+			} catch (e) {
630
+				// 错误提示由 request.js 统一处理
631
+			} finally {
632
+				this.creatingSession = false
633
+			}
381 634
 		},
382
-		scrollBottom() {
383
-			this.$nextTick(() => {
384
-				const last = this.messages.length - 1
385
-				if (last >= 0) this.scrollIntoView = 'msg-' + last
635
+		scrollBottom(animated = true, force = false) {
636
+			if (!force && !this.stickToBottom && !this.chatStreaming) return
637
+			if (this._scrollTimer) clearTimeout(this._scrollTimer)
638
+			this._scrollTimer = setTimeout(() => {
639
+				this._scrollTimer = null
640
+				this.scrollWithAnimation = !!animated
641
+				this._programmaticScroll = true
642
+				this._scrollBump = (this._scrollBump || 0) + 1
643
+				this.scrollTop = 100000 + this._scrollBump
644
+				setTimeout(() => {
645
+					this._programmaticScroll = false
646
+				}, animated ? 300 : 60)
386 647
 				setTimeout(() => this.checkReportVisibility(), 120)
648
+			}, animated ? 40 : 0)
649
+		},
650
+		scrollToBottomAnchor() {
651
+			this.scrollIntoView = ''
652
+			this.$nextTick(() => {
653
+				this.scrollIntoView = 'chat-bottom-anchor'
654
+			})
655
+		},
656
+		ensureChatBottom() {
657
+			this.stickToBottom = true
658
+			const scroll = () => {
659
+				this.scrollBottom(false, true)
660
+				this.scrollToBottomAnchor()
661
+			}
662
+			this.$nextTick(() => {
663
+				scroll()
664
+				setTimeout(scroll, 60)
665
+				setTimeout(scroll, 180)
666
+				setTimeout(scroll, 360)
387 667
 			})
388 668
 		},
389
-		onChatScroll() {
669
+		onChatScroll(e) {
670
+			const detail = e.detail || {}
671
+			this._lastScrollTop = detail.scrollTop || 0
672
+			if (this._programmaticScroll) {
673
+				this.checkReportVisibility()
674
+				return
675
+			}
676
+			const gap = (detail.scrollHeight || 0) - (detail.scrollTop || 0) - (detail.clientHeight || 0)
677
+			if (this.chatStreaming) {
678
+				// 流式输出时内容可能突增,仅当明显上滑才取消贴底
679
+				if (gap > 280) this.stickToBottom = false
680
+			} else {
681
+				this.stickToBottom = gap < 120
682
+			}
683
+			if (
684
+				!this.chatStreaming &&
685
+				!this.messagesLoading &&
686
+				!this.messagesLoadingMore &&
687
+				this._lastScrollTop <= 20
688
+			) {
689
+				this.loadMoreHistory()
690
+			}
390 691
 			this.checkReportVisibility()
391 692
 		},
392 693
 		setupReportObserver() {
@@ -426,32 +727,81 @@ export default {
426 727
 			this.openSheet('report')
427 728
 		},
428 729
 		onInput() {
429
-			this.canSend = this.inputText.trim().length > 0
730
+			this.canSend = this.inputText.trim().length > 0 && !this.chatStreaming
731
+		},
732
+		stopChatStream() {
733
+			if (this.abortChat) {
734
+				this.abortChat()
735
+				this.abortChat = null
736
+			}
737
+			this.chatStreaming = false
738
+		},
739
+		abortChatStream() {
740
+			if (this.abortChat) {
741
+				this.abortChat()
742
+				this.abortChat = null
743
+			}
430 744
 		},
431 745
 		sendMsg() {
432 746
 			const text = this.inputText.trim()
433
-			if (!text) return
434
-			this.messages.push({ type: 'text', role: 'user', content: text })
747
+			if (!text || this.chatStreaming) return
748
+			if (!this.currentConversationId) {
749
+				this.toast('请先开启新对话')
750
+				return
751
+			}
752
+
753
+			this.abortChatStream()
754
+			this.stickToBottom = true
755
+			this.messages.push({ type: 'text', role: 'user', content: text, msgKey: this.nextLocalMsgKey() })
756
+			const aiIdx = this.messages.length
757
+			this.messages.push({
758
+				type: 'text',
759
+				role: 'ai',
760
+				content: '',
761
+				streaming: true,
762
+				msgKey: this.nextLocalMsgKey()
763
+			})
435 764
 			this.inputText = ''
436 765
 			this.canSend = false
437
-			this.thinking = true
766
+			this.chatStreaming = true
438 767
 			this.scrollBottom()
439
-			setTimeout(() => {
440
-				this.thinking = false
441
-				if (/招|搬运|临时工/.test(text)) {
442
-					this.messages.push({ type: 'text', role: 'ai', content: '好的,已为您分析需求。正在生成用工草稿...' })
443
-					this.scrollBottom()
444
-					setTimeout(() => {
445
-						this.messages.push({ type: 'draft', role: 'ai' })
446
-						this.scrollBottom()
447
-					}, 600)
448
-				} else {
449
-					this.messages.push({ type: 'text', role: 'ai', content: '我理解您的问题,我可以帮您:发布用工需求、测算成本、查看进度、管理订单。请告诉我您具体想做什么?' })
450
-					this.scrollBottom()
768
+
769
+			this.abortChat = streamConversationChat({
770
+				conversationId: this.currentConversationId,
771
+				message: text,
772
+				onDelta: (delta) => {
773
+					const current = this.messages[aiIdx].content || ''
774
+					this.$set(this.messages[aiIdx], 'content', current + delta)
775
+					const force = (delta && delta.length > 80)
776
+					this.scrollBottom(false, force)
777
+					if (force) {
778
+						this.$nextTick(() => this.scrollBottom(false, true))
779
+					}
780
+				},
781
+				onDone: () => {
782
+					this.$set(this.messages[aiIdx], 'streaming', false)
783
+					if (!this.messages[aiIdx].content) {
784
+						this.$set(this.messages[aiIdx], 'content', '暂无回复,请稍后重试')
785
+					}
786
+					this.ensureChatBottom()
787
+					this.stopChatStream()
788
+					this.fetchSessions({ showError: false })
789
+				},
790
+				onError: (err) => {
791
+					this.$set(this.messages[aiIdx], 'streaming', false)
792
+					const message = (err && err.message) || '对话失败,请稍后重试'
793
+					if (!this.messages[aiIdx].content) {
794
+						this.$set(this.messages[aiIdx], 'content', message)
795
+					} else {
796
+						this.toast(message)
797
+					}
798
+					this.ensureChatBottom()
799
+					this.stopChatStream()
451 800
 				}
452
-			}, 1200)
801
+			})
453 802
 		},
454 803
 		triggerAI(text) {
804
+			if (this.chatStreaming) return
455 805
 			this.inputText = text
456 806
 			this.canSend = true
457 807
 			this.sendMsg()
@@ -464,6 +814,7 @@ export default {
464 814
 			this.activeSheet = ''
465 815
 			this.activeDrawer = ''
466 816
 			this.sidebarOpen = true
817
+			this.fetchSessions()
467 818
 		},
468 819
 		openSheet(name) {
469 820
 			this.closeAll()
@@ -606,6 +957,35 @@ export default {
606 957
 
607 958
 .chat-area__inner {
608 959
 	padding: 32rpx;
960
+	padding-bottom: 48rpx;
961
+}
962
+
963
+.chat-msg-wrap {
964
+	width: 100%;
965
+}
966
+
967
+.chat-status {
968
+	display: flex;
969
+	justify-content: center;
970
+	padding: 24rpx 0 8rpx;
971
+}
972
+
973
+.chat-status--top {
974
+	padding: 8rpx 0 24rpx;
975
+}
976
+
977
+.chat-status__text {
978
+	font-size: 24rpx;
979
+	color: $fe-gray-500;
980
+}
981
+
982
+.chat-status__text--muted {
983
+	color: $fe-gray-400;
984
+}
985
+
986
+.chat-bottom-anchor {
987
+	height: 2rpx;
988
+	width: 100%;
609 989
 }
610 990
 
611 991
 .sidebar-head {
@@ -636,4 +1016,64 @@ export default {
636 1016
 .sidebar-list {
637 1017
 	height: calc(100% - 120rpx);
638 1018
 }
1019
+
1020
+.sidebar-status {
1021
+	padding: 48rpx 28rpx;
1022
+	text-align: center;
1023
+}
1024
+
1025
+.sidebar-status__text {
1026
+	font-size: 26rpx;
1027
+	color: $fe-gray-500;
1028
+}
1029
+
1030
+.sidebar-status--action {
1031
+	cursor: pointer;
1032
+}
1033
+
1034
+.sidebar-status__text--action {
1035
+	color: $fe-primary;
1036
+	font-weight: 600;
1037
+}
1038
+
1039
+.chat-empty {
1040
+	min-height: 100%;
1041
+	display: flex;
1042
+	flex-direction: column;
1043
+	align-items: center;
1044
+	justify-content: center;
1045
+	padding: 80rpx 48rpx;
1046
+	text-align: center;
1047
+	box-sizing: border-box;
1048
+}
1049
+
1050
+.chat-empty__icon {
1051
+	width: 120rpx;
1052
+	height: 120rpx;
1053
+	border-radius: 50%;
1054
+	background: $fe-primary-bg;
1055
+	display: flex;
1056
+	align-items: center;
1057
+	justify-content: center;
1058
+	font-size: 56rpx;
1059
+	margin-bottom: 32rpx;
1060
+}
1061
+
1062
+.chat-empty__title {
1063
+	font-size: 36rpx;
1064
+	font-weight: 700;
1065
+	color: $fe-foreground;
1066
+	margin-bottom: 48rpx;
1067
+	line-height: 1.5;
1068
+	max-width: 520rpx;
1069
+}
1070
+
1071
+.chat-empty__btn {
1072
+	padding: 24rpx 64rpx;
1073
+	border-radius: $fe-radius-full;
1074
+	background: $fe-primary;
1075
+	color: #fff;
1076
+	font-size: 30rpx;
1077
+	font-weight: 600;
1078
+}
639 1079
 </style>

+ 132 - 0
huimv-employment/app/mixins/enterprise-home-guide.js

@@ -0,0 +1,132 @@
1
+import store from '@/common/store.js'
2
+
3
+import {
4
+
5
+	buildNoviceGuidanceSteps,
6
+
7
+	isEnterpriseHomeGuideDone,
8
+
9
+	NOVICE_GUIDANCE_BASE_CONFIG,
10
+
11
+	resolveGuideUserKey,
12
+
13
+	setEnterpriseHomeGuideDone
14
+
15
+} from '@/utils/home-guide.js'
16
+
17
+
18
+
19
+export default {
20
+
21
+	data() {
22
+
23
+		return {
24
+
25
+			homeGuideBaseConfig: NOVICE_GUIDANCE_BASE_CONFIG,
26
+
27
+			homeGuideSteps: buildNoviceGuidanceSteps()
28
+
29
+		}
30
+
31
+	},
32
+
33
+	methods: {
34
+
35
+		getEnterpriseHomeContext() {
36
+
37
+			const ref = this.$refs.enterpriseHome
38
+
39
+			if (Array.isArray(ref)) return ref[0] || null
40
+
41
+			return ref || null
42
+
43
+		},
44
+
45
+		buildGuideRuntimeConfig() {
46
+
47
+			const context = this.getEnterpriseHomeContext()
48
+
49
+			const steps = buildNoviceGuidanceSteps().map((step) => ({
50
+
51
+				...step,
52
+
53
+				context: () => context
54
+
55
+			}))
56
+
57
+			return { steps, baseConfig: this.homeGuideBaseConfig }
58
+
59
+		},
60
+
61
+		onHomeGuideEnded() {
62
+
63
+			const state = store.getState()
64
+
65
+			setEnterpriseHomeGuideDone(resolveGuideUserKey(state.user, state.enterprise))
66
+
67
+		},
68
+
69
+		startHomeGuide() {
70
+
71
+			const guide = this.$refs.noviceGuidance
72
+
73
+			const context = this.getEnterpriseHomeContext()
74
+
75
+			if (!guide || !context || typeof guide.start !== 'function') return false
76
+
77
+
78
+
79
+			const { steps, baseConfig } = this.buildGuideRuntimeConfig()
80
+
81
+			if (typeof guide.setConfig === 'function') {
82
+
83
+				guide.setConfig(steps, baseConfig)
84
+
85
+			}
86
+
87
+
88
+
89
+			setTimeout(() => guide.start(), 80)
90
+
91
+			return true
92
+
93
+		},
94
+
95
+		scheduleHomeGuide() {
96
+
97
+			if (this.viewRole !== 'enterprise') return
98
+
99
+			const state = store.getState()
100
+
101
+			const guideUserKey = resolveGuideUserKey(state.user, state.enterprise)
102
+
103
+			if (isEnterpriseHomeGuideDone(guideUserKey)) return
104
+
105
+
106
+
107
+			const tryStart = (attempt = 0) => {
108
+
109
+				if (this.viewRole !== 'enterprise' || !this.ready) return
110
+
111
+				if (!this.startHomeGuide() && attempt < 15) {
112
+
113
+					setTimeout(() => tryStart(attempt + 1), 150)
114
+
115
+				}
116
+
117
+			}
118
+
119
+
120
+
121
+			this.$nextTick(() => {
122
+
123
+				setTimeout(() => tryStart(), 300)
124
+
125
+			})
126
+
127
+		}
128
+
129
+	}
130
+
131
+}
132
+

+ 239 - 21
huimv-employment/app/pages/index/index.vue

@@ -1,172 +1,390 @@
1 1
 <template>
2
-	<view class="index-page">
3
-		<view v-if="booting" class="index-boot">
4
-			<view class="index-boot__spinner" />
5
-			<text class="index-boot__text">加载中...</text>
6
-		</view>
7 2
 
8
-		<view v-else-if="needRegister" class="index-blank" />
3
+	<view class="index-root">
9 4
 
10
-		<view v-else-if="ready" class="index-home">
11
-			<EnterpriseHome v-if="viewRole === 'enterprise'" :active="true" />
12
-			<WorkerHome v-else :active="true" />
13
-		</view>
5
+		<view class="index-page">
6
+
7
+			<view v-if="booting" class="index-boot">
8
+
9
+				<view class="index-boot__spinner" />
10
+
11
+				<text class="index-boot__text">加载中...</text>
14 12
 
15
-		<view
16
-			class="fe-modal-overlay"
17
-			:class="{ 'fe-modal-overlay--active': registerModalVisible }"
18
-		/>
19
-		<view class="fe-modal" :class="{ 'fe-modal--active': registerModalVisible }">
20
-			<view class="fe-modal-body">
21
-				<view class="fe-modal-icon fe-modal-icon--warn">!</view>
22
-				<view class="fe-modal-title">{{ registerModalTitle }}</view>
23
-				<view class="fe-modal-desc">完成登记后即可使用全部功能</view>
24 13
 			</view>
25
-			<view class="fe-modal-footer">
26
-				<view class="fe-modal-btn fe-modal-btn--confirm index-register-btn" @tap="goRegister">去登记</view>
14
+
15
+
16
+
17
+			<view v-else-if="needRegister" class="index-blank" />
18
+
19
+
20
+
21
+			<view v-else-if="ready" class="index-home">
22
+
23
+				<EnterpriseHome
24
+
25
+					v-if="viewRole === 'enterprise'"
26
+
27
+					ref="enterpriseHome"
28
+
29
+					:active="true"
30
+
31
+				/>
32
+
33
+				<WorkerHome v-else :active="true" />
34
+
27 35
 			</view>
36
+
37
+
38
+
39
+			<view
40
+
41
+				class="fe-modal-overlay"
42
+
43
+				:class="{ 'fe-modal-overlay--active': registerModalVisible }"
44
+
45
+			/>
46
+
47
+			<view class="fe-modal" :class="{ 'fe-modal--active': registerModalVisible }">
48
+
49
+				<view class="fe-modal-body">
50
+
51
+					<view class="fe-modal-icon fe-modal-icon--warn">!</view>
52
+
53
+					<view class="fe-modal-title">{{ registerModalTitle }}</view>
54
+
55
+					<view class="fe-modal-desc">完成登记后即可使用全部功能</view>
56
+
57
+				</view>
58
+
59
+				<view class="fe-modal-footer">
60
+
61
+					<view class="fe-modal-btn fe-modal-btn--confirm index-register-btn" @tap="goRegister">去登记</view>
62
+
63
+				</view>
64
+
65
+			</view>
66
+
28 67
 		</view>
68
+
69
+
70
+
71
+		<hz-novice-guidance
72
+
73
+			v-if="ready && viewRole === 'enterprise'"
74
+
75
+			ref="noviceGuidance"
76
+
77
+			:base-config="homeGuideBaseConfig"
78
+
79
+			:steps="homeGuideSteps"
80
+
81
+			@finished="onHomeGuideEnded"
82
+
83
+			@skiped="onHomeGuideEnded"
84
+
85
+		/>
86
+
29 87
 	</view>
88
+
30 89
 </template>
31 90
 
91
+
92
+
32 93
 <script>
94
+
33 95
 import EnterpriseHome from '@/components/home/EnterpriseHome.vue'
96
+
34 97
 import WorkerHome from '@/components/home/WorkerHome.vue'
98
+
35 99
 import store from '@/common/store.js'
100
+
36 101
 import { getToken } from '@/utils/request.js'
37 102
 
103
+import enterpriseHomeGuide from '@/mixins/enterprise-home-guide.js'
104
+
105
+
106
+
38 107
 export default {
108
+
39 109
 	components: { EnterpriseHome, WorkerHome },
110
+
111
+	mixins: [enterpriseHomeGuide],
112
+
40 113
 	data() {
114
+
41 115
 		return {
116
+
42 117
 			booting: true,
118
+
43 119
 			ready: false,
120
+
44 121
 			needRegister: false,
122
+
45 123
 			registerModalVisible: false,
124
+
46 125
 			viewRole: 'enterprise'
126
+
47 127
 		}
128
+
48 129
 	},
130
+
49 131
 	computed: {
132
+
50 133
 		registerModalTitle() {
134
+
51 135
 			return this.viewRole === 'worker' ? '暂未临时工登记' : '暂未企业登记'
136
+
52 137
 		}
138
+
53 139
 	},
140
+
54 141
 	onShow() {
142
+
55 143
 		if (this.ready) return
144
+
56 145
 		if (this.needRegister) {
146
+
57 147
 			this.registerModalVisible = true
148
+
58 149
 			return
150
+
59 151
 		}
152
+
60 153
 		this.bootstrap()
154
+
61 155
 	},
156
+
62 157
 	methods: {
158
+
63 159
 		goLogin() {
160
+
64 161
 			uni.reLaunch({ url: '/pages/auth/login' })
162
+
65 163
 		},
164
+
66 165
 		goRegister() {
166
+
67 167
 			this.registerModalVisible = false
168
+
68 169
 			const url = this.viewRole === 'worker'
170
+
69 171
 				? '/pages/worker/register'
172
+
70 173
 				: '/pages/enterprise/register'
174
+
71 175
 			uni.navigateTo({ url })
176
+
72 177
 		},
178
+
73 179
 		resolveViewRole(info) {
180
+
74 181
 			return info && info.userType === 'worker' ? 'worker' : 'enterprise'
182
+
75 183
 		},
184
+
76 185
 		async bootstrap() {
186
+
77 187
 			this.booting = true
188
+
78 189
 			this.ready = false
190
+
79 191
 			this.needRegister = false
192
+
80 193
 			this.registerModalVisible = false
81 194
 
195
+
196
+
82 197
 			const token = getToken()
198
+
83 199
 			if (!token) {
200
+
84 201
 				this.goLogin()
202
+
85 203
 				return
204
+
86 205
 			}
87 206
 
207
+
208
+
88 209
 			try {
210
+
89 211
 				const info = await store.syncProfile({ showError: false })
212
+
90 213
 				store.state.loggedIn = true
214
+
91 215
 				store.persist()
92 216
 
217
+
218
+
93 219
 				this.viewRole = this.resolveViewRole(info)
94 220
 
221
+
222
+
95 223
 				if (!info || !info.registered) {
224
+
96 225
 					this.needRegister = true
226
+
97 227
 					this.registerModalVisible = true
228
+
98 229
 					return
230
+
99 231
 				}
100 232
 
233
+
234
+
101 235
 				this.ready = true
236
+
237
+				this.scheduleHomeGuide()
238
+
102 239
 			} catch (e) {
240
+
103 241
 				store.logout()
242
+
104 243
 				this.goLogin()
244
+
105 245
 			} finally {
246
+
106 247
 				this.booting = false
248
+
107 249
 			}
250
+
108 251
 		}
252
+
109 253
 	}
254
+
110 255
 }
256
+
111 257
 </script>
112 258
 
259
+
260
+
113 261
 <style lang="scss" scoped>
262
+
263
+.index-root {
264
+
265
+	width: 100%;
266
+
267
+	height: 100vh;
268
+
269
+}
270
+
271
+
272
+
114 273
 .index-page {
274
+
115 275
 	display: flex;
276
+
116 277
 	flex-direction: column;
278
+
117 279
 	height: 100vh;
280
+
118 281
 	overflow: hidden;
282
+
119 283
 	background: $fe-bg;
284
+
120 285
 	box-sizing: border-box;
286
+
121 287
 }
122 288
 
289
+
290
+
123 291
 .index-boot,
292
+
124 293
 .index-blank {
294
+
125 295
 	flex: 1;
296
+
126 297
 	height: 100%;
298
+
127 299
 	background: $fe-surface;
300
+
128 301
 }
129 302
 
303
+
304
+
130 305
 .index-boot {
306
+
131 307
 	display: flex;
308
+
132 309
 	flex-direction: column;
310
+
133 311
 	align-items: center;
312
+
134 313
 	justify-content: center;
314
+
135 315
 	gap: 24rpx;
316
+
136 317
 }
137 318
 
319
+
320
+
138 321
 .index-boot__spinner {
322
+
139 323
 	width: 56rpx;
324
+
140 325
 	height: 56rpx;
326
+
141 327
 	border: 4rpx solid $fe-gray-200;
328
+
142 329
 	border-top-color: $fe-primary;
330
+
143 331
 	border-radius: 50%;
332
+
144 333
 	animation: index-spin 0.8s linear infinite;
334
+
145 335
 }
146 336
 
337
+
338
+
147 339
 .index-boot__text {
340
+
148 341
 	font-size: 28rpx;
342
+
149 343
 	color: $fe-gray-500;
344
+
150 345
 }
151 346
 
347
+
348
+
152 349
 .index-home {
350
+
153 351
 	flex: 1;
352
+
154 353
 	height: 100%;
354
+
155 355
 	overflow: hidden;
356
+
156 357
 }
157 358
 
359
+
360
+
158 361
 .index-register-btn {
362
+
159 363
 	flex: 1;
364
+
160 365
 }
161 366
 
367
+
368
+
162 369
 .fe-modal-icon--warn {
370
+
163 371
 	background: $fe-warning-light;
372
+
164 373
 	color: $fe-warning;
374
+
165 375
 }
166 376
 
377
+
378
+
167 379
 @keyframes index-spin {
380
+
168 381
 	to {
382
+
169 383
 		transform: rotate(360deg);
384
+
170 385
 	}
386
+
171 387
 }
388
+
172 389
 </style>
390
+

+ 13 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/changelog.md

@@ -0,0 +1,13 @@
1
+## 1.0.3(2026-03-11)
2
+- feat:`baseConfig`和`steps`新增`tipContentMaxHeight`配置,用于控制提示框中内容的最大高度,当内容超过最大高度时,内容可滚动查看,防止内容过高超出屏幕
3
+- fix:修复提示框高度在特殊情况下计算不准确,导致显示异常
4
+## 1.0.2(2025-12-26)
5
+- feat:新增  `skipStep` 方法,可跳转任意步骤
6
+- feat:新增  `setConfig` 方法,由于小程序不能通过组件参数传递函数,当配置中有函数时,调用该方法进行传递配置
7
+- feat:`baseConfig`新增`beforeNext`、`beforeSkip`、`beforeFinished`配置,用于拦截【下一步】【跳过】【完成】,且可自定义行为
8
+- feat:`steps`新增`beforeNext`配置,用于拦截【下一步】,且可自定义行为
9
+- fix:修复提示框在右侧不显示的问题
10
+## 1.0.1(2025-08-15)
11
+文档描述修改
12
+## 1.0.0(2025-08-14)
13
+操作引导组件

+ 507 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/hz-novice-guidance.vue

@@ -0,0 +1,507 @@
1
+<template>
2
+	<view class="guid-full-screen-mask" @touchmove.prevent.stop v-if="showGuide">
3
+		<view class="element-highlight-container" :style="[highlightStyle]"></view>
4
+		<view class="step-tips-arrow-container" :style="[tipsArrowPositionStyle]"
5
+			:class="stepTipsShow ? '' : 'step-tips-hide'">
6
+			<slot name="arrow" :scope="currentEleConfig" v-if="customArrow">
7
+			</slot>
8
+			<view class="step-tips-arrow" :style="[tipsArrowStyle]" v-else></view>
9
+		</view>
10
+		<view class="step-tips" :class="stepTipsShow ? '' : 'step-tips-hide'" :style="[tipsWidthStyle,tipsStyle]">
11
+			<scroll-view class="step-tips-content" :style="[tipsContentStyle]" scroll-y>
12
+				<slot name="tips" :scope="currentStepExportInfo">
13
+					<view>
14
+						<rich-text :nodes="currentEleConfig.tips || ''"></rich-text>
15
+					</view>
16
+				</slot>
17
+			</scroll-view>
18
+			<slot name="btns" v-if="!isOuterBtn" :scope="currentStepExportInfo">
19
+				<view class="step-tips-bottom">
20
+					<view v-if="showSkip" class="step-tips-jump" :style="[skipStyle]" @click="beforeSkip">{{
21
+						skipText
22
+					}}</view>
23
+					<view class="step-tips-btn" :style="[nextStyle]" @click="beforeNext">{{ nextText }}</view>
24
+				</view>
25
+			</slot>
26
+		</view>
27
+		<view class="step-tips-btn-outer-container" v-if="isOuterBtn" :class="stepTipsShow ? '' : 'step-tips-hide'"
28
+			:style="[outerBtnStyle]">
29
+			<slot name="btns" :scope="currentStepExportInfo">
30
+				<view class="step-tips-btn-outer">
31
+					<view v-if="showSkip" class="step-tips-btn-outer-jump" :style="[skipStyle]" @click="beforeSkip">{{
32
+            skipText
33
+          }}</view>
34
+					<view class="step-tips-btn-outer-next" :style="[nextStyle]" @click="beforeNext">{{
35
+            nextText
36
+          }}</view>
37
+				</view>
38
+			</slot>
39
+		</view>
40
+	</view>
41
+</template>
42
+<!--
43
+  新手引导组件
44
+-->
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'
51
+	export default {
52
+		mixins: [useGetDomInfo, useArrow, useOuterBtn, useStepTips, useGuard],
53
+		props: {
54
+			// 总体配置
55
+			baseConfig: {
56
+				type: Object,
57
+				default: () => ({})
58
+			},
59
+			// 步骤信息
60
+			steps: {
61
+				type: Array,
62
+				default: () => []
63
+			},
64
+		},
65
+		data() {
66
+			return {
67
+				// 系统信息
68
+				systemInfo: uni.getSystemInfoSync(),
69
+				// 引导组件实例
70
+				instance: null,
71
+				// 是否显示引导
72
+				showGuide: false,
73
+				// 当前步骤
74
+				currentStep: -1,
75
+				// 当前步骤元素信息
76
+				currentEleInfo: null,
77
+				// 实际渲染后的元素信息
78
+				currentEleAfterRenderInfo: null,
79
+				// 提示框展示位置
80
+				tipsPosition: '',
81
+				// 显示步骤提示弹框
82
+				stepTipsShow: false,
83
+				
84
+				//存储临时配置,用于更改配置
85
+				tempBaseConfig: null,
86
+				// 步骤列表
87
+				stepList: null
88
+			}
89
+		},
90
+		computed: {
91
+			// 合并后的总体配置
92
+			realBaseConfig() {
93
+				return {
94
+					scrollTime: 100,
95
+					customArrow: false,
96
+					autoRotateArrow: true,
97
+					showSkip: true,
98
+					tipMaxWidth: '70vw',
99
+					...(this.tempBaseConfig || {}),
100
+				}
101
+			},
102
+			// 当前步骤元素配置
103
+			currentEleConfig() {
104
+				return this.stepList[this.currentStep] || {}
105
+			},
106
+			// 高亮元素样式
107
+			highlightStyle() {
108
+				if (!this.currentEleInfo) return {}
109
+				const {
110
+					top,
111
+					left,
112
+					width,
113
+					height
114
+				} = this.currentEleInfo
115
+				const {
116
+					style,
117
+					offset
118
+				} = this.currentEleConfig
119
+
120
+				return {
121
+					'--bgcolor': 'rgba(0, 0, 0, 0.5)',
122
+					...(this.realBaseConfig?.highligthStyle || {}),
123
+					...(style || {}),
124
+					top: top + (offset?.top || 0) + 'px',
125
+					left: left + (offset?.left || 0) + 'px',
126
+					width: width + 'px',
127
+					height: height + 'px',
128
+				}
129
+			},
130
+			// 高亮元素信息
131
+			highlightElementInfo() {
132
+				const _highlightStyle = this.highlightStyle
133
+				const needFields = ['top', 'left', 'width', 'height']
134
+				const info = {}
135
+				for (const field of needFields) {
136
+					info[field] = _highlightStyle[field] ? parseFloat(_highlightStyle[field].split('px')[0]) : 0
137
+				}
138
+				return info
139
+			},
140
+			// 提示框最大宽度
141
+			tipsWidthStyle() {
142
+				return {
143
+					'--max-width': this.realBaseConfig.tipMaxWidth || '70vw'
144
+				}
145
+			},
146
+			// 提示框内容样式(最大高度)
147
+			tipsContentStyle() {
148
+				if (!this.tipsPosition) return {};
149
+				
150
+				const { top, height } = this.highlightElementInfo || {}
151
+				const { tipContentMaxHeight } = this.realBaseConfig || {}
152
+				const { tipContentMaxHeight: currentTipContentMaxHeight } = this.currentEleConfig || {}
153
+				const defaultMaxHeight = this.tipsPosition == 'bottom' 
154
+					? `calc(100vh - ${top || 0}px - ${height || 0}px - 30rpx - 60px)`
155
+					: `calc(${top || 0}px - 30rpx - 60px)`
156
+				const maxHeight = currentTipContentMaxHeight || tipContentMaxHeight || defaultMaxHeight
157
+				
158
+				return {
159
+					'max-height': maxHeight
160
+				}
161
+			},
162
+			// 当前步骤暴露出去的信息
163
+			currentStepExportInfo() { 
164
+				return {
165
+          currentEle: this.currentEleConfig,
166
+          highlightEle: this.highlightElementInfo,
167
+          tipsEle: this.tipsElementInfo,
168
+          outerBtnEle: this.outerBtnDomInfo,
169
+          tipsPosition:this.tipsPosition,
170
+          currentStep:this.currentStep,
171
+        }
172
+			}
173
+		},
174
+		watch: {
175
+			steps: {
176
+				handler(val) {
177
+					this.stepList = val || []
178
+				},
179
+				immediate: true
180
+			},
181
+			baseConfig: {
182
+				handler(val) {
183
+					this.tempBaseConfig = val || {}
184
+				},
185
+				immediate: true
186
+			}
187
+		},
188
+		created() {
189
+			this.instance = this
190
+			this.systemInfo = uni.getSystemInfoSync()
191
+		},
192
+		methods: {
193
+			// 设置配置,主要用于小程序参数中不能直接传递函数的问题
194
+			setConfig(steps, baseConfig = {}) {
195
+				this.stepList = steps || []
196
+				this.tempBaseConfig = baseConfig || {}
197
+			},
198
+			// 获取配置
199
+			getBaseConfigByKey(key, isBoolean = false) {
200
+				const val = this.currentEleConfig?.[key]
201
+				if (!isBoolean) {
202
+					return val || this.realBaseConfig?.[key] || false
203
+				}
204
+				if (typeof val != 'undefined' && typeof val != null) {
205
+					return val
206
+				}
207
+				return this.realBaseConfig?.[key] || false
208
+			},
209
+			// 延迟
210
+			sleep(time) {
211
+				return new Promise((resolve) => {
212
+					setTimeout(() => {
213
+						resolve(null)
214
+					}, time)
215
+				})
216
+			},
217
+			// 获取步骤元素信息
218
+			async getHighlightElementInfo(step = 0) {
219
+				if (!this.stepList[step]) return null
220
+				const {
221
+					el
222
+				} = this.stepList[step]
223
+				let info = await this.getDomInfo(el, this.getContext(this.stepList[step], this.instance), true)
224
+				return info
225
+			},
226
+			// 检查高亮元素是否全部在可视区域内,如果不在,则进行页面滚动
227
+			async checkHighlightElementInScreen() {
228
+				const scrollTime = this.realBaseConfig.scrollTime
229
+				let info = await this.getHighlightElementInfo(this.currentStep)
230
+				// 窗口高度,h5 取屏幕高度,其他取窗口高度
231
+				let wHeight = this.systemInfo.windowHeight
232
+				// #ifdef H5
233
+				wHeight = this.systemInfo.screenHeight
234
+				// #endif
235
+				const {
236
+					top,
237
+					bottom,
238
+					scrollTop
239
+				} = info
240
+
241
+				let isScroll = false
242
+				const gutter = 20
243
+				if (top - scrollTop < 0) {
244
+					uni.pageScrollTo({
245
+						scrollTop: top - scrollTop - gutter,
246
+						duration: scrollTime
247
+					})
248
+					isScroll = true
249
+				} else if (bottom > wHeight) {
250
+					uni.pageScrollTo({
251
+						scrollTop: bottom - wHeight + scrollTop + gutter,
252
+						duration: scrollTime
253
+					})
254
+					isScroll = true
255
+				}
256
+				if (isScroll) {
257
+					await this.sleep(scrollTime * 1.5)
258
+					info = await this.getHighlightElementInfo(this.currentStep)
259
+				}
260
+				this.currentEleInfo = info
261
+			},
262
+			// 判断提示框展示位置(上/下)
263
+			async checkTipsPosition() {
264
+				const res = await this.getDomInfo('.element-highlight-container', this.instance)
265
+				this.currentEleAfterRenderInfo = res
266
+				if (res.top + res.height / 2 > this.systemInfo.screenHeight / 2) {
267
+					this.tipsPosition = 'top'
268
+				} else {
269
+					this.tipsPosition = 'bottom'
270
+				}
271
+			},
272
+			// 计算各元素信息
273
+			async calcElementsInfo() {
274
+				await this.sleep(300)
275
+				this.checkTipsPosition()
276
+				await this.getTipsDomInfo()
277
+				this.customArrow && this.getArrowDomInfo()
278
+				this.isOuterBtn && this.getOuterBtnDomInfo()
279
+				this.stepTipsShow = true
280
+			},
281
+			// 检查下一步
282
+			checkStep(currentStep) {
283
+				const step = currentStep < this.stepList.length - 1 ? currentStep : -1
284
+				if (step == -1) {
285
+					this.beforeFinished()
286
+					return step
287
+				}
288
+				return step
289
+			}, 
290
+			// 下一步
291
+			async next() {
292
+				const step = this.checkStep(this.currentStep)
293
+				if(step == -1) return;
294
+				this.stepChange(step + 1)
295
+				await this.checkHighlightElementInScreen()
296
+				this.calcElementsInfo()
297
+			},
298
+			// 跳转步骤
299
+			skipStep(stepIndex = undefined, config = {}) {
300
+				// 不传或传空,默认为当前步骤的下一步
301
+				if(stepIndex === '' || stepIndex == null || typeof stepIndex == 'undefined'){
302
+					stepIndex = this.currentStep + 1
303
+				}
304
+				let formatStepIndex = Number(stepIndex)
305
+				if(isNaN(formatStepIndex)) {
306
+					console.error(`请传入正确的步骤索引:${stepIndex}`)
307
+					return
308
+				}
309
+				formatStepIndex = formatStepIndex >= 0 ? formatStepIndex : -1
310
+				if(formatStepIndex == -1) {
311
+					console.error(`步骤索引应大于等于0:${stepIndex}`)
312
+					return
313
+				}
314
+				
315
+				const {globalBeforeNextDisabled, beforeNextHandleDisabled} = config || {}
316
+				// 下一步函数
317
+				const next = () => {
318
+					if(this.checkStep(formatStepIndex) == -1) return;
319
+					this.start(formatStepIndex)
320
+				}
321
+				// 执行全局守卫函数
322
+				const handleStepBeforeNext = () => {
323
+					this.handleStepBeforeNext({
324
+						to: this.stepList[formatStepIndex] || null,
325
+						next: next,
326
+					}, next)
327
+				}
328
+				// 执行当前步骤守卫函数
329
+				const handleBeforeNext = () => {
330
+					this.beforeNext({
331
+						to: this.stepList[formatStepIndex] || null,
332
+						next: next,
333
+					}, next)
334
+				}
335
+				
336
+				// 不执行前置守卫
337
+				if (globalBeforeNextDisabled && beforeNextHandleDisabled) {
338
+					next()
339
+				} else if (globalBeforeNextDisabled && !beforeNextHandleDisabled) {
340
+					// 不执行全局前置守卫,执行当前步骤前置守卫
341
+					handleStepBeforeNext()
342
+				} else if (!globalBeforeNextDisabled && beforeNextHandleDisabled) {
343
+					// 执行全局前置守卫,不执行当前步骤前置守卫
344
+					handleBeforeNext()
345
+				} else {
346
+					// 都执行
347
+					this.handleStepBeforeNext({
348
+						to: this.stepList[formatStepIndex] || null,
349
+						next: handleBeforeNext,
350
+					}, handleBeforeNext)
351
+				}
352
+			},
353
+			// 完成引导
354
+			finish() {
355
+				this.beforeFinished()
356
+			},
357
+			handleFinished() {
358
+				this.showGuide = false
359
+				this.reset()
360
+				this.$emit('finished')
361
+			},
362
+			// 跳过
363
+			skip() {
364
+				this.beforeFinished()
365
+			},
366
+			handleSkip() {
367
+				this.showGuide = false
368
+				this.reset()
369
+				this.$emit('skiped')
370
+			},
371
+			// 步骤改变
372
+			stepChange(step = 0) {
373
+				this.stepTipsShow = false
374
+				this.currentStep = step
375
+				this.$emit('next', step, this.currentStepExportInfo)
376
+				// this.afterNext()
377
+			},
378
+			// 开始引导
379
+			async start(step = 0) {
380
+				this.showGuide = true
381
+				this.stepChange(step)
382
+				await this.checkHighlightElementInScreen()
383
+				this.calcElementsInfo()
384
+			},
385
+			// 重置
386
+			reset() {
387
+				this.currentStep = 0
388
+				this.stepTipsShow = false
389
+				this.currentEleInfo = undefined
390
+				this.currentEleAfterRenderInfo = undefined
391
+			}
392
+		}
393
+	}
394
+</script>
395
+
396
+
397
+<style scoped lang="scss">
398
+	.guid-full-screen-mask {
399
+		position: fixed;
400
+		top: 0;
401
+		left: 0;
402
+		width: 100vw;
403
+		height: 100vh;
404
+		z-index: 999999;
405
+		animation: fadeIn 0.3s;
406
+
407
+		.element-highlight-container {
408
+			position: absolute;
409
+			box-shadow: 0px 0px 0px 5000px var(--bgcolor);
410
+			border-radius: 16rpx;
411
+			box-sizing: content-box;
412
+			transition: all 0.3s;
413
+		}
414
+	}
415
+
416
+	.step-tips-arrow-container {
417
+		position: absolute;
418
+		z-index: 10000;
419
+		top: 0;
420
+		left: 100vw;
421
+
422
+		// transition: opacity 0.3s;
423
+		.step-tips-arrow {
424
+			height: 0;
425
+			width: 0;
426
+			border-right: var(--arrow-width) solid transparent;
427
+			border-left: var(--arrow-width) solid transparent;
428
+		}
429
+	}
430
+
431
+	.step-tips {
432
+		background: #fff;
433
+		// min-width: 50px;
434
+		max-width: var(--max-width);
435
+		animation: fadeIn 0.3s;
436
+		// box-shadow: 0px 2px 9px 0px rgba(0, 0, 0, 0.1);
437
+		position: absolute;
438
+		top: 0;
439
+		left: -100vw;
440
+		z-index: 10001;
441
+		padding: 15rpx 20rpx;
442
+		font-size: 28rpx;
443
+		border-radius: 12rpx;
444
+		box-sizing: border-box;
445
+
446
+		// transition: opacity 0.3s;
447
+		.step-tips-bottom {
448
+			font-size: 26rpx;
449
+			margin-top: 24rpx;
450
+			display: flex;
451
+			justify-content: flex-end;
452
+			align-items: center;
453
+
454
+			.step-tips-jump {
455
+				margin-right: 24rpx;
456
+			}
457
+
458
+			.step-tips-btn {
459
+				padding: 8rpx 20rpx;
460
+				background: #1cbbb4;
461
+				color: #fff;
462
+				border-radius: 8rpx;
463
+				text-align: center;
464
+			}
465
+		}
466
+	}
467
+
468
+	.step-tips-hide {
469
+		opacity: 0 !important;
470
+	}
471
+
472
+	.step-tips-btn-outer-container {
473
+		position: absolute;
474
+
475
+		.step-tips-btn-outer {
476
+			font-size: 26rpx;
477
+			display: flex;
478
+			justify-content: flex-end;
479
+			align-items: center;
480
+		}
481
+
482
+		.step-tips-btn-outer-jump {
483
+			color: #fff;
484
+			margin-right: 24rpx;
485
+		}
486
+
487
+		.step-tips-btn-outer-next {
488
+			font-size: 28rpx;
489
+			min-width: 210rpx;
490
+			padding: 16rpx 24rpx;
491
+			background: #1cbbb4;
492
+			color: #fff;
493
+			border-radius: 8rpx;
494
+			text-align: center;
495
+		}
496
+	}
497
+
498
+	@keyframes fadeIn {
499
+		0% {
500
+			opacity: 0;
501
+		}
502
+
503
+		100% {
504
+			opacity: 1;
505
+		}
506
+	}
507
+</style>

+ 106 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useArrow.js

@@ -0,0 +1,106 @@
1
+/**
2
+ * 箭头样式计算
3
+ * */
4
+export default {
5
+	data() {
6
+		return {
7
+			// 箭头元素信息
8
+			arrowDomInfo:null
9
+		}
10
+	},
11
+	computed: {
12
+		// 是否自定义箭头
13
+		customArrow() {
14
+			return this.getBaseConfigByKey('customArrow', true)
15
+		},
16
+		// 是否根据提示框显示位置自动旋转箭头
17
+		autoRotateArrow() {
18
+			return this.getBaseConfigByKey('autoRotateArrow', true)
19
+		},
20
+		// 箭头位置信息样式
21
+		tipsArrowPositionStyle() {
22
+			if (!this.currentEleAfterRenderInfo) return {}
23
+			const {
24
+				top,
25
+				left,
26
+				width,
27
+				height
28
+			} = this.currentEleAfterRenderInfo
29
+			const arrowWidth = this.arrowStyleInfo?.width || 0
30
+			const arrowHeight = this.arrowStyleInfo?.height || 0
31
+			const arrowTop = this.tipsPosition == 'top' ? 0 - arrowHeight : height
32
+			const positionStyle = {
33
+				top: top + arrowTop + 'px',
34
+				left: left + width / 2 - arrowWidth / 2 + 'px',
35
+			}
36
+			// 如果是自定义箭头,且允许箭头自动旋转,则弹窗在上方时,将箭头旋转180°
37
+			if (this.customArrow && this.autoRotateArrow && this.tipsPosition == 'top') {
38
+				positionStyle.transform = 'rotate(180deg)'
39
+			}
40
+			return positionStyle
41
+		},
42
+		// 箭头样式配置
43
+		arrowStyleConfig() {
44
+			return {
45
+				arrowStyle: {
46
+					...(this.realBaseConfig?.arrowStyle || {}),
47
+					...(this.currentEleConfig?.arrowStyle || {}),
48
+				}
49
+			}
50
+		},
51
+		// 箭头样式
52
+		tipsArrowStyle() {
53
+			// 如果是自定义箭头样式,则不使用默认箭头样式
54
+			if (this.customArrow) {
55
+				return {}
56
+			}
57
+			const {
58
+				arrowStyle
59
+			} = this.arrowStyleConfig
60
+			const {
61
+				width,
62
+				height
63
+			} = this.arrowStyleInfo
64
+			const arrowColor = arrowStyle?.color ? arrowStyle.color : '#fff'
65
+			const border = {
66
+				[this.tipsPosition == 'top' ?
67
+					'borderTop' :
68
+					'borderBottom'
69
+				]: `${height}px solid ${arrowColor}`,
70
+			}
71
+			
72
+			const _arrowStyle = {...arrowStyle}
73
+			delete _arrowStyle.width
74
+			delete _arrowStyle.height
75
+			
76
+			return {
77
+				...(_arrowStyle || {}),
78
+				...border,
79
+				'--arrow-width': width / 2 + 'px',
80
+				'--arrow-height': height + 'px',
81
+				'--arrow-color': arrowColor,
82
+			}
83
+		},
84
+		// 箭头信息
85
+		arrowStyleInfo() {
86
+			if (this.customArrow) {
87
+				return this.arrowDomInfo
88
+			}
89
+
90
+			const {
91
+				arrowStyle
92
+			} = this.arrowStyleConfig
93
+			return {
94
+				width: arrowStyle?.width ? parseFloat(arrowStyle.width) : 18,
95
+				height: arrowStyle?.height ? parseFloat(arrowStyle.height) : 12,
96
+			}
97
+		}
98
+	},
99
+	methods:{
100
+		// 获取箭头元素信息
101
+		async getArrowDomInfo(){
102
+			const res = await this.getDomInfo('.step-tips-arrow-container', this.instance)
103
+			this.arrowDomInfo= res
104
+		}
105
+	}
106
+}

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

@@ -0,0 +1,117 @@
1
+/**
2
+ * 获取元素信息
3
+ * */
4
+export default {
5
+	data() {
6
+		return {}
7
+	},
8
+
9
+	methods:{
10
+		// 获取元素信息
11
+		getDomInfo(selector, context = null, pageScroll = false) {
12
+			return new Promise(async (resolve, reject) => {
13
+				let scrollTop = 0
14
+				// #ifndef WEB
15
+				const query = uni.createSelectorQuery().in(context)
16
+				query
17
+					.select(selector)
18
+					.boundingClientRect(async (res) => {
19
+						if (pageScroll) {
20
+							scrollTop = await this.getPageScroll(context)
21
+						}
22
+						resolve(Object.assign(res || {}, {
23
+							scrollTop
24
+						}))
25
+					})
26
+					.exec()
27
+				// #endif
28
+				// #ifdef WEB
29
+				const elementQuery = document.querySelector(selector)
30
+				const elementRect = elementQuery.getBoundingClientRect()
31
+				if (pageScroll) {
32
+					scrollTop = await this.getPageScroll(context)
33
+				}
34
+				resolve(Object.assign(elementRect, {
35
+					scrollTop
36
+				}))
37
+				// #endif
38
+			})
39
+		},
40
+		// 获取context
41
+		getContext(eleConfig, instance) {
42
+			// #ifdef WEB
43
+			return null
44
+			// #endif
45
+			let context = null
46
+			if (typeof eleConfig.context === 'function') {
47
+				context = eleConfig.context()
48
+			} else if (Array.isArray(eleConfig.context) && eleConfig.context.length) {
49
+				// #ifdef VUE2
50
+				context = this.getChildComponentContextInVue2(eleConfig, instance)
51
+				// #endif
52
+				// #ifdef VUE3
53
+				context = this.getChildComponentContextInVue3(eleConfig, instance)
54
+				// #endif
55
+			} else {
56
+				// #ifdef MP-ALIPAY
57
+				context = eleConfig.context || instance.$parent.ctx
58
+				// #endif
59
+				// #ifndef MP-ALIPAY
60
+				context = eleConfig.context || instance.$parent
61
+				// #endif
62
+			}
63
+			// 如果是组件,可通过组件ref值的 $ 获取组件实例
64
+			// if (eleConfig.isComponent) {
65
+			// 	context = context?.$ || null
66
+			// }
67
+			return context
68
+		},
69
+		// vue2 环境获取子组件实例
70
+		getChildComponentContextInVue2(eleConfig, instance){
71
+			let parent = instance.$parent
72
+			eleConfig.context.forEach((item) => {
73
+				parent = parent.$refs[item]
74
+			})
75
+			return parent
76
+		},
77
+		// #ifdef VUE3
78
+		// vue3 环境获取子组件实例
79
+		getChildComponentContextInVue3(eleConfig, instance){
80
+			// #ifndef MP-ALIPAY
81
+			return this.getChildComponentContextInVue2(eleConfig, instance)
82
+			// #endif
83
+
84
+			// #ifdef MP-ALIPAY
85
+			let parent = instance.$parent
86
+			eleConfig.context.forEach((item) => {
87
+				parent = parent.$children.find((child) => {
88
+					return child.$attrs['hz-ref-name'] == item
89
+				})
90
+			})
91
+			return parent
92
+			// #endif
93
+		},
94
+		// #endif
95
+		// 获取页面滚动高度
96
+		getPageScroll(context = null) {
97
+			return new Promise((resolve, reject) => {
98
+				// #ifndef H5
99
+				const query = uni.createSelectorQuery().in(context)
100
+				query.selectViewport().scrollOffset((res) => {
101
+					resolve(res.scrollTop)
102
+				}).exec()
103
+				// #endif
104
+				// #ifdef H5
105
+				let scrollTop = document.documentElement.scrollTop
106
+				// 这里判断是否存在页面标题,如果存在,scrollTop 需要减去标题高度
107
+				const pageHeaderQuery = document.querySelector('.uni-page-head')
108
+				if (pageHeaderQuery) {
109
+					const pageHeaderRect = pageHeaderQuery?.getBoundingClientRect()
110
+					scrollTop += pageHeaderRect.height
111
+				}
112
+				resolve(scrollTop)
113
+				// #endif
114
+			})
115
+		}
116
+	}
117
+}

+ 132 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useGuard.js

@@ -0,0 +1,132 @@
1
+/*
2
+	步骤守卫
3
+*/
4
+
5
+export default {
6
+	computed: {
7
+		// 全局前置步骤守卫
8
+		golbalBeforeNext() {
9
+			const conf = this.realBaseConfig
10
+			return typeof conf.beforeNext == 'function' ? conf.beforeNext : null
11
+		},
12
+		// 全局后置步骤守卫
13
+		golbalAfterNext() {
14
+			const conf = this.realBaseConfig
15
+			return typeof conf.afterNext == 'function' ? conf.afterNext : null
16
+		},
17
+		// 当前步骤前置步骤守卫
18
+		stepBeforeNext() {
19
+			const conf = this.currentEleConfig
20
+			return typeof conf.beforeNext == 'function' ? conf.beforeNext : null
21
+		},
22
+		// 当前步骤后置步骤守卫
23
+		stepAfterNext() {
24
+			const conf = this.currentEleConfig
25
+			return typeof conf.afterNext == 'function' ? conf.afterNext : null
26
+		}
27
+	},
28
+	methods:{
29
+		// 进入步骤,步骤已改变,但还未渲染完成
30
+		enterStep() {
31
+			
32
+		},
33
+		// 离开步骤,步骤改变前,
34
+		leaveStep() {
35
+			
36
+		},
37
+		// 全局步骤守卫(前置)
38
+		beforeNext(customParams = {}, callback = null) {
39
+			const params = {
40
+				to: this.stepList[this.currentStep + 1] || null,
41
+				from: this.currentEleConfig,
42
+				next: this.handleStepBeforeNext,
43
+				skipStep: this.skipStep,
44
+				index:this.currentStep,
45
+				...customParams
46
+			}
47
+			if (this.golbalBeforeNext) {
48
+				this.golbalBeforeNext(params)
49
+				return
50
+			}
51
+			if (typeof callback == 'function') return callback();
52
+			this.handleStepBeforeNext()
53
+		},
54
+		// 执行步骤守卫(前置)
55
+		handleStepBeforeNext(customParams = {}, callback = null) {
56
+			const params = {
57
+				to: this.stepList[this.currentStep + 1] || null,
58
+				from: this.currentEleConfig,
59
+				next: this.next,
60
+				skipStep: this.skipStep,
61
+				index: this.currentStep,
62
+				...customParams
63
+			}
64
+			if (this.stepBeforeNext) {
65
+				this.stepBeforeNext(params)
66
+				return
67
+			}
68
+			if (typeof callback == 'function') return callback();
69
+			this.next()
70
+		},
71
+		// 全局步骤守卫(后置)
72
+		afterNext(customParams = {}, callback = null) {
73
+			const params = {
74
+				to: this.currentEleConfig,
75
+				from: this.stepList[this.currentStep - 1] || null,
76
+				index: this.currentStep,
77
+				skipStep: this.skipStep,
78
+				...customParams 
79
+			}
80
+			if (this.golbalAfterNext) {
81
+				this.golbalAfterNext(params)
82
+			}
83
+			if (typeof callback == 'function') return callback();
84
+			this.handleStepAfterNext()
85
+		},
86
+		// 执行步骤守卫(后置)
87
+		handleStepAfterNext(customParams = {}) {
88
+			const params = {
89
+				to: this.stepList[this.currentStep + 1] || null,
90
+				from: this.currentEleConfig,
91
+				index:this.currentStep,
92
+				skipStep: this.skipStep,
93
+				...customParams
94
+			}
95
+			if (this.stepAfterNext) {
96
+				this.stepAfterNext(params)
97
+			}
98
+		},
99
+		// 完成引导守卫
100
+		beforeFinished(customParams = {}, callback = null) {
101
+			const realBaseConfig = this.realBaseConfig
102
+			if(typeof realBaseConfig.beforeFinished == "function") {
103
+				realBaseConfig.beforeFinished({
104
+					finish: this.handleFinished,
105
+					index:this.currentStep, 
106
+					from: this.currentEleConfig,
107
+					skipStep: this.skipStep,
108
+					...customParams
109
+				})
110
+				return
111
+			}
112
+			if (typeof callback == 'function') return callback();
113
+			this.handleFinished()
114
+		},
115
+		// 跳过守卫
116
+		beforeSkip(customParams = {}, callback = null) {
117
+			const realBaseConfig = this.realBaseConfig
118
+			if(typeof realBaseConfig.beforeSkip == "function") {
119
+				realBaseConfig.beforeSkip({
120
+					skip: this.handleSkip, 
121
+					index:this.currentStep, 
122
+					from: this.currentEleConfig,
123
+					skipStep: this.skipStep,
124
+					...customParams
125
+				})
126
+				return
127
+			}
128
+			if (typeof callback == 'function') return callback();
129
+			this.handleSkip()
130
+		}
131
+	}
132
+}

+ 100 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useOuterBtn.js

@@ -0,0 +1,100 @@
1
+/**
2
+ * 步骤提示框计算
3
+ * */
4
+export default {
5
+	data() {
6
+		return {
7
+			// 外部按钮元素信息
8
+			outerBtnDomInfo: null,
9
+		}
10
+	},
11
+	computed: {
12
+		// 是否外部按钮
13
+		isOuterBtn() {
14
+			return this.getBaseConfigByKey('outerBtn', true)
15
+		},
16
+		// 外部按钮样式
17
+		outerBtnStyle() {
18
+			if (!this.isOuterBtn || !this.outerBtnDomInfo || !this.tipsElementInfo) return {}
19
+			const {
20
+				top,
21
+				left,
22
+				width,
23
+				height
24
+			} = this.tipsElementInfo
25
+			const _outerBtnDomInfo = this.outerBtnDomInfo
26
+			// 距提示框的上下间距
27
+			const gutter = 20
28
+			// 外部按钮top值
29
+			let bTop = 0
30
+			if (this.tipsPosition == 'top') {
31
+				bTop = top - gutter - _outerBtnDomInfo.height
32
+			} else {
33
+				bTop = top + gutter + height
34
+			}
35
+
36
+			// 检查边界情况
37
+			const check = (type = 'left') => {
38
+				// 高亮元素中间位置到屏幕右侧的距离
39
+				const rightDistance = this.systemInfo.windowWidth - left - width / 2
40
+				// 高亮元素中间位置到屏幕左侧的距离
41
+				const leftDistance = this.systemInfo.windowWidth - left - width / 2
42
+				if (type == 'left') {
43
+					// 检查提示框放再左侧时,位置是否合适
44
+					return (
45
+						_outerBtnDomInfo.width > width / 2 && rightDistance > _outerBtnDomInfo.width - width / 2
46
+					)
47
+				} else if (type == 'center') {
48
+					// 检查提示框放再中间时,位置是否合适
49
+					return (
50
+						rightDistance > _outerBtnDomInfo.width / 2 && leftDistance > _outerBtnDomInfo.width / 2
51
+					)
52
+				} else if (type == 'right') {
53
+					// 检查提示框放再右侧时,位置是否合适
54
+					return (
55
+						_outerBtnDomInfo.width > width / 2 && leftDistance > _outerBtnDomInfo.width - width / 2
56
+					)
57
+				}
58
+			}
59
+			// 获取left值
60
+			const getTipsLeft = (type = 'left') => {
61
+				if (type == 'left') {
62
+					return left
63
+				} else if (type == 'center') {
64
+					return left + width / 2 - _outerBtnDomInfo.width / 2
65
+				} else if (type == 'right') {
66
+					return left + width - _outerBtnDomInfo.width
67
+				}
68
+			}
69
+			// 检查提示框位置,寻找最合适的位置
70
+			const checkTipsPosition = (p = ['left', 'center', 'right']) => {
71
+				let align = ''
72
+				for (let i = 0; i < p.length; i++) {
73
+					if (check(p[i])) {
74
+						align = p[i]
75
+						break
76
+					}
77
+				}
78
+				return align
79
+			}
80
+
81
+			// 外部按钮left值
82
+			let bLeft = getTipsLeft(checkTipsPosition(['center', 'left', 'right']))
83
+			if (this.realBaseConfig.customOuterBtn) {
84
+				bLeft = 0
85
+			}
86
+			return {
87
+				top: bTop + 'px',
88
+				left: bLeft + 'px',
89
+			}
90
+		}
91
+	},
92
+	methods: {
93
+		// 获取外部按钮元素信息
94
+		async getOuterBtnDomInfo() {
95
+			const res = await this.getDomInfo('.step-tips-btn-outer-container', this.instance)
96
+			this.outerBtnDomInfo = res
97
+		},
98
+
99
+	}
100
+}

+ 138 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/components/hz-novice-guidance/mixins/useStepTips.js

@@ -0,0 +1,138 @@
1
+/**
2
+ * 步骤提示框计算
3
+ * */
4
+export default {
5
+	data() {
6
+		return {
7
+			// 提示框元素信息
8
+			tipsDomInfo: null,
9
+		}
10
+	},
11
+	computed: {
12
+		// 是否展示【跳过】按钮
13
+		showSkip() {
14
+			return this.getBaseConfigByKey('showSkip', true)
15
+		},
16
+		// 提示框样式
17
+		tipsStyle() {
18
+			if (!this.stepTipsShow || !this.tipsDomInfo || !this.currentEleAfterRenderInfo) return {
19
+				top: 0,
20
+				left: '-100vw'
21
+			}
22
+			const {
23
+				top,
24
+				left,
25
+				width,
26
+				height
27
+			} = this.currentEleAfterRenderInfo
28
+
29
+			const _tipsDomInfo = this.tipsDomInfo
30
+			const {
31
+				baseAlign = 'left', tipStyle
32
+			} = this.currentEleConfig
33
+			const arrowHeight = this.arrowStyleInfo?.height || 0
34
+			const arrowWidth = this.arrowStyleInfo?.width || 0
35
+			const tipsTop = this.tipsPosition == 'top' ? top - arrowHeight - _tipsDomInfo.height : top + height + arrowHeight
36
+			let tipsLeft = left
37
+
38
+			// 检查边界情况
39
+			const check = (type = 'left') => {
40
+				// 高亮元素中间位置到屏幕右侧的距离
41
+				const rightDistance = this.systemInfo.windowWidth - left - width / 2
42
+				// 高亮元素中间位置到屏幕左侧的距离
43
+				const leftDistance = left + width / 2
44
+				if (type == 'left') {
45
+					// 检查提示框放再左侧时,位置是否合适
46
+					return _tipsDomInfo.width > width / 2 + arrowWidth / 2 && rightDistance > _tipsDomInfo.width - width / 2
47
+				} else if (type == 'center') {
48
+					// 检查提示框放再中间时,位置是否合适
49
+					return rightDistance > _tipsDomInfo.width / 2 && leftDistance > _tipsDomInfo.width / 2
50
+				} else if (type == 'right') {
51
+					// 检查提示框放再右侧时,位置是否合适
52
+					return _tipsDomInfo.width > width / 2 + arrowWidth / 2 && leftDistance > _tipsDomInfo.width - width / 2
53
+				}
54
+			}
55
+			// 获取left值
56
+			const getTipsLeft = (type = 'left') => {
57
+				if (type == 'left') {
58
+					return left
59
+				} else if (type == 'center') {
60
+					return left + width / 2 - _tipsDomInfo.width / 2
61
+				} else if (type == 'right') {
62
+					return left + width - _tipsDomInfo.width
63
+				}
64
+			}
65
+
66
+			// 检查提示框位置,寻找最合适的位置
67
+			const checkTipsPosition = (p = ['left', 'center', 'right']) => {
68
+				let align = ''
69
+				for (let i = 0; i < p.length; i++) {
70
+					if (check(p[i])) {
71
+						align = p[i]
72
+						break
73
+					}
74
+				}
75
+				return align
76
+			}
77
+
78
+			if (baseAlign === 'left') {
79
+				tipsLeft = getTipsLeft(checkTipsPosition(['left', 'center', 'right']))
80
+			} else if (baseAlign === 'center') {
81
+				tipsLeft = getTipsLeft(checkTipsPosition(['center', 'left', 'right']))
82
+			} else if (baseAlign === 'right') {
83
+				tipsLeft = getTipsLeft(checkTipsPosition(['right', 'center', 'left']))
84
+			}
85
+			
86
+			return {
87
+				...(tipStyle || this.realBaseConfig.tipStyle || {}),
88
+				top: tipsTop + 'px',
89
+				left: tipsLeft + 'px',
90
+			}
91
+		},
92
+		// 提示框元素信息
93
+		tipsElementInfo() {
94
+			const _tipsStyle = this.tipsStyle
95
+			const _tipsDomInfo = this.tipsDomInfo
96
+			const needFields = ['top', 'left']
97
+			const info = {
98
+				width: _tipsDomInfo?.width || 0,
99
+				height: _tipsDomInfo?.height || 0,
100
+			}
101
+			for (const field of needFields) {
102
+				info[field] = _tipsStyle[field] ? parseFloat(_tipsStyle[field].split('px')[0]) : 0
103
+			}
104
+			return info
105
+		},
106
+		// 【下一步】按钮文字
107
+		nextText() {
108
+			const baseNext = this.getBaseConfigByKey('next') || '下一步'
109
+			return this.currentStep == this.stepList.length - 1 ?
110
+				this.currentEleConfig?.next || '完成' :
111
+				baseNext
112
+		},
113
+		// 【下一步】按钮样式
114
+		nextStyle() {
115
+			return this.getBaseConfigByKey('nextStyle') || {}
116
+		},
117
+		// 【【跳过】按钮文字
118
+		skipText() {
119
+			return this.getBaseConfigByKey('skip') || '跳过'
120
+		},
121
+		// 【跳过】按钮样式
122
+		skipStyle() {
123
+			return this.getBaseConfigByKey('skipStyle') || {}
124
+		},
125
+	},
126
+	methods: {
127
+		// 获取提示框元素信息
128
+		getTipsDomInfo() {
129
+			return new Promise((resolve) => {
130
+				setTimeout(async ()=>{
131
+					const res = await this.getDomInfo('.step-tips', this.instance)
132
+					this.tipsDomInfo = res
133
+					resolve()
134
+				}, 50)
135
+			})
136
+		},
137
+	}
138
+}

+ 105 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/package.json

@@ -0,0 +1,105 @@
1
+{
2
+	"id": "hz-novice-guidance",
3
+	"displayName": "操作引导组件",
4
+	"version": "1.0.3",
5
+	"description": "一个操作引导组件,支持 vue2 和 vue3,组件灵活,配置丰富。",
6
+	"keywords": [
7
+        "引导组件",
8
+        "步骤组件",
9
+        "新手引导"
10
+    ],
11
+	"repository": "",
12
+	"engines": {
13
+		"HBuilderX": "^3.1.0",
14
+		"uni-app": "^4.51",
15
+		"uni-app-x": ""
16
+	},
17
+	"dcloudext": {
18
+		"type": "component-vue",
19
+		"sale": {
20
+			"regular": {
21
+				"price": "0.00"
22
+			},
23
+			"sourcecode": {
24
+				"price": "0.00"
25
+			}
26
+		},
27
+		"contact": {
28
+			"qq": "835011968"
29
+		},
30
+		"declaration": {
31
+			"ads": "无",
32
+			"data": "无",
33
+			"permissions": "无"
34
+		},
35
+		"npmurl": "",
36
+		"darkmode": "x",
37
+		"i18n": "x",
38
+		"widescreen": "x"
39
+	},
40
+	"uni_modules": {
41
+		"dependencies": [],
42
+		"encrypt": [],
43
+		"platforms": {
44
+			"cloud": {
45
+				"tcb": "x",
46
+				"aliyun": "x",
47
+				"alipay": "x"
48
+			},
49
+			"client": {
50
+				"uni-app": {
51
+					"vue": {
52
+                        "vue2": {
53
+                        },
54
+                        "vue3": {
55
+                        }
56
+					},
57
+					"web": {
58
+                        "safari": {
59
+                        },
60
+                        "chrome": {
61
+                        }
62
+					},
63
+					"app": {
64
+						"vue": "-",
65
+						"nvue": "-",
66
+						"android": "-",
67
+						"ios": "-",
68
+						"harmony": "-"
69
+					},
70
+					"mp": {
71
+                        "weixin": {
72
+                        },
73
+                        "alipay": {
74
+                        },
75
+						"toutiao": "-",
76
+						"baidu": "-",
77
+						"kuaishou": "-",
78
+						"jd": "-",
79
+						"harmony": "-",
80
+						"qq": "-",
81
+						"lark": "-"
82
+					},
83
+					"quickapp": {
84
+						"huawei": "-",
85
+						"union": "-"
86
+					}
87
+				},
88
+				"uni-app-x": {
89
+					"web": {
90
+						"safari": "-",
91
+						"chrome": "-"
92
+					},
93
+					"app": {
94
+						"android": "-",
95
+						"ios": "-",
96
+						"harmony": "-"
97
+					},
98
+					"mp": {
99
+						"weixin": "-"
100
+					}
101
+				}
102
+			}
103
+		}
104
+	}
105
+}

+ 448 - 0
huimv-employment/app/uni_modules/hz-novice-guidance/readme.md

@@ -0,0 +1,448 @@
1
+# hz-novice-guidance 引导组件
2
+
3
+一个操作引导组件,支持`vue2`和`vue3`,组件灵活,配置丰富。目前已测试了`h5`、`微信小程序`、`支付宝小程序`,其他平台可自行尝试。
4
+
5
+## 功能概述
6
+
7
+- ✅通过配置自定义步骤
8
+- ✅支持配置到子组件、孙组件
9
+- ✅支持自定义箭头
10
+- ✅支持自定义功能按钮
11
+- ✅支持拦截【下一步】【完成】【跳过】,并自定义行为
12
+
13
+## 配置项
14
+
15
+### 组件参数
16
+| 配置项 | 类型 | 默认值 | 说明 |
17
+| --- | --- | --- | --- |
18
+| baseConfig | Object | {} | 步骤配置,具体配置见下方 |
19
+| steps | Array | [] | 步骤配置,具体配置见下方 |
20
+
21
+### baseConfig 配置项
22
+| 配置项 | 类型 | 默认值 | 说明 |
23
+| --- | --- | --- | --- |
24
+| highligthStyle | Object | - | 高亮框自定义样式,可通过设置 --bgcolor 修改遮罩背景颜色 |
25
+| tipMaxWidth | String | - | 提示框最大宽度,默认 '70vw' |
26
+| tipStyle | Object | - | 提示框自定义样式 |
27
+| arrowStyle | Object | - | 步骤提示箭头样式 |
28
+| outerBtn | Boolean | false | 【跳过】【下一步】按钮是否在提示框内显示,为 true 时显示在提示框外 |
29
+| customOuterBtn | Boolean | false | 是否自定义按钮,当 outerBtn=true 时生效,为 true 时,包裹按钮的父级元素的 left 值会变成 0,方便进行定位|
30
+| next | String | '下一步' | 下一步按钮文字,当为最后一步时,会显示 '完成',如需修改,可在steps中最后一步设置next值 |
31
+| nextStyle | Object | - | 下一步按钮样式 |
32
+| skip | String | '跳过' | 跳过按钮文字 |
33
+| skipStyle | Object | - | 跳过按钮样式 |
34
+| showSkip | Boolean | true | 是否显示跳过按钮 |
35
+| customArrow | Boolean | false | 是否自定义箭头 |
36
+| autoRotateArrow | Boolean | true | 箭头是否自动旋转 |
37
+| tipContentMaxHeight | String | '' | 提示内容最大高度,当提示内容大于该值时,可进行滚动查看。默认自动计算,不会超出屏幕 |
38
+| beforeNext | Function | - | 全局步骤守卫,步骤改变前触发,可用于拦截跳转或自定义行为。 回调参数:({to, from, index, next, skipStep}) |
39
+| beforeSkip | Function | - | 跳过守卫,【跳过】前触发,可用于拦截跳过。 回调参数:({skip, index, from, skipStep}) |
40
+| beforeFinished | Function | - | 完成守卫,【完成】前触发,可用于拦截完成。 回调参数:({finish, index, from, skipStep }) |
41
+
42
+### steps 配置项
43
+| 配置项 | 类型 | 默认值 | 说明 |
44
+| --- | --- | --- | --- |
45
+| el | String | '' | 元素选择器,支持类名、id、标签名 |
46
+| tips | String | '' | 提示内容 |
47
+| context | Array/Function | [] | 上下文,当元素在子组件中时,需要传入上下文,组件的 ref 值(从外到内依次传入组件的ref名称) |
48
+| style | Object | - | 当前步骤高亮框元素样式,会覆盖 highligthStyle |
49
+| offset | Object | {top: 0, left: 0} | 元素偏移信息,在当元素位置基础上进行偏移,单位为 px |
50
+| tipStyle | Object | - | 提示框自定义样式,该值会覆盖 baseConfig 中的 tipStyle |
51
+| arrowStyle | Object | - | 步骤提示箭头样式,width/height单位应为 px |
52
+| baseAlign | String | 'left' | 步骤提示提示框相对于高亮元素对齐方式(最终效果会根据提示框的宽度来确定对齐方式,只是这个值的优先级会更高),可选值有left、center、right |
53
+| next | String | '下一步' | 同 baseConfig 中的 next |
54
+| nextStyle | Object | - | 同 baseConfig 中的 nextStyle |
55
+| showSkip | Boolean | true | 同 baseConfig 中的 showSkip |
56
+| skip | String | '跳过' | 同 baseConfig 中的 skip |
57
+| skipStyle | Object | - | 同 baseConfig 中的 skipStyle |
58
+| customArrow | Boolean | false | 同 baseConfig 中的 customArrow |
59
+| autoRotateArrow | Boolean | true | 同 baseConfig 中的 autoRotateArrow |
60
+| tipContentMaxHeight | String | '' | 提示内容最大高度,当提示内容大于该值时,可进行滚动查看。默认自动计算,不会超出屏幕 |
61
+| beforeNext | Function | - | 步骤守卫,当前步骤改变前触发,可用于拦截跳转或自定义行为。 回调参数:({to, from, index, next, skipStep})。注:baseConfig 中的 beforeNext 也会触发且先于步骤中的 beforeNext|
62
+
63
+### 组件事件
64
+| 事件名 | 说明 | 回调参数 |
65
+| --- | --- | --- |
66
+| next | 点击下一步按钮时触发 | stepIndex: 当前步骤索引 |
67
+| finished | 点击跳过按钮时触发 | - |
68
+| skiped | 引导结束时触发 | - |
69
+
70
+### 组件方法
71
+| 方法名 | 说明 | 参数 |
72
+| --- | --- | --- |
73
+| start | 开始引导 | stepIndex: 步骤索引 |
74
+| next | 下一步 | - |
75
+| skip | 跳过 | - |
76
+| finish | 结束 | - |
77
+| skipStep | 跳转任意步骤 | (stepIndex: number, {globalBeforeNextDisabled: boolean, beforeNextHandleDisabled: boolean}) :stepIndex-步骤索引 globalBeforeNextDisabled-跳转时是否禁用全局的beforeNext函数 beforeNextHandleDisabled-跳转时是否禁用当前步骤的beforeNext函数|
78
+| setConfig | 设置参数,由于小程序不能通过组件参数传递函数,当配置中有函数时,需调用该方法进行传递配置 | (steps, baseConfig) |
79
+
80
+### 插槽
81
+| 插槽名 | 说明 |
82
+| --- | --- |
83
+| tips | 自定义内容插槽 |
84
+| btns | 自定义操作按钮插槽 |
85
+| arrow | 自定义箭头插槽, customArrow 为 true 时生效 |
86
+
87
+## 使用示例
88
+这里介绍了一些基本使用,更详细的使用可下载`示例项目`查看。ps: 文档示例使用的是 vue3 版本,示例项目为 vue2 版本。
89
+### 1. 基本使用
90
+```vue
91
+<template>
92
+  <view class="demo-step guide-step1" style="background: #000;"></view>
93
+  <view class="demo-step guide-step2" style="background: #ff5500;"></view>
94
+  <view class="demo-step guide-step3" style="background: #1a6840;"></view>
95
+  <hz-novice-guidance ref="refNoviceGuidance" :steps="steps" @finished="finished" @next="handleNext" @skiped="skiped">
96
+	</hz-novice-guidance>
97
+</template>
98
+
99
+<script setup>
100
+const refNoviceGuidance = ref()
101
+const steps = [
102
+  {
103
+    el:'.guide-step1',
104
+    tips:'新手引导组件,使用 vue3 + ts 开发,这里是组件说明',
105
+  },
106
+  {
107
+    el:'.guide-step2',
108
+    tips:`内容由 <span style='color:#ff5500;'>rich-text</span> 组件渲染,所以支持简单的HTML标签`,
109
+  },
110
+  {
111
+    el:'.guide-step3',
112
+    next:'朕知道了,下一个',
113
+    nextStyle:{
114
+      background:'#ff5500'
115
+    },
116
+    skip:'朕会了,跳过',
117
+    skipStyle:{
118
+      color:'#ff5500'
119
+    },
120
+    tips:'自定义按钮样式及文字',
121
+  },
122
+]
123
+
124
+const finished = ()=>{
125
+  console.log('引导结束')
126
+}
127
+
128
+const handleNext = (stepIndex)=>{
129
+  console.log('点击下一步', stepIndex)
130
+}
131
+
132
+const skiped = ()=>{
133
+  console.log('点击跳过')
134
+}
135
+
136
+
137
+onMounted(() => {
138
+  // 小程序需添加 setTimeout 
139
+  setTimeout(() => {
140
+    refNoviceGuidance.value.start()
141
+  }, 0)
142
+})
143
+</script>
144
+
145
+<style>
146
+.demo-step {
147
+  width: 100%; 
148
+  height: 100px; 
149
+  margin-bottom: 20px;
150
+}
151
+</style>
152
+```
153
+
154
+### 2. 配置到子组件
155
+
156
+```vue
157
+<!-- 页面组件(vue3开发的支付宝小程序,组件需加上额外参数 hz-ref-name,且值和 ref 值一致)-->
158
+<template>
159
+	<father ref="refFather" hz-ref-name="refFather"></father>
160
+</template>
161
+
162
+<!--  father组件 -->
163
+<template>
164
+  <view class="father-container ">
165
+    <view>我是父组件</view>
166
+    <child ref="refChild" hz-ref-name="refChild">
167
+    </child>
168
+  </view>
169
+</template>
170
+
171
+<!-- child组件 -->
172
+<template>
173
+  <view class="child-container">
174
+    <view>
175
+      我是子组件
176
+    </view>
177
+    <grandson ref="refGrandson" hz-ref-name="refGrandson"></grandson>
178
+  </view>
179
+</template>
180
+
181
+<!-- grandson组件 -->
182
+<template>
183
+  <view class="grandson-container">
184
+    我是孙组件
185
+  </view>
186
+</template>
187
+
188
+<script setup>
189
+const steps = [
190
+  {
191
+    // 孙组件中元素类名
192
+    el: '.grandson-container',
193
+    tips: '孙组件,el 值为孙组件中的类名',
194
+    // 传数组,值为每层组件的 ref 名称
195
+    context: ['refFather', 'refChild', 'refGrandson'],
196
+  },
197
+  {
198
+    // 子组件中元素类名
199
+    el: '.child-container',
200
+    tips: '子组件',
201
+    // 传函数,返回子组件实例
202
+    context: ['refFather', 'refChild']
203
+  },
204
+]
205
+</script>
206
+```
207
+#### 支付宝小程序兼容性问题
208
+如果是`vue3`开发的`支付宝小程序`,则需在相关子组件上加上额外参数`hz-ref-name`,值和 ref 的一致,否则支付宝小程序不生效。
209
+### 3. 自定义提示框内容、样式
210
+```vue
211
+<template>
212
+	<hz-novice-guidance ref="refNoviceGuidance1" :baseConfig="baseConfig" :steps="steps">
213
+		<template #tips="{scope}">
214
+			<view class="custom-tips">
215
+				我是自定义的插槽内容。<br/>
216
+				这是配置的原内容:<rich-text :nodes="scope.currentEle.tips"></rich-text><br/>
217
+				<text style="color: red;">注意:通过 baseConfig 传给组件的 tipStyle 样式任然生效。</text>
218
+			</view>
219
+		</template>
220
+	</hz-novice-guidance>
221
+</template>
222
+
223
+<script setup>
224
+// 整体配置
225
+const baseConfig = {
226
+  highligthStyle:{
227
+    // 配置高亮框背景颜色
228
+    '--bgcolor': 'rgba(0, 0, 0, 0.8)',
229
+  },
230
+  // 提示内容样式
231
+  tipStyle:{
232
+    background: '#fecc11',
233
+    color:'#1a6840',
234
+  },
235
+  // 箭头样式
236
+  arrowStyle:{
237
+    color: '#fecc11',
238
+  }
239
+}
240
+
241
+const steps = [
242
+  {
243
+    el:'.guide-step6',
244
+    tips:`步骤传入 tipStyle 参数将覆盖 baseConfig 中的值`,
245
+    tipStyle:{
246
+      background: '#1a6840',
247
+      color:'#fff'
248
+    },
249
+    arrowStyle:{
250
+      color: '#1a6840',
251
+    },
252
+  },
253
+  {
254
+    el:'.guide-step4',
255
+    tips:`通过插槽 <span style='color:#ff5500;'>slot='tips'</span> 可完全自定义内容`,
256
+  },
257
+]
258
+</script>
259
+```
260
+### 4. 自定义箭头样式
261
+```vue
262
+<template>
263
+	<hz-novice-guidance ref="refNoviceGuidance" :baseConfig="baseConfig" :steps="steps" >
264
+		<template #arrow>
265
+			<view class="custom-arrow">
266
+				<view class="arrow"></view>
267
+				<view class="arrow-line"></view>
268
+				<view class="arrow-head"></view>
269
+			</view>
270
+		</template>
271
+	</hz-novice-guidance>
272
+</template>
273
+
274
+<script setup>
275
+// 整体配置
276
+const baseConfig = {
277
+  // 箭头样式
278
+  arrowStyle:{
279
+    color: '#fecc11',
280
+    width:36,
281
+    height:24,
282
+  }
283
+}
284
+
285
+const steps = [
286
+  {
287
+    el:'.guide-step13',
288
+    tips:`步骤参数中的 arrowStyle 会覆盖组件的 baseConfig 中的值`,
289
+    arrowStyle:{
290
+      color: '#ff5500',
291
+      width:12,
292
+      height:8,
293
+    }
294
+  },
295
+  {
296
+    el:'.guide-step5',
297
+    tips:`使用箭头插槽`,
298
+    customArrow:true
299
+  },
300
+  {
301
+    el:'.guide-step6',
302
+    tips:`不自动旋转箭头`,
303
+    autoRotateArrow:false,
304
+    customArrow:true
305
+  },
306
+]
307
+</script>
308
+```
309
+
310
+### 5. 自定义按钮样式
311
+##### 修改文字、样式
312
+```vue
313
+<template>
314
+	<hz-novice-guidance ref="refNoviceGuidance" :baseConfig="baseConfig" :steps="steps" ></hz-novice-guidance>
315
+</template>
316
+
317
+<script setup>
318
+// 整体配置
319
+const baseConfig = {
320
+  next:'next',
321
+  nextStyle:{
322
+    background: '#ff5500',
323
+  },
324
+  skip:'skip',
325
+  skipStyle:{
326
+    color: '#ff5500',
327
+  },
328
+}
329
+const steps = [
330
+  {
331
+    el:'.guide-step1',
332
+    tips:'通过参数或插槽控制按钮样式',
333
+    showSkip:false,
334
+  },
335
+  {
336
+    el:'.guide-step3',
337
+    tips:`可通过参数,控制按钮的文字、样式`,
338
+    next:'done'
339
+  },
340
+]
341
+</script>
342
+```
343
+
344
+##### 按钮展示在外部
345
+```vue
346
+<template>
347
+	<hz-novice-guidance ref="refNoviceGuidance" :baseConfig="baseConfig" :steps="steps" ></hz-novice-guidance>
348
+</template>
349
+
350
+<script setup>
351
+// 整体配置
352
+const baseConfig = {
353
+  outerBtn: true,
354
+}
355
+const steps = [
356
+  {
357
+    el:'.guide-step4',
358
+    tips:`按钮可显示在提示框外部`,
359
+  },
360
+]
361
+</script>
362
+```
363
+
364
+##### 使用插槽自定义按钮
365
+```vue
366
+<template>
367
+	<hz-novice-guidance ref="refNoviceGuidance" :baseConfig="baseConfig" :steps="steps" >
368
+		<template #btns="{scope}">
369
+			<view class="custom-btns-outertips">
370
+				<view class="skip-btn" @click="customSkip" v-if="scope.currentStep < steps3.length - 1">跳过</view>
371
+				<view class="next-btn" @click="customNext">{{scope.currentStep < steps3.length - 1 ? '下一步' : '完成'}}</view>
372
+			</view>
373
+		</template>
374
+	</hz-novice-guidance>
375
+</template>
376
+
377
+<script setup>
378
+// 整体配置
379
+const baseConfig = {
380
+  outerBtn: true,
381
+}
382
+const steps = [
383
+  {
384
+    el:'.guide-step5',
385
+    tips:`通过插槽 <span style='color:#ff5500;'>slot='btns'</span> 可完全自定义按钮`,
386
+  },
387
+  {
388
+    el:'.guide-step15',
389
+    tips:`通过调用组件的 next、skip、finished 方法控制引导流程`,
390
+  },
391
+]
392
+
393
+const customNext = () => {
394
+  refNoviceGuidance.value.next()
395
+}
396
+const customSkip = () => {
397
+  refNoviceGuidance.value.skip()
398
+}
399
+</script>
400
+```
401
+
402
+## 常见问题
403
+1. 目前只测试过h5、微信小程序、支付宝小程序,其他平台大家可自行测试。
404
+2. el 值应是唯一的,最好使用class选择器(如 `.guide-step1`)。
405
+3. 该组件暂不适用于高度过大的元素,可能会导致提示框显示不全。
406
+3. 小程序端,由于通过组件参数不能直接传递函数,当配置中有函数时,需调用组件 `setConfig` 方法进行传递配置。
407
+
408
+## 兼容性问题
409
+##### 1. 如果是`vue3`开发的`支付宝小程序`,且需配置到子组件时,则需在相关所有子组件上加上额外参数`hz-ref-name`,值和 ref 的一致,否则支付宝小程序不生效。
410
+```vue
411
+<template>
412
+<!-- 页面组件(vue3开发的支付宝小程序,组件需加上额外参数 hz-ref-name,且值和 ref 值一致)-->
413
+	<father ref="refFather" hz-ref-name="refFather"></father>
414
+</template>
415
+<!-- father组件 -->
416
+<template>
417
+  <view class="father-container ">
418
+    <view>我是父组件</view>
419
+    <child ref="refChild" hz-ref-name="refChild">
420
+    </child>
421
+  </view>
422
+</template>
423
+
424
+<script setup>
425
+const steps = [
426
+  {
427
+    el: '.child-container',
428
+    tips: '子组件',
429
+    context: ['refFather', 'refChild']
430
+  },
431
+]
432
+</script>
433
+```
434
+
435
+##### 2. 使用`vue3`开发并编译到`支付宝小程序`时,如果使用插槽且使用了插槽传值,会导致其他插槽的默认内容无法显示。该问题官方暂未修复。
436
+```vue
437
+<!-- 这种写法在 vue3 并编译到支付宝小程序,会导致提示内容不展示 -->
438
+<hz-novice-guidance>
439
+	<template #btns="{scope}"></template>
440
+</hz-novice-guidance>
441
+
442
+<!--  这种写法正常显示 -->
443
+<hz-novice-guidance>
444
+<template #btns></template>
445
+</hz-novice-guidance>
446
+
447
+```
448
+

+ 94 - 0
huimv-employment/app/utils/conversation.js

@@ -0,0 +1,94 @@
1
+export const CHAT_TYPE_LABELS = {
2
+	employment: '用工需求咨询',
3
+	knowledge: '用工知识咨询',
4
+	order: '用工订单管理',
5
+	cost: '成本测算',
6
+	attendance: '现场考勤记录',
7
+	emergency: '紧急事件处理'
8
+}
9
+
10
+/** 会话消息分页默认每页条数 */
11
+export const CONVERSATION_MESSAGE_PAGE_SIZE = 30
12
+
13
+/**
14
+ * 根据总条数计算最后一页页码(接口按时间正序,最后一页为最新消息)
15
+ */
16
+export function calcLastMessagePage(total = 0, size = CONVERSATION_MESSAGE_PAGE_SIZE) {
17
+	if (!total || total <= 0) return 1
18
+	return Math.ceil(total / size)
19
+}
20
+
21
+/**
22
+ * 将接口消息映射为聊天气泡结构
23
+ */
24
+export function mapConversationMessage(item = {}) {
25
+	const role = item.role === 'user' ? 'user' : 'ai'
26
+	return {
27
+		id: item.id,
28
+		msgKey: item.id,
29
+		type: 'text',
30
+		role,
31
+		content: item.content || '',
32
+		contentType: item.contentType || 'text',
33
+		createTime: item.createTime
34
+	}
35
+}
36
+
37
+/**
38
+ * 开启新对话时的时段问候语
39
+ * @param {Date} [date]
40
+ */
41
+export function getNewChatGreeting(date = new Date()) {
42
+	const hour = date.getHours()
43
+	let period = '晚上'
44
+	if (hour >= 5 && hour < 11) {
45
+		period = '早上'
46
+	} else if (hour >= 11 && hour < 14) {
47
+		period = '中午'
48
+	} else if (hour >= 14 && hour < 18) {
49
+		period = '下午'
50
+	}
51
+	return `老板${period}好,有什么可以帮助您`
52
+}
53
+
54
+/**
55
+ * 格式化会话列表时间展示
56
+ * @param {string} value ISO 或 yyyy-MM-dd HH:mm:ss
57
+ */
58
+export function formatConversationTime(value) {
59
+	if (!value) return ''
60
+	const normalized = String(value).replace(' ', 'T')
61
+	const date = new Date(normalized)
62
+	if (Number.isNaN(date.getTime())) return ''
63
+
64
+	const now = new Date()
65
+	const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
66
+	const targetStart = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
67
+	const diffDays = Math.floor((todayStart - targetStart) / 86400000)
68
+
69
+	const hh = String(date.getHours()).padStart(2, '0')
70
+	const mm = String(date.getMinutes()).padStart(2, '0')
71
+	const timeStr = `${hh}:${mm}`
72
+
73
+	if (diffDays === 0) return `今天 ${timeStr}`
74
+	if (diffDays === 1) return `昨天 ${timeStr}`
75
+	if (diffDays < 7) return `${diffDays}天前`
76
+	return `${date.getMonth() + 1}月${date.getDate()}日`
77
+}
78
+
79
+/**
80
+ * 将接口会话摘要映射为侧边栏展示结构
81
+ */
82
+export function mapConversationItem(item = {}) {
83
+	const chatType = item.chatType || 'employment'
84
+	return {
85
+		id: item.id,
86
+		conversationNo: item.conversationNo,
87
+		title: item.title || CHAT_TYPE_LABELS[chatType] || '新对话',
88
+		time: formatConversationTime(item.lastMessageAt || item.createTime),
89
+		chatType,
90
+		status: item.status,
91
+		lastMessageAt: item.lastMessageAt,
92
+		createTime: item.createTime
93
+	}
94
+}

+ 117 - 0
huimv-employment/app/utils/home-guide.js

@@ -0,0 +1,117 @@
1
+/** 企业首页新手引导完成标记(本地存储,按用户区分) */
2
+export const ENTERPRISE_HOME_GUIDE_KEY = 'fe_enterprise_home_guide_v1'
3
+
4
+const GUIDE_STEP_META = [
5
+	{
6
+		el: '.guide-target--sidebar',
7
+		title: '对话列表',
8
+		desc: '点击左上角按钮,查看历史会话并在不同对话间切换。',
9
+		baseAlign: 'left'
10
+	},
11
+	{
12
+		el: '.guide-target--new-chat',
13
+		title: '新建会话',
14
+		desc: '点击「+」开启全新 AI 对话,发布用工需求从这里开始。',
15
+		baseAlign: 'right'
16
+	},
17
+	{
18
+		el: '.guide-target--message',
19
+		title: '消息待办',
20
+		desc: '查看待办事项、异常提醒和系统通知,重要事项不错过。',
21
+		baseAlign: 'right'
22
+	},
23
+	{
24
+		el: '.guide-target--avatar',
25
+		title: '个人中心',
26
+		desc: '进入企业管理中枢,查看企业信息、系统设置与帮助。',
27
+		baseAlign: 'right'
28
+	},
29
+	{
30
+		el: '.guide-target--quick-bar',
31
+		title: '快捷操作',
32
+		desc: '常用功能一键直达:发布需求、成本测算、进度总览、历史订单。',
33
+		baseAlign: 'center',
34
+		next: '开始使用'
35
+	}
36
+]
37
+
38
+function buildGuideTips(title, desc) {
39
+	return `<div style="font-weight:700;font-size:32rpx;margin-bottom:12rpx;">${title}</div><div style="line-height:1.7;color:#64748B;">${desc}</div>`
40
+}
41
+
42
+/** hz-novice-guidance 步骤配置(context 在启动时通过 setConfig 注入) */
43
+export function buildNoviceGuidanceSteps() {
44
+	return GUIDE_STEP_META.map((item) => ({
45
+		el: item.el,
46
+		tips: buildGuideTips(item.title, item.desc),
47
+		baseAlign: item.baseAlign || 'center',
48
+		...(item.next ? { next: item.next } : {})
49
+	}))
50
+}
51
+
52
+/** hz-novice-guidance 全局配置 */
53
+export const NOVICE_GUIDANCE_BASE_CONFIG = {
54
+	highligthStyle: {
55
+		'--bgcolor': 'rgba(0, 0, 0, 0.55)'
56
+	},
57
+	tipMaxWidth: '72vw',
58
+	next: '下一步',
59
+	skip: '跳过',
60
+	showSkip: true,
61
+	nextStyle: {
62
+		background: '#7C3AED',
63
+		borderRadius: '999rpx',
64
+		padding: '12rpx 32rpx'
65
+	},
66
+	skipStyle: {
67
+		color: '#64748B'
68
+	},
69
+	tipStyle: {
70
+		borderRadius: '28rpx',
71
+		padding: '32rpx 28rpx',
72
+		boxShadow: '0 20rpx 60rpx rgba(0, 0, 0, 0.2)'
73
+	}
74
+}
75
+
76
+function buildGuideKey(guideUserKey) {
77
+	return `${ENTERPRISE_HOME_GUIDE_KEY}_${guideUserKey}`
78
+}
79
+
80
+/** 用于本地引导标记的用户标识(优先 userId,否则手机号/企业 ID) */
81
+export function resolveGuideUserKey(user = {}, enterprise = {}) {
82
+	if (user.userId != null && user.userId !== '') return String(user.userId)
83
+	if (user.mobile) return `m_${user.mobile}`
84
+	if (enterprise.enterpriseId != null && enterprise.enterpriseId !== '') {
85
+		return `e_${enterprise.enterpriseId}`
86
+	}
87
+	return 'enterprise'
88
+}
89
+
90
+export function isEnterpriseHomeGuideDone(guideUserKey) {
91
+	if (!guideUserKey) return false
92
+	try {
93
+		if (uni.getStorageSync(buildGuideKey(guideUserKey))) return true
94
+		return !!uni.getStorageSync(ENTERPRISE_HOME_GUIDE_KEY)
95
+	} catch (e) {
96
+		return false
97
+	}
98
+}
99
+
100
+export function setEnterpriseHomeGuideDone(guideUserKey) {
101
+	if (!guideUserKey) return
102
+	try {
103
+		uni.setStorageSync(buildGuideKey(guideUserKey), '1')
104
+		uni.removeStorageSync(ENTERPRISE_HOME_GUIDE_KEY)
105
+	} catch (e) {}
106
+}
107
+
108
+/** 退出登录时清除,下次登录重新展示引导 */
109
+export function clearEnterpriseHomeGuide(user = {}, enterprise = {}) {
110
+	const guideUserKey = typeof user === 'string'
111
+		? user
112
+		: resolveGuideUserKey(user, enterprise)
113
+	try {
114
+		if (guideUserKey) uni.removeStorageSync(buildGuideKey(guideUserKey))
115
+		uni.removeStorageSync(ENTERPRISE_HOME_GUIDE_KEY)
116
+	} catch (e) {}
117
+}

+ 78 - 0
huimv-employment/app/utils/sse-parse.js

@@ -0,0 +1,78 @@
1
+/**
2
+ * 解析智能体 SSE(data: {...})行
3
+ */
4
+
5
+export function createSseLineBuffer() {
6
+	let pending = ''
7
+
8
+	return {
9
+		push(chunkText) {
10
+			if (!chunkText) return []
11
+			pending += chunkText
12
+			const lines = []
13
+			let pos = 0
14
+			while (true) {
15
+				const end = pending.indexOf('\n', pos)
16
+				if (end === -1) break
17
+				lines.push(pending.slice(pos, end))
18
+				pos = end + 1
19
+			}
20
+			pending = pending.slice(pos)
21
+			return lines
22
+		},
23
+		flushLine() {
24
+			if (!pending) return null
25
+			const line = pending
26
+			pending = ''
27
+			return line
28
+		}
29
+	}
30
+}
31
+
32
+export function parseConsoleChatSseLine(line) {
33
+	const trimmed = (line || '').trim()
34
+	if (!trimmed || trimmed.startsWith(':')) return null
35
+	if (!trimmed.startsWith('data:')) return null
36
+
37
+	const payload = trimmed.slice(5).trim()
38
+	if (!payload || payload === '[DONE]') {
39
+		return { done: true }
40
+	}
41
+
42
+	try {
43
+		return { data: JSON.parse(payload) }
44
+	} catch (e) {
45
+		return null
46
+	}
47
+}
48
+
49
+/**
50
+ * 从 SSE JSON 中提取 assistant 文本增量
51
+ */
52
+export function extractAssistantDelta(data) {
53
+	if (!data || typeof data !== 'object') return ''
54
+
55
+	if (data.error) {
56
+		const err = data.error
57
+		const msg = (typeof err === 'string' ? err : (err.message || err.msg)) || 'AI 服务异常'
58
+		throw new Error(msg)
59
+	}
60
+
61
+	const parts = []
62
+	const output = data.output
63
+	if (Array.isArray(output)) {
64
+		for (const item of output) {
65
+			if (!item || item.role !== 'assistant' || !Array.isArray(item.content)) continue
66
+			for (const block of item.content) {
67
+				if (block && block.type === 'text' && block.text) {
68
+					parts.push(block.text)
69
+				}
70
+			}
71
+		}
72
+	}
73
+
74
+	if (!parts.length && typeof data.text === 'string') parts.push(data.text)
75
+	if (!parts.length && typeof data.delta === 'string') parts.push(data.delta)
76
+
77
+	return parts.join('')
78
+}

+ 189 - 0
huimv-employment/app/utils/sse-stream.js

@@ -0,0 +1,189 @@
1
+import { BASE_API } from '@/common/config.js'
2
+import { getToken } from '@/utils/request.js'
3
+import {
4
+	createSseLineBuffer,
5
+	parseConsoleChatSseLine,
6
+	extractAssistantDelta
7
+} from '@/utils/sse-parse.js'
8
+
9
+function buildUrl(url) {
10
+	if (/^https?:\/\//.test(url)) return url
11
+	const base = (BASE_API || '').replace(/\/$/, '')
12
+	if (!base) return ''
13
+	const path = url.startsWith('/') ? url : `/${url}`
14
+	return `${base}${path}`
15
+}
16
+
17
+function decodeChunk(data, decoder, stream) {
18
+	if (typeof data === 'string') return data
19
+	if (!(data instanceof ArrayBuffer)) return ''
20
+
21
+	if (decoder) {
22
+		return decoder.decode(data, { stream: !!stream })
23
+	}
24
+
25
+	const bytes = new Uint8Array(data)
26
+	let text = ''
27
+	for (let i = 0; i < bytes.length; i++) {
28
+		text += String.fromCharCode(bytes[i])
29
+	}
30
+	try {
31
+		return decodeURIComponent(escape(text))
32
+	} catch (e) {
33
+		return text
34
+	}
35
+}
36
+
37
+function getStreamFailMessage(err) {
38
+	const errMsg = (err && err.errMsg) || ''
39
+	if (!errMsg) return '网络连接失败,请稍后重试'
40
+	if (errMsg.includes('abort')) return '请求已取消'
41
+	if (errMsg.includes('timeout')) return '对话超时,请稍后重试'
42
+	if (errMsg.includes('url not in domain list')) {
43
+		return '请求域名未配置,请在开发者工具中关闭域名校验'
44
+	}
45
+	return '网络异常,请稍后重试'
46
+}
47
+
48
+function parseFallbackBody(res) {
49
+	if (!res || res.data == null) return ''
50
+	if (typeof res.data === 'string') return res.data
51
+	if (res.data instanceof ArrayBuffer) {
52
+		return decodeChunk(res.data, null, false)
53
+	}
54
+	if (typeof res.data === 'object' && typeof res.data.msg === 'string') {
55
+		throw new Error(res.data.msg)
56
+	}
57
+	return ''
58
+}
59
+
60
+/**
61
+ * 微信小程序兼容的 SSE 流式请求(uni.request + enableChunked + onChunkReceived)
62
+ * @returns {() => void} abort
63
+ */
64
+export function streamSseRequest(options = {}) {
65
+	const {
66
+		url,
67
+		data,
68
+		header = {},
69
+		auth = true,
70
+		timeout = 300000,
71
+		onChunk,
72
+		onDone,
73
+		onError
74
+	} = options
75
+
76
+	const fullUrl = buildUrl(url)
77
+	if (!fullUrl) {
78
+		const err = new Error('API 地址未配置')
79
+		if (onError) onError(err)
80
+		return () => {}
81
+	}
82
+
83
+	const headers = {
84
+		'Content-Type': 'application/json',
85
+		Accept: 'text/event-stream',
86
+		...header
87
+	}
88
+
89
+	if (auth) {
90
+		const token = getToken()
91
+		if (token) headers.Authorization = `Bearer ${token}`
92
+	}
93
+
94
+	let aborted = false
95
+	let finished = false
96
+	let chunkReceived = false
97
+	const decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8') : null
98
+	const lineBuffer = createSseLineBuffer()
99
+
100
+	const abort = () => {
101
+		aborted = true
102
+		if (requestTask && typeof requestTask.abort === 'function') {
103
+			requestTask.abort()
104
+		}
105
+	}
106
+
107
+	const finish = () => {
108
+		if (finished || aborted) return
109
+		finished = true
110
+		const lastLine = lineBuffer.flushLine()
111
+		if (lastLine) processLine(lastLine)
112
+		if (onDone) onDone()
113
+	}
114
+
115
+	const processLine = (line) => {
116
+		const parsed = parseConsoleChatSseLine(line)
117
+		if (!parsed) return
118
+		if (parsed.done) return
119
+		try {
120
+			const delta = extractAssistantDelta(parsed.data)
121
+			if (delta && onChunk) onChunk(delta, parsed.data)
122
+		} catch (e) {
123
+			aborted = true
124
+			if (requestTask && typeof requestTask.abort === 'function') {
125
+				requestTask.abort()
126
+			}
127
+			if (onError) onError(e)
128
+		}
129
+	}
130
+
131
+	const handleText = (text) => {
132
+		if (!text || aborted) return
133
+		const lines = lineBuffer.push(text)
134
+		for (const line of lines) {
135
+			processLine(line)
136
+		}
137
+	}
138
+
139
+	let requestTask = uni.request({
140
+		url: fullUrl,
141
+		method: 'POST',
142
+		header: headers,
143
+		data,
144
+		enableChunked: true,
145
+		timeout,
146
+		success(res) {
147
+			if (aborted) return
148
+
149
+			if (res.statusCode < 200 || res.statusCode >= 300) {
150
+				let message = '对话请求失败'
151
+				try {
152
+					const body = parseFallbackBody(res)
153
+					if (body) {
154
+						const json = JSON.parse(body)
155
+						message = json.msg || json.message || message
156
+					}
157
+				} catch (e) {
158
+					// ignore parse error
159
+				}
160
+				if (onError) onError(new Error(message))
161
+				return
162
+			}
163
+
164
+			if (!chunkReceived) {
165
+				try {
166
+					handleText(parseFallbackBody(res))
167
+				} catch (e) {
168
+					if (onError) onError(e)
169
+					return
170
+				}
171
+			}
172
+			finish()
173
+		},
174
+		fail(err) {
175
+			if (aborted) return
176
+			if (onError) onError(new Error(getStreamFailMessage(err)))
177
+		}
178
+	})
179
+
180
+	if (requestTask && typeof requestTask.onChunkReceived === 'function') {
181
+		requestTask.onChunkReceived((res) => {
182
+			if (aborted) return
183
+			chunkReceived = true
184
+			handleText(decodeChunk(res.data, decoder, true))
185
+		})
186
+	}
187
+
188
+	return abort
189
+}