访客落地页、在线读秒与浏览轨迹
记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 SPA 路由。
This commit is contained in:
@@ -48,12 +48,16 @@ function parseWelcomeSegments(data: {
|
||||
|
||||
type LayoutMode = 'floating' | 'fill'
|
||||
|
||||
export type HostPageInfo = { url?: string; title?: string; referrer?: string }
|
||||
|
||||
interface VisitorChatProps {
|
||||
defaultOpen?: boolean
|
||||
channelKey?: string
|
||||
/** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */
|
||||
embedded?: boolean
|
||||
layout?: LayoutMode
|
||||
/** 宿主页初始 URL(由 widget.js 经 query 传入) */
|
||||
initialPage?: HostPageInfo
|
||||
}
|
||||
|
||||
const VisitorChat = ({
|
||||
@@ -61,6 +65,7 @@ const VisitorChat = ({
|
||||
channelKey = 'WK_8a3f2e',
|
||||
embedded = false,
|
||||
layout = 'floating',
|
||||
initialPage,
|
||||
}: VisitorChatProps) => {
|
||||
const storageKey = useMemo(() => `kefu_widget_session_${channelKey}`, [channelKey])
|
||||
const tokenKey = useMemo(() => `kefu_widget_visitor_token_${channelKey}`, [channelKey])
|
||||
@@ -124,6 +129,13 @@ const VisitorChat = ({
|
||||
const agentNameRef = useRef(agentName)
|
||||
const loadMessagesRef = useRef<(sid?: number, token?: string, opts?: { afterSeq?: number; full?: boolean }) => Promise<void>>(async () => {})
|
||||
const msgsKeyRef = useRef(msgsKey)
|
||||
/** 宿主页信息(嵌入时由 postMessage / 初始 query 更新) */
|
||||
const hostPageRef = useRef<HostPageInfo>({
|
||||
url: initialPage?.url || (typeof window !== 'undefined' ? window.location.href : ''),
|
||||
title: initialPage?.title || (typeof document !== 'undefined' ? document.title : ''),
|
||||
referrer: initialPage?.referrer || (typeof document !== 'undefined' ? document.referrer : ''),
|
||||
})
|
||||
const lastPageURLRef = useRef('')
|
||||
/** 本地已同步到的最大 seq(从缓存消息初始化) */
|
||||
const lastSeqRef = useRef((() => {
|
||||
try {
|
||||
@@ -300,12 +312,45 @@ const VisitorChat = ({
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const reportPageView = useCallback(async (url: string, title: string) => {
|
||||
const sid = sessionIdRef.current
|
||||
const token = visitorTokenRef.current
|
||||
if (!sid || !token || !url || sessionEnded) return
|
||||
if (url === lastPageURLRef.current) return
|
||||
lastPageURLRef.current = url
|
||||
try {
|
||||
await fetch('/api/widget/pageview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Visitor-Token': token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: sid,
|
||||
visitor_token: token,
|
||||
page_url: url,
|
||||
page_title: title || '',
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
/* 轨迹失败不影响会话 */
|
||||
}
|
||||
}, [sessionEnded])
|
||||
|
||||
/** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */
|
||||
const bootstrapNewSession = useCallback(async () => {
|
||||
const res = await fetch(
|
||||
`/api/widget/init?channel_key=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const page = hostPageRef.current
|
||||
const res = await fetch('/api/widget/init', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channel_key: channelKey,
|
||||
visitor_name: '访客',
|
||||
page_url: page.url || '',
|
||||
page_title: page.title || '',
|
||||
referrer: page.referrer || '',
|
||||
}),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '初始化会话失败')
|
||||
@@ -333,6 +378,7 @@ const VisitorChat = ({
|
||||
localStorage.setItem(tokenKey, token)
|
||||
localStorage.removeItem(msgsKey)
|
||||
lastSeqRef.current = 0
|
||||
if (page.url) lastPageURLRef.current = page.url
|
||||
await loadMessages(sid, token, { full: true })
|
||||
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey])
|
||||
|
||||
@@ -417,6 +463,57 @@ const VisitorChat = ({
|
||||
if (open) initSession()
|
||||
}, [open, initSession])
|
||||
|
||||
// 嵌入模式:通知宿主就绪,并接收宿主页 URL 变更
|
||||
useEffect(() => {
|
||||
if (!embedded) return
|
||||
try {
|
||||
window.parent.postMessage({ type: 'kefu-widget-ready' }, '*')
|
||||
} catch { /* ignore */ }
|
||||
const onMsg = (event: MessageEvent) => {
|
||||
const data = event?.data
|
||||
if (!data || data.type !== 'kefu-host-page') return
|
||||
const url = typeof data.url === 'string' ? data.url : ''
|
||||
const title = typeof data.title === 'string' ? data.title : ''
|
||||
const referrer = typeof data.referrer === 'string' ? data.referrer : hostPageRef.current.referrer
|
||||
if (!url) return
|
||||
hostPageRef.current = { url, title, referrer }
|
||||
void reportPageView(url, title)
|
||||
}
|
||||
window.addEventListener('message', onMsg)
|
||||
return () => window.removeEventListener('message', onMsg)
|
||||
}, [embedded, reportPageView])
|
||||
|
||||
// 非嵌入预览:用当前页作为来源
|
||||
useEffect(() => {
|
||||
if (embedded || !sessionId || !visitorToken || sessionEnded) return
|
||||
const url = window.location.href
|
||||
const title = document.title
|
||||
hostPageRef.current = {
|
||||
url,
|
||||
title,
|
||||
referrer: document.referrer || hostPageRef.current.referrer,
|
||||
}
|
||||
void reportPageView(url, title)
|
||||
}, [embedded, sessionId, visitorToken, sessionEnded, reportPageView])
|
||||
|
||||
// 心跳:刷新 last_seen_at,供客服端在线读秒
|
||||
useEffect(() => {
|
||||
if (!sessionId || !visitorToken || sessionEnded || !open) return
|
||||
const beat = () => {
|
||||
void fetch('/api/widget/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Visitor-Token': visitorToken,
|
||||
},
|
||||
body: JSON.stringify({ session_id: sessionId, visitor_token: visitorToken }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
beat()
|
||||
const t = window.setInterval(beat, 20000)
|
||||
return () => clearInterval(t)
|
||||
}, [sessionId, visitorToken, sessionEnded, open])
|
||||
|
||||
// 兜底轮询:按 after_seq 增量对齐
|
||||
useEffect(() => {
|
||||
if (sessionId && visitorToken && open) {
|
||||
|
||||
Reference in New Issue
Block a user