import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Select, Space, Statistic, Table, Typography, message, } from 'antd'; import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; import ReactECharts from 'echarts-for-react'; import type { EChartsOption } from 'echarts'; 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'; 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 发布', }; type CityOption = { id: string; name: string; code: string }; type PromoOption = { id: string; code: string; name: string }; type AnalyticsFilters = { range: [Dayjs, Dayjs]; cityId?: string; promoCodeId?: string; partnerAccountId?: string; }; function buildAnalyticsQs(f: AnalyticsFilters) { const qs = new URLSearchParams(); 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); if (f.promoCodeId) qs.set('promoCodeId', f.promoCodeId); if (f.partnerAccountId) qs.set('partnerAccountId', f.partnerAccountId); return qs.toString(); } 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 [filterForm] = Form.useForm<{ range: [Dayjs, Dayjs]; cityId?: string; promoCodeId?: string; partnerAccountId?: string; }>(); const [filters, setFilters] = useState({ range: [dayjs().subtract(29, 'day'), dayjs()], }); const [analytics, setAnalytics] = useState(null); const [analyticsLoading, setAnalyticsLoading] = useState(true); const [cities, setCities] = useState([]); const [promos, setPromos] = useState([]); const [partners, setPartners] = useState>([]); 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); }); void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) .then((res) => setCities(res.items ?? [])) .catch(() => setCities([])); void request>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) .then((res) => setPromos(res.items ?? [])) .catch(() => setPromos([])); void request>( `/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`, ) .then((res) => setPartners(res.items ?? [])) .catch(() => setPartners([])); }, [loadVersion]); useEffect(() => { void loadAnalytics(filters); }, [filters, loadAnalytics]); function applyFilters(values: { range?: [Dayjs, Dayjs]; cityId?: string; promoCodeId?: string; partnerAccountId?: string; }) { const next: AnalyticsFilters = { range: values.range ?? filters.range, cityId: values.cityId || undefined, promoCodeId: values.promoCodeId || undefined, partnerAccountId: values.partnerAccountId || undefined, }; setFilters(next); } function setCityFilter(cityId: string) { const next = { ...filters, cityId: cityId === filters.cityId ? undefined : cityId }; filterForm.setFieldsValue({ cityId: next.cityId }); setFilters(next); } function setPromoFilter(promoCodeId: string) { const key = promoCodeId === 'null' || promoCodeId === '' ? 'none' : promoCodeId; const next = { ...filters, promoCodeId: key === filters.promoCodeId ? undefined : key, }; filterForm.setFieldsValue({ promoCodeId: next.promoCodeId }); setFilters(next); } function setPartnerFilter(partnerAccountId: string) { const next = { ...filters, partnerAccountId: partnerAccountId === filters.partnerAccountId ? undefined : partnerAccountId, }; filterForm.setFieldsValue({ partnerAccountId: next.partnerAccountId }); setFilters(next); } 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 ordersDrillQs = useMemo(() => { const qs = new URLSearchParams(); qs.set('createdFrom', filters.range[0].format('YYYY-MM-DD')); qs.set('createdTo', filters.range[1].format('YYYY-MM-DD')); if (filters.cityId && filters.cityId !== 'none') qs.set('cityId', filters.cityId); return qs.toString(); }, [filters]); const byDateOption = useMemo(() => { const rows = analytics?.byDate ?? []; return { tooltip: { trigger: 'axis' }, legend: { data: ['新增用户', '订单数'] }, grid: { left: 40, right: 20, top: 40, bottom: 40 }, xAxis: { type: 'category', data: rows.map((r) => r.date.slice(5)), axisLabel: { rotate: rows.length > 14 ? 45 : 0 }, }, yAxis: { type: 'value', minInterval: 1 }, series: [ { name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) }, { name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) }, ], }; }, [analytics]); const byCityOption = useMemo(() => { const rows = analytics?.byCity ?? []; return { tooltip: { trigger: 'axis' }, legend: { data: ['用户', '订单'] }, grid: { left: 48, right: 20, top: 40, bottom: 48 }, xAxis: { type: 'category', data: rows.map((r) => r.cityName), axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, }, yAxis: { type: 'value', minInterval: 1 }, series: [ { name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 }, { name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 }, ], }; }, [analytics]); const byPromoOption = useMemo(() => { const rows = analytics?.byPromo ?? []; return { tooltip: { trigger: 'axis' }, legend: { data: ['用户', '订单'] }, grid: { left: 48, right: 20, top: 40, bottom: 64 }, xAxis: { type: 'category', data: rows.map((r) => r.name || r.code), axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 }, }, yAxis: { type: 'value', minInterval: 1 }, series: [ { name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 }, { name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 }, ], }; }, [analytics]); const opsByDateOption = useMemo(() => { const rows = analytics?.byDate ?? []; return { tooltip: { trigger: 'axis' }, legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] }, grid: { left: 48, right: 48, top: 48, bottom: 40 }, xAxis: { type: 'category', data: rows.map((r) => r.date.slice(5)), axisLabel: { rotate: rows.length > 14 ? 45 : 0 }, }, yAxis: [ { type: 'value', name: '数量', minInterval: 1 }, { type: 'value', name: '金额', minInterval: 1 }, ], series: [ { name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) }, { name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) }, { name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) }, { name: '核销金额', type: 'line', smooth: true, yAxisIndex: 1, data: rows.map((r) => r.redeemAmount), }, ], }; }, [analytics]); const opsByCityOption = useMemo(() => { const rows = analytics?.byCity ?? []; return { tooltip: { trigger: 'axis' }, legend: { data: ['合伙人', '门店', '核销笔数'] }, grid: { left: 48, right: 20, top: 40, bottom: 48 }, xAxis: { type: 'category', data: rows.map((r) => r.cityName), axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, }, yAxis: { type: 'value', minInterval: 1 }, series: [ { name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 }, { name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 }, { name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 }, ], }; }, [analytics]); const opsByPartnerOption = useMemo(() => { const rows = analytics?.byPartner ?? []; return { tooltip: { trigger: 'axis' }, legend: { data: ['门店', '核销笔数', '核销金额'] }, grid: { left: 48, right: 48, top: 40, bottom: 64 }, xAxis: { type: 'category', data: rows.map((r) => r.companyName), axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 }, }, yAxis: [ { type: 'value', name: '数量', minInterval: 1 }, { type: 'value', name: '金额' }, ], series: [ { name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 }, { name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 }, { name: '核销金额', type: 'line', yAxisIndex: 1, data: rows.map((r) => r.redeemAmount), }, ], }; }, [analytics]); const redeemByCityOption = useMemo(() => { const rows = analytics?.byCity ?? []; return { tooltip: { trigger: 'axis' }, legend: { data: ['核销笔数', '核销金额'] }, grid: { left: 48, right: 48, top: 40, bottom: 48 }, xAxis: { type: 'category', data: rows.map((r) => r.cityName), axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 }, }, yAxis: [ { type: 'value', name: '笔数', minInterval: 1 }, { type: 'value', name: '金额' }, ], series: [ { name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 36 }, { name: '核销金额', type: 'line', yAxisIndex: 1, data: rows.map((r) => r.redeemAmount), }, ], }; }, [analytics]); return (
数据概览 {isSuperAdmin ? ( } > {version ? ( {version.branch || '—'} {version.gitTag || '—'} {shortSha} {version.commitMessage} {fmtTime(version.deployedAt)} {DEPLOYED_BY_LABELS[version.deployedBy || ''] || version.deployedBy || '—'} ) : ( 尚未记录发版信息 )} ) : null} } size="small" onClick={() => void loadAnalytics(filters)} > 刷新 } >
({ value: p.id, label: `${p.name}(${p.code})` })), ]} />