676 lines
23 KiB
TypeScript
676 lines
23 KiB
TypeScript
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<string, string> = {
|
||
PENDING_PAY: '待付款',
|
||
PENDING_SHIP: '待发货',
|
||
OUT_WAREHOUSE: '已出库',
|
||
SHIPPING: '配送中',
|
||
PENDING_RECEIVE: '待收货',
|
||
COMPLETED: '已完成',
|
||
CANCELLED: '已取消',
|
||
REFUNDING: '退款中',
|
||
REFUNDED: '已退款',
|
||
};
|
||
|
||
const DEPLOYED_BY_LABELS: Record<string, string> = {
|
||
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<DashboardLineHref, string> = {
|
||
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<DashboardStats | null>(null);
|
||
const [version, setVersion] = useState<SystemVersion | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [versionLoading, setVersionLoading] = useState(true);
|
||
const [profile, setProfile] = useState<HqProfile | null>(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<AnalyticsFilters>({
|
||
granularity: 'day',
|
||
range: defaultRange('day'),
|
||
});
|
||
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
|
||
const [analyticsLoading, setAnalyticsLoading] = useState(true);
|
||
const [cities, setCities] = useState<CityOption[]>([]);
|
||
const [metric, setMetric] = useState<ChartMetric>('total');
|
||
const [pdfLoading, setPdfLoading] = useState(false);
|
||
const chartsWrapRef = useRef<HTMLDivElement>(null);
|
||
|
||
const loadVersion = useCallback(() => {
|
||
setVersionLoading(true);
|
||
return request<SystemVersion | null>('/admin/dashboard/version')
|
||
.then(setVersion)
|
||
.catch(() => setVersion(null))
|
||
.finally(() => setVersionLoading(false));
|
||
}, []);
|
||
|
||
const loadAnalytics = useCallback((f: AnalyticsFilters) => {
|
||
setAnalyticsLoading(true);
|
||
return request<DashboardAnalytics>(`/admin/dashboard/analytics?${buildAnalyticsQs(f)}`)
|
||
.then(setAnalytics)
|
||
.catch((e) => {
|
||
message.error(e instanceof Error ? e.message : '加载统计失败');
|
||
setAnalytics(null);
|
||
})
|
||
.finally(() => setAnalyticsLoading(false));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
request<DashboardStats>('/admin/dashboard/stats')
|
||
.then(setStats)
|
||
.finally(() => setLoading(false));
|
||
request<HqProfile>('/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<Paginated<CityOption>>(`/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<DeployTriggerResult>('/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<HTMLElement>('[_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 (
|
||
<div>
|
||
<Typography.Title level={4}>
|
||
{cityScoped ? '数据概览(负责城市)' : '数据概览'}
|
||
</Typography.Title>
|
||
|
||
{isSuperAdmin ? (
|
||
<Card
|
||
title="系统版本"
|
||
loading={versionLoading}
|
||
style={{ marginBottom: 24 }}
|
||
extra={
|
||
<Space>
|
||
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
|
||
刷新版本
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<CloudUploadOutlined />}
|
||
loading={deploying}
|
||
onClick={handleDeploy}
|
||
>
|
||
发布更新
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
{version ? (
|
||
<Descriptions column={{ xs: 1, sm: 2, lg: 3 }} size="small">
|
||
<Descriptions.Item label="分支">{version.branch || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="Tag">{version.gitTag || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="Commit">
|
||
<Typography.Text copyable={{ text: version.commitId }}>{shortSha}</Typography.Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="提交说明" span={3}>{version.commitMessage}</Descriptions.Item>
|
||
<Descriptions.Item label="发布时间">{fmtTime(version.deployedAt)}</Descriptions.Item>
|
||
<Descriptions.Item label="触发来源">
|
||
{DEPLOYED_BY_LABELS[version.deployedBy || ''] || version.deployedBy || '—'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
) : (
|
||
<Typography.Text type="secondary">尚未记录发版信息</Typography.Text>
|
||
)}
|
||
</Card>
|
||
) : null}
|
||
|
||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||
{!permsReady ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading />
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canUsers ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading}>
|
||
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canUsers ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading}>
|
||
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canUsers ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading}>
|
||
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canOrders ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading}>
|
||
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canStores ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading}>
|
||
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canStores ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading} extra={<Link to="/stores?auditStatus=PENDING">去处理</Link>}>
|
||
<Statistic
|
||
title="待入驻审核"
|
||
value={stats?.pendingStoreOnboard ?? 0}
|
||
valueStyle={{ color: (stats?.pendingStoreOnboard ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canStoreAudits ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading} extra={<Link to="/store-package-audits">去处理</Link>}>
|
||
<Statistic
|
||
title="待套餐/信息审核"
|
||
value={(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0)}
|
||
valueStyle={{
|
||
color:
|
||
(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0) > 0
|
||
? '#fa8c16'
|
||
: undefined,
|
||
}}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canPartners ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading}>
|
||
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canBenefit ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading}>
|
||
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{permsReady && canDeliveries ? (
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card loading={loading}>
|
||
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
</Row>
|
||
|
||
<Card
|
||
title="全局筛选"
|
||
style={{ marginBottom: 24 }}
|
||
extra={
|
||
<Button
|
||
icon={<ReloadOutlined />}
|
||
size="small"
|
||
onClick={() => void loadAnalytics(filters)}
|
||
>
|
||
刷新
|
||
</Button>
|
||
}
|
||
>
|
||
<Form
|
||
form={filterForm}
|
||
layout="inline"
|
||
initialValues={{ range: filters.range }}
|
||
onFinish={applyFilters}
|
||
>
|
||
<Form.Item label="粒度">
|
||
<Segmented
|
||
options={GRAIN_OPTIONS}
|
||
value={filters.granularity}
|
||
onChange={(v) => setGranularity(v as DashboardGranularity)}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item label="日期" required>
|
||
<Space size={8} wrap>
|
||
<Form.Item name="range" noStyle rules={[{ required: true, message: '请选择日期' }]}>
|
||
<DatePicker.RangePicker allowClear={false} />
|
||
</Form.Item>
|
||
{DATE_PRESETS.map((preset) => (
|
||
<Button key={preset.key} autoInsertSpace={false} onClick={() => applyDatePreset(preset.key)}>
|
||
{preset.label}
|
||
</Button>
|
||
))}
|
||
</Space>
|
||
</Form.Item>
|
||
<Form.Item name="cityId" label="城市">
|
||
<Select
|
||
allowClear
|
||
placeholder={cityScoped ? '全部负责城市' : '全部开城'}
|
||
style={{ width: 160 }}
|
||
options={[
|
||
...(cityScoped ? [] : [{ value: 'none', label: '未选城' }]),
|
||
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item label="指标">
|
||
<Segmented
|
||
options={METRIC_OPTIONS}
|
||
value={metric}
|
||
onChange={(v) => setMetric(v as ChartMetric)}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">查询</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button
|
||
icon={<DownloadOutlined />}
|
||
loading={pdfLoading}
|
||
disabled={!hasAnalytics || analyticsLoading || chartOptions.length === 0}
|
||
onClick={() => void handleDownloadPdf()}
|
||
>
|
||
下载PDF
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
</Card>
|
||
|
||
{permsReady && !hasAnalytics ? (
|
||
<Card style={{ marginBottom: 24 }}>
|
||
<Typography.Text type="secondary">当前账号无可视指标</Typography.Text>
|
||
</Card>
|
||
) : null}
|
||
|
||
{hasAnalytics ? (
|
||
<div ref={chartsWrapRef} style={{ marginBottom: 24 }}>
|
||
{chartOptions.map(({ chart, option }) => (
|
||
<Card
|
||
key={chart.key}
|
||
title={chart.title}
|
||
extra={<Link to={listHref(chart.href, filters)}>查看</Link>}
|
||
loading={analyticsLoading}
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<ReactECharts option={option} style={{ height: 320, width: '100%' }} notMerge />
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{canFinance || canTickets ? (
|
||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||
{canFinance ? (
|
||
<Col xs={24} sm={12} lg={8}>
|
||
<Card
|
||
loading={loading}
|
||
title="待审核合伙人打款"
|
||
extra={<Link to="/finance/partner-bills">去处理</Link>}
|
||
>
|
||
<Statistic
|
||
value={stats?.pendingBills ?? 0}
|
||
suffix="笔"
|
||
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||
/>
|
||
<Typography.Text type="secondary">
|
||
合伙人已确认申请,待总部通过或驳回
|
||
{(stats?.pendingPartnerDraftBills ?? 0) > 0
|
||
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
|
||
: ''}
|
||
</Typography.Text>
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{canFinance ? (
|
||
<Col xs={24} sm={12} lg={8}>
|
||
<Card
|
||
loading={loading}
|
||
title="待打款门店结算"
|
||
extra={<Link to="/finance/store-bills">去处理</Link>}
|
||
>
|
||
<Statistic
|
||
value={stats?.pendingPayouts ?? 0}
|
||
suffix="笔"
|
||
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{canFinance ? (
|
||
<Col xs={24} sm={12} lg={8}>
|
||
<Card
|
||
loading={loading}
|
||
title="待审门店提现"
|
||
extra={<Link to="/finance/store-bills?kind=WITHDRAW">去处理</Link>}
|
||
>
|
||
<Statistic
|
||
value={stats?.pendingStoreWithdrawals ?? 0}
|
||
suffix="笔"
|
||
valueStyle={{
|
||
color: (stats?.overdueStoreWithdrawals ?? 0) > 0 ? '#cf1322' : (stats?.pendingStoreWithdrawals ?? 0) > 0 ? '#fa8c16' : undefined,
|
||
}}
|
||
/>
|
||
<Typography.Text type="secondary">
|
||
{(stats?.overdueStoreWithdrawals ?? 0) > 0
|
||
? `超时未审 ${stats?.overdueStoreWithdrawals} 笔(FIN-003)`
|
||
: '工作日 T+0 审完'}
|
||
</Typography.Text>
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{canTickets ? (
|
||
<Col xs={24} sm={12} lg={8}>
|
||
<Card loading={loading} title="待处理工单">
|
||
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
</Row>
|
||
) : null}
|
||
|
||
{canUsers || canOrders ? (
|
||
<Row gutter={[16, 16]}>
|
||
{canUsers ? (
|
||
<Col xs={24} lg={canOrders ? 12 : 24}>
|
||
<Card title="已合并访客账号" loading={loading}>
|
||
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
{canOrders ? (
|
||
<Col xs={24} lg={canUsers ? 12 : 24}>
|
||
<Card title="订单状态分布" loading={loading}>
|
||
<Table
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="status"
|
||
dataSource={stats?.ordersByStatus ?? []}
|
||
columns={[
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
render: (s: string) => STATUS_LABELS[s] || s,
|
||
},
|
||
{ title: '数量', dataIndex: 'count' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
</Row>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|