53e0db6a98
Add log_promo_event for scan/attribution/register/order with IP; admin metrics APIs and ECharts on promo detail page. Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
7.5 KiB
TypeScript
259 lines
7.5 KiB
TypeScript
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<PromoMetricTimelineDto | null>(null);
|
|
const [timelineLoading, setTimelineLoading] = useState(false);
|
|
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
|
|
const [eventsPage, setEventsPage] = useState(1);
|
|
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
|
const [eventsLoading, setEventsLoading] = useState(false);
|
|
|
|
const loadTimeline = useCallback(() => {
|
|
setTimelineLoading(true);
|
|
return request<PromoMetricTimelineDto>(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<Paginated<PromoMetricEventItem>>(
|
|
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<EChartsOption>(() => {
|
|
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<PromoMetricEventItem> = [
|
|
{
|
|
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 (
|
|
<Card
|
|
title="数据趋势"
|
|
style={{ marginTop: 16 }}
|
|
extra={
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
仅统计功能上线后的新事件
|
|
</Typography.Text>
|
|
}
|
|
>
|
|
<Space wrap style={{ marginBottom: 16 }}>
|
|
<DatePicker.RangePicker
|
|
value={range}
|
|
onChange={(v) => {
|
|
if (v?.[0] && v[1]) {
|
|
setRange([v[0], v[1]]);
|
|
setEventsPage(1);
|
|
}
|
|
}}
|
|
allowClear={false}
|
|
/>
|
|
<Radio.Group
|
|
value={granularity}
|
|
onChange={(e) => setGranularity(e.target.value as 'day' | 'hour')}
|
|
optionType="button"
|
|
buttonStyle="solid"
|
|
options={[
|
|
{ label: '按日', value: 'day' },
|
|
{ label: '按时', value: 'hour' },
|
|
]}
|
|
/>
|
|
</Space>
|
|
|
|
<ReactECharts
|
|
option={chartOption}
|
|
style={{ height: 360 }}
|
|
showLoading={timelineLoading}
|
|
notMerge
|
|
/>
|
|
|
|
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
|
事件日志
|
|
</Typography.Title>
|
|
<Space wrap style={{ marginBottom: 12 }}>
|
|
<Select
|
|
allowClear
|
|
placeholder="全部类型"
|
|
style={{ width: 160 }}
|
|
value={eventType}
|
|
onChange={(v) => {
|
|
setEventType(v);
|
|
setEventsPage(1);
|
|
}}
|
|
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
|
value,
|
|
label,
|
|
}))}
|
|
/>
|
|
</Space>
|
|
<Table
|
|
rowKey="id"
|
|
size="small"
|
|
columns={columns}
|
|
dataSource={events?.items ?? []}
|
|
loading={eventsLoading}
|
|
pagination={{
|
|
current: eventsPage,
|
|
pageSize: events?.pageSize ?? 20,
|
|
total: events?.total ?? 0,
|
|
showSizeChanger: false,
|
|
onChange: (p) => setEventsPage(p),
|
|
}}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|