增加客服上传账号统计
This commit is contained in:
@@ -24,12 +24,14 @@ type MetricsDTO struct {
|
|||||||
|
|
||||||
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
|
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
|
||||||
type ListingDailyOverviewDTO struct {
|
type ListingDailyOverviewDTO struct {
|
||||||
Today ListingDayStatsDTO `json:"today"`
|
Today ListingDayStatsDTO `json:"today"`
|
||||||
Trend []ListingDayStatsDTO `json:"trend"`
|
Trend []ListingDayStatsDTO `json:"trend"`
|
||||||
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
|
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
|
||||||
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
|
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
|
||||||
Days int `json:"days"`
|
TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"`
|
||||||
Timezone string `json:"timezone"`
|
UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListingDayStatsDTO struct {
|
type ListingDayStatsDTO struct {
|
||||||
@@ -47,6 +49,13 @@ type ListingChannelDayStatsDTO struct {
|
|||||||
TradeLeaveCount int64 `json:"trade_leave_count"`
|
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 {
|
type PendingDTO struct {
|
||||||
ListingReviews int64 `json:"listing_reviews"`
|
ListingReviews int64 `json:"listing_reviews"`
|
||||||
Disputes int64 `json:"disputes"`
|
Disputes int64 `json:"disputes"`
|
||||||
|
|||||||
@@ -130,9 +130,18 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
|||||||
Scan(&events).Error; err != nil {
|
Scan(&events).Error; err != nil {
|
||||||
return ListingDailyOverviewDTO{}, err
|
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)
|
byDay := make(map[string]*listingDailyBucket, days)
|
||||||
byChannel := make(map[listingChannelKey]*listingDailyBucket)
|
byChannel := make(map[listingChannelKey]*listingDailyBucket)
|
||||||
|
byUploader := make(map[listingUploaderKey]int64)
|
||||||
for _, ev := range events {
|
for _, ev := range events {
|
||||||
key := ev.CreatedAt.In(loc).Format("2006-01-02")
|
key := ev.CreatedAt.In(loc).Format("2006-01-02")
|
||||||
b := byDay[key]
|
b := byDay[key]
|
||||||
@@ -152,6 +161,11 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
|||||||
}
|
}
|
||||||
addListingDailyEvent(cb, ev.EventType, ev.Source)
|
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)
|
trend := make([]ListingDayStatsDTO, 0, days)
|
||||||
for i := days - 1; i >= 0; i-- {
|
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]
|
todayStats = trend[len(trend)-1]
|
||||||
}
|
}
|
||||||
channelTrend := listingChannelTrend(byChannel)
|
channelTrend := listingChannelTrend(byChannel)
|
||||||
|
uploaderTrend := listingUploaderTrend(byUploader)
|
||||||
todayChannels := make([]ListingChannelDayStatsDTO, 0)
|
todayChannels := make([]ListingChannelDayStatsDTO, 0)
|
||||||
for _, item := range channelTrend {
|
for _, item := range channelTrend {
|
||||||
if item.Date == todayStats.Date {
|
if item.Date == todayStats.Date {
|
||||||
todayChannels = append(todayChannels, item)
|
todayChannels = append(todayChannels, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
todayUploaders := make([]ListingUploaderDayStatsDTO, 0)
|
||||||
|
for _, item := range uploaderTrend {
|
||||||
|
if item.Date == todayStats.Date {
|
||||||
|
todayUploaders = append(todayUploaders, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
return ListingDailyOverviewDTO{
|
return ListingDailyOverviewDTO{
|
||||||
Today: todayStats,
|
Today: todayStats,
|
||||||
Trend: trend,
|
Trend: trend,
|
||||||
TodayChannels: todayChannels,
|
TodayChannels: todayChannels,
|
||||||
ChannelTrend: channelTrend,
|
ChannelTrend: channelTrend,
|
||||||
Days: days,
|
TodayUploaders: todayUploaders,
|
||||||
Timezone: "Asia/Shanghai",
|
UploaderTrend: uploaderTrend,
|
||||||
|
Days: days,
|
||||||
|
Timezone: "Asia/Shanghai",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type listingUploaderRow struct {
|
||||||
|
UploaderID uint64
|
||||||
|
UploaderName string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type listingChannelKey struct {
|
type listingChannelKey struct {
|
||||||
date string
|
date string
|
||||||
sourceChannel string
|
sourceChannel string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type listingUploaderKey struct {
|
||||||
|
date string
|
||||||
|
uploaderID uint64
|
||||||
|
uploaderName string
|
||||||
|
}
|
||||||
|
|
||||||
type listingDailyBucket struct {
|
type listingDailyBucket struct {
|
||||||
published int64
|
published int64
|
||||||
activeOffline int64
|
activeOffline int64
|
||||||
@@ -244,6 +279,35 @@ func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []Listi
|
|||||||
return items
|
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 {
|
func listingChannelRank(channel string) int {
|
||||||
switch channel {
|
switch channel {
|
||||||
case "咸鱼":
|
case "咸鱼":
|
||||||
|
|||||||
@@ -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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -36,10 +36,19 @@ export interface ListingDailyOverview {
|
|||||||
trend: ListingDayStats[]
|
trend: ListingDayStats[]
|
||||||
today_channels: ListingChannelDayStats[]
|
today_channels: ListingChannelDayStats[]
|
||||||
channel_trend: ListingChannelDayStats[]
|
channel_trend: ListingChannelDayStats[]
|
||||||
|
today_uploaders: ListingUploaderDayStats[]
|
||||||
|
uploader_trend: ListingUploaderDayStats[]
|
||||||
days: number
|
days: number
|
||||||
timezone: string
|
timezone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListingUploaderDayStats {
|
||||||
|
date: string
|
||||||
|
uploader_id: number
|
||||||
|
uploader_name: string
|
||||||
|
upload_count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardRecentOrder {
|
export interface DashboardRecentOrder {
|
||||||
id: number
|
id: number
|
||||||
order_no: string
|
order_no: string
|
||||||
@@ -90,6 +99,8 @@ export async function fetchAdminDashboard() {
|
|||||||
trend: listingDaily?.trend ?? [],
|
trend: listingDaily?.trend ?? [],
|
||||||
today_channels: listingDaily?.today_channels ?? [],
|
today_channels: listingDaily?.today_channels ?? [],
|
||||||
channel_trend: listingDaily?.channel_trend ?? [],
|
channel_trend: listingDaily?.channel_trend ?? [],
|
||||||
|
today_uploaders: listingDaily?.today_uploaders ?? [],
|
||||||
|
uploader_trend: listingDaily?.uploader_trend ?? [],
|
||||||
days: listingDaily?.days ?? 7,
|
days: listingDaily?.days ?? 7,
|
||||||
timezone: listingDaily?.timezone ?? 'Asia/Shanghai',
|
timezone: listingDaily?.timezone ?? 'Asia/Shanghai',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type AdminDashboard,
|
type AdminDashboard,
|
||||||
type ListingChannelDayStats,
|
type ListingChannelDayStats,
|
||||||
type ListingDayStats,
|
type ListingDayStats,
|
||||||
|
type ListingUploaderDayStats,
|
||||||
} from '@/features/admin/api/adminDashboard'
|
} from '@/features/admin/api/adminDashboard'
|
||||||
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
|
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
|
||||||
import { formatCentWithSymbol } from '@/shared/utils/money'
|
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||||
@@ -43,6 +44,7 @@ const {
|
|||||||
|
|
||||||
const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? [])
|
const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? [])
|
||||||
const listingChannelTrend = computed(() => dashboard.value?.listing_daily?.channel_trend ?? [])
|
const listingChannelTrend = computed(() => dashboard.value?.listing_daily?.channel_trend ?? [])
|
||||||
|
const listingUploaderTrend = computed(() => dashboard.value?.listing_daily?.uploader_trend ?? [])
|
||||||
const listingToday = computed(
|
const listingToday = computed(
|
||||||
() =>
|
() =>
|
||||||
dashboard.value?.listing_daily?.today ?? {
|
dashboard.value?.listing_daily?.today ?? {
|
||||||
@@ -67,6 +69,14 @@ const listingTodayChannels = computed<ListingChannelDayStats[]>(() => {
|
|||||||
const extras = rows.filter(row => !channelDisplayOrder.includes(row.source_channel || '未填写'))
|
const extras = rows.filter(row => !channelDisplayOrder.includes(row.source_channel || '未填写'))
|
||||||
return [...ordered, ...extras]
|
return [...ordered, ...extras]
|
||||||
})
|
})
|
||||||
|
const listingTodayUploaders = computed<ListingUploaderDayStats[]>(() => {
|
||||||
|
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) {
|
function leaveCount(row: ListingChannelDayStats | ListingDayStats) {
|
||||||
return Number(row.active_offline_count || 0) + Number(row.trade_leave_count || 0)
|
return Number(row.active_offline_count || 0) + Number(row.trade_leave_count || 0)
|
||||||
@@ -311,6 +321,21 @@ function shortDate(date: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listingTodayUploaders.length" class="listing-uploader-block">
|
||||||
|
<div class="channel-summary-title">今日外部上传账号统计</div>
|
||||||
|
<div class="listing-uploader-summary">
|
||||||
|
<div
|
||||||
|
v-for="row in listingTodayUploaders"
|
||||||
|
:key="`today-uploader-${row.uploader_id}-${row.uploader_name}`"
|
||||||
|
class="listing-uploader-item"
|
||||||
|
>
|
||||||
|
<span class="uploader-name">{{ row.uploader_name || '未填写' }}</span>
|
||||||
|
<strong class="uploader-total">{{ row.upload_count }}</strong>
|
||||||
|
<span class="uploader-unit">个账号</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 7日上下架趋势明细表 -->
|
<!-- 7日上下架趋势明细表 -->
|
||||||
<div class="table-container trend-table-wrap">
|
<div class="table-container trend-table-wrap">
|
||||||
<el-table class="custom-enterprise-table" :data="listingTrend" size="small" stripe>
|
<el-table class="custom-enterprise-table" :data="listingTrend" size="small" stripe>
|
||||||
@@ -381,6 +406,24 @@ function shortDate(date: string) {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listingUploaderTrend.length" class="listing-uploader-block">
|
||||||
|
<div class="channel-summary-title">近 7 日客服上传账号明细</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<el-table
|
||||||
|
class="custom-enterprise-table"
|
||||||
|
:data="listingUploaderTrend"
|
||||||
|
size="small"
|
||||||
|
stripe
|
||||||
|
>
|
||||||
|
<el-table-column prop="date" label="统计日期" min-width="120" />
|
||||||
|
<el-table-column prop="uploader_name" label="客服" min-width="140">
|
||||||
|
<template #default="{ row }">{{ row.uploader_name || '未填写' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="upload_count" label="上传账号数" width="130" align="center" />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 待处理事项 & 快捷入口 -->
|
<!-- 待处理事项 & 快捷入口 -->
|
||||||
@@ -1148,6 +1191,49 @@ function shortDate(date: string) {
|
|||||||
margin-top: 20px;
|
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 {
|
.channel-badge {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user