diff --git a/server/internal/handler/page_track.go b/server/internal/handler/page_track.go index 3071029..a25dd40 100644 --- a/server/internal/handler/page_track.go +++ b/server/internal/handler/page_track.go @@ -67,8 +67,12 @@ func sanitizePageURL(raw string) string { if changed { u.RawQuery = q.Encode() } - // 去掉 fragment - u.Fragment = "" + // 保留 hash:大量 SPA(含演示站 demo-shop)靠 #/path 区分页面; + // 若去掉 fragment,换页后 URL 相同会被服务端当成重复而丢弃轨迹。 + // 仅截断过长 hash,避免异常数据撑爆字段。 + if utf8.RuneCountInString(u.Fragment) > 200 { + u.Fragment = string([]rune(u.Fragment)[:200]) + } out := u.String() return truncateRunes(out, maxPageURLLen) } diff --git a/server/internal/handler/page_track_test.go b/server/internal/handler/page_track_test.go index e421817..e287775 100644 --- a/server/internal/handler/page_track_test.go +++ b/server/internal/handler/page_track_test.go @@ -7,13 +7,22 @@ import ( ) func TestSanitizePageURL(t *testing.T) { - got := sanitizePageURL("https://shop.example.com/p/1?token=secret&ok=1#hash") - if got == "" || strings.Contains(got, "token=") || strings.Contains(got, "#") { + got := sanitizePageURL("https://shop.example.com/p/1?token=secret&ok=1#/cart") + if got == "" || strings.Contains(got, "token=") { t.Fatalf("sanitize failed: %q", got) } if !strings.Contains(got, "ok=1") { t.Fatalf("should keep ok param: %q", got) } + // SPA hash 路由必须保留,否则换页轨迹会被去重丢掉 + if !strings.Contains(got, "#/cart") { + t.Fatalf("should keep hash fragment for SPA: %q", got) + } + home := sanitizePageURL("http://localhost:5173/demo-shop/index.html#/") + cart := sanitizePageURL("http://localhost:5173/demo-shop/index.html#/cart") + if home == "" || cart == "" || home == cart { + t.Fatalf("hash pages must differ: home=%q cart=%q", home, cart) + } if sanitizePageURL("javascript:alert(1)") != "" { t.Fatal("reject javascript") } diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index dcfa917..12140fe 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -353,7 +353,8 @@ func (h *SessionHandler) Get(c *gin.Context) { var events []model.SessionEvent model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events) var pageViews []model.VisitorPageView - model.DB.Where("session_id = ?", session.ID).Order("entered_at asc").Limit(maxPageViewsPerSession).Find(&pageViews) + // 最新在前,坐席侧无需滚到底 + model.DB.Where("session_id = ?", session.ID).Order("entered_at desc").Limit(maxPageViewsPerSession).Find(&pageViews) var pendingCount int64 model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount) diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index f8e8b6c..30443f3 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -274,13 +274,34 @@ func (h *WidgetHandler) PageView(c *gin.Context) { pageTitle := sanitizePageTitle(req.PageTitle) now := time.Now() - // 与当前页相同则只刷新 last_seen,不重复插轨迹 + // 与当前页相同:只刷新活跃时间/标题,不插重复轨迹 if strings.TrimSpace(session.CurrentURL) == pageURL { - _ = model.DB.Model(session).Updates(map[string]interface{}{ - "last_seen_at": now, - "current_title": pageTitle, - }).Error - c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true}}) + updates := map[string]interface{}{ + "last_seen_at": now, + } + titleFixed := false + if pageTitle != "" && pageTitle != strings.TrimSpace(session.CurrentTitle) { + updates["current_title"] = pageTitle + // 同步最新一条轨迹的标题(纠正「慢一页」时写入的旧 title) + var last model.VisitorPageView + if err := model.DB.Where("session_id = ? AND url = ?", session.ID, pageURL). + Order("entered_at desc").First(&last).Error; err == nil { + _ = model.DB.Model(&last).Update("title", pageTitle).Error + titleFixed = true + if payload, err := ws.NewEvent("page_view", session.ID, gin.H{ + "id": last.ID, + "session_id": last.SessionID, + "url": pageURL, + "title": pageTitle, + "entered_at": last.EnteredAt, + "title_fix": true, + }); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } + } + } + _ = model.DB.Model(session).Updates(updates).Error + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true, "title_updated": titleFixed}}) return } @@ -324,7 +345,9 @@ func (h *WidgetHandler) PageView(c *gin.Context) { "title": pv.Title, "entered_at": pv.EnteredAt, }); err == nil { - ws.DefaultHub.BroadcastToSessionStaff(session.TenantID, session.AgentID, payload) + // 排队中 AgentID 为空时,BroadcastToSessionStaff 只会推给 admin/supervisor, + // 一线坐席看不到实时轨迹;轨迹对租户内工作人员统一广播。 + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } c.JSON(http.StatusOK, gin.H{"code": 0, "data": pv}) diff --git a/web/public/demo-shop/index.html b/web/public/demo-shop/index.html new file mode 100644 index 0000000..215b231 --- /dev/null +++ b/web/public/demo-shop/index.html @@ -0,0 +1,491 @@ + + + + + + 演示商城 · 首页 + + + +
+
+ + + 访客轨迹测试站 +
+
+ +
+ +
+
当前宿主页(会上报给客服)
+ +
title: —
+
+ 点右下角蓝钮打开客服 → 再点上方导航换页 → 在坐席工作台右侧看「落地页 / 浏览轨迹 / 在线时长」 +
+
+ + + + diff --git a/web/public/widget.js b/web/public/widget.js index b196b06..0b7a033 100644 --- a/web/public/widget.js +++ b/web/public/widget.js @@ -30,8 +30,9 @@ var open = false; var iframe = null; - var lastSentURL = ''; + var lastSentKey = ''; var lastSentAt = 0; + var routeTimer = null; var btn = document.createElement('button'); btn.type = 'button'; @@ -82,8 +83,10 @@ if (!iframe || !iframe.contentWindow) return; var info = hostPageInfo(); var now = Date.now(); - if (!force && info.url === lastSentURL && now - lastSentAt < 800) return; - lastSentURL = info.url; + // url + title 一起去重:SPA 常先改 hash 再改 title,只比 url 会丢掉正确标题 + var key = info.url + '\0' + info.title; + if (!force && key === lastSentKey && now - lastSentAt < 500) return; + lastSentKey = key; lastSentAt = now; try { iframe.contentWindow.postMessage({ @@ -95,6 +98,21 @@ } catch (e) { /* ignore */ } } + /** + * 路由变化后延迟采集:让宿主 SPA(含 demo-shop)先跑完 render、更新 document.title, + * 避免轨迹标题永远慢一页(URL 已是新页、title 仍是上一页)。 + */ + function schedulePostPage(force) { + if (!open && !force) return; + if (routeTimer) clearTimeout(routeTimer); + routeTimer = setTimeout(function () { + routeTimer = null; + postPageToIframe(!!force); + // 再补一帧:部分框架 title 在 paint 后才写 + setTimeout(function () { postPageToIframe(false); }, 50); + }, 0); + } + function ensureIframe() { if (iframe) return; iframe = document.createElement('iframe'); @@ -103,7 +121,7 @@ iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;'; iframe.src = buildEmbedURL(); iframe.addEventListener('load', function () { - postPageToIframe(true); + schedulePostPage(true); }); panel.appendChild(iframe); } @@ -114,7 +132,7 @@ ensureIframe(); panel.style.display = 'block'; btn.style.display = 'none'; - postPageToIframe(true); + schedulePostPage(true); } else { panel.style.display = 'none'; btn.style.display = 'flex'; @@ -130,14 +148,14 @@ } // iframe 就绪后可再次同步宿主页 if (event.data.type === 'kefu-widget-ready') { - postPageToIframe(true); + schedulePostPage(true); } }); // —— SPA / 浏览器导航监听 —— function onRouteMaybeChanged() { if (!open) return; - postPageToIframe(false); + schedulePostPage(false); } try { diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 5f90852..0b491ab 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -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 || '/' + // 必须带 hash:demo-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 = () => {
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' }`} >
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} - {idx === detail.pageViews.length - 1 && ( + {idx === 0 && ( 当前 )}
diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 376da38..46fba3f 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -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 diff --git a/web/vite.config.ts b/web/vite.config.ts index 5b4e49c..94e549f 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,10 +1,29 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' +/** 避免 /demo-shop/ 被 SPA 回退成 React,导致 404;改为提供 public 静态页 */ +function demoShopStatic(): Plugin { + return { + name: 'demo-shop-static', + configureServer(server) { + // 必须挂在内部中间件之前,否则会先进 React SPA + server.middlewares.use((req, _res, next) => { + const raw = req.url || '' + const pathname = raw.split('?')[0] + if (pathname === '/demo-shop' || pathname === '/demo-shop/') { + const qs = raw.includes('?') ? raw.slice(raw.indexOf('?')) : '' + req.url = `/demo-shop/index.html${qs}` + } + next() + }) + }, + } +} + export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), demoShopStatic()], resolve: { alias: { '@': path.resolve(__dirname, 'src'),