优化快捷回复 CSV 为中文四列模板,操作列改为单行
导入/导出统一为一级分类、二级分类、输入码、内容;列表操作按钮紧凑并排不换行。
This commit is contained in:
@@ -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++
|
||||
|
||||
Reference in New Issue
Block a user