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

记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 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
+78
View File
@@ -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)
}
@@ -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))
}
}
+2
View File
@@ -40,6 +40,8 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
widgetApi.GET("/ws", widget.Connect) widgetApi.GET("/ws", widget.Connect)
widgetApi.POST("/rating", widget.SubmitRating) widgetApi.POST("/rating", widget.SubmitRating)
widgetApi.POST("/upload", upload.WidgetUploadImage) widgetApi.POST("/upload", upload.WidgetUploadImage)
widgetApi.POST("/pageview", widget.PageView)
widgetApi.POST("/heartbeat", widget.Heartbeat)
// 需要认证的接口 // 需要认证的接口
authRequired := api.Group("") authRequired := api.Group("")
+3
View File
@@ -352,6 +352,8 @@ func (h *SessionHandler) Get(c *gin.Context) {
} }
var events []model.SessionEvent var events []model.SessionEvent
model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events) 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 var pendingCount int64
model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount) 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, "session": session,
"messages": messages, "messages": messages,
"events": events, "events": events,
"page_views": pageViews,
"pending_count": pendingCount, "pending_count": pendingCount,
"max_seq": maxSeq, "max_seq": maxSeq,
}) })
+150
View File
@@ -22,6 +22,22 @@ func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} }
type WidgetInitReq struct { type WidgetInitReq struct {
ChannelKey string `json:"channel_key" form:"channel_key"` ChannelKey string `json:"channel_key" form:"channel_key"`
VisitorName string `json:"visitor_name"` 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 { type WidgetMessageReq struct {
@@ -104,6 +120,20 @@ func (h *WidgetHandler) Init(c *gin.Context) {
} }
visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c) 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{ session := model.Session{
@@ -114,6 +144,12 @@ func (h *WidgetHandler) Init(c *gin.Context) {
VisitorIP: visitorIP, VisitorIP: visitorIP,
VisitorRegion: visitorRegion, VisitorRegion: visitorRegion,
UserAgent: userAgent, UserAgent: userAgent,
LandingURL: pageURL,
LandingTitle: pageTitle,
Referrer: referrer,
CurrentURL: pageURL,
CurrentTitle: pageTitle,
LastSeenAt: &now,
Status: "waiting", Status: "waiting",
Priority: "normal", Priority: "normal",
} }
@@ -121,6 +157,16 @@ func (h *WidgetHandler) Init(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"})
return 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). 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()}) 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 { } else if canServeNow {
resp["agent_name"] = agentNickname resp["agent_name"] = agentNickname
} }
resp["landing_url"] = session.LandingURL
resp["current_url"] = session.CurrentURL
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp}) 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 { func visitorTokenFromRequest(c *gin.Context, bodyToken string) string {
if token := c.GetHeader("X-Visitor-Token"); token != "" { if token := c.GetHeader("X-Visitor-Token"); token != "" {
return token return token
+1
View File
@@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error {
&Customer{}, &Customer{},
&CustomerTag{}, &CustomerTag{},
&Session{}, &Session{},
&VisitorPageView{},
&Message{}, &Message{},
&SessionEvent{}, &SessionEvent{},
&Category{}, &Category{},
+18
View File
@@ -84,6 +84,14 @@ type Session struct {
VisitorIP string `gorm:"size:64" json:"visitor_ip"` VisitorIP string `gorm:"size:64" json:"visitor_ip"`
VisitorRegion string `gorm:"size:100" json:"visitor_region"` VisitorRegion string `gorm:"size:100" json:"visitor_region"`
UserAgent string `gorm:"size:500" json:"user_agent"` 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"` LastReadSeq int `gorm:"default:0" json:"last_read_seq"`
Status string `gorm:"size:20;default:waiting" json:"status"` Status string `gorm:"size:20;default:waiting" json:"status"`
Priority string `gorm:"size:20;default:normal" json:"priority"` Priority string `gorm:"size:20;default:normal" json:"priority"`
@@ -95,6 +103,16 @@ type Session struct {
UpdatedAt time.Time `json:"updated_at"` 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 { type Message struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
SessionID uint `gorm:"not null;uniqueIndex:idx_message_session_seq" json:"session_id"` SessionID uint `gorm:"not null;uniqueIndex:idx_message_session_seq" json:"session_id"`
+86 -1
View File
@@ -1,6 +1,8 @@
/** /**
* 客服云访客 Widget 嵌入脚本 * 客服云访客 Widget 嵌入脚本
* 用法: <script src="https://your-host/widget.js" data-id="WK_xxxx"></script> * 用法: <script src="https://your-host/widget.js" data-id="WK_xxxx"></script>
*
* 向 iframe 同步宿主页 URL / 标题,并监听 SPA 路由变化,供客服端展示落地页与浏览轨迹。
*/ */
(function () { (function () {
if (window.__KEFU_WIDGET_LOADED__) return; if (window.__KEFU_WIDGET_LOADED__) return;
@@ -28,6 +30,8 @@
var open = false; var open = false;
var iframe = null; var iframe = null;
var lastSentURL = '';
var lastSentAt = 0;
var btn = document.createElement('button'); var btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
@@ -51,13 +55,56 @@
'display:none', 'background:#fff', 'display:none', 'background:#fff',
].join(';'); ].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() { function ensureIframe() {
if (iframe) return; if (iframe) return;
iframe = document.createElement('iframe'); iframe = document.createElement('iframe');
iframe.title = '在线客服'; iframe.title = '在线客服';
iframe.allow = 'clipboard-write'; iframe.allow = 'clipboard-write';
iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;'; 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); panel.appendChild(iframe);
} }
@@ -67,6 +114,7 @@
ensureIframe(); ensureIframe();
panel.style.display = 'block'; panel.style.display = 'block';
btn.style.display = 'none'; btn.style.display = 'none';
postPageToIframe(true);
} else { } else {
panel.style.display = 'none'; panel.style.display = 'none';
btn.style.display = 'flex'; btn.style.display = 'flex';
@@ -80,8 +128,45 @@
if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') { if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') {
setOpen(false); 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() { function mount() {
document.body.appendChild(btn); document.body.appendChild(btn);
document.body.appendChild(panel); document.body.appendChild(panel);
+6
View File
@@ -7,6 +7,11 @@ const WidgetEmbed = () => {
const [params] = useSearchParams() const [params] = useSearchParams()
const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params]) const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params])
const embedded = params.get('embedded') === '1' 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 ( return (
<div className="h-screen w-screen overflow-hidden bg-transparent"> <div className="h-screen w-screen overflow-hidden bg-transparent">
@@ -15,6 +20,7 @@ const WidgetEmbed = () => {
channelKey={channelKey} channelKey={channelKey}
embedded={embedded} embedded={embedded}
layout={embedded ? 'fill' : 'floating'} layout={embedded ? 'fill' : 'floating'}
initialPage={initialPage}
/> />
</div> </div>
) )
+164 -8
View File
@@ -13,7 +13,7 @@ import {
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply, suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply,
type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply, type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply,
type Session, type SessionEvent, type Session, type SessionEvent, type VisitorPageView,
} from '@/services/api' } from '@/services/api'
/** 按 id 合并消息,再按 seq / id 排序 */ /** 按 id 合并消息,再按 seq / id 排序 */
@@ -114,9 +114,35 @@ function summarizeDevice(ua?: string) {
interface SessionDetail { interface SessionDetail {
messages: Message[] messages: Message[]
events: SessionEvent[] events: SessionEvent[]
pageViews: VisitorPageView[]
pendingCount: number 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 Dashboard = () => {
const { user } = useAuth() const { user } = useAuth()
const [sessions, setSessions] = useState<Session[]>([]) const [sessions, setSessions] = useState<Session[]>([])
@@ -154,6 +180,8 @@ const Dashboard = () => {
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all') const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
const [filterOpen, setFilterOpen] = useState(false) const [filterOpen, setFilterOpen] = useState(false)
const [visitorTyping, setVisitorTyping] = useState(false) const [visitorTyping, setVisitorTyping] = useState(false)
/** 驱动在线读秒每秒刷新 */
const [clockTick, setClockTick] = useState(0)
const initialLoad = useRef(true) const initialLoad = useRef(true)
const chatEndRef = useRef<HTMLDivElement>(null) const chatEndRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -228,7 +256,12 @@ const Dashboard = () => {
const response = await getSession(id) const response = await getSession(id)
const data = response.data const data = response.data
const messages = data.messages || [] 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) rememberMessagesSeq(id, messages)
if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq) if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq)
// 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确 // 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确
@@ -279,7 +312,7 @@ const Dashboard = () => {
latest = batch[batch.length - 1] latest = batch[batch.length - 1]
setDetail(previous => { setDetail(previous => {
if (selectedIdRef.current !== id) return 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) } return { ...previous, messages: mergeMessagesBySeq(previous.messages, batch) }
}) })
rememberMessagesSeq(id, batch) rememberMessagesSeq(id, batch)
@@ -329,7 +362,7 @@ const Dashboard = () => {
if (selectedIdRef.current !== sessionId) return previous if (selectedIdRef.current !== sessionId) return previous
if (!previous) { if (!previous) {
appended = true appended = true
return { messages: [msg], events: [], pendingCount: 0 } return { messages: [msg], events: [], pageViews: [], pendingCount: 0 }
} }
if (previous.messages.some(item => item.id === msg.id)) { if (previous.messages.some(item => item.id === msg.id)) {
appended = true appended = true
@@ -429,6 +462,27 @@ const Dashboard = () => {
// 转接/分配/结束等会写 SessionEvent,需全量刷新详情(含 events) // 转接/分配/结束等会写 SessionEvent,需全量刷新详情(含 events)
if (sameSession) void loadDetailRef.current(sid, false, { silent: true }) 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 { } catch {
// ignore malformed frames // ignore malformed frames
} }
@@ -548,6 +602,13 @@ const Dashboard = () => {
const selectedCustomer = selected ? customers[selected.customer_id] : null const selectedCustomer = selected ? customers[selected.customer_id] : null
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') 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(() => { useEffect(() => {
if (!selectedCustomer) { if (!selectedCustomer) {
setCustomerHistory([]) setCustomerHistory([])
@@ -1007,6 +1068,14 @@ const Dashboard = () => {
<span className="truncate"> <span className="truncate">
{selected.visitor_region || '未知'} {selected.visitor_region || '未知'}
</span> </span>
<span className="shrink-0 text-neutral-300">|</span>
<span className="tabular-nums shrink-0" title="访客在线时长(自进线起)">
线 {formatOnlineDuration(
selected.created_at,
selected.status === 'ended' || selected.status === 'archived' ? selected.ended_at : null,
clockTick >= 0 ? Date.now() : Date.now(),
)}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1350,17 +1419,104 @@ const Dashboard = () => {
<span className="shrink-0 text-neutral-400 min-w-12"></span> <span className="shrink-0 text-neutral-400 min-w-12"></span>
<span className="text-neutral-800">{channelLabel(selectedCustomer.source)}线</span> <span className="text-neutral-800">{channelLabel(selectedCustomer.source)}线</span>
</div> </div>
<div className="flex items-start gap-2">
<span className="shrink-0 text-neutral-400 min-w-12"></span>
<span className="text-neutral-800">{selectedCustomer.source || '直接访问'}</span>
</div>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="shrink-0 text-neutral-400 min-w-12"></span> <span className="shrink-0 text-neutral-400 min-w-12"></span>
<span className="text-neutral-800">{summarizeDevice(selected?.user_agent) || '—'}</span> <span className="text-neutral-800">{summarizeDevice(selected?.user_agent) || '—'}</span>
</div> </div>
<div className="flex items-start gap-2 min-w-0">
<span className="shrink-0 text-neutral-400 min-w-12"></span>
{selected?.landing_url ? (
<a
href={selected.landing_url}
target="_blank"
rel="noreferrer"
className="text-[#2563eb] hover:underline break-all min-w-0"
title={selected.landing_title || selected.landing_url}
>
{selected.landing_title || shortPagePath(selected.landing_url)}
</a>
) : (
<span className="text-neutral-400"></span>
)}
</div>
<div className="flex items-start gap-2 min-w-0">
<span className="shrink-0 text-neutral-400 min-w-12"></span>
{selected?.current_url ? (
<a
href={selected.current_url}
target="_blank"
rel="noreferrer"
className="text-neutral-800 hover:text-[#2563eb] hover:underline break-all min-w-0"
title={selected.current_title || selected.current_url}
>
{selected.current_title || shortPagePath(selected.current_url)}
</a>
) : (
<span className="text-neutral-400"></span>
)}
</div>
{selected?.referrer ? (
<div className="flex items-start gap-2 min-w-0">
<span className="shrink-0 text-neutral-400 min-w-12"></span>
<a
href={selected.referrer}
target="_blank"
rel="noreferrer"
className="text-neutral-500 hover:underline break-all text-xs min-w-0"
>
{shortPagePath(selected.referrer)}
</a>
</div>
) : null}
<div className="flex items-start gap-2">
<span className="shrink-0 text-neutral-400 min-w-12">线</span>
<span className="text-neutral-800 tabular-nums font-medium">
{formatOnlineDuration(
selected?.created_at,
selected?.status === 'ended' || selected?.status === 'archived' ? selected?.ended_at : null,
clockTick >= 0 ? Date.now() : Date.now(),
)}
</span>
</div>
</div> </div>
</div> </div>
<div className="mb-5">
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider"></div>
{!detail?.pageViews?.length ? (
<div className="text-xs text-neutral-400"> widget </div>
) : (
<div className="flex flex-col gap-0 max-h-48 overflow-y-auto rounded-lg border border-neutral-100">
{detail.pageViews.map((pv, idx) => (
<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'
}`}
>
<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 && (
<span className="text-[10px] text-[#2563eb] font-medium"></span>
)}
</div>
<a
href={pv.url}
target="_blank"
rel="noreferrer"
className="text-neutral-800 hover:text-[#2563eb] break-all leading-snug"
title={pv.url}
>
{pv.title || shortPagePath(pv.url)}
</a>
</div>
))}
</div>
)}
</div>
<div className="mb-5"> <div className="mb-5">
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider"></div> <div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider"></div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
+16
View File
@@ -12,6 +12,12 @@ export interface Session {
visitor_ip?: string visitor_ip?: string
visitor_region?: string visitor_region?: string
user_agent?: 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 created_at: string; ended_at: string | null
/** 列表接口补全字段 */ /** 列表接口补全字段 */
message_count?: number message_count?: number
@@ -21,6 +27,15 @@ export interface Session {
channel_type?: string channel_type?: string
} }
export interface VisitorPageView {
id: number
session_id: number
tenant_id?: number
url: string
title: string
entered_at: string
}
export interface Message { export interface Message {
id: number; session_id: number; sender_type: 'visitor' | 'agent'; sender_id: number | null id: number; session_id: number; sender_type: 'visitor' | 'agent'; sender_id: number | null
content: string; type: 'text' | 'image'; seq: number; sent_at: string content: string; type: 'text' | 'image'; seq: number; sent_at: string
@@ -178,6 +193,7 @@ export const getSession = (id: number) => get<{
session: Session session: Session
messages: Message[] messages: Message[]
events: SessionEvent[] events: SessionEvent[]
page_views?: VisitorPageView[]
pending_count: number pending_count: number
max_seq?: number max_seq?: number
}>(`/sessions/${id}`) }>(`/sessions/${id}`)
+101 -4
View File
@@ -48,12 +48,16 @@ function parseWelcomeSegments(data: {
type LayoutMode = 'floating' | 'fill' type LayoutMode = 'floating' | 'fill'
export type HostPageInfo = { url?: string; title?: string; referrer?: string }
interface VisitorChatProps { interface VisitorChatProps {
defaultOpen?: boolean defaultOpen?: boolean
channelKey?: string channelKey?: string
/** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */ /** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */
embedded?: boolean embedded?: boolean
layout?: LayoutMode layout?: LayoutMode
/** 宿主页初始 URL(由 widget.js 经 query 传入) */
initialPage?: HostPageInfo
} }
const VisitorChat = ({ const VisitorChat = ({
@@ -61,6 +65,7 @@ const VisitorChat = ({
channelKey = 'WK_8a3f2e', channelKey = 'WK_8a3f2e',
embedded = false, embedded = false,
layout = 'floating', layout = 'floating',
initialPage,
}: VisitorChatProps) => { }: VisitorChatProps) => {
const storageKey = useMemo(() => `kefu_widget_session_${channelKey}`, [channelKey]) const storageKey = useMemo(() => `kefu_widget_session_${channelKey}`, [channelKey])
const tokenKey = useMemo(() => `kefu_widget_visitor_token_${channelKey}`, [channelKey]) const tokenKey = useMemo(() => `kefu_widget_visitor_token_${channelKey}`, [channelKey])
@@ -124,6 +129,13 @@ const VisitorChat = ({
const agentNameRef = useRef(agentName) const agentNameRef = useRef(agentName)
const loadMessagesRef = useRef<(sid?: number, token?: string, opts?: { afterSeq?: number; full?: boolean }) => Promise<void>>(async () => {}) const loadMessagesRef = useRef<(sid?: number, token?: string, opts?: { afterSeq?: number; full?: boolean }) => Promise<void>>(async () => {})
const msgsKeyRef = useRef(msgsKey) 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(从缓存消息初始化) */ /** 本地已同步到的最大 seq(从缓存消息初始化) */
const lastSeqRef = useRef((() => { const lastSeqRef = useRef((() => {
try { try {
@@ -300,12 +312,45 @@ const VisitorChat = ({
return true 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 创建新会话并写入本地状态(不复用已结束会话) */ /** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */
const bootstrapNewSession = useCallback(async () => { const bootstrapNewSession = useCallback(async () => {
const res = await fetch( const page = hostPageRef.current
`/api/widget/init?channel_key=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`, const res = await fetch('/api/widget/init', {
{ method: 'POST' }, 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() const json = await res.json()
if (json.code !== 0) { if (json.code !== 0) {
throw new Error(json.message || '初始化会话失败') throw new Error(json.message || '初始化会话失败')
@@ -333,6 +378,7 @@ const VisitorChat = ({
localStorage.setItem(tokenKey, token) localStorage.setItem(tokenKey, token)
localStorage.removeItem(msgsKey) localStorage.removeItem(msgsKey)
lastSeqRef.current = 0 lastSeqRef.current = 0
if (page.url) lastPageURLRef.current = page.url
await loadMessages(sid, token, { full: true }) await loadMessages(sid, token, { full: true })
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey]) }, [channelKey, loadMessages, storageKey, tokenKey, msgsKey])
@@ -417,6 +463,57 @@ const VisitorChat = ({
if (open) initSession() if (open) initSession()
}, [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 增量对齐 // 兜底轮询:按 after_seq 增量对齐
useEffect(() => { useEffect(() => {
if (sessionId && visitorToken && open) { if (sessionId && visitorToken && open) {