From f33dc97f6faee98d79484018f5c7b67dbcb1e3a7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 13 Oct 2026 16:22:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=AE=A2=E6=9C=8D=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E5=BD=95=E5=8D=95=E6=94=AF=E6=8C=81=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E4=B8=8E=E6=93=8D=E4=BD=9C=E4=BA=BA=EF=BC=8C=E5=B9=B6=E5=8F=AF?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E9=85=8D=E7=BD=AE=E5=8F=AF=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 录单列表新增「平台」「操作人」两列,录单表单与编辑弹窗新增同名字段下拉 - 新增 cs_manual_entry_options 表与 4 个接口(读取/新增/重命名/删除),页面右上角弹窗维护平台与操作人 - 迁移 000087:cs_manual_entries 增加 platform/operator 列与索引,注册 cs_manual_entry:option_manage 权限并授予 ops/super_admin - 记录保存名称快照,删除选项不影响历史记录;同类型重名返回 409,跨类型同名允许 - 顶部关键词查询支持命中平台/操作人 - 新增单测:选项 CRUD 边界与录单平台/操作人读写 --- backend/internal/model/cs_manual_entry.go | 20 +- backend/internal/modules/csmanualentry/dto.go | 46 ++- .../internal/modules/csmanualentry/errors.go | 4 + .../internal/modules/csmanualentry/handler.go | 83 +++++ .../modules/csmanualentry/repository.go | 117 ++++++- .../modules/csmanualentry/repository_test.go | 156 +++++++++ .../internal/modules/csmanualentry/service.go | 89 ++++- backend/internal/router/router.go | 4 + ...0087_cs_manual_entry_platform_operator.sql | 52 +++ .../features/admin/api/adminManualEntry.ts | 56 +++ .../admin/views/AdminManualEntryView.css | 102 +++++- .../admin/views/AdminManualEntryView.vue | 318 +++++++++++++++++- 12 files changed, 1017 insertions(+), 30 deletions(-) create mode 100644 backend/internal/modules/csmanualentry/repository_test.go create mode 100644 backend/migrations/000087_cs_manual_entry_platform_operator.sql diff --git a/backend/internal/model/cs_manual_entry.go b/backend/internal/model/cs_manual_entry.go index 8730db2..3cf143d 100644 --- a/backend/internal/model/cs_manual_entry.go +++ b/backend/internal/model/cs_manual_entry.go @@ -4,10 +4,13 @@ import "time" // CSManualEntry 客服手动录单记录。 // 客服在后台手动登记一个编号(如 202610120013),该编号与其登录账号绑定, -// 记录登记时间等,供后续查询与按客服统计今日录单量。 +// 记录平台、操作人、登记时间等,供后续查询与按客服统计今日录单量。 +// platform / operator 存名称快照:后台删掉选项也不影响历史记录展示。 type CSManualEntry struct { ID uint64 `gorm:"primaryKey" json:"id"` EntryNo string `gorm:"size:64;not null;uniqueIndex" json:"entry_no"` + Platform string `gorm:"size:64;not null;default:'';index" json:"platform"` + Operator string `gorm:"size:64;not null;default:'';index" json:"operator"` AdminID uint64 `gorm:"not null;index" json:"admin_id"` AdminName string `gorm:"size:64;not null;default:''" json:"admin_name"` AdminUsername string `gorm:"size:64;not null;default:''" json:"admin_username"` @@ -19,3 +22,18 @@ type CSManualEntry struct { func (CSManualEntry) TableName() string { return "cs_manual_entries" } + +// CSManualEntryOption 客服手动录单的可选项配置(平台 / 操作人)。 +// 仅作为录单表单的下拉数据源,记录里保存的是名称快照。 +type CSManualEntryOption struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Type string `gorm:"size:16;not null;uniqueIndex:uk_cs_manual_entry_options_type_name,priority:1" json:"type"` + Name string `gorm:"size:64;not null;uniqueIndex:uk_cs_manual_entry_options_type_name,priority:2" json:"name"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (CSManualEntryOption) TableName() string { + return "cs_manual_entry_options" +} diff --git a/backend/internal/modules/csmanualentry/dto.go b/backend/internal/modules/csmanualentry/dto.go index fd8fd24..c3068b2 100644 --- a/backend/internal/modules/csmanualentry/dto.go +++ b/backend/internal/modules/csmanualentry/dto.go @@ -2,22 +2,34 @@ package csmanualentry import "time" +// 录单选项类型。 +const ( + OptionTypePlatform = "platform" // 平台 + OptionTypeOperator = "operator" // 操作人 +) + // CreateRequest 客服手动录单请求。 type CreateRequest struct { - EntryNo string `json:"entry_no" binding:"required"` - Remark string `json:"remark"` + EntryNo string `json:"entry_no" binding:"required"` + Platform string `json:"platform"` + Operator string `json:"operator"` + Remark string `json:"remark"` } // UpdateRequest 管理员修正录单请求。 type UpdateRequest struct { - EntryNo string `json:"entry_no" binding:"required"` - Remark string `json:"remark"` + EntryNo string `json:"entry_no" binding:"required"` + Platform string `json:"platform"` + Operator string `json:"operator"` + Remark string `json:"remark"` } // EntryDTO 录单记录。 type EntryDTO struct { ID uint64 `json:"id"` EntryNo string `json:"entry_no"` + Platform string `json:"platform"` + Operator string `json:"operator"` AdminID uint64 `json:"admin_id"` AdminName string `json:"admin_name"` AdminUsername string `json:"admin_username"` @@ -56,3 +68,29 @@ type AgentDayStatsDTO struct { type CreateResult struct { Entry EntryDTO `json:"entry"` } + +// OptionDTO 录单可选项(平台 / 操作人)。 +type OptionDTO struct { + ID uint64 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} + +// OptionListDTO 平台与操作人选项集合,供录单表单下拉直接使用。 +type OptionListDTO struct { + Platforms []OptionDTO `json:"platforms"` + Operators []OptionDTO `json:"operators"` +} + +// CreateOptionRequest 新增平台 / 操作人选项。 +type CreateOptionRequest struct { + Type string `json:"type" binding:"required"` + Name string `json:"name" binding:"required"` +} + +// UpdateOptionRequest 重命名平台 / 操作人选项。 +type UpdateOptionRequest struct { + Name string `json:"name" binding:"required"` +} diff --git a/backend/internal/modules/csmanualentry/errors.go b/backend/internal/modules/csmanualentry/errors.go index 714e74f..6fb8ef5 100644 --- a/backend/internal/modules/csmanualentry/errors.go +++ b/backend/internal/modules/csmanualentry/errors.go @@ -8,4 +8,8 @@ var ( ErrEntryNoInvalid = errors.New("entry number is invalid") ErrEntryNotFound = errors.New("manual entry not found") ErrAdminNotFound = errors.New("admin not found") + ErrOptionTypeInvalid = errors.New("manual entry option type is invalid") + ErrOptionNameInvalid = errors.New("manual entry option name is invalid") + ErrOptionExists = errors.New("manual entry option already exists") + ErrOptionNotFound = errors.New("manual entry option not found") ) diff --git a/backend/internal/modules/csmanualentry/handler.go b/backend/internal/modules/csmanualentry/handler.go index 9d58258..5b42f7a 100644 --- a/backend/internal/modules/csmanualentry/handler.go +++ b/backend/internal/modules/csmanualentry/handler.go @@ -122,6 +122,81 @@ func (h *Handler) Delete(c *gin.Context) { response.OK(c, gin.H{"deleted": true}) } +// ListOptions 返回平台与操作人选项,供录单表单下拉使用。 +func (h *Handler) ListOptions(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + result, err := h.service.ListOptions(c.Request.Context()) + if err != nil { + writeManualEntryError(c, err) + return + } + response.OK(c, result) +} + +// CreateOption 新增平台 / 操作人选项。 +func (h *Handler) CreateOption(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req CreateOptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "选项参数不正确") + return + } + option, err := h.service.CreateOption(c.Request.Context(), req) + if err != nil { + writeManualEntryError(c, err) + return + } + response.Created(c, option) +} + +// UpdateOption 重命名平台 / 操作人选项。 +func (h *Handler) UpdateOption(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return + } + var req UpdateOptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "选项参数不正确") + return + } + option, err := h.service.UpdateOption(c.Request.Context(), id, req) + if err != nil { + writeManualEntryError(c, err) + return + } + response.OK(c, option) +} + +// DeleteOption 删除平台 / 操作人选项。 +func (h *Handler) DeleteOption(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return + } + if err := h.service.DeleteOption(c.Request.Context(), id); err != nil { + writeManualEntryError(c, err) + return + } + response.OK(c, gin.H{"deleted": true}) +} + func currentAdminID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextAdminID) if !ok { @@ -159,6 +234,14 @@ func writeManualEntryError(c *gin.Context, err error) { response.NotFound(c, "录单记录不存在") case errors.Is(err, ErrEntryNoExists): response.Error(c, http.StatusConflict, "entry_no_exists", "该编号已被登记") + case errors.Is(err, ErrOptionTypeInvalid): + response.BadRequest(c, "选项类型不正确") + case errors.Is(err, ErrOptionNameInvalid): + response.BadRequest(c, "选项名称不正确") + case errors.Is(err, ErrOptionNotFound): + response.NotFound(c, "选项不存在") + case errors.Is(err, ErrOptionExists): + response.Error(c, http.StatusConflict, "option_exists", "该选项已存在") default: response.Error(c, http.StatusInternalServerError, "internal_error", "录单服务暂时不可用") } diff --git a/backend/internal/modules/csmanualentry/repository.go b/backend/internal/modules/csmanualentry/repository.go index 86b9f71..69cf0bb 100644 --- a/backend/internal/modules/csmanualentry/repository.go +++ b/backend/internal/modules/csmanualentry/repository.go @@ -34,9 +34,11 @@ func (r *Repository) FindAdminSnapshot(ctx context.Context, adminID uint64) (nam } // Create 写入一条录单记录。 -func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, remark string) (*EntryDTO, error) { +func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, platform, operator, remark string) (*EntryDTO, error) { row := model.CSManualEntry{ EntryNo: entryNo, + Platform: platform, + Operator: operator, AdminID: adminID, AdminName: adminName, AdminUsername: adminUsername, @@ -65,12 +67,14 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*EntryDTO, error) return &dto, nil } -// Update 修正录单的编号与备注。编号唯一冲突返回 ErrEntryNoExists。 -func (r *Repository) Update(ctx context.Context, id uint64, entryNo, remark string) (*EntryDTO, error) { +// Update 修正录单的编号、平台、操作人与备注。编号唯一冲突返回 ErrEntryNoExists。 +func (r *Repository) Update(ctx context.Context, id uint64, entryNo, platform, operator, remark string) (*EntryDTO, error) { result := r.db.WithContext(ctx).Model(&model.CSManualEntry{}). Where("id = ?", id). Updates(map[string]any{ "entry_no": entryNo, + "platform": platform, + "operator": operator, "remark": remark, }) if result.Error != nil { @@ -118,7 +122,10 @@ func (r *Repository) List(ctx context.Context, query ListQuery) (*PaginatedResul } if kw := strings.TrimSpace(query.Keyword); kw != "" { like := "%" + kw + "%" - cond := r.db.Where("entry_no LIKE ? OR admin_name LIKE ? OR admin_username LIKE ?", like, like, like) + cond := r.db.Where( + "entry_no LIKE ? OR admin_name LIKE ? OR admin_username LIKE ? OR platform LIKE ? OR operator LIKE ?", + like, like, like, like, like, + ) db = db.Where(cond) countDB = countDB.Where(cond) } @@ -192,11 +199,99 @@ func (r *Repository) TodayAgentStats(ctx context.Context, day time.Time) ([]Agen return items, nil } +// ListOptions 返回某类型(平台 / 操作人)的全部可选项。 +func (r *Repository) ListOptions(ctx context.Context, optionType string) ([]OptionDTO, error) { + if !IsValidOptionType(optionType) { + return nil, ErrOptionTypeInvalid + } + rows := make([]model.CSManualEntryOption, 0) + if err := r.db.WithContext(ctx). + Where("type = ?", optionType). + Order("sort_order ASC, id ASC"). + Find(&rows).Error; err != nil { + return nil, err + } + items := make([]OptionDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toOptionDTO(row)) + } + return items, nil +} + +// ListAllOptions 返回平台与操作人两组可选项,供录单表单直接使用。 +func (r *Repository) ListAllOptions(ctx context.Context) (*OptionListDTO, error) { + platforms, err := r.ListOptions(ctx, OptionTypePlatform) + if err != nil { + return nil, err + } + operators, err := r.ListOptions(ctx, OptionTypeOperator) + if err != nil { + return nil, err + } + return &OptionListDTO{Platforms: platforms, Operators: operators}, nil +} + +// CreateOption 新增一个平台 / 操作人选项,重名返回 ErrOptionExists。 +func (r *Repository) CreateOption(ctx context.Context, optionType, name string) (*OptionDTO, error) { + if !IsValidOptionType(optionType) { + return nil, ErrOptionTypeInvalid + } + row := model.CSManualEntryOption{Type: optionType, Name: name} + if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { + if isDuplicateEntry(err) { + return nil, ErrOptionExists + } + return nil, err + } + dto := toOptionDTO(row) + return &dto, nil +} + +// UpdateOption 重命名一个选项,重名返回 ErrOptionExists。 +func (r *Repository) UpdateOption(ctx context.Context, id uint64, name string) (*OptionDTO, error) { + var row model.CSManualEntryOption + if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrOptionNotFound + } + return nil, err + } + result := r.db.WithContext(ctx).Model(&model.CSManualEntryOption{}). + Where("id = ?", id). + Update("name", name) + if result.Error != nil { + if isDuplicateEntry(result.Error) { + return nil, ErrOptionExists + } + return nil, result.Error + } + row.Name = name + dto := toOptionDTO(row) + return &dto, nil +} + +// DeleteOption 删除一个选项(历史录单里的名称快照不受影响)。 +func (r *Repository) DeleteOption(ctx context.Context, id uint64) error { + result := r.db.WithContext(ctx).Delete(&model.CSManualEntryOption{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrOptionNotFound + } + return nil +} + +// IsValidOptionType 判断选项类型是否合法。 +func IsValidOptionType(optionType string) bool { + return optionType == OptionTypePlatform || optionType == OptionTypeOperator +} + func parseDay(value string, loc *time.Location) (time.Time, error) { return time.ParseInLocation("2006-01-02", value, loc) } -// isDuplicateEntry 判断错误是否为 entry_no 唯一键冲突。 +// isDuplicateEntry 判断错误是否为唯一键冲突。 func isDuplicateEntry(err error) bool { if err == nil { return false @@ -211,6 +306,8 @@ func toDTO(row model.CSManualEntry) EntryDTO { return EntryDTO{ ID: row.ID, EntryNo: row.EntryNo, + Platform: row.Platform, + Operator: row.Operator, AdminID: row.AdminID, AdminName: row.AdminName, AdminUsername: row.AdminUsername, @@ -218,3 +315,13 @@ func toDTO(row model.CSManualEntry) EntryDTO { CreatedAt: row.CreatedAt, } } + +func toOptionDTO(row model.CSManualEntryOption) OptionDTO { + return OptionDTO{ + ID: row.ID, + Type: row.Type, + Name: row.Name, + SortOrder: row.SortOrder, + CreatedAt: row.CreatedAt, + } +} diff --git a/backend/internal/modules/csmanualentry/repository_test.go b/backend/internal/modules/csmanualentry/repository_test.go new file mode 100644 index 0000000..c310aed --- /dev/null +++ b/backend/internal/modules/csmanualentry/repository_test.go @@ -0,0 +1,156 @@ +package csmanualentry + +import ( + "errors" + "strings" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +func newManualEntryTestRepo(t *testing.T) (*Repository, *gorm.DB) { + t.Helper() + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, + &model.AdminUser{}, + &model.CSManualEntry{}, + &model.CSManualEntryOption{}, + ); err != nil { + t.Fatalf("迁移测试表失败: %v", err) + } + return NewRepository(db), db +} + +func TestNormalizeOptionName(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr error + }{ + {name: "trim", input: " 网易大神 ", want: "网易大神"}, + {name: "empty allowed", input: " ", want: ""}, + {name: "inner space kept", input: "网易 大神", want: "网易 大神"}, + {name: "64 runes", input: strings.Repeat("游", 64), want: strings.Repeat("游", 64)}, + {name: "65 runes", input: strings.Repeat("游", 65), wantErr: ErrOptionNameInvalid}, + {name: "newline", input: "网易\n大神", wantErr: ErrOptionNameInvalid}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeOptionName(tt.input) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("normalizeOptionName(%q) error = %v, want %v", tt.input, err, tt.wantErr) + } + if tt.wantErr == nil && got != tt.want { + t.Fatalf("normalizeOptionName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestRepositoryOptionCRUDAndEntryPlatformOperator(t *testing.T) { + repo, db := newManualEntryTestRepo(t) + ctx := t.Context() + + admin := model.AdminUser{Username: "cs001", Nickname: "客服小张", PasswordHash: "x"} + if err := db.Create(&admin).Error; err != nil { + t.Fatalf("创建管理员失败: %v", err) + } + + // 1. 选项 CRUD + platform, err := repo.CreateOption(ctx, OptionTypePlatform, "网易大神") + if err != nil { + t.Fatalf("创建平台选项失败: %v", err) + } + // 同类型第二个平台,用于验证重命名撞名(唯一键是 type+name,跨类型不算冲突) + if _, err := repo.CreateOption(ctx, OptionTypePlatform, "交易猫"); err != nil { + t.Fatalf("创建第二个平台选项失败: %v", err) + } + operator, err := repo.CreateOption(ctx, OptionTypeOperator, "技术测试") + if err != nil { + t.Fatalf("创建操作人选项失败: %v", err) + } + if _, err := repo.CreateOption(ctx, OptionTypePlatform, "网易大神"); !errors.Is(err, ErrOptionExists) { + t.Fatalf("重复平台应返回 ErrOptionExists, got %v", err) + } + if _, err := repo.CreateOption(ctx, "unknown", "x"); !errors.Is(err, ErrOptionTypeInvalid) { + t.Fatalf("非法类型应返回 ErrOptionTypeInvalid, got %v", err) + } + // 与平台同名的操作人是允许的(不同 type) + if _, err := repo.CreateOption(ctx, OptionTypeOperator, "网易大神"); err != nil { + t.Fatalf("跨类型同名应允许: %v", err) + } + + if _, err := repo.UpdateOption(ctx, platform.ID, "网易"); err != nil { + t.Fatalf("重命名选项失败: %v", err) + } + if _, err := repo.UpdateOption(ctx, platform.ID, "交易猫"); !errors.Is(err, ErrOptionExists) { + t.Fatalf("重命名撞同类型已有名应返回 ErrOptionExists, got %v", err) + } + if _, err := repo.UpdateOption(ctx, 999999, "任何"); !errors.Is(err, ErrOptionNotFound) { + t.Fatalf("重命名不存在选项应返回 ErrOptionNotFound, got %v", err) + } + + options, err := repo.ListAllOptions(ctx) + if err != nil { + t.Fatalf("读取选项列表失败: %v", err) + } + if len(options.Platforms) != 2 || options.Platforms[0].Name != "网易" { + t.Fatalf("平台选项 = %+v, want 2 条且首条为网易", options.Platforms) + } + if len(options.Operators) != 2 { + t.Fatalf("操作人选项 = %+v, want 2 条", options.Operators) + } + + // 2. 录单写入平台 / 操作人 + entry, err := repo.Create(ctx, admin.ID, "客服小张", "cs001", "202610130001", "网易", "技术测试", "备注1") + if err != nil { + t.Fatalf("录单失败: %v", err) + } + if entry.Platform != "网易" || entry.Operator != "技术测试" { + t.Fatalf("录单平台/操作人 = %q/%q", entry.Platform, entry.Operator) + } + + // 修正录单的平台与操作人 + updated, err := repo.Update(ctx, entry.ID, "202610130001", "网易", "客服小李", "备注2") + if err != nil { + t.Fatalf("修正录单失败: %v", err) + } + if updated.Operator != "客服小李" || updated.Remark != "备注2" { + t.Fatalf("修正后 = %+v", updated) + } + + // 3. 关键字命中平台 / 操作人 + list, err := repo.List(ctx, ListQuery{Keyword: "客服小李", Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("按操作人查询失败: %v", err) + } + if list.Total != 1 { + t.Fatalf("按操作人查询命中 %d 条, want 1", list.Total) + } + list, err = repo.List(ctx, ListQuery{Keyword: "网易", Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("按平台查询失败: %v", err) + } + if list.Total != 1 { + t.Fatalf("按平台查询命中 %d 条, want 1", list.Total) + } + + // 4. 删除选项不影响历史录单(记录里是名称快照) + if err := repo.DeleteOption(ctx, operator.ID); err != nil { + t.Fatalf("删除操作人选项失败: %v", err) + } + if err := repo.DeleteOption(ctx, operator.ID); !errors.Is(err, ErrOptionNotFound) { + t.Fatalf("重复删除应返回 ErrOptionNotFound, got %v", err) + } + after, err := repo.FindByID(ctx, entry.ID) + if err != nil { + t.Fatalf("删除选项后读取录单失败: %v", err) + } + if after.Operator != "客服小李" { + t.Fatalf("删除选项后历史录单操作人 = %q, want 客服小李", after.Operator) + } +} diff --git a/backend/internal/modules/csmanualentry/service.go b/backend/internal/modules/csmanualentry/service.go index 601eaec..510e0be 100644 --- a/backend/internal/modules/csmanualentry/service.go +++ b/backend/internal/modules/csmanualentry/service.go @@ -3,11 +3,15 @@ package csmanualentry import ( "context" "strings" + "unicode/utf8" "hfb_sys/backend/internal/timeutil" ) -const maxEntryNoLength = 64 +const ( + maxEntryNoLength = 64 + maxOptionNameLength = 64 +) type Service struct { repo *Repository @@ -26,6 +30,14 @@ func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest) if err != nil { return nil, err } + platform, err := normalizeOptionName(req.Platform) + if err != nil { + return nil, err + } + operator, err := normalizeOptionName(req.Operator) + if err != nil { + return nil, err + } adminName, adminUsername, err := s.repo.FindAdminSnapshot(ctx, adminID) if err != nil { return nil, err @@ -33,7 +45,7 @@ func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest) if adminName == "" { adminName = adminUsername } - entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, strings.TrimSpace(req.Remark)) + entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, platform, operator, strings.TrimSpace(req.Remark)) if err != nil { return nil, err } @@ -49,7 +61,15 @@ func (s *Service) Update(ctx context.Context, id uint64, req UpdateRequest) (*En if err != nil { return nil, err } - return s.repo.Update(ctx, id, entryNo, strings.TrimSpace(req.Remark)) + platform, err := normalizeOptionName(req.Platform) + if err != nil { + return nil, err + } + operator, err := normalizeOptionName(req.Operator) + if err != nil { + return nil, err + } + return s.repo.Update(ctx, id, entryNo, platform, operator, strings.TrimSpace(req.Remark)) } // Delete 管理员删除录单记录。 @@ -76,6 +96,56 @@ func (s *Service) TodayAgentStats(ctx context.Context) ([]AgentDayStatsDTO, erro return s.repo.TodayAgentStats(ctx, timeutil.ShanghaiNow()) } +// ListOptions 返回平台与操作人两组可选项。 +func (s *Service) ListOptions(ctx context.Context) (*OptionListDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListAllOptions(ctx) +} + +// CreateOption 新增平台 / 操作人选项。 +func (s *Service) CreateOption(ctx context.Context, req CreateOptionRequest) (*OptionDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + optionType := strings.TrimSpace(req.Type) + if !IsValidOptionType(optionType) { + return nil, ErrOptionTypeInvalid + } + name, err := normalizeOptionName(req.Name) + if err != nil { + return nil, err + } + if name == "" { + return nil, ErrOptionNameInvalid + } + return s.repo.CreateOption(ctx, optionType, name) +} + +// UpdateOption 重命名平台 / 操作人选项。 +func (s *Service) UpdateOption(ctx context.Context, id uint64, req UpdateOptionRequest) (*OptionDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + name, err := normalizeOptionName(req.Name) + if err != nil { + return nil, err + } + if name == "" { + return nil, ErrOptionNameInvalid + } + return s.repo.UpdateOption(ctx, id, name) +} + +// DeleteOption 删除平台 / 操作人选项。 +func (s *Service) DeleteOption(ctx context.Context, id uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.DeleteOption(ctx, id) +} + // normalizeEntryNo 去除首尾空白并做基础校验。 func normalizeEntryNo(raw string) (string, error) { value := strings.TrimSpace(raw) @@ -90,3 +160,16 @@ func normalizeEntryNo(raw string) (string, error) { } return value, nil } + +// normalizeOptionName 规整平台 / 操作人名称:去首尾空白、按字符数限长。 +// 允许为空(录单时平台与操作人选填),但内部不允许换行等控制字符。 +func normalizeOptionName(raw string) (string, error) { + value := strings.TrimSpace(raw) + if utf8.RuneCountInString(value) > maxOptionNameLength { + return "", ErrOptionNameInvalid + } + if strings.ContainsAny(value, "\t\r\n") { + return "", ErrOptionNameInvalid + } + return value, nil +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index eb992b2..72fe339 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -721,6 +721,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/files/upload", fileHandler.Upload) adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary) adminRoutes.GET("/manual-entries/today-stats", requirePerm("cs_manual_entry:view"), manualEntryHandler.TodayAgentStats) + adminRoutes.GET("/manual-entries/options", requirePerm("cs_manual_entry:view"), manualEntryHandler.ListOptions) + adminRoutes.POST("/manual-entries/options", requirePerm("cs_manual_entry:option_manage"), manualEntryHandler.CreateOption) + adminRoutes.PUT("/manual-entries/options/:id", requirePerm("cs_manual_entry:option_manage"), manualEntryHandler.UpdateOption) + adminRoutes.DELETE("/manual-entries/options/:id", requirePerm("cs_manual_entry:option_manage"), manualEntryHandler.DeleteOption) adminRoutes.GET("/manual-entries", requirePerm("cs_manual_entry:view"), manualEntryHandler.List) adminRoutes.POST("/manual-entries", requirePerm("cs_manual_entry:create"), manualEntryHandler.Create) adminRoutes.PUT("/manual-entries/:id", requirePerm("cs_manual_entry:update"), manualEntryHandler.Update) diff --git a/backend/migrations/000087_cs_manual_entry_platform_operator.sql b/backend/migrations/000087_cs_manual_entry_platform_operator.sql new file mode 100644 index 0000000..5837a3c --- /dev/null +++ b/backend/migrations/000087_cs_manual_entry_platform_operator.sql @@ -0,0 +1,52 @@ +-- +goose Up + +-- 客服手动录单:新增「平台」与「操作人」两列,并支持后台维护可选项。 +-- platform/operator 存名称快照(与 admin_name 同样的快照思路), +-- 选项只作为下拉数据源,删除选项不影响历史记录。 +ALTER TABLE cs_manual_entries + ADD COLUMN platform VARCHAR(64) NOT NULL DEFAULT '' COMMENT '平台名称快照' AFTER entry_no, + ADD COLUMN operator VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称快照' AFTER platform, + ADD KEY idx_cs_manual_entries_platform (platform), + ADD KEY idx_cs_manual_entries_operator (operator); + +CREATE TABLE IF NOT EXISTS cs_manual_entry_options ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + type VARCHAR(16) NOT NULL COMMENT '选项类型: platform=平台, operator=操作人', + name VARCHAR(64) NOT NULL COMMENT '选项名称', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_cs_manual_entry_options_type_name (type, name), + KEY idx_cs_manual_entry_options_type_sort (type, sort_order, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服手动录单平台/操作人选项配置表'; + +INSERT INTO permissions (code, name, resource, action) VALUES +('cs_manual_entry:option_manage', '配置客服手动录单平台与操作人', 'cs_manual_entry', 'option_manage') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +-- 运营/超管:可维护平台与操作人选项 +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code = 'cs_manual_entry:option_manage' +WHERE r.code IN ('ops', 'super_admin'); + +-- +goose Down + +DELETE rp +FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'cs_manual_entry:option_manage'; + +DELETE FROM permissions WHERE code = 'cs_manual_entry:option_manage'; + +DROP TABLE IF EXISTS cs_manual_entry_options; + +ALTER TABLE cs_manual_entries + DROP KEY idx_cs_manual_entries_platform, + DROP KEY idx_cs_manual_entries_operator, + DROP COLUMN platform, + DROP COLUMN operator; diff --git a/frontend/src/features/admin/api/adminManualEntry.ts b/frontend/src/features/admin/api/adminManualEntry.ts index 1f57c89..9347044 100644 --- a/frontend/src/features/admin/api/adminManualEntry.ts +++ b/frontend/src/features/admin/api/adminManualEntry.ts @@ -2,9 +2,14 @@ import { apiClient } from '@/shared/api/client' import type { ApiResponse, PaginatedResult } from '@/shared/types/types' +/** 录单可选项类型:平台 / 操作人 */ +export type ManualEntryOptionType = 'platform' | 'operator' + export interface ManualEntryItem { id: number entry_no: string + platform: string + operator: string admin_id: number admin_name: string admin_username: string @@ -12,6 +17,19 @@ export interface ManualEntryItem { created_at: string } +export interface ManualEntryOption { + id: number + type: ManualEntryOptionType + name: string + sort_order: number + created_at: string +} + +export interface ManualEntryOptions { + platforms: ManualEntryOption[] + operators: ManualEntryOption[] +} + export interface ManualEntryAgentStats { admin_id: number admin_name: string @@ -20,11 +38,15 @@ export interface ManualEntryAgentStats { export interface CreateManualEntryRequest { entry_no: string + platform?: string + operator?: string remark?: string } export interface UpdateManualEntryRequest { entry_no: string + platform?: string + operator?: string remark?: string } @@ -79,3 +101,37 @@ export async function fetchManualEntryTodayStats() { ) return data.data } + +/** 获取平台 / 操作人可选项(录单下拉数据源) */ +export async function fetchManualEntryOptions() { + const { data } = await apiClient.get>( + '/admin/manual-entries/options' + ) + return data.data +} + +export async function createManualEntryOption(payload: { + type: ManualEntryOptionType + name: string +}) { + const { data } = await apiClient.post>( + '/admin/manual-entries/options', + payload + ) + return data.data +} + +export async function updateManualEntryOption(id: number, name: string) { + const { data } = await apiClient.put>( + `/admin/manual-entries/options/${id}`, + { name } + ) + return data.data +} + +export async function deleteManualEntryOption(id: number) { + const { data } = await apiClient.delete>( + `/admin/manual-entries/options/${id}` + ) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminManualEntryView.css b/frontend/src/features/admin/views/AdminManualEntryView.css index 201fe8c..9014a47 100644 --- a/frontend/src/features/admin/views/AdminManualEntryView.css +++ b/frontend/src/features/admin/views/AdminManualEntryView.css @@ -13,6 +13,12 @@ margin-top: 20px; } +.page-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + .manual-entry-form-head { display: flex; align-items: baseline; @@ -34,19 +40,22 @@ .manual-entry-form-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; - align-items: end; } .manual-entry-form-grid :deep(.el-form-item) { margin-bottom: 0; } -.manual-entry-submit { +.full-control { + width: 100%; +} + +.manual-entry-submit-row { display: flex; - align-items: center; - height: 32px; + justify-content: flex-end; + margin-top: 18px; } .manual-entry-filter-row { @@ -62,7 +71,7 @@ } .filter-keyword { - width: 260px; + width: 320px; } .filter-date { @@ -76,8 +85,87 @@ color: #1e3a8a; } +/* 平台 / 操作人配置弹窗 */ +.option-config-tip { + margin: 0 0 16px; + font-size: 12px; + color: #94a3b8; +} + +.option-config-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.option-config-panel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #f8fafc; +} + +.option-config-title { + font-size: 14px; + font-weight: 700; + color: #1e293b; +} + +.option-config-add { + display: flex; + gap: 8px; +} + +.option-config-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + max-height: 320px; + overflow-y: auto; +} + +.option-config-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border-radius: 8px; + background: #ffffff; + border: 1px solid #e2e8f0; +} + +.option-config-name { + font-size: 13px; + color: #0f172a; + word-break: break-all; +} + +.option-config-actions { + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +.option-config-actions :deep(.el-button) { + margin: 0; +} + +.option-config-empty { + margin: 0; + font-size: 12px; + color: #94a3b8; +} + @media (max-width: 1100px) { - .manual-entry-form-grid { + .manual-entry-form-grid, + .option-config-grid { grid-template-columns: 1fr; } } diff --git a/frontend/src/features/admin/views/AdminManualEntryView.vue b/frontend/src/features/admin/views/AdminManualEntryView.vue index f05b75b..8008eaf 100644 --- a/frontend/src/features/admin/views/AdminManualEntryView.vue +++ b/frontend/src/features/admin/views/AdminManualEntryView.vue @@ -1,13 +1,20 @@ + + + +

+ 这里维护的可选项会出现在录单表单的下拉中;删除选项不会影响已登记的历史记录。 +

+
+
+
平台
+
+ + + 添加 + +
+
    +
  • + {{ item.name }} + + + 重命名 + + + 删除 + + +
  • +
+

暂无平台,请先添加

+
+ +
+
操作人
+
+ + + 添加 + +
+
    +
  • + {{ item.name }} + + + 重命名 + + + 删除 + + +
  • +
+

暂无操作人,请先添加

+
+
+ +