diff --git a/apps/backend/src/repositories/worker-platform/worker-ranking-repo.ts b/apps/backend/src/repositories/worker-platform/worker-ranking-repo.ts index 1418f963..14d57e18 100644 --- a/apps/backend/src/repositories/worker-platform/worker-ranking-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-ranking-repo.ts @@ -277,3 +277,145 @@ function buildWorkerAcceptedOrdersCountSql() { ) AS completed_orders ` } + +export type AdminLeaderboardPeriod = 'all' | 'daily' | 'weekly' | 'monthly' + +/** 后台排行榜:预置周期按上海时区自然日/周/月计算;from/to 为自定义区间(YYYY-MM-DD,含起止日)。 */ +export async function listAdminWorkerLeaderboard(input: { + period: AdminLeaderboardPeriod + limit: number + fromDate?: string | null + toDate?: string | null +}): Promise< + Array<{ + rank: number + workerId: number + username: string + displayName: string + levelName: string + workerStatus: string + acceptedOrderCount: number + rewardAmount: number + lastAcceptedAt: string | null + }> +> { + const limit = Math.min(100, Math.max(1, Math.floor(Number(input.limit || 50)))) + const periodUnit = + input.period === 'daily' + ? 'day' + : input.period === 'weekly' + ? 'week' + : input.period === 'monthly' + ? 'month' + : '' + const result = await query<{ + rank: number + worker_id: number + username: string + display_name: string + level_name: string | null + worker_status: string + accepted_order_count: number + reward_amount: string | number + last_accepted_at: Date | string | null + }>( + ` + WITH leaderboard_window AS ( + SELECT + CASE + WHEN $1::text <> '' THEN date_trunc($1::text, CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai') + AT TIME ZONE 'Asia/Shanghai' + WHEN $2::date IS NOT NULL THEN $2::date::timestamp AT TIME ZONE 'Asia/Shanghai' + ELSE NULL + END AS starts_at, + CASE + WHEN $1::text = 'day' THEN (date_trunc('day', CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai') + AT TIME ZONE 'Asia/Shanghai') + INTERVAL '1 day' + WHEN $1::text = 'week' THEN (date_trunc('week', CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai') + AT TIME ZONE 'Asia/Shanghai') + INTERVAL '7 days' + WHEN $1::text = 'month' THEN (date_trunc('month', CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai') + AT TIME ZONE 'Asia/Shanghai') + INTERVAL '1 month' + WHEN $3::date IS NOT NULL THEN ($3::date + 1)::timestamp AT TIME ZONE 'Asia/Shanghai' + ELSE NULL + END AS ends_at + ), + accepted_totals AS ( + SELECT + worker_id, + COUNT(DISTINCT order_key)::int AS accepted_order_count, + SUM(reward_amount)::bigint AS reward_amount, + MAX(last_accepted_at) AS last_accepted_at + FROM ( + SELECT + assigned_worker_id AS worker_id, + COALESCE( + 'order:' || NULLIF(order_id::text, ''), + 'platform_order:' || NULLIF(BTRIM(platform_order_id), ''), + 'work_order:' || id::text + ) AS order_key, + reward_amount, + MAX(accepted_at) AS last_accepted_at + FROM work_orders + CROSS JOIN leaderboard_window + WHERE assigned_worker_id IS NOT NULL + AND status = 'accepted' + AND (starts_at IS NULL OR (accepted_at >= starts_at AND accepted_at < ends_at)) + GROUP BY assigned_worker_id, order_key, reward_amount + + UNION ALL + + SELECT + wos.worker_id, + COALESCE( + 'order:' || NULLIF(wo.order_id::text, ''), + 'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''), + 'work_order:' || wo.id::text + ) AS order_key, + wos.share_reward AS reward_amount, + MAX(wos.accepted_at) AS last_accepted_at + FROM work_order_shares wos + INNER JOIN work_orders wo ON wo.id = wos.work_order_id + CROSS JOIN leaderboard_window + WHERE wos.status = 'accepted' + AND (starts_at IS NULL OR (wos.accepted_at >= starts_at AND wos.accepted_at < ends_at)) + GROUP BY wos.worker_id, order_key, wos.share_reward + ) AS accepted_records + GROUP BY worker_id + ), + ranked_workers AS ( + SELECT + ROW_NUMBER() OVER ( + ORDER BY totals.accepted_order_count DESC, totals.last_accepted_at DESC NULLS LAST, wu.id ASC + )::int AS rank, + wu.id AS worker_id, + wu.username, + wu.display_name, + wu.status AS worker_status, + wl.name AS level_name, + totals.accepted_order_count, + totals.reward_amount, + totals.last_accepted_at + FROM worker_users wu + INNER JOIN accepted_totals totals ON totals.worker_id = wu.id + LEFT JOIN worker_levels wl ON wl.id = wu.level_id + ) + SELECT rank, worker_id, username, display_name, level_name, worker_status, + accepted_order_count, reward_amount, last_accepted_at + FROM ranked_workers + ORDER BY rank ASC + LIMIT $4 + `, + [periodUnit, input.fromDate || null, input.toDate || null, limit], + ) + return result.rows.map((row) => ({ + rank: Number(row.rank || 0), + workerId: Number(row.worker_id || 0), + username: String(row.username || '').trim(), + displayName: String(row.display_name || '').trim(), + levelName: String(row.level_name || '').trim(), + workerStatus: String(row.worker_status || ''), + acceptedOrderCount: Number(row.accepted_order_count || 0), + rewardAmount: Number(row.reward_amount || 0), + lastAcceptedAt: row.last_accepted_at ? new Date(row.last_accepted_at).toISOString() : null, + })) +} diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 1d375196..50692b60 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -7,6 +7,7 @@ import adminWorkerPlatformOrdersRoutes from './worker-platform/orders.js' import adminWorkerPlatformReviewRoutes from './worker-platform/review.js' import adminWorkerPlatformAcceptanceRoutes from './worker-platform/acceptance.js' import adminWorkerPlatformAfterSalesRoutes from './worker-platform/after-sales.js' +import adminWorkerPlatformLeaderboardRoutes from './worker-platform/leaderboard.js' const router = Router() @@ -17,5 +18,6 @@ router.use(adminWorkerPlatformOrdersRoutes) router.use(adminWorkerPlatformReviewRoutes) router.use(adminWorkerPlatformAfterSalesRoutes) router.use(adminWorkerPlatformAcceptanceRoutes) +router.use(adminWorkerPlatformLeaderboardRoutes) export default router diff --git a/apps/backend/src/routes/admin/worker-platform/leaderboard.ts b/apps/backend/src/routes/admin/worker-platform/leaderboard.ts new file mode 100644 index 00000000..6a63be91 --- /dev/null +++ b/apps/backend/src/routes/admin/worker-platform/leaderboard.ts @@ -0,0 +1,18 @@ +import { Router } from 'express' + +import { getAdminWorkerLeaderboard } from '../../../services/worker-platform/index.js' +import { createJsonHandler, requireAdminRoles } from '../session.js' + +const router = Router() + +router.get( + '/worker-platform/leaderboard', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => getAdminWorkerLeaderboard(req.query), { + successMessage: 'ok', + errorMessage: '读取接单排行榜失败', + scope: '[admin/worker-platform/leaderboard]', + }), +) + +export default router diff --git a/apps/backend/src/services/worker-platform/admin-worker-leaderboard-service.ts b/apps/backend/src/services/worker-platform/admin-worker-leaderboard-service.ts new file mode 100644 index 00000000..23b612ac --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-worker-leaderboard-service.ts @@ -0,0 +1,38 @@ +import { + listAdminWorkerLeaderboard, + type AdminLeaderboardPeriod, +} from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' + +const PERIODS: AdminLeaderboardPeriod[] = ['all', 'daily', 'weekly', 'monthly'] +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ +const MAX_CUSTOM_RANGE_DAYS = 366 + +function normalizeDate(value: unknown): string | null { + const text = String(value || '').trim() + return DATE_PATTERN.test(text) ? text : null +} + +/** 后台接单排行榜读取:预置周期或自定义日期区间(含起止日)+ 完成单数与奖励汇总。 */ +export async function getAdminWorkerLeaderboard(query: JsonObject = {}) { + const requested = String(query.period || 'all').trim() as AdminLeaderboardPeriod + const period = PERIODS.includes(requested) ? requested : 'all' + const limit = Number(query.limit || 50) + const fromDate = normalizeDate(query.from) + const toDate = normalizeDate(query.to) + if ((fromDate || toDate) && !(fromDate && toDate)) { + throw new Error('自定义区间需要同时提供开始与结束日期') + } + if (fromDate && toDate) { + const fromTime = new Date(`${fromDate}T00:00:00Z`).getTime() + const toTime = new Date(`${toDate}T00:00:00Z`).getTime() + if (fromTime > toTime) { + throw new Error('自定义区间的开始日期不能晚于结束日期') + } + if ((toTime - fromTime) / 86400000 > MAX_CUSTOM_RANGE_DAYS) { + throw new Error('自定义区间最长不能超过一年') + } + } + const items = await listAdminWorkerLeaderboard({ period, limit, fromDate, toDate }) + return { period: fromDate && toDate ? 'custom' : period, from: fromDate, to: toDate, items } +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 63a28920..2c6d17c8 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -22,6 +22,7 @@ export * from './after-sales-service.js' export * from './admin-service.js' export * from './admin-worker-finance-service.js' export * from './admin-worker-catalog-service.js' +export * from './admin-worker-leaderboard-service.js' export * from './admin-kuaishou-match-service.js' export * from './admin-worker-config-service.js' export * from './admin-worker-user-service.js' diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 48ef66e0..2c2daf92 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -14,6 +14,7 @@ import CategoriesPanel from './panels/CategoriesPanel' import FeedbacksPanel from './panels/FeedbacksPanel' import FinancePanel from './panels/FinancePanel' import HallConfigPanel from './panels/HallConfigPanel' +import LeaderboardPanel from './panels/LeaderboardPanel' import LevelsPanel from './panels/LevelsPanel' import NotificationsPanel from './panels/NotificationsPanel' import ProductMatchPanel from './panels/ProductMatchPanel' @@ -94,6 +95,7 @@ export default function AdminWorkerPlatformPage() { { key: 'product-match', label: '匹配诊断', children: }, { key: 'categories', label: '分类', children: }, { key: 'workers', label: '打手', children: }, + { key: 'leaderboard', label: '排行榜', children: }, { key: 'finance', label: '资金', children: }, { key: 'levels', label: '等级权限', children: }, { key: 'hall', label: '大厅配置', children: }, @@ -112,6 +114,7 @@ const ACTIVE_TAB_STORAGE_KEY = 'admin-worker-platform-tab' function loadActiveTabPreference(): string { const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) return saved === 'orders' || + saved === 'leaderboard' || saved === 'cancel-requests' || saved === 'feedbacks' || saved === 'after-sales' || @@ -135,6 +138,7 @@ function loadActiveTab(tab: string | null): string { function isWorkerPlatformTab(tab: string | null): tab is string { return [ 'orders', + 'leaderboard', 'cancel-requests', 'feedbacks', 'after-sales', diff --git a/apps/frontend/src/pages/admin/panels/LeaderboardPanel.tsx b/apps/frontend/src/pages/admin/panels/LeaderboardPanel.tsx new file mode 100644 index 00000000..d2fa1520 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/LeaderboardPanel.tsx @@ -0,0 +1,147 @@ +import { useQuery } from '@tanstack/react-query' +import { Card, Segmented, Space, Table, Tag, Typography } from 'antd' +import type { TableColumnsType } from 'antd' +import { useState } from 'react' + +import { AdminRangePicker } from '@/components/admin/AdminDatePicker' +import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs' +import { + fetchAdminWorkerLeaderboard, + type AdminLeaderboardPeriod, + type AdminWorkerLeaderboardEntry, +} from '@/services/admin' +import { formatAdminDateTime } from '@/utils/admin-time' +import { formatMoney } from './shared' + +const PERIOD_OPTIONS: { label: string; value: AdminLeaderboardPeriod | 'custom' }[] = [ + { label: '今日', value: 'daily' }, + { label: '本周', value: 'weekly' }, + { label: '本月', value: 'monthly' }, + { label: '全部', value: 'all' }, + { label: '自定义', value: 'custom' }, +] + +const WORKER_STATUS_LABELS: Record = { + active: '已启用', + pending_review: '待处理', + rejected: '已冻结', + disabled: '已停用', +} + +export default function LeaderboardPanel() { + const [period, setPeriod] = useState('daily') + const [range, setRange] = useState<[string, string] | null>(null) + + const leaderboardQuery = useQuery({ + queryKey: ['admin-worker-leaderboard', period, range], + queryFn: () => + fetchAdminWorkerLeaderboard( + period === 'custom' ? 'all' : period, + 50, + period === 'custom' && range ? { from: range[0], to: range[1] } : undefined, + ), + enabled: period !== 'custom' || Boolean(range), + }) + + const items = leaderboardQuery.data?.data.items || [] + + const columns: TableColumnsType = [ + { + title: '排名', + dataIndex: 'rank', + width: 90, + render: (_, row) => ( + + {row.rank <= 3 ? `🏅 ${row.rank}` : row.rank} + + ), + }, + { + title: '打手', + dataIndex: 'displayName', + minWidth: 200, + render: (_, row) => ( +
+ {row.displayName || row.username} + {row.username} +
+ ), + }, + { title: '等级', dataIndex: 'levelName', width: 120, render: (value) => value || '-' }, + { + title: '状态', + dataIndex: 'workerStatus', + width: 100, + render: (value: string) => ( + + {WORKER_STATUS_LABELS[value] || value} + + ), + }, + { + title: '完成单数', + dataIndex: 'acceptedOrderCount', + width: 110, + align: 'right', + sorter: (a, b) => a.acceptedOrderCount - b.acceptedOrderCount, + render: (value) => {value}, + }, + { + title: '奖励总额', + dataIndex: 'rewardAmount', + width: 120, + align: 'right', + render: (value: number) => formatMoney(value), + }, + { + title: '最近完成', + dataIndex: 'lastAcceptedAt', + width: 170, + render: (value: string | null) => formatAdminDateTime(value), + }, + ] + + return ( + + + + setPeriod(value as AdminLeaderboardPeriod | 'custom')} + /> + {period === 'custom' ? ( + { + const [start, end] = dates || [] + setRange( + start && end + ? [start.format(ADMIN_DATE_FORMAT), end.format(ADMIN_DATE_FORMAT)] + : null, + ) + }} + disabledDate={(current) => current && current.isAfter(dayjs(), 'day')} + /> + ) : null} + + + 按验收完成单数排名,同一订单的拼单份数只计入一单;奖励为所选时间段内验收奖励合计。 + {period === 'custom' + ? range + ? ` 当前区间:${range[0]} 至 ${range[1]}(含起止日)。` + : ' 请选择统计区间。' + : ''} + + + + rowKey="workerId" + columns={columns} + dataSource={items} + loading={leaderboardQuery.isLoading} + pagination={false} + scroll={{ x: 820 }} + /> + + ) +} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 75a3087b..0265a316 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -36,6 +36,37 @@ type WorkOrderVoucherConsumeResult = { errorMessage: string } +export type AdminLeaderboardPeriod = 'all' | 'daily' | 'weekly' | 'monthly' + +export type AdminWorkerLeaderboardEntry = { + rank: number + workerId: number + username: string + displayName: string + levelName: string + workerStatus: string + acceptedOrderCount: number + rewardAmount: number + lastAcceptedAt: string | null +} + +export function fetchAdminWorkerLeaderboard( + period: AdminLeaderboardPeriod, + limit = 50, + range?: { from: string; to: string }, +) { + return apiGet<{ + period: AdminLeaderboardPeriod | 'custom' + from: string | null + to: string | null + items: AdminWorkerLeaderboardEntry[] + }>('/api/v1/admin/worker-platform/leaderboard', { + period, + limit, + ...(range ? { from: range.from, to: range.to } : {}), + }) +} + export function fetchAdminWorkerPlatformSummary() { return apiGet<{ pendingWorkerCount: number