import { useCallback, useEffect, useMemo, useState } from 'react'; import { Card, DatePicker, Radio, Select, Space, Table, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import dayjs, { type Dayjs } from 'dayjs'; import ReactECharts from 'echarts-for-react'; import type { EChartsOption } from 'echarts'; import { PROMO_METRIC_EVENT_LABELS, type PromoMetricEventItem, type PromoMetricEventType, type PromoMetricTimelineDto, } from '@dukang/shared-types'; import { request, type Paginated } from '../../lib/api'; import { fmtTime } from '../../lib/constants'; type Props = { promoId: string; }; const SERIES = [ { key: 'scan', name: '扫码进入', color: '#1677ff' }, { key: 'attribution', name: '归因用户', color: '#52c41a' }, { key: 'register', name: '扫码注册', color: '#faad14' }, { key: 'order', name: '订单', color: '#eb2f96' }, ] as const; function buildTimelineQs(promoId: string, range: [Dayjs, Dayjs], granularity: 'day' | 'hour') { const qs = new URLSearchParams(); qs.set('dateFrom', range[0].format('YYYY-MM-DD')); qs.set('dateTo', range[1].format('YYYY-MM-DD')); qs.set('granularity', granularity); return `/admin/promo-codes/${promoId}/metrics/timeline?${qs.toString()}`; } function buildEventsQs( promoId: string, page: number, pageSize: number, range: [Dayjs, Dayjs], eventType?: PromoMetricEventType, ) { const qs = new URLSearchParams(); qs.set('page', String(page)); qs.set('pageSize', String(pageSize)); qs.set('dateFrom', range[0].format('YYYY-MM-DD')); qs.set('dateTo', range[1].format('YYYY-MM-DD')); if (eventType) qs.set('eventType', eventType); return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`; } function formatRefId(row: PromoMetricEventItem): string { if (row.orderId) return `订单 ${row.orderId}`; if (row.userId) return `用户 ${row.userId}`; if (row.sessionId) return `会话 ${row.sessionId}`; return '—'; } function formatLocation(row: PromoMetricEventItem): string { const parts = [row.ipProvince, row.ipCity].filter(Boolean); if (parts.length) return parts.join(' '); return '—'; } export default function PromoCodeMetricsPanel({ promoId }: Props) { const [range, setRange] = useState<[Dayjs, Dayjs]>([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' }, ]} /> 事件日志