优化快捷回复 CSV 为中文四列模板,操作列改为单行

导入/导出统一为一级分类、二级分类、输入码、内容;列表操作按钮紧凑并排不换行。
This commit is contained in:
yml2213
2026-07-19 00:59:53 +08:00
parent 4b7d8b04ea
commit c6532f2ee3
4 changed files with 156 additions and 54 deletions
+108 -41
View File
@@ -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++
@@ -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")
}
}