feat(admin): 新增客服手动录单功能与仪表盘今日录单统计

- 做了什么:
  - 新增客服手动录单数据库迁移 000084 及对应模型,支持编号唯一校验、客服快照绑定与 CRUD 操作;
  - 后端提供 csmanualentry 业务模块及相关 API,并在仪表盘摘要中聚合今日各客服录单数量;
  - 前端增加客服手动录单视图页面、路由与侧边栏导航,支持录单二次确认与细粒度权限控制;
  - 管理后台仪表盘增加今日客服手动录单卡片展示。
- 为什么:
  - 满足客服在后台手动登记外部业务编号需求,并便于运营按客服统计今日录单业绩和追溯对账。
- 验证方式:
  - 执行后端全量测试 go test ./... 与静态检查 go vet ./... 全部通过;
  - 执行前端代码门禁 npm run check 与 vite build 全部通过;
  - 执行 goose down/up 迁移回滚与应用测试通过,角色权限分配正确;
  - 本地 dev 联调 POST /api/admin/manual-entries 录单成功返回 201。
This commit is contained in:
yml2213
2026-10-12 14:52:24 +08:00
parent 5840b7da40
commit cca40940d5
20 changed files with 1267 additions and 12 deletions
+14 -6
View File
@@ -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"`
@@ -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").