From 81852e0ddd7499891d27da93fdd2d71509391f0a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 18 Jul 2026 22:19:50 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BF=E5=AE=A2=E8=90=BD=E5=9C=B0=E9=A1=B5?= =?UTF-8?q?=E3=80=81=E5=9C=A8=E7=BA=BF=E8=AF=BB=E7=A7=92=E4=B8=8E=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E8=BD=A8=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 SPA 路由。 --- server/internal/handler/page_track.go | 78 ++++++++++ server/internal/handler/page_track_test.go | 31 ++++ server/internal/handler/router.go | 2 + server/internal/handler/session.go | 3 + server/internal/handler/widget.go | 150 ++++++++++++++++++ server/internal/model/db.go | 1 + server/internal/model/models.go | 18 +++ web/public/widget.js | 87 ++++++++++- web/src/pages/WidgetEmbed.tsx | 6 + web/src/pages/agent/Dashboard.tsx | 172 ++++++++++++++++++++- web/src/services/api.ts | 16 ++ web/src/widgets/VisitorChat.tsx | 105 ++++++++++++- 12 files changed, 656 insertions(+), 13 deletions(-) create mode 100644 server/internal/handler/page_track.go create mode 100644 server/internal/handler/page_track_test.go diff --git a/server/internal/handler/page_track.go b/server/internal/handler/page_track.go new file mode 100644 index 0000000..3071029 --- /dev/null +++ b/server/internal/handler/page_track.go @@ -0,0 +1,78 @@ +package handler + +import ( + "net/url" + "strings" + "unicode/utf8" +) + +const ( + maxPageURLLen = 1000 + maxPageTitleLen = 200 + // 单会话最多保留轨迹条数(超出删最旧) + maxPageViewsPerSession = 50 +) + +// 常见敏感 query 参数,上报 URL 时剥离 +var sensitiveQueryKeys = map[string]bool{ + "token": true, "access_token": true, "refresh_token": true, + "password": true, "passwd": true, "pwd": true, + "phone": true, "mobile": true, "tel": true, + "idcard": true, "id_card": true, "secret": true, "key": true, + "authorization": true, "auth": true, "session": true, "sid": true, + "code": true, "otp": true, "verify": true, +} + +func truncateRunes(s string, max int) string { + s = strings.TrimSpace(s) + if max <= 0 || s == "" { + return s + } + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + return string(r[:max]) +} + +// sanitizePageURL 规范化并脱敏 URL,非法则返回空。 +func sanitizePageURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if utf8.RuneCountInString(raw) > maxPageURLLen*2 { + raw = string([]rune(raw)[:maxPageURLLen*2]) + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + // 允许无 scheme 的相对路径拼不成绝对时,若已是 http(s) 失败则截断原文 + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + return truncateRunes(raw, maxPageURLLen) + } + return "" + } + if u.Scheme != "http" && u.Scheme != "https" { + return "" + } + q := u.Query() + changed := false + for k := range q { + lk := strings.ToLower(k) + if sensitiveQueryKeys[lk] || strings.Contains(lk, "token") || strings.Contains(lk, "password") { + q.Del(k) + changed = true + } + } + if changed { + u.RawQuery = q.Encode() + } + // 去掉 fragment + u.Fragment = "" + out := u.String() + return truncateRunes(out, maxPageURLLen) +} + +func sanitizePageTitle(raw string) string { + return truncateRunes(raw, maxPageTitleLen) +} diff --git a/server/internal/handler/page_track_test.go b/server/internal/handler/page_track_test.go new file mode 100644 index 0000000..e421817 --- /dev/null +++ b/server/internal/handler/page_track_test.go @@ -0,0 +1,31 @@ +package handler + +import ( + "strings" + "testing" + "unicode/utf8" +) + +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, "#") { + t.Fatalf("sanitize failed: %q", got) + } + if !strings.Contains(got, "ok=1") { + t.Fatalf("should keep ok param: %q", got) + } + if sanitizePageURL("javascript:alert(1)") != "" { + t.Fatal("reject javascript") + } + if sanitizePageURL("") != "" { + t.Fatal("empty") + } +} + +func TestSanitizePageTitle(t *testing.T) { + long := strings.Repeat("测", 300) + got := sanitizePageTitle(long) + if utf8.RuneCountInString(got) != maxPageTitleLen { + t.Fatalf("title len %d", utf8.RuneCountInString(got)) + } +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 7b26994..50440db 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -40,6 +40,8 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S widgetApi.GET("/ws", widget.Connect) widgetApi.POST("/rating", widget.SubmitRating) widgetApi.POST("/upload", upload.WidgetUploadImage) + widgetApi.POST("/pageview", widget.PageView) + widgetApi.POST("/heartbeat", widget.Heartbeat) // 需要认证的接口 authRequired := api.Group("") diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index 71b3e3f..dcfa917 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -352,6 +352,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) var pendingCount int64 model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount) @@ -365,6 +367,7 @@ func (h *SessionHandler) Get(c *gin.Context) { "session": session, "messages": messages, "events": events, + "page_views": pageViews, "pending_count": pendingCount, "max_seq": maxSeq, }) diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 7583ea0..f8e8b6c 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -22,6 +22,22 @@ func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} } type WidgetInitReq struct { ChannelKey string `json:"channel_key" form:"channel_key"` VisitorName string `json:"visitor_name"` + // 宿主页信息(由 widget.js / 前端上报) + PageURL string `json:"page_url" form:"page_url"` + PageTitle string `json:"page_title" form:"page_title"` + Referrer string `json:"referrer" form:"referrer"` +} + +type WidgetPageViewReq struct { + SessionID uint `json:"session_id" binding:"required"` + VisitorToken string `json:"visitor_token"` + PageURL string `json:"page_url" binding:"required"` + PageTitle string `json:"page_title"` +} + +type WidgetHeartbeatReq struct { + SessionID uint `json:"session_id" binding:"required"` + VisitorToken string `json:"visitor_token"` } type WidgetMessageReq struct { @@ -104,6 +120,20 @@ func (h *WidgetHandler) Init(c *gin.Context) { } visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c) + pageURL := sanitizePageURL(req.PageURL) + if pageURL == "" { + // query 兜底 + pageURL = sanitizePageURL(c.Query("page_url")) + } + pageTitle := sanitizePageTitle(req.PageTitle) + if pageTitle == "" { + pageTitle = sanitizePageTitle(c.Query("page_title")) + } + referrer := sanitizePageURL(req.Referrer) + if referrer == "" { + referrer = sanitizePageURL(c.Query("referrer")) + } + now := time.Now() // 创建会话 session := model.Session{ @@ -114,6 +144,12 @@ func (h *WidgetHandler) Init(c *gin.Context) { VisitorIP: visitorIP, VisitorRegion: visitorRegion, UserAgent: userAgent, + LandingURL: pageURL, + LandingTitle: pageTitle, + Referrer: referrer, + CurrentURL: pageURL, + CurrentTitle: pageTitle, + LastSeenAt: &now, Status: "waiting", Priority: "normal", } @@ -121,6 +157,16 @@ func (h *WidgetHandler) Init(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"}) return } + // 首条浏览轨迹 + if pageURL != "" { + _ = model.DB.Create(&model.VisitorPageView{ + SessionID: session.ID, + TenantID: channel.TenantID, + URL: pageURL, + Title: pageTitle, + EnteredAt: now, + }).Error + } model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, channel.TenantID). Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()}) @@ -197,10 +243,114 @@ func (h *WidgetHandler) Init(c *gin.Context) { } else if canServeNow { resp["agent_name"] = agentNickname } + resp["landing_url"] = session.LandingURL + resp["current_url"] = session.CurrentURL c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp}) } +// PageView POST /api/widget/pageview — 访客换页上报 +func (h *WidgetHandler) PageView(c *gin.Context) { + var req WidgetPageViewReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + token := visitorTokenFromRequest(c, req.VisitorToken) + session, ok := loadVisitorSession(c, req.SessionID, token) + if !ok { + return + } + if session.Status == "ended" || session.Status == "archived" { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"}) + return + } + + pageURL := sanitizePageURL(req.PageURL) + if pageURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "页面地址无效"}) + return + } + 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}}) + return + } + + pv := model.VisitorPageView{ + SessionID: session.ID, + TenantID: session.TenantID, + URL: pageURL, + Title: pageTitle, + EnteredAt: now, + } + if err := model.DB.Create(&pv).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "记录失败"}) + return + } + _ = model.DB.Model(session).Updates(map[string]interface{}{ + "current_url": pageURL, + "current_title": pageTitle, + "last_seen_at": now, + }).Error + + // 控制单会话条数 + var count int64 + model.DB.Model(&model.VisitorPageView{}).Where("session_id = ?", session.ID).Count(&count) + if count > maxPageViewsPerSession { + var oldest []model.VisitorPageView + model.DB.Where("session_id = ?", session.ID).Order("entered_at asc"). + Limit(int(count - maxPageViewsPerSession)).Find(&oldest) + ids := make([]uint, 0, len(oldest)) + for _, o := range oldest { + ids = append(ids, o.ID) + } + if len(ids) > 0 { + model.DB.Where("id IN ?", ids).Delete(&model.VisitorPageView{}) + } + } + + if payload, err := ws.NewEvent("page_view", session.ID, gin.H{ + "id": pv.ID, + "session_id": pv.SessionID, + "url": pv.URL, + "title": pv.Title, + "entered_at": pv.EnteredAt, + }); err == nil { + ws.DefaultHub.BroadcastToSessionStaff(session.TenantID, session.AgentID, payload) + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "data": pv}) +} + +// Heartbeat POST /api/widget/heartbeat — 刷新 last_seen,供在线读秒 +func (h *WidgetHandler) Heartbeat(c *gin.Context) { + var req WidgetHeartbeatReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + token := visitorTokenFromRequest(c, req.VisitorToken) + session, ok := loadVisitorSession(c, req.SessionID, token) + if !ok { + return + } + if session.Status == "ended" || session.Status == "archived" { + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "ended": true}}) + return + } + now := time.Now() + _ = model.DB.Model(session).Update("last_seen_at", now).Error + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "last_seen_at": now}}) +} + func visitorTokenFromRequest(c *gin.Context, bodyToken string) string { if token := c.GetHeader("X-Visitor-Token"); token != "" { return token diff --git a/server/internal/model/db.go b/server/internal/model/db.go index 9586619..13c2c03 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error { &Customer{}, &CustomerTag{}, &Session{}, + &VisitorPageView{}, &Message{}, &SessionEvent{}, &Category{}, diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 0ecadb5..bd21cb1 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -84,6 +84,14 @@ type Session struct { VisitorIP string `gorm:"size:64" json:"visitor_ip"` VisitorRegion string `gorm:"size:100" json:"visitor_region"` UserAgent string `gorm:"size:500" json:"user_agent"` + // 落地页 / 当前页(访客浏览轨迹) + LandingURL string `gorm:"size:1000" json:"landing_url"` + LandingTitle string `gorm:"size:200" json:"landing_title"` + Referrer string `gorm:"size:1000" json:"referrer"` + CurrentURL string `gorm:"size:1000" json:"current_url"` + CurrentTitle string `gorm:"size:200" json:"current_title"` + // LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态 + LastSeenAt *time.Time `json:"last_seen_at"` LastReadSeq int `gorm:"default:0" json:"last_read_seq"` Status string `gorm:"size:20;default:waiting" json:"status"` Priority string `gorm:"size:20;default:normal" json:"priority"` @@ -95,6 +103,16 @@ type Session struct { UpdatedAt time.Time `json:"updated_at"` } +// VisitorPageView 访客在宿主站的页面浏览记录(按会话)。 +type VisitorPageView struct { + ID uint `gorm:"primaryKey" json:"id"` + SessionID uint `gorm:"index;not null" json:"session_id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + URL string `gorm:"size:1000;not null" json:"url"` + Title string `gorm:"size:200" json:"title"` + EnteredAt time.Time `json:"entered_at"` +} + type Message struct { ID uint `gorm:"primaryKey" json:"id"` SessionID uint `gorm:"not null;uniqueIndex:idx_message_session_seq" json:"session_id"` diff --git a/web/public/widget.js b/web/public/widget.js index 96fc89f..b196b06 100644 --- a/web/public/widget.js +++ b/web/public/widget.js @@ -1,6 +1,8 @@ /** * 客服云访客 Widget 嵌入脚本 * 用法: + * + * 向 iframe 同步宿主页 URL / 标题,并监听 SPA 路由变化,供客服端展示落地页与浏览轨迹。 */ (function () { if (window.__KEFU_WIDGET_LOADED__) return; @@ -28,6 +30,8 @@ var open = false; var iframe = null; + var lastSentURL = ''; + var lastSentAt = 0; var btn = document.createElement('button'); btn.type = 'button'; @@ -51,13 +55,56 @@ 'display:none', 'background:#fff', ].join(';'); + function hostPageInfo() { + var href = ''; + var title = ''; + var ref = ''; + try { + href = String(window.location.href || ''); + title = String(document.title || ''); + ref = String(document.referrer || ''); + } catch (e) { /* ignore */ } + return { url: href, title: title, referrer: ref }; + } + + function buildEmbedURL() { + var info = hostPageInfo(); + var q = + 'channel_key=' + encodeURIComponent(channelKey) + + '&embedded=1' + + '&page_url=' + encodeURIComponent(info.url) + + '&page_title=' + encodeURIComponent(info.title) + + '&referrer=' + encodeURIComponent(info.referrer); + return base + '/widget/embed?' + q; + } + + function postPageToIframe(force) { + if (!iframe || !iframe.contentWindow) return; + var info = hostPageInfo(); + var now = Date.now(); + if (!force && info.url === lastSentURL && now - lastSentAt < 800) return; + lastSentURL = info.url; + lastSentAt = now; + try { + iframe.contentWindow.postMessage({ + type: 'kefu-host-page', + url: info.url, + title: info.title, + referrer: info.referrer, + }, '*'); + } catch (e) { /* ignore */ } + } + function ensureIframe() { if (iframe) return; iframe = document.createElement('iframe'); iframe.title = '在线客服'; iframe.allow = 'clipboard-write'; iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;'; - iframe.src = base + '/widget/embed?channel_key=' + encodeURIComponent(channelKey) + '&embedded=1'; + iframe.src = buildEmbedURL(); + iframe.addEventListener('load', function () { + postPageToIframe(true); + }); panel.appendChild(iframe); } @@ -67,6 +114,7 @@ ensureIframe(); panel.style.display = 'block'; btn.style.display = 'none'; + postPageToIframe(true); } else { panel.style.display = 'none'; btn.style.display = 'flex'; @@ -80,8 +128,45 @@ if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') { setOpen(false); } + // iframe 就绪后可再次同步宿主页 + if (event.data.type === 'kefu-widget-ready') { + postPageToIframe(true); + } }); + // —— SPA / 浏览器导航监听 —— + function onRouteMaybeChanged() { + if (!open) return; + postPageToIframe(false); + } + + try { + var _push = history.pushState; + var _replace = history.replaceState; + history.pushState = function () { + var r = _push.apply(this, arguments); + onRouteMaybeChanged(); + return r; + }; + history.replaceState = function () { + var r = _replace.apply(this, arguments); + onRouteMaybeChanged(); + return r; + }; + } catch (e) { /* ignore */ } + + window.addEventListener('popstate', onRouteMaybeChanged); + window.addEventListener('hashchange', onRouteMaybeChanged); + // 标题可能异步更新 + try { + var titleEl = document.querySelector('title'); + if (titleEl && typeof MutationObserver !== 'undefined') { + new MutationObserver(function () { onRouteMaybeChanged(); }).observe(titleEl, { + subtree: true, characterData: true, childList: true, + }); + } + } catch (e) { /* ignore */ } + function mount() { document.body.appendChild(btn); document.body.appendChild(panel); diff --git a/web/src/pages/WidgetEmbed.tsx b/web/src/pages/WidgetEmbed.tsx index f337796..e669d46 100644 --- a/web/src/pages/WidgetEmbed.tsx +++ b/web/src/pages/WidgetEmbed.tsx @@ -7,6 +7,11 @@ const WidgetEmbed = () => { const [params] = useSearchParams() const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params]) const embedded = params.get('embedded') === '1' + const initialPage = useMemo(() => ({ + url: params.get('page_url') || undefined, + title: params.get('page_title') || undefined, + referrer: params.get('referrer') || undefined, + }), [params]) return (
@@ -15,6 +20,7 @@ const WidgetEmbed = () => { channelKey={channelKey} embedded={embedded} layout={embedded ? 'fill' : 'floating'} + initialPage={initialPage} />
) diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 24fe420..5f90852 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -13,7 +13,7 @@ import { getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply, type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply, - type Session, type SessionEvent, + type Session, type SessionEvent, type VisitorPageView, } from '@/services/api' /** 按 id 合并消息,再按 seq / id 排序 */ @@ -114,9 +114,35 @@ function summarizeDevice(ua?: string) { interface SessionDetail { messages: Message[] events: SessionEvent[] + pageViews: VisitorPageView[] pendingCount: number } +function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string { + if (!startIso) return '—' + const start = new Date(startIso).getTime() + if (!Number.isFinite(start)) return '—' + const end = endIso ? new Date(endIso).getTime() : (nowMs ?? Date.now()) + let sec = Math.max(0, Math.floor((end - start) / 1000)) + const h = Math.floor(sec / 3600) + sec %= 3600 + const m = Math.floor(sec / 60) + const s = sec % 60 + if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` +} + +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 || '/' + } catch { + return url.length > 48 ? `${url.slice(0, 46)}…` : url + } +} + const Dashboard = () => { const { user } = useAuth() const [sessions, setSessions] = useState([]) @@ -154,6 +180,8 @@ const Dashboard = () => { const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all') const [filterOpen, setFilterOpen] = useState(false) const [visitorTyping, setVisitorTyping] = useState(false) + /** 驱动在线读秒每秒刷新 */ + const [clockTick, setClockTick] = useState(0) const initialLoad = useRef(true) const chatEndRef = useRef(null) const fileInputRef = useRef(null) @@ -228,7 +256,12 @@ const Dashboard = () => { const response = await getSession(id) const data = response.data const messages = data.messages || [] - setDetail({ messages, events: data.events || [], pendingCount: data.pending_count || 0 }) + setDetail({ + messages, + events: data.events || [], + pageViews: Array.isArray(data.page_views) ? data.page_views : [], + pendingCount: data.pending_count || 0, + }) rememberMessagesSeq(id, messages) if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq) // 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确 @@ -279,7 +312,7 @@ const Dashboard = () => { latest = batch[batch.length - 1] setDetail(previous => { if (selectedIdRef.current !== id) return previous - if (!previous) return { messages: batch, events: [], pendingCount: 0 } + if (!previous) return { messages: batch, events: [], pageViews: [], pendingCount: 0 } return { ...previous, messages: mergeMessagesBySeq(previous.messages, batch) } }) rememberMessagesSeq(id, batch) @@ -329,7 +362,7 @@ const Dashboard = () => { if (selectedIdRef.current !== sessionId) return previous if (!previous) { appended = true - return { messages: [msg], events: [], pendingCount: 0 } + return { messages: [msg], events: [], pageViews: [], pendingCount: 0 } } if (previous.messages.some(item => item.id === msg.id)) { appended = true @@ -429,6 +462,27 @@ const Dashboard = () => { // 转接/分配/结束等会写 SessionEvent,需全量刷新详情(含 events) if (sameSession) void loadDetailRef.current(sid, false, { silent: true }) } + + if (payload.type === 'page_view' && sameSession && payload.data) { + const pv = payload.data as unknown as VisitorPageView + if (pv?.url) { + setDetail(prev => { + if (!prev) return prev + if (prev.pageViews.some(p => p.id === pv.id)) return prev + return { ...prev, pageViews: [...prev.pageViews, pv] } + }) + 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, + } + : session, + )) + } + } } catch { // ignore malformed frames } @@ -548,6 +602,13 @@ const Dashboard = () => { const selectedCustomer = selected ? customers[selected.customer_id] : null const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') + // 进行中会话:在线时长每秒刷新 + useEffect(() => { + if (!selected || selected.status === 'ended' || selected.status === 'archived') return + const t = window.setInterval(() => setClockTick(n => n + 1), 1000) + return () => clearInterval(t) + }, [selected?.id, selected?.status]) + useEffect(() => { if (!selectedCustomer) { setCustomerHistory([]) @@ -1007,6 +1068,14 @@ const Dashboard = () => { {selected.visitor_region || '未知'} + | + + 在线 {formatOnlineDuration( + selected.created_at, + selected.status === 'ended' || selected.status === 'archived' ? selected.ended_at : null, + clockTick >= 0 ? Date.now() : Date.now(), + )} + @@ -1350,17 +1419,104 @@ const Dashboard = () => { 渠道 {channelLabel(selectedCustomer.source)}在线客服 -
- 来源 - {selectedCustomer.source || '直接访问'} -
设备 {summarizeDevice(selected?.user_agent) || '—'}
+
+ 落地页 + {selected?.landing_url ? ( + + {selected.landing_title || shortPagePath(selected.landing_url)} + + ) : ( + + )} +
+
+ 当前页 + {selected?.current_url ? ( + + {selected.current_title || shortPagePath(selected.current_url)} + + ) : ( + + )} +
+ {selected?.referrer ? ( +
+ 来路 + + {shortPagePath(selected.referrer)} + +
+ ) : null} +
+ 在线 + + {formatOnlineDuration( + selected?.created_at, + selected?.status === 'ended' || selected?.status === 'archived' ? selected?.ended_at : null, + clockTick >= 0 ? Date.now() : Date.now(), + )} + +
+
+
浏览轨迹
+ {!detail?.pageViews?.length ? ( +
暂无页面记录(需嵌入 widget 并打开会话)
+ ) : ( +
+ {detail.pageViews.map((pv, idx) => ( +
0 ? 'border-t border-neutral-50' : ''} ${ + idx === detail.pageViews.length - 1 ? '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 && ( + 当前 + )} +
+ + {pv.title || shortPagePath(pv.url)} + +
+ ))} +
+ )} +
+
历史会话
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index d32ea55..bc7a1e8 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -12,6 +12,12 @@ export interface Session { visitor_ip?: string visitor_region?: string user_agent?: string + landing_url?: string + landing_title?: string + referrer?: string + current_url?: string + current_title?: string + last_seen_at?: string | null created_at: string; ended_at: string | null /** 列表接口补全字段 */ message_count?: number @@ -21,6 +27,15 @@ export interface Session { channel_type?: string } +export interface VisitorPageView { + id: number + session_id: number + tenant_id?: number + url: string + title: string + entered_at: string +} + export interface Message { id: number; session_id: number; sender_type: 'visitor' | 'agent'; sender_id: number | null content: string; type: 'text' | 'image'; seq: number; sent_at: string @@ -178,6 +193,7 @@ export const getSession = (id: number) => get<{ session: Session messages: Message[] events: SessionEvent[] + page_views?: VisitorPageView[] pending_count: number max_seq?: number }>(`/sessions/${id}`) diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index f46055c..376da38 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -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>(async () => {}) const msgsKeyRef = useRef(msgsKey) + /** 宿主页信息(嵌入时由 postMessage / 初始 query 更新) */ + const hostPageRef = useRef({ + 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) {