From 53e0db6a9846c31fb07273df807b184e605229f1 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Thu, 6 Aug 2026 00:06:52 +0800 Subject: [PATCH] feat(promo): promo metric event logs and HQ timeline charts (v3.4.13) Add log_promo_event for scan/attribution/register/order with IP; admin metrics APIs and ECharts on promo detail page. Co-authored-by: Cursor --- .../src/pages/promo/PromoCodeDetailPage.tsx | 3 + .../src/pages/promo/PromoCodeMetricsPanel.tsx | 258 ++++++++++++++++++ packages/shared-types/src/promo.ts | 44 +++ server/dukang-api/prisma/schema.prisma | 27 ++ .../modules/analytics/analytics.controller.ts | 12 +- .../promo/admin-promo-code.controller.ts | 12 + .../src/modules/promo/dto/promo-code.dto.ts | 34 +++ .../src/modules/promo/promo-code.service.ts | 174 +++++++++++- .../modules/promo/promo-metric-log.service.ts | 49 ++++ .../src/modules/promo/promo-metric.util.ts | 106 +++++++ .../src/modules/promo/promo.module.ts | 5 +- .../src/modules/trade/trade.service.ts | 55 +++- 杜康好客-v3-PRD.md | 2 +- 杜康好客-v3-现状对照.md | 2 +- 杜康好客-v3.4.13-体验优化开发文档.md | 27 +- 15 files changed, 785 insertions(+), 25 deletions(-) create mode 100644 apps/admin-web/src/pages/promo/PromoCodeMetricsPanel.tsx create mode 100644 server/dukang-api/src/modules/promo/promo-metric-log.service.ts create mode 100644 server/dukang-api/src/modules/promo/promo-metric.util.ts diff --git a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx index 0092b3f..0137f3c 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx @@ -24,6 +24,7 @@ import { import { request } from '../../lib/api'; import { fmtTime } from '../../lib/constants'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; +import PromoCodeMetricsPanel from './PromoCodeMetricsPanel'; const descLabelStyle: CSSProperties = { whiteSpace: 'nowrap', @@ -243,6 +244,8 @@ export default function PromoCodeDetailPage() { + + ([dayjs().subtract(6, 'day'), dayjs()]); + const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); + const [timeline, setTimeline] = useState(null); + const [timelineLoading, setTimelineLoading] = useState(false); + const [eventType, setEventType] = useState(); + const [eventsPage, setEventsPage] = useState(1); + const [events, setEvents] = useState | null>(null); + const [eventsLoading, setEventsLoading] = useState(false); + + const loadTimeline = useCallback(() => { + setTimelineLoading(true); + return request(buildTimelineQs(promoId, range, granularity)) + .then(setTimeline) + .catch((e) => { + message.error(e instanceof Error ? e.message : '加载趋势失败'); + setTimeline(null); + }) + .finally(() => setTimelineLoading(false)); + }, [promoId, range, granularity]); + + const loadEvents = useCallback(() => { + setEventsLoading(true); + return request>( + buildEventsQs(promoId, eventsPage, 20, range, eventType), + ) + .then(setEvents) + .catch((e) => { + message.error(e instanceof Error ? e.message : '加载事件失败'); + setEvents(null); + }) + .finally(() => setEventsLoading(false)); + }, [promoId, eventsPage, range, eventType]); + + useEffect(() => { + void loadTimeline(); + }, [loadTimeline]); + + useEffect(() => { + void loadEvents(); + }, [loadEvents]); + + const chartOption = useMemo(() => { + const buckets = timeline?.buckets ?? []; + const xData = buckets.map((b) => b.key); + + return { + tooltip: { trigger: 'axis' }, + legend: { data: SERIES.map((s) => s.name), bottom: 0 }, + grid: { left: 48, right: 24, top: 24, bottom: 48 }, + xAxis: { + type: 'category', + data: xData, + axisLabel: { + rotate: granularity === 'hour' ? 45 : 0, + formatter: (v: string) => (granularity === 'hour' ? v.slice(5, 16) : v.slice(5)), + }, + }, + yAxis: { type: 'value', minInterval: 1 }, + series: SERIES.map((s) => ({ + name: s.name, + type: 'line', + smooth: true, + data: buckets.map((b) => b[s.key]), + itemStyle: { color: s.color }, + markPoint: { + symbol: 'pin', + symbolSize: 42, + data: (() => { + const peak = timeline?.peak[s.key]; + if (!peak || peak.count <= 0) return []; + return [{ name: '高峰', coord: [peak.key, peak.count], value: peak.count }]; + })(), + }, + })), + }; + }, [timeline, granularity]); + + const columns: ColumnsType = [ + { + title: '时间', + dataIndex: 'createdAt', + width: 168, + render: (v: string) => fmtTime(v), + }, + { + title: '类型', + dataIndex: 'eventType', + width: 108, + render: (v: PromoMetricEventType) => PROMO_METRIC_EVENT_LABELS[v], + }, + { + title: 'ID', + key: 'ref', + render: (_, row) => formatRefId(row), + }, + { + title: 'IP', + dataIndex: 'clientIp', + width: 128, + render: (v: string | null) => v ?? '—', + }, + { + title: '地点', + key: 'location', + width: 120, + render: (_, row) => formatLocation(row), + }, + ]; + + return ( + + 仅统计功能上线后的新事件 + + } + > + + { + if (v?.[0] && v[1]) { + setRange([v[0], v[1]]); + setEventsPage(1); + } + }} + allowClear={false} + /> + setGranularity(e.target.value as 'day' | 'hour')} + optionType="button" + buttonStyle="solid" + options={[ + { label: '按日', value: 'day' }, + { label: '按时', value: 'hour' }, + ]} + /> + + + + + + 事件日志 + + +