From 87d252bbee358918ae6a1d6cf38398e1f3e96985 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 19:09:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E5=A2=9E=E5=8A=A0=E6=9E=81?= =?UTF-8?q?=E5=93=81=E4=B8=93=E5=8C=BA=E4=B8=8E=E4=BF=9D=E9=99=A9=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仪表盘新增今日极品专区、保险8%、保险4%成交数量 - 展示近7日每日成交趋势和明细 - 按已支付订单口径统计,补充回归测试 --- .../internal/modules/admindashboard/dto.go | 20 ++ .../modules/admindashboard/repository.go | 78 ++++++++ .../modules/admindashboard/repository_test.go | 50 +++++ .../src/features/admin/api/adminDashboard.ts | 29 +++ .../admin/views/AdminDashboardView.css | 27 +++ .../admin/views/AdminDashboardView.vue | 178 ++++++++++++++++++ 6 files changed, 382 insertions(+) diff --git a/backend/internal/modules/admindashboard/dto.go b/backend/internal/modules/admindashboard/dto.go index 43f281f5..9d7d73f5 100644 --- a/backend/internal/modules/admindashboard/dto.go +++ b/backend/internal/modules/admindashboard/dto.go @@ -6,6 +6,7 @@ type DashboardDTO struct { Metrics MetricsDTO `json:"metrics"` Pending PendingDTO `json:"pending"` ListingDaily ListingDailyOverviewDTO `json:"listing_daily"` + TradingDaily TradingDailyOverviewDTO `json:"trading_daily"` ManualEntryToday []ManualEntryAgentStatsDTO `json:"manual_entry_today"` RecentOrders []RecentOrderDTO `json:"recent_orders"` RecentDisputes []RecentDisputeDTO `json:"recent_disputes"` @@ -57,6 +58,25 @@ type ListingUploaderDayStatsDTO struct { UploadCount int64 `json:"upload_count"` } +// TradingDailyOverviewDTO 经营成交统计:今日指标 + 近 7 日趋势。 +// 统计口径:已支付订单(status 不在 pending_payment/cancelled/closed),按创建日归属北京时间自然日。 +type TradingDailyOverviewDTO struct { + Today TradingDayStatsDTO `json:"today"` + Trend []TradingDayStatsDTO `json:"trend"` + Days int `json:"days"` + Timezone string `json:"timezone"` +} + +type TradingDayStatsDTO struct { + Date string `json:"date"` // YYYY-MM-DD(上海时区) + // PremiumOrderCount 极品专区成交订单数。 + PremiumOrderCount int64 `json:"premium_order_count"` + // Insurance8OrderCount 选择保险 8% 的成交订单数。 + Insurance8OrderCount int64 `json:"insurance_8_order_count"` + // Insurance4OrderCount 选择保险 4% 的成交订单数。 + Insurance4OrderCount int64 `json:"insurance_4_order_count"` +} + // ManualEntryAgentStatsDTO 今日客服手动录单统计(按客服聚合)。 type ManualEntryAgentStatsDTO struct { AdminID uint64 `json:"admin_id"` diff --git a/backend/internal/modules/admindashboard/repository.go b/backend/internal/modules/admindashboard/repository.go index 24be0647..3ed06bd5 100644 --- a/backend/internal/modules/admindashboard/repository.go +++ b/backend/internal/modules/admindashboard/repository.go @@ -14,8 +14,11 @@ import ( const ( listingDailyTrendDays = 7 + tradingDailyTrendDays = 7 listingChannelWebsite = "站内发布" listingChannelExternalUnknown = "未填写" + // 成交口径:排除待支付、取消、关闭,与推广归因的成交统计保持一致。 + tradingOrderPaidCondition = "status NOT IN ('pending_payment', 'cancelled', 'closed')" ) type Repository struct { @@ -78,6 +81,10 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) { if err != nil { return nil, err } + tradingDaily, err := r.tradingDailyOverview(ctx, now, tradingDailyTrendDays) + if err != nil { + return nil, err + } manualEntryToday, err := r.manualEntryToday(ctx, now) if err != nil { return nil, err @@ -94,6 +101,7 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) { Metrics: metrics, Pending: pending, ListingDaily: listingDaily, + TradingDaily: tradingDaily, ManualEntryToday: manualEntryToday, RecentOrders: recentOrders, RecentDisputes: recentDisputes, @@ -353,6 +361,76 @@ func isTradeLeaveEvent(eventType, source string) bool { } } +// tradingDailyOverview 统计经营成交数据:极品专区成交、保险 8%/4% 成交。 +// 订单在下单时已快照 is_premium / compensation_plan / insurance_fee_bps, +// 因此按订单统计不受商品后续修改影响。 +func (r *Repository) tradingDailyOverview(ctx context.Context, now time.Time, days int) (TradingDailyOverviewDTO, error) { + if days <= 0 { + days = tradingDailyTrendDays + } + loc := timeutil.ShanghaiLocation() + now = now.In(loc) + end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1) + start := end.AddDate(0, 0, -days) + + type row struct { + CreatedAt time.Time + IsPremium bool + CompensationPlan string + InsuranceFeeBps int + } + rows := make([]row, 0) + // 读取区间内订单后在 Go 侧按上海时区归日,避免依赖 MySQL 会话时区。 + if err := r.db.WithContext(ctx).Table("rental_orders"). + Select("created_at, is_premium, compensation_plan, insurance_fee_bps"). + Where(tradingOrderPaidCondition). + Where("created_at >= ? AND created_at < ?", start, end). + Scan(&rows).Error; err != nil { + return TradingDailyOverviewDTO{}, err + } + + byDay := make(map[string]TradingDayStatsDTO, days) + for _, item := range rows { + key := item.CreatedAt.In(loc).Format("2006-01-02") + stats := byDay[key] + stats.Date = key + if item.IsPremium { + stats.PremiumOrderCount++ + } + if item.CompensationPlan == "insurance" { + switch item.InsuranceFeeBps { + case 800: + stats.Insurance8OrderCount++ + case 400: + stats.Insurance4OrderCount++ + } + } + byDay[key] = stats + } + + trend := make([]TradingDayStatsDTO, 0, days) + for i := days - 1; i >= 0; i-- { + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -i) + key := day.Format("2006-01-02") + item := TradingDayStatsDTO{Date: key} + if v, ok := byDay[key]; ok { + item = v + } + trend = append(trend, item) + } + + today := TradingDayStatsDTO{Date: now.Format("2006-01-02")} + if len(trend) > 0 { + today = trend[len(trend)-1] + } + return TradingDailyOverviewDTO{ + Today: today, + Trend: trend, + Days: days, + Timezone: "Asia/Shanghai", + }, nil +} + // manualEntryToday 统计今日(北京时间自然日)各客服的手动录单数量。 // 录单表是后来新增的功能表,若迁移尚未执行(表不存在)则降级为空统计, // 避免连带整个仪表盘不可用。 diff --git a/backend/internal/modules/admindashboard/repository_test.go b/backend/internal/modules/admindashboard/repository_test.go index 8ee5f890..6c1a1a2d 100644 --- a/backend/internal/modules/admindashboard/repository_test.go +++ b/backend/internal/modules/admindashboard/repository_test.go @@ -106,3 +106,53 @@ func TestListingDailyOverviewGroupsExternalUploadsByUploader(t *testing.T) { t.Fatalf("uploader trend = %#v, want 3 rows", overview.UploaderTrend) } } + +func TestTradingDailyOverviewCountsPremiumAndInsurance(t *testing.T) { + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, &model.RentalOrder{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + loc := timeutil.ShanghaiLocation() + now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc) + + orders := []model.RentalOrder{ + // 今日:极品 + 保险8%,成交 + {OrderNo: "T1", ListingID: 1, AccountID: 1, OwnerID: 1, RenterID: 1, Status: "pending_handoff", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 800, CreatedAt: now}, + // 今日:极品 + 保险4%,成交 + {OrderNo: "T2", ListingID: 2, AccountID: 2, OwnerID: 2, RenterID: 2, Status: "renting", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 400, CreatedAt: now.Add(time.Hour)}, + // 今日:非极品 + 保险4%,成交 + {OrderNo: "T3", ListingID: 3, AccountID: 3, OwnerID: 3, RenterID: 3, Status: "completed", CompensationPlan: "insurance", InsuranceFeeBps: 400, CreatedAt: now.Add(2 * time.Hour)}, + // 今日:待支付,不计入成交 + {OrderNo: "T4", ListingID: 4, AccountID: 4, OwnerID: 4, RenterID: 4, Status: "pending_payment", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 400, CreatedAt: now.Add(3 * time.Hour)}, + // 今日:已取消,不计入成交 + {OrderNo: "T5", ListingID: 5, AccountID: 5, OwnerID: 5, RenterID: 5, Status: "cancelled", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 800, CreatedAt: now.Add(4 * time.Hour)}, + // 昨日:极品 + 保险8%,成交 + {OrderNo: "T6", ListingID: 6, AccountID: 6, OwnerID: 6, RenterID: 6, Status: "completed", IsPremium: true, CompensationPlan: "insurance", InsuranceFeeBps: 800, CreatedAt: now.AddDate(0, 0, -1)}, + // 前日:普通赔付,不计入保险 + {OrderNo: "T7", ListingID: 7, AccountID: 7, OwnerID: 7, RenterID: 7, Status: "completed", CompensationPlan: "normal", CreatedAt: now.AddDate(0, 0, -2)}, + } + if err := db.Create(&orders).Error; err != nil { + t.Fatalf("create orders error = %v", err) + } + + overview, err := NewRepository(db).tradingDailyOverview(t.Context(), now, 7) + if err != nil { + t.Fatalf("tradingDailyOverview() error = %v", err) + } + if overview.Days != 7 || len(overview.Trend) != 7 { + t.Fatalf("trend = %#v, want 7 days", overview.Trend) + } + if overview.Today.Date != "2026-07-25" { + t.Fatalf("today date = %q, want 2026-07-25", overview.Today.Date) + } + if overview.Today.PremiumOrderCount != 2 || + overview.Today.Insurance8OrderCount != 1 || + overview.Today.Insurance4OrderCount != 2 { + t.Fatalf("today stats = %#v, want premium=2 insurance8=1 insurance4=2", overview.Today) + } + + yesterday := overview.Trend[len(overview.Trend)-2] + if yesterday.PremiumOrderCount != 1 || yesterday.Insurance8OrderCount != 1 || yesterday.Insurance4OrderCount != 0 { + t.Fatalf("yesterday stats = %#v, want premium=1 insurance8=1 insurance4=0", yesterday) + } +} diff --git a/frontend/src/features/admin/api/adminDashboard.ts b/frontend/src/features/admin/api/adminDashboard.ts index e088af35..5ba0816a 100644 --- a/frontend/src/features/admin/api/adminDashboard.ts +++ b/frontend/src/features/admin/api/adminDashboard.ts @@ -42,6 +42,20 @@ export interface ListingDailyOverview { timezone: string } +export interface TradingDayStats { + date: string + premium_order_count: number + insurance_8_order_count: number + insurance_4_order_count: number +} + +export interface TradingDailyOverview { + today: TradingDayStats + trend: TradingDayStats[] + days: number + timezone: string +} + export interface ListingUploaderDayStats { date: string uploader_id: number @@ -82,6 +96,7 @@ export interface AdminDashboard { metrics: DashboardMetrics pending: DashboardPending listing_daily: ListingDailyOverview + trading_daily: TradingDailyOverview manual_entry_today: ManualEntryTodayStats[] recent_orders: DashboardRecentOrder[] recent_disputes: DashboardRecentDispute[] @@ -95,10 +110,18 @@ const emptyDayStats = (): ListingDayStats => ({ trade_leave_count: 0, }) +const emptyTradingDayStats = (): TradingDayStats => ({ + date: '', + premium_order_count: 0, + insurance_8_order_count: 0, + insurance_4_order_count: 0, +}) + export async function fetchAdminDashboard() { const { data } = await apiClient.get>('/admin/dashboard') const payload = data.data const listingDaily = payload.listing_daily + const tradingDaily = payload.trading_daily return { ...payload, listing_daily: { @@ -111,6 +134,12 @@ export async function fetchAdminDashboard() { days: listingDaily?.days ?? 7, timezone: listingDaily?.timezone ?? 'Asia/Shanghai', }, + trading_daily: { + today: tradingDaily?.today ?? emptyTradingDayStats(), + trend: tradingDaily?.trend ?? [], + days: tradingDaily?.days ?? 7, + timezone: tradingDaily?.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/views/AdminDashboardView.css b/frontend/src/features/admin/views/AdminDashboardView.css index cf4bd4df..75731aa2 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.css +++ b/frontend/src/features/admin/views/AdminDashboardView.css @@ -335,6 +335,21 @@ gap: 12px; } +.tone-premium .listing-stat-icon { + background: #fef3c7; + color: #d97706; +} + +.tone-insurance8 .listing-stat-icon { + background: #ecfeff; + color: #0891b2; +} + +.tone-insurance4 .listing-stat-icon { + background: #eff6ff; + color: #2563eb; +} + .listing-stat-icon { width: 40px; height: 40px; @@ -422,6 +437,18 @@ background: linear-gradient(180deg, #60a5fa, #3b82f6); } +.mini-bar.premium { + background: linear-gradient(180deg, #fcd34d, #f59e0b); +} + +.mini-bar.insurance8 { + background: linear-gradient(180deg, #67e8f9, #0891b2); +} + +.mini-bar.insurance4 { + background: linear-gradient(180deg, #93c5fd, #2563eb); +} + .mini-bar-col em { font-style: normal; font-size: 10px; diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue index 1c509222..bfe2ac4b 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.vue +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -8,13 +8,17 @@ import { Document, Grid, Loading, + Medal, Operation, + Present, Refresh, ScaleToOriginal, ShoppingBag, Shop, Tickets, Top, + Trophy, + Umbrella, User, Van, Wallet, @@ -27,6 +31,7 @@ import { type ListingDayStats, type ListingUploaderDayStats, type ManualEntryTodayStats, + type TradingDayStats, } from '@/features/admin/api/adminDashboard' import { useAdminTable } from '@/features/admin/composables/useAdminTable' import { formatCentWithSymbol } from '@/shared/utils/money' @@ -43,6 +48,18 @@ const { fetchFn: fetchAdminDashboard, }) +const tradingTrend = computed(() => dashboard.value?.trading_daily?.trend ?? []) +const tradingToday = computed( + () => + dashboard.value?.trading_daily?.today ?? { + date: '', + premium_order_count: 0, + insurance_8_order_count: 0, + insurance_4_order_count: 0, + } +) +const tradingDays = computed(() => dashboard.value?.trading_daily?.days ?? 7) + const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? []) const listingChannelTrend = computed(() => dashboard.value?.listing_daily?.channel_trend ?? []) const listingUploaderTrend = computed(() => dashboard.value?.listing_daily?.uploader_trend ?? []) @@ -94,6 +111,11 @@ function trendMax(getter: (row: ListingDayStats) => number) { return Math.max(1, ...values, 0) } +function tradingTrendMax(getter: (row: TradingDayStats) => number) { + const values = tradingTrend.value.map(getter) + return Math.max(1, ...values, 0) +} + function barHeight(value: number, max: number) { if (max <= 0) return '8%' return `${Math.max(8, Math.round((value / max) * 100))}%` @@ -194,6 +216,162 @@ function shortDate(date: string) { + +
+
+
+ + 经营成交统计 + 按北京时间自然日 · 近 {{ tradingDays }} 日 +
+
+ +
+
+
+
+ +
+
+ 今日极品专区成交 + {{ tradingToday.premium_order_count }} +
+
+

已支付订单,按订单创建日归集(不含待支付/取消/关闭)

+
+
+
+ {{ shortDate(row.date) }} + {{ row.premium_order_count }} +
+
+
+ +
+
+
+ +
+
+ 今日保险 8% 成交 + {{ tradingToday.insurance_8_order_count }} +
+
+

选择无忧保险 8% 的已支付订单

+
+
+
+ {{ shortDate(row.date) }} + {{ row.insurance_8_order_count }} +
+
+
+ +
+
+
+ +
+
+ 今日保险 4% 成交 + {{ tradingToday.insurance_4_order_count }} +
+
+

选择无忧保险 4% 的已支付订单

+
+
+
+ {{ shortDate(row.date) }} + {{ row.insurance_4_order_count }} +
+
+
+
+ +
近 {{ tradingDays }} 日经营成交明细
+
+ + + + + + + + + + + + + + + +
+
+