diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index 355939c..bfb110a 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -18,6 +18,7 @@ "echarts": "^6.1.0", "echarts-for-react": "^3.0.6", "element-china-area-data": "^6.1.0", + "jspdf": "^4.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 6b56e63..8d16802 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -51,6 +51,7 @@ import SystemSettingsPage from './pages/SystemSettingsPage'; import TestWhitelistPage from './pages/TestWhitelistPage'; import WecomBotsPage from './pages/WecomBotsPage'; import WecomMessagePushesPage from './pages/WecomMessagePushesPage'; +import WecomReportsPage from './pages/WecomReportsPage'; import WecomBotLogsPage from './pages/WecomBotLogsPage'; import LlmConfigsPage from './pages/LlmConfigsPage'; import KnowledgeBasesPage from './pages/KnowledgeBasesPage'; @@ -95,6 +96,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 87b123c..770f709 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -129,6 +129,7 @@ const MENU_ITEMS: MenuProps['items'] = [ children: [ { key: '/wecom/bots', label: '智能机器人' }, { key: '/wecom/pushes', label: '消息推送' }, + { key: '/wecom/reports', label: '报告' }, ], }, { key: '/llm-configs', icon: , label: '语言模型' }, @@ -186,6 +187,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/activity-posters': 'activity_posters', '/wecom/bots': 'wecom_bots', '/wecom/pushes': 'wecom_bots', + '/wecom/reports': 'wecom_bots', 'wecom-group': 'wecom_bots', '/llm-configs': 'llm_configs', '/knowledge-bases': 'knowledge_bases', diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index c6ea6d2..6715378 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -147,50 +147,12 @@ export type DashboardStats = { ordersByStatus: Array<{ status: string; count: number }>; }; -export type DashboardAnalytics = { - summary: { - users: number; - orders: number; - payingUsers: number; - partners: number; - stores: number; - redeems: number; - redeemAmount: number; - }; - byDate: Array<{ - date: string; - users: number; - orders: number; - partners: number; - stores: number; - redeems: number; - redeemAmount: number; - }>; - byCity: Array<{ - cityId: string; - cityName: string; - users: number; - orders: number; - partners: number; - stores: number; - redeems: number; - redeemAmount: number; - }>; - byPromo: Array<{ - promoCodeId: string | null; - code: string; - name: string; - users: number; - orders: number; - }>; - byPartner: Array<{ - partnerAccountId: string; - companyName: string; - stores: number; - redeems: number; - redeemAmount: number; - }>; -}; +export type { + DashboardAnalytics, + DashboardGranularity, + DashboardLineChart, + DashboardLineHref, +} from '@dukang/shared-types'; export type SystemVersion = { id: string; diff --git a/apps/admin-web/src/lib/dashboard-charts-pdf.ts b/apps/admin-web/src/lib/dashboard-charts-pdf.ts new file mode 100644 index 0000000..84d3671 --- /dev/null +++ b/apps/admin-web/src/lib/dashboard-charts-pdf.ts @@ -0,0 +1,94 @@ +import type { ECharts } from 'echarts'; + +const TITLE_FONT = 'bold 32px "Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif'; +const SUB_FONT = '22px "Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif'; + +type ChartShot = { + title: string; + instance: ECharts | undefined; +}; + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('图表截图失败')); + img.src = src; + }); +} + +async function composePage(params: { + chartPng: string; + title: string; + subtitle: string; +}): Promise<{ dataUrl: string; width: number; height: number }> { + const img = await loadImage(params.chartPng); + const header = 96; + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height + header; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('无法生成 PDF 画布'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.textBaseline = 'top'; + ctx.fillStyle = '#1f1f1f'; + ctx.font = TITLE_FONT; + ctx.fillText(params.title, 24, 16, canvas.width - 48); + ctx.fillStyle = '#8c8c8c'; + ctx.font = SUB_FONT; + ctx.fillText(params.subtitle, 24, 56, canvas.width - 48); + ctx.drawImage(img, 0, header); + return { + dataUrl: canvas.toDataURL('image/jpeg', 0.92), + width: canvas.width, + height: canvas.height, + }; +} + +/** 把当前已渲染折线图打成横向 A4 PDF(不含 KPI / 待办)。 */ +export async function downloadDashboardChartsPdf(params: { + filename: string; + subtitle: string; + charts: ChartShot[]; +}): Promise { + const ready = params.charts.filter((c): c is { title: string; instance: ECharts } => !!c.instance); + if (ready.length === 0) { + throw new Error('暂无可下载的图'); + } + if (ready.length !== params.charts.length) { + throw new Error('图表尚未渲染完成,请稍后再试'); + } + + const { jsPDF } = await import('jspdf'); + const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); + const pageW = pdf.internal.pageSize.getWidth(); + const pageH = pdf.internal.pageSize.getHeight(); + const margin = 10; + + for (let i = 0; i < ready.length; i += 1) { + const chart = ready[i]; + const png = chart.instance.getDataURL({ + type: 'png', + pixelRatio: 2, + backgroundColor: '#ffffff', + }); + const page = await composePage({ + chartPng: png, + title: chart.title, + subtitle: params.subtitle, + }); + const maxW = pageW - margin * 2; + const maxH = pageH - margin * 2; + const ratio = Math.min(maxW / page.width, maxH / page.height); + const w = page.width * ratio; + const h = page.height * ratio; + const x = (pageW - w) / 2; + const y = margin + (maxH - h) / 2; + if (i > 0) pdf.addPage(); + pdf.addImage(page.dataUrl, 'JPEG', x, y, w, h); + } + + const filename = params.filename.endsWith('.pdf') ? params.filename : `${params.filename}.pdf`; + pdf.save(filename); +} diff --git a/apps/admin-web/src/pages/CityPartnersPage.tsx b/apps/admin-web/src/pages/CityPartnersPage.tsx index f658c27..c75b6b5 100644 --- a/apps/admin-web/src/pages/CityPartnersPage.tsx +++ b/apps/admin-web/src/pages/CityPartnersPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, Button, @@ -129,12 +129,18 @@ function formatApiError(err: unknown): string | null { export default function CityPartnersPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const initialCityId = searchParams.get('cityId')?.trim() || ''; const [filterForm] = Form.useForm(); const [editForm] = Form.useForm(); const [createForm] = Form.useForm(); const [subForm] = Form.useForm(); const [subEditForm] = Form.useForm(); - const [filters, setFilters] = useState>({}); + const [filters, setFilters] = useState>(() => { + const init: Record = {}; + if (initialCityId) init.cityId = initialCityId; + return init; + }); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/partners', () => { @@ -484,6 +490,7 @@ export default function CityPartnersPage() { form={filterForm} layout="inline" style={{ marginBottom: 16 }} + initialValues={initialCityId ? { cityId: initialCityId } : undefined} onFinish={(v) => { setFilters(v); setPage(1); diff --git a/apps/admin-web/src/pages/DashboardPage.tsx b/apps/admin-web/src/pages/DashboardPage.tsx index 5c24f83..ae118fe 100644 --- a/apps/admin-web/src/pages/DashboardPage.tsx +++ b/apps/admin-web/src/pages/DashboardPage.tsx @@ -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 = { PENDING_PAY: '待付款', @@ -36,26 +48,121 @@ const DEPLOYED_BY_LABELS: Record = { 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 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(null); const [version, setVersion] = useState(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({ - range: [dayjs().subtract(29, 'day'), dayjs()], + granularity: 'day', + range: defaultRange('day'), }); const [analytics, setAnalytics] = useState(null); const [analyticsLoading, setAnalyticsLoading] = useState(true); const [cities, setCities] = useState([]); - const [promos, setPromos] = useState([]); - const [partners, setPartners] = useState>([]); + const [metric, setMetric] = useState('total'); + const [pdfLoading, setPdfLoading] = useState(false); + const chartsWrapRef = useRef(null); const loadVersion = useCallback(() => { setVersionLoading(true); @@ -136,18 +243,6 @@ export default function DashboardPage() { void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) .then((res) => setCities(res.items ?? [])) .catch(() => setCities([])); - if ((profile.permissionKeys ?? []).includes('promo_codes')) { - void request>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) - .then((res) => setPromos(res.items ?? [])) - .catch(() => setPromos([])); - } - if ((profile.permissionKeys ?? []).includes('partners')) { - void request>( - `/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(() => { - 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(() => { - 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(() => { - 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(() => { - const rows = analytics?.byDate ?? []; - const legend: string[] = []; - const series: NonNullable = []; - 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('[_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(() => { - 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(() => { - const rows = analytics?.byPartner ?? []; - const legend: string[] = []; - const series: NonNullable = []; - 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(() => { - 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 (
@@ -574,7 +476,7 @@ export default function DashboardPage() { - - + + setGranularity(v as DashboardGranularity)} + /> + + + + + + + {DATE_PRESETS.map((preset) => ( + + ))} + ({ value: p.id, label: `${p.name}(${p.code})` })), - ]} - /> - - ) : null} - {canPartners ? ( - - + + + + + + + + + + + {kind === 'weekly' ? ( + +