v4.0.14概览和日报
CI / verify (pull_request) Waiting to run

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
+1
View File
@@ -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"
+2
View File
@@ -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() {
<Route path="/wecom-bots" element={<Navigate to="/wecom/bots" replace />} />
<Route path="/wecom/bots" element={<WecomBotsPage />} />
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
<Route path="/wecom/reports" element={<WecomReportsPage />} />
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
<Route path="/llm-configs" element={<LlmConfigsPage />} />
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
@@ -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: <RobotOutlined />, 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',
+6 -44
View File
@@ -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;
@@ -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<HTMLImageElement> {
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<void> {
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);
}
@@ -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<Record<string, string | boolean>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/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);
+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 ? (
+21 -4
View File
@@ -327,6 +327,9 @@ export default function OrdersPage() {
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
const initialCityId = searchParams.get('cityId')?.trim() || '';
const initialCreatedFrom = searchParams.get('createdFrom')?.trim() || '';
const initialCreatedTo = searchParams.get('createdTo')?.trim() || '';
const initialStatusParam = searchParams.getAll('status').join(',');
const initialStatuses = useMemo(
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
@@ -389,6 +392,18 @@ export default function OrdersPage() {
}
}, [form, initialOrderNo]);
useEffect(() => {
const patch: { cityId?: string; dateRange?: [Dayjs, Dayjs] } = {};
if (initialCityId) patch.cityId = initialCityId;
if (
/^\d{4}-\d{2}-\d{2}$/.test(initialCreatedFrom) &&
/^\d{4}-\d{2}-\d{2}$/.test(initialCreatedTo)
) {
patch.dateRange = [dayjs(initialCreatedFrom), dayjs(initialCreatedTo)];
}
if (Object.keys(patch).length) form.setFieldsValue(patch);
}, [form, initialCityId, initialCreatedFrom, initialCreatedTo]);
useEffect(() => {
if (initialStatuses.length) {
form.setFieldsValue({ status: initialStatuses });
@@ -437,7 +452,7 @@ export default function OrdersPage() {
const statusList = statuses.length ? statuses : initialStatuses;
for (const status of statusList) qs.append('status', status);
if (values.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId);
if (values.cityId || initialCityId) qs.set('cityId', values.cityId || initialCityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
@@ -445,14 +460,16 @@ export default function OrdersPage() {
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId);
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
const createdFrom = values.dateRange?.[0]?.format('YYYY-MM-DD') || initialCreatedFrom;
const createdTo = values.dateRange?.[1]?.format('YYYY-MM-DD') || initialCreatedTo;
if (createdFrom) qs.set('createdFrom', createdFrom);
if (createdTo) qs.set('createdTo', createdTo);
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]);
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses, initialCityId, initialCreatedFrom, initialCreatedTo]);
useEffect(() => {
void load();
@@ -0,0 +1,239 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Tabs,
TimePicker,
Typography,
message,
} from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import {
WECOM_REPORT_KIND_LABELS,
WECOM_REPORT_KINDS,
WECOM_REPORT_WEEKDAY_OPTIONS,
type WecomReportKind,
type WecomReportPreviewDto,
type WecomReportPushDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
type FormValues = {
name: string;
webhookUrl: string;
enabled: boolean;
mentionWecomUserId?: string;
sendTime: Dayjs;
sendWeekday: number;
sendMonthDay: number;
};
function ReportKindPane({
kind,
row,
onSaved,
}: {
kind: WecomReportKind;
row: WecomReportPushDto | undefined;
onSaved: () => void;
}) {
const [form] = Form.useForm<FormValues>();
const [saving, setSaving] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [sending, setSending] = useState(false);
const [preview, setPreview] = useState<WecomReportPreviewDto | null>(null);
useEffect(() => {
if (!row) return;
form.setFieldsValue({
name: row.name,
webhookUrl: row.webhookUrl,
enabled: row.enabled,
mentionWecomUserId: row.mentionWecomUserId ?? undefined,
sendTime: dayjs().hour(row.sendHour).minute(row.sendMinute).second(0),
sendWeekday: row.sendWeekday,
sendMonthDay: row.sendMonthDay,
});
}, [form, row]);
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
await request(`/admin/wecom-reports/${kind}`, {
method: 'PUT',
body: JSON.stringify({
name: values.name,
webhookUrl: values.webhookUrl,
enabled: values.enabled,
mentionWecomUserId: values.mentionWecomUserId?.trim() || null,
sendHour: values.sendTime.hour(),
sendMinute: values.sendTime.minute(),
sendWeekday: values.sendWeekday,
sendMonthDay: values.sendMonthDay,
}),
});
message.success('已保存');
onSaved();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function openPreview() {
setPreviewing(true);
try {
const data = await request<WecomReportPreviewDto>(`/admin/wecom-reports/${kind}/preview`, {
method: 'POST',
body: '{}',
});
setPreview(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '预览失败');
} finally {
setPreviewing(false);
}
}
async function sendNow() {
setSending(true);
try {
const res = await request<{ message: string }>(`/admin/wecom-reports/${kind}/send`, {
method: 'POST',
body: '{}',
});
message.success(res.message || '已发送');
onSaved();
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败');
} finally {
setSending(false);
}
}
const label = WECOM_REPORT_KIND_LABELS[kind];
return (
<>
<Form form={form} layout="vertical" style={{ maxWidth: 640 }}>
<Form.Item name="enabled" label="启用定时推送" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input maxLength={64} />
</Form.Item>
<Form.Item
name="webhookUrl"
label="群机器人 Webhook"
extra="企业微信群「群机器人」Webhook,与「消息推送」分开配置。"
rules={[{ required: true, message: '请填写 Webhook' }]}
>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="mentionWecomUserId" label="@成员 userid">
<Input placeholder="可选,企业微信成员账号" />
</Form.Item>
<Form.Item
name="sendTime"
label="发送时刻"
extra={
kind === 'daily'
? '按北京时间当天该时刻发送当日数据。'
: kind === 'weekly'
? '按北京时间该星期该时刻发送上一自然周。'
: '按北京时间每月该日该时刻发送上一自然月。'
}
rules={[{ required: true, message: '请选择时刻' }]}
>
<TimePicker format="HH:mm" minuteStep={1} allowClear={false} />
</Form.Item>
{kind === 'weekly' ? (
<Form.Item name="sendWeekday" label="发送星期" rules={[{ required: true }]}>
<Select options={WECOM_REPORT_WEEKDAY_OPTIONS} />
</Form.Item>
) : null}
{kind === 'monthly' ? (
<Form.Item name="sendMonthDay" label="每月几号" rules={[{ required: true }]}>
<InputNumber min={1} max={31} />
</Form.Item>
) : null}
<Typography.Paragraph type="secondary">
{row?.lastSentAt
? `上次发送:${fmtTime(row.lastSentAt)}${row.lastSentPeriod ?? '—'}`
: '尚未发送'}
</Typography.Paragraph>
<Space wrap>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
<Button loading={previewing} onClick={() => void openPreview()}>
</Button>
<Button loading={sending} onClick={() => void sendNow()}>
</Button>
</Space>
</Form>
<Modal
title={`预览${label}`}
open={!!preview}
onCancel={() => setPreview(null)}
footer={<Button onClick={() => setPreview(null)}></Button>}
width={560}
>
<Typography.Paragraph type="secondary">
{preview?.rangeLabel}
</Typography.Paragraph>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{preview?.markdown}</pre>
</Modal>
</>
);
}
export default function WecomReportsPage() {
const [rows, setRows] = useState<WecomReportPushDto[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
return request<WecomReportPushDto[]>('/admin/wecom-reports')
.then(setRows)
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
void load();
}, [load]);
const byKind = new Map(rows.map((r) => [r.kind, r]));
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Typography.Paragraph type="secondary">
/ / Webhook
payAmount
</Typography.Paragraph>
<Card loading={loading}>
<Tabs
items={WECOM_REPORT_KINDS.map((kind) => ({
key: kind,
label: WECOM_REPORT_KIND_LABELS[kind],
children: <ReportKindPane kind={kind} row={byKind.get(kind)} onSaved={() => void load()} />,
}))}
/>
</Card>
</div>
);
}