7269e27e24
点位成功率改为按券成功率的算术平均(前端从按券明细上卷) --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #46
908 lines
32 KiB
TypeScript
908 lines
32 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
App,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
DatePicker,
|
||
Divider,
|
||
Input,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
Typography,
|
||
} from 'antd';
|
||
import { DownOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||
import dayjs, { type Dayjs } from 'dayjs';
|
||
import { api, errMsg } from '@/lib/api';
|
||
import { formatUtcTime } from '@/lib/format';
|
||
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
|
||
|
||
const { RangePicker } = DatePicker;
|
||
|
||
// ── 领券数据看板类型(仅本页用,内联定义)──
|
||
interface CouponDataSummary {
|
||
started_count: number;
|
||
completed_count: number;
|
||
avg_elapsed_ms: number | null;
|
||
p5_ms: number | null;
|
||
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<string, number | null>; // {平台id: rate|null};恒含美团/淘宝/京东三档
|
||
}
|
||
interface CouponDataDaily {
|
||
date: string;
|
||
started_count: number;
|
||
completed_count: number;
|
||
avg_elapsed_ms: number | null;
|
||
}
|
||
interface CouponDataHourly {
|
||
hour: number;
|
||
started_count: number;
|
||
completed_count: number;
|
||
avg_elapsed_ms: number | null;
|
||
}
|
||
interface CouponDataRow {
|
||
id: number;
|
||
trace_id: string;
|
||
user_id: number | null;
|
||
user_phone: string | null;
|
||
user_nickname: string | null;
|
||
status: string;
|
||
platforms: string[] | null;
|
||
origin_package: string | null;
|
||
elapsed_ms: number | null;
|
||
platform_elapsed: Record<string, number> | null;
|
||
device_model: string | null;
|
||
rom: string | null;
|
||
app_env: string | null;
|
||
started_at: string;
|
||
claimed_count: number | null;
|
||
trace_url: string | null;
|
||
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
|
||
}
|
||
interface CouponDataReport {
|
||
date_from: string;
|
||
date_to: string;
|
||
summary: CouponDataSummary;
|
||
daily: CouponDataDaily[];
|
||
hourly: CouponDataHourly[];
|
||
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 {
|
||
if (!pkg) return '傻瓜比价';
|
||
if (pkg.includes('meituan') || pkg.includes('sankuai')) return '美团';
|
||
if (pkg.includes('taobao') || pkg.includes('ele')) return '淘宝';
|
||
if (pkg.includes('jingdong') || pkg.includes('jd')) return '京东';
|
||
return pkg;
|
||
}
|
||
|
||
// 领券状态 → 颜色 + 中文(started 无终态 = 中途流失)
|
||
const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
||
started: { color: 'default', label: '未完成' },
|
||
completed: { color: 'green', label: '完成' },
|
||
failed: { color: 'red', label: '失败' },
|
||
abandoned: { color: 'orange', label: '中途退出' },
|
||
};
|
||
|
||
// ms → "1.5s"(空值显示 -)
|
||
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)}%`;
|
||
|
||
// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位。
|
||
const fmtYuan = (v: number | null | undefined): string =>
|
||
v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`;
|
||
|
||
// 一组按券行的 success_rate 算术平均(每张券等权;空值券跳过);无有效券 → null(显示 -)。
|
||
// 汇总卡「分平台/合计点位成功率」据此由按券明细上卷,口径与下方按券表一致。
|
||
const slotRateMean = (rows: CouponSlotRow[]): number | null => {
|
||
const rates = rows
|
||
.map((r) => r.success_rate)
|
||
.filter((v): v is number => v != null);
|
||
return rates.length ? rates.reduce((a, b) => a + b, 0) / rates.length : null;
|
||
};
|
||
|
||
// 汇总卡成功率口径 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 SLOT_PLATFORM_COLOR: Record<string, string> = {
|
||
'meituan-waimai': '#FFB300',
|
||
'taobao-shanguang': '#FF6A00',
|
||
'jd-waimai': '#E1251B',
|
||
};
|
||
|
||
// 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。
|
||
const RECORD_COLUMNS: ColumnsType<CouponDataRow> = [
|
||
{ title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 80,
|
||
render: (s: string) => {
|
||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||
return <Tag color={t.color}>{t.label}</Tag>;
|
||
},
|
||
},
|
||
{ title: '发起平台', dataIndex: 'origin_package', width: 90, render: (pkg: string | null) => originLabel(pkg) },
|
||
{ title: '耗时', dataIndex: 'elapsed_ms', width: 80, align: 'right', render: (v: number | null) => fmtSec(v) },
|
||
{ title: '美团', key: 'mt', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['meituan-waimai']) },
|
||
{ title: '淘宝', key: 'tb', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['taobao-shanguang']) },
|
||
{ title: '京东', key: 'jd', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['jd-waimai']) },
|
||
{
|
||
title: 'trace',
|
||
dataIndex: 'trace_id',
|
||
width: 90,
|
||
render: (v: string, r: CouponDataRow) =>
|
||
r.trace_url ? (
|
||
<a href={r.trace_url} target="_blank" rel="noreferrer" title={v}>trace</a>
|
||
) : (
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{v.slice(0, 6)}…</Typography.Text>
|
||
),
|
||
},
|
||
];
|
||
|
||
// ── 趋势图(纯 SVG,零依赖,复用广告报表同款):浅蓝柱=发起数、深蓝柱=完成数(左轴 次数),橙线=平均耗时(右轴 秒)──
|
||
const CHART_BAR_STARTED = '#bae0ff';
|
||
const CHART_BAR_COMPLETED = '#1677ff';
|
||
const CHART_LINE = '#fa8c16';
|
||
|
||
interface TrendPoint {
|
||
label: string; // x 轴刻度(小时数 / MM-DD)
|
||
tip: string; // hover 原生 tooltip
|
||
started: number;
|
||
completed: number;
|
||
avgSec: number | null; // 平均耗时(秒);该桶无完成为 null(不画线点)
|
||
}
|
||
|
||
// 按天聚合:用 from..to 补齐空缺日为 0,轴连续。
|
||
function aggregateDaily(dateFrom: string, dateTo: string, daily: CouponDataDaily[]): TrendPoint[] {
|
||
const map = new Map(daily.map((d) => [d.date, d]));
|
||
const out: TrendPoint[] = [];
|
||
let cur = dayjs(dateFrom);
|
||
let guard = 0;
|
||
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
|
||
const ds = cur.format('YYYY-MM-DD');
|
||
const d = map.get(ds);
|
||
const started = d?.started_count ?? 0;
|
||
const completed = d?.completed_count ?? 0;
|
||
const avgSec = d?.avg_elapsed_ms != null ? d.avg_elapsed_ms / 1000 : null;
|
||
out.push({
|
||
label: ds.slice(5),
|
||
tip: `${ds} 发起 ${started} · 完成 ${completed} · 平均耗时 ${avgSec == null ? '-' : avgSec.toFixed(1) + 's'}`,
|
||
started,
|
||
completed,
|
||
avgSec,
|
||
});
|
||
cur = cur.add(1, 'day');
|
||
guard += 1;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// 按小时聚合(0–23)。
|
||
function aggregateHourly(rows: CouponDataHourly[]): TrendPoint[] {
|
||
const byHour = new Map(rows.map((r) => [r.hour, r]));
|
||
return Array.from({ length: 24 }, (_, h) => {
|
||
const r = byHour.get(h);
|
||
const started = r?.started_count ?? 0;
|
||
const completed = r?.completed_count ?? 0;
|
||
const avgSec = r?.avg_elapsed_ms != null ? r.avg_elapsed_ms / 1000 : null;
|
||
return {
|
||
label: String(h),
|
||
tip: `${String(h).padStart(2, '0')}:00 发起 ${started} · 完成 ${completed} · 平均耗时 ${avgSec == null ? '-' : avgSec.toFixed(1) + 's'}`,
|
||
started,
|
||
completed,
|
||
avgSec,
|
||
};
|
||
});
|
||
}
|
||
|
||
function TrendChart({ points }: { points: TrendPoint[] }) {
|
||
const n = points.length;
|
||
const maxBar = Math.max(1, ...points.map((p) => p.started));
|
||
const maxSec = Math.max(1e-9, ...points.map((p) => p.avgSec ?? 0));
|
||
|
||
const W = 960;
|
||
const H = 280;
|
||
const padL = 48;
|
||
const padR = 56;
|
||
const padT = 16;
|
||
const padB = 32;
|
||
const plotW = W - padL - padR;
|
||
const plotH = H - padT - padB;
|
||
const step = plotW / Math.max(1, n);
|
||
const barW = Math.min(24, step * 0.5);
|
||
const yBase = padT + plotH;
|
||
|
||
const barX = (i: number) => padL + i * step + (step - barW) / 2;
|
||
const cx = (i: number) => padL + i * step + step / 2;
|
||
const barH = (v: number) => (v / maxBar) * plotH;
|
||
const secY = (v: number) => yBase - (v / maxSec) * plotH;
|
||
// X 轴刻度按天/按小时尽量每格都标(≤31 个点每个都标,超出才抽稀),避免跨度稍大就隔天显示。
|
||
const labelEvery = Math.max(1, Math.ceil(n / 31));
|
||
|
||
// 平均耗时线:仅 avgSec 非 null 的点连线(无完成的桶断开)。
|
||
const linePts = points
|
||
.map((p, i) => (p.avgSec == null ? null : `${cx(i)},${secY(p.avgSec)}`))
|
||
.filter((x): x is string => x !== null)
|
||
.join(' ');
|
||
|
||
return (
|
||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
|
||
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
|
||
const y = yBase - t * plotH;
|
||
return (
|
||
<g key={t}>
|
||
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
|
||
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR_COMPLETED}>
|
||
{Math.round(maxBar * t)}
|
||
</text>
|
||
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
|
||
{(maxSec * t).toFixed(1)}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{/* 发起数柱(浅) */}
|
||
{points.map((p, i) => (
|
||
<rect
|
||
key={`s-${i}`}
|
||
x={barX(i)}
|
||
y={yBase - barH(p.started)}
|
||
width={barW}
|
||
height={barH(p.started)}
|
||
fill={CHART_BAR_STARTED}
|
||
rx={2}
|
||
>
|
||
<title>{p.tip}</title>
|
||
</rect>
|
||
))}
|
||
{/* 完成数柱(深,窄,叠中间) */}
|
||
{points.map((p, i) => {
|
||
const cw = barW * 0.55;
|
||
return (
|
||
<rect
|
||
key={`c-${i}`}
|
||
x={cx(i) - cw / 2}
|
||
y={yBase - barH(p.completed)}
|
||
width={cw}
|
||
height={barH(p.completed)}
|
||
fill={CHART_BAR_COMPLETED}
|
||
rx={1}
|
||
>
|
||
<title>{p.tip}</title>
|
||
</rect>
|
||
);
|
||
})}
|
||
{/* 平均耗时线(橙,右轴) */}
|
||
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
|
||
{points.map((p, i) =>
|
||
p.avgSec == null ? null : (
|
||
<circle key={`p-${i}`} cx={cx(i)} cy={secY(p.avgSec)} r={2.5} fill={CHART_LINE}>
|
||
<title>{p.tip}</title>
|
||
</circle>
|
||
),
|
||
)}
|
||
{points.map((p, i) =>
|
||
i % labelEvery === 0 || i === n - 1 ? (
|
||
<text key={`x-${i}`} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
|
||
{p.label}
|
||
</text>
|
||
) : null,
|
||
)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// 领券数据看板:上半汇总卡 + 按天/小时趋势(参考广告收益大盘),下半逐条领券明细。
|
||
// 数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报);耗时统计只算 completed。
|
||
export default function CouponDataPage() {
|
||
const { message } = App.useApp();
|
||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
||
const [user, setUser] = useState<string>('');
|
||
const [appEnv, setAppEnv] = useState<string>('prod');
|
||
// 领券状态多选(方案 A:整视图联动),默认全选四态(= 现状,不改口径)。
|
||
const [statuses, setStatuses] = useState<string[]>(['started', 'completed', 'failed', 'abandoned']);
|
||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||
const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time');
|
||
const [limit, setLimit] = useState<number>(100);
|
||
const [page, setPage] = useState<number>(1);
|
||
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day');
|
||
const [queriedMultiDay, setQueriedMultiDay] = useState(false);
|
||
const [queriedLimit, setQueriedLimit] = useState<number>(100);
|
||
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 [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
|
||
const openUserRecords = (r: CouponDataRow) => {
|
||
if (r.user_id != null) setRecordsUser({ userId: r.user_id, phone: r.user_phone });
|
||
};
|
||
|
||
// 跨多天时「按小时」无意义,粒度强制按天(同广告报表)。
|
||
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
|
||
|
||
const load = useCallback(
|
||
async (targetPage = 1, targetLimit = limit, targetSort = sortBy) => {
|
||
const from = range[0].format('YYYY-MM-DD');
|
||
const to = range[1].format('YYYY-MM-DD');
|
||
const multiDay = from !== to;
|
||
const gran = multiDay ? 'day' : granularity;
|
||
setLoading(true);
|
||
try {
|
||
const res = await api.get<CouponDataReport>('/admin/api/coupon-data', {
|
||
params: {
|
||
date_from: from,
|
||
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<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);
|
||
setQueriedLimit(targetLimit);
|
||
setQueriedGranularity(gran);
|
||
setQueriedMultiDay(multiDay);
|
||
} catch (e) {
|
||
message.error(errMsg(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[range, user, appEnv, statuses, granularity, limit, sortBy, message],
|
||
);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
// 仅首次自动拉近 7 天;之后由「查询」按钮触发
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const columns: ColumnsType<CouponDataRow> = [
|
||
{
|
||
title: '用户',
|
||
dataIndex: 'user_nickname',
|
||
width: 140,
|
||
render: (_: unknown, r: CouponDataRow) => {
|
||
if (r.user_nickname) {
|
||
return (
|
||
<span>
|
||
{r.user_nickname}
|
||
{r.user_id != null && (
|
||
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
|
||
#{r.user_id}
|
||
</Typography.Text>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
if (r.user_id != null) return <Typography.Text type="secondary">#{r.user_id}</Typography.Text>;
|
||
return <Typography.Text type="secondary">游客</Typography.Text>;
|
||
},
|
||
},
|
||
{
|
||
title: '手机号',
|
||
dataIndex: 'user_phone',
|
||
width: 130,
|
||
// 点手机号 → 抽屉看该用户全部领券(参考低价审核/反馈页)。游客(无 user_id)不可点。
|
||
render: (phone: string | null, r: CouponDataRow) =>
|
||
r.user_id != null ? (
|
||
<a onClick={() => openUserRecords(r)}>{phone || `#${r.user_id}`}</a>
|
||
) : (
|
||
<Typography.Text type="secondary">游客</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 90,
|
||
render: (s: string) => {
|
||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||
return <Tag color={t.color}>{t.label}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '发起平台',
|
||
dataIndex: 'origin_package',
|
||
width: 110,
|
||
render: (pkg: string | null) => originLabel(pkg),
|
||
},
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'started_at',
|
||
width: 165,
|
||
render: (v: string) => formatUtcTime(v),
|
||
},
|
||
{
|
||
title: '耗时',
|
||
dataIndex: 'elapsed_ms',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (v: number | null) => fmtSec(v),
|
||
},
|
||
{
|
||
title: '广告收益',
|
||
dataIndex: 'ad_revenue_yuan',
|
||
width: 100,
|
||
align: 'right',
|
||
render: (v: number) => fmtYuan(v),
|
||
},
|
||
{
|
||
title: '美团耗时',
|
||
key: 'mt_elapsed',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['meituan-waimai']),
|
||
},
|
||
{
|
||
title: '淘宝耗时',
|
||
key: 'tb_elapsed',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['taobao-shanguang']),
|
||
},
|
||
{
|
||
title: '京东耗时',
|
||
key: 'jd_elapsed',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['jd-waimai']),
|
||
},
|
||
{
|
||
title: '机型/ROM',
|
||
key: 'device',
|
||
width: 200,
|
||
render: (_: unknown, r: CouponDataRow) => {
|
||
const parts = [r.device_model, r.rom].filter(Boolean);
|
||
return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>;
|
||
},
|
||
},
|
||
{
|
||
title: '领券 trace',
|
||
dataIndex: 'trace_id',
|
||
width: 150,
|
||
// 有 trace_url(pricebot 上云,仅 completed)→ 可点链接打开完整现场;否则降级为可复制 trace_id。
|
||
render: (v: string, r: CouponDataRow) =>
|
||
r.trace_url ? (
|
||
<a href={r.trace_url} target="_blank" rel="noreferrer" title={v}>
|
||
trace({v.slice(0, 8)}…)
|
||
</a>
|
||
) : (
|
||
<Typography.Text copyable={{ text: v }} style={{ fontSize: 12 }}>
|
||
{v.slice(0, 8)}…
|
||
</Typography.Text>
|
||
),
|
||
},
|
||
];
|
||
|
||
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 items = data?.items ?? [];
|
||
|
||
return (
|
||
<div>
|
||
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
|
||
<h2 style={{ margin: 0 }}>领券数据</h2>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
发起→完成耗时口径为客户端全程计时;均值/分位仅统计「完成」的领券
|
||
</Typography.Text>
|
||
</Space>
|
||
|
||
<Card size="small" style={{ marginBottom: 16 }}>
|
||
<Space wrap size="middle" align="center">
|
||
<Space size={6}>
|
||
<Typography.Text type="secondary">日期</Typography.Text>
|
||
<RangePicker
|
||
value={range}
|
||
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
|
||
allowClear={false}
|
||
presets={[
|
||
{ label: '今天', value: [dayjs(), dayjs()] },
|
||
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
|
||
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
|
||
]}
|
||
/>
|
||
</Space>
|
||
<Space size={6}>
|
||
<Typography.Text type="secondary">用户</Typography.Text>
|
||
<Input
|
||
placeholder="手机号/昵称"
|
||
value={user}
|
||
onChange={(e) => setUser(e.target.value)}
|
||
onPressEnter={() => load(1)}
|
||
allowClear
|
||
style={{ width: 150 }}
|
||
/>
|
||
</Space>
|
||
<Space size={6}>
|
||
<Typography.Text type="secondary">环境</Typography.Text>
|
||
<Select
|
||
value={appEnv}
|
||
onChange={setAppEnv}
|
||
style={{ width: 120 }}
|
||
options={[
|
||
{ value: 'prod', label: '正式(prod)' },
|
||
{ value: 'dev', label: '测试(dev)' },
|
||
{ value: 'all', label: '全部' },
|
||
]}
|
||
/>
|
||
</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}>
|
||
<Typography.Text type="secondary">粒度</Typography.Text>
|
||
<Select
|
||
value={rangeMultiDay ? 'day' : granularity}
|
||
onChange={setGranularity}
|
||
disabled={rangeMultiDay}
|
||
style={{ width: 110 }}
|
||
title={rangeMultiDay ? '跨多天仅支持按天' : undefined}
|
||
options={[
|
||
{ value: 'day', label: '按天' },
|
||
{ value: 'hour', label: '按小时' },
|
||
]}
|
||
/>
|
||
</Space>
|
||
<Space size={6}>
|
||
<Typography.Text type="secondary">排序</Typography.Text>
|
||
<Select
|
||
value={sortBy}
|
||
onChange={(v) => {
|
||
setSortBy(v);
|
||
load(1, limit, v);
|
||
}}
|
||
style={{ width: 130 }}
|
||
options={[
|
||
{ value: 'time', label: '时间倒序' },
|
||
{ value: 'elapsed', label: '耗时倒序' },
|
||
]}
|
||
/>
|
||
</Space>
|
||
<Space size={6}>
|
||
<Typography.Text type="secondary">每页</Typography.Text>
|
||
<Select
|
||
value={limit}
|
||
onChange={(v) => {
|
||
setLimit(v);
|
||
load(1, v);
|
||
}}
|
||
style={{ width: 120 }}
|
||
options={[20, 50, 100, 200, 500].map((nn) => ({ value: nn, label: `${nn} 条/页` }))}
|
||
/>
|
||
</Space>
|
||
<Button type="primary" onClick={() => load(1)} loading={loading}>
|
||
查询
|
||
</Button>
|
||
</Space>
|
||
</Card>
|
||
|
||
{summary && (
|
||
<Card size="small" style={{ marginBottom: 16 }}>
|
||
<Row gutter={[16, 12]}>
|
||
<Col flex="1 1 0">
|
||
<Statistic title="领券发起数" value={summary.started_count} />
|
||
</Col>
|
||
<Col flex="1 1 0">
|
||
<Statistic title="领券完成数" value={summary.completed_count} />
|
||
</Col>
|
||
<Col flex="1 1 0">
|
||
<Statistic title="平均耗时" value={fmtSec(summary.avg_elapsed_ms)} />
|
||
</Col>
|
||
</Row>
|
||
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
|
||
<Row gutter={[16, 12]}>
|
||
<Col flex="1 1 0">
|
||
<Statistic title="耗时 5 分位" value={fmtSec(summary.p5_ms)} />
|
||
</Col>
|
||
<Col flex="1 1 0">
|
||
<Statistic title="耗时 50 分位" value={fmtSec(summary.p50_ms)} />
|
||
</Col>
|
||
<Col flex="1 1 0">
|
||
<Statistic title="耗时 95 分位" value={fmtSec(summary.p95_ms)} />
|
||
</Col>
|
||
<Col flex="1 1 0">
|
||
<Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} />
|
||
</Col>
|
||
</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(slotRateMean(couponSlots))}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
|
||
<Row gutter={[16, 12]}>
|
||
{(['meituan-waimai', 'taobao-shanguang', 'jd-waimai'] as const).map((pid) => {
|
||
const active = selectedSlotPlatform === pid;
|
||
// 点位成功率 = 该平台各券成功率的算术平均(源自按券明细 couponSlots,与下方展开的按券表一致)
|
||
const rate = slotRateMean(couponSlots.filter((r) => r.platform === pid));
|
||
// 平台名(前置品牌色圆点) | 大数字成功率 | 「查看明细」按钮;仅按钮可展开表格。
|
||
return (
|
||
<Col flex="1 1 0" key={pid}>
|
||
<div
|
||
style={{
|
||
border: `1px solid ${active ? '#91caff' : '#f0f0f0'}`,
|
||
borderRadius: 8,
|
||
background: active ? '#f0f7ff' : '#fff',
|
||
transition: 'all .2s',
|
||
padding: '10px 14px',
|
||
}}
|
||
>
|
||
{/* 平台名前放小品牌色圆点(取代原左侧竖条),克制地标记平台身份,不打破页面灰度。 */}
|
||
<div style={{ fontSize: 13, color: '#666', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span
|
||
style={{
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: '50%',
|
||
flex: '0 0 auto',
|
||
background: SLOT_PLATFORM_COLOR[pid],
|
||
}}
|
||
/>
|
||
{`${SLOT_PLATFORM[pid]}点位成功率`}
|
||
</div>
|
||
<div style={{ fontSize: 26, fontWeight: 600, lineHeight: 1.1, margin: '4px 0 10px' }}>
|
||
{fmtPct(rate)}
|
||
</div>
|
||
<Button
|
||
size="small"
|
||
type={active ? 'primary' : 'default'}
|
||
onClick={() => setSelectedSlotPlatform(active ? null : pid)}
|
||
>
|
||
查看明细{' '}
|
||
<DownOutlined
|
||
style={{
|
||
fontSize: 10,
|
||
transform: active ? 'rotate(180deg)' : 'none',
|
||
transition: 'transform .2s',
|
||
}}
|
||
/>
|
||
</Button>
|
||
</div>
|
||
</Col>
|
||
);
|
||
})}
|
||
</Row>
|
||
{selectedSlotPlatform && (
|
||
// 表格区域:浅灰底 + 内边距,和上方指标卡主体分层区分。
|
||
<div
|
||
style={{
|
||
marginTop: 12,
|
||
background: '#fafafa',
|
||
border: '1px solid #f0f0f0',
|
||
borderRadius: 8,
|
||
padding: 12,
|
||
}}
|
||
>
|
||
<div
|
||
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}
|
||
>
|
||
<span style={{ fontWeight: 500 }}>{`${slotPlatformLabel(selectedSlotPlatform)} · 按券成功率`}</span>
|
||
<a onClick={() => setSelectedSlotPlatform(null)}>收起</a>
|
||
</div>
|
||
<Table<CouponSlotRow>
|
||
rowKey="coupon_id"
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={couponSlots.filter((r) => r.platform === selectedSlotPlatform)}
|
||
columns={slotColumns}
|
||
/>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
{data &&
|
||
(queriedMultiDay
|
||
? (data.daily?.length ?? 0) > 0
|
||
: queriedGranularity === 'hour' && (data.hourly?.length ?? 0) > 0) && (
|
||
<Card
|
||
size="small"
|
||
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
|
||
style={{ marginBottom: 16 }}
|
||
extra={
|
||
<Space size={16}>
|
||
<span style={{ fontSize: 12, color: '#666' }}>
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
width: 10,
|
||
height: 10,
|
||
background: CHART_BAR_STARTED,
|
||
borderRadius: 2,
|
||
marginRight: 4,
|
||
}}
|
||
/>
|
||
发起数
|
||
</span>
|
||
<span style={{ fontSize: 12, color: '#666' }}>
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
width: 10,
|
||
height: 10,
|
||
background: CHART_BAR_COMPLETED,
|
||
borderRadius: 2,
|
||
marginRight: 4,
|
||
}}
|
||
/>
|
||
完成数
|
||
</span>
|
||
<span style={{ fontSize: 12, color: '#666' }}>
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
width: 14,
|
||
borderTop: `2px solid ${CHART_LINE}`,
|
||
marginRight: 4,
|
||
verticalAlign: 'middle',
|
||
}}
|
||
/>
|
||
平均耗时(秒)
|
||
</span>
|
||
</Space>
|
||
}
|
||
>
|
||
{queriedMultiDay ? (
|
||
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
|
||
) : (
|
||
<TrendChart points={aggregateHourly(data.hourly)} />
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
<Table
|
||
rowKey="trace_id"
|
||
columns={columns}
|
||
dataSource={items}
|
||
loading={loading}
|
||
pagination={{
|
||
current: page,
|
||
pageSize: queriedLimit,
|
||
total: data?.total ?? 0,
|
||
showSizeChanger: false,
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
onChange: (p) => load(p, queriedLimit),
|
||
}}
|
||
size="small"
|
||
scroll={{ x: 1560 }}
|
||
/>
|
||
|
||
<UserRecordsDrawer<CouponDataRow>
|
||
open={recordsUser != null}
|
||
onClose={() => setRecordsUser(null)}
|
||
userId={recordsUser?.userId ?? null}
|
||
phone={recordsUser?.phone ?? null}
|
||
endpoint="/admin/api/coupon-data/user-records"
|
||
columns={RECORD_COLUMNS}
|
||
recordLabel="领券"
|
||
countLabel="领券次数"
|
||
rewardLabel="领到券张数"
|
||
rewardSuffix="张"
|
||
rewardOf={(r) => r.claimed_count ?? 0}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|