v4.0.14概览和日报
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-09-02 21:11:58 +08:00
parent e5fe8b903a
commit 68a4b7f984
41 changed files with 3815 additions and 1098 deletions
+223 -486
View File
@@ -1,12 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Select, Space, Statistic, Table, Typography, message,
Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Segmented, Select, Space, Statistic, Table, Typography, message,
} from 'antd';
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
import { CloudUploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
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,
@@ -17,6 +28,7 @@ import {
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: '待付款',
@@ -36,26 +48,121 @@ const DEPLOYED_BY_LABELS: Record<string, string> = {
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 PromoOption = { id: string; code: string; name: string };
type AnalyticsFilters = {
granularity: DashboardGranularity;
range: [Dayjs, Dayjs];
cityId?: string;
promoCodeId?: string;
partnerAccountId?: 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);
if (f.promoCodeId) qs.set('promoCodeId', f.promoCodeId);
if (f.partnerAccountId) qs.set('partnerAccountId', f.partnerAccountId);
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);
@@ -72,27 +179,27 @@ export default function DashboardPage() {
const canStores = has('stores');
const canStoreAudits = has('store_audits');
const canPartners = has('partners');
const canPromo = has('promo_codes');
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;
promoCodeId?: string;
partnerAccountId?: string;
}>();
const [filters, setFilters] = useState<AnalyticsFilters>({
range: [dayjs().subtract(29, 'day'), dayjs()],
granularity: 'day',
range: defaultRange('day'),
});
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(true);
const [cities, setCities] = useState<CityOption[]>([]);
const [promos, setPromos] = useState<PromoOption[]>([]);
const [partners, setPartners] = useState<Array<{ id: string; companyName?: string | null; name: string }>>([]);
const [metric, setMetric] = useState<ChartMetric>('total');
const [pdfLoading, setPdfLoading] = useState(false);
const chartsWrapRef = useRef<HTMLDivElement>(null);
const loadVersion = useCallback(() => {
setVersionLoading(true);
@@ -136,18 +243,6 @@ export default function DashboardPage() {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => setCities([]));
if ((profile.permissionKeys ?? []).includes('promo_codes')) {
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPromos(res.items ?? []))
.catch(() => setPromos([]));
}
if ((profile.permissionKeys ?? []).includes('partners')) {
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}
}, [profile]);
useEffect(() => {
@@ -157,42 +252,22 @@ export default function DashboardPage() {
function applyFilters(values: {
range?: [Dayjs, Dayjs];
cityId?: string;
promoCodeId?: string;
partnerAccountId?: string;
}) {
const next: AnalyticsFilters = {
range: values.range ?? filters.range,
setFilters((prev) => ({
...prev,
range: values.range ?? prev.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 setGranularity(granularity: DashboardGranularity) {
setFilters((prev) => ({ ...prev, granularity }));
}
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 applyDatePreset(kind: 'lastWeek' | 'lastMonth' | 'lastQuarter') {
const range = datePresetRange(kind);
filterForm.setFieldsValue({ range });
setFilters((prev) => ({ ...prev, range }));
}
function handleDeploy() {
@@ -217,224 +292,51 @@ export default function DashboardPage() {
}
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],
);
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<EChartsOption>(() => {
const rows = analytics?.byDate ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('新增用户');
series.push({ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) });
async function handleDownloadPdf() {
if (analyticsLoading || chartOptions.length === 0) {
message.warning('暂无可下载的图');
return;
}
if (canOrders) {
legend.push('订单数');
series.push({ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) });
}
return {
tooltip: { trigger: 'axis' },
legend: { data: legend },
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,
};
}, [analytics, canUsers, canOrders]);
const byCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('用户');
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
}
if (canOrders) {
legend.push('订单');
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
}
return {
tooltip: { trigger: 'axis' },
legend: { data: legend },
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,
};
}, [analytics, canUsers, canOrders]);
const byPromoOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPromo ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canUsers) {
legend.push('用户');
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
}
if (canOrders) {
legend.push('订单');
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
}
return {
tooltip: { trigger: 'axis' },
legend: { data: legend },
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,
};
}, [analytics, canUsers, canOrders]);
const opsByDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? [];
const legend: string[] = [];
const series: NonNullable<EChartsOption['series']> = [];
if (canPartners) {
legend.push('新增合伙人');
series.push({ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) });
}
if (canStores) {
legend.push('新签门店');
series.push({ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) });
}
if (canBenefit) {
legend.push('核销笔数', '核销金额');
series.push({ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) });
series.push({
name: '核销金额',
type: 'line',
smooth: true,
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
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 {
tooltip: { trigger: 'axis' },
legend: { data: legend },
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,
};
}, [analytics, canPartners, canStores, canBenefit]);
const opsByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
const legend: string[] = [];
const series: EChartsOption['series'] = [];
if (canPartners) {
legend.push('合伙人');
series.push({ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 });
}
if (canStores) {
legend.push('门店');
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
}
if (canBenefit) {
legend.push('核销笔数');
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
}
return {
tooltip: { trigger: 'axis' },
legend: { data: legend },
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,
};
}, [analytics, canPartners, canStores, canBenefit]);
const opsByPartnerOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPartner ?? [];
const legend: string[] = [];
const series: NonNullable<EChartsOption['series']> = [];
if (canStores) {
legend.push('门店');
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
}
if (canBenefit) {
legend.push('核销笔数', '核销金额');
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
series.push({
name: '核销金额',
type: 'line',
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
});
}
return {
tooltip: { trigger: 'axis' },
legend: { data: legend },
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,
};
}, [analytics, canStores, canBenefit]);
const redeemByCityOption = useMemo<EChartsOption>(() => {
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 (
<div>
@@ -574,7 +476,7 @@ export default function DashboardPage() {
</Row>
<Card
title="数据统计筛选"
title="全局筛选"
style={{ marginBottom: 24 }}
extra={
<Button
@@ -592,8 +494,24 @@ export default function DashboardPage() {
initialValues={{ range: filters.range }}
onFinish={applyFilters}
>
<Form.Item name="range" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker.RangePicker allowClear={false} />
<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
@@ -606,230 +524,49 @@ export default function DashboardPage() {
]}
/>
</Form.Item>
{canPromo ? (
<Form.Item name="promoCodeId" label="推广码">
<Select
allowClear
placeholder="全部来源"
style={{ width: 200 }}
options={[
{ value: 'none', label: '自然量 / 无推广码' },
...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })),
]}
/>
</Form.Item>
) : null}
{canPartners ? (
<Form.Item name="partnerAccountId" label="合伙人">
<Select
allowClear
placeholder="全部合伙人"
style={{ width: 200 }}
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.name,
}))}
/>
</Form.Item>
) : null}
<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>
{canUsers || canOrders ? (
<Card
title={canUsers && canOrders ? '用户 / 订单统计' : canUsers ? '用户统计' : '订单统计'}
style={{ marginBottom: 24 }}
extra={
<Space>
{canOrders ? <Link to={`/orders?${ordersDrillQs}`}></Link> : null}
{canUsers ? <Link to="/users"></Link> : null}
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
{canUsers ? (
<Col xs={24} sm={8}>
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
</Col>
) : null}
{canOrders ? (
<Col xs={24} sm={8}>
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
</Col>
) : null}
{canOrders ? (
<Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
</Col>
) : null}
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
</Card>
</Col>
<Col xs={24} lg={canPromo ? 12 : 24}>
<Card
type="inner"
title="按城市(点击柱联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={byCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
</Card>
</Col>
{canPromo ? (
<Col xs={24} lg={12}>
<Card
type="inner"
title="按推广码(点击柱联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={byPromoOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byPromo ?? []).find(
(r) => (r.name || r.code) === params.name,
);
if (row) setPromoFilter(row.promoCodeId ?? 'none');
},
}}
/>
</Card>
</Col>
) : null}
</Row>
</Card>
{permsReady && !hasAnalytics ? (
<Card style={{ marginBottom: 24 }}>
<Typography.Text type="secondary"></Typography.Text>
</Card>
) : null}
{canPartners || canStores || canBenefit ? (
<Card
title={[canPartners && '合伙人', canStores && '门店', canBenefit && '核销'].filter(Boolean).join(' / ') + '统计'}
style={{ marginBottom: 24 }}
extra={
<Space>
{canPartners ? <Link to="/city-partners"></Link> : null}
{canStores ? <Link to="/stores"></Link> : null}
{canBenefit ? <Link to="/redeem-records"></Link> : null}
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
{canPartners ? (
<Col xs={24} sm={6}>
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
</Col>
) : null}
{canStores ? (
<Col xs={24} sm={6}>
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
</Col>
) : null}
{canBenefit ? (
<Col xs={24} sm={6}>
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
</Col>
) : null}
{canBenefit ? (
<Col xs={24} sm={6}>
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
</Col>
) : null}
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
</Card>
</Col>
<Col xs={24} lg={canBenefit ? 12 : 24}>
{hasAnalytics ? (
<div ref={chartsWrapRef} style={{ marginBottom: 24 }}>
{chartOptions.map(({ chart, option }) => (
<Card
type="inner"
title="按城市(点击柱联动筛选)"
key={chart.key}
title={chart.title}
extra={<Link to={listHref(chart.href, filters)}></Link>}
loading={analyticsLoading}
size="small"
style={{ marginBottom: 16 }}
>
<ReactECharts
option={opsByCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
<ReactECharts option={option} style={{ height: 320, width: '100%' }} notMerge />
</Card>
</Col>
{canBenefit ? (
<Col xs={24} lg={12}>
<Card
type="inner"
title="按城市核销金额"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={redeemByCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
</Card>
</Col>
) : null}
{canPartners && (canStores || canBenefit) ? (
<Col xs={24}>
<Card
type="inner"
title="按合伙人(门店 / 核销,点击联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={opsByPartnerOption}
style={{ height: 320 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byPartner ?? []).find(
(r) => r.companyName === params.name,
);
if (row) setPartnerFilter(row.partnerAccountId);
},
}}
/>
</Card>
</Col>
) : null}
</Row>
</Card>
))}
</div>
) : null}
{canFinance || canTickets ? (