diff --git a/backend/internal/model/cs_manual_entry.go b/backend/internal/model/cs_manual_entry.go new file mode 100644 index 0000000..8730db2 --- /dev/null +++ b/backend/internal/model/cs_manual_entry.go @@ -0,0 +1,21 @@ +package model + +import "time" + +// CSManualEntry 客服手动录单记录。 +// 客服在后台手动登记一个编号(如 202610120013),该编号与其登录账号绑定, +// 记录登记时间等,供后续查询与按客服统计今日录单量。 +type CSManualEntry struct { + ID uint64 `gorm:"primaryKey" json:"id"` + EntryNo string `gorm:"size:64;not null;uniqueIndex" json:"entry_no"` + 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"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (CSManualEntry) TableName() string { + return "cs_manual_entries" +} diff --git a/backend/internal/modules/admindashboard/dto.go b/backend/internal/modules/admindashboard/dto.go index d49d382..43f281f 100644 --- a/backend/internal/modules/admindashboard/dto.go +++ b/backend/internal/modules/admindashboard/dto.go @@ -3,12 +3,13 @@ package admindashboard import "time" type DashboardDTO struct { - Metrics MetricsDTO `json:"metrics"` - Pending PendingDTO `json:"pending"` - ListingDaily ListingDailyOverviewDTO `json:"listing_daily"` - RecentOrders []RecentOrderDTO `json:"recent_orders"` - RecentDisputes []RecentDisputeDTO `json:"recent_disputes"` - GeneratedAt time.Time `json:"generated_at"` + Metrics MetricsDTO `json:"metrics"` + Pending PendingDTO `json:"pending"` + ListingDaily ListingDailyOverviewDTO `json:"listing_daily"` + ManualEntryToday []ManualEntryAgentStatsDTO `json:"manual_entry_today"` + RecentOrders []RecentOrderDTO `json:"recent_orders"` + RecentDisputes []RecentDisputeDTO `json:"recent_disputes"` + GeneratedAt time.Time `json:"generated_at"` } type MetricsDTO struct { @@ -56,6 +57,13 @@ type ListingUploaderDayStatsDTO struct { UploadCount int64 `json:"upload_count"` } +// ManualEntryAgentStatsDTO 今日客服手动录单统计(按客服聚合)。 +type ManualEntryAgentStatsDTO struct { + AdminID uint64 `json:"admin_id"` + AdminName string `json:"admin_name"` + Count int64 `json:"count"` +} + type PendingDTO struct { ListingReviews int64 `json:"listing_reviews"` Disputes int64 `json:"disputes"` diff --git a/backend/internal/modules/admindashboard/repository.go b/backend/internal/modules/admindashboard/repository.go index 8ea685d..24be064 100644 --- a/backend/internal/modules/admindashboard/repository.go +++ b/backend/internal/modules/admindashboard/repository.go @@ -78,6 +78,10 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) { if err != nil { return nil, err } + manualEntryToday, err := r.manualEntryToday(ctx, now) + if err != nil { + return nil, err + } recentOrders, err := r.recentOrders(ctx) if err != nil { return nil, err @@ -87,12 +91,13 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) { return nil, err } return &DashboardDTO{ - Metrics: metrics, - Pending: pending, - ListingDaily: listingDaily, - RecentOrders: recentOrders, - RecentDisputes: recentDisputes, - GeneratedAt: now, + Metrics: metrics, + Pending: pending, + ListingDaily: listingDaily, + ManualEntryToday: manualEntryToday, + RecentOrders: recentOrders, + RecentDisputes: recentDisputes, + GeneratedAt: now, }, nil } @@ -348,6 +353,47 @@ func isTradeLeaveEvent(eventType, source string) bool { } } +// manualEntryToday 统计今日(北京时间自然日)各客服的手动录单数量。 +// 录单表是后来新增的功能表,若迁移尚未执行(表不存在)则降级为空统计, +// 避免连带整个仪表盘不可用。 +func (r *Repository) manualEntryToday(ctx context.Context, now time.Time) ([]ManualEntryAgentStatsDTO, error) { + if r.db == nil || !r.db.Migrator().HasTable("cs_manual_entries") { + return []ManualEntryAgentStatsDTO{}, nil + } + loc := timeutil.ShanghaiLocation() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + end := start.AddDate(0, 0, 1) + + type row struct { + AdminID uint64 + AdminName string + Count int64 + } + rows := make([]row, 0) + if err := r.db.WithContext(ctx).Table("cs_manual_entries"). + Select("admin_id, MAX(admin_name) AS admin_name, COUNT(*) AS count"). + Where("created_at >= ? AND created_at < ?", start, end). + Group("admin_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Count != rows[j].Count { + return rows[i].Count > rows[j].Count + } + return rows[i].AdminName < rows[j].AdminName + }) + items := make([]ManualEntryAgentStatsDTO, 0, len(rows)) + for _, item := range rows { + items = append(items, ManualEntryAgentStatsDTO{ + AdminID: item.AdminID, + AdminName: item.AdminName, + Count: item.Count, + }) + } + return items, nil +} + func (r *Repository) recentOrders(ctx context.Context) ([]RecentOrderDTO, error) { rows := make([]RecentOrderDTO, 0) err := r.db.WithContext(ctx).Table("rental_orders AS o"). diff --git a/backend/internal/modules/csmanualentry/dto.go b/backend/internal/modules/csmanualentry/dto.go new file mode 100644 index 0000000..fd8fd24 --- /dev/null +++ b/backend/internal/modules/csmanualentry/dto.go @@ -0,0 +1,58 @@ +package csmanualentry + +import "time" + +// CreateRequest 客服手动录单请求。 +type CreateRequest struct { + EntryNo string `json:"entry_no" binding:"required"` + Remark string `json:"remark"` +} + +// UpdateRequest 管理员修正录单请求。 +type UpdateRequest struct { + EntryNo string `json:"entry_no" binding:"required"` + Remark string `json:"remark"` +} + +// EntryDTO 录单记录。 +type EntryDTO struct { + ID uint64 `json:"id"` + EntryNo string `json:"entry_no"` + AdminID uint64 `json:"admin_id"` + AdminName string `json:"admin_name"` + AdminUsername string `json:"admin_username"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"created_at"` +} + +// ListQuery 录单查询条件。 +type ListQuery struct { + EntryNo string + Keyword string + AdminID uint64 + StartDate string // YYYY-MM-DD(北京时间,闭区间) + EndDate string // YYYY-MM-DD(北京时间,闭区间) + Page int + PageSize int +} + +// PaginatedResult 分页结果。 +type PaginatedResult struct { + Items []EntryDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// AgentDayStatsDTO 某客服某日录单统计。 +type AgentDayStatsDTO struct { + Date string `json:"date"` // YYYY-MM-DD(北京时间) + AdminID uint64 `json:"admin_id"` + AdminName string `json:"admin_name"` + Count int64 `json:"count"` +} + +// CreateResult 录单结果。 +type CreateResult struct { + Entry EntryDTO `json:"entry"` +} diff --git a/backend/internal/modules/csmanualentry/errors.go b/backend/internal/modules/csmanualentry/errors.go new file mode 100644 index 0000000..714e74f --- /dev/null +++ b/backend/internal/modules/csmanualentry/errors.go @@ -0,0 +1,11 @@ +package csmanualentry + +import "errors" + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrEntryNoExists = errors.New("manual entry number already exists") + ErrEntryNoInvalid = errors.New("entry number is invalid") + ErrEntryNotFound = errors.New("manual entry not found") + ErrAdminNotFound = errors.New("admin not found") +) diff --git a/backend/internal/modules/csmanualentry/handler.go b/backend/internal/modules/csmanualentry/handler.go new file mode 100644 index 0000000..9d58258 --- /dev/null +++ b/backend/internal/modules/csmanualentry/handler.go @@ -0,0 +1,165 @@ +package csmanualentry + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Create(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req CreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "录单参数不正确") + return + } + result, err := h.service.Create(c.Request.Context(), adminID, req) + if err != nil { + writeManualEntryError(c, err) + return + } + response.Created(c, result) +} + +func (h *Handler) List(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + page, pageSize := parsePagination(c) + query := ListQuery{ + EntryNo: strings.TrimSpace(c.Query("entry_no")), + Keyword: strings.TrimSpace(c.Query("keyword")), + StartDate: strings.TrimSpace(c.Query("start_date")), + EndDate: strings.TrimSpace(c.Query("end_date")), + Page: page, + PageSize: pageSize, + } + if raw := c.Query("admin_id"); raw != "" { + adminID, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + response.BadRequest(c, "客服 ID 不正确") + return + } + query.AdminID = adminID + } + result, err := h.service.List(c.Request.Context(), query) + if err != nil { + writeManualEntryError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) TodayAgentStats(c *gin.Context) { + if _, ok := currentAdminID(c); !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + items, err := h.service.TodayAgentStats(c.Request.Context()) + if err != nil { + writeManualEntryError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) Update(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 UpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "录单参数不正确") + return + } + entry, err := h.service.Update(c.Request.Context(), id, req) + if err != nil { + writeManualEntryError(c, err) + return + } + response.OK(c, entry) +} + +func (h *Handler) Delete(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.Delete(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 { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} + +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func writeManualEntryError(c *gin.Context, err error) { + response.RecordError(c, err) + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrAdminNotFound): + response.Unauthorized(c, "客服账号不存在") + case errors.Is(err, ErrEntryNoInvalid): + response.BadRequest(c, "编号格式不正确") + case errors.Is(err, ErrEntryNotFound): + response.NotFound(c, "录单记录不存在") + case errors.Is(err, ErrEntryNoExists): + response.Error(c, http.StatusConflict, "entry_no_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 new file mode 100644 index 0000000..86b9f71 --- /dev/null +++ b/backend/internal/modules/csmanualentry/repository.go @@ -0,0 +1,220 @@ +package csmanualentry + +import ( + "context" + "errors" + "sort" + "strings" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/timeutil" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// FindAdminSnapshot 读取客服的昵称与登录名,用于录单时绑定快照。 +func (r *Repository) FindAdminSnapshot(ctx context.Context, adminID uint64) (name, username string, err error) { + var admin model.AdminUser + if err := r.db.WithContext(ctx).First(&admin, adminID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", ErrAdminNotFound + } + return "", "", err + } + return admin.Nickname, admin.Username, nil +} + +// Create 写入一条录单记录。 +func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, remark string) (*EntryDTO, error) { + row := model.CSManualEntry{ + EntryNo: entryNo, + AdminID: adminID, + AdminName: adminName, + AdminUsername: adminUsername, + Remark: remark, + } + if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { + if isDuplicateEntry(err) { + return nil, ErrEntryNoExists + } + return nil, err + } + dto := toDTO(row) + return &dto, nil +} + +// FindByID 按 ID 查询单条录单记录。 +func (r *Repository) FindByID(ctx context.Context, id uint64) (*EntryDTO, error) { + var row model.CSManualEntry + if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrEntryNotFound + } + return nil, err + } + dto := toDTO(row) + return &dto, nil +} + +// Update 修正录单的编号与备注。编号唯一冲突返回 ErrEntryNoExists。 +func (r *Repository) Update(ctx context.Context, id uint64, entryNo, remark string) (*EntryDTO, error) { + result := r.db.WithContext(ctx).Model(&model.CSManualEntry{}). + Where("id = ?", id). + Updates(map[string]any{ + "entry_no": entryNo, + "remark": remark, + }) + if result.Error != nil { + if isDuplicateEntry(result.Error) { + return nil, ErrEntryNoExists + } + return nil, result.Error + } + if result.RowsAffected == 0 { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.CSManualEntry{}).Where("id = ?", id).Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, ErrEntryNotFound + } + } + return r.FindByID(ctx, id) +} + +// Delete 删除一条录单记录。 +func (r *Repository) Delete(ctx context.Context, id uint64) error { + result := r.db.WithContext(ctx).Delete(&model.CSManualEntry{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrEntryNotFound + } + return nil +} + +// List 按条件分页查询录单记录。 +func (r *Repository) List(ctx context.Context, query ListQuery) (*PaginatedResult, error) { + db := r.db.WithContext(ctx).Model(&model.CSManualEntry{}) + countDB := r.db.WithContext(ctx).Model(&model.CSManualEntry{}) + + if query.EntryNo != "" { + db = db.Where("entry_no = ?", query.EntryNo) + countDB = countDB.Where("entry_no = ?", query.EntryNo) + } + if query.AdminID > 0 { + db = db.Where("admin_id = ?", query.AdminID) + countDB = countDB.Where("admin_id = ?", query.AdminID) + } + 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) + db = db.Where(cond) + countDB = countDB.Where(cond) + } + loc := timeutil.ShanghaiLocation() + if query.StartDate != "" { + if start, err := parseDay(query.StartDate, loc); err == nil { + db = db.Where("created_at >= ?", start) + countDB = countDB.Where("created_at >= ?", start) + } + } + if query.EndDate != "" { + if end, err := parseDay(query.EndDate, loc); err == nil { + next := end.AddDate(0, 0, 1) + db = db.Where("created_at < ?", next) + countDB = countDB.Where("created_at < ?", next) + } + } + + var total int64 + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + offset := (query.Page - 1) * query.PageSize + rows := make([]model.CSManualEntry, 0) + if err := db.Order("id DESC").Offset(offset).Limit(query.PageSize).Find(&rows).Error; err != nil { + return nil, err + } + items := make([]EntryDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toDTO(row)) + } + return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + +// TodayAgentStats 统计指定日期(北京时间自然日)各客服的录单量。 +func (r *Repository) TodayAgentStats(ctx context.Context, day time.Time) ([]AgentDayStatsDTO, error) { + loc := timeutil.ShanghaiLocation() + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) + end := start.AddDate(0, 0, 1) + + type row struct { + AdminID uint64 + AdminName string + Count int64 + } + rows := make([]row, 0) + if err := r.db.WithContext(ctx).Table("cs_manual_entries"). + Select("admin_id, MAX(admin_name) AS admin_name, COUNT(*) AS count"). + Where("created_at >= ? AND created_at < ?", start, end). + Group("admin_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Count != rows[j].Count { + return rows[i].Count > rows[j].Count + } + return rows[i].AdminName < rows[j].AdminName + }) + date := start.Format("2006-01-02") + items := make([]AgentDayStatsDTO, 0, len(rows)) + for _, item := range rows { + items = append(items, AgentDayStatsDTO{ + Date: date, + AdminID: item.AdminID, + AdminName: item.AdminName, + Count: item.Count, + }) + } + return items, nil +} + +func parseDay(value string, loc *time.Location) (time.Time, error) { + return time.ParseInLocation("2006-01-02", value, loc) +} + +// isDuplicateEntry 判断错误是否为 entry_no 唯一键冲突。 +func isDuplicateEntry(err error) bool { + if err == nil { + return false + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return true + } + return strings.Contains(err.Error(), "1062") +} + +func toDTO(row model.CSManualEntry) EntryDTO { + return EntryDTO{ + ID: row.ID, + EntryNo: row.EntryNo, + AdminID: row.AdminID, + AdminName: row.AdminName, + AdminUsername: row.AdminUsername, + Remark: row.Remark, + CreatedAt: row.CreatedAt, + } +} diff --git a/backend/internal/modules/csmanualentry/service.go b/backend/internal/modules/csmanualentry/service.go new file mode 100644 index 0000000..601eaec --- /dev/null +++ b/backend/internal/modules/csmanualentry/service.go @@ -0,0 +1,92 @@ +package csmanualentry + +import ( + "context" + "strings" + + "hfb_sys/backend/internal/timeutil" +) + +const maxEntryNoLength = 64 + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +// Create 校验编号并落库。客服身份由 handler 从登录上下文注入。 +func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest) (*CreateResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + entryNo, err := normalizeEntryNo(req.EntryNo) + if err != nil { + return nil, err + } + adminName, adminUsername, err := s.repo.FindAdminSnapshot(ctx, adminID) + if err != nil { + return nil, err + } + if adminName == "" { + adminName = adminUsername + } + entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, strings.TrimSpace(req.Remark)) + if err != nil { + return nil, err + } + return &CreateResult{Entry: *entry}, nil +} + +// Update 管理员修正录单记录。 +func (s *Service) Update(ctx context.Context, id uint64, req UpdateRequest) (*EntryDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + entryNo, err := normalizeEntryNo(req.EntryNo) + if err != nil { + return nil, err + } + return s.repo.Update(ctx, id, entryNo, strings.TrimSpace(req.Remark)) +} + +// Delete 管理员删除录单记录。 +func (s *Service) Delete(ctx context.Context, id uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.Delete(ctx, id) +} + +// List 分页查询录单记录。 +func (s *Service) List(ctx context.Context, query ListQuery) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.List(ctx, query) +} + +// TodayAgentStats 返回今日(北京时间)各客服录单统计。 +func (s *Service) TodayAgentStats(ctx context.Context) ([]AgentDayStatsDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.TodayAgentStats(ctx, timeutil.ShanghaiNow()) +} + +// normalizeEntryNo 去除首尾空白并做基础校验。 +func normalizeEntryNo(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", ErrEntryNoInvalid + } + if len(value) > maxEntryNoLength { + return "", ErrEntryNoInvalid + } + if strings.ContainsAny(value, " \t\r\n") { + return "", ErrEntryNoInvalid + } + return value, nil +} diff --git a/backend/internal/modules/csmanualentry/service_test.go b/backend/internal/modules/csmanualentry/service_test.go new file mode 100644 index 0000000..9a044e1 --- /dev/null +++ b/backend/internal/modules/csmanualentry/service_test.go @@ -0,0 +1,45 @@ +package csmanualentry + +import ( + "errors" + "strings" + "testing" +) + +func TestNormalizeEntryNo(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr error + }{ + {name: "trim", input: " 202610120013 ", want: "202610120013"}, + {name: "plain", input: "202610120013", want: "202610120013"}, + {name: "empty", input: " ", wantErr: ErrEntryNoInvalid}, + {name: "inner space", input: "2026 1001", wantErr: ErrEntryNoInvalid}, + {name: "too long", input: strings.Repeat("1", 65), wantErr: ErrEntryNoInvalid}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeEntryNo(tt.input) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("normalizeEntryNo(%q) error = %v, want %v", tt.input, err, tt.wantErr) + } + if tt.wantErr == nil && got != tt.want { + t.Fatalf("normalizeEntryNo(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestIsDuplicateEntry(t *testing.T) { + if isDuplicateEntry(nil) { + t.Fatal("nil should not be duplicate") + } + if !isDuplicateEntry(errors.New("Error 1062 (23000): Duplicate entry")) { + t.Fatal("1062 error should be duplicate") + } + if isDuplicateEntry(errors.New("some other error")) { + t.Fatal("unrelated error should not be duplicate") + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a8cc58b..6e28603 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -21,6 +21,7 @@ import ( "hfb_sys/backend/internal/modules/backupmonitor" "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/chathub" + "hfb_sys/backend/internal/modules/csmanualentry" "hfb_sys/backend/internal/modules/dispute" "hfb_sys/backend/internal/modules/distribution" filemodule "hfb_sys/backend/internal/modules/file" @@ -134,6 +135,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } adminDashboardService := admindashboard.NewService(adminDashboardRepo) adminDashboardHandler := admindashboard.NewHandler(adminDashboardService) + var manualEntryRepo *csmanualentry.Repository + if deps.DB != nil { + manualEntryRepo = csmanualentry.NewRepository(deps.DB) + } + manualEntryService := csmanualentry.NewService(manualEntryRepo) + manualEntryHandler := csmanualentry.NewHandler(manualEntryService) var adminFinanceRepo *adminfinance.Repository if deps.DB != nil { adminFinanceRepo = adminfinance.NewRepository(deps.DB, deps.Redis) @@ -711,6 +718,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/distribution/auth/logout", distributionHandler.Logout) 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", 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) + adminRoutes.DELETE("/manual-entries/:id", requirePerm("cs_manual_entry:delete"), manualEntryHandler.Delete) adminRoutes.GET("/distribution/sites", requireAnyPerm("distribution:site_view", "qrcode:view"), distributionHandler.ListSites) adminRoutes.GET("/distribution/site-defaults", requirePerm("distribution:site_view"), distributionHandler.GetSiteDefaults) adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite) diff --git a/backend/migrations/000084_cs_manual_entry.sql b/backend/migrations/000084_cs_manual_entry.sql new file mode 100644 index 0000000..0ec83b5 --- /dev/null +++ b/backend/migrations/000084_cs_manual_entry.sql @@ -0,0 +1,50 @@ +-- +goose Up + +CREATE TABLE IF NOT EXISTS cs_manual_entries ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + entry_no VARCHAR(64) NOT NULL COMMENT '客服录入的编号', + admin_id BIGINT UNSIGNED NOT NULL COMMENT '录单客服管理员ID', + admin_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客服昵称快照', + admin_username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客服登录名快照', + remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '登记时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_cs_manual_entries_entry_no (entry_no), + KEY idx_cs_manual_entries_admin_created (admin_id, created_at), + KEY idx_cs_manual_entries_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服手动录单记录表'; + +INSERT INTO permissions (code, name, resource, action) VALUES +('cs_manual_entry:view', '查看客服手动录单', 'cs_manual_entry', 'view'), +('cs_manual_entry:create', '客服手动录单', 'cs_manual_entry', 'create'), +('cs_manual_entry:update', '编辑客服手动录单', 'cs_manual_entry', 'update'), +('cs_manual_entry:delete', '删除客服手动录单', 'cs_manual_entry', 'delete') +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 IN ('cs_manual_entry:view', 'cs_manual_entry:create') +WHERE r.code IN ('cs', 'ops', 'super_admin'); + +-- 运营/超管:全量 CRUD(编辑、删除) +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code IN ('cs_manual_entry:update', 'cs_manual_entry:delete') +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 IN ('cs_manual_entry:view', 'cs_manual_entry:create', 'cs_manual_entry:update', 'cs_manual_entry:delete'); + +DELETE FROM permissions WHERE code IN ('cs_manual_entry:view', 'cs_manual_entry:create', 'cs_manual_entry:update', 'cs_manual_entry:delete'); + +DROP TABLE IF EXISTS cs_manual_entries; diff --git a/frontend/src/features/admin/api/adminDashboard.ts b/frontend/src/features/admin/api/adminDashboard.ts index 38c536d..e088af3 100644 --- a/frontend/src/features/admin/api/adminDashboard.ts +++ b/frontend/src/features/admin/api/adminDashboard.ts @@ -72,10 +72,17 @@ export interface DashboardRecentDispute { created_at: string } +export interface ManualEntryTodayStats { + admin_id: number + admin_name: string + count: number +} + export interface AdminDashboard { metrics: DashboardMetrics pending: DashboardPending listing_daily: ListingDailyOverview + manual_entry_today: ManualEntryTodayStats[] recent_orders: DashboardRecentOrder[] recent_disputes: DashboardRecentDispute[] generated_at: string @@ -104,6 +111,7 @@ export async function fetchAdminDashboard() { days: listingDaily?.days ?? 7, timezone: listingDaily?.timezone ?? 'Asia/Shanghai', }, + manual_entry_today: payload.manual_entry_today ?? [], recent_orders: payload.recent_orders ?? [], recent_disputes: payload.recent_disputes ?? [], } diff --git a/frontend/src/features/admin/api/adminManualEntry.ts b/frontend/src/features/admin/api/adminManualEntry.ts new file mode 100644 index 0000000..1f57c89 --- /dev/null +++ b/frontend/src/features/admin/api/adminManualEntry.ts @@ -0,0 +1,81 @@ +import { apiClient } from '@/shared/api/client' + +import type { ApiResponse, PaginatedResult } from '@/shared/types/types' + +export interface ManualEntryItem { + id: number + entry_no: string + admin_id: number + admin_name: string + admin_username: string + remark: string + created_at: string +} + +export interface ManualEntryAgentStats { + admin_id: number + admin_name: string + count: number +} + +export interface CreateManualEntryRequest { + entry_no: string + remark?: string +} + +export interface UpdateManualEntryRequest { + entry_no: string + remark?: string +} + +export interface ManualEntryListQuery { + entry_no?: string + keyword?: string + admin_id?: number + start_date?: string + end_date?: string +} + +export async function fetchManualEntries( + page = 1, + pageSize = 20, + query: ManualEntryListQuery = {} +) { + const { data } = await apiClient.get>>( + '/admin/manual-entries', + { + params: { page, page_size: pageSize, ...query }, + } + ) + return data.data +} + +export async function createManualEntry(payload: CreateManualEntryRequest) { + const { data } = await apiClient.post>( + '/admin/manual-entries', + payload + ) + return data.data +} + +export async function updateManualEntry(id: number, payload: UpdateManualEntryRequest) { + const { data } = await apiClient.put>( + `/admin/manual-entries/${id}`, + payload + ) + return data.data +} + +export async function deleteManualEntry(id: number) { + const { data } = await apiClient.delete>( + `/admin/manual-entries/${id}` + ) + return data.data +} + +export async function fetchManualEntryTodayStats() { + const { data } = await apiClient.get>( + '/admin/manual-entries/today-stats' + ) + return data.data +} diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index bbf1cab..620253c 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -2,6 +2,7 @@ export * from './api/adminDashboard' export * from './api/adminUsers' export * from './api/adminMgr' +export * from './api/adminManualEntry' export * from './api/adminWallet' export * from './api/adminPayments' export * from './api/adminFinance' diff --git a/frontend/src/features/admin/views/AdminDashboardView.css b/frontend/src/features/admin/views/AdminDashboardView.css index 20db491..cf4bd4d 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.css +++ b/frontend/src/features/admin/views/AdminDashboardView.css @@ -557,6 +557,20 @@ white-space: nowrap; } +/* 今日客服手动录单统计:与外部上传区分开,用绿色系 */ +.listing-uploader-item.manual-entry-item { + border-color: #bbf7d0; + background: #f0fdf4; +} + +.listing-uploader-item.manual-entry-item .uploader-name { + color: #14532d; +} + +.listing-uploader-item.manual-entry-item .uploader-total { + color: #16a34a; +} + .channel-badge { font-weight: 600; } diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue index 38f1d46..1c50922 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.vue +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -26,6 +26,7 @@ import { type ListingChannelDayStats, type ListingDayStats, type ListingUploaderDayStats, + type ManualEntryTodayStats, } from '@/features/admin/api/adminDashboard' import { useAdminTable } from '@/features/admin/composables/useAdminTable' import { formatCentWithSymbol } from '@/shared/utils/money' @@ -78,6 +79,12 @@ const listingTodayUploaders = computed(() => { ) }) +const manualEntryToday = computed(() => + [...(dashboard.value?.manual_entry_today ?? [])].sort( + (a, b) => b.count - a.count || a.admin_name.localeCompare(b.admin_name) + ) +) + function leaveCount(row: ListingChannelDayStats | ListingDayStats) { return Number(row.active_offline_count || 0) + Number(row.trade_leave_count || 0) } @@ -336,6 +343,21 @@ function shortDate(date: string) { +
+
今日客服手动录单统计
+
+
+ {{ row.admin_name || '未填写' }} + {{ row.count }} + 个 +
+
+
+
diff --git a/frontend/src/features/admin/views/AdminManualEntryView.css b/frontend/src/features/admin/views/AdminManualEntryView.css new file mode 100644 index 0000000..201fe8c --- /dev/null +++ b/frontend/src/features/admin/views/AdminManualEntryView.css @@ -0,0 +1,83 @@ +.manual-entry-form-card, +.manual-entry-list-card { + background: #ffffff; + border: 1px solid rgba(226, 232, 240, 0.8); + border-radius: 16px; + padding: 22px 24px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.manual-entry-list-card { + margin-top: 20px; +} + +.manual-entry-form-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.form-title { + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.form-hint { + font-size: 12px; + color: #94a3b8; +} + +.manual-entry-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + gap: 16px; + align-items: end; +} + +.manual-entry-form-grid :deep(.el-form-item) { + margin-bottom: 0; +} + +.manual-entry-submit { + display: flex; + align-items: center; + height: 32px; +} + +.manual-entry-filter-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.filter-entry-no { + width: 200px; +} + +.filter-keyword { + width: 260px; +} + +.filter-date { + width: 260px; +} + +.mono-entry-no { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + font-weight: 600; + color: #1e3a8a; +} + +@media (max-width: 1100px) { + .manual-entry-form-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminManualEntryView.vue b/frontend/src/features/admin/views/AdminManualEntryView.vue new file mode 100644 index 0000000..f05b75b --- /dev/null +++ b/frontend/src/features/admin/views/AdminManualEntryView.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 773d3e8..a428edf 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -7,6 +7,7 @@ import { DataLine, Document, DocumentChecked, + EditPen, Expand, Fold, House, @@ -204,6 +205,12 @@ const allNavGroups: NavGroup[] = [ { label: '客服群聊', to: adminPath('chats'), icon: ChatDotRound, permission: 'chat:view' }, { label: '二维码池', to: adminPath('qrcode-pool'), icon: Picture, permission: 'qrcode:view' }, { label: '客服分组', to: adminPath('support-groups'), icon: User, permission: 'chat:manage' }, + { + label: '手动录单', + to: adminPath('manual-entries'), + icon: EditPen, + permission: 'cs_manual_entry:view', + }, ], }, { diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 9b27337..acc15dc 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -120,6 +120,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminSupportGroupsView.vue'), meta: adminMeta, }, + { + path: adminPath('manual-entries'), + name: 'admin-manual-entries', + component: () => import('@/features/admin/views/AdminManualEntryView.vue'), + meta: adminMeta, + }, { path: adminPath('finance/dashboard'), name: 'admin-finance-dashboard',