From 98f6ee94514c0546093f18f014fb06400fdff59d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 19:44:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AE=A2=E6=9C=8D=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E8=B4=A6=E5=8F=B7=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/admindashboard/dto.go | 21 +++-- .../modules/admindashboard/repository.go | 76 ++++++++++++++-- .../modules/admindashboard/repository_test.go | 41 +++++++++ ...061_listing_upload_uploader_date_index.sql | 9 ++ .../src/features/admin/api/adminDashboard.ts | 11 +++ .../admin/views/AdminDashboardView.vue | 86 +++++++++++++++++++ 6 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/000061_listing_upload_uploader_date_index.sql diff --git a/backend/internal/modules/admindashboard/dto.go b/backend/internal/modules/admindashboard/dto.go index 2bf981d..d49d382 100644 --- a/backend/internal/modules/admindashboard/dto.go +++ b/backend/internal/modules/admindashboard/dto.go @@ -24,12 +24,14 @@ type MetricsDTO struct { // ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。 type ListingDailyOverviewDTO struct { - Today ListingDayStatsDTO `json:"today"` - Trend []ListingDayStatsDTO `json:"trend"` - TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"` - ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"` - Days int `json:"days"` - Timezone string `json:"timezone"` + Today ListingDayStatsDTO `json:"today"` + Trend []ListingDayStatsDTO `json:"trend"` + TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"` + ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"` + TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"` + UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"` + Days int `json:"days"` + Timezone string `json:"timezone"` } type ListingDayStatsDTO struct { @@ -47,6 +49,13 @@ type ListingChannelDayStatsDTO struct { TradeLeaveCount int64 `json:"trade_leave_count"` } +type ListingUploaderDayStatsDTO struct { + Date string `json:"date"` // YYYY-MM-DD(上海时区) + UploaderID uint64 `json:"uploader_id"` + UploaderName string `json:"uploader_name"` + UploadCount int64 `json:"upload_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 1623e76..8ea685d 100644 --- a/backend/internal/modules/admindashboard/repository.go +++ b/backend/internal/modules/admindashboard/repository.go @@ -130,9 +130,18 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da Scan(&events).Error; err != nil { return ListingDailyOverviewDTO{}, err } + uploads := make([]listingUploaderRow, 0) + if err := db.Table("listing_uploads"). + Select("COALESCE(matched_admin_id, 0) AS uploader_id, uploader_name, created_at"). + Where("listing_id IS NOT NULL"). + Where("created_at >= ? AND created_at < ?", start, end). + Scan(&uploads).Error; err != nil { + return ListingDailyOverviewDTO{}, err + } byDay := make(map[string]*listingDailyBucket, days) byChannel := make(map[listingChannelKey]*listingDailyBucket) + byUploader := make(map[listingUploaderKey]int64) for _, ev := range events { key := ev.CreatedAt.In(loc).Format("2006-01-02") b := byDay[key] @@ -152,6 +161,11 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da } addListingDailyEvent(cb, ev.EventType, ev.Source) } + for _, upload := range uploads { + key := upload.CreatedAt.In(loc).Format("2006-01-02") + uploaderKey := listingUploaderKey{date: key, uploaderID: upload.UploaderID, uploaderName: upload.UploaderName} + byUploader[uploaderKey]++ + } trend := make([]ListingDayStatsDTO, 0, days) for i := days - 1; i >= 0; i-- { @@ -171,27 +185,48 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da todayStats = trend[len(trend)-1] } channelTrend := listingChannelTrend(byChannel) + uploaderTrend := listingUploaderTrend(byUploader) todayChannels := make([]ListingChannelDayStatsDTO, 0) for _, item := range channelTrend { if item.Date == todayStats.Date { todayChannels = append(todayChannels, item) } } + todayUploaders := make([]ListingUploaderDayStatsDTO, 0) + for _, item := range uploaderTrend { + if item.Date == todayStats.Date { + todayUploaders = append(todayUploaders, item) + } + } return ListingDailyOverviewDTO{ - Today: todayStats, - Trend: trend, - TodayChannels: todayChannels, - ChannelTrend: channelTrend, - Days: days, - Timezone: "Asia/Shanghai", + Today: todayStats, + Trend: trend, + TodayChannels: todayChannels, + ChannelTrend: channelTrend, + TodayUploaders: todayUploaders, + UploaderTrend: uploaderTrend, + Days: days, + Timezone: "Asia/Shanghai", }, nil } +type listingUploaderRow struct { + UploaderID uint64 + UploaderName string + CreatedAt time.Time +} + type listingChannelKey struct { date string sourceChannel string } +type listingUploaderKey struct { + date string + uploaderID uint64 + uploaderName string +} + type listingDailyBucket struct { published int64 activeOffline int64 @@ -244,6 +279,35 @@ func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []Listi return items } +func listingUploaderTrend(rows map[listingUploaderKey]int64) []ListingUploaderDayStatsDTO { + keys := make([]listingUploaderKey, 0, len(rows)) + for key := range rows { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].date != keys[j].date { + return keys[i].date < keys[j].date + } + if rows[keys[i]] != rows[keys[j]] { + return rows[keys[i]] > rows[keys[j]] + } + if keys[i].uploaderName != keys[j].uploaderName { + return keys[i].uploaderName < keys[j].uploaderName + } + return keys[i].uploaderID < keys[j].uploaderID + }) + items := make([]ListingUploaderDayStatsDTO, 0, len(keys)) + for _, key := range keys { + items = append(items, ListingUploaderDayStatsDTO{ + Date: key.date, + UploaderID: key.uploaderID, + UploaderName: key.uploaderName, + UploadCount: rows[key], + }) + } + return items +} + func listingChannelRank(channel string) int { switch channel { case "咸鱼": diff --git a/backend/internal/modules/admindashboard/repository_test.go b/backend/internal/modules/admindashboard/repository_test.go index 5ae4f82..fe9b0fc 100644 --- a/backend/internal/modules/admindashboard/repository_test.go +++ b/backend/internal/modules/admindashboard/repository_test.go @@ -65,3 +65,44 @@ func assertChannelStats(t *testing.T, item ListingChannelDayStatsDTO, published, t.Fatalf("channel %q stats = %#v, want published=%d offline=%d tradeLeave=%d", item.SourceChannel, item, published, offline, tradeLeave) } } + +func TestListingDailyOverviewGroupsExternalUploadsByUploader(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + loc := timeutil.ShanghaiLocation() + now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc) + uploaderX := uint64(11) + uploaderY := uint64(12) + listingOne := uint64(101) + listingTwo := uint64(102) + listingThree := uint64(103) + uploads := []model.ListingUpload{ + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now}, + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingTwo, CreatedAt: now.Add(time.Hour)}, + {UploaderName: "小美", MatchedAdminID: &uploaderY, ListingID: &listingThree, CreatedAt: now.Add(2 * time.Hour)}, + {UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now.AddDate(0, 0, -1)}, + } + if err := db.Create(&uploads).Error; err != nil { + t.Fatalf("create uploads error = %v", err) + } + + overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 2) + if err != nil { + t.Fatalf("listingDailyOverview() error = %v", err) + } + if len(overview.TodayUploaders) != 2 { + t.Fatalf("today uploaders = %#v, want 2 rows", overview.TodayUploaders) + } + counts := make(map[string]int64) + for _, item := range overview.TodayUploaders { + counts[item.UploaderName] = item.UploadCount + } + if counts["小晴"] != 2 || counts["小美"] != 1 { + t.Fatalf("today uploader counts = %#v, want 小晴=2, 小美=1", counts) + } + if len(overview.UploaderTrend) != 3 { + t.Fatalf("uploader trend = %#v, want 3 rows", overview.UploaderTrend) + } +} diff --git a/backend/migrations/000061_listing_upload_uploader_date_index.sql b/backend/migrations/000061_listing_upload_uploader_date_index.sql new file mode 100644 index 0000000..aee4363 --- /dev/null +++ b/backend/migrations/000061_listing_upload_uploader_date_index.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE listing_uploads + ADD KEY idx_listing_uploads_created_admin (created_at, matched_admin_id); + +-- +goose Down + +ALTER TABLE listing_uploads + DROP KEY idx_listing_uploads_created_admin; diff --git a/frontend/src/features/admin/api/adminDashboard.ts b/frontend/src/features/admin/api/adminDashboard.ts index 862348d..38c536d 100644 --- a/frontend/src/features/admin/api/adminDashboard.ts +++ b/frontend/src/features/admin/api/adminDashboard.ts @@ -36,10 +36,19 @@ export interface ListingDailyOverview { trend: ListingDayStats[] today_channels: ListingChannelDayStats[] channel_trend: ListingChannelDayStats[] + today_uploaders: ListingUploaderDayStats[] + uploader_trend: ListingUploaderDayStats[] days: number timezone: string } +export interface ListingUploaderDayStats { + date: string + uploader_id: number + uploader_name: string + upload_count: number +} + export interface DashboardRecentOrder { id: number order_no: string @@ -90,6 +99,8 @@ export async function fetchAdminDashboard() { trend: listingDaily?.trend ?? [], today_channels: listingDaily?.today_channels ?? [], channel_trend: listingDaily?.channel_trend ?? [], + today_uploaders: listingDaily?.today_uploaders ?? [], + uploader_trend: listingDaily?.uploader_trend ?? [], days: listingDaily?.days ?? 7, timezone: listingDaily?.timezone ?? 'Asia/Shanghai', }, diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue index c30af10..dd1339d 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.vue +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -25,6 +25,7 @@ import { type AdminDashboard, type ListingChannelDayStats, type ListingDayStats, + type ListingUploaderDayStats, } from '@/features/admin/api/adminDashboard' import { useAdminTable } from '@/features/admin/composables/useAdminTable' import { formatCentWithSymbol } from '@/shared/utils/money' @@ -43,6 +44,7 @@ const { 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 ?? []) const listingToday = computed( () => dashboard.value?.listing_daily?.today ?? { @@ -67,6 +69,14 @@ const listingTodayChannels = computed(() => { const extras = rows.filter(row => !channelDisplayOrder.includes(row.source_channel || '未填写')) return [...ordered, ...extras] }) +const listingTodayUploaders = computed(() => { + const date = listingToday.value.date + return (dashboard.value?.listing_daily?.today_uploaders ?? []) + .filter(row => row.date === date) + .sort( + (a, b) => b.upload_count - a.upload_count || a.uploader_name.localeCompare(b.uploader_name) + ) +}) function leaveCount(row: ListingChannelDayStats | ListingDayStats) { return Number(row.active_offline_count || 0) + Number(row.trade_leave_count || 0) @@ -311,6 +321,21 @@ function shortDate(date: string) { +
+
今日外部上传账号统计
+
+
+ {{ row.uploader_name || '未填写' }} + {{ row.upload_count }} + 个账号 +
+
+
+
@@ -381,6 +406,24 @@ function shortDate(date: string) {
+ +
+
近 7 日客服上传账号明细
+
+ + + + + + + +
+
@@ -1148,6 +1191,49 @@ function shortDate(date: string) { margin-top: 20px; } +.listing-uploader-block { + margin-top: 20px; +} + +.listing-uploader-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.listing-uploader-item { + display: flex; + align-items: baseline; + gap: 8px; + border: 1px solid #dbeafe; + border-radius: 10px; + background: #eff6ff; + padding: 14px; +} + +.uploader-name { + overflow: hidden; + color: #1e3a8a; + font-size: 13.5px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploader-total { + margin-left: auto; + color: #2563eb; + font-size: 20px; + font-weight: 800; +} + +.uploader-unit { + color: #64748b; + font-size: 11.5px; + white-space: nowrap; +} + .channel-badge { font-weight: 600; }