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

从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 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)
// 团队快捷回复(独立模块;知识库「快捷回复模板」分类可逐步弃用)
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"},
+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()
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)
+1
View File
@@ -38,6 +38,7 @@ func Migrate(db *gorm.DB) error {
&SessionEvent{},
&Category{},
&KnowledgeEntry{},
&QuickReply{},
&Plan{},
&OperationLog{},
&Announcement{},
+18
View File
@@ -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"`
@@ -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: <TeamOutlined />, label: '客户管理' },
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' },
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' },
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' },
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
]
+189 -26
View File
@@ -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<KnowledgeEntry[]>([])
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 [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 = () => {
<FlagOutlined />
</button>
</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)}>
<BookOutlined />
</button>
@@ -1083,32 +1148,65 @@ const Dashboard = () => {
<PaperClipOutlined />
</button>
<div className="w-px h-5 bg-neutral-200 mx-1" />
<Dropdown
menu={{
items: quickReplies.map((content, index) => ({
key: String(index),
label: content,
onClick: () => setMessageInput(content),
})),
}}
<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={() => setQuickOpen(true)}
>
<button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100">
</button>
</Dropdown>
<ThunderboltOutlined />
</button>
<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 />
</button>
<span className="text-[11px] text-neutral-400 ml-1"> / </span>
</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
ref={messageInputRef}
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"
placeholder="输入回复内容… Enter 发送,Shift+Enter 换行"
placeholder="输入回复内容… / 调话术,Enter 发送,Shift+Enter 换行"
value={messageInput}
onChange={event => { setMessageInput(event.target.value); emitTyping() }}
onChange={event => {
const v = event.target.value
setMessageInput(v)
syncSlashFromInput(v)
emitTyping()
}}
onPaste={event => {
const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/'))
if (image) {
@@ -1117,6 +1215,33 @@ const Dashboard = () => {
}
}}
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) {
event.preventDefault()
sendMessage(messageInput)
@@ -1362,7 +1487,7 @@ const Dashboard = () => {
</div>
<p className="text-xs text-neutral-400 text-center mt-3 mb-0"> WebP </p>
</Modal>
<Modal title="知识库与快捷回复" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}>
<Modal title="知识库" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}>
<Input
prefix={<SearchOutlined />}
placeholder="搜索标题或内容"
@@ -1391,6 +1516,44 @@ const Dashboard = () => {
</div>
)}
</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>
)
}
+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 Customers = lazy(() => import('@/pages/agent/Customers'))
const Knowledge = lazy(() => import('@/pages/agent/Knowledge'))
const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies'))
const Statistics = lazy(() => import('@/pages/agent/Statistics'))
const Settings = lazy(() => import('@/pages/agent/Settings'))
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
@@ -66,6 +67,7 @@ export const router = createBrowserRouter([
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> },
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> },
{
element: <RequireSupervisor />,
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)
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
export const getChannels = () => get<Channel[]>('/channels')
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)