From c5f28dca1cefa41f088e6deda1a609426119f4ae Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 17 Jul 2026 21:59:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=8B=AC=E7=AB=8B=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E5=9B=9E=E5=A4=8D=E6=A8=A1=E5=9D=97=EF=BC=88=E5=9B=A2?= =?UTF-8?q?=E9=98=9F/=E4=B8=AA=E4=BA=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 CSV 导入导出;管理页顶栏与侧栏对齐。 --- server/cmd/seed/main.go | 9 + server/internal/handler/quick_reply.go | 669 ++++++++++++++++++++ server/internal/handler/quick_reply_test.go | 34 + server/internal/handler/router.go | 15 + server/internal/model/db.go | 1 + server/internal/model/models.go | 18 + web/src/components/layout/AgentSidebar.tsx | 2 + web/src/pages/agent/Dashboard.tsx | 215 ++++++- web/src/pages/agent/QuickReplies.tsx | 469 ++++++++++++++ web/src/router/index.tsx | 2 + web/src/services/api.ts | 83 +++ 11 files changed, 1491 insertions(+), 26 deletions(-) create mode 100644 server/internal/handler/quick_reply.go create mode 100644 server/internal/handler/quick_reply_test.go create mode 100644 web/src/pages/agent/QuickReplies.tsx diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go index e665ad2..2bae0e4 100644 --- a/server/cmd/seed/main.go +++ b/server/cmd/seed/main.go @@ -154,6 +154,15 @@ func seed() { } model.DB.Create(&entries) + // 团队快捷回复(独立模块;知识库「快捷回复模板」分类可逐步弃用) + quickReplies := []model.QuickReply{ + {TenantID: tid, Scope: "team", Title: "打招呼", Content: "您好!欢迎来到客服云,请问有什么可以帮您的?", Shortcut: "nh", GroupName: "通用", Status: "published", UsageCount: 120}, + {TenantID: tid, Scope: "team", Title: "稍等查询", Content: "您好,正在为您查询,请稍候。", Shortcut: "sd", GroupName: "通用", Status: "published", UsageCount: 80}, + {TenantID: tid, Scope: "team", Title: "提供订单号", Content: "为更快处理,请您提供订单号或相关截图,谢谢。", Shortcut: "ddh", GroupName: "售后", Status: "published", UsageCount: 56}, + {TenantID: tid, Scope: "team", Title: "结束语", Content: "感谢您的咨询,祝您生活愉快!如有其它问题随时联系我们。", Shortcut: "js", GroupName: "通用", Status: "draft", UsageCount: 0}, + } + model.DB.Create(&quickReplies) + // Announcements model.DB.Create(&[]model.Announcement{ {Title: "系统维护通知", Content: "平台将于7月20日 02:00-04:00 进行例行维护", Status: "published"}, diff --git a/server/internal/handler/quick_reply.go b/server/internal/handler/quick_reply.go new file mode 100644 index 0000000..8a60f2d --- /dev/null +++ b/server/internal/handler/quick_reply.go @@ -0,0 +1,669 @@ +package handler + +import ( + "encoding/csv" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "kefu-cloud/server/internal/middleware" + "kefu-cloud/server/internal/model" +) + +const ( + quickReplyScopeTeam = "team" + quickReplyScopePersonal = "personal" + quickReplyStatusDraft = "draft" + quickReplyStatusPub = "published" + + maxQuickReplyTitle = 100 + maxQuickReplyContent = 2000 + maxQuickReplyShort = 32 + maxQuickReplyGroup = 50 + maxQuickReplyImport = 1000 +) + +var shortcutRe = regexp.MustCompile(`^[a-zA-Z0-9_\-]{1,32}$`) + +type QuickReplyHandler struct{} + +func NewQuickReplyHandler() *QuickReplyHandler { return &QuickReplyHandler{} } + +func requireTeamQuickReplyManager(c *gin.Context) bool { + if middleware.HasAnyRole(c, "admin", "supervisor") { + return true + } + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理团队快捷回复"}) + return false +} + +func normalizeShortcut(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", nil + } + // 允许用户输入 /nh,统一去掉前导 / + s = strings.TrimPrefix(s, "/") + s = strings.ToLower(strings.TrimSpace(s)) + if !shortcutRe.MatchString(s) { + return "", fmt.Errorf("输入码仅支持字母、数字、下划线、短横线,最长 32") + } + if utf8.RuneCountInString(s) > maxQuickReplyShort { + return "", fmt.Errorf("输入码过长") + } + return s, nil +} + +func normalizeQRStatus(s string, fallback string) string { + s = strings.TrimSpace(s) + if s == quickReplyStatusDraft || s == quickReplyStatusPub { + return s + } + return fallback +} + +func loadQuickReply(c *gin.Context, id uint) (*model.QuickReply, bool) { + tenantID := middleware.GetTenantID(c) + var item model.QuickReply + if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&item).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "快捷回复不存在"}) + return nil, false + } + return &item, true +} + +func canEditQuickReply(c *gin.Context, item *model.QuickReply) bool { + uid := middleware.GetUserID(c) + if item.Scope == quickReplyScopeTeam { + return middleware.HasAnyRole(c, "admin", "supervisor") + } + return item.OwnerUserID != nil && *item.OwnerUserID == uid +} + +func shortcutConflict(tenantID uint, scope string, ownerID *uint, shortcut string, excludeID uint) bool { + if shortcut == "" { + return false + } + q := model.DB.Model(&model.QuickReply{}). + Where("tenant_id = ? AND scope = ? AND shortcut = ?", tenantID, scope, shortcut) + if excludeID > 0 { + q = q.Where("id <> ?", excludeID) + } + if scope == quickReplyScopePersonal { + if ownerID == nil { + return true + } + q = q.Where("owner_user_id = ?", *ownerID) + } else { + q = q.Where("owner_user_id IS NULL") + } + var n int64 + q.Count(&n) + return n > 0 +} + +type quickReplyReq struct { + Title string `json:"title"` + Content string `json:"content"` + Shortcut string `json:"shortcut"` + GroupName string `json:"group_name"` + Status string `json:"status"` + SortOrder *int `json:"sort_order"` + Scope string `json:"scope"` // create only +} + +func parseQuickReplyBody(req *quickReplyReq) (title, content, shortcut, group, status string, err error) { + title = strings.TrimSpace(req.Title) + content = strings.TrimSpace(req.Content) + if title == "" { + return "", "", "", "", "", fmt.Errorf("标题不能为空") + } + if utf8.RuneCountInString(title) > maxQuickReplyTitle { + return "", "", "", "", "", fmt.Errorf("标题不能超过 %d 字", maxQuickReplyTitle) + } + if content == "" { + return "", "", "", "", "", fmt.Errorf("内容不能为空") + } + if utf8.RuneCountInString(content) > maxQuickReplyContent { + return "", "", "", "", "", fmt.Errorf("内容不能超过 %d 字", maxQuickReplyContent) + } + shortcut, err = normalizeShortcut(req.Shortcut) + if err != nil { + return "", "", "", "", "", err + } + group = strings.TrimSpace(req.GroupName) + if utf8.RuneCountInString(group) > maxQuickReplyGroup { + return "", "", "", "", "", fmt.Errorf("分组名过长") + } + status = normalizeQRStatus(req.Status, quickReplyStatusDraft) + return title, content, shortcut, group, status, nil +} + +// List GET /api/quick-replies +// scope=team|personal|all status=draft|published q=keyword +func (h *QuickReplyHandler) List(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + uid := middleware.GetUserID(c) + scope := strings.TrimSpace(c.Query("scope")) + if scope == "" { + scope = "all" + } + status := strings.TrimSpace(c.Query("status")) + q := strings.TrimSpace(c.Query("q")) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + + db := model.DB.Model(&model.QuickReply{}).Where("tenant_id = ?", tenantID) + + switch scope { + case quickReplyScopeTeam: + db = db.Where("scope = ?", quickReplyScopeTeam) + // 非管理端:工作台只看已发布;管理页可传 status + if !middleware.HasAnyRole(c, "admin", "supervisor") { + db = db.Where("status = ?", quickReplyStatusPub) + } else if status != "" { + db = db.Where("status = ?", status) + } + case quickReplyScopePersonal: + db = db.Where("scope = ? AND owner_user_id = ?", quickReplyScopePersonal, uid) + if status != "" { + db = db.Where("status = ?", status) + } + case "all": + // 工作台:团队已发布 + 本人个人(默认全部个人,便于草稿也自己用) + db = db.Where( + "(scope = ? AND status = ?) OR (scope = ? AND owner_user_id = ?)", + quickReplyScopeTeam, quickReplyStatusPub, + quickReplyScopePersonal, uid, + ) + if status == quickReplyStatusPub { + db = db.Where("status = ?", quickReplyStatusPub) + } + default: + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"}) + return + } + + if q != "" { + like := "%" + q + "%" + db = db.Where("title LIKE ? OR content LIKE ? OR shortcut LIKE ? OR group_name LIKE ?", like, like, like, like) + } + + var total int64 + db.Count(&total) + + var list []model.QuickReply + if err := db.Order("usage_count desc, sort_order asc, id desc"). + Offset((page - 1) * pageSize).Limit(pageSize). + Find(&list).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载失败"}) + return + } + middleware.JSONList(c, list, total, page, pageSize) +} + +// Suggest GET /api/quick-replies/suggest?prefix= +// 供输入框 / 触发:匹配 shortcut 前缀或标题 +func (h *QuickReplyHandler) Suggest(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + uid := middleware.GetUserID(c) + prefix := strings.TrimSpace(c.Query("prefix")) + prefix = strings.TrimPrefix(strings.ToLower(prefix), "/") + + db := model.DB.Model(&model.QuickReply{}).Where("tenant_id = ?", tenantID).Where( + "(scope = ? AND status = ?) OR (scope = ? AND owner_user_id = ? AND status = ?)", + quickReplyScopeTeam, quickReplyStatusPub, + quickReplyScopePersonal, uid, quickReplyStatusPub, + ) + + if prefix != "" { + like := prefix + "%" + titleLike := "%" + prefix + "%" + db = db.Where("shortcut LIKE ? OR title LIKE ?", like, titleLike) + } + + var list []model.QuickReply + // 无前缀时返回高频 + order := "usage_count desc, sort_order asc, id desc" + if err := db.Order(order).Limit(20).Find(&list).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "搜索失败"}) + return + } + middleware.JSON(c, list) +} + +// Create POST /api/quick-replies +func (h *QuickReplyHandler) Create(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + uid := middleware.GetUserID(c) + + var req quickReplyReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + scope := strings.TrimSpace(req.Scope) + if scope == "" { + scope = quickReplyScopePersonal + } + if scope != quickReplyScopeTeam && scope != quickReplyScopePersonal { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"}) + return + } + if scope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) { + return + } + + title, content, shortcut, group, status, err := parseQuickReplyBody(&req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + // 个人默认 published,便于立刻使用;团队默认 draft(可显式 published) + if req.Status == "" { + if scope == quickReplyScopePersonal { + status = quickReplyStatusPub + } else { + status = quickReplyStatusDraft + } + } + + var ownerID *uint + if scope == quickReplyScopePersonal { + ownerID = &uid + } + if shortcutConflict(tenantID, scope, ownerID, shortcut, 0) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "输入码已存在"}) + return + } + + sortOrder := 0 + if req.SortOrder != nil { + sortOrder = *req.SortOrder + } + item := model.QuickReply{ + TenantID: tenantID, + Scope: scope, + OwnerUserID: ownerID, + Title: title, + Content: content, + Shortcut: shortcut, + GroupName: group, + Status: status, + SortOrder: sortOrder, + } + if err := model.DB.Create(&item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) + return + } + middleware.JSON(c, item) +} + +// Update PUT /api/quick-replies/:id +func (h *QuickReplyHandler) Update(c *gin.Context) { + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + item, ok := loadQuickReply(c, uint(id64)) + if !ok { + return + } + if !canEditQuickReply(c, item) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改该快捷回复"}) + return + } + + var req quickReplyReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + title, content, shortcut, group, status, err := parseQuickReplyBody(&req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + if req.Status == "" { + status = item.Status + } + if shortcutConflict(item.TenantID, item.Scope, item.OwnerUserID, shortcut, item.ID) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "输入码已存在"}) + return + } + + updates := map[string]interface{}{ + "title": title, + "content": content, + "shortcut": shortcut, + "group_name": group, + "status": status, + } + if req.SortOrder != nil { + updates["sort_order"] = *req.SortOrder + } + if err := model.DB.Model(item).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) + return + } + _ = model.DB.First(item, item.ID) + middleware.JSON(c, item) +} + +// Delete DELETE /api/quick-replies/:id +func (h *QuickReplyHandler) Delete(c *gin.Context) { + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + item, ok := loadQuickReply(c, uint(id64)) + if !ok { + return + } + if !canEditQuickReply(c, item) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权删除该快捷回复"}) + return + } + if err := model.DB.Delete(item).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"}) + return + } + middleware.JSON(c, gin.H{"ok": true}) +} + +// Publish POST /api/quick-replies/:id/publish 团队:暂存 → 发布同步 +func (h *QuickReplyHandler) Publish(c *gin.Context) { + if !requireTeamQuickReplyManager(c) { + return + } + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + item, ok := loadQuickReply(c, uint(id64)) + if !ok { + return + } + if item.Scope != quickReplyScopeTeam { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅团队快捷回复支持发布同步"}) + return + } + if err := model.DB.Model(item).Update("status", quickReplyStatusPub).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发布失败"}) + return + } + _ = model.DB.First(item, item.ID) + middleware.JSON(c, item) +} + +// Unpublish POST /api/quick-replies/:id/unpublish +func (h *QuickReplyHandler) Unpublish(c *gin.Context) { + if !requireTeamQuickReplyManager(c) { + return + } + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + item, ok := loadQuickReply(c, uint(id64)) + if !ok { + return + } + if item.Scope != quickReplyScopeTeam { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅团队快捷回复支持下线"}) + return + } + if err := model.DB.Model(item).Update("status", quickReplyStatusDraft).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "操作失败"}) + return + } + _ = model.DB.First(item, item.ID) + middleware.JSON(c, item) +} + +// Use POST /api/quick-replies/:id/use 使用计数 +1 +func (h *QuickReplyHandler) Use(c *gin.Context) { + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + item, ok := loadQuickReply(c, uint(id64)) + if !ok { + return + } + // 可见性:团队已发布,或本人个人 + uid := middleware.GetUserID(c) + if item.Scope == quickReplyScopeTeam && item.Status != quickReplyStatusPub { + if !middleware.HasAnyRole(c, "admin", "supervisor") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "该话术尚未发布"}) + return + } + } + if item.Scope == quickReplyScopePersonal && (item.OwnerUserID == nil || *item.OwnerUserID != uid) { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权使用"}) + return + } + _ = model.DB.Model(item).UpdateColumn("usage_count", item.UsageCount+1).Error + middleware.JSON(c, gin.H{"ok": true, "usage_count": item.UsageCount + 1}) +} + +// Export GET /api/quick-replies/export?scope=team|personal +func (h *QuickReplyHandler) Export(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + uid := middleware.GetUserID(c) + scope := strings.TrimSpace(c.Query("scope")) + if scope == "" { + scope = quickReplyScopePersonal + } + + db := model.DB.Where("tenant_id = ?", tenantID) + switch scope { + case quickReplyScopeTeam: + if !requireTeamQuickReplyManager(c) { + return + } + db = db.Where("scope = ?", quickReplyScopeTeam) + case quickReplyScopePersonal: + db = db.Where("scope = ? AND owner_user_id = ?", quickReplyScopePersonal, uid) + default: + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"}) + return + } + + var list []model.QuickReply + if err := db.Order("id asc").Limit(exportMaxRows).Find(&list).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出失败"}) + return + } + + header := []string{"title", "content", "shortcut", "group_name", "status", "scope"} + rows := make([][]string, 0, len(list)) + for _, item := range list { + rows = append(rows, []string{ + item.Title, + item.Content, + item.Shortcut, + item.GroupName, + item.Status, + item.Scope, + }) + } + name := fmt.Sprintf("quick_replies_%s_%s.csv", scope, time.Now().Format("20060102_150405")) + writeCSVResponse(c, name, header, rows) +} + +// ImportTemplate GET /api/quick-replies/import-template +func (h *QuickReplyHandler) ImportTemplate(c *gin.Context) { + header := []string{"title", "content", "shortcut", "group_name", "status"} + rows := [][]string{ + {"打招呼", "您好!请问有什么可以帮您?", "nh", "通用", "published"}, + {"稍等", "好的,请稍等,我帮您查询一下。", "sd", "通用", "published"}, + } + writeCSVResponse(c, "quick_replies_template.csv", header, rows) +} + +// Import POST /api/quick-replies/import multipart file + scope=team|personal + on_conflict=skip|overwrite +func (h *QuickReplyHandler) Import(c *gin.Context) { + tenantID := middleware.GetTenantID(c) + uid := middleware.GetUserID(c) + scope := strings.TrimSpace(c.DefaultPostForm("scope", c.Query("scope"))) + if scope == "" { + scope = quickReplyScopePersonal + } + if scope != quickReplyScopeTeam && scope != quickReplyScopePersonal { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"}) + return + } + if scope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) { + return + } + onConflict := strings.TrimSpace(c.DefaultPostForm("on_conflict", "skip")) + if onConflict != "skip" && onConflict != "overwrite" { + onConflict = "skip" + } + + file, _, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请上传 CSV 文件字段 file"}) + return + } + defer file.Close() + + reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 + reader.LazyQuotes = true + records, err := reader.ReadAll() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "CSV 解析失败"}) + return + } + if len(records) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "CSV 为空"}) + return + } + + // 跳过表头 + start := 0 + if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "title") { + start = 1 + } + body := records[start:] + if len(body) > maxQuickReplyImport { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("单次最多导入 %d 行", maxQuickReplyImport)}) + return + } + + var ownerID *uint + if scope == quickReplyScopePersonal { + ownerID = &uid + } + + created, updated, skipped := 0, 0, 0 + var errors []string + + for i, row := range body { + lineNo := start + i + 1 + if len(row) == 0 || (len(row) == 1 && strings.TrimSpace(row[0]) == "") { + continue + } + // pad columns + for len(row) < 5 { + row = append(row, "") + } + title := strings.TrimSpace(row[0]) + content := strings.TrimSpace(row[1]) + shortcutRaw := strings.TrimSpace(row[2]) + group := strings.TrimSpace(row[3]) + status := normalizeQRStatus(row[4], quickReplyStatusPub) + + if title == "" || content == "" { + skipped++ + errors = append(errors, fmt.Sprintf("第 %d 行:标题或内容为空", lineNo)) + continue + } + if utf8.RuneCountInString(title) > maxQuickReplyTitle || utf8.RuneCountInString(content) > maxQuickReplyContent { + skipped++ + errors = append(errors, fmt.Sprintf("第 %d 行:标题或内容过长", lineNo)) + continue + } + shortcut, err := normalizeShortcut(shortcutRaw) + if err != nil { + skipped++ + errors = append(errors, fmt.Sprintf("第 %d 行:%s", lineNo, err.Error())) + continue + } + if utf8.RuneCountInString(group) > maxQuickReplyGroup { + group = string([]rune(group)[:maxQuickReplyGroup]) + } + + // 冲突:有 shortcut 按 shortcut;否则按 title + var existing model.QuickReply + found := false + if shortcut != "" { + q := model.DB.Where("tenant_id = ? AND scope = ? AND shortcut = ?", tenantID, scope, shortcut) + if scope == quickReplyScopePersonal { + q = q.Where("owner_user_id = ?", uid) + } + if err := q.First(&existing).Error; err == nil { + found = true + } + } else { + q := model.DB.Where("tenant_id = ? AND scope = ? AND title = ?", tenantID, scope, title) + if scope == quickReplyScopePersonal { + q = q.Where("owner_user_id = ?", uid) + } + if err := q.First(&existing).Error; err == nil { + found = true + } + } + + if found { + if onConflict == "skip" { + skipped++ + continue + } + if err := model.DB.Model(&existing).Updates(map[string]interface{}{ + "title": title, "content": content, "shortcut": shortcut, + "group_name": group, "status": status, + }).Error; err != nil { + skipped++ + errors = append(errors, fmt.Sprintf("第 %d 行:更新失败", lineNo)) + continue + } + updated++ + continue + } + + item := model.QuickReply{ + TenantID: tenantID, + Scope: scope, + OwnerUserID: ownerID, + Title: title, + Content: content, + Shortcut: shortcut, + GroupName: group, + Status: status, + } + if err := model.DB.Create(&item).Error; err != nil { + skipped++ + errors = append(errors, fmt.Sprintf("第 %d 行:创建失败", lineNo)) + continue + } + created++ + } + + if len(errors) > 20 { + errors = errors[:20] + } + middleware.JSON(c, gin.H{ + "created": created, + "updated": updated, + "skipped": skipped, + "errors": errors, + }) +} + diff --git a/server/internal/handler/quick_reply_test.go b/server/internal/handler/quick_reply_test.go new file mode 100644 index 0000000..eb8e08d --- /dev/null +++ b/server/internal/handler/quick_reply_test.go @@ -0,0 +1,34 @@ +package handler + +import "testing" + +func TestNormalizeShortcut(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {"", "", false}, + {"nh", "nh", false}, + {"/NH", "nh", false}, + {"hello_1", "hello_1", false}, + {"a-b", "a-b", false}, + {"中文", "", true}, + {"has space", "", true}, + } + for _, tc := range cases { + got, err := normalizeShortcut(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("normalizeShortcut(%q) 期望错误", tc.in) + } + continue + } + if err != nil { + t.Fatalf("normalizeShortcut(%q): %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("normalizeShortcut(%q)=%q want %q", tc.in, got, tc.want) + } + } +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index d9f3a10..aceea6e 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -12,6 +12,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S session := NewSessionHandler() customer := NewCustomerHandler() knowledge := NewKnowledgeHandler() + quickReply := NewQuickReplyHandler() stats := NewStatisticsHandler() admin := NewAdminHandler(store) channel := NewChannelHandler() @@ -87,6 +88,20 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S kb.PUT("/entries/:id", knowledge.UpdateEntry) kb.DELETE("/entries/:id", knowledge.DeleteEntry) + // 快捷回复(团队 / 个人,与知识库独立) + qr := authRequired.Group("/quick-replies") + qr.GET("", quickReply.List) + qr.GET("/suggest", quickReply.Suggest) + qr.GET("/export", quickReply.Export) + qr.GET("/import-template", quickReply.ImportTemplate) + qr.POST("/import", quickReply.Import) + qr.POST("", quickReply.Create) + qr.PUT("/:id", quickReply.Update) + qr.DELETE("/:id", quickReply.Delete) + qr.POST("/:id/publish", quickReply.Publish) + qr.POST("/:id/unpublish", quickReply.Unpublish) + qr.POST("/:id/use", quickReply.Use) + // 渠道设置(租户级) channels := authRequired.Group("/channels") channels.GET("", channel.List) diff --git a/server/internal/model/db.go b/server/internal/model/db.go index caed790..33904eb 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -38,6 +38,7 @@ func Migrate(db *gorm.DB) error { &SessionEvent{}, &Category{}, &KnowledgeEntry{}, + &QuickReply{}, &Plan{}, &OperationLog{}, &Announcement{}, diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 3bbebea..3378895 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -123,6 +123,24 @@ type KnowledgeEntry struct { UpdatedAt time.Time `json:"updated_at"` } +// QuickReply 团队/个人快捷回复(与知识库独立)。 +// Scope=team 时 OwnerUserID 为空,全租户共享;Scope=personal 时归属 OwnerUserID。 +type QuickReply struct { + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + Scope string `gorm:"size:20;index;not null" json:"scope"` // team | personal + OwnerUserID *uint `gorm:"index" json:"owner_user_id,omitempty"` + Title string `gorm:"size:100;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + Shortcut string `gorm:"size:32;index" json:"shortcut"` // 输入码,如 nh → /nh + GroupName string `gorm:"size:50" json:"group_name"` + Status string `gorm:"size:20;default:draft;index" json:"status"` // draft | published + SortOrder int `gorm:"default:0" json:"sort_order"` + UsageCount int `gorm:"default:0" json:"usage_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Plan struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"size:30;not null" json:"name"` diff --git a/web/src/components/layout/AgentSidebar.tsx b/web/src/components/layout/AgentSidebar.tsx index 4531113..80f9ffb 100644 --- a/web/src/components/layout/AgentSidebar.tsx +++ b/web/src/components/layout/AgentSidebar.tsx @@ -2,6 +2,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, + ThunderboltOutlined, } from '@ant-design/icons' import { useAuth } from '@/stores/auth' @@ -10,6 +11,7 @@ const menuItems = [ { key: '/agent/customers', icon: , label: '客户管理' }, { key: '/agent/chat-history', icon: , label: '对话记录' }, { key: '/agent/knowledge', icon: , label: '知识库' }, + { key: '/agent/quick-replies', icon: , label: '快捷回复' }, { key: '/agent/statistics', icon: , label: '数据统计' }, { key: '/agent/settings', icon: , label: '系统设置' }, ] diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index e320a4d..24fe420 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -3,16 +3,17 @@ import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popove import { CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined, - ExportOutlined, BookOutlined, PictureOutlined, + ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined, } from '@ant-design/icons' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import { ChatImage } from '@/components/common/ImagePreview' import { useAuth } from '@/stores/auth' import { addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries, - getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, transferSession, updateSessionPriority, - uploadImage, - type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type Session, type SessionEvent, + getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, + suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply, + type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply, + type Session, type SessionEvent, } from '@/services/api' /** 按 id 合并消息,再按 seq / id 排序 */ @@ -51,12 +52,6 @@ const endReasons = [ { value: 'transferred', label: '已转接' }, { value: 'other', label: '其他' }, ] -const quickReplies = [ - '您好,正在为您查询,请稍候。', - '感谢您的耐心等待,还有什么可以帮您?', - '为更快处理,请您提供订单号或截图。', -] - /** 列表项展示:紧急 / 等待中 / 进行中 */ function listStatusMeta(session: Session) { if (session.priority === 'urgent') { @@ -142,6 +137,15 @@ const Dashboard = () => { const [knowledgeKeyword, setKnowledgeKeyword] = useState('') const [knowledgeEntries, setKnowledgeEntries] = useState([]) const [knowledgeLoading, setKnowledgeLoading] = useState(false) + const [quickOpen, setQuickOpen] = useState(false) + const [quickKeyword, setQuickKeyword] = useState('') + const [quickList, setQuickList] = useState([]) + const [quickLoading, setQuickLoading] = useState(false) + /** 输入框 / 触发建议 */ + const [slashOpen, setSlashOpen] = useState(false) + const [slashPrefix, setSlashPrefix] = useState('') + const [slashItems, setSlashItems] = useState([]) + const [slashIndex, setSlashIndex] = useState(0) const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null) const [noteInput, setNoteInput] = useState('') const [savingNote, setSavingNote] = useState(false) @@ -482,6 +486,64 @@ const Dashboard = () => { }).catch(() => setKnowledgeEntries([])).finally(() => setKnowledgeLoading(false)) }, [knowledgeOpen, knowledgeKeyword]) + useEffect(() => { + if (!quickOpen) return + setQuickLoading(true) + getQuickReplies({ + scope: 'all', + q: quickKeyword.trim() || undefined, + page: 1, + pageSize: 50, + }).then(response => { + setQuickList(Array.isArray(response.list) ? response.list : []) + }).catch(() => setQuickList([])).finally(() => setQuickLoading(false)) + }, [quickOpen, quickKeyword]) + + useEffect(() => { + if (!slashOpen) return + let cancelled = false + suggestQuickReplies(slashPrefix).then(res => { + if (cancelled) return + const list = Array.isArray(res.data) ? res.data : [] + setSlashItems(list) + setSlashIndex(0) + }).catch(() => { + if (!cancelled) setSlashItems([]) + }) + return () => { cancelled = true } + }, [slashOpen, slashPrefix]) + + const applyQuickReply = useCallback(async (item: QuickReply) => { + setMessageInput(item.content) + setQuickOpen(false) + setSlashOpen(false) + setSlashPrefix('') + try { + await useQuickReply(item.id) + } catch { /* 计数失败可忽略 */ } + requestAnimationFrame(() => { + const el = messageInputRef.current + if (el) { + el.focus() + const len = item.content.length + el.setSelectionRange(len, len) + } + }) + }, []) + + /** 从输入内容解析末尾 /shortcut 触发 */ + const syncSlashFromInput = useCallback((value: string) => { + // 匹配末尾未完成的 /xxx(前面是行首或空白) + const m = /(^|[\s\n])\/([a-zA-Z0-9_-]*)$/.exec(value) + if (m) { + setSlashOpen(true) + setSlashPrefix(m[2] || '') + } else { + setSlashOpen(false) + setSlashPrefix('') + } + }, []) + 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') @@ -962,6 +1024,9 @@ const Dashboard = () => { + @@ -1083,32 +1148,65 @@ const Dashboard = () => {
- ({ - key: String(index), - label: content, - onClick: () => setMessageInput(content), - })), - }} + - + + 快捷回复 + + 输入 / 调用话术
-
+
+ {slashOpen && ( +
+ {slashItems.length === 0 ? ( +
无匹配话术
+ ) : ( + slashItems.map((item, idx) => ( + + )) + )} +
+ )}