访客落地页、在线读秒与浏览轨迹
记录进线页面与换页路径,心跳刷新活跃时间;工作台展示落地页/当前页/轨迹时间线与在线时长,嵌入脚本同步宿主 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.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("")
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&Customer{},
|
&Customer{},
|
||||||
&CustomerTag{},
|
&CustomerTag{},
|
||||||
&Session{},
|
&Session{},
|
||||||
|
&VisitorPageView{},
|
||||||
&Message{},
|
&Message{},
|
||||||
&SessionEvent{},
|
&SessionEvent{},
|
||||||
&Category{},
|
&Category{},
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,15 +1419,102 @@ 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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user