新增独立快捷回复模块(团队/个人)
从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 CSV 导入导出;管理页顶栏与侧栏对齐。
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -38,6 +38,7 @@ func Migrate(db *gorm.DB) error {
|
||||
&SessionEvent{},
|
||||
&Category{},
|
||||
&KnowledgeEntry{},
|
||||
&QuickReply{},
|
||||
&Plan{},
|
||||
&OperationLog{},
|
||||
&Announcement{},
|
||||
|
||||
@@ -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: '系统设置' },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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> }],
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user