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'),