From 138f0514a9672c581ae5319ecd0fa562da32f452 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 27 May 2026 06:43:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E5=8F=AF=E4=BB=A5=E7=BB=99?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=AE=BE=E7=BD=AE=E4=B8=AA=E4=BA=BA=E5=A4=87?= =?UTF-8?q?=E6=B3=A8,=20=E8=AE=BE=E7=BD=AE=20=E5=BF=AB=E6=8D=B7=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=20=E5=BB=BA=E7=BE=A4=E8=87=AA=E5=8A=A8=E8=AF=9D?= =?UTF-8?q?=E6=9C=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/chat.go | 1 + backend/internal/model/chat_quick_reply.go | 17 + backend/internal/modules/chat/dto.go | 36 ++ backend/internal/modules/chat/handler.go | 157 ++++++++- backend/internal/modules/chat/repository.go | 317 +++++++++++++++++- backend/internal/modules/chat/service.go | 76 +++++ backend/internal/router/router.go | 9 + .../migrations/000007_chat_enhancements.sql | 19 ++ frontend/components.d.ts | 4 + frontend/src/api/chats.ts | 71 +++- frontend/src/views/admin/AdminChatsView.vue | 266 +++++++++++++-- .../views/admin/AdminSystemConfigsView.vue | 3 + .../admin/components/AutoWelcomeConfig.vue | 136 ++++++++ .../admin/components/QuickReplyDialog.vue | 253 ++++++++++++++ .../views/admin/components/TransferDialog.vue | 131 ++++++++ 15 files changed, 1462 insertions(+), 34 deletions(-) create mode 100644 backend/internal/model/chat_quick_reply.go create mode 100644 backend/migrations/000007_chat_enhancements.sql create mode 100644 frontend/src/views/admin/components/AutoWelcomeConfig.vue create mode 100644 frontend/src/views/admin/components/QuickReplyDialog.vue create mode 100644 frontend/src/views/admin/components/TransferDialog.vue diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index 87ee928..27af0f6 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -29,6 +29,7 @@ type ChatParticipant struct { ParticipantType string `gorm:"size:16;not null;index" json:"participant_type"` ParticipantID uint64 `gorm:"not null;index" json:"participant_id"` Role string `gorm:"size:32;not null" json:"role"` + Remark string `gorm:"size:128;not null;default:''" json:"remark"` LastReadAt *time.Time `json:"last_read_at"` JoinedAt time.Time `json:"joined_at"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/model/chat_quick_reply.go b/backend/internal/model/chat_quick_reply.go new file mode 100644 index 0000000..087ce2a --- /dev/null +++ b/backend/internal/model/chat_quick_reply.go @@ -0,0 +1,17 @@ +package model + +import "time" + +type ChatQuickReply struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + AdminUserID uint64 `gorm:"not null;default:0;index" json:"admin_user_id"` // 0=全局, >0=个人 + Title string `gorm:"size:64;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + CreatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +func (ChatQuickReply) TableName() string { + return "chat_quick_replies" +} diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index d8397d1..66d54a5 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -29,6 +29,7 @@ type ParticipantDTO struct { ParticipantType string `json:"participant_type"` ParticipantID uint64 `json:"participant_id"` Role string `json:"role"` + Remark string `json:"remark"` DisplayName string `json:"display_name"` AvatarURL string `json:"avatar_url"` LastReadAt *time.Time `json:"last_read_at"` @@ -54,6 +55,41 @@ type SendMessageRequest struct { Content string `json:"content" binding:"required"` } +type TransferRequest struct { + ToAdminID uint64 `json:"to_admin_id" binding:"required"` +} + +type UpdateRemarkRequest struct { + Remark string `json:"remark"` +} + +type SupportAdminDTO struct { + ID uint64 `json:"id"` + Nickname string `json:"nickname"` + ChatCount int64 `json:"chat_count"` +} + +type QuickReplyDTO struct { + ID uint64 `json:"id"` + AdminUserID uint64 `json:"admin_user_id"` + Title string `json:"title"` + Content string `json:"content"` + SortOrder int `json:"sort_order"` + IsGlobal bool `json:"is_global"` +} + +type CreateQuickReplyRequest struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + SortOrder int `json:"sort_order"` +} + +type UpdateQuickReplyRequest struct { + Title string `json:"title"` + Content string `json:"content"` + SortOrder *int `json:"sort_order"` +} + type PaginatedResult struct { Items interface{} `json:"items"` Total int64 `json:"total"` diff --git a/backend/internal/modules/chat/handler.go b/backend/internal/modules/chat/handler.go index ce1e464..d0a769a 100644 --- a/backend/internal/modules/chat/handler.go +++ b/backend/internal/modules/chat/handler.go @@ -34,7 +34,15 @@ func (h *Handler) AdminList(c *gin.Context) { response.Unauthorized(c, "缺少管理员上下文") return } - h.list(c, Principal{Type: "admin", ID: adminID}) + filter := c.DefaultQuery("filter", "all") + page, pageSize := parsePagination(c) + principal := Principal{Type: "admin", ID: adminID} + result, err := h.service.ListConversationsWithFilter(principal, page, pageSize, filter) + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, result) } func (h *Handler) Detail(c *gin.Context) { @@ -127,6 +135,153 @@ func (h *Handler) AdminMarkRead(c *gin.Context) { h.markRead(c, Principal{Type: "admin", ID: adminID}) } +func (h *Handler) AdminTransfer(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c, "id") + if !ok { + return + } + var req TransferRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "目标客服 ID 不能为空") + return + } + principal := Principal{Type: "admin", ID: adminID} + if err := h.service.TransferConversation(principal, id, req); err != nil { + writeChatError(c, err) + return + } + response.OK(c, gin.H{"transferred": true}) +} + +func (h *Handler) AdminSupportAdmins(c *gin.Context) { + admins, err := h.service.GetAvailableSupportAdmins() + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, admins) +} + +func (h *Handler) AdminUpdateRemark(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c, "id") + if !ok { + return + } + var req UpdateRemarkRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求参数错误") + return + } + principal := Principal{Type: "admin", ID: adminID} + if err := h.service.UpdateRemark(principal, id, req); err != nil { + writeChatError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func (h *Handler) AdminListQuickReplies(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + replies, err := h.service.ListQuickReplies(adminID) + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, replies) +} + +func (h *Handler) AdminCreateQuickReply(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req CreateQuickReplyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "标题和内容不能为空") + return + } + reply, err := h.service.CreateQuickReply(adminID, req) + if err != nil { + writeChatError(c, err) + return + } + response.Created(c, reply) +} + +func (h *Handler) AdminUpdateQuickReply(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c, "id") + if !ok { + return + } + var req UpdateQuickReplyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求参数错误") + return + } + if err := h.service.UpdateQuickReply(adminID, id, req); err != nil { + writeChatError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func (h *Handler) AdminDeleteQuickReply(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c, "id") + if !ok { + return + } + if err := h.service.DeleteQuickReply(adminID, id); err != nil { + writeChatError(c, err) + return + } + response.OK(c, gin.H{"deleted": true}) +} + +func (h *Handler) AdminGetAutoWelcome(c *gin.Context) { + message := h.service.GetAutoWelcomeMessage() + response.OK(c, gin.H{"message": message}) +} + +func (h *Handler) AdminUpdateAutoWelcome(c *gin.Context) { + var req struct { + Message string `json:"message" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "话术内容不能为空") + return + } + if err := h.service.UpdateAutoWelcomeMessage(req.Message); err != nil { + writeChatError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + func (h *Handler) list(c *gin.Context, principal Principal) { page, pageSize := parsePagination(c) result, err := h.service.ListConversations(principal, page, pageSize) diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go index a38dc84..1c266b9 100644 --- a/backend/internal/modules/chat/repository.go +++ b/backend/internal/modules/chat/repository.go @@ -83,12 +83,18 @@ func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatC } } + // 获取自动话术 + autoMessage := "订单已支付,群聊已创建。租客、号主和客服可在这里沟通交接与结账问题。" + var cfg model.SystemConfig + if err := tx.Where("`key` = ?", "chat.auto_welcome_message").First(&cfg).Error; err == nil && cfg.Value != "" { + autoMessage = cfg.Value + } message := model.ChatMessage{ ConversationID: conversation.ID, SenderType: "system", SenderRole: "system", ContentType: "system", - Content: "订单已支付,群聊已创建。租客、号主和客服可在这里沟通交接与结账问题。", + Content: autoMessage, AttachmentURLS: emptyJSONList(), } if err := tx.Create(&message).Error; err != nil { @@ -499,6 +505,54 @@ func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO } func defaultSupportAdminID(tx *gorm.DB) uint64 { + // 查询所有有 chat:view 权限且状态为 active 的管理员 + var adminIDs []uint64 + err := tx.Table("admin_users AS au"). + Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). + Joins("JOIN role_permissions AS rp ON rp.role_id = aur.role_id"). + Joins("JOIN permissions AS p ON p.id = rp.permission_id"). + Where("au.status = ? AND p.code = ?", "active", "chat:view"). + Distinct("au.id"). + Pluck("au.id", &adminIDs).Error + if err != nil || len(adminIDs) == 0 { + // 回退到原有逻辑 + return fallbackSupportAdminID(tx) + } + + // 统计每个客服当前负责的会话数,选择负载最少的 + type adminLoad struct { + AdminID uint64 + Count int64 + } + var loads []adminLoad + tx.Table("chat_participants AS cp"). + Select("cp.participant_id AS admin_id, COUNT(*) AS count"). + Where("cp.participant_type = ? AND cp.role = ? AND cp.participant_id IN ?", "admin", "support", adminIDs). + Group("cp.participant_id"). + Scan(&loads) + + loadMap := make(map[uint64]int64) + for _, l := range loads { + loadMap[l.AdminID] = l.Count + } + + // 找到负载最少的客服 + var minLoad int64 = -1 + var selectedID uint64 + for _, id := range adminIDs { + count := loadMap[id] + if minLoad < 0 || count < minLoad { + minLoad = count + selectedID = id + } + } + if selectedID > 0 { + return selectedID + } + return fallbackSupportAdminID(tx) +} + +func fallbackSupportAdminID(tx *gorm.DB) uint64 { var cfg model.SystemConfig if err := tx.Where("`key` = ?", "chat.default_support_admin_id").First(&cfg).Error; err == nil { id, parseErr := strconv.ParseUint(cfg.Value, 10, 64) @@ -570,6 +624,175 @@ func truncatePreview(content string) string { return string(runes[:80]) } +// TransferConversation 转接会话给其他客服 +func (r *Repository) TransferConversation(principal Principal, conversationID uint64, toAdminID uint64) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // 验证当前操作者是会话参与者 + if _, err := r.findParticipant(tx, principal, conversationID, false); err != nil { + return err + } + // 验证目标客服存在且活跃 + if !adminActive(tx, toAdminID) { + return fmt.Errorf("目标客服不存在或已禁用") + } + // 检查目标客服是否已有该会话 + var count int64 + if err := tx.Model(&model.ChatParticipant{}). + Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, "admin", toAdminID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return fmt.Errorf("该客服已在会话中") + } + // 删除原客服参与者 + if err := tx.Where("conversation_id = ? AND participant_type = ? AND role = ?", conversationID, "admin", "support"). + Delete(&model.ChatParticipant{}).Error; err != nil { + return err + } + // 添加新客服参与者 + participant := model.ChatParticipant{ + ConversationID: conversationID, + ParticipantType: "admin", + ParticipantID: toAdminID, + Role: "support", + JoinedAt: time.Now(), + } + if err := tx.Create(&participant).Error; err != nil { + return err + } + // 添加系统消息记录转接 + message := model.ChatMessage{ + ConversationID: conversationID, + SenderType: "system", + SenderRole: "system", + ContentType: "system", + Content: "会话已转接给其他客服", + AttachmentURLS: emptyJSONList(), + } + if err := tx.Create(&message).Error; err != nil { + return err + } + return nil + }) +} + +// GetAvailableSupportAdmins 获取可用客服列表及其会话数 +func (r *Repository) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) { + // 查询所有有 chat:view 权限且状态为 active 的管理员 + type adminRow struct { + ID uint64 + Nickname string + } + var admins []adminRow + err := r.db.Table("admin_users AS au"). + Select("au.id, au.nickname"). + Joins("JOIN admin_user_roles AS aur ON aur.admin_user_id = au.id"). + Joins("JOIN role_permissions AS rp ON rp.role_id = aur.role_id"). + Joins("JOIN permissions AS p ON p.id = rp.permission_id"). + Where("au.status = ? AND p.code = ?", "active", "chat:view"). + Distinct("au.id"). + Scan(&admins).Error + if err != nil { + return nil, err + } + + // 统计每个客服的会话数 + type loadRow struct { + AdminID uint64 + Count int64 + } + var loads []loadRow + adminIDs := make([]uint64, len(admins)) + for i, a := range admins { + adminIDs[i] = a.ID + } + if len(adminIDs) > 0 { + r.db.Table("chat_participants"). + Select("participant_id AS admin_id, COUNT(*) AS count"). + Where("participant_type = ? AND role = ? AND participant_id IN ?", "admin", "support", adminIDs). + Group("participant_id"). + Scan(&loads) + } + loadMap := make(map[uint64]int64) + for _, l := range loads { + loadMap[l.AdminID] = l.Count + } + + result := make([]SupportAdminDTO, len(admins)) + for i, a := range admins { + result[i] = SupportAdminDTO{ + ID: a.ID, + Nickname: a.Nickname, + ChatCount: loadMap[a.ID], + } + } + return result, nil +} + +// ListConversationsWithFilter 支持筛选的会话列表 +func (r *Repository) ListConversationsWithFilter(principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) { + page, pageSize = normalizePagination(page, pageSize) + var total int64 + + countDB := r.db.Table("chat_conversations AS c"). + Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id") + + switch filter { + case "mine": + // 只看我的会话 + countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + case "unassigned": + // 未分配客服的会话 + countDB = countDB.Where("c.id NOT IN (?)", + r.db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) + default: + // 全部会话(admin 可以看所有) + if principal.Type == "admin" { + // 管理员看所有会话 + } else { + countDB = countDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + } + } + + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + var rows []conversationRow + offset := (page - 1) * pageSize + + queryDB := r.conversationQuery(principal) + switch filter { + case "mine": + queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + case "unassigned": + queryDB = queryDB.Where("c.id NOT IN (?)", + r.db.Table("chat_participants").Select("conversation_id").Where("participant_type = ? AND role = ?", "admin", "support")) + default: + if principal.Type == "admin" { + // 管理员看所有会话 + } else { + queryDB = queryDB.Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + } + } + + err := queryDB. + Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC"). + Offset(offset). + Limit(pageSize). + Scan(&rows).Error + if err != nil { + return nil, err + } + + items := make([]ConversationDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO(nil)) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + func fallbackName(participantType string, id uint64, name string) string { if name != "" { return name @@ -602,6 +825,98 @@ func emptyJSONList() datatypes.JSON { return datatypes.JSON(raw) } +// UpdateRemark 更新会话备注 +func (r *Repository) UpdateRemark(principal Principal, conversationID uint64, remark string) error { + return r.db.Model(&model.ChatParticipant{}). + Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). + Update("remark", remark).Error +} + +// ListQuickReplies 获取快捷回复列表(个人 + 全局) +func (r *Repository) ListQuickReplies(adminID uint64) ([]QuickReplyDTO, error) { + var replies []model.ChatQuickReply + err := r.db.Where("admin_user_id = ? OR admin_user_id = 0", adminID). + Order("admin_user_id DESC, sort_order ASC, id ASC"). + Find(&replies).Error + if err != nil { + return nil, err + } + result := make([]QuickReplyDTO, len(replies)) + for i, reply := range replies { + result[i] = QuickReplyDTO{ + ID: reply.ID, + AdminUserID: reply.AdminUserID, + Title: reply.Title, + Content: reply.Content, + SortOrder: reply.SortOrder, + IsGlobal: reply.AdminUserID == 0, + } + } + return result, nil +} + +// CreateQuickReply 创建快捷回复 +func (r *Repository) CreateQuickReply(adminID uint64, req CreateQuickReplyRequest) (*QuickReplyDTO, error) { + reply := model.ChatQuickReply{ + AdminUserID: adminID, + Title: req.Title, + Content: req.Content, + SortOrder: req.SortOrder, + } + if err := r.db.Create(&reply).Error; err != nil { + return nil, err + } + return &QuickReplyDTO{ + ID: reply.ID, + AdminUserID: reply.AdminUserID, + Title: reply.Title, + Content: reply.Content, + SortOrder: reply.SortOrder, + IsGlobal: false, + }, nil +} + +// UpdateQuickReply 更新快捷回复 +func (r *Repository) UpdateQuickReply(adminID uint64, replyID uint64, req UpdateQuickReplyRequest) error { + query := r.db.Model(&model.ChatQuickReply{}).Where("id = ? AND (admin_user_id = ? OR admin_user_id = 0)", replyID, adminID) + updates := map[string]interface{}{} + if req.Title != "" { + updates["title"] = req.Title + } + if req.Content != "" { + updates["content"] = req.Content + } + if req.SortOrder != nil { + updates["sort_order"] = *req.SortOrder + } + if len(updates) == 0 { + return nil + } + return query.Updates(updates).Error +} + +// DeleteQuickReply 删除快捷回复 +func (r *Repository) DeleteQuickReply(adminID uint64, replyID uint64) error { + return r.db.Where("id = ? AND admin_user_id = ?", replyID, adminID). + Delete(&model.ChatQuickReply{}).Error +} + +// GetAutoWelcomeMessage 获取建群自动话术 +func (r *Repository) GetAutoWelcomeMessage() string { + var cfg model.SystemConfig + if err := r.db.Where("`key` = ?", "chat.auto_welcome_message").First(&cfg).Error; err != nil { + return "欢迎加入订单群聊!如有任何问题,请随时沟通。" + } + return cfg.Value +} + +// UpdateAutoWelcomeMessage 更新建群自动话术 +func (r *Repository) UpdateAutoWelcomeMessage(message string) error { + return r.db.Model(&model.SystemConfig{}). + Where("`key` = ?", "chat.auto_welcome_message"). + Update("value", message).Error +} + func decodeStringList(raw datatypes.JSON) []string { if len(raw) == 0 { return []string{} diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go index b477298..1a7170b 100644 --- a/backend/internal/modules/chat/service.go +++ b/backend/internal/modules/chat/service.go @@ -68,3 +68,79 @@ func (s *Service) MarkRead(principal Principal, conversationID uint64) error { } return s.repo.MarkRead(principal, conversationID) } + +func (s *Service) TransferConversation(principal Principal, conversationID uint64, req TransferRequest) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if conversationID == 0 || req.ToAdminID == 0 { + return ErrInvalidMessage + } + return s.repo.TransferConversation(principal, conversationID, req.ToAdminID) +} + +func (s *Service) GetAvailableSupportAdmins() ([]SupportAdminDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.GetAvailableSupportAdmins() +} + +func (s *Service) ListConversationsWithFilter(principal Principal, page, pageSize int, filter string) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListConversationsWithFilter(principal, page, pageSize, filter) +} + +func (s *Service) UpdateRemark(principal Principal, conversationID uint64, req UpdateRemarkRequest) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.UpdateRemark(principal, conversationID, req.Remark) +} + +func (s *Service) ListQuickReplies(adminID uint64) ([]QuickReplyDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListQuickReplies(adminID) +} + +func (s *Service) CreateQuickReply(adminID uint64, req CreateQuickReplyRequest) (*QuickReplyDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Title == "" || req.Content == "" { + return nil, ErrInvalidMessage + } + return s.repo.CreateQuickReply(adminID, req) +} + +func (s *Service) UpdateQuickReply(adminID uint64, replyID uint64, req UpdateQuickReplyRequest) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.UpdateQuickReply(adminID, replyID, req) +} + +func (s *Service) DeleteQuickReply(adminID uint64, replyID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.DeleteQuickReply(adminID, replyID) +} + +func (s *Service) GetAutoWelcomeMessage() string { + if s.repo == nil { + return "" + } + return s.repo.GetAutoWelcomeMessage() +} + +func (s *Service) UpdateAutoWelcomeMessage(message string) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.UpdateAutoWelcomeMessage(message) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 071237f..63a41ad 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -303,6 +303,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/chats/:id/messages", requirePerm("chat:view"), chatHandler.AdminMessages) adminRoutes.POST("/chats/:id/messages", requirePerm("chat:send"), chatHandler.AdminSend) adminRoutes.POST("/chats/:id/read", requirePerm("chat:view"), chatHandler.AdminMarkRead) + adminRoutes.POST("/chats/:id/transfer", requirePerm("chat:send"), chatHandler.AdminTransfer) + adminRoutes.GET("/chats/support-admins", requirePerm("chat:view"), chatHandler.AdminSupportAdmins) + adminRoutes.PUT("/chats/:id/remark", requirePerm("chat:send"), chatHandler.AdminUpdateRemark) + adminRoutes.GET("/chats/quick-replies", requirePerm("chat:view"), chatHandler.AdminListQuickReplies) + adminRoutes.POST("/chats/quick-replies", requirePerm("chat:send"), chatHandler.AdminCreateQuickReply) + adminRoutes.PUT("/chats/quick-replies/:id", requirePerm("chat:send"), chatHandler.AdminUpdateQuickReply) + adminRoutes.DELETE("/chats/quick-replies/:id", requirePerm("chat:send"), chatHandler.AdminDeleteQuickReply) + adminRoutes.GET("/chats/auto-welcome", requirePerm("system_config:view"), chatHandler.AdminGetAutoWelcome) + adminRoutes.PUT("/chats/auto-welcome", requirePerm("system_config:update"), chatHandler.AdminUpdateAutoWelcome) // 角色管理 adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List) diff --git a/backend/migrations/000007_chat_enhancements.sql b/backend/migrations/000007_chat_enhancements.sql new file mode 100644 index 0000000..466a571 --- /dev/null +++ b/backend/migrations/000007_chat_enhancements.sql @@ -0,0 +1,19 @@ +-- 会话备注字段(个人级别) +ALTER TABLE chat_participants ADD COLUMN remark VARCHAR(128) DEFAULT '' AFTER last_read_at; + +-- 快捷回复表 +CREATE TABLE IF NOT EXISTS chat_quick_replies ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + admin_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '0=全局, >0=个人', + title VARCHAR(64) NOT NULL COMMENT '快捷回复标题', + content TEXT NOT NULL COMMENT '回复内容', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_admin_user_id (admin_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快捷回复模板'; + +-- 建群自动话术配置 +INSERT INTO system_configs (`key`, `value`, description) VALUES +('chat.auto_welcome_message', '欢迎加入订单群聊!如有任何问题,请随时沟通。', '建群后自动发送的欢迎话术') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/frontend/components.d.ts b/frontend/components.d.ts index bc854e0..5a149eb 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -18,6 +18,9 @@ declare module 'vue' { ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] @@ -26,6 +29,7 @@ declare module 'vue' { ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] + ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElSelect: typeof import('element-plus/es')['ElSelect'] diff --git a/frontend/src/api/chats.ts b/frontend/src/api/chats.ts index 69c9954..6820816 100644 --- a/frontend/src/api/chats.ts +++ b/frontend/src/api/chats.ts @@ -7,6 +7,7 @@ export interface ChatParticipant { participant_type: 'user' | 'admin' participant_id: number role: 'renter' | 'owner' | 'support' + remark: string display_name: string avatar_url: string last_read_at?: string @@ -78,9 +79,9 @@ export async function markChatRead(id: number) { return data.data } -export async function fetchAdminChats(page = 1, pageSize = 50) { +export async function fetchAdminChats(page = 1, pageSize = 50, filter = 'all') { const { data } = await apiClient.get>>('/admin/chats', { - params: { page, page_size: pageSize }, + params: { page, page_size: pageSize, filter }, }) return data.data } @@ -106,3 +107,69 @@ export async function markAdminChatRead(id: number) { const { data } = await apiClient.post>(`/admin/chats/${id}/read`) return data.data } + +export interface SupportAdmin { + id: number + nickname: string + chat_count: number +} + +export async function fetchSupportAdmins() { + const { data } = await apiClient.get>('/admin/chats/support-admins') + return data.data +} + +export async function transferChat(id: number, toAdminId: number) { + const { data } = await apiClient.post>(`/admin/chats/${id}/transfer`, { + to_admin_id: toAdminId, + }) + return data.data +} + +export async function updateChatRemark(id: number, remark: string) { + const { data } = await apiClient.put>(`/admin/chats/${id}/remark`, { remark }) + return data.data +} + +export interface QuickReply { + id: number + admin_user_id: number + title: string + content: string + sort_order: number + is_global: boolean +} + +export async function fetchQuickReplies() { + const { data } = await apiClient.get>('/admin/chats/quick-replies') + return data.data +} + +export async function createQuickReply(title: string, content: string, sortOrder = 0) { + const { data } = await apiClient.post>('/admin/chats/quick-replies', { + title, + content, + sort_order: sortOrder, + }) + return data.data +} + +export async function updateQuickReply(id: number, updates: { title?: string; content?: string; sort_order?: number }) { + const { data } = await apiClient.put>(`/admin/chats/quick-replies/${id}`, updates) + return data.data +} + +export async function deleteQuickReply(id: number) { + const { data } = await apiClient.delete>(`/admin/chats/quick-replies/${id}`) + return data.data +} + +export async function fetchAutoWelcomeMessage() { + const { data } = await apiClient.get>('/admin/chats/auto-welcome') + return data.data.message +} + +export async function updateAutoWelcomeMessage(message: string) { + const { data } = await apiClient.put>('/admin/chats/auto-welcome', { message }) + return data.data +} diff --git a/frontend/src/views/admin/AdminChatsView.vue b/frontend/src/views/admin/AdminChatsView.vue index c09dee7..580a2ec 100644 --- a/frontend/src/views/admin/AdminChatsView.vue +++ b/frontend/src/views/admin/AdminChatsView.vue @@ -5,13 +5,18 @@ import { fetchAdminChat, fetchAdminChatMessages, fetchAdminChats, + fetchQuickReplies, markAdminChatRead, sendAdminChatMessage, + updateChatRemark, type ChatConversation, type ChatMessage, + type QuickReply, } from '@/api/chats' import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE' import { formatDateMinute } from '@/utils/time' +import TransferDialog from './components/TransferDialog.vue' +import QuickReplyDialog from './components/QuickReplyDialog.vue' const currentAdminId = Number(localStorage.getItem('admin_id') || 0) @@ -23,12 +28,30 @@ const messageLoading = ref(false) const sending = ref(false) const content = ref('') const listRef = ref(null) +const filter = ref<'all' | 'mine' | 'unassigned'>('mine') +const transferVisible = ref(false) +const quickReplyVisible = ref(false) +const quickReplies = ref([]) +const remarkEditing = ref(false) +const remarkValue = ref('') const activeMembers = computed(() => { const participants = active.value?.participants || [] - return participants.map(item => `${roleLabel(item.role)}:${item.display_name}`).join(' / ') + return participants.map(item => { + const remark = getParticipantRemark(item) + const name = remark ? `${remark}(${item.display_name})` : item.display_name + return `${roleLabel(item.role)}:${name}` + }).join(' / ') }) +function getParticipantRemark(participant: any) { + if (!active.value) return '' + const myParticipant = active.value.participants?.find( + p => p.participant_type === 'admin' && p.participant_id === currentAdminId + ) + return myParticipant?.remark || '' +} + function handleSSEEvent(event: ChatEvent) { if (event.type === 'conversation_updated') { loadConversations(false) @@ -59,13 +82,13 @@ const { onEvent } = useChatSSE('admin', '/api/admin/chats/events') onEvent(handleSSEEvent) onMounted(async () => { - await loadConversations() + await Promise.all([loadConversations(), loadQuickReplies()]) }) async function loadConversations(showLoading = true) { if (showLoading) loading.value = true try { - const res = await fetchAdminChats(1, 100) + const res = await fetchAdminChats(1, 100, filter.value) conversations.value = res.items const first = conversations.value[0] if (!active.value && first) { @@ -78,6 +101,12 @@ async function loadConversations(showLoading = true) { } } +async function loadQuickReplies() { + try { + quickReplies.value = await fetchQuickReplies() + } catch { /* ignore */ } +} + async function openConversation(item: ChatConversation) { messageLoading.value = true try { @@ -85,6 +114,8 @@ async function openConversation(item: ChatConversation) { await loadMessages(item.id) await markAdminChatRead(item.id) await loadConversations(false) + remarkEditing.value = false + remarkValue.value = '' } catch { ElMessage.error('会话详情加载失败') } finally { @@ -115,6 +146,49 @@ async function handleSend() { } } +function handleQuickReplySelect(reply: QuickReply) { + content.value = reply.content + quickReplyVisible.value = false +} + +function handleFilterChange(val: string) { + filter.value = val as typeof filter.value + active.value = null + messages.value = [] + loadConversations() +} + +function handleTransferSuccess() { + loadConversations(false) + if (active.value) { + loadMessages(active.value.id, false) + } +} + +async function startEditRemark() { + if (!active.value) return + const myParticipant = active.value.participants?.find( + p => p.participant_type === 'admin' && p.participant_id === currentAdminId + ) + remarkValue.value = myParticipant?.remark || active.value.title + remarkEditing.value = true +} + +async function saveRemark() { + if (!active.value) return + try { + await updateChatRemark(active.value.id, remarkValue.value) + ElMessage.success('备注已更新') + remarkEditing.value = false + await fetchAdminChat(active.value.id).then(chat => { + active.value = chat + }) + await loadConversations(false) + } catch { + ElMessage.error('更新备注失败') + } +} + function scrollBottom() { const el = listRef.value if (!el) return @@ -135,6 +209,18 @@ function senderLabel(item: ChatMessage) { if (item.sender_type === 'system') return '系统' return `${roleLabel(item.sender_role)} · ${item.sender_name}` } + +function getConversationTitle(item: ChatConversation) { + const myParticipant = item.participants?.find( + p => p.participant_type === 'admin' && p.participant_id === currentAdminId + ) + return myParticipant?.remark || item.title +} + +function getSupportName(item: ChatConversation) { + const support = item.participants?.find(p => p.role === 'support') + return support?.display_name || '未分配' +} @@ -231,6 +382,11 @@ function senderLabel(item: ChatMessage) { color: #6b7280; } +.head-right { + display: flex; + gap: 8px; +} + .chat-workbench { display: grid; min-height: 640px; @@ -247,6 +403,11 @@ function senderLabel(item: ChatMessage) { background: #f8fafc; } +.filter-tabs { + padding: 12px; + border-bottom: 1px solid #e5e7eb; +} + .conversation-row { position: relative; display: block; @@ -292,10 +453,19 @@ function senderLabel(item: ChatMessage) { white-space: nowrap; } -.conversation-row em { - position: absolute; - right: 12px; - bottom: 12px; +.row-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.support-name { + color: #8a94a6; + font-size: 12px; +} + +.row-meta em { min-width: 18px; height: 18px; padding: 0 5px; @@ -317,23 +487,41 @@ function senderLabel(item: ChatMessage) { .message-head { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid #e5e7eb; } -.message-head h2 { - margin: 0; - font-size: 18px; +.head-title { + flex: 1; + min-width: 0; } -.message-head p { +.head-title h2 { + margin: 0; + font-size: 18px; + display: flex; + align-items: center; + gap: 8px; +} + +.head-title p { margin: 6px 0 0; color: #6b7280; font-size: 13px; } +.head-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.head-actions a { + text-decoration: none; +} + .message-list { overflow-y: auto; padding: 18px; @@ -386,11 +574,29 @@ function senderLabel(item: ChatMessage) { } .composer { + border-top: 1px solid #e5e7eb; +} + +.composer-tools { + padding: 8px 14px; + border-bottom: 1px solid #f0f0f0; +} + +.composer-input { display: grid; grid-template-columns: minmax(0, 1fr) 88px; gap: 12px; align-items: end; padding: 14px; - border-top: 1px solid #e5e7eb; +} + +.reply-title { + font-weight: 500; + margin-right: 8px; +} + +.reply-preview { + color: #9ca3af; + font-size: 12px; } diff --git a/frontend/src/views/admin/AdminSystemConfigsView.vue b/frontend/src/views/admin/AdminSystemConfigsView.vue index 9b51922..9672952 100644 --- a/frontend/src/views/admin/AdminSystemConfigsView.vue +++ b/frontend/src/views/admin/AdminSystemConfigsView.vue @@ -25,6 +25,7 @@ import SalePriceDialog from './components/SalePriceDialog.vue' import HomeAnnouncementsDialog from './components/HomeAnnouncementsDialog.vue' import HomeBannersDialog from './components/HomeBannersDialog.vue' import GeneralConfigDialog from './components/GeneralConfigDialog.vue' +import AutoWelcomeConfig from './components/AutoWelcomeConfig.vue' const loading = ref(false) const configs = ref([]) @@ -267,6 +268,8 @@ function formatConfigValue(row: SystemConfig) { + + diff --git a/frontend/src/views/admin/components/AutoWelcomeConfig.vue b/frontend/src/views/admin/components/AutoWelcomeConfig.vue new file mode 100644 index 0000000..c10a6d9 --- /dev/null +++ b/frontend/src/views/admin/components/AutoWelcomeConfig.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/views/admin/components/QuickReplyDialog.vue b/frontend/src/views/admin/components/QuickReplyDialog.vue new file mode 100644 index 0000000..acf4578 --- /dev/null +++ b/frontend/src/views/admin/components/QuickReplyDialog.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/frontend/src/views/admin/components/TransferDialog.vue b/frontend/src/views/admin/components/TransferDialog.vue new file mode 100644 index 0000000..5ae93d4 --- /dev/null +++ b/frontend/src/views/admin/components/TransferDialog.vue @@ -0,0 +1,131 @@ + + + + +