diff --git a/src/app/(main)/coupon-data/page.tsx b/src/app/(main)/coupon-data/page.tsx index 01590dc..659b512 100644 --- a/src/app/(main)/coupon-data/page.tsx +++ b/src/app/(main)/coupon-data/page.tsx @@ -16,8 +16,10 @@ import { Statistic, Table, Tag, + Tooltip, Typography, } from 'antd'; +import { InfoCircleOutlined } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { formatUtcTime } from '@/lib/format'; @@ -34,6 +36,13 @@ interface CouponDataSummary { p50_ms: number | null; p95_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; // {平台id: rate|null};恒含美团/淘宝/京东三档 } interface CouponDataDaily { date: string; @@ -74,6 +83,20 @@ interface CouponDataReport { total: number; 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 包名→对应平台。 function originLabel(pkg: string | null): string { @@ -96,6 +119,24 @@ const STATUS_TAG: Record = { const fmtSec = (ms: number | null | undefined): string => 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 = { + 'meituan-waimai': '美团', + 'taobao-shanguang': '淘宝', + 'jd-waimai': '京东', +}; +const slotPlatformLabel = (p: string | null): string => (p ? SLOT_PLATFORM[p] ?? p : '其他'); + // 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。 const RECORD_COLUMNS: ColumnsType = [ { 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 [user, setUser] = useState(''); const [appEnv, setAppEnv] = useState('prod'); + // 领券状态多选(方案 A:整视图联动),默认全选四态(= 现状,不改口径)。 + const [statuses, setStatuses] = useState(['started', 'completed', 'failed', 'abandoned']); const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time'); const [limit, setLimit] = useState(100); @@ -294,6 +337,9 @@ export default function CouponDataPage() { const [queriedMultiDay, setQueriedMultiDay] = useState(false); const [queriedLimit, setQueriedLimit] = useState(100); const [data, setData] = useState(null); + const [couponSlots, setCouponSlots] = useState([]); + // 按券表:默认隐藏,点某平台点位成功率卡 → 展开该平台的券表(null=隐藏)。 + const [selectedSlotPlatform, setSelectedSlotPlatform] = useState(null); const [loading, setLoading] = useState(false); // 点手机号:抽屉看该用户全部领券(总次数 + 记录) const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null); @@ -318,13 +364,23 @@ export default function CouponDataPage() { date_to: to, user: user.trim() || undefined, app_env: appEnv, + status: statuses, granularity: gran, limit: targetLimit, offset: (targetPage - 1) * targetLimit, sort: targetSort, }, + // status 是数组:repeat 无方括号序列化(status=a&status=b),对齐 FastAPI list[str] Query + paramsSerializer: { indexes: null }, }); setData(res.data); + // 并行拉「按券成功率」(独立端点,失败不影响主报表) + api + .get('/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); setQueriedLimit(targetLimit); setQueriedGranularity(gran); @@ -335,7 +391,7 @@ export default function CouponDataPage() { setLoading(false); } }, - [range, user, appEnv, granularity, limit, sortBy, message], + [range, user, appEnv, statuses, granularity, limit, sortBy, message], ); useEffect(() => { @@ -393,6 +449,12 @@ export default function CouponDataPage() { width: 110, render: (pkg: string | null) => originLabel(pkg), }, + { + title: '时间', + dataIndex: 'started_at', + width: 165, + render: (v: string) => formatUtcTime(v), + }, { title: '耗时', dataIndex: 'elapsed_ms', @@ -430,12 +492,6 @@ export default function CouponDataPage() { return parts.length ? parts.join(' / ') : -; }, }, - { - title: '时间', - dataIndex: 'started_at', - width: 165, - render: (v: string) => formatUtcTime(v), - }, { title: '领券 trace', dataIndex: 'trace_id', @@ -454,6 +510,31 @@ export default function CouponDataPage() { }, ]; + const slotColumns: ColumnsType = [ + { + 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 items = data?.items ?? []; @@ -505,6 +586,18 @@ export default function CouponDataPage() { ]} /> + + 状态 + + + + + + 整单成功率{' '} + + + + + } + value={fmtPct(summary.full_success_rate)} + /> + + + + 点位成功率(合计){' '} + + + + + } + value={fmtPct(summary.point_success_rate)} + /> + + + + + {( + [ + ['meituan-waimai', '美团点位成功率'], + ['taobao-shanguang', '淘宝点位成功率'], + ['jd-waimai', '京东点位成功率'], + ] as const + ).map(([pid, title]) => { + const active = selectedSlotPlatform === pid; + return ( + +
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', + }} + > + +
+ + ); + })} +
+ + )} + + {selectedSlotPlatform && ( + setSelectedSlotPlatform(null)}>收起} + style={{ marginBottom: 16 }} + > + + rowKey="coupon_id" + size="small" + pagination={false} + dataSource={couponSlots.filter((r) => r.platform === selectedSlotPlatform)} + columns={slotColumns} + /> )}