Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 327b043b89 | |||
| c1854da671 | |||
| 7269e27e24 |
@@ -0,0 +1,101 @@
|
|||||||
|
# 点位成功率改为「按券成功率的算术平均」设计
|
||||||
|
|
||||||
|
- **日期**: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,6 +38,15 @@ const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`);
|
|||||||
const fmtTok = (inTok: number | null, outTok: number | null) =>
|
const fmtTok = (inTok: number | null, outTok: number | null) =>
|
||||||
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
|
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 就缩进展开,否则原样。
|
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
|
||||||
function pretty(s: string | null): string {
|
function pretty(s: string | null): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
@@ -215,7 +224,16 @@ export default function ComparisonRecordsPage() {
|
|||||||
title: '成本',
|
title: '成本',
|
||||||
key: 'cost',
|
key: 'cost',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (_, r) => fmtCost(calcCost(r.input_tokens, r.output_tokens, pricePerMTok)),
|
// 有后端冻结的实际成本(当时价)就用它;否则回退老的前端估算(需顶部填 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>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '机型/ROM',
|
title: '机型/ROM',
|
||||||
@@ -292,7 +310,7 @@ export default function ComparisonRecordsPage() {
|
|||||||
min={0}
|
min={0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
style={{ width: 210 }}
|
style={{ width: 210 }}
|
||||||
addonAfter="元/百万token"
|
suffix="元/百万token"
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -331,12 +349,27 @@ export default function ComparisonRecordsPage() {
|
|||||||
? '-'
|
? '-'
|
||||||
: `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`}
|
: `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="估算成本">
|
<Descriptions.Item label="LLM 成本">
|
||||||
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))}
|
{detail.llm_cost_yuan != null ? (
|
||||||
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null)
|
<>
|
||||||
? <span style={{ color: '#999', fontSize: 12 }}>(顶部填 LLM 单价后显示)</span>
|
{fmtCost(detail.llm_cost_yuan)}
|
||||||
: null}
|
<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>
|
</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.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="最优">{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>
|
<Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
|
||||||
@@ -432,7 +465,7 @@ export default function ComparisonRecordsPage() {
|
|||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<div>
|
<div>
|
||||||
{c.input_messages.map((m, j) => (
|
{(c.input_messages ?? []).map((m, j) => (
|
||||||
<div key={j} style={{ marginBottom: 8 }}>
|
<div key={j} style={{ marginBottom: 8 }}>
|
||||||
<Tag>{m.role}</Tag>
|
<Tag>{m.role}</Tag>
|
||||||
<pre style={preStyle}>{pretty(m.content)}</pre>
|
<pre style={preStyle}>{pretty(m.content)}</pre>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface ConfigItem {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
type: string; // int / int_list / dict_str_int / bool
|
type: string; // int / int_list / dict_str_int / bool / json
|
||||||
help: string | null;
|
help: string | null;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
default: any;
|
default: any;
|
||||||
@@ -24,7 +24,7 @@ interface ConfigItem {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function toEdit(item: ConfigItem): any {
|
function toEdit(item: ConfigItem): any {
|
||||||
if (item.type === 'int_list') return (item.value as number[]).join(', ');
|
if (item.type === 'int_list') return (item.value as number[]).join(', ');
|
||||||
if (item.type === 'dict_str_int') return JSON.stringify(item.value);
|
if (item.type === 'dict_str_int' || item.type === 'json') return JSON.stringify(item.value);
|
||||||
return item.value;
|
return item.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ function fromEdit(type: string, raw: any): any {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => parseInt(s.trim(), 10));
|
.map((s) => parseInt(s.trim(), 10));
|
||||||
}
|
}
|
||||||
if (type === 'dict_str_int') return JSON.parse(raw);
|
if (type === 'dict_str_int' || type === 'json') return JSON.parse(raw);
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,11 +128,21 @@ const fmtPct = (v: number | null | undefined): string =>
|
|||||||
const fmtYuan = (v: number | null | undefined): string =>
|
const fmtYuan = (v: number | null | undefined): string =>
|
||||||
v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`;
|
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 文案
|
// 汇总卡成功率口径 tooltip 文案
|
||||||
const FULL_RATE_HINT =
|
const FULL_RATE_HINT =
|
||||||
'整单成功率:一次领券勾选的平台全部至少领到一张的场次 ÷ 领券发起数;基数含未完成/失败/中途退出。';
|
'整单成功率:一次领券勾选的平台全部至少领到一张的场次 ÷ 领券发起数;基数含未完成/失败/中途退出。';
|
||||||
const POINT_RATE_HINT =
|
const POINT_RATE_HINT =
|
||||||
'点位成功率(合计):平台粒度——各平台(美团/淘宝闪购/京东)成功点位数之和 ÷ 勾选平台点位数之和;空勾选按全领三档计。注意与「数据大盘」券粒度的点位成功率口径不同。';
|
'点位成功率(合计):当前区间内全部券成功率的算术平均(每张券等权);' +
|
||||||
|
'单券成功率=成功(含已领)÷尝试(不含跳过),源自领券每券记录。';
|
||||||
|
|
||||||
// 按券表:coupon_id 平台 → 中文
|
// 按券表:coupon_id 平台 → 中文
|
||||||
const SLOT_PLATFORM: Record<string, string> = {
|
const SLOT_PLATFORM: Record<string, string> = {
|
||||||
@@ -717,7 +727,7 @@ export default function CouponDataPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
value={fmtPct(summary.point_success_rate)}
|
value={fmtPct(slotRateMean(couponSlots))}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -725,7 +735,8 @@ export default function CouponDataPage() {
|
|||||||
<Row gutter={[16, 12]}>
|
<Row gutter={[16, 12]}>
|
||||||
{(['meituan-waimai', 'taobao-shanguang', 'jd-waimai'] as const).map((pid) => {
|
{(['meituan-waimai', 'taobao-shanguang', 'jd-waimai'] as const).map((pid) => {
|
||||||
const active = selectedSlotPlatform === pid;
|
const active = selectedSlotPlatform === pid;
|
||||||
const rate = summary.per_platform?.[pid];
|
// 点位成功率 = 该平台各券成功率的算术平均(源自按券明细 couponSlots,与下方展开的按券表一致)
|
||||||
|
const rate = slotRateMean(couponSlots.filter((r) => r.platform === pid));
|
||||||
// 平台名(前置品牌色圆点) | 大数字成功率 | 「查看明细」按钮;仅按钮可展开表格。
|
// 平台名(前置品牌色圆点) | 大数字成功率 | 「查看明细」按钮;仅按钮可展开表格。
|
||||||
return (
|
return (
|
||||||
<Col flex="1 1 0" key={pid}>
|
<Col flex="1 1 0" key={pid}>
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ export interface ComparisonRecordListItem {
|
|||||||
retry_count: number | null;
|
retry_count: number | null;
|
||||||
input_tokens: number | null;
|
input_tokens: number | null;
|
||||||
output_tokens: number | null;
|
output_tokens: number | null;
|
||||||
|
llm_cost_yuan: number | null; // 后端按「当时价」冻结的实际成本(元);旧记录 null → 「成本」列回退估算
|
||||||
device_model: string | null;
|
device_model: string | null;
|
||||||
rom_vendor: string | null;
|
rom_vendor: string | null;
|
||||||
rom_name: string | null;
|
rom_name: string | null;
|
||||||
@@ -396,6 +397,8 @@ export interface ComparisonRecordDetail extends ComparisonRecordListItem {
|
|||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
llm_calls: LlmCall[] | null;
|
llm_calls: LlmCall[] | null;
|
||||||
raw_payload: Record<string, unknown> | null;
|
raw_payload: Record<string, unknown> | null;
|
||||||
|
// 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。
|
||||||
|
llm_price_snapshot: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdRevenueImpression {
|
export interface AdRevenueImpression {
|
||||||
|
|||||||
Reference in New Issue
Block a user