feat(promo): HQ 推广码订单只计已完成并补扫码订单快链

列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 12:55:19 +08:00
parent 737efb89a3
commit fccfd7abbe
15 changed files with 421 additions and 124 deletions
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Card,
DatePicker,
@@ -21,6 +22,7 @@ import {
} from '@dukang/shared-types';
import { request, type Paginated } from '../../lib/api';
import { fmtTime } from '../../lib/constants';
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
type Props = {
promoId: string;
@@ -57,11 +59,45 @@ function buildEventsQs(
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 parseEventType(raw: string | null): PromoMetricEventType | undefined {
if (raw === 'SCAN' || raw === 'ATTRIBUTION' || raw === 'REGISTER' || raw === 'ORDER') return raw;
return undefined;
}
function EventRefLinks({ row }: { row: PromoMetricEventItem }) {
const navigate = useNavigate();
const links = [];
if (row.orderId) {
links.push(
<AdminPrimaryLink
key="order"
onClick={() => {
if (row.orderNo) {
navigate(`/orders?orderNo=${encodeURIComponent(row.orderNo)}`);
} else {
navigate('/orders', { state: { openOrderId: String(row.orderId) } });
}
}}
>
{row.orderNo || row.orderId}
</AdminPrimaryLink>,
);
}
if (row.userId) {
links.push(
<AdminPrimaryLink
key="user"
onClick={() => navigate('/users', { state: { openUserId: String(row.userId) } })}
>
{row.userNo || row.userId}
</AdminPrimaryLink>,
);
}
if (links.length) {
return <Space size={12}>{links}</Space>;
}
if (row.sessionId) return <> {row.sessionId}</>;
return <></>;
}
function formatLocation(row: PromoMetricEventItem): string {
@@ -71,15 +107,27 @@ function formatLocation(row: PromoMetricEventItem): string {
}
export default function PromoCodeMetricsPanel({ promoId }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const urlEventType = parseEventType(searchParams.get('eventType'));
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 [eventType, setEventType] = useState<PromoMetricEventType | undefined>(urlEventType);
const [eventsPage, setEventsPage] = useState(1);
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
const [eventsLoading, setEventsLoading] = useState(false);
useEffect(() => {
setEventType(urlEventType);
setEventsPage(1);
if (urlEventType) {
window.requestAnimationFrame(() => {
document.getElementById('promo-metric-events')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
}, [urlEventType]);
const loadTimeline = useCallback(() => {
setTimelineLoading(true);
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
@@ -164,7 +212,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
{
title: 'ID',
key: 'ref',
render: (_, row) => formatRefId(row),
render: (_, row) => <EventRefLinks row={row} />,
},
{
title: 'IP',
@@ -220,7 +268,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
notMerge
/>
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
<Typography.Title id="promo-metric-events" level={5} style={{ marginTop: 24, marginBottom: 12 }}>
</Typography.Title>
<Space wrap style={{ marginBottom: 12 }}>
@@ -232,6 +280,10 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
onChange={(v) => {
setEventType(v);
setEventsPage(1);
const next = new URLSearchParams(searchParams);
if (v) next.set('eventType', v);
else next.delete('eventType');
setSearchParams(next, { replace: true });
}}
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
value,