@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user