@anker-in/campaign-ui 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/components/LiveChatWidget/LiveChatWidget.js +1 -1
- package/dist/cjs/components/LiveChatWidget/LiveChatWidget.js.map +3 -3
- package/dist/cjs/components/LiveChatWidget/components/MessageContent/PromotionList.js.map +2 -2
- package/dist/cjs/components/LiveChatWidget/components/MessageList.js +3 -3
- package/dist/cjs/components/LiveChatWidget/components/MessageList.js.map +3 -3
- package/dist/cjs/components/LiveChatWidget/hooks/useChatState.d.ts +8 -3
- package/dist/cjs/components/LiveChatWidget/hooks/useChatState.js +1 -1
- package/dist/cjs/components/LiveChatWidget/hooks/useChatState.js.map +2 -2
- package/dist/cjs/components/LiveChatWidget/index.d.ts +1 -1
- package/dist/cjs/components/LiveChatWidget/index.js +1 -1
- package/dist/cjs/components/LiveChatWidget/index.js.map +2 -2
- package/dist/cjs/components/LiveChatWidget/types.d.ts +4 -1
- package/dist/cjs/components/LiveChatWidget/types.js.map +1 -1
- package/dist/cjs/components/LiveChatWidget/utils/userId.d.ts +7 -2
- package/dist/cjs/components/LiveChatWidget/utils/userId.js +1 -1
- package/dist/cjs/components/LiveChatWidget/utils/userId.js.map +3 -3
- package/dist/cjs/components/credits/creditsBanner/index.js +2 -2
- package/dist/cjs/components/credits/creditsBanner/index.js.map +2 -2
- package/dist/cjs/stories/LiveChatWidget.stories.js +2 -9
- package/dist/cjs/stories/LiveChatWidget.stories.js.map +2 -2
- package/dist/esm/components/LiveChatWidget/LiveChatWidget.js +1 -1
- package/dist/esm/components/LiveChatWidget/LiveChatWidget.js.map +3 -3
- package/dist/esm/components/LiveChatWidget/components/MessageContent/PromotionList.js.map +2 -2
- package/dist/esm/components/LiveChatWidget/components/MessageList.js +3 -3
- package/dist/esm/components/LiveChatWidget/components/MessageList.js.map +3 -3
- package/dist/esm/components/LiveChatWidget/hooks/useChatState.d.ts +8 -3
- package/dist/esm/components/LiveChatWidget/hooks/useChatState.js +1 -1
- package/dist/esm/components/LiveChatWidget/hooks/useChatState.js.map +3 -3
- package/dist/esm/components/LiveChatWidget/index.d.ts +1 -1
- package/dist/esm/components/LiveChatWidget/index.js +1 -1
- package/dist/esm/components/LiveChatWidget/index.js.map +3 -3
- package/dist/esm/components/LiveChatWidget/types.d.ts +4 -1
- package/dist/esm/components/LiveChatWidget/utils/userId.d.ts +7 -2
- package/dist/esm/components/LiveChatWidget/utils/userId.js +1 -1
- package/dist/esm/components/LiveChatWidget/utils/userId.js.map +3 -3
- package/dist/esm/components/credits/creditsBanner/index.js +2 -2
- package/dist/esm/components/credits/creditsBanner/index.js.map +2 -2
- package/dist/esm/stories/LiveChatWidget.stories.js +1 -8
- package/dist/esm/stories/LiveChatWidget.stories.js.map +2 -2
- package/dist/index.d.mts +140 -13
- package/dist/index.d.ts +140 -13
- package/dist/index.js +2109 -6424
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1901 -6216
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/LiveChatWidget/LiveChatWidget.tsx +60 -7
- package/src/components/LiveChatWidget/components/MessageContent/PromotionList.tsx +1 -1
- package/src/components/LiveChatWidget/components/MessageList.tsx +39 -44
- package/src/components/LiveChatWidget/hooks/useChatState.ts +22 -10
- package/src/components/LiveChatWidget/index.tsx +1 -1
- package/src/components/LiveChatWidget/types.ts +4 -1
- package/src/components/LiveChatWidget/utils/userId.ts +13 -62
- package/src/components/credits/creditsBanner/index.tsx +5 -5
- package/src/stories/LiveChatWidget.stories.tsx +8 -13
- package/src/styles/livechat.css +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anker-in/campaign-ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Campaign UI components and utilities for Anker projects",
|
|
5
5
|
"main": "./dist/cjs/index.js",
|
|
6
6
|
"module": "./dist/esm/index.js",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"swiper": "^11.1.3",
|
|
96
96
|
"tailwind-merge": "^2.3.0",
|
|
97
97
|
"tailwindcss": "^3.4.3",
|
|
98
|
-
"@anker-in/lib": "1.1.
|
|
98
|
+
"@anker-in/lib": "1.1.3"
|
|
99
99
|
},
|
|
100
100
|
"publishConfig": {
|
|
101
101
|
"access": "public",
|
|
@@ -23,6 +23,7 @@ import { useChatState } from './hooks/useChatState'
|
|
|
23
23
|
import { useChatAPI } from './hooks/useChatAPI'
|
|
24
24
|
import { MessageRendererRegistry } from './utils/messageRenderers'
|
|
25
25
|
import { sanitizeInput } from './utils/validation'
|
|
26
|
+
import { saveUserId } from './utils/userId'
|
|
26
27
|
import { transformProducts } from './utils/productTransformers.js'
|
|
27
28
|
import { transformCartData } from './utils/cartTransformers.js'
|
|
28
29
|
import Cookies from 'js-cookie'
|
|
@@ -175,6 +176,7 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
175
176
|
openChat,
|
|
176
177
|
closeChat,
|
|
177
178
|
setInputValue,
|
|
179
|
+
setUserId,
|
|
178
180
|
addMessage,
|
|
179
181
|
setMessages,
|
|
180
182
|
clearMessages,
|
|
@@ -183,6 +185,9 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
183
185
|
clearSession,
|
|
184
186
|
} = chatState
|
|
185
187
|
|
|
188
|
+
// 初始化加载状态(用户未配置欢迎语时,显示 loading 直到接口返回)
|
|
189
|
+
const [isInitializing, setIsInitializing] = React.useState(false)
|
|
190
|
+
|
|
186
191
|
// API 调用
|
|
187
192
|
const { sendMessageStream, createSession } = useChatAPI({
|
|
188
193
|
apiBaseUrl,
|
|
@@ -229,6 +234,9 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
229
234
|
return registry
|
|
230
235
|
}, [customRenderers])
|
|
231
236
|
|
|
237
|
+
// 标记是否已经初始化过会话,防止重复调用
|
|
238
|
+
const sessionInitializedRef = React.useRef(false)
|
|
239
|
+
|
|
232
240
|
/**
|
|
233
241
|
* T043: 打开聊天窗口时初始化会话
|
|
234
242
|
* 使用 API v2.0.0 的统一接口:
|
|
@@ -236,7 +244,18 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
236
244
|
* - 如果有 sessionId,恢复会话并加载历史消息
|
|
237
245
|
*/
|
|
238
246
|
useEffect(() => {
|
|
239
|
-
|
|
247
|
+
// 窗口关闭时重置初始化标记
|
|
248
|
+
if (!isOpen) {
|
|
249
|
+
sessionInitializedRef.current = false
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// userId 为 undefined 表示尚未初始化,等待初始化完成
|
|
254
|
+
if (userId === undefined) return
|
|
255
|
+
|
|
256
|
+
// 如果已经初始化过,不再重复调用
|
|
257
|
+
if (sessionInitializedRef.current) return
|
|
258
|
+
sessionInitializedRef.current = true
|
|
240
259
|
|
|
241
260
|
const currentSessionId = sessionId
|
|
242
261
|
|
|
@@ -369,20 +388,33 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
369
388
|
* 创建新会话
|
|
370
389
|
*/
|
|
371
390
|
const handleCreateNewSession = useCallback(async () => {
|
|
372
|
-
|
|
391
|
+
// userId 为 undefined 表示尚未初始化,等待初始化完成
|
|
392
|
+
if (userId === undefined) return
|
|
393
|
+
|
|
394
|
+
// 如果用户没有配置欢迎语,显示 loading 状态
|
|
395
|
+
if (!welcomeMessage) {
|
|
396
|
+
setIsInitializing(true)
|
|
397
|
+
}
|
|
373
398
|
|
|
374
399
|
try {
|
|
375
400
|
const response = await createSession({
|
|
376
|
-
user_id: userId,
|
|
401
|
+
user_id: userId ?? '',
|
|
377
402
|
site: site,
|
|
378
403
|
channel_code: channelCode,
|
|
379
404
|
real_user_id: loginUserId,
|
|
405
|
+
page_url: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
380
406
|
})
|
|
381
407
|
|
|
382
408
|
if (response.success) {
|
|
383
409
|
// 保存新会话 ID
|
|
384
410
|
saveSession(response.sessionId)
|
|
385
411
|
|
|
412
|
+
// 保存后端返回的 userId(如果有)
|
|
413
|
+
if (response.userId) {
|
|
414
|
+
saveUserId(response.userId)
|
|
415
|
+
setUserId(response.userId)
|
|
416
|
+
}
|
|
417
|
+
|
|
386
418
|
// 清空消息列表
|
|
387
419
|
clearMessages()
|
|
388
420
|
|
|
@@ -450,6 +482,9 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
450
482
|
],
|
|
451
483
|
timestamp: Date.now(),
|
|
452
484
|
})
|
|
485
|
+
} finally {
|
|
486
|
+
// 接口返回后关闭 loading 状态
|
|
487
|
+
setIsInitializing(false)
|
|
453
488
|
}
|
|
454
489
|
}, [
|
|
455
490
|
userId,
|
|
@@ -470,18 +505,30 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
470
505
|
*/
|
|
471
506
|
const handleResumeSession = useCallback(
|
|
472
507
|
async (existingSessionId: string) => {
|
|
508
|
+
// 如果用户没有配置欢迎语,显示 loading 状态
|
|
509
|
+
if (!welcomeMessage) {
|
|
510
|
+
setIsInitializing(true)
|
|
511
|
+
}
|
|
512
|
+
|
|
473
513
|
try {
|
|
474
514
|
const response = await createSession({
|
|
475
|
-
user_id: userId,
|
|
515
|
+
user_id: userId ?? '',
|
|
476
516
|
session_id: existingSessionId,
|
|
477
517
|
site: site,
|
|
478
518
|
channel_code: channelCode,
|
|
479
519
|
real_user_id: loginUserId,
|
|
520
|
+
page_url: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
480
521
|
})
|
|
481
522
|
|
|
482
523
|
if (response.success && response.resumed) {
|
|
483
524
|
// 会话恢复成功
|
|
484
525
|
|
|
526
|
+
// 保存后端返回的 userId(如果有)
|
|
527
|
+
if (response.userId) {
|
|
528
|
+
saveUserId(response.userId)
|
|
529
|
+
setUserId(response.userId)
|
|
530
|
+
}
|
|
531
|
+
|
|
485
532
|
// 准备欢迎消息(无论是否有历史消息都需要)
|
|
486
533
|
const messageText = response.welcomeMessage || welcomeMessage
|
|
487
534
|
const welcomeContent: MessageContent[] = messageText ? [{ type: 'text', text: messageText }] : []
|
|
@@ -590,6 +637,9 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
590
637
|
clearSession()
|
|
591
638
|
handleCreateNewSession()
|
|
592
639
|
}
|
|
640
|
+
} finally {
|
|
641
|
+
// 接口返回后关闭 loading 状态
|
|
642
|
+
setIsInitializing(false)
|
|
593
643
|
}
|
|
594
644
|
},
|
|
595
645
|
[
|
|
@@ -664,10 +714,11 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
664
714
|
if (!currentSessionId) {
|
|
665
715
|
// 没有会话,创建新会话
|
|
666
716
|
const response = await createSession({
|
|
667
|
-
user_id: userId,
|
|
717
|
+
user_id: userId ?? '',
|
|
668
718
|
site: site,
|
|
669
719
|
channel_code: channelCode,
|
|
670
720
|
real_user_id: loginUserId,
|
|
721
|
+
page_url: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
671
722
|
})
|
|
672
723
|
if (response.success) {
|
|
673
724
|
currentSessionId = response.sessionId
|
|
@@ -680,7 +731,7 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
680
731
|
// 构建请求参数(session_id 现在是必填的)
|
|
681
732
|
const requestPayload: ChatStreamRequest = {
|
|
682
733
|
message: sanitized,
|
|
683
|
-
user_id: userId,
|
|
734
|
+
user_id: userId ?? '',
|
|
684
735
|
session_id: currentSessionId,
|
|
685
736
|
context: {
|
|
686
737
|
cartId: cartId,
|
|
@@ -711,10 +762,11 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
711
762
|
|
|
712
763
|
// 创建新会话
|
|
713
764
|
const response = await createSession({
|
|
714
|
-
user_id: userId,
|
|
765
|
+
user_id: userId ?? '',
|
|
715
766
|
site: site,
|
|
716
767
|
channel_code: channelCode,
|
|
717
768
|
real_user_id: loginUserId,
|
|
769
|
+
page_url: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
718
770
|
})
|
|
719
771
|
|
|
720
772
|
if (response.success) {
|
|
@@ -873,6 +925,7 @@ export const LiveChatWidget: React.FC<LiveChatWidgetProps> = ({
|
|
|
873
925
|
title={title}
|
|
874
926
|
logoUrl={logoUrl}
|
|
875
927
|
isSending={isStreaming}
|
|
928
|
+
isLoadingHistory={isInitializing}
|
|
876
929
|
rendererRegistry={rendererRegistry}
|
|
877
930
|
inputPlaceholder=""
|
|
878
931
|
onAddToCart={onAddToCart}
|
|
@@ -103,7 +103,7 @@ export const PromotionList: React.FC<PromotionListProps> = ({ data, isUser = fal
|
|
|
103
103
|
return (
|
|
104
104
|
<div className="space-y-3">
|
|
105
105
|
{results.map(promotion => {
|
|
106
|
-
const bannerUrl = promotion.banner_url ||promotion?.metadata?.banner_url
|
|
106
|
+
const bannerUrl = promotion.banner_url || promotion?.metadata?.banner_url
|
|
107
107
|
|
|
108
108
|
// 没有图片则不展示
|
|
109
109
|
if (!bannerUrl) {
|
|
@@ -117,33 +117,37 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|
|
117
117
|
className = '',
|
|
118
118
|
onAddToCart,
|
|
119
119
|
}) => {
|
|
120
|
-
|
|
120
|
+
// 使用 callback ref + state 确保 DOM 挂载后触发重新渲染
|
|
121
|
+
const [listElement, setListElement] = useState<HTMLDivElement | null>(null)
|
|
122
|
+
const listRef = useCallback((node: HTMLDivElement | null) => {
|
|
123
|
+
setListElement(node)
|
|
124
|
+
}, [])
|
|
121
125
|
const [showScrollButton, setShowScrollButton] = useState(false)
|
|
122
126
|
|
|
123
127
|
// 检查是否接近底部
|
|
124
|
-
const isNearBottom = useCallback(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
const isNearBottom = useCallback(
|
|
129
|
+
(threshold = 100) => {
|
|
130
|
+
if (!listElement) return true
|
|
131
|
+
|
|
132
|
+
const { scrollTop, scrollHeight, clientHeight } = listElement
|
|
133
|
+
return scrollHeight - scrollTop - clientHeight < threshold
|
|
134
|
+
},
|
|
135
|
+
[listElement]
|
|
136
|
+
)
|
|
131
137
|
|
|
132
138
|
// 平滑滚动到底部
|
|
133
139
|
const scrollToBottom = useCallback(() => {
|
|
134
|
-
|
|
135
|
-
if (!container) return
|
|
140
|
+
if (!listElement) return
|
|
136
141
|
|
|
137
|
-
|
|
138
|
-
top:
|
|
142
|
+
listElement.scrollTo({
|
|
143
|
+
top: listElement.scrollHeight,
|
|
139
144
|
behavior: 'smooth',
|
|
140
145
|
})
|
|
141
|
-
}, [])
|
|
146
|
+
}, [listElement])
|
|
142
147
|
|
|
143
148
|
// 监听滚动事件,控制按钮显示
|
|
144
149
|
useEffect(() => {
|
|
145
|
-
|
|
146
|
-
if (!container) return
|
|
150
|
+
if (!listElement) return
|
|
147
151
|
|
|
148
152
|
const handleScroll = () => {
|
|
149
153
|
setShowScrollButton(!isNearBottom())
|
|
@@ -152,9 +156,9 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|
|
152
156
|
// 初始检查
|
|
153
157
|
handleScroll()
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
return () =>
|
|
157
|
-
}, [isNearBottom])
|
|
159
|
+
listElement.addEventListener('scroll', handleScroll, { passive: true })
|
|
160
|
+
return () => listElement.removeEventListener('scroll', handleScroll)
|
|
161
|
+
}, [listElement, isNearBottom])
|
|
158
162
|
|
|
159
163
|
// 当消息列表变化时,重新检查按钮状态(但不在自动滚动时)
|
|
160
164
|
useEffect(() => {
|
|
@@ -169,22 +173,31 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|
|
169
173
|
|
|
170
174
|
// 监听消息列表变化,自动滚动
|
|
171
175
|
useEffect(() => {
|
|
172
|
-
if (!autoScroll || !
|
|
176
|
+
if (!autoScroll || !listElement) return
|
|
173
177
|
|
|
174
178
|
// 延迟滚动以确保 DOM 已更新
|
|
175
179
|
const timeoutId = setTimeout(() => {
|
|
176
|
-
if (
|
|
177
|
-
|
|
180
|
+
if (listElement) {
|
|
181
|
+
listElement.scrollTop = listElement.scrollHeight
|
|
178
182
|
// 自动滚动到底部后,隐藏按钮
|
|
179
183
|
setShowScrollButton(false)
|
|
180
184
|
}
|
|
181
185
|
}, 100)
|
|
182
186
|
|
|
183
187
|
return () => clearTimeout(timeoutId)
|
|
184
|
-
}, [messages, autoScroll])
|
|
185
|
-
|
|
186
|
-
// 空状态
|
|
187
|
-
if (messages.length === 0
|
|
188
|
+
}, [messages, autoScroll, listElement])
|
|
189
|
+
|
|
190
|
+
// 空状态 + 加载中
|
|
191
|
+
if (messages.length === 0) {
|
|
192
|
+
if (isLoadingHistory) {
|
|
193
|
+
// 加载中,显示 loading
|
|
194
|
+
return (
|
|
195
|
+
<div className={`flex flex-1 items-center justify-center overflow-hidden ${className}`}>
|
|
196
|
+
<LoadingIndicator />
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
// 空状态
|
|
188
201
|
return (
|
|
189
202
|
<div className={`flex-1 overflow-hidden ${className}`}>{emptyPlaceholder || <DefaultEmptyPlaceholder />}</div>
|
|
190
203
|
)
|
|
@@ -199,9 +212,6 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|
|
199
212
|
${className}
|
|
200
213
|
`}
|
|
201
214
|
>
|
|
202
|
-
{/* 加载历史消息指示器 */}
|
|
203
|
-
{isLoadingHistory && <LoadingIndicator />}
|
|
204
|
-
|
|
205
215
|
{/* 消息列表 */}
|
|
206
216
|
<div className="flex flex-col gap-1">
|
|
207
217
|
{messages.map(message => (
|
|
@@ -223,22 +233,7 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|
|
223
233
|
{/* 回到底部按钮 */}
|
|
224
234
|
<button
|
|
225
235
|
onClick={scrollToBottom}
|
|
226
|
-
className={`
|
|
227
|
-
style={{
|
|
228
|
-
position: 'absolute',
|
|
229
|
-
bottom: '24px',
|
|
230
|
-
left: '50%',
|
|
231
|
-
zIndex: 10,
|
|
232
|
-
width: '40px',
|
|
233
|
-
height: '40px',
|
|
234
|
-
borderRadius: '50%',
|
|
235
|
-
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
236
|
-
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
|
237
|
-
transition: 'all 300ms ease-in-out',
|
|
238
|
-
opacity: showScrollButton ? 1 : 0,
|
|
239
|
-
cursor: 'pointer',
|
|
240
|
-
border: 'none',
|
|
241
|
-
}}
|
|
236
|
+
className={`livechat-scroll-to-bottom ${showScrollButton ? 'visible' : 'hidden'}`}
|
|
242
237
|
aria-label="Scroll to bottom"
|
|
243
238
|
aria-hidden={!showScrollButton}
|
|
244
239
|
>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
8
8
|
import type { Message, MessageContent, SSEEvent, StatusData, ErrorData, MessageStartData, BackendCartData, Product, TextContent, ProductCardContent, ProductListContent } from '../types'
|
|
9
|
-
import { getUserId } from '../utils/userId'
|
|
9
|
+
import { getUserId, saveUserId } from '../utils/userId'
|
|
10
10
|
import { useSession } from './useSession'
|
|
11
11
|
import { transformProducts } from '../utils/productTransformers'
|
|
12
12
|
import { transformCartData } from '../utils/cartTransformers'
|
|
@@ -357,8 +357,9 @@ export interface UseChatStateOptions {
|
|
|
357
357
|
|
|
358
358
|
/**
|
|
359
359
|
* AI 回复促销卡片时触发
|
|
360
|
+
* @param promotions 促销活动数组数据
|
|
360
361
|
*/
|
|
361
|
-
onPromotionList?: () => void
|
|
362
|
+
onPromotionList?: (promotions: any[]) => void
|
|
362
363
|
|
|
363
364
|
/**
|
|
364
365
|
* 商品添加到购物车回调
|
|
@@ -390,9 +391,9 @@ export interface UseChatStateReturn {
|
|
|
390
391
|
isOpen: boolean
|
|
391
392
|
|
|
392
393
|
/**
|
|
393
|
-
* 用户 ID
|
|
394
|
+
* 用户 ID(undefined 表示尚未初始化,空字符串表示由后端生成)
|
|
394
395
|
*/
|
|
395
|
-
userId: string
|
|
396
|
+
userId: string | undefined
|
|
396
397
|
|
|
397
398
|
/**
|
|
398
399
|
* 会话 ID
|
|
@@ -429,6 +430,11 @@ export interface UseChatStateReturn {
|
|
|
429
430
|
*/
|
|
430
431
|
setInputValue: (value: string) => void
|
|
431
432
|
|
|
433
|
+
/**
|
|
434
|
+
* 设置用户 ID(用于保存后端返回的 userId)
|
|
435
|
+
*/
|
|
436
|
+
setUserId: (id: string) => void
|
|
437
|
+
|
|
432
438
|
/**
|
|
433
439
|
* 添加消息到列表
|
|
434
440
|
*/
|
|
@@ -494,8 +500,8 @@ export function useChatState(options: UseChatStateOptions = {}): UseChatStateRet
|
|
|
494
500
|
// 会话管理
|
|
495
501
|
const { sessionId, saveSession, clearSession } = useSession()
|
|
496
502
|
|
|
497
|
-
// 用户 ID (
|
|
498
|
-
const [userId, setUserId] = useState<string>(
|
|
503
|
+
// 用户 ID (初始化时异步生成,undefined 表示尚未初始化)
|
|
504
|
+
const [userId, setUserId] = useState<string | undefined>(undefined)
|
|
499
505
|
|
|
500
506
|
// 初始化 userId
|
|
501
507
|
useEffect(() => {
|
|
@@ -645,11 +651,16 @@ export function useChatState(options: UseChatStateOptions = {}): UseChatStateRet
|
|
|
645
651
|
// 重置卡片缓存队列
|
|
646
652
|
pendingCardsRef.current = []
|
|
647
653
|
|
|
648
|
-
// T039: 保存 sessionId(如果后端返回)
|
|
649
|
-
const messageStartData = data as MessageStartData
|
|
654
|
+
// T039: 保存 sessionId 和 userId(如果后端返回)
|
|
655
|
+
const messageStartData = data as MessageStartData & { userId?: string }
|
|
650
656
|
if (messageStartData.sessionId && messageStartData.sessionId !== sessionId) {
|
|
651
657
|
saveSession(messageStartData.sessionId)
|
|
652
658
|
}
|
|
659
|
+
// 保存后端返回的 userId(如果有)
|
|
660
|
+
if (messageStartData.userId) {
|
|
661
|
+
saveUserId(messageStartData.userId)
|
|
662
|
+
setUserId(messageStartData.userId)
|
|
663
|
+
}
|
|
653
664
|
|
|
654
665
|
// 检查最后一条消息是否是 thinking 消息(用户发送消息时已添加)
|
|
655
666
|
setMessagesState(prev => {
|
|
@@ -852,8 +863,8 @@ export function useChatState(options: UseChatStateOptions = {}): UseChatStateRet
|
|
|
852
863
|
// ========== 6. 促销活动列表 (Promotion List) ==========
|
|
853
864
|
// 后端格式: {type: "promotion_list", data: {found, count, total, results: [...]}}
|
|
854
865
|
else if (contentType === 'promotion_list' && contentData.found !== undefined) {
|
|
855
|
-
//
|
|
856
|
-
onPromotionList?.()
|
|
866
|
+
// 触发促销卡片回调,传递促销活动数组
|
|
867
|
+
onPromotionList?.(contentData.results || [])
|
|
857
868
|
|
|
858
869
|
messageContent = {
|
|
859
870
|
type: 'promotion_list',
|
|
@@ -1080,6 +1091,7 @@ export function useChatState(options: UseChatStateOptions = {}): UseChatStateRet
|
|
|
1080
1091
|
closeChat,
|
|
1081
1092
|
toggleChat,
|
|
1082
1093
|
setInputValue,
|
|
1094
|
+
setUserId,
|
|
1083
1095
|
addMessage,
|
|
1084
1096
|
setMessages,
|
|
1085
1097
|
clearMessages,
|
|
@@ -44,7 +44,7 @@ export { useSession } from './hooks/useSession'
|
|
|
44
44
|
|
|
45
45
|
// 导出工具类
|
|
46
46
|
export { MessageRendererRegistry } from './utils/messageRenderers'
|
|
47
|
-
export { getUserId } from './utils/userId'
|
|
47
|
+
export { getUserId, saveUserId, clearUserId } from './utils/userId'
|
|
48
48
|
export { sanitizeInput, isValidUrl, isValidUUID, isValidMessageContent, escapeHtml } from './utils/validation'
|
|
49
49
|
|
|
50
50
|
// 导出消息渲染器(供自定义使用)
|
|
@@ -637,11 +637,13 @@ export interface NewSessionRequest {
|
|
|
637
637
|
site?: string
|
|
638
638
|
channel_code?: string
|
|
639
639
|
real_user_id?: string
|
|
640
|
+
page_url?: string
|
|
640
641
|
}
|
|
641
642
|
|
|
642
643
|
export interface NewSessionResponse {
|
|
643
644
|
success: boolean
|
|
644
645
|
sessionId: string
|
|
646
|
+
userId?: string // 后端生成的 userId(当请求的 user_id 为空时返回)
|
|
645
647
|
message: string
|
|
646
648
|
resumed?: boolean
|
|
647
649
|
messages?: Message[]
|
|
@@ -923,8 +925,9 @@ export interface LiveChatWidgetProps {
|
|
|
923
925
|
|
|
924
926
|
/**
|
|
925
927
|
* AI 回复促销卡片时触发
|
|
928
|
+
* @param promotions 促销活动数组数据
|
|
926
929
|
*/
|
|
927
|
-
onPromotionList?: () => void
|
|
930
|
+
onPromotionList?: (promotions: PromotionItem[]) => void
|
|
928
931
|
|
|
929
932
|
/**
|
|
930
933
|
* 商品操作回调
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* 策略:
|
|
6
6
|
* 1. 优先从 localStorage 读取
|
|
7
7
|
* 2. 尝试获取 Google Analytics ID (GAID)
|
|
8
|
-
* 3.
|
|
8
|
+
* 3. 返回空字符串,由后端生成
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const STORAGE_KEY = 'livechat_user_id'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* 获取用户唯一标识符(异步版本)
|
|
15
|
-
* @returns userId (GAID
|
|
15
|
+
* @returns userId (GAID 或空字符串,空字符串由后端生成)
|
|
16
16
|
*/
|
|
17
17
|
export async function getUserId(): Promise<string> {
|
|
18
18
|
// 1. 尝试从 localStorage 读取
|
|
@@ -30,21 +30,17 @@ export async function getUserId(): Promise<string> {
|
|
|
30
30
|
return gaid
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// 3.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (typeof window !== 'undefined') {
|
|
45
|
-
localStorage.setItem(STORAGE_KEY, fallback)
|
|
46
|
-
}
|
|
47
|
-
return fallback
|
|
33
|
+
// 3. 返回空字符串,由后端生成
|
|
34
|
+
return ''
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 保存后端返回的 userId 到 localStorage
|
|
39
|
+
* @param id 后端生成的 userId
|
|
40
|
+
*/
|
|
41
|
+
export function saveUserId(id: string): void {
|
|
42
|
+
if (id && typeof window !== 'undefined') {
|
|
43
|
+
localStorage.setItem(STORAGE_KEY, id)
|
|
48
44
|
}
|
|
49
45
|
}
|
|
50
46
|
|
|
@@ -85,51 +81,6 @@ function getGAID(): string | null {
|
|
|
85
81
|
}
|
|
86
82
|
}
|
|
87
83
|
|
|
88
|
-
/**
|
|
89
|
-
* 生成哈希用户 ID(兜底方案)
|
|
90
|
-
* @returns 格式为 user-{hash} 的用户 ID,hash 由 timestamp + random 通过 SHA-256 生成
|
|
91
|
-
*/
|
|
92
|
-
async function generateHashedUserId(): Promise<string> {
|
|
93
|
-
const timestamp = Date.now()
|
|
94
|
-
const random = Math.random().toString(36).substring(2, 15) // 生成随机字符串
|
|
95
|
-
|
|
96
|
-
// 将 timestamp 和 random 组合成字符串
|
|
97
|
-
const rawString = `${timestamp}${random}`
|
|
98
|
-
|
|
99
|
-
// 使用 Web Crypto API 生成 SHA-256 哈希
|
|
100
|
-
const encoder = new TextEncoder()
|
|
101
|
-
const data = encoder.encode(rawString)
|
|
102
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
103
|
-
|
|
104
|
-
// 将 ArrayBuffer 转换为 16 进制字符串
|
|
105
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
106
|
-
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
107
|
-
|
|
108
|
-
// 取前 16 位作为用户 ID(保持简洁)
|
|
109
|
-
return `user-${hashHex.substring(0, 16)}`
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* 同步版本的哈希用户 ID 生成(兜底的兜底)
|
|
114
|
-
* 当 Web Crypto API 不可用时使用
|
|
115
|
-
*/
|
|
116
|
-
function generateHashedUserIdSync(): string {
|
|
117
|
-
const timestamp = Date.now()
|
|
118
|
-
const random = Math.random().toString(36).substring(2, 15)
|
|
119
|
-
const rawString = `${timestamp}${random}`
|
|
120
|
-
|
|
121
|
-
// 使用简单的哈希算法作为兜底
|
|
122
|
-
let hash = 0
|
|
123
|
-
for (let i = 0; i < rawString.length; i++) {
|
|
124
|
-
const char = rawString.charCodeAt(i)
|
|
125
|
-
hash = (hash << 5) - hash + char
|
|
126
|
-
hash = hash & hash
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const hashString = Math.abs(hash).toString(16).padStart(8, '0')
|
|
130
|
-
return `user-${hashString}`
|
|
131
|
-
}
|
|
132
|
-
|
|
133
84
|
/**
|
|
134
85
|
* 清除保存的 userId(用于测试或重置)
|
|
135
86
|
*/
|
|
@@ -80,25 +80,25 @@ export function CreditsBanner({ copy, id }: { copy: CreditsBannerCopy; id?: stri
|
|
|
80
80
|
></div>
|
|
81
81
|
)}
|
|
82
82
|
|
|
83
|
-
<Container className="l:h-auto !absolute inset-0 mx-auto grid h-full" asChild>
|
|
83
|
+
<Container className="l-tablet:h-auto !absolute inset-0 mx-auto grid h-full" asChild>
|
|
84
84
|
<div className="grid grid-cols-12">
|
|
85
|
-
<div className="l:col-span-12 l:justify-start l:truncate l:pt-[32px] col-span-5 flex h-full flex-col justify-center text-[#1F2021]">
|
|
85
|
+
<div className="l-tablet:col-span-12 l-tablet:justify-start l-tablet:truncate l-tablet:pt-[32px] col-span-5 flex h-full flex-col justify-center text-[#1F2021]">
|
|
86
86
|
<Heading
|
|
87
87
|
as="h1"
|
|
88
|
-
className="text-[48px] font-bold xl-xxl:text-[40px] l:text-[32px]"
|
|
88
|
+
className="text-[48px] font-bold xl-xxl:text-[40px] l-tablet:text-[32px]"
|
|
89
89
|
html={isLogin ? copy.login.title?.replace('$name', displayName || '') : copy.unLogin.title}
|
|
90
90
|
></Heading>
|
|
91
91
|
|
|
92
92
|
<Text
|
|
93
93
|
size="3"
|
|
94
|
-
className="l:mt-[4px] l-xxl:text-[14px] mt-[16px]"
|
|
94
|
+
className="l-tablet:mt-[4px] l-xxl:text-[14px] mt-[16px]"
|
|
95
95
|
html={isLogin ? copy.login.description : copy.unLogin.description}
|
|
96
96
|
></Text>
|
|
97
97
|
|
|
98
98
|
{!isLogin && (
|
|
99
99
|
<div
|
|
100
100
|
className={classNames(
|
|
101
|
-
'mt-[32px] grid w-fit grid-flow-col gap-[12px] l:mt-[24px]',
|
|
101
|
+
'mt-[32px] grid w-fit grid-flow-col gap-[12px] l-tablet:mt-[24px]',
|
|
102
102
|
isLogin && 'hidden'
|
|
103
103
|
)}
|
|
104
104
|
>
|
|
@@ -147,8 +147,8 @@ type Story = StoryObj<typeof LiveChatWidget>
|
|
|
147
147
|
export const Default: Story = {
|
|
148
148
|
args: {
|
|
149
149
|
// 基础配置
|
|
150
|
-
loginUserId: '
|
|
151
|
-
apiBaseUrl: '
|
|
150
|
+
loginUserId: 'test_test1',
|
|
151
|
+
apiBaseUrl: 'https://beta-api-v2-livechat.anker.com',
|
|
152
152
|
site: 'beta.eufy.com',
|
|
153
153
|
channelCode: 'dtc',
|
|
154
154
|
title: 'eufy AI Assistant',
|
|
@@ -159,14 +159,7 @@ export const Default: Story = {
|
|
|
159
159
|
position: { bottom: '24px', right: '30px' },
|
|
160
160
|
|
|
161
161
|
// 欢迎消息
|
|
162
|
-
welcomeMessage:
|
|
163
|
-
|
|
164
|
-
I can help you with:
|
|
165
|
-
- Product recommendations
|
|
166
|
-
- Order tracking
|
|
167
|
-
- FAQs and support
|
|
168
|
-
|
|
169
|
-
How can I assist you today?`,
|
|
162
|
+
welcomeMessage: '',
|
|
170
163
|
|
|
171
164
|
// 快捷回复
|
|
172
165
|
quickReplies: [
|
|
@@ -179,9 +172,11 @@ How can I assist you today?`,
|
|
|
179
172
|
// 法规协议弹窗
|
|
180
173
|
complianceConfig: {
|
|
181
174
|
title: "Hi! I'm your eufy AI assistant.",
|
|
182
|
-
content:
|
|
183
|
-
|
|
184
|
-
|
|
175
|
+
content:
|
|
176
|
+
'AI-generated responses can be inaccurate. Please verify important info. Do not input sensitive personal data.',
|
|
177
|
+
checkboxText:
|
|
178
|
+
'By starting to use "Live Chat", you agree to Anker\'s <a href="https://www.anker.com/pages/privacy-policy" target="_blank" rel="noopener noreferrer" style="text-decoration: underline;">LIVE CHAT PRIVACY NOTICE</a>.',
|
|
179
|
+
agreeButtonText: 'Agree',
|
|
185
180
|
},
|
|
186
181
|
|
|
187
182
|
// reCAPTCHA 配置(取消注释以启用)
|