完成客服工作台闭环

This commit is contained in:
yml2213
2026-07-15 10:49:52 +08:00
parent 8cff2a5824
commit 1d31c9eba3
12 changed files with 727 additions and 235 deletions
@@ -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("不支持的消息类型")
}
}
@@ -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("超长文本未被拦截")
}
}
+3
View File
@@ -37,6 +37,7 @@ func SetupRoutes(r *gin.Engine) {
authRequired.GET("/ws", ws.Connect) authRequired.GET("/ws", ws.Connect)
// 会话管理 // 会话管理
authRequired.GET("/agents/available", session.ListAvailableAgents)
sessions := authRequired.Group("/sessions") sessions := authRequired.Group("/sessions")
sessions.GET("", session.List) sessions.GET("", session.List)
sessions.GET("/:id", session.Get) sessions.GET("/:id", session.Get)
@@ -46,6 +47,8 @@ func SetupRoutes(r *gin.Engine) {
sessions.POST("/:id/end", session.End) sessions.POST("/:id/end", session.End)
sessions.PUT("/:id/priority", session.UpdatePriority) sessions.PUT("/:id/priority", session.UpdatePriority)
sessions.POST("/:id/messages", session.SendMessage) sessions.POST("/:id/messages", session.SendMessage)
sessions.POST("/:id/read", session.MarkRead)
sessions.POST("/:id/notes", session.AddNote)
// 客户管理 // 客户管理
customers := authRequired.Group("/customers") customers := authRequired.Group("/customers")
@@ -336,3 +336,105 @@ func TestKnowledgeEntryRespectsPlanCapacity(t *testing.T) {
t.Fatalf("超出知识库容量状态码 = %d,期望 %d,响应 = %s", recorder.Code, http.StatusConflict, recorder.Body.String()) 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)
}
}
+125 -6
View File
@@ -2,7 +2,9 @@ package handler
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"unicode/utf8"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -20,6 +22,15 @@ type SendMessageReq struct {
Type string `json:"type"` 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) { func (h *SessionHandler) SendMessage(c *gin.Context) {
userID := middleware.GetUserID(c) userID := middleware.GetUserID(c)
id := c.Param("id") id := c.Param("id")
@@ -32,6 +43,11 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
if req.Type == "" { if req.Type == "" {
req.Type = "text" 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) session, ok := loadTenantSession(c, id)
if !ok { if !ok {
@@ -50,7 +66,7 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
SessionID: session.ID, SessionID: session.ID,
SenderType: "agent", SenderType: "agent",
SenderID: &userID, SenderID: &userID,
Content: req.Content, Content: content,
Type: req.Type, Type: req.Type,
SentAt: time.Now(), SentAt: time.Now(),
} }
@@ -59,6 +75,10 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
return 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) broadcastSessionMessage(session, msg)
middleware.JSON(c, msg) middleware.JSON(c, msg)
@@ -134,6 +154,14 @@ func loadAssignableAgent(tenantID, agentID uint) error {
return nil 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) { func (h *SessionHandler) List(c *gin.Context) {
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
page, pageSize := middleware.GetPageParams(c) page, pageSize := middleware.GetPageParams(c)
@@ -155,9 +183,21 @@ func (h *SessionHandler) List(c *gin.Context) {
} }
query.Model(&model.Session{}).Count(&total) 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) { func (h *SessionHandler) Get(c *gin.Context) {
@@ -179,8 +219,81 @@ 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 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) { 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": "创建会话失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"})
return 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) middleware.JSON(c, session)
} }
@@ -259,7 +374,7 @@ func (h *SessionHandler) Assign(c *gin.Context) {
result := model.DB.Model(&model.Session{}). result := model.DB.Model(&model.Session{}).
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "waiting"). 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 { if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "分配失败"}) 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{}). result := model.DB.Model(&model.Session{}).
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active"). 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 { if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "转接失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "转接失败"})
return return
@@ -336,6 +451,10 @@ func (h *SessionHandler) End(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写结束原因"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写结束原因"})
return 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() now := time.Now()
result := model.DB.Model(&model.Session{}). result := model.DB.Model(&model.Session{}).
+15 -11
View File
@@ -105,6 +105,8 @@ 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
} }
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 { if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil {
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
} }
@@ -150,6 +152,11 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
if req.Type == "" { if req.Type == "" {
req.Type = "text" 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)) session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken))
if !ok { if !ok {
@@ -160,21 +167,12 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
return 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 custID := session.CustomerID
msg := model.Message{ msg := model.Message{
SessionID: session.ID, SessionID: session.ID,
SenderType: "visitor", SenderType: "visitor",
SenderID: &custID, SenderID: &custID,
Content: req.Content, Content: content,
Type: req.Type, Type: req.Type,
SentAt: time.Now(), SentAt: time.Now(),
} }
@@ -183,8 +181,14 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
return 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 { 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}) c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
+1
View File
@@ -70,6 +70,7 @@ type Session struct {
CustomerID uint `gorm:"index" json:"customer_id"` CustomerID uint `gorm:"index" json:"customer_id"`
AgentID *uint `gorm:"index" json:"agent_id"` AgentID *uint `gorm:"index" json:"agent_id"`
VisitorTokenHash string `gorm:"size:64;index" json:"-"` 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"` 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"`
SatisfactionScore *int `json:"satisfaction_score"` SatisfactionScore *int `json:"satisfaction_score"`
+49 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"kefu-sys/server/internal/model"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
@@ -32,6 +33,11 @@ type Event struct {
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
} }
type ClientEvent struct {
Type string `json:"type"`
SessionID uint `json:"session_id"`
}
type Hub struct { type Hub struct {
clients map[*Client]bool clients map[*Client]bool
register chan *Client 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) { func HandleWebSocket(client *Client) {
defer func() { defer func() {
DefaultHub.unregister <- client DefaultHub.unregister <- client
client.Conn.Close() 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) go writePump(client)
for { for {
if _, _, err := client.Conn.ReadMessage(); err != nil { _, message, err := client.Conn.ReadMessage()
if err != nil {
return return
} }
var event ClientEvent
if json.Unmarshal(message, &event) == nil {
handleClientEvent(client, event)
}
} }
} }
+28
View File
@@ -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:
}
}
}
+299 -209
View File
@@ -1,68 +1,110 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Input, Spin, message as antMsg } from 'antd' import { Button, Dropdown, Input, Modal, Select, Spin, Tooltip, message as antMsg } from 'antd'
import { SearchOutlined, StarFilled } from '@ant-design/icons' import {
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
SearchOutlined, SendOutlined, StarFilled, SwapOutlined,
} from '@ant-design/icons'
import { useAuth } from '@/stores/auth' 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<string, string> = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' } const priorityColors: Record<string, string> = { urgent: '#dc2626', normal: '#2563eb' }
const priorityLabels: Record<string, string> = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' } const priorityLabels: Record<string, string> = { urgent: '紧急', normal: '普通' }
const statusLabels: Record<string, string> = { active: '进行中', waiting: '等待中', ended: '已结束' } const statusLabels: Record<string, string> = { 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 Dashboard = () => {
const { user } = useAuth() const { user } = useAuth()
const [sessions, setSessions] = useState<SessionType[]>([]) const [sessions, setSessions] = useState<Session[]>([])
const [customers, setCustomers] = useState<Record<number, Customer>>({}) const [customers, setCustomers] = useState<Record<number, Customer>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
const [detail, setDetail] = useState<{ messages: { sender: string; content: string; time: string }[] } | null>(null) const [detail, setDetail] = useState<SessionDetail | null>(null)
const [detailLoading, setDetailLoading] = useState(false) const [detailLoading, setDetailLoading] = useState(false)
const [messageInput, setMessageInput] = useState('') const [messageInput, setMessageInput] = useState('')
const [search, setSearch] = useState('')
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [transferOpen, setTransferOpen] = useState(false)
const [availableAgents, setAvailableAgents] = useState<AvailableAgent[]>([])
const [targetAgentID, setTargetAgentID] = useState<number>()
const [endingOpen, setEndingOpen] = useState(false)
const [endReason, setEndReason] = useState('resolved')
const [knowledgeOpen, setKnowledgeOpen] = useState(false)
const [knowledgeKeyword, setKnowledgeKeyword] = useState('')
const [knowledgeEntries, setKnowledgeEntries] = useState<KnowledgeEntry[]>([])
const [knowledgeLoading, setKnowledgeLoading] = useState(false)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [noteInput, setNoteInput] = useState('')
const [savingNote, setSavingNote] = useState(false)
const initialLoad = useRef(true) const initialLoad = useRef(true)
const chatEndRef = useRef<HTMLDivElement>(null) const chatEndRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const socketRef = useRef<WebSocket | null>(null)
const lastTypingAt = useRef(0)
const isManager = user?.role === 'admin' || user?.role === 'supervisor'
const loadAll = useCallback(async () => { const loadAll = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const [sRes, cRes] = await Promise.all([ const [sessionRes, customerRes] = await Promise.all([
getSessions(), getSessions({ page: 1, pageSize: 100 }),
getCustomers({ page: 1 }), getCustomers({ page: 1, pageSize: 100 }),
]) ])
const sessionList = Array.isArray(sRes.list) ? sRes.list : [] const sessionList = Array.isArray(sessionRes.list) ? sessionRes.list : []
const customerList = Array.isArray(cRes.list) ? cRes.list : [] const customerList = Array.isArray(customerRes.list) ? customerRes.list : []
setSessions(sessionList) setSessions(sessionList)
const map: Record<number, Customer> = {} setCustomers(Object.fromEntries(customerList.map(customer => [customer.id, customer])))
customerList.forEach(c => { map[c.id] = c })
setCustomers(map)
if (sessionList.length > 0 && initialLoad.current) { 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 initialLoad.current = false
} }
} catch (err) { } catch {
console.error('加载失败:', err) antMsg.error('加载会话失败')
setSessions([])
setCustomers({})
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [])
const loadDetail = useCallback(async (id: number) => { const loadDetail = useCallback(async (id: number, markRead = true) => {
setDetailLoading(true) setDetailLoading(true)
try { try {
const res = await getSession(id) const response = await getSession(id)
const msgs: any = res.data as any const data = response.data
setDetail({ setDetail({ messages: data.messages || [], events: data.events || [], pendingCount: data.pending_count || 0 })
messages: (msgs.messages || []).map((m: any) => ({ if (markRead) {
sender: m.sender_type === 'agent' ? '客服' : '访客', await markSessionRead(id)
content: m.content, setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session))
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), }
})),
})
} catch { } catch {
antMsg.error('加载消息失败') antMsg.error('加载消息失败')
} finally { } finally {
setDetailLoading(false) setDetailLoading(false)
} }
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 200) setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 80)
}, []) }, [])
useEffect(() => { loadAll() }, [loadAll]) useEffect(() => { loadAll() }, [loadAll])
@@ -71,221 +113,269 @@ const Dashboard = () => {
if (!user?.token) return if (!user?.token) return
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(`${scheme}//${window.location.host}/api/ws`, ['kefu-v1', user.token]) const socket = new WebSocket(`${scheme}//${window.location.host}/api/ws`, ['kefu-v1', user.token])
socket.onmessage = (event) => { socketRef.current = socket
socket.onmessage = event => {
try { try {
const payload = JSON.parse(event.data) const payload = JSON.parse(event.data)
if (payload.type === 'message' && payload.session_id === selectedId) { if (payload.type === 'message' && payload.session_id === selectedId) {
loadDetail(payload.session_id) 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() loadAll()
} }
} catch { } catch {
// 忽略格式错误的实时消息 // 忽略格式错误的实时消息
} }
} }
return () => socket.close() return () => {
socket.close()
socketRef.current = null
}
}, [user?.token, selectedId, loadAll, loadDetail]) }, [user?.token, selectedId, loadAll, loadDetail])
useEffect(() => { useEffect(() => {
if (selectedId) loadDetail(selectedId) if (selectedId) loadDetail(selectedId)
}, [selectedId, loadDetail]) }, [selectedId, loadDetail])
const handleSend = async () => { useEffect(() => {
if (!messageInput.trim() || !selectedId || sending) return if (!knowledgeOpen) return
const text = messageInput.trim() setKnowledgeLoading(true)
setMessageInput('') 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) setSending(true)
try { try {
const token = localStorage.getItem('auth_user') await sendSessionMessage(selectedId, content, type)
const t = token ? JSON.parse(token).token : '' if (type === 'text') setMessageInput('')
const res = await fetch(`/api/sessions/${selectedId}/messages`, { await loadDetail(selectedId)
method: 'POST', await loadAll()
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, } catch (error) {
body: JSON.stringify({ content: text, type: 'text' }), antMsg.error(error instanceof Error ? error.message : '发送失败')
})
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('发送失败')
} finally { } finally {
setSending(false) setSending(false)
} }
} }
const handleEnd = async () => { const handleImage = (file?: File) => {
if (!selectedId || sending) return 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 { try {
await endSession(selectedId, 'resolved') await claimSession(sessionID)
antMsg.success('会话已结束') antMsg.success('已领取会话')
setSelectedId(sessionID)
await loadAll() await loadAll()
await loadDetail(selectedId) } catch (error) {
} catch { antMsg.error(error instanceof Error ? error.message : '领取失败')
antMsg.error('结束会话失败')
} }
} }
const selected = sessions.find(s => s.id === selectedId) const openTransfer = async () => {
const selectedCustomer = selected ? customers[selected.customer_id] : null if (!selected) return
try {
if (loading) { const response = await getAvailableAgents()
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div> setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id))
setTargetAgentID(undefined)
setTransferOpen(true)
} catch {
antMsg.error('加载在线客服失败')
}
} }
return ( const handleTransfer = async () => {
<div className="h-full flex"> if (!selected || !targetAgentID) return
{/* 左侧:会话列表 */} try {
<div className="w-[300px] flex-shrink-0 border-r border-neutral-200 bg-white flex flex-col"> await transferSession(selected.id, targetAgentID)
<div className="px-3 py-3 border-b border-neutral-100"> antMsg.success('会话已转接')
<div className="text-sm font-medium text-neutral-800">{user?.nickname || '客服'}</div> setTransferOpen(false)
<div className="text-xs text-neutral-400">线</div> setSelectedId(null)
</div> setDetail(null)
<div className="p-3 border-b border-neutral-100"> await loadAll()
<Input prefix={<SearchOutlined />} placeholder="搜索会话..." variant="borderless" size="small" /> } catch (error) {
</div> antMsg.error(error instanceof Error ? error.message : '转接失败')
<div className="flex-1 overflow-auto"> }
{sessions.length === 0 ? ( }
<div className="flex items-center justify-center h-full text-neutral-400 text-sm"></div>
) : (
sessions.map(s => {
const c = customers[s.customer_id]
return (
<div key={s.id}
className={`px-3 py-2.5 cursor-pointer border-b border-neutral-50 hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
onClick={() => setSelectedId(s.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: priorityColors[s.priority] || '#2563eb' }} />
<span className="text-sm font-medium text-neutral-800">{c ? c.name : `客户${s.customer_id}`}</span>
</div>
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div className="flex items-center justify-between mt-1 pl-4">
<span className="text-xs text-neutral-400">{statusLabels[s.status] || s.status}</span>
{s.satisfaction_score && <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span>}
</div>
</div>
)
})
)}
</div>
</div>
{/* 中间:聊天区 */} const handlePriority = async (priority: 'normal' | 'urgent') => {
<div className="flex-1 flex flex-col min-w-0 bg-white"> if (!selected) return
{selected && selectedCustomer ? ( try {
<> await updateSessionPriority(selected.id, priority)
<div className="h-12 px-4 flex items-center justify-between border-b border-neutral-100 flex-shrink-0"> antMsg.success('优先级已更新')
<div className="flex items-center gap-2"> await loadAll()
<span className="text-sm font-medium text-neutral-800">{selectedCustomer.name}</span> } catch (error) {
<span className={`text-xs px-1.5 py-0.5 rounded ${selected.priority === 'urgent' ? 'text-red-600 bg-red-50' : 'text-blue-600 bg-blue-50'}`}> antMsg.error(error instanceof Error ? error.message : '更新失败')
{priorityLabels[selected.priority] || selected.priority} }
</span> }
<span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span>
</div>
<div className="flex gap-2">
<span className="text-xs text-neutral-400 cursor-not-allowed"></span>
<span className="text-xs text-neutral-400 cursor-pointer hover:text-red-500" onClick={handleEnd}></span>
</div>
</div>
<div className="flex-1 overflow-auto p-4 flex flex-col gap-3">
{detailLoading ? (
<div className="flex-1 flex items-center justify-center"><Spin /></div>
) : detail && detail.messages.length > 0 ? (
detail.messages.map((msg, i) => (
<div key={i} className={`flex ${msg.sender === '客服' ? 'justify-start' : 'justify-end'}`}>
<div className={`max-w-[65%] rounded-lg px-3 py-2 text-sm ${msg.sender === '客服' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
{msg.content}
<div className={`text-xs mt-1 ${msg.sender === '客服' ? 'text-neutral-400' : 'text-white/60'}`}>{msg.time}</div>
</div>
</div>
))
) : (
<div className="flex-1 flex items-center justify-center text-neutral-400 text-sm"></div>
)}
<div ref={chatEndRef} />
</div>
<div className="p-3 border-t border-neutral-100 flex-shrink-0">
<div className="flex items-center gap-2 bg-neutral-50 rounded-lg px-3 py-2">
<input
className="flex-1 bg-transparent outline-none text-sm text-neutral-700 placeholder:text-neutral-400"
placeholder="输入消息... (Enter 发送)"
value={messageInput}
onChange={e => setMessageInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !sending) handleSend() }}
disabled={sending}
/>
{sending && <Spin size="small" />}
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-neutral-400"></div>
)}
</div>
{/* 右侧:客户信息面板 */} const handleEnd = async () => {
<div className="w-[300px] flex-shrink-0 border-l border-neutral-200 bg-white overflow-auto"> if (!selected) return
{selectedCustomer ? ( try {
<div className="p-4 space-y-4"> await endSession(selected.id, endReason)
<div className="flex items-center gap-3 pb-3 border-b border-neutral-100"> antMsg.success('会话已结束')
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-500 font-semibold"> setEndingOpen(false)
{selectedCustomer.name[0]} setSelectedId(null)
</div> setDetail(null)
<div> await loadAll()
<div className="text-sm font-medium text-neutral-800">{selectedCustomer.name}</div> } catch (error) {
<div className="text-xs text-neutral-400">{selectedCustomer.source || '未知渠道'}</div> antMsg.error(error instanceof Error ? error.message : '结束会话失败')
</div> }
</div> }
<div>
<div className="text-xs text-neutral-400 mb-1.5"></div> const handleAddNote = async () => {
<div className="text-sm text-neutral-700 space-y-1"> if (!selected || !noteInput.trim()) return
{selectedCustomer.phone && <div>{selectedCustomer.phone}</div>} setSavingNote(true)
{selectedCustomer.email && <div>{selectedCustomer.email}</div>} try {
</div> await addSessionNote(selected.id, noteInput)
</div> setNoteInput('')
<div> await loadDetail(selected.id, false)
<div className="text-xs text-neutral-400 mb-1.5"></div> } catch (error) {
<div className="flex flex-wrap gap-1"> antMsg.error(error instanceof Error ? error.message : '保存备注失败')
{((): string[] => { try { return JSON.parse(selectedCustomer.tags) } catch { return [] } })().map((t: string) => ( } finally {
<span key={t} className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-600">{t}</span> setSavingNote(false)
))} }
</div> }
</div>
<div> const renderSessionGroup = (title: string, items: Session[], color: string) => (
<div className="text-xs text-neutral-400 mb-1.5"></div> <div className="mb-3" key={title}>
<div className="grid grid-cols-2 gap-2"> <div className="px-3 py-1.5 text-xs font-medium text-neutral-500 flex items-center gap-1.5">
<div className="bg-neutral-50 rounded p-2 text-center"> <span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<div className="text-lg font-semibold text-neutral-800">{selectedCustomer.conversation_count}</div> {title} · {items.length}
<div className="text-xs text-neutral-400"></div> </div>
</div> {items.map(session => {
<div className="bg-neutral-50 rounded p-2 text-center"> const customer = customers[session.customer_id]
<div className="text-lg font-semibold text-green-600 flex items-center justify-center gap-0.5"> return <button key={session.id} type="button"
{selected?.satisfaction_score || '-'} className={`w-full text-left px-3 py-2.5 border-y border-neutral-50 hover:bg-neutral-50 ${selectedId === session.id ? 'bg-blue-50' : ''}`}
{selected?.satisfaction_score ? <StarFilled className="text-xs" /> : null} onClick={() => setSelectedId(session.id)}>
</div> <div className="flex items-center gap-2">
<div className="text-xs text-neutral-400"></div> <span className="w-2 h-2 rounded-full" style={{ backgroundColor: priorityColors[session.priority] || '#2563eb' }} />
</div> <span className="text-sm font-medium text-neutral-800 truncate flex-1">{customer?.name || `客户${session.customer_id}`}</span>
</div> {session.unread_count > 0 && <span className="min-w-5 h-5 px-1 rounded-full bg-red-500 text-white text-xs text-center leading-5">{session.unread_count > 99 ? '99+' : session.unread_count}</span>}
</div>
</div> </div>
) : null} <div className="flex justify-between items-center mt-1 pl-4 gap-2">
</div> <span className="text-xs text-neutral-400">{statusLabels[session.status]}</span>
{session.status === 'waiting' && user?.role === 'agent' ? <span role="button" tabIndex={0}
className="text-xs text-blue-600 hover:text-blue-700" onClick={event => { event.stopPropagation(); handleClaim(session.id) }}
onKeyDown={event => { if (event.key === 'Enter') { event.stopPropagation(); handleClaim(session.id) } }}></span> :
<span className="text-xs text-neutral-300">{new Date(session.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>}
</div>
</button>
})}
</div> </div>
) )
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
return <div className="h-full flex">
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/gif" className="hidden" onChange={event => { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }} />
<aside className="w-[300px] flex-shrink-0 border-r border-neutral-200 bg-white flex flex-col">
<div className="px-3 py-3 border-b border-neutral-100">
<div className="text-sm font-medium text-neutral-800">{user?.nickname || '客服'}</div>
<div className="text-xs text-green-600">线 · </div>
</div>
<div className="p-3 border-b border-neutral-100"><Input prefix={<SearchOutlined />} placeholder="搜索访客或会话 ID" value={search} onChange={event => setSearch(event.target.value)} size="small" allowClear /></div>
<div className="flex-1 overflow-auto py-2">
{filteredSessions.length === 0 ? <div className="text-center text-sm text-neutral-400 py-10"></div> : <>
{renderSessionGroup('紧急会话', urgentSessions, '#dc2626')}
{renderSessionGroup('等待中', waitingSessions, '#d97706')}
{renderSessionGroup('进行中', activeSessions, '#2563eb')}
</>}
</div>
</aside>
<main className="flex-1 flex flex-col min-w-0 bg-white">
{selected && selectedCustomer ? <>
<div className="h-14 px-4 flex items-center justify-between border-b border-neutral-100 flex-shrink-0">
<div className="min-w-0"><div className="flex items-center gap-2"><span className="text-sm font-medium text-neutral-800 truncate">{selectedCustomer.name}</span><span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">{priorityLabels[selected.priority]}</span><span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span></div><div className="text-xs text-neutral-400 mt-0.5"> #{selected.id}</div></div>
<div className="flex items-center gap-1">
<Dropdown menu={{ items: [{ key: 'urgent', label: '标记紧急', onClick: () => handlePriority('urgent') }, { key: 'normal', label: '标记普通', onClick: () => handlePriority('normal') }] }} disabled={!canOperate}><Button type="text" size="small" icon={<FlagOutlined />}></Button></Dropdown>
<Button type="text" size="small" icon={<FileTextOutlined />} onClick={() => setKnowledgeOpen(true)} disabled={!canOperate}></Button>
<Button type="text" size="small" icon={<SwapOutlined />} onClick={openTransfer} disabled={!canOperate}></Button>
<Button type="text" size="small" danger icon={<CheckCircleOutlined />} onClick={() => setEndingOpen(true)} disabled={!canOperate}></Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 flex flex-col gap-3">
{detailLoading ? <div className="flex-1 flex items-center justify-center"><Spin /></div> : detail?.messages.length ? detail.messages.map(message => <div key={message.id} className={`flex ${message.sender_type === 'agent' ? 'justify-start' : 'justify-end'}`}>
<div className={`max-w-[65%] rounded-lg px-3 py-2 text-sm ${message.sender_type === 'agent' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
{message.type === 'image' ? <img src={message.content} alt="聊天图片" className="max-w-64 max-h-64 rounded" /> : <div className="whitespace-pre-wrap break-words">{message.content}</div>}
<div className={`text-xs mt-1 ${message.sender_type === 'agent' ? 'text-neutral-400' : 'text-white/60'}`}>{message.sender_type === 'agent' ? '客服' : '访客'} · {new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</div>
</div>
</div>) : <div className="flex-1 flex items-center justify-center text-neutral-400 text-sm"></div>}
<div ref={chatEndRef} />
</div>
<div className="p-3 border-t border-neutral-100 flex-shrink-0">
{selected.status !== 'active' || !canOperate ? <div className="text-center text-sm text-neutral-400 py-2">{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束'}</div> : <>
<div className="flex items-center gap-1 mb-1">
<Dropdown menu={{ items: quickReplies.map((content, index) => ({ key: String(index), label: content, onClick: () => setMessageInput(content) })) }}><Button type="text" size="small"></Button></Dropdown>
<Tooltip title="搜索知识库"><Button type="text" size="small" icon={<FileTextOutlined />} onClick={() => setKnowledgeOpen(true)} /></Tooltip>
<Tooltip title="发送图片"><Button type="text" size="small" icon={<PaperClipOutlined />} onClick={() => fileInputRef.current?.click()} /></Tooltip>
</div>
<div className="flex items-end gap-2 bg-neutral-50 rounded-lg px-3 py-2">
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} bordered={false} className="!bg-transparent" placeholder="输入消息… Enter 发送,Shift+Enter 换行" value={messageInput}
onChange={event => { 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) } }} />
<Button type="primary" shape="circle" icon={sending ? <Spin size="small" /> : <SendOutlined />} disabled={!messageInput.trim() || sending} onClick={() => sendMessage(messageInput)} />
</div>
</>}
</div>
</> : <div className="flex-1 flex items-center justify-center text-neutral-400"></div>}
</main>
<aside className="w-[300px] flex-shrink-0 border-l border-neutral-200 bg-white overflow-auto">
{selectedCustomer && <div className="p-4 space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-neutral-100"><div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-500 font-semibold">{selectedCustomer.name[0]}</div><div><div className="text-sm font-medium text-neutral-800">{selectedCustomer.name}</div><div className="text-xs text-neutral-400">{selectedCustomer.source || '未知渠道'}</div></div></div>
<div><div className="text-xs text-neutral-400 mb-1.5"></div><div className="text-sm text-neutral-700 space-y-1">{selectedCustomer.phone && <div>{selectedCustomer.phone}</div>}{selectedCustomer.email && <div>{selectedCustomer.email}</div>}{!selectedCustomer.phone && !selectedCustomer.email && <div></div>}</div></div>
<div><div className="text-xs text-neutral-400 mb-1.5"></div><div className="flex flex-wrap gap-1">{((): string[] => { try { return JSON.parse(selectedCustomer.tags) } catch { return [] } })().map(tag => <span key={tag} className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-600">{tag}</span>)}</div></div>
<div className="grid grid-cols-3 gap-2"><div className="bg-neutral-50 rounded p-2 text-center"><div className="text-lg font-semibold text-neutral-800">{selectedCustomer.conversation_count}</div><div className="text-xs text-neutral-400"></div></div><div className="bg-neutral-50 rounded p-2 text-center"><div className="text-lg font-semibold text-green-600 flex items-center justify-center gap-0.5">{selected?.satisfaction_score || '-'}{selected?.satisfaction_score ? <StarFilled className="text-xs" /> : null}</div><div className="text-xs text-neutral-400"></div></div><div className="bg-neutral-50 rounded p-2 text-center"><div className="text-lg font-semibold text-neutral-800">{detail?.pendingCount || 0}</div><div className="text-xs text-neutral-400"></div></div></div>
<div><div className="text-xs text-neutral-400 mb-2"></div><div className="space-y-2 max-h-36 overflow-auto">{notes.length === 0 ? <div className="text-xs text-neutral-400"></div> : notes.map(note => <div key={note.id} className="bg-amber-50 text-amber-900 rounded p-2 text-xs whitespace-pre-wrap">{note.detail}<div className="text-amber-600/70 mt-1">{new Date(note.created_at).toLocaleString('zh-CN')}</div></div>)}</div>{canOperate && <div className="mt-2 flex gap-1"><Input size="small" maxLength={500} placeholder="添加仅客服可见的备注" value={noteInput} onChange={event => setNoteInput(event.target.value)} onPressEnter={handleAddNote} /><Button size="small" loading={savingNote} onClick={handleAddNote}></Button></div>}</div>
</div>}
</aside>
<Modal title="转接会话" open={transferOpen} onCancel={() => setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}>
<p className="text-sm text-neutral-500 mb-3">线</p><Select className="w-full" placeholder="选择客服" value={targetAgentID} onChange={setTargetAgentID} options={availableAgents.map(agent => ({ value: agent.id, label: agent.nickname }))} />
</Modal>
<Modal title="结束会话" open={endingOpen} onCancel={() => setEndingOpen(false)} onOk={handleEnd} okText="确认结束"><p className="text-sm text-neutral-500 mb-3"></p><Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} /></Modal>
<Modal title="图片预览" open={Boolean(imagePreview)} onCancel={() => setImagePreview(null)} onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }} okText="发送" okButtonProps={{ loading: sending }}><div className="flex justify-center"><img src={imagePreview || ''} alt="待发送图片预览" className="max-h-[420px] max-w-full rounded-lg" /></div></Modal>
<Modal title="知识库与快捷回复" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}><Input prefix={<SearchOutlined />} placeholder="搜索标题或内容" value={knowledgeKeyword} onChange={event => setKnowledgeKeyword(event.target.value)} allowClear className="mb-3" />{knowledgeLoading ? <div className="py-10 text-center"><Spin /></div> : <div className="space-y-2 max-h-96 overflow-auto">{knowledgeEntries.length === 0 ? <div className="text-center text-neutral-400 py-8"></div> : knowledgeEntries.map(entry => <button key={entry.id} type="button" className="w-full text-left border border-neutral-100 rounded-lg p-3 hover:border-blue-300 hover:bg-blue-50" onClick={() => { setMessageInput(entry.content); setKnowledgeOpen(false) }}><div className="text-sm font-medium text-neutral-800">{entry.title}</div><div className="text-xs text-neutral-500 mt-1 line-clamp-2">{entry.content}</div></button>)}</div>}</Modal>
</div>
} }
export default Dashboard export default Dashboard
+28 -8
View File
@@ -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 LoginParams { username: string; password: string }
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string } export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
export interface Session { export interface Session {
id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null 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 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 { export interface Customer {
id: number; tenant_id: number; name: string; phone: string; email: string; tags: string id: number; tenant_id: number; name: string; phone: string; email: string; tags: string
source: string; status: string; conversation_count: number; last_contact_at: 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<LoginResult>('/login', params) export const login = (params: LoginParams) => post<LoginResult>('/login', params)
// Sessions // 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() const search = new URLSearchParams()
if (params?.status) search.set('status', params.status) if (params?.status) search.set('status', params.status)
if (params?.priority) search.set('priority', params.priority) 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<Session>(`/sessions?${search}`) return getList<Session>(`/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 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 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<SessionEvent>(`/sessions/${id}/notes`, { content })
export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post<Message>(`/sessions/${id}/messages`, { content, type })
export const getAvailableAgents = () => get<AvailableAgent[]>('/agents/available')
// Customers // 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() const search = new URLSearchParams()
if (params?.search) search.set('search', params.search) if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status) 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<Customer>(`/customers?${search}`) return getList<Customer>(`/customers?${search}`)
} }
+10
View File
@@ -28,9 +28,11 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
const [showRating, setShowRating] = useState(false) const [showRating, setShowRating] = useState(false)
const [rated, setRated] = useState(false) const [rated, setRated] = useState(false)
const [sessionEnded, setSessionEnded] = useState(false) const [sessionEnded, setSessionEnded] = useState(false)
const [agentTyping, setAgentTyping] = useState(false)
const [ratingText, setRatingText] = useState('') const [ratingText, setRatingText] = useState('')
const pollRef = useRef<number | null>(null) const pollRef = useRef<number | null>(null)
const initRef = useRef(false) const initRef = useRef(false)
const typingTimerRef = useRef<number | null>(null)
const loadMessages = useCallback(async (sid?: number, token?: string) => { const loadMessages = useCallback(async (sid?: number, token?: string) => {
const s = sid || sessionId const s = sid || sessionId
@@ -101,6 +103,12 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
socket.onmessage = (event) => { socket.onmessage = (event) => {
try { try {
const payload = JSON.parse(event.data) 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.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) {
if (payload.type === 'session_updated' && payload.data?.status === 'ended') { if (payload.type === 'session_updated' && payload.data?.status === 'ended') {
setSessionEnded(true) setSessionEnded(true)
@@ -114,6 +122,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
} }
return () => { return () => {
socket.close() socket.close()
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
} }
}, [sessionId, visitorToken, open, loadMessages]) }, [sessionId, visitorToken, open, loadMessages])
@@ -200,6 +209,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
<div className="text-sm"></div> <div className="text-sm"></div>
</div> </div>
)} )}
{agentTyping && <div className="text-xs text-neutral-400"></div>}
{messages.map(msg => ( {messages.map(msg => (
<div key={msg.id} className={`flex ${msg.sender === 'visitor' ? 'justify-end' : 'justify-start'}`}> <div key={msg.id} className={`flex ${msg.sender === 'visitor' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${msg.sender === 'visitor' ? 'bg-blue-500 text-white' : 'bg-white text-neutral-700 border border-neutral-200'}`}> <div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${msg.sender === 'visitor' ? 'bg-blue-500 text-white' : 'bg-white text-neutral-700 border border-neutral-200'}`}>