import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Segmented, Select, Space, Statistic, Table, Typography, message, } from 'antd'; import { CloudUploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; import ReactECharts from 'echarts-for-react'; import { getInstanceByDom, type EChartsOption } from 'echarts'; import { addShanghaiDays, defaultShanghaiRangeYmds, shanghaiMonthRange, shanghaiQuarterIndex, shanghaiQuarterRange, shanghaiWeekRange, shanghaiYearMonth, shanghaiYmd, } from '@dukang/domain'; import type { DashboardGranularity, DashboardLineChart, DashboardLineHref } from '@dukang/shared-types'; import { request, type DashboardAnalytics, type DashboardStats, type DeployTriggerResult, type HqProfile, type Paginated, type SystemVersion, } from '../lib/api'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { downloadDashboardChartsPdf } from '../lib/dashboard-charts-pdf'; const STATUS_LABELS: Record = { PENDING_PAY: '待付款', PENDING_SHIP: '待发货', OUT_WAREHOUSE: '已出库', SHIPPING: '配送中', PENDING_RECEIVE: '待收货', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退款中', REFUNDED: '已退款', }; const DEPLOYED_BY_LABELS: Record = { webhook: 'Webhook', manual: '手动脚本', admin: 'Admin 发布', }; const GRAIN_OPTIONS: Array<{ label: string; value: DashboardGranularity }> = [ { label: '日', value: 'day' }, { label: '周', value: 'week' }, { label: '月', value: 'month' }, { label: '季', value: 'quarter' }, { label: '年', value: 'year' }, ]; type ChartMetric = 'total' | 'increment'; const METRIC_OPTIONS: Array<{ label: string; value: ChartMetric }> = [ { label: '总量', value: 'total' }, { label: '增量', value: 'increment' }, ]; function toDayjsRange(from: Date, toInclusive: Date): [Dayjs, Dayjs] { return [dayjs(shanghaiYmd(from)), dayjs(shanghaiYmd(toInclusive))]; } /** 完整上一自然周 / 上月 / 上季(北京日历) */ function datePresetRange(kind: 'lastWeek' | 'lastMonth' | 'lastQuarter'): [Dayjs, Dayjs] { const today = new Date(); if (kind === 'lastWeek') { const thisMonday = shanghaiWeekRange(today).start; return toDayjsRange(addShanghaiDays(thisMonday, -7), addShanghaiDays(thisMonday, -1)); } if (kind === 'lastMonth') { const { year, month } = shanghaiYearMonth(today); const prev = month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 }; const r = shanghaiMonthRange(prev.year, prev.month); return toDayjsRange(r.start, addShanghaiDays(r.endExclusive, -1)); } const { year, quarter } = shanghaiQuarterIndex(today); const prev = quarter === 1 ? { year: year - 1, quarter: 4 } : { year, quarter: quarter - 1 }; const r = shanghaiQuarterRange(prev.year, prev.quarter); return toDayjsRange(r.start, addShanghaiDays(r.endExclusive, -1)); } const DATE_PRESETS: Array<{ key: 'lastWeek' | 'lastMonth' | 'lastQuarter'; label: string }> = [ { key: 'lastWeek', label: '上周' }, { key: 'lastMonth', label: '上月' }, { key: 'lastQuarter', label: '上季度' }, ]; function chartIsMetric(key: string, metric: ChartMetric): boolean { const isInc = key.includes('.increment'); return metric === 'increment' ? isInc : !isInc; } const HREF_PATH: Record = { users: '/users', partners: '/city-partners', stores: '/stores', orders: '/orders', redeems: '/redeem-records', }; type CityOption = { id: string; name: string; code: string }; type AnalyticsFilters = { granularity: DashboardGranularity; range: [Dayjs, Dayjs]; cityId?: string; }; function defaultRange(grain: DashboardGranularity): [Dayjs, Dayjs] { const y = defaultShanghaiRangeYmds(grain); return [dayjs(y.from), dayjs(y.to)]; } function buildAnalyticsQs(f: AnalyticsFilters) { const qs = new URLSearchParams(); qs.set('granularity', f.granularity); qs.set('dateFrom', f.range[0].format('YYYY-MM-DD')); qs.set('dateTo', f.range[1].format('YYYY-MM-DD')); if (f.cityId) qs.set('cityId', f.cityId); return qs.toString(); } function listHref(kind: DashboardLineHref, f: AnalyticsFilters): string { const qs = new URLSearchParams(); qs.set('createdFrom', f.range[0].format('YYYY-MM-DD')); qs.set('createdTo', f.range[1].format('YYYY-MM-DD')); if (f.cityId && f.cityId !== 'none') qs.set('cityId', f.cityId); const s = qs.toString(); return s ? `${HREF_PATH[kind]}?${s}` : HREF_PATH[kind]; } function lineOption( periods: Array<{ key: string; label: string }>, chart: DashboardLineChart, ): EChartsOption { return { tooltip: { trigger: 'axis' }, legend: { type: 'scroll', top: 0 }, grid: { left: 56, right: 28, top: 48, bottom: periods.length > 14 ? 64 : 36 }, xAxis: { type: 'category', data: periods.map((p) => p.label), axisLabel: { rotate: periods.length > 14 ? 45 : 0 }, }, yAxis: { type: 'value', minInterval: chart.unit === 'count' ? 1 : undefined, }, series: chart.series.map((s) => ({ name: s.name, type: 'line' as const, smooth: true, showSymbol: periods.length <= 24, data: s.values, })), }; } export default function DashboardPage() { const [stats, setStats] = useState(null); const [version, setVersion] = useState(null); const [loading, setLoading] = useState(true); const [versionLoading, setVersionLoading] = useState(true); const [profile, setProfile] = useState(null); const [deploying, setDeploying] = useState(false); const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const keys = profile?.permissionKeys ?? []; const has = (k: string) => keys.includes(k); const cityScoped = !!profile?.cityScoped; const canUsers = has('users'); const canOrders = has('orders'); const canStores = has('stores'); const canStoreAudits = has('store_audits'); const canPartners = has('partners'); const canFinance = has('finance'); const canBenefit = has('benefit'); const canDeliveries = has('deliveries'); const canTickets = has('tickets'); const permsReady = profile !== null; const hasAnalytics = canUsers || canOrders || canPartners || canStores || canBenefit; const [filterForm] = Form.useForm<{ range: [Dayjs, Dayjs]; cityId?: string; }>(); const [filters, setFilters] = useState({ granularity: 'day', range: defaultRange('day'), }); const [analytics, setAnalytics] = useState(null); const [analyticsLoading, setAnalyticsLoading] = useState(true); const [cities, setCities] = useState([]); const [metric, setMetric] = useState('total'); const [pdfLoading, setPdfLoading] = useState(false); const chartsWrapRef = useRef(null); const loadVersion = useCallback(() => { setVersionLoading(true); return request('/admin/dashboard/version') .then(setVersion) .catch(() => setVersion(null)) .finally(() => setVersionLoading(false)); }, []); const loadAnalytics = useCallback((f: AnalyticsFilters) => { setAnalyticsLoading(true); return request(`/admin/dashboard/analytics?${buildAnalyticsQs(f)}`) .then(setAnalytics) .catch((e) => { message.error(e instanceof Error ? e.message : '加载统计失败'); setAnalytics(null); }) .finally(() => setAnalyticsLoading(false)); }, []); useEffect(() => { request('/admin/dashboard/stats') .then(setStats) .finally(() => setLoading(false)); request('/admin/auth/me') .then((p) => { setProfile(p); if (p.adminRole === 'SUPER_ADMIN') { void loadVersion(); } else { setVersionLoading(false); } }) .catch(() => { setVersionLoading(false); }); }, [loadVersion]); useEffect(() => { if (!profile) return; void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) .then((res) => setCities(res.items ?? [])) .catch(() => setCities([])); }, [profile]); useEffect(() => { void loadAnalytics(filters); }, [filters, loadAnalytics]); function applyFilters(values: { range?: [Dayjs, Dayjs]; cityId?: string; }) { setFilters((prev) => ({ ...prev, range: values.range ?? prev.range, cityId: values.cityId || undefined, })); } function setGranularity(granularity: DashboardGranularity) { setFilters((prev) => ({ ...prev, granularity })); } function applyDatePreset(kind: 'lastWeek' | 'lastMonth' | 'lastQuarter') { const range = datePresetRange(kind); filterForm.setFieldsValue({ range }); setFilters((prev) => ({ ...prev, range })); } function handleDeploy() { Modal.confirm({ title: '确认发布更新?', content: '将触发服务器 webhook,拉取 auto-release.env 中配置的 GIT_BRANCH 并执行发版。发版通常需要数分钟,完成后可刷新版本信息。', okText: '开始发布', cancelText: '取消', onOk: async () => { setDeploying(true); try { const result = await request('/admin/deploy/trigger', { method: 'POST' }); message.success(result.message || '已触发发布'); } catch (e) { message.error(e instanceof Error ? e.message : '触发发布失败'); throw e; } finally { setDeploying(false); } }, }); } const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—'; const periods = analytics?.periods ?? []; const chartOptions = useMemo( () => (analytics?.charts ?? []) .filter((chart) => chartIsMetric(chart.key, metric)) .map((chart) => ({ chart, option: lineOption(periods, chart), })), [analytics, periods, metric], ); async function handleDownloadPdf() { if (analyticsLoading || chartOptions.length === 0) { message.warning('暂无可下载的图'); return; } const grainLabel = GRAIN_OPTIONS.find((o) => o.value === filters.granularity)?.label ?? filters.granularity; const metricLabel = metric === 'increment' ? '增量' : '总量'; const cityLabel = !filters.cityId ? (cityScoped ? '全部负责城市' : '全部开城') : filters.cityId === 'none' ? '未选城' : (cities.find((c) => c.id === filters.cityId)?.name ?? '城市'); const from = filters.range[0].format('YYYY-MM-DD'); const to = filters.range[1].format('YYYY-MM-DD'); setPdfLoading(true); try { const doms = [ ...(chartsWrapRef.current?.querySelectorAll('[_echarts_instance_]') ?? []), ]; await downloadDashboardChartsPdf({ filename: `数据概览-${metricLabel}-${grainLabel}-${from}_${to}.pdf`, subtitle: `${metricLabel} · ${grainLabel} · ${from} ~ ${to} · ${cityLabel}`, charts: chartOptions.map(({ chart }, index) => ({ title: chart.title, instance: doms[index] ? getInstanceByDom(doms[index]) : undefined, })), }); } catch (e) { message.error(e instanceof Error ? e.message : '下载 PDF 失败'); } finally { setPdfLoading(false); } } return (
{cityScoped ? '数据概览(负责城市)' : '数据概览'} {isSuperAdmin ? ( } > {version ? ( {version.branch || '—'} {version.gitTag || '—'} {shortSha} {version.commitMessage} {fmtTime(version.deployedAt)} {DEPLOYED_BY_LABELS[version.deployedBy || ''] || version.deployedBy || '—'} ) : ( 尚未记录发版信息 )} ) : null} {!permsReady ? ( ) : null} {permsReady && canUsers ? ( ) : null} {permsReady && canUsers ? ( ) : null} {permsReady && canUsers ? ( ) : null} {permsReady && canOrders ? ( ) : null} {permsReady && canStores ? ( ) : null} {permsReady && canStores ? ( 去处理}> 0 ? '#fa8c16' : undefined }} /> ) : null} {permsReady && canStoreAudits ? ( 去处理}> 0 ? '#fa8c16' : undefined, }} /> ) : null} {permsReady && canPartners ? ( ) : null} {permsReady && canBenefit ? ( ) : null} {permsReady && canDeliveries ? ( ) : null} } size="small" onClick={() => void loadAnalytics(filters)} > 刷新 } >
setGranularity(v as DashboardGranularity)} /> {DATE_PRESETS.map((preset) => ( ))}