diff --git a/server/internal/handler/message_validation.go b/server/internal/handler/message_validation.go new file mode 100644 index 0000000..94816d8 --- /dev/null +++ b/server/internal/handler/message_validation.go @@ -0,0 +1,45 @@ +package handler + +import ( + "encoding/base64" + "errors" + "strings" + "unicode/utf8" +) + +const ( + maxTextMessageLength = 2000 + maxImageMessageSize = 5 * 1024 * 1024 +) + +func validateMessageContent(messageType, content string) (string, error) { + switch messageType { + case "text": + content = strings.TrimSpace(content) + if content == "" { + return "", errors.New("消息内容不能为空") + } + if utf8.RuneCountInString(content) > maxTextMessageLength { + return "", errors.New("单条文本消息不能超过 2000 字") + } + return content, nil + case "image": + parts := strings.SplitN(content, ",", 2) + if len(parts) != 2 { + return "", errors.New("图片格式无效") + } + if parts[0] != "data:image/jpeg;base64" && parts[0] != "data:image/png;base64" && parts[0] != "data:image/gif;base64" { + return "", errors.New("仅支持 jpg、png、gif 图片") + } + bytes, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil || len(bytes) == 0 { + return "", errors.New("图片内容无效") + } + if len(bytes) > maxImageMessageSize { + return "", errors.New("图片不能超过 5 MB") + } + return content, nil + default: + return "", errors.New("不支持的消息类型") + } +} diff --git a/server/internal/handler/message_validation_test.go b/server/internal/handler/message_validation_test.go new file mode 100644 index 0000000..5beaf63 --- /dev/null +++ b/server/internal/handler/message_validation_test.go @@ -0,0 +1,22 @@ +package handler + +import ( + "strings" + "testing" +) + +func TestValidateMessageContent(t *testing.T) { + validImage := "data:image/png;base64,aGVsbG8=" + if content, err := validateMessageContent("image", validImage); err != nil || content != validImage { + t.Fatalf("合法图片校验失败: content=%q err=%v", content, err) + } + if _, err := validateMessageContent("image", "data:image/webp;base64,aGVsbG8="); err == nil { + t.Fatal("不支持的图片格式未被拦截") + } + if _, err := validateMessageContent("text", " "); err == nil { + t.Fatal("空白文本未被拦截") + } + if _, err := validateMessageContent("text", strings.Repeat("字", maxTextMessageLength+1)); err == nil { + t.Fatal("超长文本未被拦截") + } +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 7c18696..7ad2d2a 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -37,6 +37,7 @@ func SetupRoutes(r *gin.Engine) { authRequired.GET("/ws", ws.Connect) // 会话管理 + authRequired.GET("/agents/available", session.ListAvailableAgents) sessions := authRequired.Group("/sessions") sessions.GET("", session.List) sessions.GET("/:id", session.Get) @@ -46,6 +47,8 @@ func SetupRoutes(r *gin.Engine) { sessions.POST("/:id/end", session.End) sessions.PUT("/:id/priority", session.UpdatePriority) sessions.POST("/:id/messages", session.SendMessage) + sessions.POST("/:id/read", session.MarkRead) + sessions.POST("/:id/notes", session.AddNote) // 客户管理 customers := authRequired.Group("/customers") diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index ceda422..1a12da2 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -336,3 +336,105 @@ func TestKnowledgeEntryRespectsPlanCapacity(t *testing.T) { t.Fatalf("超出知识库容量状态码 = %d,期望 %d,响应 = %s", recorder.Code, http.StatusConflict, recorder.Body.String()) } } + +func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) { + router := setupRouter(t) + tenant := createTenant(t, "工作台租户", "normal") + agentOne := createUser(t, tenant.ID, "workbench-agent-one", "agent") + agentTwo := createUser(t, tenant.ID, "workbench-agent-two", "agent") + customer := model.Customer{TenantID: tenant.ID, Name: "工作台客户", Source: "网页"} + if err := model.DB.Create(&customer).Error; err != nil { + t.Fatalf("创建工作台客户失败: %v", err) + } + session := model.Session{TenantID: tenant.ID, CustomerID: customer.ID, Status: "waiting", Priority: "normal"} + if err := model.DB.Create(&session).Error; err != nil { + t.Fatalf("创建等待会话失败: %v", err) + } + visitorMessage := model.Message{SessionID: session.ID, SenderType: "visitor", Content: "需要咨询", Type: "text", Seq: 1, SentAt: time.Now()} + if err := model.DB.Create(&visitorMessage).Error; err != nil { + t.Fatalf("创建访客消息失败: %v", err) + } + + availableRecorder := httptest.NewRecorder() + router.ServeHTTP(availableRecorder, bearerRequest(t, http.MethodGet, "/api/agents/available", nil, agentOne)) + if availableRecorder.Code != http.StatusOK { + t.Fatalf("查询在线客服失败: %d %s", availableRecorder.Code, availableRecorder.Body.String()) + } + + claimRecorder := httptest.NewRecorder() + router.ServeHTTP(claimRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/assign", session.ID), []byte(`{}`), agentOne)) + if claimRecorder.Code != http.StatusOK { + t.Fatalf("领取会话失败: %d %s", claimRecorder.Code, claimRecorder.Body.String()) + } + + unreadRecorder := httptest.NewRecorder() + router.ServeHTTP(unreadRecorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, agentOne)) + var unreadResponse struct { + List []struct { + ID uint `json:"id"` + UnreadCount int `json:"unread_count"` + } `json:"list"` + } + if err := json.Unmarshal(unreadRecorder.Body.Bytes(), &unreadResponse); err != nil { + t.Fatalf("解析未读列表失败: %v", err) + } + if len(unreadResponse.List) != 1 || unreadResponse.List[0].UnreadCount != 1 { + t.Fatalf("领取后的未读数不正确: %s", unreadRecorder.Body.String()) + } + + readRecorder := httptest.NewRecorder() + router.ServeHTTP(readRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/read", session.ID), []byte(`{}`), agentOne)) + if readRecorder.Code != http.StatusOK { + t.Fatalf("标记已读失败: %d %s", readRecorder.Code, readRecorder.Body.String()) + } + readListRecorder := httptest.NewRecorder() + router.ServeHTTP(readListRecorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, agentOne)) + var readListResponse struct { + List []struct { + UnreadCount int `json:"unread_count"` + } `json:"list"` + } + if err := json.Unmarshal(readListRecorder.Body.Bytes(), &readListResponse); err != nil || len(readListResponse.List) != 1 || readListResponse.List[0].UnreadCount != 0 { + t.Fatalf("标记已读后的未读数不正确: %s", readListRecorder.Body.String()) + } + + noteRecorder := httptest.NewRecorder() + router.ServeHTTP(noteRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/notes", session.ID), []byte(`{"content":"已核对客户需求"}`), agentOne)) + if noteRecorder.Code != http.StatusOK { + t.Fatalf("添加内部备注失败: %d %s", noteRecorder.Code, noteRecorder.Body.String()) + } + + transferRecorder := httptest.NewRecorder() + transferBody := []byte(fmt.Sprintf(`{"agent_id":%d}`, agentTwo.ID)) + router.ServeHTTP(transferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, agentOne)) + if transferRecorder.Code != http.StatusOK { + t.Fatalf("转接会话失败: %d %s", transferRecorder.Code, transferRecorder.Body.String()) + } + + oldAgentMessageRecorder := httptest.NewRecorder() + router.ServeHTTP(oldAgentMessageRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/messages", session.ID), []byte(`{"content":"越权回复"}`), agentOne)) + if oldAgentMessageRecorder.Code != http.StatusForbidden { + t.Fatalf("转接后原客服仍可回复: %d %s", oldAgentMessageRecorder.Code, oldAgentMessageRecorder.Body.String()) + } + + imageRecorder := httptest.NewRecorder() + imageBody := []byte(`{"content":"data:image/png;base64,aGVsbG8=","type":"image"}`) + router.ServeHTTP(imageRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/messages", session.ID), imageBody, agentTwo)) + if imageRecorder.Code != http.StatusOK { + t.Fatalf("发送图片消息失败: %d %s", imageRecorder.Code, imageRecorder.Body.String()) + } + + invalidEndRecorder := httptest.NewRecorder() + router.ServeHTTP(invalidEndRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/end?reason=bad_reason", session.ID), []byte(`{}`), agentTwo)) + if invalidEndRecorder.Code != http.StatusBadRequest { + t.Fatalf("无效结束原因未被拦截: %d %s", invalidEndRecorder.Code, invalidEndRecorder.Body.String()) + } + + var savedSession model.Session + if err := model.DB.First(&savedSession, session.ID).Error; err != nil { + t.Fatalf("读取工作台会话失败: %v", err) + } + if savedSession.AgentID == nil || *savedSession.AgentID != agentTwo.ID || savedSession.LastReadSeq != 2 { + t.Fatalf("会话领取/转接/已读状态错误: %+v", savedSession) + } +} diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index 207c149..5b96b06 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -2,7 +2,9 @@ package handler import ( "net/http" + "strings" "time" + "unicode/utf8" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -20,6 +22,15 @@ type SendMessageReq struct { Type string `json:"type"` } +type SessionListItem struct { + model.Session + UnreadCount int `json:"unread_count"` +} + +type CreateNoteReq struct { + Content string `json:"content" binding:"required"` +} + func (h *SessionHandler) SendMessage(c *gin.Context) { userID := middleware.GetUserID(c) id := c.Param("id") @@ -32,6 +43,11 @@ func (h *SessionHandler) SendMessage(c *gin.Context) { if req.Type == "" { req.Type = "text" } + content, err := validateMessageContent(req.Type, req.Content) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } session, ok := loadTenantSession(c, id) if !ok { @@ -50,7 +66,7 @@ func (h *SessionHandler) SendMessage(c *gin.Context) { SessionID: session.ID, SenderType: "agent", SenderID: &userID, - Content: req.Content, + Content: content, Type: req.Type, SentAt: time.Now(), } @@ -59,6 +75,10 @@ func (h *SessionHandler) SendMessage(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) return } + if middleware.GetRole(c) == "agent" { + model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", msg.Seq) + session.LastReadSeq = msg.Seq + } broadcastSessionMessage(session, msg) middleware.JSON(c, msg) @@ -134,6 +154,14 @@ func loadAssignableAgent(tenantID, agentID uint) error { return nil } +func unreadCount(session model.Session) int { + var count int64 + model.DB.Model(&model.Message{}). + Where("session_id = ? AND sender_type = ? AND seq > ?", session.ID, "visitor", session.LastReadSeq). + Count(&count) + return int(count) +} + func (h *SessionHandler) List(c *gin.Context) { tenantID := middleware.GetTenantID(c) page, pageSize := middleware.GetPageParams(c) @@ -155,9 +183,21 @@ func (h *SessionHandler) List(c *gin.Context) { } query.Model(&model.Session{}).Count(&total) - query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions) + if err := query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话失败"}) + return + } - middleware.JSONList(c, sessions, total, page, pageSize) + items := make([]SessionListItem, 0, len(sessions)) + for _, session := range sessions { + item := SessionListItem{Session: session} + if middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) { + item.UnreadCount = unreadCount(session) + } + items = append(items, item) + } + + middleware.JSONList(c, items, total, page, pageSize) } func (h *SessionHandler) Get(c *gin.Context) { @@ -179,8 +219,81 @@ 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 pendingCount int64 + model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount) - middleware.JSON(c, gin.H{"session": session, "messages": messages, "events": events}) + middleware.JSON(c, gin.H{"session": session, "messages": messages, "events": events, "pending_count": pendingCount}) +} + +func (h *SessionHandler) MarkRead(c *gin.Context) { + session, ok := loadTenantSession(c, c.Param("id")) + if !ok { + return + } + if !canReadSession(c, session) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权标记该会话已读"}) + return + } + if session.AgentID == nil || middleware.GetRole(c) != "agent" || *session.AgentID != middleware.GetUserID(c) { + middleware.JSON(c, gin.H{"last_read_seq": session.LastReadSeq}) + return + } + var maxSeq int + if err := model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "标记已读失败"}) + return + } + if err := model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", maxSeq).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "标记已读失败"}) + return + } + middleware.JSON(c, gin.H{"last_read_seq": maxSeq}) +} + +func (h *SessionHandler) AddNote(c *gin.Context) { + session, ok := loadTenantSession(c, c.Param("id")) + if !ok { + return + } + if !canOperateSession(c, session) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权添加内部备注"}) + return + } + var req CreateNoteReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + content := strings.TrimSpace(req.Content) + if content == "" || utf8.RuneCountInString(content) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "内部备注需为 1 至 500 字"}) + return + } + event := model.SessionEvent{SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "note", Detail: content} + if err := model.DB.Create(&event).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存内部备注失败"}) + return + } + middleware.JSON(c, event) +} + +func (h *SessionHandler) ListAvailableAgents(c *gin.Context) { + type agentItem struct { + ID uint `json:"id"` + Nickname string `json:"nickname"` + Status string `json:"status"` + } + var users []model.User + if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", middleware.GetTenantID(c), "agent", "online"). + Order("nickname asc").Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"}) + return + } + items := make([]agentItem, 0, len(users)) + for _, user := range users { + items = append(items, agentItem{ID: user.ID, Nickname: user.Nickname, Status: user.Status}) + } + middleware.JSON(c, items) } func (h *SessionHandler) Create(c *gin.Context) { @@ -222,6 +335,8 @@ func (h *SessionHandler) Create(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"}) return } + model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, tenantID). + Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()}) middleware.JSON(c, session) } @@ -259,7 +374,7 @@ func (h *SessionHandler) Assign(c *gin.Context) { result := model.DB.Model(&model.Session{}). Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "waiting"). - Updates(map[string]interface{}{"agent_id": req.AgentID, "status": "active"}) + Updates(map[string]interface{}{"agent_id": req.AgentID, "status": "active", "last_read_seq": 0}) if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "分配失败"}) @@ -299,7 +414,7 @@ func (h *SessionHandler) Transfer(c *gin.Context) { } result := model.DB.Model(&model.Session{}). Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active"). - Update("agent_id", req.AgentID) + Updates(map[string]interface{}{"agent_id": req.AgentID, "last_read_seq": 0}) if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "转接失败"}) return @@ -336,6 +451,10 @@ func (h *SessionHandler) End(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写结束原因"}) return } + if reason != "resolved" && reason != "no_response" && reason != "visitor_left" && reason != "transferred" && reason != "other" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "结束原因无效"}) + return + } now := time.Now() result := model.DB.Model(&model.Session{}). diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index b0b8281..efe8338 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -105,6 +105,8 @@ func (h *WidgetHandler) Init(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"}) return } + 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()}) if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } @@ -150,6 +152,11 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { if req.Type == "" { req.Type = "text" } + content, err := validateMessageContent(req.Type, req.Content) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if !ok { @@ -160,21 +167,12 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { return } - // 如果是等待中的会话,更新为活跃 - if session.Status == "waiting" { - if err := model.DB.Model(session).Update("status", "active").Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新会话失败"}) - return - } - session.Status = "active" - } - custID := session.CustomerID msg := model.Message{ SessionID: session.ID, SenderType: "visitor", SenderID: &custID, - Content: req.Content, + Content: content, Type: req.Type, SentAt: time.Now(), } @@ -183,8 +181,14 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) return } + model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now()) if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { - ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + if session.AgentID == nil { + // 等待会话的消息要通知全部客服,便于任一在线客服及时领取。 + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } else { + ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + } } c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg}) diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 917cad0..553e9b5 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -70,6 +70,7 @@ type Session struct { CustomerID uint `gorm:"index" json:"customer_id"` AgentID *uint `gorm:"index" json:"agent_id"` VisitorTokenHash string `gorm:"size:64;index" json:"-"` + 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"` SatisfactionScore *int `json:"satisfaction_score"` diff --git a/server/internal/ws/ws.go b/server/internal/ws/ws.go index 36f30a3..f68d36d 100644 --- a/server/internal/ws/ws.go +++ b/server/internal/ws/ws.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gorilla/websocket" + "kefu-sys/server/internal/model" ) var upgrader = websocket.Upgrader{ @@ -32,6 +33,11 @@ type Event struct { Timestamp int64 `json:"timestamp"` } +type ClientEvent struct { + Type string `json:"type"` + SessionID uint `json:"session_id"` +} + type Hub struct { clients map[*Client]bool register chan *Client @@ -118,17 +124,59 @@ func (h *Hub) BroadcastToTenantStaff(tenantID uint, message []byte) { } } +func (h *Hub) BroadcastToVisitor(tenantID, sessionID uint, message []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + + for client := range h.clients { + if client.TenantID == tenantID && client.Kind == "visitor" && client.SessionID != nil && *client.SessionID == sessionID { + h.send(client, message) + } + } +} + +func handleClientEvent(client *Client, event ClientEvent) { + if client.Kind != "agent" || event.Type != "typing" || event.SessionID == 0 { + return + } + var session model.Session + if err := model.DB.Where("id = ? AND tenant_id = ?", event.SessionID, client.TenantID).First(&session).Error; err != nil { + return + } + if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) { + return + } + if client.Role != "agent" && client.Role != "admin" && client.Role != "supervisor" { + return + } + payload, err := NewEvent("typing", session.ID, nil) + if err == nil { + DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload) + } +} + func HandleWebSocket(client *Client) { defer func() { DefaultHub.unregister <- client client.Conn.Close() }() + client.Conn.SetReadLimit(1024) + client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + client.Conn.SetPongHandler(func(string) error { + client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) go writePump(client) for { - if _, _, err := client.Conn.ReadMessage(); err != nil { + _, message, err := client.Conn.ReadMessage() + if err != nil { return } + var event ClientEvent + if json.Unmarshal(message, &event) == nil { + handleClientEvent(client, event) + } } } diff --git a/server/internal/ws/ws_test.go b/server/internal/ws/ws_test.go index 454b6b5..c86a0fe 100644 --- a/server/internal/ws/ws_test.go +++ b/server/internal/ws/ws_test.go @@ -39,3 +39,31 @@ func TestBroadcastToSessionRestrictsRecipients(t *testing.T) { } } } + +func TestBroadcastToVisitorRestrictsSession(t *testing.T) { + hub := NewHub() + go hub.Run() + + sessionID := uint(21) + otherSessionID := uint(22) + visitor := &Client{TenantID: 1, Kind: "visitor", SessionID: &sessionID, Send: make(chan []byte, 1)} + otherVisitor := &Client{TenantID: 1, Kind: "visitor", SessionID: &otherSessionID, Send: make(chan []byte, 1)} + agent := &Client{TenantID: 1, Kind: "agent", UserID: 1, Role: "agent", Send: make(chan []byte, 1)} + for _, client := range []*Client{visitor, otherVisitor, agent} { + hub.register <- client + } + + hub.BroadcastToVisitor(1, sessionID, []byte(`{"type":"typing"}`)) + select { + case <-visitor.Send: + case <-time.After(time.Second): + t.Fatal("目标访客未收到输入状态") + } + for _, client := range []*Client{otherVisitor, agent} { + select { + case <-client.Send: + t.Fatalf("无关客户端收到访客状态:%+v", client) + default: + } + } +} diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index a1a3ad7..c10b7c8 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -1,68 +1,110 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Input, Spin, message as antMsg } from 'antd' -import { SearchOutlined, StarFilled } from '@ant-design/icons' +import { Button, Dropdown, Input, Modal, Select, Spin, Tooltip, message as antMsg } from 'antd' +import { + CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, + SearchOutlined, SendOutlined, StarFilled, SwapOutlined, +} from '@ant-design/icons' import { useAuth } from '@/stores/auth' -import { endSession, getSessions, getSession, getCustomers, type Session as SessionType, type Customer } from '@/services/api' +import { + addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries, + getSession, getSessions, markSessionRead, sendSessionMessage, transferSession, updateSessionPriority, + type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type Session, type SessionEvent, +} from '@/services/api' -const priorityColors: Record = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' } -const priorityLabels: Record = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' } +const priorityColors: Record = { urgent: '#dc2626', normal: '#2563eb' } +const priorityLabels: Record = { urgent: '紧急', normal: '普通' } const statusLabels: Record = { active: '进行中', waiting: '等待中', ended: '已结束' } +const endReasons = [ + { value: 'resolved', label: '已解决' }, + { value: 'no_response', label: '无人回复' }, + { value: 'visitor_left', label: '访客离开' }, + { value: 'transferred', label: '已转接' }, + { value: 'other', label: '其他' }, +] +const quickReplies = [ + '您好,正在为您查询,请稍候。', + '感谢您的耐心等待,还有什么可以帮您?', + '为更快处理,请您提供订单号或截图。', +] + +interface SessionDetail { + messages: Message[] + events: SessionEvent[] + pendingCount: number +} const Dashboard = () => { const { user } = useAuth() - const [sessions, setSessions] = useState([]) + const [sessions, setSessions] = useState([]) const [customers, setCustomers] = useState>({}) const [loading, setLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) - const [detail, setDetail] = useState<{ messages: { sender: string; content: string; time: string }[] } | null>(null) + const [detail, setDetail] = useState(null) const [detailLoading, setDetailLoading] = useState(false) const [messageInput, setMessageInput] = useState('') + const [search, setSearch] = useState('') const [sending, setSending] = useState(false) + const [transferOpen, setTransferOpen] = useState(false) + const [availableAgents, setAvailableAgents] = useState([]) + const [targetAgentID, setTargetAgentID] = useState() + const [endingOpen, setEndingOpen] = useState(false) + const [endReason, setEndReason] = useState('resolved') + const [knowledgeOpen, setKnowledgeOpen] = useState(false) + const [knowledgeKeyword, setKnowledgeKeyword] = useState('') + const [knowledgeEntries, setKnowledgeEntries] = useState([]) + const [knowledgeLoading, setKnowledgeLoading] = useState(false) + const [imagePreview, setImagePreview] = useState(null) + const [noteInput, setNoteInput] = useState('') + const [savingNote, setSavingNote] = useState(false) const initialLoad = useRef(true) const chatEndRef = useRef(null) + const fileInputRef = useRef(null) + const socketRef = useRef(null) + const lastTypingAt = useRef(0) + + const isManager = user?.role === 'admin' || user?.role === 'supervisor' const loadAll = useCallback(async () => { setLoading(true) try { - const [sRes, cRes] = await Promise.all([ - getSessions(), - getCustomers({ page: 1 }), + const [sessionRes, customerRes] = await Promise.all([ + getSessions({ page: 1, pageSize: 100 }), + getCustomers({ page: 1, pageSize: 100 }), ]) - const sessionList = Array.isArray(sRes.list) ? sRes.list : [] - const customerList = Array.isArray(cRes.list) ? cRes.list : [] - setSessions(sessionList) - const map: Record = {} - customerList.forEach(c => { map[c.id] = c }) - setCustomers(map) + const sessionList = Array.isArray(sessionRes.list) ? sessionRes.list : [] + const customerList = Array.isArray(customerRes.list) ? customerRes.list : [] + setSessions(sessionList) + setCustomers(Object.fromEntries(customerList.map(customer => [customer.id, customer]))) if (sessionList.length > 0 && initialLoad.current) { - setSelectedId(sessionList[0].id) + const preferred = sessionList.find(session => session.status === 'active') || sessionList[0] + setSelectedId(preferred.id) initialLoad.current = false } - } catch (err) { - console.error('加载失败:', err) + } catch { + antMsg.error('加载会话失败') + setSessions([]) + setCustomers({}) } finally { setLoading(false) } }, []) - const loadDetail = useCallback(async (id: number) => { + const loadDetail = useCallback(async (id: number, markRead = true) => { setDetailLoading(true) try { - const res = await getSession(id) - const msgs: any = res.data as any - setDetail({ - messages: (msgs.messages || []).map((m: any) => ({ - sender: m.sender_type === 'agent' ? '客服' : '访客', - content: m.content, - time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), - })), - }) + const response = await getSession(id) + const data = response.data + setDetail({ messages: data.messages || [], events: data.events || [], pendingCount: data.pending_count || 0 }) + if (markRead) { + await markSessionRead(id) + setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session)) + } } catch { antMsg.error('加载消息失败') } finally { setDetailLoading(false) } - setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 200) + setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 80) }, []) useEffect(() => { loadAll() }, [loadAll]) @@ -71,221 +113,269 @@ const Dashboard = () => { if (!user?.token) return const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const socket = new WebSocket(`${scheme}//${window.location.host}/api/ws`, ['kefu-v1', user.token]) - socket.onmessage = (event) => { + socketRef.current = socket + socket.onmessage = event => { try { const payload = JSON.parse(event.data) if (payload.type === 'message' && payload.session_id === selectedId) { loadDetail(payload.session_id) } - if (payload.type === 'session_created' || payload.type === 'session_updated') { + if (payload.type === 'message' || payload.type === 'session_created' || payload.type === 'session_updated') { loadAll() } } catch { // 忽略格式错误的实时消息 } } - return () => socket.close() + return () => { + socket.close() + socketRef.current = null + } }, [user?.token, selectedId, loadAll, loadDetail]) useEffect(() => { if (selectedId) loadDetail(selectedId) }, [selectedId, loadDetail]) - const handleSend = async () => { - if (!messageInput.trim() || !selectedId || sending) return - const text = messageInput.trim() - setMessageInput('') + useEffect(() => { + if (!knowledgeOpen) return + setKnowledgeLoading(true) + getKnowledgeEntries({ search: knowledgeKeyword, page: 1 }).then(response => { + setKnowledgeEntries(Array.isArray(response.list) ? response.list : []) + }).catch(() => setKnowledgeEntries([])).finally(() => setKnowledgeLoading(false)) + }, [knowledgeOpen, knowledgeKeyword]) + + const selected = sessions.find(session => session.id === selectedId) + const selectedCustomer = selected ? customers[selected.customer_id] : null + const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') + const filteredSessions = sessions.filter(session => { + if (session.status === 'ended') return false + const customer = customers[session.customer_id] + const keyword = search.trim().toLowerCase() + return !keyword || customer?.name.toLowerCase().includes(keyword) || String(session.id).includes(keyword) + }) + const urgentSessions = filteredSessions.filter(session => session.priority === 'urgent') + const waitingSessions = filteredSessions.filter(session => session.status === 'waiting' && session.priority !== 'urgent') + const activeSessions = filteredSessions.filter(session => session.status === 'active' && session.priority !== 'urgent') + const notes = detail?.events.filter(event => event.action === 'note').slice().reverse() || [] + + const emitTyping = () => { + if (!selectedId || !canOperate) return + const now = Date.now() + if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return + lastTypingAt.current = now + socketRef.current.send(JSON.stringify({ type: 'typing', session_id: selectedId })) + } + + const sendMessage = async (content: string, type: 'text' | 'image' = 'text') => { + if (!selectedId || !canOperate || sending || (type === 'text' && !content.trim())) return setSending(true) try { - const token = localStorage.getItem('auth_user') - const t = token ? JSON.parse(token).token : '' - const res = await fetch(`/api/sessions/${selectedId}/messages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, - body: JSON.stringify({ content: text, type: 'text' }), - }) - const json = await res.json() - if (json.code === 0) { - // 本地立即显示 - setDetail(prev => prev ? { - messages: [...prev.messages, { - sender: '客服', - content: text, - time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), - }], - } : prev) - setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100) - } else { - antMsg.error(json.message || '发送失败') - } - } catch { - antMsg.error('发送失败') + await sendSessionMessage(selectedId, content, type) + if (type === 'text') setMessageInput('') + await loadDetail(selectedId) + await loadAll() + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '发送失败') } finally { setSending(false) } } - const handleEnd = async () => { - if (!selectedId || sending) return + const handleImage = (file?: File) => { + if (!file) return + if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) { + antMsg.error('仅支持 jpg、png、gif 图片') + return + } + if (file.size > 5 * 1024 * 1024) { + antMsg.error('图片不能超过 5 MB') + return + } + const reader = new FileReader() + reader.onload = () => setImagePreview(String(reader.result)) + reader.readAsDataURL(file) + } + + const handleClaim = async (sessionID: number) => { try { - await endSession(selectedId, 'resolved') - antMsg.success('会话已结束') + await claimSession(sessionID) + antMsg.success('已领取会话') + setSelectedId(sessionID) await loadAll() - await loadDetail(selectedId) - } catch { - antMsg.error('结束会话失败') + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '领取失败') } } - const selected = sessions.find(s => s.id === selectedId) - const selectedCustomer = selected ? customers[selected.customer_id] : null - - if (loading) { - return
+ const openTransfer = async () => { + if (!selected) return + try { + const response = await getAvailableAgents() + setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id)) + setTargetAgentID(undefined) + setTransferOpen(true) + } catch { + antMsg.error('加载在线客服失败') + } } - return ( -
- {/* 左侧:会话列表 */} -
-
-
{user?.nickname || '客服'}
-
在线
-
-
- } placeholder="搜索会话..." variant="borderless" size="small" /> -
-
- {sessions.length === 0 ? ( -
暂无会话
- ) : ( - sessions.map(s => { - const c = customers[s.customer_id] - return ( -
setSelectedId(s.id)} - > -
-
- - {c ? c.name : `客户${s.customer_id}`} -
- {new Date(s.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} -
-
- {statusLabels[s.status] || s.status} - {s.satisfaction_score && {'★'.repeat(s.satisfaction_score)}} -
-
- ) - }) - )} -
-
+ const handleTransfer = async () => { + if (!selected || !targetAgentID) return + try { + await transferSession(selected.id, targetAgentID) + antMsg.success('会话已转接') + setTransferOpen(false) + setSelectedId(null) + setDetail(null) + await loadAll() + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '转接失败') + } + } - {/* 中间:聊天区 */} -
- {selected && selectedCustomer ? ( - <> -
-
- {selectedCustomer.name} - - {priorityLabels[selected.priority] || selected.priority} - - {statusLabels[selected.status]} -
-
- 转接 - 结束 -
-
-
- {detailLoading ? ( -
- ) : detail && detail.messages.length > 0 ? ( - detail.messages.map((msg, i) => ( -
-
- {msg.content} -
{msg.time}
-
-
- )) - ) : ( -
暂无消息,开始对话吧
- )} -
-
-
-
- setMessageInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && !sending) handleSend() }} - disabled={sending} - /> - {sending && } -
-
- - ) : ( -
选择一个会话
- )} -
+ const handlePriority = async (priority: 'normal' | 'urgent') => { + if (!selected) return + try { + await updateSessionPriority(selected.id, priority) + antMsg.success('优先级已更新') + await loadAll() + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '更新失败') + } + } - {/* 右侧:客户信息面板 */} -
- {selectedCustomer ? ( -
-
-
- {selectedCustomer.name[0]} -
-
-
{selectedCustomer.name}
-
{selectedCustomer.source || '未知渠道'}
-
-
-
-
联系方式
-
- {selectedCustomer.phone &&
{selectedCustomer.phone}
} - {selectedCustomer.email &&
{selectedCustomer.email}
} -
-
-
-
标签
-
- {((): string[] => { try { return JSON.parse(selectedCustomer.tags) } catch { return [] } })().map((t: string) => ( - {t} - ))} -
-
-
-
统计数据
-
-
-
{selectedCustomer.conversation_count}
-
对话次数
-
-
-
- {selected?.satisfaction_score || '-'} - {selected?.satisfaction_score ? : null} -
-
满意度
-
-
-
+ const handleEnd = async () => { + if (!selected) return + try { + await endSession(selected.id, endReason) + antMsg.success('会话已结束') + setEndingOpen(false) + setSelectedId(null) + setDetail(null) + await loadAll() + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '结束会话失败') + } + } + + const handleAddNote = async () => { + if (!selected || !noteInput.trim()) return + setSavingNote(true) + try { + await addSessionNote(selected.id, noteInput) + setNoteInput('') + await loadDetail(selected.id, false) + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '保存备注失败') + } finally { + setSavingNote(false) + } + } + + const renderSessionGroup = (title: string, items: Session[], color: string) => ( +
+
+ + {title} · {items.length} +
+ {items.map(session => { + const customer = customers[session.customer_id] + return
+
+ {statusLabels[session.status]} + {session.status === 'waiting' && user?.role === 'agent' ? { event.stopPropagation(); handleClaim(session.id) }} + onKeyDown={event => { if (event.key === 'Enter') { event.stopPropagation(); handleClaim(session.id) } }}>领取 : + {new Date(session.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}} +
+ + })}
) + + if (loading) return
+ + return
+ { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }} /> + + +
+ {selected && selectedCustomer ? <> +
+
{selectedCustomer.name}{priorityLabels[selected.priority]}{statusLabels[selected.status]}
会话 #{selected.id}
+
+ handlePriority('urgent') }, { key: 'normal', label: '标记普通', onClick: () => handlePriority('normal') }] }} disabled={!canOperate}> + + + +
+
+
+ {detailLoading ?
: detail?.messages.length ? detail.messages.map(message =>
+
+ {message.type === 'image' ? 聊天图片 :
{message.content}
} +
{message.sender_type === 'agent' ? '客服' : '访客'} · {new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
+
+
) :
暂无消息,开始对话吧
} +
+
+
+ {selected.status !== 'active' || !canOperate ?
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束'}
: <> +
+ ({ key: String(index), label: content, onClick: () => setMessageInput(content) })) }}> +
+
+ { setMessageInput(event.target.value); emitTyping() }} + onPaste={event => { const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/')); if (image) { event.preventDefault(); handleImage(image.getAsFile() || undefined) } }} + onKeyDown={event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(messageInput) } }} /> +
+ } +
+ :
选择一个会话开始处理
} +
+ + + + setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}> +

