Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59d2586e41 | |||
| 3a2aa584a3 |
@@ -1,101 +0,0 @@
|
||||
# 点位成功率改为「按券成功率的算术平均」设计
|
||||
|
||||
- **日期**:2026-07-11
|
||||
- **范围**:纯前端 `shaguabijia-admin-web`,**后端零改动、零 schema 改动、零迁移**
|
||||
- **涉及文件**:`src/app/(main)/coupon-data/page.tsx`(单文件)
|
||||
|
||||
## 目标
|
||||
|
||||
「领券数据」看板汇总卡里的 **分平台点位成功率**(美团/淘宝/京东)与 **点位成功率(合计)**,改为
|
||||
**该平台(/全部)几张券成功率的算术平均**,让平台数字与下方「按券明细」自洽,便于快速揪出坏券。
|
||||
|
||||
## 背景
|
||||
|
||||
现状:分平台 / 合计点位成功率来自后端 `_success_rates`(**会话口径**,数据源 `coupon_session.platform_success`),
|
||||
与页面下方「按券成功率」(数据源 `coupon_claim_record`)口径不同,导致同一平台的平台数(如淘宝 71.4%)
|
||||
与其券明细(100%/80%/80%)对不上,甚至出现「合计低于每个平台」的反直觉现象。
|
||||
|
||||
关键点:**本页已经把全量按券数据拉到前端**——`couponSlots`(来自 `/admin/api/coupon-data/coupons`,
|
||||
后端 `coupon_slot_report` 不分页、返回全量),每行含 `platform` 与 `success_rate`。因此直接在前端
|
||||
对其求算术平均即可,**无需后端改动**。
|
||||
|
||||
## 口径(改动后)
|
||||
|
||||
- **分平台点位成功率** = 该平台所有券 `success_rate` 的算术平均(每张券等权;`success_rate` 为空的券跳过;无券 → 显示 `-`)。
|
||||
- **点位成功率(合计)** = `couponSlots` 全部券 `success_rate` 的算术平均。
|
||||
- **每张券成功率**(按券明细,不变):成功(success + already_claimed) ÷ 尝试(+ failed),skipped 排除(后端 `coupon_slot_report`)。
|
||||
|
||||
例:淘宝三张券 100% / 80% / 80% → 淘宝点位 = (100+80+80)/3 = **86.7%**。
|
||||
|
||||
## 不改(其他保持不变)
|
||||
|
||||
- **后端**:`coupon_slot_report` / `_success_rates` / `CouponDataSummary` schema 全不动。
|
||||
`summary.per_platform`、`summary.point_success_rate` 仍会返回(会话口径),只是前端这两处不再引用它们(留着不删,接口稳定)。
|
||||
- **整单成功率**:会话口径不变。
|
||||
- **按券明细表**:排序、列等不动。
|
||||
- **筛选联动**:整单 / 发起 / 完成 / 耗时仍随「用户 / 状态 / 日期 / 环境」全部筛选;
|
||||
点位随 `couponSlots` 走(= 日期 + 环境,不随「用户 / 状态」搜索变——与按券表现状一致)。
|
||||
|
||||
## 实现(单文件 `page.tsx`)
|
||||
|
||||
### 1. 加一个求均值 helper(放 `fmtPct` 附近)
|
||||
|
||||
```ts
|
||||
// 一组按券行的 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;
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 合计:改 value
|
||||
|
||||
把
|
||||
```tsx
|
||||
value={fmtPct(summary.point_success_rate)}
|
||||
```
|
||||
改成
|
||||
```tsx
|
||||
value={fmtPct(slotRateMean(couponSlots))}
|
||||
```
|
||||
|
||||
### 3. 分平台卡:改 rate 来源
|
||||
|
||||
把每个平台卡里的
|
||||
```tsx
|
||||
const rate = summary.per_platform?.[pid];
|
||||
```
|
||||
改成
|
||||
```tsx
|
||||
const rate = slotRateMean(couponSlots.filter((r) => r.platform === pid));
|
||||
```
|
||||
|
||||
### 4. 更新口径 tooltip 文案
|
||||
|
||||
`POINT_RATE_HINT` 改成(去掉旧的会话口径描述):
|
||||
```ts
|
||||
const POINT_RATE_HINT =
|
||||
'点位成功率(合计):当前区间内全部券成功率的算术平均(每张券等权);' +
|
||||
'单券成功率=成功(含已领)÷尝试(不含跳过),源自领券每券记录。';
|
||||
```
|
||||
分平台卡标题(`${SLOT_PLATFORM[pid]}点位成功率`)如需补口径,注明「该平台各券成功率的算术平均」。
|
||||
|
||||
## 边角 / 注意
|
||||
|
||||
- **精度**:`success_rate` 后端已 round 到 4 位,前端再平均,展示只到 1 位小数(`fmtPct`),误差可忽略。
|
||||
- **依赖按券请求**:点位现在依赖 `couponSlots`(独立请求,失败时 `setCouponSlots([])`)。若该请求失败,点位卡显示 `-`(优雅降级)。此前它来自主 `summary` 请求。
|
||||
- **platform=null 的券**:coupon_id 前缀无法识别的券(实践中 pricebot 都是 `mt_`/`tb_`/`ele_`/`elm_`/`jd_`,基本不出现)会计入 **合计**、但不在任何分平台卡里。可接受。
|
||||
|
||||
## 验证
|
||||
|
||||
- **手动**:选一天,展开某平台「查看明细」,核对该平台卡数字 = 明细表各券成功率的算术平均;合计 = 全部券的算术平均。
|
||||
- `npx tsc --noEmit` 通过;`npm run lint` 无新增 error。
|
||||
|
||||
## 不做(YAGNI)
|
||||
|
||||
- 最小尝试数阈值 / 低量券滤噪 / 坏券标红。
|
||||
- 按券表默认升序(坏券置顶)。
|
||||
- 「数据大盘」overview 的领券点位成功率(另一页、另一口径)。
|
||||
- 删除后端已不再被前端使用的 `per_platform` / `point_success_rate` 字段。
|
||||
@@ -38,15 +38,6 @@ const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`);
|
||||
const fmtTok = (inTok: number | null, outTok: number | null) =>
|
||||
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
|
||||
|
||||
// LLM 价格快照只展示 prices 里各模型的单价(元/每百万 token),忽略 mode/_source 等元字段。
|
||||
function llmPriceRows(snapshot: Record<string, unknown> | null): string[] {
|
||||
const prices = snapshot?.prices;
|
||||
if (!prices || typeof prices !== 'object') return [];
|
||||
return Object.entries(prices as Record<string, { input_per_1m?: number; output_per_1m?: number }>).map(
|
||||
([model, p]) => `${model} 输入 ${p?.input_per_1m ?? '-'}元/每百万tokens,输出${p?.output_per_1m ?? '-'}元/每百万tokens`,
|
||||
);
|
||||
}
|
||||
|
||||
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
|
||||
function pretty(s: string | null): string {
|
||||
if (!s) return '';
|
||||
@@ -193,18 +184,6 @@ export default function ComparisonRecordsPage() {
|
||||
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '广告收益',
|
||||
key: 'ad_revenue',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
render: (_, r) =>
|
||||
r.ad_revenue_yuan > 0 ? (
|
||||
<span style={{ color: '#3f8600' }}>¥{r.ad_revenue_yuan.toFixed(4)}</span>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
),
|
||||
},
|
||||
{ title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs },
|
||||
{ title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' },
|
||||
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
|
||||
@@ -224,16 +203,7 @@ export default function ComparisonRecordsPage() {
|
||||
title: '成本',
|
||||
key: 'cost',
|
||||
width: 90,
|
||||
// 有后端冻结的实际成本(当时价)就用它;否则回退老的前端估算(需顶部填 LLM 单价)。
|
||||
render: (_, r) => {
|
||||
if (r.llm_cost_yuan != null) {
|
||||
return <span title="实际成本(按调用时单价冻结)" style={{ color: '#389e0d' }}>{fmtCost(r.llm_cost_yuan)}</span>;
|
||||
}
|
||||
const est = calcCost(r.input_tokens, r.output_tokens, pricePerMTok);
|
||||
return est == null
|
||||
? '-'
|
||||
: <span title="估算(顶部 LLM 单价 × token)" style={{ color: '#999' }}>{fmtCost(est)}</span>;
|
||||
},
|
||||
render: (_, r) => fmtCost(calcCost(r.input_tokens, r.output_tokens, pricePerMTok)),
|
||||
},
|
||||
{
|
||||
title: '机型/ROM',
|
||||
@@ -310,7 +280,7 @@ export default function ComparisonRecordsPage() {
|
||||
min={0}
|
||||
step={0.1}
|
||||
style={{ width: 210 }}
|
||||
suffix="元/百万token"
|
||||
addonAfter="元/百万token"
|
||||
/>
|
||||
</Space>
|
||||
|
||||
@@ -327,7 +297,7 @@ export default function ComparisonRecordsPage() {
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange,
|
||||
}}
|
||||
scroll={{ x: 1920 }}
|
||||
scroll={{ x: 1820 }}
|
||||
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
||||
/>
|
||||
|
||||
@@ -349,27 +319,12 @@ export default function ComparisonRecordsPage() {
|
||||
? '-'
|
||||
: `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="LLM 成本">
|
||||
{detail.llm_cost_yuan != null ? (
|
||||
<>
|
||||
{fmtCost(detail.llm_cost_yuan)}
|
||||
<Tag color="green" style={{ marginLeft: 6 }}>实际·当时价</Tag>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))}
|
||||
<span style={{ color: '#999', fontSize: 12, marginLeft: 6 }}>估算</span>
|
||||
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null)
|
||||
? <span style={{ color: '#999', fontSize: 12 }}>(顶部填 LLM 单价后显示)</span>
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
<Descriptions.Item label="估算成本">
|
||||
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))}
|
||||
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null)
|
||||
? <span style={{ color: '#999', fontSize: 12 }}>(顶部填 LLM 单价后显示)</span>
|
||||
: null}
|
||||
</Descriptions.Item>
|
||||
{detail.llm_price_snapshot ? (
|
||||
<Descriptions.Item label="LLM价格快照" span={2}>
|
||||
{llmPriceRows(detail.llm_price_snapshot).map((r) => <div key={r}>{r}</div>)}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="源平台">{detail.source_platform_name || '-'}({cents(detail.source_price_cents)})</Descriptions.Item>
|
||||
<Descriptions.Item label="最优">{detail.best_platform_name || '-'}({cents(detail.best_price_cents)})</Descriptions.Item>
|
||||
<Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
|
||||
@@ -465,7 +420,7 @@ export default function ComparisonRecordsPage() {
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{(c.input_messages ?? []).map((m, j) => (
|
||||
{c.input_messages.map((m, j) => (
|
||||
<div key={j} style={{ marginBottom: 8 }}>
|
||||
<Tag>{m.role}</Tag>
|
||||
<pre style={preStyle}>{pretty(m.content)}</pre>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface ConfigItem {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
type: string; // int / int_list / dict_str_int / bool / json
|
||||
type: string; // int / int_list / dict_str_int / bool
|
||||
help: string | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: any;
|
||||
@@ -24,7 +24,7 @@ interface ConfigItem {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function toEdit(item: ConfigItem): any {
|
||||
if (item.type === 'int_list') return (item.value as number[]).join(', ');
|
||||
if (item.type === 'dict_str_int' || item.type === 'json') return JSON.stringify(item.value);
|
||||
if (item.type === 'dict_str_int') return JSON.stringify(item.value);
|
||||
return item.value;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function fromEdit(type: string, raw: any): any {
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10));
|
||||
}
|
||||
if (type === 'dict_str_int' || type === 'json') return JSON.parse(raw);
|
||||
if (type === 'dict_str_int') return JSON.parse(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
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';
|
||||
@@ -36,13 +34,6 @@ 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<string, number | null>; // {平台id: rate|null};恒含美团/淘宝/京东三档
|
||||
}
|
||||
interface CouponDataDaily {
|
||||
date: string;
|
||||
@@ -73,7 +64,6 @@ interface CouponDataRow {
|
||||
started_at: string;
|
||||
claimed_count: number | null;
|
||||
trace_url: string | null;
|
||||
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
|
||||
}
|
||||
interface CouponDataReport {
|
||||
date_from: string;
|
||||
@@ -84,20 +74,6 @@ 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 {
|
||||
@@ -120,45 +96,6 @@ const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
||||
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) },
|
||||
@@ -349,8 +286,6 @@ export default function CouponDataPage() {
|
||||
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);
|
||||
@@ -359,9 +294,6 @@ export default function CouponDataPage() {
|
||||
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);
|
||||
@@ -386,23 +318,13 @@ 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<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);
|
||||
@@ -413,7 +335,7 @@ export default function CouponDataPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[range, user, appEnv, statuses, granularity, limit, sortBy, message],
|
||||
[range, user, appEnv, granularity, limit, sortBy, message],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -471,12 +393,6 @@ 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',
|
||||
@@ -484,13 +400,6 @@ export default function CouponDataPage() {
|
||||
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',
|
||||
@@ -521,6 +430,12 @@ export default function CouponDataPage() {
|
||||
return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 165,
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
},
|
||||
{
|
||||
title: '领券 trace',
|
||||
dataIndex: 'trace_id',
|
||||
@@ -539,31 +454,6 @@ 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 items = data?.items ?? [];
|
||||
|
||||
@@ -615,18 +505,6 @@ export default function CouponDataPage() {
|
||||
]}
|
||||
/>
|
||||
</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
|
||||
@@ -702,114 +580,6 @@ export default function CouponDataPage() {
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -886,7 +656,7 @@ export default function CouponDataPage() {
|
||||
onChange: (p) => load(p, queriedLimit),
|
||||
}}
|
||||
size="small"
|
||||
scroll={{ x: 1560 }}
|
||||
scroll={{ x: 1450 }}
|
||||
/>
|
||||
|
||||
<UserRecordsDrawer<CouponDataRow>
|
||||
|
||||
@@ -354,13 +354,11 @@ export interface ComparisonRecordListItem {
|
||||
retry_count: number | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
llm_cost_yuan: number | null; // 后端按「当时价」冻结的实际成本(元);旧记录 null → 「成本」列回退估算
|
||||
device_model: string | null;
|
||||
rom_vendor: string | null;
|
||||
rom_name: string | null;
|
||||
android_version: string | null;
|
||||
app_version: string | null;
|
||||
ad_revenue_yuan: number; // 本次比价看的信息流广告预估收益(元)
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -397,8 +395,6 @@ export interface ComparisonRecordDetail extends ComparisonRecordListItem {
|
||||
latitude: number | null;
|
||||
llm_calls: LlmCall[] | null;
|
||||
raw_payload: Record<string, unknown> | null;
|
||||
// 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。
|
||||
llm_price_snapshot: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AdRevenueImpression {
|
||||
|
||||
Reference in New Issue
Block a user