feat(coupon-data): 领券数据看板加成功率 + 按券成功率下钻表 (#43)

概述

领券数据看板两项增强:新增**成功率指标 + 按券下钻表**,以及**领券状态多选筛选**。纯前端改动,仅涉及 `src/app/(main)/coupon-data/page.tsx`;配套后端接口见下方「后端依赖」。

## 1. 成功率指标 + 按券下钻表

- 汇总卡新增:**整单成功率**、**点位成功率(合计)**,及 **美团 / 淘宝 / 京东** 三档分平台点位成功率卡;每项带 `Tooltip` 口径说明:
  - 整单成功率 = 一次领券勾选平台全部至少领到一张的场次 ÷ 领券发起数(基数含未完成/失败/中途退出)。
  - 点位成功率(合计)= 各平台成功点位数之和 ÷ 勾选点位数之和;**注意与「数据大盘」券粒度的点位成功率口径不同**。
- 点击任一分平台卡**下钻**展开「按券成功率」表(`coupon_id` 粒度:券名 / 尝试 / 成功 / 成功率,默认按成功率降序、可排序);再次点击或「收起」关闭。
- 按券数据走独立端点 `GET /admin/api/coupon-data/coupons`,与主报表并行拉取,失败不影响主报表。
- 页内类型 `CouponDataSummary` 增加 `full_success_*` / `point_success_*` / `per_platform` 字段;新增 `CouponSlotRow` / `CouponSlotsOut`。

## 2. 领券状态多选筛选(默认全选)

- 筛选卡新增状态多选(未完成 / 完成 / 失败 / 中途退出),**默认全选 = 与现状口径一致**,不改变既有统计。
- `status` 数组用 `paramsSerializer: { indexes: null }` 序列化为 `status=a&status=b`,对齐 FastAPI `list[str]` Query。
- 附带:记录表「时间」列位置调整(移到「发起平台」之后)。

---

## 后端依赖

- 领券汇总接口返回上述成功率字段(`full_success_*` / `point_success_*` / `per_platform`)。
- 新增 `GET /admin/api/coupon-data/coupons`(按券成功率,`coupon_id` 粒度)。
- 领券报表接口支持 `status` 多值筛选。
- 口径详见 app-server `docs/guides/领券成功率指标-设计与埋点.md`(§3/§12/§13)。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
2026-07-09 17:31:53 +08:00
parent 03d1003c3c
commit 26e468766b
+176 -7
View File
@@ -16,8 +16,10 @@ import {
Statistic, Statistic,
Table, Table,
Tag, Tag,
Tooltip,
Typography, Typography,
} from 'antd'; } from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api'; import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format'; import { formatUtcTime } from '@/lib/format';
@@ -34,6 +36,13 @@ interface CouponDataSummary {
p50_ms: number | null; p50_ms: number | null;
p95_ms: number | null; p95_ms: number | null;
p99_ms: number | null; p99_ms: number | null;
// 平台粒度成功率(见 shaguabijia-app-server docs/guides/领券成功率指标-设计与埋点.md §3/§12)
full_success_count: number;
full_success_rate: number | null;
point_success_count: number;
point_total_count: number;
point_success_rate: number | null;
per_platform: Record<string, number | null>; // {平台id: rate|null};恒含美团/淘宝/京东三档
} }
interface CouponDataDaily { interface CouponDataDaily {
date: string; date: string;
@@ -74,6 +83,20 @@ interface CouponDataReport {
total: number; total: number;
items: CouponDataRow[]; items: CouponDataRow[];
} }
// 按券成功率(§13):coupon_id 粒度,数据源 coupon_claim_record;成功率=成功/(成功+失败),skipped 排除。
interface CouponSlotRow {
coupon_id: string;
coupon_name: string | null;
platform: string | null;
tried: number;
succeeded: number;
success_rate: number | null;
}
interface CouponSlotsOut {
date_from: string;
date_to: string;
items: CouponSlotRow[];
}
// 发起来源 App 包名 → 中文(「发起平台」列):空=傻瓜比价首页发起,外卖 App 包名→对应平台。 // 发起来源 App 包名 → 中文(「发起平台」列):空=傻瓜比价首页发起,外卖 App 包名→对应平台。
function originLabel(pkg: string | null): string { function originLabel(pkg: string | null): string {
@@ -96,6 +119,24 @@ const STATUS_TAG: Record<string, { color: string; label: string }> = {
const fmtSec = (ms: number | null | undefined): string => const fmtSec = (ms: number | null | undefined): string =>
ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`; ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
// rate(0..1)→ "60.0%"(空值显示 -)
const fmtPct = (v: number | null | undefined): string =>
v == null ? '-' : `${(v * 100).toFixed(1)}%`;
// 汇总卡成功率口径 tooltip 文案
const FULL_RATE_HINT =
'整单成功率:一次领券勾选的平台全部至少领到一张的场次 ÷ 领券发起数;基数含未完成/失败/中途退出。';
const POINT_RATE_HINT =
'点位成功率(合计):平台粒度——各平台(美团/淘宝闪购/京东)成功点位数之和 ÷ 勾选平台点位数之和;空勾选按全领三档计。注意与「数据大盘」券粒度的点位成功率口径不同。';
// 按券表:coupon_id 平台 → 中文
const SLOT_PLATFORM: Record<string, string> = {
'meituan-waimai': '美团',
'taobao-shanguang': '淘宝',
'jd-waimai': '京东',
};
const slotPlatformLabel = (p: string | null): string => (p ? SLOT_PLATFORM[p] ?? p : '其他');
// 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。 // 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。
const RECORD_COLUMNS: ColumnsType<CouponDataRow> = [ const RECORD_COLUMNS: ColumnsType<CouponDataRow> = [
{ title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) }, { title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) },
@@ -286,6 +327,8 @@ export default function CouponDataPage() {
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]); const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
const [user, setUser] = useState<string>(''); const [user, setUser] = useState<string>('');
const [appEnv, setAppEnv] = useState<string>('prod'); const [appEnv, setAppEnv] = useState<string>('prod');
// 领券状态多选(方案 A:整视图联动),默认全选四态(= 现状,不改口径)。
const [statuses, setStatuses] = useState<string[]>(['started', 'completed', 'failed', 'abandoned']);
const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time'); const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time');
const [limit, setLimit] = useState<number>(100); const [limit, setLimit] = useState<number>(100);
@@ -294,6 +337,9 @@ export default function CouponDataPage() {
const [queriedMultiDay, setQueriedMultiDay] = useState(false); const [queriedMultiDay, setQueriedMultiDay] = useState(false);
const [queriedLimit, setQueriedLimit] = useState<number>(100); const [queriedLimit, setQueriedLimit] = useState<number>(100);
const [data, setData] = useState<CouponDataReport | null>(null); const [data, setData] = useState<CouponDataReport | null>(null);
const [couponSlots, setCouponSlots] = useState<CouponSlotRow[]>([]);
// 按券表:默认隐藏,点某平台点位成功率卡 → 展开该平台的券表(null=隐藏)。
const [selectedSlotPlatform, setSelectedSlotPlatform] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 点手机号:抽屉看该用户全部领券(总次数 + 记录) // 点手机号:抽屉看该用户全部领券(总次数 + 记录)
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null); const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
@@ -318,13 +364,23 @@ export default function CouponDataPage() {
date_to: to, date_to: to,
user: user.trim() || undefined, user: user.trim() || undefined,
app_env: appEnv, app_env: appEnv,
status: statuses,
granularity: gran, granularity: gran,
limit: targetLimit, limit: targetLimit,
offset: (targetPage - 1) * targetLimit, offset: (targetPage - 1) * targetLimit,
sort: targetSort, sort: targetSort,
}, },
// status 是数组:repeat 无方括号序列化(status=a&status=b),对齐 FastAPI list[str] Query
paramsSerializer: { indexes: null },
}); });
setData(res.data); setData(res.data);
// 并行拉「按券成功率」(独立端点,失败不影响主报表)
api
.get<CouponSlotsOut>('/admin/api/coupon-data/coupons', {
params: { date_from: from, date_to: to, app_env: appEnv },
})
.then((r) => setCouponSlots(r.data.items))
.catch(() => setCouponSlots([]));
setPage(targetPage); setPage(targetPage);
setQueriedLimit(targetLimit); setQueriedLimit(targetLimit);
setQueriedGranularity(gran); setQueriedGranularity(gran);
@@ -335,7 +391,7 @@ export default function CouponDataPage() {
setLoading(false); setLoading(false);
} }
}, },
[range, user, appEnv, granularity, limit, sortBy, message], [range, user, appEnv, statuses, granularity, limit, sortBy, message],
); );
useEffect(() => { useEffect(() => {
@@ -393,6 +449,12 @@ export default function CouponDataPage() {
width: 110, width: 110,
render: (pkg: string | null) => originLabel(pkg), render: (pkg: string | null) => originLabel(pkg),
}, },
{
title: '时间',
dataIndex: 'started_at',
width: 165,
render: (v: string) => formatUtcTime(v),
},
{ {
title: '耗时', title: '耗时',
dataIndex: 'elapsed_ms', dataIndex: 'elapsed_ms',
@@ -430,12 +492,6 @@ export default function CouponDataPage() {
return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>; return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>;
}, },
}, },
{
title: '时间',
dataIndex: 'started_at',
width: 165,
render: (v: string) => formatUtcTime(v),
},
{ {
title: '领券 trace', title: '领券 trace',
dataIndex: 'trace_id', dataIndex: 'trace_id',
@@ -454,6 +510,31 @@ export default function CouponDataPage() {
}, },
]; ];
const slotColumns: ColumnsType<CouponSlotRow> = [
{
title: '券名',
dataIndex: 'coupon_name',
render: (v: string | null, r: CouponSlotRow) => v || r.coupon_id,
},
{
title: '尝试',
dataIndex: 'tried',
width: 90,
align: 'right',
sorter: (a: CouponSlotRow, b: CouponSlotRow) => a.tried - b.tried,
},
{ title: '成功', dataIndex: 'succeeded', width: 90, align: 'right' },
{
title: '成功率',
dataIndex: 'success_rate',
width: 100,
align: 'right',
defaultSortOrder: 'descend',
render: (v: number | null) => fmtPct(v),
sorter: (a: CouponSlotRow, b: CouponSlotRow) => (a.success_rate ?? 0) - (b.success_rate ?? 0),
},
];
const summary = data?.summary; const summary = data?.summary;
const items = data?.items ?? []; const items = data?.items ?? [];
@@ -505,6 +586,18 @@ export default function CouponDataPage() {
]} ]}
/> />
</Space> </Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
mode="multiple"
value={statuses}
onChange={setStatuses}
style={{ minWidth: 240 }}
maxTagCount="responsive"
placeholder="全部状态"
options={Object.entries(STATUS_TAG).map(([value, t]) => ({ value, label: t.label }))}
/>
</Space>
<Space size={6}> <Space size={6}>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
<Select <Select
@@ -580,6 +673,82 @@ export default function CouponDataPage() {
<Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} /> <Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} />
</Col> </Col>
</Row> </Row>
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic
title={
<span>
{' '}
<Tooltip title={FULL_RATE_HINT}>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(summary.full_success_rate)}
/>
</Col>
<Col flex="1 1 0">
<Statistic
title={
<span>
(){' '}
<Tooltip title={POINT_RATE_HINT}>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(summary.point_success_rate)}
/>
</Col>
</Row>
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
<Row gutter={[16, 12]}>
{(
[
['meituan-waimai', '美团点位成功率'],
['taobao-shanguang', '淘宝点位成功率'],
['jd-waimai', '京东点位成功率'],
] as const
).map(([pid, title]) => {
const active = selectedSlotPlatform === pid;
return (
<Col flex="1 1 0" key={pid}>
<div
onClick={() => setSelectedSlotPlatform(active ? null : pid)}
title="点击查看该平台各券成功率"
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 6,
border: active ? '1px solid #1677ff' : '1px solid transparent',
background: active ? '#e6f4ff' : undefined,
transition: 'all .2s',
}}
>
<Statistic title={title} value={fmtPct(summary.per_platform?.[pid])} />
</div>
</Col>
);
})}
</Row>
</Card>
)}
{selectedSlotPlatform && (
<Card
size="small"
title={`${slotPlatformLabel(selectedSlotPlatform)} · 按券成功率`}
extra={<a onClick={() => setSelectedSlotPlatform(null)}></a>}
style={{ marginBottom: 16 }}
>
<Table<CouponSlotRow>
rowKey="coupon_id"
size="small"
pagination={false}
dataSource={couponSlots.filter((r) => r.platform === selectedSlotPlatform)}
columns={slotColumns}
/>
</Card> </Card>
)} )}