请选择一位在线客服接手当前会话。

+ setImagePreview(null)} onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }} okText="发送" okButtonProps={{ loading: sending }}>
待发送图片预览
+ setKnowledgeOpen(false)} footer={null} width={640}>} placeholder="搜索标题或内容" value={knowledgeKeyword} onChange={event => setKnowledgeKeyword(event.target.value)} allowClear className="mb-3" />{knowledgeLoading ?
:
{knowledgeEntries.length === 0 ?
未找到可用知识条目
: knowledgeEntries.map(entry => )}
}
+
} export default Dashboard diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 762a337..907eba6 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1,13 +1,24 @@ -import { get, post, getList } from './request' +import { get, post, put, getList } from './request' export interface LoginParams { username: string; password: string } export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string } export interface Session { - id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null - status: string; priority: string; satisfaction_score: number | null; created_at: string; ended_at: string | null + id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null + status: string; priority: string; unread_count: number; satisfaction_score: number | null; created_at: string; ended_at: string | null } +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 +} + +export interface SessionEvent { + id: number; session_id: number; operator_id: number; action: string; detail: string; created_at: string +} + +export interface AvailableAgent { id: number; nickname: string; status: string } + export interface Customer { id: number; tenant_id: number; name: string; phone: string; email: string; tags: string source: string; status: string; conversation_count: number; last_contact_at: string @@ -35,23 +46,32 @@ export interface StatisticsKpis { export const login = (params: LoginParams) => post('/login', params) // Sessions -export const getSessions = (params?: { status?: string; priority?: string; page?: number }) => { +export const getSessions = (params?: { status?: string; priority?: string; page?: number; pageSize?: number }) => { const search = new URLSearchParams() if (params?.status) search.set('status', params.status) if (params?.priority) search.set('priority', params.priority) - if (params?.page) search.set('page', String(params.page)) + if (params?.page) search.set('page', String(params.page)) + if (params?.pageSize) search.set('pageSize', String(params.pageSize)) return getList(`/sessions?${search}`) } -export const getSession = (id: number) => get<{ session: Session; messages: unknown[] }>(`/sessions/${id}`) +export const getSession = (id: number) => get<{ session: Session; messages: Message[]; events: SessionEvent[]; pending_count: number }>(`/sessions/${id}`) export const assignSession = (id: number, agentId: number) => post(`/sessions/${id}/assign`, { agent_id: agentId }) +export const claimSession = (id: number) => post(`/sessions/${id}/assign`, {}) +export const transferSession = (id: number, agentId: number) => post(`/sessions/${id}/transfer`, { agent_id: agentId }) export const endSession = (id: number, reason: string) => post(`/sessions/${id}/end?reason=${reason}`, {}) +export const updateSessionPriority = (id: number, priority: 'normal' | 'urgent') => put(`/sessions/${id}/priority?priority=${priority}`, {}) +export const markSessionRead = (id: number) => post(`/sessions/${id}/read`, {}) +export const addSessionNote = (id: number, content: string) => post(`/sessions/${id}/notes`, { content }) +export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post(`/sessions/${id}/messages`, { content, type }) +export const getAvailableAgents = () => get('/agents/available') // Customers -export const getCustomers = (params?: { search?: string; status?: string; page?: number }) => { +export const getCustomers = (params?: { search?: string; status?: string; page?: number; pageSize?: number }) => { const search = new URLSearchParams() if (params?.search) search.set('search', params.search) if (params?.status) search.set('status', params.status) - if (params?.page) search.set('page', String(params.page || 1)) + if (params?.page) search.set('page', String(params.page || 1)) + if (params?.pageSize) search.set('pageSize', String(params.pageSize)) return getList(`/customers?${search}`) } diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 810fcab..79cbca9 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -28,9 +28,11 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const [showRating, setShowRating] = useState(false) const [rated, setRated] = useState(false) const [sessionEnded, setSessionEnded] = useState(false) + const [agentTyping, setAgentTyping] = useState(false) const [ratingText, setRatingText] = useState('') const pollRef = useRef(null) const initRef = useRef(false) + const typingTimerRef = useRef(null) const loadMessages = useCallback(async (sid?: number, token?: string) => { const s = sid || sessionId @@ -101,6 +103,12 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { socket.onmessage = (event) => { try { const payload = JSON.parse(event.data) + if (payload.session_id === sessionId && payload.type === 'typing') { + setAgentTyping(true) + if (typingTimerRef.current) clearTimeout(typingTimerRef.current) + typingTimerRef.current = window.setTimeout(() => setAgentTyping(false), 1800) + return + } if (payload.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) { if (payload.type === 'session_updated' && payload.data?.status === 'ended') { setSessionEnded(true) @@ -114,6 +122,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { } return () => { socket.close() + if (typingTimerRef.current) clearTimeout(typingTimerRef.current) } }, [sessionId, visitorToken, open, loadMessages]) @@ -200,6 +209,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
欢迎咨询,请描述您的问题
)} + {agentTyping &&
客服正在输入…
} {messages.map(msg => (