From 9485c28abd5e506a7122ec083f5df1f0342b7bd3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 11:09:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20P0=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=86=E9=85=8D=E3=80=81=E7=A6=BB=E7=BA=BF=E7=95=99=E8=A8=80?= =?UTF-8?q?=E4=B8=8E=E5=8F=AF=E5=B5=8C=E5=85=A5=20Widget=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话创建时按负载自动分配在线客服,无客服则进入离线模式 - 新增 /api/widget/leave-message 沉淀联系方式与留言事件 - 访客端离线留言表单;工作台展示离线留言/自动分配事件 - 提供 public/widget.js + /widget/embed 嵌入方案 --- server/cmd/seed/main.go | 2 +- server/internal/handler/assignment.go | 96 +++ server/internal/handler/router.go | 1 + .../handler/security_integration_test.go | 102 +++ server/internal/handler/widget.go | 183 ++++- web/public/widget.js | 100 +++ web/src/pages/WidgetEmbed.tsx | 23 + web/src/pages/WidgetPreview.tsx | 27 +- web/src/pages/agent/Dashboard.tsx | 27 +- web/src/router/index.tsx | 2 + web/src/widgets/VisitorChat.tsx | 649 +++++++++++------- 11 files changed, 930 insertions(+), 282 deletions(-) create mode 100644 server/internal/handler/assignment.go create mode 100644 web/public/widget.js create mode 100644 web/src/pages/WidgetEmbed.tsx diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go index 2240ef8..8a1ba8a 100644 --- a/server/cmd/seed/main.go +++ b/server/cmd/seed/main.go @@ -68,7 +68,7 @@ func seed() { // Channels for Tenant 1 channels := []model.Channel{ - {TenantID: tenants[0].ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: ``}, + {TenantID: tenants[0].ID, Type: "web", Name: "网页聊天", Status: "enabled", ScriptCode: ``}, {TenantID: tenants[0].ID, Type: "wechat", Name: "微信公众号", Status: "enabled"}, {TenantID: tenants[0].ID, Type: "app", Name: "APP内嵌", Status: "disabled"}, } diff --git a/server/internal/handler/assignment.go b/server/internal/handler/assignment.go new file mode 100644 index 0000000..6448b0e --- /dev/null +++ b/server/internal/handler/assignment.go @@ -0,0 +1,96 @@ +package handler + +import ( + "fmt" + + "kefu-sys/server/internal/model" + "kefu-sys/server/internal/ws" +) + +const offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。" + +// pickLeastLoadedAgent 在在线一线客服中选择当前进行中会话最少的一位。 +func pickLeastLoadedAgent(tenantID uint) (*model.User, error) { + var agents []model.User + if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online"). + Order("id asc").Find(&agents).Error; err != nil { + return nil, err + } + if len(agents) == 0 { + return nil, nil + } + + best := &agents[0] + bestCount := int64(-1) + for i := range agents { + var count int64 + if err := model.DB.Model(&model.Session{}). + Where("tenant_id = ? AND agent_id = ? AND status = ?", tenantID, agents[i].ID, "active"). + Count(&count).Error; err != nil { + return nil, err + } + if bestCount < 0 || count < bestCount { + bestCount = count + best = &agents[i] + } + } + return best, nil +} + +// countOnlineAgents 统计租户当前可接待的在线客服数。 +func countOnlineAgents(tenantID uint) (int64, error) { + var count int64 + err := model.DB.Model(&model.User{}). + Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online"). + Count(&count).Error + return count, err +} + +// tryAutoAssign 将会话自动分配给负载最低的在线客服;无可分配客服时返回 nil。 +func tryAutoAssign(session *model.Session) (*model.User, error) { + if session == nil || session.ID == 0 { + return nil, nil + } + if session.Status != "waiting" || session.AgentID != nil { + return nil, nil + } + + agent, err := pickLeastLoadedAgent(session.TenantID) + if err != nil || agent == nil { + return nil, err + } + + result := model.DB.Model(&model.Session{}). + Where("id = ? AND tenant_id = ? AND status = ? AND agent_id IS NULL", session.ID, session.TenantID, "waiting"). + Updates(map[string]interface{}{ + "agent_id": agent.ID, + "status": "active", + "last_read_seq": 0, + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + // 并发下可能已被他人领取 + if err := model.DB.First(session, session.ID).Error; err != nil { + return nil, err + } + return nil, nil + } + + session.AgentID = &agent.ID + session.Status = "active" + detail := fmt.Sprintf("系统自动分配给 %s", agent.Nickname) + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: 0, + Action: "auto_assign", + Detail: detail, + }) + + if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + ws.DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload) + } + return agent, nil +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 7ad2d2a..9ff44b3 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -25,6 +25,7 @@ func SetupRoutes(r *gin.Engine) { widgetApi.POST("/init", widget.Init) widgetApi.GET("/init", widget.Init) widgetApi.POST("/message", widget.SendMessage) + widgetApi.POST("/leave-message", widget.LeaveMessage) widgetApi.GET("/messages", widget.GetMessages) widgetApi.GET("/ws", widget.Connect) widgetApi.POST("/rating", widget.SubmitRating) diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 1a12da2..1caabca 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -438,3 +438,105 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) { t.Fatalf("会话领取/转接/已读状态错误: %+v", savedSession) } } + +func TestWidgetAutoAssignAndOfflineLeave(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "自动分配租户", "normal") + channel := model.Channel{ + TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled", + ScriptCode: ``, + } + if err := model.DB.Create(&channel).Error; err != nil { + t.Fatalf("创建渠道失败: %v", err) + } + + // 无在线客服 → 离线模式 + initRecorder := httptest.NewRecorder() + initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_auto_001","visitor_name":"离线访客"}`)) + initReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(initRecorder, initReq) + if initRecorder.Code != http.StatusOK { + t.Fatalf("离线初始化失败: %s", initRecorder.Body.String()) + } + var offlineInit struct { + Data struct { + SessionID uint `json:"session_id"` + VisitorToken string `json:"visitor_token"` + AgentsOnline bool `json:"agents_online"` + OfflinePrompt string `json:"offline_prompt"` + SessionStatus string `json:"session_status"` + } `json:"data"` + } + if err := json.Unmarshal(initRecorder.Body.Bytes(), &offlineInit); err != nil { + t.Fatalf("解析离线初始化失败: %v", err) + } + if offlineInit.Data.AgentsOnline || offlineInit.Data.OfflinePrompt == "" || offlineInit.Data.SessionStatus != "waiting" { + t.Fatalf("期望离线等待会话: %+v", offlineInit.Data) + } + + leaveRecorder := httptest.NewRecorder() + leaveBody := fmt.Sprintf(`{"session_id":%d,"content":"请回电处理订单问题","name":"张留言","phone":"13800138000","email":"leave@example.com"}`, offlineInit.Data.SessionID) + leaveReq := httptest.NewRequest(http.MethodPost, "/api/widget/leave-message", bytes.NewBufferString(leaveBody)) + leaveReq.Header.Set("Content-Type", "application/json") + leaveReq.Header.Set("X-Visitor-Token", offlineInit.Data.VisitorToken) + router.ServeHTTP(leaveRecorder, leaveReq) + if leaveRecorder.Code != http.StatusOK { + t.Fatalf("离线留言失败: %s", leaveRecorder.Body.String()) + } + var session model.Session + if err := model.DB.First(&session, offlineInit.Data.SessionID).Error; err != nil { + t.Fatalf("查询会话失败: %v", err) + } + var customer model.Customer + if err := model.DB.First(&customer, session.CustomerID).Error; err != nil { + t.Fatalf("查询客户失败: %v", err) + } + if customer.Phone != "13800138000" || customer.Email != "leave@example.com" || customer.Name != "张留言" { + t.Fatalf("联系方式未沉淀: %+v", customer) + } + var msgCount int64 + model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Count(&msgCount) + if msgCount != 1 { + t.Fatalf("留言消息数 = %d", msgCount) + } + var event model.SessionEvent + if err := model.DB.Where("session_id = ? AND action = ?", session.ID, "offline_leave").First(&event).Error; err != nil { + t.Fatalf("未记录离线留言事件: %v", err) + } + + // 有在线客服 → 自动分配 + agent := createUser(t, tenant.ID, "auto-agent-1", "agent") + init2 := httptest.NewRecorder() + init2Req := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_auto_001","visitor_name":"在线访客"}`)) + init2Req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(init2, init2Req) + if init2.Code != http.StatusOK { + t.Fatalf("在线初始化失败: %s", init2.Body.String()) + } + var onlineInit struct { + Data struct { + SessionID uint `json:"session_id"` + AgentsOnline bool `json:"agents_online"` + SessionStatus string `json:"session_status"` + AgentID uint `json:"agent_id"` + AgentName string `json:"agent_name"` + } `json:"data"` + } + if err := json.Unmarshal(init2.Body.Bytes(), &onlineInit); err != nil { + t.Fatalf("解析在线初始化失败: %v", err) + } + if !onlineInit.Data.AgentsOnline || onlineInit.Data.SessionStatus != "active" || onlineInit.Data.AgentID != agent.ID { + t.Fatalf("期望自动分配给在线客服: %+v agent=%d", onlineInit.Data, agent.ID) + } + var assigned model.Session + if err := model.DB.First(&assigned, onlineInit.Data.SessionID).Error; err != nil { + t.Fatalf("查询已分配会话失败: %v", err) + } + if assigned.AgentID == nil || *assigned.AgentID != agent.ID || assigned.Status != "active" { + t.Fatalf("会话分配状态不正确: %+v", assigned) + } + var assignEvent model.SessionEvent + if err := model.DB.Where("session_id = ? AND action = ?", assigned.ID, "auto_assign").First(&assignEvent).Error; err != nil { + t.Fatalf("未记录自动分配事件: %v", err) + } +} diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index efe8338..928f0d2 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -38,7 +38,18 @@ type WidgetRatingReq struct { VisitorToken string `json:"visitor_token"` } +type WidgetLeaveReq struct { + SessionID uint `json:"session_id" binding:"required"` + Content string `json:"content" binding:"required"` + Name string `json:"name"` + Phone string `json:"phone"` + Email string `json:"email"` + VisitorToken string `json:"visitor_token"` +} + var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`) +var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) +var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) func (h *WidgetHandler) Init(c *gin.Context) { var req WidgetInitReq @@ -68,7 +79,7 @@ func (h *WidgetHandler) Init(c *gin.Context) { return } - name := req.VisitorName + name := strings.TrimSpace(req.VisitorName) if name == "" { name = "访客" } @@ -107,20 +118,45 @@ func (h *WidgetHandler) Init(c *gin.Context) { } 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()}) + + onlineCount, err := countOnlineAgents(channel.TenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"}) + return + } + + var assignedAgent *model.User + if onlineCount > 0 { + assignedAgent, err = tryAutoAssign(&session) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "自动分配失败"}) + return + } + } + if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "data": gin.H{ - "session_id": session.ID, - "customer_id": customer.ID, - "channel_id": channel.ID, - "tenant_id": channel.TenantID, - "visitor_token": visitorToken, - }, - }) + agentsOnline := onlineCount > 0 + resp := gin.H{ + "session_id": session.ID, + "customer_id": customer.ID, + "channel_id": channel.ID, + "tenant_id": channel.TenantID, + "visitor_token": visitorToken, + "agents_online": agentsOnline, + "session_status": session.Status, + } + if !agentsOnline { + resp["offline_prompt"] = offlineLeavePrompt + } + if assignedAgent != nil { + resp["agent_id"] = assignedAgent.ID + resp["agent_name"] = assignedAgent.Nickname + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp}) } func visitorTokenFromRequest(c *gin.Context, bodyToken string) string { @@ -182,6 +218,14 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { return } model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now()) + + // 仍在排队时尝试自动分配(客服刚上线的场景) + if session.Status == "waiting" && session.AgentID == nil { + if _, err := tryAutoAssign(session); err == nil { + _ = model.DB.First(session, session.ID) + } + } + if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { if session.AgentID == nil { // 等待会话的消息要通知全部客服,便于任一在线客服及时领取。 @@ -194,6 +238,123 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg}) } +// LeaveMessage 无客服在线时提交留言并沉淀联系方式。 +func (h *WidgetHandler) LeaveMessage(c *gin.Context) { + var req WidgetLeaveReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + content, err := validateMessageContent("text", req.Content) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + name := strings.TrimSpace(req.Name) + phone := strings.TrimSpace(req.Phone) + email := strings.TrimSpace(req.Email) + if phone == "" && email == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请至少填写手机号或邮箱,便于我们回复"}) + return + } + if phone != "" && !phonePattern.MatchString(phone) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "手机号格式不正确"}) + return + } + if email != "" && !emailPattern.MatchString(email) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "邮箱格式不正确"}) + return + } + if name != "" && (len([]rune(name)) < 2 || len([]rune(name)) > 50) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "姓名需为 2 至 50 个字符"}) + return + } + + session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) + if !ok { + return + } + if session.Status == "ended" || session.Status == "archived" { + c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"}) + return + } + + // 若此刻已有客服在线,直接走自动分配,留言仍作为普通消息入库 + onlineCount, _ := countOnlineAgents(session.TenantID) + if onlineCount > 0 && session.Status == "waiting" { + if _, err := tryAutoAssign(session); err == nil { + _ = model.DB.First(session, session.ID) + } + } + + updates := map[string]interface{}{"last_contact_at": time.Now()} + if name != "" { + updates["name"] = name + } + if phone != "" { + updates["phone"] = phone + } + if email != "" { + updates["email"] = email + } + if err := model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID). + Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新联系方式失败"}) + return + } + + custID := session.CustomerID + msg := model.Message{ + SessionID: session.ID, + SenderType: "visitor", + SenderID: &custID, + Content: content, + Type: "text", + SentAt: time.Now(), + } + if err := model.CreateMessage(&msg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "留言失败"}) + return + } + + contactBits := make([]string, 0, 3) + if name != "" { + contactBits = append(contactBits, "姓名:"+name) + } + if phone != "" { + contactBits = append(contactBits, "手机:"+phone) + } + if email != "" { + contactBits = append(contactBits, "邮箱:"+email) + } + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: 0, + Action: "offline_leave", + Detail: "离线留言 · " + strings.Join(contactBits, " · "), + }) + + if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { + if session.AgentID == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } else { + ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + } + } + if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "data": gin.H{ + "message": msg, + "session_status": session.Status, + "agents_online": onlineCount > 0, + }, + }) +} + func visitorTokenFromWebSocket(c *gin.Context) string { protocols := strings.Split(c.GetHeader("Sec-WebSocket-Protocol"), ",") if len(protocols) == 2 && strings.TrimSpace(protocols[0]) == "kefu-visitor-v1" { diff --git a/web/public/widget.js b/web/public/widget.js new file mode 100644 index 0000000..96fc89f --- /dev/null +++ b/web/public/widget.js @@ -0,0 +1,100 @@ +/** + * 客服云访客 Widget 嵌入脚本 + * 用法: + */ +(function () { + if (window.__KEFU_WIDGET_LOADED__) return; + window.__KEFU_WIDGET_LOADED__ = true; + + var script = document.currentScript; + if (!script) { + var scripts = document.getElementsByTagName('script'); + script = scripts[scripts.length - 1]; + } + var channelKey = (script && script.getAttribute('data-id')) || ''; + if (!channelKey) { + console.warn('[kefu-widget] missing data-id on script tag'); + return; + } + + var src = script && script.src ? script.src : ''; + var base = ''; + try { + var u = new URL(src, window.location.href); + base = u.origin; + } catch (e) { + base = window.location.origin; + } + + var open = false; + var iframe = null; + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.setAttribute('aria-label', '打开在线客服'); + btn.style.cssText = [ + 'position:fixed', 'right:24px', 'bottom:24px', 'z-index:2147483000', + 'width:56px', 'height:56px', 'border:none', 'border-radius:9999px', + 'background:#2563eb', 'color:#fff', 'cursor:pointer', + 'box-shadow:0 8px 24px rgba(0,0,0,0.12)', + 'display:flex', 'align-items:center', 'justify-content:center', + 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif', + ].join(';'); + btn.innerHTML = ''; + + var panel = document.createElement('div'); + panel.style.cssText = [ + 'position:fixed', 'right:24px', 'bottom:24px', 'z-index:2147483001', + 'width:400px', 'height:600px', 'max-width:calc(100vw - 32px)', 'max-height:calc(100vh - 32px)', + 'border-radius:12px', 'overflow:hidden', + 'box-shadow:0 8px 24px rgba(0,0,0,0.12)', + 'display:none', 'background:#fff', + ].join(';'); + + 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'; + panel.appendChild(iframe); + } + + function setOpen(next) { + open = next; + if (open) { + ensureIframe(); + panel.style.display = 'block'; + btn.style.display = 'none'; + } else { + panel.style.display = 'none'; + btn.style.display = 'flex'; + } + } + + btn.addEventListener('click', function () { setOpen(true); }); + + window.addEventListener('message', function (event) { + if (!event || !event.data) return; + if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') { + setOpen(false); + } + }); + + function mount() { + document.body.appendChild(btn); + document.body.appendChild(panel); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount); + } else { + mount(); + } + + window.KefuWidget = { + open: function () { setOpen(true); }, + close: function () { setOpen(false); }, + channelKey: channelKey, + }; +})(); diff --git a/web/src/pages/WidgetEmbed.tsx b/web/src/pages/WidgetEmbed.tsx new file mode 100644 index 0000000..f337796 --- /dev/null +++ b/web/src/pages/WidgetEmbed.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react' +import { useSearchParams } from 'react-router-dom' +import VisitorChat from '@/widgets/VisitorChat' + +/** 供 widget.js iframe 嵌入的无边框页面 */ +const WidgetEmbed = () => { + const [params] = useSearchParams() + const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params]) + const embedded = params.get('embedded') === '1' + + return ( +
+ +
+ ) +} + +export default WidgetEmbed diff --git a/web/src/pages/WidgetPreview.tsx b/web/src/pages/WidgetPreview.tsx index 9b8bb4b..b33073d 100644 --- a/web/src/pages/WidgetPreview.tsx +++ b/web/src/pages/WidgetPreview.tsx @@ -1,9 +1,10 @@ import VisitorChat from '@/widgets/VisitorChat' const WidgetPreview = () => { + const origin = typeof window !== 'undefined' ? window.location.origin : 'https://your-host' + return (
- {/* 模拟宿主站点骨架,贴近设计稿模糊背景 */}
@@ -15,16 +16,28 @@ const WidgetPreview = () => {
-
-
-
-

访客 Widget 预览

-

右下角为可嵌入聊天组件 · 消息会写入数据库

+ +
+
+

访客 Widget 预览

+

+ 右下角为聊天组件。任意站点可嵌入下方脚本(iframe 版 SDK)。 +

+
+            {``}
+          
+

+ 也可打开独立嵌入页: + + /widget/embed + +

- + +
) } diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 49c2823..ff03006 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -253,7 +253,7 @@ const Dashboard = () => { return tb - ta }) - const notes = detail?.events.filter(event => event.action === 'note').slice().reverse() || [] + const notes = detail?.events.filter(event => event.action === 'note' || event.action === 'offline_leave' || event.action === 'auto_assign').slice().reverse() || [] const emitTyping = () => { if (!selectedId || !canOperate) return @@ -899,12 +899,25 @@ const Dashboard = () => {
{notes.length === 0 ? (
暂无内部备注
- ) : notes.map(note => ( -
- {note.detail} -
{new Date(note.created_at).toLocaleString('zh-CN')}
-
- ))} + ) : notes.map(note => { + const isLeave = note.action === 'offline_leave' + const isAssign = note.action === 'auto_assign' + const box = isLeave + ? 'bg-orange-50 text-orange-900 border-orange-100' + : isAssign + ? 'bg-blue-50 text-blue-900 border-blue-100' + : 'bg-amber-50 text-amber-900 border-amber-100' + const timeCls = isLeave ? 'text-orange-600/70' : isAssign ? 'text-blue-600/70' : 'text-amber-600/70' + return ( +
+ {(isLeave || isAssign) && ( +
{isLeave ? '离线留言' : '自动分配'}
+ )} + {note.detail} +
{new Date(note.created_at).toLocaleString('zh-CN')}
+
+ ) + })}
{canOperate && (
diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 4466c8f..8279a1f 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -17,6 +17,7 @@ const Tenants = lazy(() => import('@/pages/admin/Tenants')) const Plans = lazy(() => import('@/pages/admin/Plans')) const Ops = lazy(() => import('@/pages/admin/Ops')) const WidgetPreview = lazy(() => import('@/pages/WidgetPreview')) +const WidgetEmbed = lazy(() => import('@/pages/WidgetEmbed')) const loading = (
@@ -31,6 +32,7 @@ function Lazy({ children }: { children: React.ReactNode }) { export const router = createBrowserRouter([ { path: '/login', element: }, { path: '/widget/preview', element: }, + { path: '/widget/embed', element: }, { path: '/', element: , diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index fe35307..35be5be 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined, StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined, @@ -12,19 +12,36 @@ interface Message { type?: 'text' | 'image' } -const quickQuestions = ['查询订单状态', '退换货政策', '配送时效说明'] -const STORAGE_KEY = 'kefu_widget_session' -const VISITOR_TOKEN_KEY = 'kefu_widget_visitor_token' +const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明'] -const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { - const [open, setOpen] = useState(defaultOpen) +type LayoutMode = 'floating' | 'fill' + +interface VisitorChatProps { + defaultOpen?: boolean + channelKey?: string + /** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */ + embedded?: boolean + layout?: LayoutMode +} + +const VisitorChat = ({ + defaultOpen = false, + channelKey = 'WK_8a3f2e', + embedded = false, + layout = 'floating', +}: VisitorChatProps) => { + const storageKey = useMemo(() => `kefu_widget_session_${channelKey}`, [channelKey]) + const tokenKey = useMemo(() => `kefu_widget_visitor_token_${channelKey}`, [channelKey]) + const msgsKey = useMemo(() => `${storageKey}_msgs`, [storageKey]) + + const [open, setOpen] = useState(defaultOpen || layout === 'fill') const [sessionId, setSessionId] = useState(() => { - const saved = localStorage.getItem(STORAGE_KEY) + const saved = localStorage.getItem(storageKey) return saved ? Number(saved) : null }) - const [visitorToken, setVisitorToken] = useState(() => localStorage.getItem(VISITOR_TOKEN_KEY) || '') + const [visitorToken, setVisitorToken] = useState(() => localStorage.getItem(tokenKey) || '') const [messages, setMessages] = useState(() => { - const saved = localStorage.getItem(STORAGE_KEY + '_msgs') + const saved = localStorage.getItem(msgsKey) return saved ? JSON.parse(saved) : [] }) const [input, setInput] = useState('') @@ -37,6 +54,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const [hoverStar, setHoverStar] = useState(0) const [imagePreview, setImagePreview] = useState(null) const [sendError, setSendError] = useState('') + const [agentsOnline, setAgentsOnline] = useState(true) + const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。') + const [agentName, setAgentName] = useState('') + const [leaveName, setLeaveName] = useState('') + const [leavePhone, setLeavePhone] = useState('') + const [leaveEmail, setLeaveEmail] = useState('') + const [leaveSent, setLeaveSent] = useState(false) const pollRef = useRef(null) const initRef = useRef(false) const typingTimerRef = useRef(null) @@ -47,7 +71,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const loadMessages = useCallback(async (sid?: number, token?: string) => { const s = sid || sessionId - const visitorCredential = token || visitorToken || localStorage.getItem(VISITOR_TOKEN_KEY) || '' + const visitorCredential = token || visitorToken || localStorage.getItem(tokenKey) || '' if (!s || !visitorCredential) return try { const res = await fetch(`/api/widget/messages?session_id=${s}`, { @@ -63,10 +87,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), })) setMessages(msgs) - localStorage.setItem(STORAGE_KEY + '_msgs', JSON.stringify(msgs)) + localStorage.setItem(msgsKey, JSON.stringify(msgs)) } } catch { /* ignore */ } - }, [sessionId, visitorToken]) + }, [sessionId, visitorToken, tokenKey, msgsKey]) const initSession = useCallback(async () => { if (sessionId && visitorToken) { @@ -76,21 +100,31 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { if (initRef.current) return initRef.current = true try { - const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=客服云访客`, { method: 'POST' }) + const res = await fetch(`/api/widget/init?channel_key=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`, { + method: 'POST', + }) const json = await res.json() if (json.code === 0) { - const sid = json.data.session_id - const token = json.data.visitor_token + const data = json.data + const sid = data.session_id + const token = data.visitor_token setSessionId(sid) setVisitorToken(token) - localStorage.setItem(STORAGE_KEY, String(sid)) - localStorage.setItem(VISITOR_TOKEN_KEY, token) + setAgentsOnline(Boolean(data.agents_online)) + if (data.offline_prompt) setOfflinePrompt(data.offline_prompt) + if (data.agent_name) setAgentName(data.agent_name) + if (data.session_status === 'ended') setSessionEnded(true) + localStorage.setItem(storageKey, String(sid)) + localStorage.setItem(tokenKey, token) await loadMessages(sid, token) + } else { + setSendError(json.message || '初始化会话失败') } } catch (e) { console.error('Init failed:', e) + setSendError('连接客服失败,请稍后重试') } - }, [sessionId, visitorToken, loadMessages]) + }, [sessionId, visitorToken, loadMessages, channelKey, storageKey, tokenKey]) useEffect(() => { if (open) initSession() @@ -115,7 +149,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { try { const payload = JSON.parse(event.data) if (payload.session_id === sessionId && payload.type === 'typing') { - // 仅展示客服侧输入状态 if (payload.data?.from && payload.data.from !== 'agent') return setAgentTyping(true) if (typingTimerRef.current) clearTimeout(typingTimerRef.current) @@ -123,9 +156,15 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { return } if (payload.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) { - if (payload.type === 'session_updated' && payload.data?.status === 'ended') { - setSessionEnded(true) - setShowRating(true) + if (payload.type === 'session_updated') { + if (payload.data?.status === 'ended') { + setSessionEnded(true) + setShowRating(true) + } + if (payload.data?.status === 'active') { + setAgentsOnline(true) + setAgentName(payload.data?.agent_name || agentName) + } } setAgentTyping(false) loadMessages(sessionId, visitorToken) @@ -139,14 +178,20 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { socketRef.current = null if (typingTimerRef.current) clearTimeout(typingTimerRef.current) } - }, [sessionId, visitorToken, open, loadMessages]) + }, [sessionId, visitorToken, open, loadMessages, agentName]) useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages, agentTyping, open, imagePreview]) + const notifyHost = (type: 'kefu-widget-close' | 'kefu-widget-minimize') => { + if (embedded && window.parent && window.parent !== window) { + window.parent.postMessage({ type }, '*') + } + } + const emitTyping = () => { - if (!sessionId || sessionEnded) return + if (!sessionId || sessionEnded || !agentsOnline) return const now = Date.now() if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return lastTypingAt.current = now @@ -169,6 +214,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const sendMessage = async (text: string) => { if (!text.trim() || sending || sessionEnded) return + if (!agentsOnline) { + setSendError('当前无客服在线,请使用下方留言表单') + return + } const content = text.trim() setInput('') setSendError('') @@ -194,8 +243,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { } } + const submitLeaveMessage = async () => { + if (!sessionId || !visitorToken || sending || leaveSent) return + const content = input.trim() || '请尽快与我联系,谢谢。' + if (!leavePhone.trim() && !leaveEmail.trim()) { + setSendError('请至少填写手机号或邮箱') + return + } + setSending(true) + setSendError('') + try { + const res = await fetch('/api/widget/leave-message', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken }, + body: JSON.stringify({ + session_id: sessionId, + content, + name: leaveName.trim(), + phone: leavePhone.trim(), + email: leaveEmail.trim(), + }), + }) + const json = await res.json() + if (json.code !== 0) throw new Error(json.message || '留言失败') + setLeaveSent(true) + setInput('') + if (json.data?.agents_online) setAgentsOnline(true) + await loadMessages(sessionId, visitorToken) + } catch (e) { + setSendError(e instanceof Error ? e.message : '留言失败') + } finally { + setSending(false) + } + } + const handleImageFile = (file?: File | null) => { - if (!file || sessionEnded || !sessionId) return + if (!file || sessionEnded || !sessionId || !agentsOnline) return if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) { setSendError('仅支持 jpg、png、gif 图片') return @@ -211,7 +294,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { } const sendImage = async () => { - if (!imagePreview || sending || sessionEnded) return + if (!imagePreview || sending || sessionEnded || !agentsOnline) return setSending(true) setSendError('') const localMsg: Message = { @@ -241,11 +324,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const handleClose = () => { setOpen(false) + notifyHost('kefu-widget-close') if (sessionEnded && !rated) setShowRating(true) } const handleMinimize = () => { setOpen(false) + notifyHost('kefu-widget-minimize') } const submitRating = async (score: number) => { @@ -266,12 +351,290 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { } } - const showWelcome = messages.length === 0 - const showQuick = messages.length <= 1 && !sessionEnded + const showWelcome = messages.length === 0 && !leaveSent + const showQuick = messages.length <= 1 && !sessionEnded && agentsOnline + const isFill = layout === 'fill' + const shellClass = isFill + ? 'relative w-full h-full flex flex-col bg-white overflow-hidden' + : 'fixed z-50 right-6 bottom-6 w-[400px] h-[600px] max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex flex-col rounded-xl overflow-hidden bg-white' + + const panel = open && ( +
+
+
+ +
+
+

在线客服

+

+ + {sessionEnded + ? '会话已结束' + : agentsOnline + ? (agentName ? `${agentName} 为您服务` : '正在为您服务') + : '客服离线 · 可留言'} +

+
+
+ {!isFill && ( + + )} + +
+
+ +
+ {showWelcome && ( +
+
+

+ {agentsOnline + ? '您好!欢迎咨询,请问有什么可以帮您?' + : offlinePrompt} +

+
+
+ )} + + {showQuick && ( +
+ {defaultQuickQuestions.map((q, i) => ( + + ))} +
+ )} + + {messages.map(msg => ( + msg.sender === 'visitor' ? ( +
+
+ {msg.type === 'image' ? ( + 图片 + ) : ( +

{msg.content}

+ )} +
+
+ ) : ( +
+
+ +
+
+ {msg.type === 'image' ? ( + 图片 + ) : ( +

{msg.content}

+ )} +
+
+ ) + ))} + + {agentTyping && ( + <> +
+
+ +
+
+ + + +
+
+

客服正在输入...

+ + )} +
+
+ +
+
+ {sendError &&
{sendError}
} + + {!agentsOnline && !sessionEnded && ( +
+ {leaveSent ? ( +
+ 留言已提交,客服上线后会尽快联系您。您也可继续补充留言内容。 +
+ ) : ( + <> +
离线留言
+ setLeaveName(e.target.value)} + /> + setLeavePhone(e.target.value)} + /> + setLeaveEmail(e.target.value)} + /> + + )} +
+ )} + + {imagePreview && agentsOnline && ( +
+ 预览 +
确认发送图片?
+ + +
+ )} + + {agentsOnline && ( +
+ + + { handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }} + /> +
+ )} + +
+ { setInput(e.target.value); if (agentsOnline) emitTyping() }} + onPaste={e => { + if (!agentsOnline) return + const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/')) + if (item) { + e.preventDefault() + handleImageFile(item.getAsFile()) + } + }} + onKeyDown={e => { + if (e.key === 'Enter') { + if (agentsOnline) sendMessage(input) + } + }} + disabled={sending || !sessionId || sessionEnded} + /> + {agentsOnline ? ( + + ) : ( + + )} +
+
+ + {showRating && ( +
+
+
本次服务如何?
+
请对我们的服务进行评价
+
+ {[1, 2, 3, 4, 5].map(star => ( + setHoverStar(star)} + onMouseLeave={() => setHoverStar(0)} + onClick={() => submitRating(star)} + /> + ))} +
+