访客落地页、在线读秒与浏览轨迹

记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 SPA 路由。
This commit is contained in:
yml2213
2026-07-18 22:19:50 +08:00
parent 7a08f5f729
commit 81852e0ddd
12 changed files with 656 additions and 13 deletions
+101 -4
View File
@@ -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) {