Compare commits

..

8 Commits

Author SHA1 Message Date
guke 327b043b89 feat(comparison-records): 比价记录展示 LLM 实际成本(列表列 + 详情 + 价格快照) (#48)
调整LLM价格快照展示样式

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #48
2026-07-14 14:38:38 +08:00
guke c1854da671 feat(comparison-records): 比价记录展示 LLM 实际成本(列表列 + 详情 + 价格快照) (#47)
- 列表「成本」列优先显示后端冻结的实际成本(当时价/绿色),无则回退前端估算(灰色)
- 详情抽屉「LLM 成本」:有 llm_cost_yuan 显示实际值 +「实际·当时价」标,无则回退估算;
  另展示所用单价快照 JSON
- types.ts:ComparisonRecordListItem 加 llm_cost_yuan(详情继承)
- 顺带:InputNumber addonAfter → suffix(消 antd 弃用警告);详情 llm_calls 遍历对
  input_messages 加空值防御(残缺数据不再整页白屏)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #47
2026-07-13 17:35:17 +08:00
guke 7269e27e24 feat(coupon-data): 点位成功率改为按券成功率的算术平均(前端从按券明细上卷) (#46)
点位成功率改为按券成功率的算术平均(前端从按券明细上卷)

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #46
2026-07-11 18:48:54 +08:00
guke e3ab28addc feat(admin-web): 领券数据/比价记录看板新增「广告收益」列 (#45)
## 背景
后端两个看板接口新增逐行 `ad_revenue_yuan`(元),前端补上展示列。

## 改动
- `src/lib/types.ts`:`ComparisonRecordListItem` 增 `ad_revenue_yuan: number`
  (`ComparisonRecordDetail` 自动继承)。
- `comparison-records/page.tsx`:「省」列后新增「广告收益」列(>0 显示绿色 ¥x.xxxx,
  否则 `-`);表格 `scroll.x` 1820 → 1920。
- `coupon-data/page.tsx`:`CouponDataRow` 增字段;新增 `fmtYuan` 格式化(¥ 保留 4 位、
  空/≤0 显示 `-`);主表「耗时」列后新增「广告收益」列(不动用户抽屉 RECORD_COLUMNS);
  `scroll.x` 1450 → 1560。

## 说明
- 后端未更新时该字段为 undefined → 显示 `-`,无害。
- 单次广告收益很小(如 ¥0.0034),故 4 位小数。
- 领券看板默认按会话 app_env=`prod` 筛;真机测试数据在 `dev`,需切换环境才可见。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #45
2026-07-10 22:14:14 +08:00
guke d5acf3d1e1 feat(coupon-data): 点位成功率指标卡改版(品牌色圆点+环比+查看明细下钻) (#44)
feat(coupon-data): 点位成功率指标卡改版(品牌色圆点+环比+查看明细下钻)
- 三平台点位成功率卡:平台名前品牌色小圆点 + 大数字 +「查看明细」按钮
- 仅「查看明细」按钮触发下钻(原整卡整块可点),下钻表改为卡内浅灰分层区域
- 新增环比:并行拉上一等长周期汇总,按百分点(pt)展示涨跌;无上期/失败显示「暂无上期」(对齐数据大盘 pointDelta 口径)

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #44
2026-07-10 11:19:15 +08:00
guke 26e468766b 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
2026-07-09 17:31:53 +08:00
guke 03d1003c3c feat(analytics-health): 新增埋点/上报成功率看板(super_admin) (#42)
功能明细
总览卡:埋点成功率、上报成功率两大率居前(<90% 标红并打「偏低」标);attempted / drop_capture / delivered / drop_undelivered 四原子量次之(drop_* > 0 橙色提示),每项带 tooltip 口径说明。
按天双折线趋势:埋点率(蓝)/ 上报率(橙),Y 轴锁 [0,1] 显示百分比。
维度下钻表:event / app_ver / oem 三档切换,后端已按上报率升序返回(最差在前),率 <90% 标红。
顶部「刷新」+「数据更新于」时间戳;日期区间支持 今天 / 近 7 天 / 近 30 天 预设。

上线依赖(部署顺序)
页面依赖后端 feat/analytics-success-rate 分支的三个只读端点 /admin/api/analytics-health/{overview,trend,breakdown}。后端须先合并部署,否则前端会 404。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #42
2026-07-09 17:31:19 +08:00
liujiahui 557348bd6b 权限管理新增人员支持「自定义」角色(前端) (#41)
角色下拉末尾加「自定义」,选中时「可见页面」从只读矩阵切成可勾选(复用 PermEditor),提交带 pages_override;
编辑自定义用户回填已勾选页;列表里 custom 显示「自定义」,列表内切到自定义转去编辑页勾选。
AdminInfo 加 pages_override 字段。npm run build 通过。

配套后端 PR:shaguabijia-app-server#admin-custom-perms。

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #41
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-09 01:11:29 +08:00
7 changed files with 776 additions and 20 deletions
@@ -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` 字段。
+350
View File
@@ -0,0 +1,350 @@
'use client';
// 埋点 / 上报成功率大盘:度量客户端埋点数据可信度,两段漏斗两个大率。
// 数据源 analytics_selfstat 快照差分(与埋点日志表无关);三个只读端点在 admin 服务(8771)。
// 时区:端点吃 UTC 时刻(见 toUtcRange);trend.day 已是北京日,直接展示不再换算。
import { useCallback, useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Button,
Card,
Col,
DatePicker,
Empty,
Row,
Segmented,
Space,
Statistic,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { api, errMsg } from '@/lib/api';
import type { HealthBreakdownRow, HealthMetrics, HealthTrendPoint } from '@/lib/types';
dayjs.extend(utc);
dayjs.extend(timezone);
const CN = 'Asia/Shanghai';
// @ant-design/plots 依赖 DOM,关掉 SSR 仅客户端渲染(否则 Next 预渲染报 document undefined)。
// 用仓内 v2 写法(colorField/scale/axis/interaction),非指南里的 v1(seriesField/yAxis)。
const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false });
const { RangePicker } = DatePicker;
type Dim = 'event' | 'app_ver' | 'oem';
const DIM_LABEL: Record<Dim, string> = { event: '事件', app_ver: '版本', oem: 'ROM' };
const LOW = 0.9; // 成功率低于此阈值标红(定位掉数据最狠的点位/版本/ROM)
// 北京日范围 → UTC ISO 瞬时(右开区间;date_to = 结束日 +1 天的北京零点)。
// 端点按 UTC 时刻比较 created_at,直接传 YYYY-MM-DD 会被当 UTC 零点,与北京日差 8 小时。
function toUtcRange(start: Dayjs, end: Dayjs) {
return {
date_from: dayjs.tz(start.format('YYYY-MM-DD'), CN).utc().toISOString(),
date_to: dayjs.tz(end.add(1, 'day').format('YYYY-MM-DD'), CN).utc().toISOString(),
};
}
// 率:null → "-"(分母为 0 ≠ 0%);否则百分比一位小数。
const pct = (v: number | null | undefined) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`);
const int = (v: number | null | undefined) =>
v == null ? '-' : new Intl.NumberFormat('zh-CN').format(v);
// 四原子计数说明(总览卡标题 + 下钻表头 tooltip 共用,一处改两处同步)。
const METRIC_TIP = {
attempted: '端上 track() 被调用的次数,是埋点段分母。埋点成功率 =(attempted drop_capture)/ attempted。',
drop_capture: '采集环节因异常(队列满 / 序列化失败等)被丢弃、未能落地的事件数,计入埋点失败。',
delivered: '成功送达并入库后端的事件数。上报成功率 = delivered /(delivered + drop_undelivered)。',
drop_undelivered:
'已落盘但久未送达、被队列淘汰的事件数(弱网 / 被 ROM 杀后台等);在途未送达的不计入。',
} as const;
// 标题/表头后跟一个说明图标(hover 出 tooltip)。
const withTip = (label: string, tip: string) => (
<Space size={4}>
{label}
<Tooltip title={tip}>
<InfoCircleOutlined style={{ color: '#bfbfbf', cursor: 'help' }} />
</Tooltip>
</Space>
);
// 大率卡:率偏低标红 + 「偏低」标。
function RateStat({
title,
value,
loading,
}: {
title: string;
value: number | null | undefined;
loading: boolean;
}) {
const low = value != null && value < LOW;
return (
<Card loading={loading}>
<Statistic
title={
<Space size={6}>
{title}
{low && <Tag color="error"></Tag>}
</Space>
}
value={value == null ? '-' : (value * 100).toFixed(1)}
suffix={value == null ? undefined : '%'}
valueStyle={low ? { color: '#cf1322' } : undefined}
/>
</Card>
);
}
// 原子量卡(attempted / drop_capture / delivered / drop_undelivered)。
function CountStat({
title,
value,
loading,
warn,
tip,
}: {
title: string;
value: number | null | undefined;
loading: boolean;
warn?: boolean; // drop_* > 0 时橙色提示
tip?: string;
}) {
const bad = warn && value != null && value > 0;
return (
<Card loading={loading}>
<Statistic
title={tip ? withTip(title, tip) : title}
value={value == null ? '-' : value}
valueStyle={bad ? { color: '#d46b08' } : undefined}
/>
</Card>
);
}
export default function AnalyticsHealthPage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([
dayjs().tz(CN).subtract(6, 'day'),
dayjs().tz(CN),
]);
const [dim, setDim] = useState<Dim>('event');
const [overview, setOverview] = useState<HealthMetrics | null>(null);
const [trend, setTrend] = useState<HealthTrendPoint[]>([]);
const [rows, setRows] = useState<HealthBreakdownRow[]>([]);
const [loading, setLoading] = useState(false); // 总览 + 趋势
const [rowsLoading, setRowsLoading] = useState(false); // 下钻表
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
// 总览 + 趋势:只随区间变。
const loadMain = useCallback(async () => {
const params = toUtcRange(range[0], range[1]);
setLoading(true);
try {
const [ov, tr] = await Promise.all([
api.get<HealthMetrics>('/admin/api/analytics-health/overview', { params }),
api.get<HealthTrendPoint[]>('/admin/api/analytics-health/trend', { params }),
]);
setOverview(ov.data);
setTrend(tr.data);
setUpdatedAt(dayjs().tz(CN).format('YYYY-MM-DD HH:mm'));
} catch (e) {
message.error(errMsg(e, '加载成功率数据失败'));
} finally {
setLoading(false);
}
}, [range, message]);
// 下钻表:随区间或维度变(切 dim 只重拉表,不动总览/趋势)。
const loadRows = useCallback(async () => {
const params = toUtcRange(range[0], range[1]);
setRowsLoading(true);
try {
const bd = await api.get<HealthBreakdownRow[]>('/admin/api/analytics-health/breakdown', {
params: { ...params, dim },
});
setRows(bd.data);
} catch (e) {
message.error(errMsg(e, '加载下钻数据失败'));
} finally {
setRowsLoading(false);
}
}, [range, dim, message]);
useEffect(() => {
void loadMain();
}, [loadMain]);
useEffect(() => {
void loadRows();
}, [loadRows]);
const refresh = () => {
void loadMain();
void loadRows();
};
// 折线长表:每天 ×{埋点率, 上报率}。保留 rate=null 的点(不 filter)→ G2 在断点处断线,
// 真正实现「null 点跳过不连线」;若 filter 掉 null 会把两侧点连成一条误导直线。
const trendLong = useMemo(
() =>
trend.flatMap((p) => [
{ day: p.day, metric: '埋点成功率', rate: p.track_success_rate },
{ day: p.day, metric: '上报成功率', rate: p.report_success_rate },
]),
[trend],
);
const chartConfig = {
data: trendLong,
xField: 'day',
yField: 'rate',
colorField: 'metric',
height: 300,
scale: {
y: { domainMin: 0, domainMax: 1, nice: false },
color: { range: ['#1677ff', '#fa8c16'] }, // 埋点率蓝 / 上报率橙(按长表首次出现序)
},
axis: { y: { labelFormatter: (v: number) => `${(v * 100).toFixed(0)}%` } },
point: { style: { r: 3 } },
legend: { color: { position: 'top' as const } },
tooltip: { items: [{ channel: 'y' as const, valueFormatter: (v: number) => pct(v) }] },
interaction: { tooltip: { shared: true } },
};
const columns: ColumnsType<HealthBreakdownRow> = [
{ title: DIM_LABEL[dim], dataIndex: 'key', key: 'key', render: (v: string) => v || '(unknown)' },
{
title: '埋点成功率',
key: 'track',
width: 110,
align: 'right',
render: (_: unknown, r: HealthBreakdownRow) => (
<span style={r.track_success_rate != null && r.track_success_rate < LOW ? { color: '#cf1322', fontWeight: 600 } : undefined}>
{pct(r.track_success_rate)}
</span>
),
},
{
title: '上报成功率',
key: 'report',
width: 110,
align: 'right',
render: (_: unknown, r: HealthBreakdownRow) => (
<span style={r.report_success_rate != null && r.report_success_rate < LOW ? { color: '#cf1322', fontWeight: 600 } : undefined}>
{pct(r.report_success_rate)}
</span>
),
},
{ title: withTip('尝试总数', METRIC_TIP.attempted), dataIndex: 'attempted', key: 'attempted', width: 120, align: 'right', render: (v: number) => int(v) },
{ title: withTip('采集丢弃', METRIC_TIP.drop_capture), dataIndex: 'drop_capture', key: 'drop_capture', width: 128, align: 'right', render: (v: number) => int(v) },
{ title: withTip('送达', METRIC_TIP.delivered), dataIndex: 'delivered', key: 'delivered', width: 108, align: 'right', render: (v: number) => int(v) },
{ title: withTip('淘汰未送达', METRIC_TIP.drop_undelivered), dataIndex: 'drop_undelivered', key: 'drop_undelivered', width: 150, align: 'right', render: (v: number) => int(v) },
];
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
<Space align="center">
<h2 style={{ margin: 0 }}></h2>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
/;,
</Typography.Text>
</Space>
<Space align="center">
{updatedAt && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{updatedAt}
</Typography.Text>
)}
<Button icon={<ReloadOutlined />} onClick={refresh} loading={loading || rowsLoading}>
</Button>
</Space>
</div>
<Card size="small" style={{ marginBottom: 16 }}>
<Space size={6} align="center">
<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().tz(CN), dayjs().tz(CN)] },
{ label: '近 7 天', value: [dayjs().tz(CN).subtract(6, 'day'), dayjs().tz(CN)] },
{ label: '近 30 天', value: [dayjs().tz(CN).subtract(29, 'day'), dayjs().tz(CN)] },
]}
/>
</Space>
</Card>
{/* 总览:两大率居前,四原子量次之 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12}>
<RateStat title="埋点成功率" value={overview?.track_success_rate} loading={loading} />
</Col>
<Col xs={24} sm={12}>
<RateStat title="上报成功率" value={overview?.report_success_rate} loading={loading} />
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<CountStat title="尝试总数 attempted" value={overview?.attempted} loading={loading} tip={METRIC_TIP.attempted} />
</Col>
<Col xs={12} sm={6}>
<CountStat title="采集丢弃 drop_capture" value={overview?.drop_capture} loading={loading} warn tip={METRIC_TIP.drop_capture} />
</Col>
<Col xs={12} sm={6}>
<CountStat title="送达 delivered" value={overview?.delivered} loading={loading} tip={METRIC_TIP.delivered} />
</Col>
<Col xs={12} sm={6}>
<CountStat title="淘汰未送达 drop_undelivered" value={overview?.drop_undelivered} loading={loading} warn tip={METRIC_TIP.drop_undelivered} />
</Col>
</Row>
{/* 趋势:两条率折线,Y 轴锁 [0,1] 显示百分比,null 断线 */}
<Card title="按天趋势(北京时间)" size="small" style={{ marginBottom: 16 }}>
{trendLong.length > 0 ? (
<Line {...chartConfig} />
) : (
<Empty style={{ padding: '60px 0' }} description={loading ? '加载中…' : '该区间暂无埋点数据'} />
)}
</Card>
{/* 下钻:后端已按上报率升序(最差在前),率 < 90% 标红 */}
<Card
title="维度下钻(按上报成功率升序,最差在前)"
size="small"
extra={
<Segmented
value={dim}
onChange={(v) => setDim(v as Dim)}
options={[
{ label: '按事件', value: 'event' },
{ label: '按版本', value: 'app_ver' },
{ label: '按 ROM', value: 'oem' },
]}
/>
}
>
<Table<HealthBreakdownRow>
rowKey="key"
dataSource={rows}
columns={columns}
loading={rowsLoading}
pagination={false}
size="small"
scroll={{ x: 820 }}
locale={{ emptyText: rowsLoading ? '加载中…' : '该区间暂无数据' }}
/>
</Card>
</div>
);
}
+54 -9
View File
@@ -38,6 +38,15 @@ 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 '';
@@ -184,6 +193,18 @@ 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 ?? '-' },
@@ -203,7 +224,16 @@ export default function ComparisonRecordsPage() {
title: '成本',
key: 'cost',
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',
@@ -280,7 +310,7 @@ export default function ComparisonRecordsPage() {
min={0}
step={0.1}
style={{ width: 210 }}
addonAfter="元/百万token"
suffix="元/百万token"
/>
</Space>
@@ -297,7 +327,7 @@ export default function ComparisonRecordsPage() {
showTotal: (t) => `${t}`,
onChange,
}}
scroll={{ x: 1820 }}
scroll={{ x: 1920 }}
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
/>
@@ -319,12 +349,27 @@ 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="估算成本">
{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 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>
{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>
@@ -420,7 +465,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>
+3 -3
View File
@@ -11,7 +11,7 @@ interface ConfigItem {
key: string;
label: 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;
// 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') return JSON.stringify(item.value);
if (item.type === 'dict_str_int' || item.type === 'json') 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') return JSON.parse(raw);
if (type === 'dict_str_int' || type === 'json') return JSON.parse(raw);
return raw;
}
+238 -8
View File
@@ -16,8 +16,10 @@ 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';
@@ -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<string, number | null>; // {平台id: rate|null};恒含美团/淘宝/京东三档
}
interface CouponDataDaily {
date: string;
@@ -64,6 +73,7 @@ interface CouponDataRow {
started_at: string;
claimed_count: number | null;
trace_url: string | null;
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
}
interface CouponDataReport {
date_from: string;
@@ -74,6 +84,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 +120,45 @@ 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) },
@@ -286,6 +349,8 @@ 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);
@@ -294,6 +359,9 @@ 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);
@@ -318,13 +386,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<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);
@@ -335,7 +413,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 +471,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',
@@ -400,6 +484,13 @@ 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',
@@ -430,12 +521,6 @@ 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',
@@ -454,6 +539,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 items = data?.items ?? [];
@@ -505,6 +615,18 @@ 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
@@ -580,6 +702,114 @@ 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>
)}
@@ -656,7 +886,7 @@ export default function CouponDataPage() {
onChange: (p) => load(p, queriedLimit),
}}
size="small"
scroll={{ x: 1450 }}
scroll={{ x: 1560 }}
/>
<UserRecordsDrawer<CouponDataRow>
+2
View File
@@ -10,6 +10,7 @@ import {
FlagOutlined,
GiftOutlined,
HeartOutlined,
LineChartOutlined,
LogoutOutlined,
MessageOutlined,
MoneyCollectOutlined,
@@ -57,6 +58,7 @@ const NAV_GROUPS: NavGroup[] = [
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
{ key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' },
],
},
{
+28
View File
@@ -354,11 +354,13 @@ 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;
}
@@ -395,6 +397,8 @@ 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 {
@@ -727,3 +731,27 @@ export interface CpsStats {
total_est_commission_cents: number;
total_settled_commission_cents: number;
}
// ===== 埋点 / 上报成功率大盘(analytics_selfstat 快照差分聚合)=====
// 后端 feat/analytics-success-rate 分支的三个只读端点(/admin/api/analytics-health/*)。
// rate 分母为 0 时后端给 null(前端显示 "-",不是 0%)。
/** 一组区间聚合指标:两大率 + 四原子量。 */
export interface HealthMetrics {
attempted: number; // track 被调用次数(埋点段分母)
drop_capture: number; // 采集时异常丢弃(队列/序列化失败)
delivered: number; // 成功送达后端
drop_undelivered: number; // 已落盘但久未送达被队列淘汰
track_success_rate: number | null; // 埋点成功率 ∈ [0,1]:(attempted drop_capture) / attempted
report_success_rate: number | null; // 上报成功率 ∈ [0,1]:delivered / (delivered + drop_undelivered)
}
/** 趋势点:某北京天的指标(trend 端点,按北京天升序)。 */
export interface HealthTrendPoint extends HealthMetrics {
day: string; // 北京日期 "YYYY-MM-DD"
}
/** 下钻行:某维度值的指标(breakdown 端点,已按上报率升序,最差在前)。 */
export interface HealthBreakdownRow extends HealthMetrics {
key: string; // 维度值(event 名 / app_ver / oem);缺失为 "(unknown)"
}