From c6532f2ee30171a313feb504ea77c01f35fb326b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 19 Jul 2026 00:59:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BF=AB=E6=8D=B7=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=20CSV=20=E4=B8=BA=E4=B8=AD=E6=96=87=E5=9B=9B=E5=88=97?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=EF=BC=8C=E6=93=8D=E4=BD=9C=E5=88=97=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8D=95=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导入/导出统一为一级分类、二级分类、输入码、内容;列表操作按钮紧凑并排不换行。 --- server/internal/handler/quick_reply.go | 149 ++++++++++++++------ server/internal/handler/quick_reply_test.go | 30 ++++ web/src/pages/agent/QuickReplies.tsx | 29 ++-- web/src/services/api.ts | 2 +- 4 files changed, 156 insertions(+), 54 deletions(-) diff --git a/server/internal/handler/quick_reply.go b/server/internal/handler/quick_reply.go index 8a60f2d..dc5e9da 100644 --- a/server/internal/handler/quick_reply.go +++ b/server/internal/handler/quick_reply.go @@ -451,6 +451,53 @@ func (h *QuickReplyHandler) Use(c *gin.Context) { middleware.JSON(c, gin.H{"ok": true, "usage_count": item.UsageCount + 1}) } +// 快捷回复 CSV 固定四列(与导入模板一致) +// 一级分类=团队/个人;二级分类=标题;输入码=快捷键;内容=回复正文 +func quickReplyCSVHeader() []string { + return []string{"一级分类", "二级分类", "输入码", "内容"} +} + +func scopeLabelCN(scope string) string { + if scope == quickReplyScopeTeam { + return "团队" + } + return "个人" +} + +// parseQuickReplyScopeLabel 仅接受 团队 / 个人(及空=沿用默认) +func parseQuickReplyScopeLabel(raw string) (string, bool) { + s := strings.TrimSpace(raw) + s = strings.TrimPrefix(s, "\ufeff") + switch s { + case "团队": + return quickReplyScopeTeam, true + case "个人": + return quickReplyScopePersonal, true + case "": + return "", true + default: + return "", false + } +} + +func cellAt(row []string, idx int) string { + if idx < 0 || idx >= len(row) { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(row[idx], "\ufeff")) +} + +// isQuickReplyCSVHeader 判断首行是否为标准表头 +func isQuickReplyCSVHeader(row []string) bool { + if len(row) < 4 { + return false + } + return cellAt(row, 0) == "一级分类" && + cellAt(row, 1) == "二级分类" && + cellAt(row, 2) == "输入码" && + cellAt(row, 3) == "内容" +} + // Export GET /api/quick-replies/export?scope=team|personal func (h *QuickReplyHandler) Export(c *gin.Context) { tenantID := middleware.GetTenantID(c) @@ -480,51 +527,53 @@ func (h *QuickReplyHandler) Export(c *gin.Context) { return } - header := []string{"title", "content", "shortcut", "group_name", "status", "scope"} + header := quickReplyCSVHeader() rows := make([][]string, 0, len(list)) for _, item := range list { rows = append(rows, []string{ + scopeLabelCN(item.Scope), item.Title, - item.Content, item.Shortcut, - item.GroupName, - item.Status, - item.Scope, + item.Content, }) } - name := fmt.Sprintf("quick_replies_%s_%s.csv", scope, time.Now().Format("20060102_150405")) + name := fmt.Sprintf("快捷回复_%s_%s.csv", scopeLabelCN(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"} + header := quickReplyCSVHeader() rows := [][]string{ - {"打招呼", "您好!请问有什么可以帮您?", "nh", "通用", "published"}, - {"稍等", "好的,请稍等,我帮您查询一下。", "sd", "通用", "published"}, + {"团队", "欢迎词", "hy", "欢迎访问,请问有什么可以帮您?"}, + {"个人", "您好", "nh", "您好!很高兴为您服务。"}, + {"团队", "稍等", "sd", "好的,请稍等,我帮您查询一下。"}, } - writeCSVResponse(c, "quick_replies_template.csv", header, rows) + writeCSVResponse(c, "快捷回复导入模板.csv", header, rows) } -// Import POST /api/quick-replies/import multipart file + scope=team|personal + on_conflict=skip|overwrite +// Import POST /api/quick-replies/import +// 固定 CSV 列:一级分类,二级分类,输入码,内容 +// 一级分类为空时使用表单 scope;可在同一文件中混写团队/个人。 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 + defaultScope := strings.TrimSpace(c.DefaultPostForm("scope", c.Query("scope"))) + if defaultScope == "" { + defaultScope = quickReplyScopePersonal } - if scope != quickReplyScopeTeam && scope != quickReplyScopePersonal { + if defaultScope != quickReplyScopeTeam && defaultScope != quickReplyScopePersonal { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"}) return } - if scope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) { + if defaultScope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) { return } onConflict := strings.TrimSpace(c.DefaultPostForm("on_conflict", "skip")) if onConflict != "skip" && onConflict != "overwrite" { onConflict = "skip" } + canManageTeam := middleware.HasAnyRole(c, "admin", "supervisor") file, _, err := c.Request.FormFile("file") if err != nil { @@ -546,10 +595,16 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { return } - // 跳过表头 start := 0 - if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "title") { + if isQuickReplyCSVHeader(records[0]) { start = 1 + } else { + // 必须使用标准模板表头 + c.JSON(http.StatusBadRequest, gin.H{ + "code": 400, + "message": "CSV 表头不正确,请下载「导入模板」:一级分类,二级分类,输入码,内容", + }) + return } body := records[start:] if len(body) > maxQuickReplyImport { @@ -557,11 +612,6 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { return } - var ownerID *uint - if scope == quickReplyScopePersonal { - ownerID = &uid - } - created, updated, skipped := 0, 0, 0 var errors []string @@ -570,19 +620,36 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { if len(row) == 0 || (len(row) == 1 && strings.TrimSpace(row[0]) == "") { continue } - // pad columns - for len(row) < 5 { + // 固定列:0一级分类 1二级分类 2输入码 3内容 + for len(row) < 4 { 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) + + rawScope := cellAt(row, 0) + title := cellAt(row, 1) + shortcutRaw := cellAt(row, 2) + content := cellAt(row, 3) + + rowScope := defaultScope + parsed, ok := parseQuickReplyScopeLabel(rawScope) + if !ok { + skipped++ + errors = append(errors, fmt.Sprintf("第 %d 行:一级分类请填「团队」或「个人」", lineNo)) + continue + } + if parsed != "" { + rowScope = parsed + } + + if rowScope == quickReplyScopeTeam && !canManageTeam { + skipped++ + errors = append(errors, fmt.Sprintf("第 %d 行:无权限导入团队快捷回复", lineNo)) + continue + } if title == "" || content == "" { skipped++ - errors = append(errors, fmt.Sprintf("第 %d 行:标题或内容为空", lineNo)) + errors = append(errors, fmt.Sprintf("第 %d 行:二级分类或内容为空", lineNo)) continue } if utf8.RuneCountInString(title) > maxQuickReplyTitle || utf8.RuneCountInString(content) > maxQuickReplyContent { @@ -596,24 +663,26 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { errors = append(errors, fmt.Sprintf("第 %d 行:%s", lineNo, err.Error())) continue } - if utf8.RuneCountInString(group) > maxQuickReplyGroup { - group = string([]rune(group)[:maxQuickReplyGroup]) + + var ownerID *uint + if rowScope == quickReplyScopePersonal { + ownerID = &uid } // 冲突:有 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 := model.DB.Where("tenant_id = ? AND scope = ? AND shortcut = ?", tenantID, rowScope, shortcut) + if rowScope == 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 := model.DB.Where("tenant_id = ? AND scope = ? AND title = ?", tenantID, rowScope, title) + if rowScope == quickReplyScopePersonal { q = q.Where("owner_user_id = ?", uid) } if err := q.First(&existing).Error; err == nil { @@ -628,7 +697,6 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { } 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)) @@ -640,13 +708,12 @@ func (h *QuickReplyHandler) Import(c *gin.Context) { item := model.QuickReply{ TenantID: tenantID, - Scope: scope, + Scope: rowScope, OwnerUserID: ownerID, Title: title, Content: content, Shortcut: shortcut, - GroupName: group, - Status: status, + Status: quickReplyStatusPub, } if err := model.DB.Create(&item).Error; err != nil { skipped++ diff --git a/server/internal/handler/quick_reply_test.go b/server/internal/handler/quick_reply_test.go index eb8e08d..cbc806d 100644 --- a/server/internal/handler/quick_reply_test.go +++ b/server/internal/handler/quick_reply_test.go @@ -32,3 +32,33 @@ func TestNormalizeShortcut(t *testing.T) { } } } + +func TestParseQuickReplyScopeLabel(t *testing.T) { + cases := []struct { + in string + want string + wantOK bool + }{ + {"团队", "team", true}, + {"个人", "personal", true}, + {"", "", true}, + {"team", "", false}, + {"personal", "", false}, + {"其它", "", false}, + } + for _, tc := range cases { + got, ok := parseQuickReplyScopeLabel(tc.in) + if ok != tc.wantOK || got != tc.want { + t.Fatalf("parseQuickReplyScopeLabel(%q)=(%q,%v) want (%q,%v)", tc.in, got, ok, tc.want, tc.wantOK) + } + } +} + +func TestIsQuickReplyCSVHeader(t *testing.T) { + if !isQuickReplyCSVHeader([]string{"一级分类", "二级分类", "输入码", "内容"}) { + t.Fatal("expected header match") + } + if isQuickReplyCSVHeader([]string{"title", "content", "shortcut", "group_name"}) { + t.Fatal("legacy english header should not match") + } +} diff --git a/web/src/pages/agent/QuickReplies.tsx b/web/src/pages/agent/QuickReplies.tsx index 5b57ee7..f6381d4 100644 --- a/web/src/pages/agent/QuickReplies.tsx +++ b/web/src/pages/agent/QuickReplies.tsx @@ -4,8 +4,8 @@ import { } from 'antd' import type { ColumnsType } from 'antd/es/table' import { - PlusOutlined, EditOutlined, DeleteOutlined, CloudUploadOutlined, DownloadOutlined, - ThunderboltOutlined, CheckOutlined, StopOutlined, ImportOutlined, ExportOutlined, + PlusOutlined, CloudUploadOutlined, DownloadOutlined, + ThunderboltOutlined, ImportOutlined, ExportOutlined, } from '@ant-design/icons' import { createQuickReply, deleteQuickReply, downloadQuickReplyTemplate, exportQuickReplies, @@ -210,33 +210,33 @@ const QuickReplies = () => { { title: '操作', key: 'actions', - width: tab === 'team' && canManageTeam ? 220 : 120, + width: tab === 'team' && canManageTeam ? 168 : 112, fixed: 'right', render: (_, row) => ( - +
{canEditRow(row) && ( - )} {tab === 'team' && canManageTeam && row.status === 'draft' && ( - )} {tab === 'team' && canManageTeam && row.status === 'published' && ( - )} {canEditRow(row) && ( void handleDelete(row)}> - )} - +
), }, ] @@ -272,16 +272,21 @@ const QuickReplies = () => {

快捷回复

- { + const name = (file as File).name || '' + if (/\.xlsx?$/i.test(name)) { + message.warning('请先将 Excel 另存为 CSV(UTF-8)后再导入;可先下载「导入模板」参考格式') + return false + } try { const res = await importQuickReplies(file as File, effectiveTab, 'skip') const d = res.data diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 100633e..61a8c08 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -521,7 +521,7 @@ 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') + downloadFile('/quick-replies/import-template', '快捷回复导入模板.csv') export const importQuickReplies = (file: File, scope: QuickReplyScope, onConflict: 'skip' | 'overwrite' = 'skip') => { const form = new FormData()