feat(promo): HQ 推广码订单只计已完成并补扫码订单快链
列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
@@ -35,6 +37,9 @@ const descContentStyle: CSSProperties = {
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
const ATTRIBUTION_HINT =
|
||||
'首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。';
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
@@ -51,6 +56,7 @@ async function downloadQrcode(url: string, filename: string) {
|
||||
}
|
||||
|
||||
export default function PromoCodeDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
|
||||
const [editForm] = Form.useForm();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -209,14 +215,26 @@ export default function PromoCodeDetailPage() {
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
|
||||
>
|
||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="归因用户数"
|
||||
title={
|
||||
<span>
|
||||
归因用户数
|
||||
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={stats?.attributionCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
@@ -230,8 +248,23 @@ export default function PromoCodeDetailPage() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
||||
>
|
||||
<Statistic
|
||||
title={
|
||||
<span>
|
||||
订单数
|
||||
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={stats?.orderCount ?? detail.orderCount}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { Button, Space, Table, Tag } from 'antd';
|
||||
import { Button, Space, Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
USER_SOURCE_TYPE_LABELS,
|
||||
@@ -66,7 +66,7 @@ export default function PromoCodeUsersPage() {
|
||||
width: 160,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{ title: <Tooltip title="仅统计已完成订单">订单数</Tooltip>, dataIndex: 'orderCount', width: 80 },
|
||||
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
|
||||
Reference in New Issue
Block a user