@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user