访客落地页、在线读秒与浏览轨迹
记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 SPA 路由。
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error {
|
||||
&Customer{},
|
||||
&CustomerTag{},
|
||||
&Session{},
|
||||
&VisitorPageView{},
|
||||
&Message{},
|
||||
&SessionEvent{},
|
||||
&Category{},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+86
-1
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* 客服云访客 Widget 嵌入脚本
|
||||
* 用法: <script src="https://your-host/widget.js" data-id="WK_xxxx"></script>
|
||||
*
|
||||
* 向 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);
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-screen w-screen overflow-hidden bg-transparent">
|
||||
@@ -15,6 +20,7 @@ const WidgetEmbed = () => {
|
||||
channelKey={channelKey}
|
||||
embedded={embedded}
|
||||
layout={embedded ? 'fill' : 'floating'}
|
||||
initialPage={initialPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<Session[]>([])
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 = () => {
|
||||
<span className="truncate">
|
||||
{selected.visitor_region || '未知'}
|
||||
</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>
|
||||
@@ -1350,15 +1419,102 @@ const Dashboard = () => {
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">渠道</span>
|
||||
<span className="text-neutral-800">{channelLabel(selectedCustomer.source)}在线客服</span>
|
||||
</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">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">设备</span>
|
||||
<span className="text-neutral-800">{summarizeDevice(selected?.user_agent) || '—'}</span>
|
||||
</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 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">
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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<void>>(async () => {})
|
||||
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(从缓存消息初始化) */
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user