修复访客浏览轨迹延迟与排序,增加演示站

保留 SPA hash 以正确区分页面;延迟采集 title 避免慢一页;轨迹改为最新在上;补充 demo-shop 测试页与 Vite 静态路由。
This commit is contained in:
yml2213
2026-07-18 23:22:47 +08:00
parent 81852e0ddd
commit 970bae16d1
9 changed files with 651 additions and 44 deletions
+36 -15
View File
@@ -136,10 +136,11 @@ function shortPagePath(url?: string): string {
if (!url) return '—'
try {
const u = new URL(url)
const path = u.pathname + (u.search || '')
return path.length > 48 ? `${path.slice(0, 46)}` : path || '/'
// 必须带 hashdemo-shop 等 SPA 靠 #/cart 区分页面
const path = u.pathname + (u.search || '') + (u.hash || '')
return path.length > 56 ? `${path.slice(0, 54)}` : path || '/'
} catch {
return url.length > 48 ? `${url.slice(0, 46)}` : url
return url.length > 56 ? `${url.slice(0, 54)}` : url
}
}
@@ -259,7 +260,10 @@ const Dashboard = () => {
setDetail({
messages,
events: data.events || [],
pageViews: Array.isArray(data.page_views) ? data.page_views : [],
// 接口已 desc;再保险按时间新→旧
pageViews: (Array.isArray(data.page_views) ? data.page_views : [])
.slice()
.sort((a, b) => new Date(b.entered_at).getTime() - new Date(a.entered_at).getTime()),
pendingCount: data.pending_count || 0,
})
rememberMessagesSeq(id, messages)
@@ -464,20 +468,29 @@ const Dashboard = () => {
}
if (payload.type === 'page_view' && sameSession && payload.data) {
const pv = payload.data as unknown as VisitorPageView
if (pv?.url) {
const raw = payload.data as unknown as VisitorPageView & { title_fix?: boolean }
if (raw?.url) {
setDetail(prev => {
if (!prev) return prev
if (prev.pageViews.some(p => p.id === pv.id)) return prev
return { ...prev, pageViews: [...prev.pageViews, pv] }
const idx = prev.pageViews.findIndex(p => p.id === raw.id)
if (idx >= 0) {
// 标题纠正:更新已有条目,并移到最前(仍是当前页)
const next = prev.pageViews.slice()
const item = { ...next[idx], title: raw.title || next[idx].title, url: raw.url }
next.splice(idx, 1)
return { ...prev, pageViews: [item, ...next] }
}
if (prev.pageViews.some(p => p.url === raw.url && !raw.id)) return prev
// 最新轨迹插到最上方
return { ...prev, pageViews: [raw, ...prev.pageViews] }
})
setSessions(previous => previous.map(session =>
session.id === sid
? {
...session,
current_url: pv.url,
current_title: pv.title || session.current_title,
last_seen_at: pv.entered_at || session.last_seen_at,
current_url: raw.url,
current_title: raw.title || session.current_title,
last_seen_at: raw.entered_at || session.last_seen_at,
}
: session,
))
@@ -602,11 +615,19 @@ const Dashboard = () => {
const selectedCustomer = selected ? customers[selected.customer_id] : null
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
// 进行中会话:在线时长每秒刷新
// 进行中会话:在线时长每秒刷新;轨迹每 8s 静默拉一次(防 WS 漏推)
useEffect(() => {
if (!selected || selected.status === 'ended' || selected.status === 'archived') return
const t = window.setInterval(() => setClockTick(n => n + 1), 1000)
return () => clearInterval(t)
const poll = window.setInterval(() => {
if (selectedIdRef.current != null) {
void loadDetailRef.current(selectedIdRef.current, false, { silent: true })
}
}, 8000)
return () => {
clearInterval(t)
clearInterval(poll)
}
}, [selected?.id, selected?.status])
useEffect(() => {
@@ -1491,14 +1512,14 @@ const Dashboard = () => {
<div
key={pv.id || `${pv.url}-${pv.entered_at}`}
className={`px-2.5 py-2 text-xs ${idx > 0 ? 'border-t border-neutral-50' : ''} ${
idx === detail.pageViews.length - 1 ? 'bg-blue-50/50' : 'bg-white'
idx === 0 ? 'bg-blue-50/50' : 'bg-white'
}`}
>
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-neutral-400 tabular-nums shrink-0">
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
{idx === detail.pageViews.length - 1 && (
{idx === 0 && (
<span className="text-[10px] text-[#2563eb] font-medium"></span>
)}
</div>
+29 -8
View File
@@ -135,7 +135,7 @@ const VisitorChat = ({
title: initialPage?.title || (typeof document !== 'undefined' ? document.title : ''),
referrer: initialPage?.referrer || (typeof document !== 'undefined' ? document.referrer : ''),
})
const lastPageURLRef = useRef('')
const lastPageKeyRef = useRef('') // url + title,避免同 URL 标题更新被吞
/** 本地已同步到的最大 seq(从缓存消息初始化) */
const lastSeqRef = useRef((() => {
try {
@@ -315,11 +315,23 @@ const VisitorChat = ({
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
if (!url) return
// 会话尚未 Init 完成:只缓存宿主页,避免丢掉打开前/初始化中的换页
if (!sid || !token) {
hostPageRef.current = {
...hostPageRef.current,
url,
title: title || hostPageRef.current.title,
}
return
}
if (sessionEnded) return
const key = `${url}\0${title || ''}`
// 同 URL 但标题变了仍要上报(服务端会更新 current_title;新 URL 会插轨迹)
if (key === lastPageKeyRef.current) return
lastPageKeyRef.current = key
try {
await fetch('/api/widget/pageview', {
const res = await fetch('/api/widget/pageview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -332,8 +344,11 @@ const VisitorChat = ({
page_title: title || '',
}),
})
if (!res.ok) {
lastPageKeyRef.current = ''
}
} catch {
/* 轨迹失败不影响会话 */
lastPageKeyRef.current = ''
}
}, [sessionEnded])
@@ -378,9 +393,15 @@ const VisitorChat = ({
localStorage.setItem(tokenKey, token)
localStorage.removeItem(msgsKey)
lastSeqRef.current = 0
if (page.url) lastPageURLRef.current = page.url
// init 已写入落地页;标记已上报,避免立刻重复;宿主若已换页则补报
const latest = hostPageRef.current
if (page.url) lastPageKeyRef.current = `${page.url}\0${page.title || ''}`
await loadMessages(sid, token, { full: true })
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey])
if (latest.url && (latest.url !== page.url || (latest.title || '') !== (page.title || ''))) {
lastPageKeyRef.current = ''
void reportPageView(latest.url, latest.title || '')
}
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey, reportPageView])
const closeSocket = useCallback(() => {
const sock = socketRef.current