完成客服工作台闭环
This commit is contained in:
@@ -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("超长文本未被拦截")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}).
|
||||
|
||||
@@ -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,9 +181,15 @@ 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 {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+282
-192
@@ -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<string, string> = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' }
|
||||
const priorityLabels: Record<string, string> = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' }
|
||||
const priorityColors: Record<string, string> = { urgent: '#dc2626', normal: '#2563eb' }
|
||||
const priorityLabels: Record<string, string> = { urgent: '紧急', normal: '普通' }
|
||||
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 { user } = useAuth()
|
||||
const [sessions, setSessions] = useState<SessionType[]>([])
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [customers, setCustomers] = useState<Record<number, Customer>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
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 [messageInput, setMessageInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
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 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 () => {
|
||||
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 : []
|
||||
const sessionList = Array.isArray(sessionRes.list) ? sessionRes.list : []
|
||||
const customerList = Array.isArray(customerRes.list) ? customerRes.list : []
|
||||
setSessions(sessionList)
|
||||
const map: Record<number, Customer> = {}
|
||||
customerList.forEach(c => { map[c.id] = c })
|
||||
setCustomers(map)
|
||||
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 (error) {
|
||||
antMsg.error(error instanceof Error ? error.message : '领取失败')
|
||||
}
|
||||
}
|
||||
|
||||
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('结束会话失败')
|
||||
antMsg.error('加载在线客服失败')
|
||||
}
|
||||
}
|
||||
|
||||
const selected = sessions.find(s => s.id === selectedId)
|
||||
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
|
||||
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 : '转接失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
{/* 左侧:会话列表 */}
|
||||
<div 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-neutral-400">在线</div>
|
||||
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 : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
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) => (
|
||||
<div className="mb-3" key={title}>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-neutral-500 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
{title} · {items.length}
|
||||
</div>
|
||||
<div className="p-3 border-b border-neutral-100">
|
||||
<Input prefix={<SearchOutlined />} placeholder="搜索会话..." variant="borderless" size="small" />
|
||||
</div>
|
||||
<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">
|
||||
{items.map(session => {
|
||||
const customer = customers[session.customer_id]
|
||||
return <button key={session.id} type="button"
|
||||
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' : ''}`}
|
||||
onClick={() => setSelectedId(session.id)}>
|
||||
<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>
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: priorityColors[session.priority] || '#2563eb' }} />
|
||||
<span className="text-sm font-medium text-neutral-800 truncate flex-1">{customer?.name || `客户${session.customer_id}`}</span>
|
||||
{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>
|
||||
<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 className="flex justify-between items-center mt-1 pl-4 gap-2">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 中间:聊天区 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-white">
|
||||
{selected && selectedCustomer ? (
|
||||
<>
|
||||
<div className="h-12 px-4 flex items-center justify-between border-b border-neutral-100 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-800">{selectedCustomer.name}</span>
|
||||
<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'}`}>
|
||||
{priorityLabels[selected.priority] || selected.priority}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span>
|
||||
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="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 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 && 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>
|
||||
{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>) : <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" />}
|
||||
{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 className="flex-1 flex items-center justify-center text-neutral-400">选择一个会话</div>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
</> : <div className="flex-1 flex items-center justify-center text-neutral-400">选择一个会话开始处理</div>}
|
||||
</main>
|
||||
|
||||
{/* 右侧:客户信息面板 */}
|
||||
<div className="w-[300px] flex-shrink-0 border-l border-neutral-200 bg-white overflow-auto">
|
||||
{selectedCustomer ? (
|
||||
<div className="p-4 space-y-4">
|
||||
<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]}
|
||||
<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>
|
||||
<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>}
|
||||
</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((t: string) => (
|
||||
<span key={t} className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-600">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400 mb-1.5">统计数据</div>
|
||||
<div className="grid grid-cols-2 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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
|
||||
+25
-5
@@ -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
|
||||
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<LoginResult>('/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?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
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 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<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
|
||||
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?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
return getList<Customer>(`/customers?${search}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const initRef = useRef(false)
|
||||
const typingTimerRef = useRef<number | null>(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 }) => {
|
||||
<div className="text-sm">欢迎咨询,请描述您的问题</div>
|
||||
</div>
|
||||
)}
|
||||
{agentTyping && <div className="text-xs text-neutral-400">客服正在输入…</div>}
|
||||
{messages.map(msg => (
|
||||
<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'}`}>
|
||||
|
||||
Reference in New Issue
Block a user