新增独立快捷回复模块(团队/个人)

从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 CSV 导入导出;管理页顶栏与侧栏对齐。
This commit is contained in:
yml2213
2026-07-17 21:59:10 +08:00
parent 54d23bb595
commit c5f28dca1c
11 changed files with 1491 additions and 26 deletions
+9
View File
@@ -154,6 +154,15 @@ func seed() {
} }
model.DB.Create(&entries) 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 // Announcements
model.DB.Create(&[]model.Announcement{ model.DB.Create(&[]model.Announcement{
{Title: "系统维护通知", Content: "平台将于7月20日 02:00-04:00 进行例行维护", Status: "published"}, {Title: "系统维护通知", Content: "平台将于7月20日 02:00-04:00 进行例行维护", Status: "published"},
+669
View File
@@ -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,
})
}
@@ -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)
}
}
}
+15
View File
@@ -12,6 +12,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
session := NewSessionHandler() session := NewSessionHandler()
customer := NewCustomerHandler() customer := NewCustomerHandler()
knowledge := NewKnowledgeHandler() knowledge := NewKnowledgeHandler()
quickReply := NewQuickReplyHandler()
stats := NewStatisticsHandler() stats := NewStatisticsHandler()
admin := NewAdminHandler(store) admin := NewAdminHandler(store)
channel := NewChannelHandler() channel := NewChannelHandler()
@@ -87,6 +88,20 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
kb.PUT("/entries/:id", knowledge.UpdateEntry) kb.PUT("/entries/:id", knowledge.UpdateEntry)
kb.DELETE("/entries/:id", knowledge.DeleteEntry) 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 := authRequired.Group("/channels")
channels.GET("", channel.List) channels.GET("", channel.List)
+1
View File
@@ -38,6 +38,7 @@ func Migrate(db *gorm.DB) error {
&SessionEvent{}, &SessionEvent{},
&Category{}, &Category{},
&KnowledgeEntry{}, &KnowledgeEntry{},
&QuickReply{},
&Plan{}, &Plan{},
&OperationLog{}, &OperationLog{},
&Announcement{}, &Announcement{},
+18
View File
@@ -123,6 +123,24 @@ type KnowledgeEntry struct {
UpdatedAt time.Time `json:"updated_at"` 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 { type Plan struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:30;not null" json:"name"` Name string `gorm:"size:30;not null" json:"name"`
@@ -2,6 +2,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { import {
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
ThunderboltOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
@@ -10,6 +11,7 @@ const menuItems = [
{ key: '/agent/customers', icon: <TeamOutlined />, label: '客户管理' }, { key: '/agent/customers', icon: <TeamOutlined />, label: '客户管理' },
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' }, { key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' }, { key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' },
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' },
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' }, { key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' },
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' }, { key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
] ]
+187 -24
View File
@@ -3,16 +3,17 @@ import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popove
import { import {
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined, SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
ExportOutlined, BookOutlined, PictureOutlined, ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview' import { ChatImage } from '@/components/common/ImagePreview'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
import { import {
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries, addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, transferSession, updateSessionPriority, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
uploadImage, suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply,
type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type Session, type SessionEvent, type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply,
type Session, type SessionEvent,
} from '@/services/api' } from '@/services/api'
/** 按 id 合并消息,再按 seq / id 排序 */ /** 按 id 合并消息,再按 seq / id 排序 */
@@ -51,12 +52,6 @@ const endReasons = [
{ value: 'transferred', label: '已转接' }, { value: 'transferred', label: '已转接' },
{ value: 'other', label: '其他' }, { value: 'other', label: '其他' },
] ]
const quickReplies = [
'您好,正在为您查询,请稍候。',
'感谢您的耐心等待,还有什么可以帮您?',
'为更快处理,请您提供订单号或截图。',
]
/** 列表项展示:紧急 / 等待中 / 进行中 */ /** 列表项展示:紧急 / 等待中 / 进行中 */
function listStatusMeta(session: Session) { function listStatusMeta(session: Session) {
if (session.priority === 'urgent') { if (session.priority === 'urgent') {
@@ -142,6 +137,15 @@ const Dashboard = () => {
const [knowledgeKeyword, setKnowledgeKeyword] = useState('') const [knowledgeKeyword, setKnowledgeKeyword] = useState('')
const [knowledgeEntries, setKnowledgeEntries] = useState<KnowledgeEntry[]>([]) const [knowledgeEntries, setKnowledgeEntries] = useState<KnowledgeEntry[]>([])
const [knowledgeLoading, setKnowledgeLoading] = useState(false) const [knowledgeLoading, setKnowledgeLoading] = useState(false)
const [quickOpen, setQuickOpen] = useState(false)
const [quickKeyword, setQuickKeyword] = useState('')
const [quickList, setQuickList] = useState<QuickReply[]>([])
const [quickLoading, setQuickLoading] = useState(false)
/** 输入框 / 触发建议 */
const [slashOpen, setSlashOpen] = useState(false)
const [slashPrefix, setSlashPrefix] = useState('')
const [slashItems, setSlashItems] = useState<QuickReply[]>([])
const [slashIndex, setSlashIndex] = useState(0)
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null) const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
const [noteInput, setNoteInput] = useState('') const [noteInput, setNoteInput] = useState('')
const [savingNote, setSavingNote] = useState(false) const [savingNote, setSavingNote] = useState(false)
@@ -482,6 +486,64 @@ const Dashboard = () => {
}).catch(() => setKnowledgeEntries([])).finally(() => setKnowledgeLoading(false)) }).catch(() => setKnowledgeEntries([])).finally(() => setKnowledgeLoading(false))
}, [knowledgeOpen, knowledgeKeyword]) }, [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 selected = sessions.find(session => session.id === selectedId)
const selectedCustomer = selected ? customers[selected.customer_id] : null const selectedCustomer = selected ? customers[selected.customer_id] : null
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
@@ -962,6 +1024,9 @@ const Dashboard = () => {
<FlagOutlined /> <FlagOutlined />
</button> </button>
</Dropdown> </Dropdown>
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="快捷回复" disabled={!canOperate} onClick={() => setQuickOpen(true)}>
<ThunderboltOutlined />
</button>
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="知识库" disabled={!canOperate} onClick={() => setKnowledgeOpen(true)}> <button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="知识库" disabled={!canOperate} onClick={() => setKnowledgeOpen(true)}>
<BookOutlined /> <BookOutlined />
</button> </button>
@@ -1083,32 +1148,65 @@ const Dashboard = () => {
<PaperClipOutlined /> <PaperClipOutlined />
</button> </button>
<div className="w-px h-5 bg-neutral-200 mx-1" /> <div className="w-px h-5 bg-neutral-200 mx-1" />
<Dropdown <button
menu={{ type="button"
items: quickReplies.map((content, index) => ({ className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 flex items-center gap-1"
key: String(index), onClick={() => setQuickOpen(true)}
label: content,
onClick: () => setMessageInput(content),
})),
}}
> >
<button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100"> <ThunderboltOutlined />
</button> </button>
</Dropdown>
<button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 flex items-center gap-1" onClick={() => setKnowledgeOpen(true)}> <button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 flex items-center gap-1" onClick={() => setKnowledgeOpen(true)}>
<FileTextOutlined /> <FileTextOutlined />
</button> </button>
<span className="text-[11px] text-neutral-400 ml-1"> / </span>
</div> </div>
<div className="flex items-end gap-2.5 px-4 pb-3 pt-1"> <div className="relative flex items-end gap-2.5 px-4 pb-3 pt-1">
{slashOpen && (
<div className="absolute bottom-full left-4 right-16 mb-1 z-20 max-h-56 overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg">
{slashItems.length === 0 ? (
<div className="px-3 py-2 text-xs text-neutral-400"></div>
) : (
slashItems.map((item, idx) => (
<button
key={item.id}
type="button"
className={`w-full text-left px-3 py-2 border-0 cursor-pointer ${
idx === slashIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
}`}
onMouseDown={e => {
e.preventDefault()
void applyQuickReply(item)
}}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-800 truncate">{item.title}</span>
{item.shortcut && (
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded shrink-0">/{item.shortcut}</code>
)}
<span className="text-[10px] text-neutral-400 shrink-0">
{item.scope === 'team' ? '团队' : '个人'}
</span>
</div>
<div className="text-xs text-neutral-500 line-clamp-1 mt-0.5">{item.content}</div>
</button>
))
)}
</div>
)}
<textarea <textarea
ref={messageInputRef} ref={messageInputRef}
rows={3} rows={3}
className="flex-1 min-w-0 min-h-[88px] max-h-[160px] rounded-xl px-3.5 py-3 bg-neutral-50 border border-neutral-200 text-sm leading-6 text-neutral-800 placeholder:text-neutral-400 outline-none resize-y focus:border-[#2563eb] transition-colors" className="flex-1 min-w-0 min-h-[88px] max-h-[160px] rounded-xl px-3.5 py-3 bg-neutral-50 border border-neutral-200 text-sm leading-6 text-neutral-800 placeholder:text-neutral-400 outline-none resize-y focus:border-[#2563eb] transition-colors"
placeholder="输入回复内容… Enter 发送,Shift+Enter 换行" placeholder="输入回复内容… / 调话术,Enter 发送,Shift+Enter 换行"
value={messageInput} value={messageInput}
onChange={event => { setMessageInput(event.target.value); emitTyping() }} onChange={event => {
const v = event.target.value
setMessageInput(v)
syncSlashFromInput(v)
emitTyping()
}}
onPaste={event => { onPaste={event => {
const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/')) const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/'))
if (image) { if (image) {
@@ -1117,6 +1215,33 @@ const Dashboard = () => {
} }
}} }}
onKeyDown={event => { onKeyDown={event => {
if (slashOpen && slashItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSlashIndex(i => (i + 1) % slashItems.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSlashIndex(i => (i - 1 + slashItems.length) % slashItems.length)
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
return
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashOpen(false)
return
}
if (event.key === 'Tab') {
event.preventDefault()
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
return
}
}
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault() event.preventDefault()
sendMessage(messageInput) sendMessage(messageInput)
@@ -1362,7 +1487,7 @@ const Dashboard = () => {
</div> </div>
<p className="text-xs text-neutral-400 text-center mt-3 mb-0"> WebP </p> <p className="text-xs text-neutral-400 text-center mt-3 mb-0"> WebP </p>
</Modal> </Modal>
<Modal title="知识库与快捷回复" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}> <Modal title="知识库" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}>
<Input <Input
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
placeholder="搜索标题或内容" placeholder="搜索标题或内容"
@@ -1391,6 +1516,44 @@ const Dashboard = () => {
</div> </div>
)} )}
</Modal> </Modal>
<Modal title="快捷回复" open={quickOpen} onCancel={() => setQuickOpen(false)} footer={null} width={640}>
<Input
prefix={<SearchOutlined />}
placeholder="搜索标题、内容或输入码"
value={quickKeyword}
onChange={event => setQuickKeyword(event.target.value)}
allowClear
className="mb-3"
/>
<p className="text-xs text-neutral-400 mb-2"> + </p>
{quickLoading ? (
<div className="py-10 text-center"><Spin /></div>
) : (
<div className="space-y-2 max-h-96 overflow-auto">
{quickList.length === 0 ? (
<div className="text-center text-neutral-400 py-8"></div>
) : quickList.map(item => (
<button
key={item.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={() => void applyQuickReply(item)}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-800">{item.title}</span>
{item.shortcut && (
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded">/{item.shortcut}</code>
)}
<span className="text-[10px] text-neutral-400 ml-auto">
{item.scope === 'team' ? '团队' : '个人'}
</span>
</div>
<div className="text-xs text-neutral-500 mt-1 line-clamp-2">{item.content}</div>
</button>
))}
</div>
)}
</Modal>
</div> </div>
) )
} }
+469
View File
@@ -0,0 +1,469 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button, Form, Input, Modal, Popconfirm, Select, Spin, Table, Tabs, Upload, message, Tag, Space,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
PlusOutlined, EditOutlined, DeleteOutlined, CloudUploadOutlined, DownloadOutlined,
ThunderboltOutlined, CheckOutlined, StopOutlined, ImportOutlined, ExportOutlined,
} from '@ant-design/icons'
import {
createQuickReply, deleteQuickReply, downloadQuickReplyTemplate, exportQuickReplies,
getQuickReplies, importQuickReplies, publishQuickReply, unpublishQuickReply, updateQuickReply,
type QuickReply, type QuickReplyScope,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
const QuickReplies = () => {
const { user } = useAuth()
const canManageTeam = user?.role === 'admin' || user?.role === 'supervisor'
const [tab, setTab] = useState<QuickReplyScope>(canManageTeam ? 'team' : 'personal')
const [list, setList] = useState<QuickReply[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [q, setQ] = useState('')
const [statusFilter, setStatusFilter] = useState<string | undefined>()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<QuickReply | null>(null)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
const load = useCallback(async () => {
setLoading(true)
try {
const res = await getQuickReplies({
scope: tab,
status: statusFilter,
q: q.trim() || undefined,
page,
pageSize,
})
setList(Array.isArray(res.list) ? res.list : [])
setTotal(res.total || 0)
} catch (e) {
setList([])
setTotal(0)
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [tab, statusFilter, q, page, pageSize])
useEffect(() => {
void load()
}, [load])
const openCreate = () => {
setEditing(null)
form.resetFields()
form.setFieldsValue({
status: tab === 'personal' ? 'published' : 'draft',
shortcut: '',
group_name: '',
})
setModalOpen(true)
}
const openEdit = (row: QuickReply) => {
setEditing(row)
form.setFieldsValue({
title: row.title,
content: row.content,
shortcut: row.shortcut || '',
group_name: row.group_name || '',
status: row.status,
})
setModalOpen(true)
}
const handleSave = async (values: {
title: string
content: string
shortcut?: string
group_name?: string
status?: string
}) => {
setSaving(true)
try {
const payload = {
title: values.title.trim(),
content: values.content.trim(),
shortcut: values.shortcut?.trim() || '',
group_name: values.group_name?.trim() || '',
status: values.status,
}
if (editing) {
await updateQuickReply(editing.id, payload)
message.success('已更新')
} else {
await createQuickReply({ scope: tab, ...payload })
message.success(tab === 'team' && payload.status === 'draft' ? '已暂存' : '已创建')
}
setModalOpen(false)
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (row: QuickReply) => {
try {
await deleteQuickReply(row.id)
message.success('已删除')
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const handlePublish = async (row: QuickReply) => {
try {
await publishQuickReply(row.id)
message.success('已发布同步,全员可用')
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '发布失败')
}
}
const handleUnpublish = async (row: QuickReply) => {
try {
await unpublishQuickReply(row.id)
message.success('已下线为暂存')
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
const handleExport = async () => {
try {
await exportQuickReplies(tab)
message.success('导出已开始')
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败')
}
}
const handleTemplate = async () => {
try {
await downloadQuickReplyTemplate()
} catch (e) {
message.error(e instanceof Error ? e.message : '下载模板失败')
}
}
const canEditRow = (row: QuickReply) => {
if (row.scope === 'team') return canManageTeam
return true
}
const columns: ColumnsType<QuickReply> = [
{
title: '标题',
dataIndex: 'title',
width: 160,
ellipsis: true,
},
{
title: '内容',
dataIndex: 'content',
ellipsis: true,
render: (v: string) => <span className="text-neutral-600 text-sm">{v}</span>,
},
{
title: '输入码',
dataIndex: 'shortcut',
width: 100,
render: (v: string) =>
v ? <code className="text-xs bg-neutral-100 px-1.5 py-0.5 rounded">/{v}</code> : <span className="text-neutral-300"></span>,
},
{
title: '分组',
dataIndex: 'group_name',
width: 90,
render: (v: string) => v || '—',
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) =>
s === 'published' ? (
<Tag color="success"></Tag>
) : (
<Tag></Tag>
),
},
{
title: '使用',
dataIndex: 'usage_count',
width: 70,
align: 'right',
},
{
title: '操作',
key: 'actions',
width: tab === 'team' && canManageTeam ? 220 : 120,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
{canEditRow(row) && (
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>
</Button>
)}
{tab === 'team' && canManageTeam && row.status === 'draft' && (
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => void handlePublish(row)}>
</Button>
)}
{tab === 'team' && canManageTeam && row.status === 'published' && (
<Button type="link" size="small" icon={<StopOutlined />} onClick={() => void handleUnpublish(row)}>
线
</Button>
)}
{canEditRow(row) && (
<Popconfirm title="确认删除?" onConfirm={() => void handleDelete(row)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
),
},
]
const tabItems = [
...(canManageTeam
? [{
key: 'team',
label: (
<span className="inline-flex items-center gap-1">
<ThunderboltOutlined />
</span>
),
}]
: []),
{
key: 'personal',
label: '我的快捷回复',
},
]
// agent 只能看个人
const effectiveTab = canManageTeam ? tab : 'personal'
return (
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
{/* 顶栏 56px — 与侧栏 Logo 区底边对齐(其它页统一) */}
<header
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-neutral-200 bg-white"
style={{ height: 'var(--header-height)' }}
>
<div className="min-w-0">
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate"></h1>
</div>
<Space wrap size="small" className="shrink-0">
<Button size="small" icon={<DownloadOutlined />} onClick={() => void handleTemplate()}>
</Button>
<Button size="small" icon={<ExportOutlined />} onClick={() => void handleExport()}>
</Button>
<Upload
accept=".csv,text/csv"
showUploadList={false}
beforeUpload={async file => {
try {
const res = await importQuickReplies(file as File, effectiveTab, 'skip')
const d = res.data
message.success(
`导入完成:新增 ${d?.created ?? 0},更新 ${d?.updated ?? 0},跳过 ${d?.skipped ?? 0}`,
)
if (d?.errors?.length) {
message.warning(d.errors.slice(0, 3).join(''))
}
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败')
}
return false
}}
>
<Button size="small" icon={<ImportOutlined />}> CSV</Button>
</Upload>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
</header>
{/* 工具栏:Tab + 搜索 */}
<div className="shrink-0 px-6 pt-2 pb-3 bg-white border-b border-neutral-200">
<Tabs
size="small"
className="!mb-0 quick-reply-tabs"
activeKey={effectiveTab}
onChange={k => {
setTab(k as QuickReplyScope)
setPage(1)
setStatusFilter(undefined)
}}
items={tabItems}
tabBarStyle={{ marginBottom: 8 }}
/>
<div className="flex flex-wrap gap-2 items-center">
<Input.Search
allowClear
placeholder="搜索标题、内容、输入码"
className="!w-64"
onSearch={v => {
setQ(v)
setPage(1)
}}
/>
<Select
allowClear
placeholder="状态"
className="!w-28"
value={statusFilter}
onChange={v => {
setStatusFilter(v)
setPage(1)
}}
options={[
{ value: 'published', label: '已发布' },
{ value: 'draft', label: '暂存' },
]}
/>
{effectiveTab === 'team' && canManageTeam && (
<span className="text-xs text-neutral-400">
· /
</span>
)}
{effectiveTab === 'personal' && (
<span className="text-xs text-neutral-400">
· /
</span>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto p-4">
<div className="bg-white rounded-xl border border-neutral-200 p-2">
{loading && list.length === 0 ? (
<div className="py-16 text-center"><Spin /></div>
) : (
<Table
rowKey="id"
size="middle"
columns={columns}
dataSource={list}
scroll={{ x: 900 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
/>
)}
</div>
</div>
<Modal
title={editing ? '编辑快捷回复' : `新建${effectiveTab === 'team' ? '团队' : '个人'}快捷回复`}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={saving}
okText={effectiveTab === 'team' && !editing ? '暂存' : '保存'}
width={560}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={values => void handleSave(values)} className="mt-2">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }, { max: 100 }]}>
<Input placeholder="如:打招呼" maxLength={100} />
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }, { max: 2000 }]}>
<Input.TextArea rows={4} maxLength={2000} showCount placeholder="插入到输入框的正文" />
</Form.Item>
<Form.Item
name="shortcut"
label="输入码"
extra="工作台输入 /nh 可快速填入;仅字母数字下划线短横线"
rules={[
{
pattern: /^\/?[a-zA-Z0-9_-]*$/,
message: '输入码格式无效',
},
]}
>
<Input placeholder="如 nh(可选)" maxLength={32} addonBefore="/" />
</Form.Item>
<Form.Item name="group_name" label="分组" rules={[{ max: 50 }]}>
<Input placeholder="如:通用、售后" maxLength={50} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'draft', label: '暂存' },
{ value: 'published', label: effectiveTab === 'team' ? '已发布(全员可用)' : '已发布' },
]}
/>
</Form.Item>
{effectiveTab === 'team' && canManageTeam && (
<p className="text-xs text-neutral-500 -mt-2 mb-0">
</p>
)}
</Form>
{effectiveTab === 'team' && canManageTeam && editing?.status === 'draft' && (
<Button
className="mt-2"
type="dashed"
icon={<CloudUploadOutlined />}
block
onClick={async () => {
try {
const values = await form.validateFields()
setSaving(true)
await updateQuickReply(editing.id, {
title: values.title.trim(),
content: values.content.trim(),
shortcut: values.shortcut?.trim() || '',
group_name: values.group_name?.trim() || '',
status: 'draft',
})
await publishQuickReply(editing.id)
message.success('已保存并发布同步')
setModalOpen(false)
await load()
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message)
} finally {
setSaving(false)
}
}}
>
</Button>
)}
</Modal>
</div>
)
}
export default QuickReplies
+2
View File
@@ -10,6 +10,7 @@ const Dashboard = lazy(() => import('@/pages/agent/Dashboard'))
const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory')) const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
const Customers = lazy(() => import('@/pages/agent/Customers')) const Customers = lazy(() => import('@/pages/agent/Customers'))
const Knowledge = lazy(() => import('@/pages/agent/Knowledge')) const Knowledge = lazy(() => import('@/pages/agent/Knowledge'))
const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies'))
const Statistics = lazy(() => import('@/pages/agent/Statistics')) const Statistics = lazy(() => import('@/pages/agent/Statistics'))
const Settings = lazy(() => import('@/pages/agent/Settings')) const Settings = lazy(() => import('@/pages/agent/Settings'))
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard')) const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
@@ -66,6 +67,7 @@ export const router = createBrowserRouter([
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> }, { path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> },
{ path: 'customers', element: <Lazy><Customers /></Lazy> }, { path: 'customers', element: <Lazy><Customers /></Lazy> },
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> }, { path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> },
{ {
element: <RequireSupervisor />, element: <RequireSupervisor />,
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }], children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
+83
View File
@@ -323,6 +323,89 @@ export const updateKnowledgeEntry = (id: number, data: Partial<KnowledgeEntry>)
put<KnowledgeEntry>(`/knowledge/entries/${id}`, data) put<KnowledgeEntry>(`/knowledge/entries/${id}`, data)
export const deleteKnowledgeEntry = (id: number) => del(`/knowledge/entries/${id}`) export const deleteKnowledgeEntry = (id: number) => del(`/knowledge/entries/${id}`)
// 快捷回复(团队 / 个人)
export type QuickReplyScope = 'team' | 'personal'
export interface QuickReply {
id: number
tenant_id: number
scope: QuickReplyScope
owner_user_id?: number | null
title: string
content: string
shortcut: string
group_name: string
status: 'draft' | 'published' | string
sort_order: number
usage_count: number
created_at: string
updated_at: string
}
export const getQuickReplies = (params?: {
scope?: 'team' | 'personal' | 'all'
status?: string
q?: string
page?: number
pageSize?: number
}) => {
const search = new URLSearchParams()
if (params?.scope) search.set('scope', params.scope)
if (params?.status) search.set('status', params.status)
if (params?.q) search.set('q', params.q)
if (params?.page) search.set('page', String(params.page))
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
const qs = search.toString()
return getList<QuickReply>(`/quick-replies${qs ? `?${qs}` : ''}`)
}
export const suggestQuickReplies = (prefix = '') => {
const search = new URLSearchParams()
if (prefix) search.set('prefix', prefix)
const qs = search.toString()
return get<QuickReply[]>(`/quick-replies/suggest${qs ? `?${qs}` : ''}`)
}
export const createQuickReply = (data: {
scope: QuickReplyScope
title: string
content: string
shortcut?: string
group_name?: string
status?: string
sort_order?: number
}) => post<QuickReply>('/quick-replies', data)
export const updateQuickReply = (id: number, data: {
title: string
content: string
shortcut?: string
group_name?: string
status?: string
sort_order?: number
}) => put<QuickReply>(`/quick-replies/${id}`, data)
export const deleteQuickReply = (id: number) => del(`/quick-replies/${id}`)
export const publishQuickReply = (id: number) => post<QuickReply>(`/quick-replies/${id}/publish`, {})
export const unpublishQuickReply = (id: number) => post<QuickReply>(`/quick-replies/${id}/unpublish`, {})
export const useQuickReply = (id: number) => post<{ ok: boolean; usage_count: number }>(`/quick-replies/${id}/use`, {})
export const exportQuickReplies = (scope: QuickReplyScope) =>
downloadFile(`/quick-replies/export?scope=${scope}`, `quick_replies_${scope}.csv`)
export const downloadQuickReplyTemplate = () =>
downloadFile('/quick-replies/import-template', 'quick_replies_template.csv')
export const importQuickReplies = (file: File, scope: QuickReplyScope, onConflict: 'skip' | 'overwrite' = 'skip') => {
const form = new FormData()
form.append('file', file)
form.append('scope', scope)
form.append('on_conflict', onConflict)
return postForm<{ created: number; updated: number; skipped: number; errors: string[] }>(
`/quick-replies/import?scope=${scope}`,
form,
)
}
// Channels // Channels
export const getChannels = () => get<Channel[]>('/channels') export const getChannels = () => get<Channel[]>('/channels')
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data) export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)