Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ab623d6aa | |||
| 327b043b89 | |||
| c1854da671 | |||
| 7269e27e24 | |||
| e3ab28addc | |||
| d5acf3d1e1 | |||
| 26e468766b | |||
| 03d1003c3c | |||
| 557348bd6b | |||
| bc677817e4 | |||
| 33c80d6927 |
@@ -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` 字段。
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
App,
|
||||
@@ -46,11 +46,11 @@ const { RangePicker } = DatePicker;
|
||||
|
||||
// 广告类型标签
|
||||
const TYPE_TAG: Record<string, { color: string; label: string }> = {
|
||||
reward_video: { color: 'blue', label: '激励视频' },
|
||||
reward_video: { color: 'blue', label: '看视频' },
|
||||
// 历史误标 feed(领券/比价广告修 adType 之前上报)一律按 Draw 信息流显示——业务已全切 Draw
|
||||
feed: { color: 'geekblue', label: 'Draw 信息流' },
|
||||
draw: { color: 'geekblue', label: 'Draw 信息流' },
|
||||
withdrawal_video: { color: 'gold', label: '提现激励视频' },
|
||||
withdrawal_video: { color: 'gold', label: '提现看视频' },
|
||||
};
|
||||
|
||||
// Draw 信息流投放场景标签(后端 AdRevenueRow.feed_scene:comparison/coupon/welfare)
|
||||
@@ -74,6 +74,13 @@ const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
||||
too_short: { color: 'gold', label: '未满10秒' },
|
||||
closed_early: { color: 'default', label: '提前关闭' },
|
||||
};
|
||||
const STATUS_HINT: Record<string, string> = {
|
||||
granted: '已满足当前客户端发奖条件并完成金币发放。',
|
||||
too_short: '旧版 Draw 信息流观看不足 10 秒,不发金币;新版按观看比例发放时会记为已发。',
|
||||
closed_early: '用户在达到发奖条件前主动关闭,不发金币。',
|
||||
capped: '已达到次数上限,不再发金币。',
|
||||
ecpm_missing: '缺少有效 eCPM,无法计算金币。',
|
||||
};
|
||||
|
||||
const fmtFactorRange = (a: number | null, b: number | null) => {
|
||||
if (a == null) return '-';
|
||||
@@ -84,6 +91,14 @@ const fmtIndexRange = (a: number | null, b: number | null) => {
|
||||
if (a == null) return '-';
|
||||
return a === b ? String(a) : `${a}–${b}`;
|
||||
};
|
||||
const percentile = (values: number[], q: number) => {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const idx = (sorted.length - 1) * q;
|
||||
const lo = Math.floor(idx);
|
||||
const hi = Math.min(lo + 1, sorted.length - 1);
|
||||
return sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo);
|
||||
};
|
||||
|
||||
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
|
||||
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
|
||||
@@ -104,35 +119,37 @@ const LT_FACTOR_ROWS = [
|
||||
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
|
||||
];
|
||||
|
||||
type AdRevenueDetailRow = AdRevenueRecord & { source_adn?: string | null };
|
||||
|
||||
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
|
||||
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
|
||||
const DETAIL_COLUMNS: ColumnsType<AdRevenueDetailRow> = [
|
||||
{ title: '来源广告网络', dataIndex: 'source_adn', width: 110, render: (value: string | null) => value || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (s: string) => {
|
||||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
|
||||
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
|
||||
{ title: '份数', dataIndex: 'units', width: 60 },
|
||||
{
|
||||
title: 'LT累计条数',
|
||||
key: 'lt_index',
|
||||
title: 'eCPM(元)',
|
||||
dataIndex: 'ecpm',
|
||||
width: 100,
|
||||
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
||||
render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100).toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: '单次 eCPM(元)',
|
||||
dataIndex: 'ecpm',
|
||||
width: 120,
|
||||
render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100 / 1000).toFixed(4)),
|
||||
},
|
||||
{ title: '实发金币数', dataIndex: 'actual_coin', width: 100 },
|
||||
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
|
||||
{
|
||||
title: '因子2',
|
||||
key: 'lt_factor',
|
||||
width: 90,
|
||||
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
|
||||
render: (_: unknown, r: AdRevenueDetailRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
|
||||
},
|
||||
{
|
||||
title: 'LT累计条数',
|
||||
key: 'lt_index',
|
||||
width: 100,
|
||||
render: (_: unknown, r: AdRevenueDetailRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
||||
},
|
||||
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
|
||||
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
|
||||
];
|
||||
|
||||
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴),
|
||||
@@ -290,19 +307,20 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
// 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。
|
||||
export default function AdRevenueReportPage() {
|
||||
const { message } = App.useApp();
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')]);
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [adType, setAdType] = useState<string | undefined>();
|
||||
// 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。
|
||||
const [scene, setScene] = useState<string | undefined>();
|
||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||
const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序
|
||||
const [limit, setLimit] = useState<number>(500); // 每页条数(分页大小)
|
||||
const [limit, setLimit] = useState<number>(1000); // 默认拉满接口单页上限,供单次流程广告数分位统计
|
||||
const [page, setPage] = useState<number>(1); // 当前页码(后端分页;1 起)
|
||||
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
|
||||
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
|
||||
const [data, setData] = useState<AdRevenueReport | null>(null);
|
||||
const [queriedLimit, setQueriedLimit] = useState<number>(500);
|
||||
const [allFlowItems, setAllFlowItems] = useState<AdRevenueRow[]>([]);
|
||||
const [queriedLimit, setQueriedLimit] = useState<number>(1000);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formulaOpen, setFormulaOpen] = useState(false);
|
||||
// 点用户手机号弹出的「用户广告收益详情」半屏抽屉(userId + 手机号;null=关闭)
|
||||
@@ -320,17 +338,20 @@ export default function AdRevenueReportPage() {
|
||||
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
|
||||
setLoading(true);
|
||||
try {
|
||||
const baseParams = {
|
||||
date_from: from,
|
||||
date_to: to,
|
||||
user_id: userId ?? undefined,
|
||||
ad_type: adType ?? undefined,
|
||||
feed_scene: scene ?? undefined,
|
||||
granularity: gran,
|
||||
sort: targetSort,
|
||||
};
|
||||
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
||||
params: {
|
||||
date_from: from,
|
||||
date_to: to,
|
||||
user_id: userId ?? undefined,
|
||||
ad_type: adType ?? undefined,
|
||||
feed_scene: scene ?? undefined,
|
||||
granularity: gran,
|
||||
...baseParams,
|
||||
limit: targetLimit,
|
||||
offset: (targetPage - 1) * targetLimit,
|
||||
sort: targetSort,
|
||||
},
|
||||
});
|
||||
setData(res.data);
|
||||
@@ -338,6 +359,24 @@ export default function AdRevenueReportPage() {
|
||||
setQueriedLimit(targetLimit);
|
||||
setQueriedGranularity(gran);
|
||||
setQueriedMultiDay(multiDay);
|
||||
|
||||
// 单次流程广告数分位必须覆盖完整查询结果,不能只统计当前表格分页。
|
||||
let firstOverviewPage = res.data;
|
||||
if (targetPage !== 1 || targetLimit !== 1000) {
|
||||
const overviewRes = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
||||
params: { ...baseParams, limit: 1000, offset: 0 },
|
||||
});
|
||||
firstOverviewPage = overviewRes.data;
|
||||
}
|
||||
const flowItems = [...firstOverviewPage.items];
|
||||
while (flowItems.length < firstOverviewPage.total) {
|
||||
const nextRes = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
||||
params: { ...baseParams, limit: 1000, offset: flowItems.length },
|
||||
});
|
||||
if (nextRes.data.items.length === 0) break;
|
||||
flowItems.push(...nextRes.data.items);
|
||||
}
|
||||
setAllFlowItems(flowItems);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
@@ -349,11 +388,11 @@ export default function AdRevenueReportPage() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// 仅首次自动拉今天;之后由「查询」按钮触发
|
||||
// 仅首次自动拉昨日;之后由「查询」按钮触发
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<AdRevenueRow> = [
|
||||
const rawColumns: ColumnsType<AdRevenueRow> = [
|
||||
...(queriedMultiDay
|
||||
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
|
||||
: []),
|
||||
@@ -401,7 +440,10 @@ export default function AdRevenueReportPage() {
|
||||
title: '场景',
|
||||
dataIndex: 'feed_scene',
|
||||
width: 80,
|
||||
render: (v: string | null | undefined) => {
|
||||
render: (v: string | null | undefined, row: AdRevenueRow) => {
|
||||
if (!v && (row.ad_type === 'reward_video' || row.ad_type === 'withdrawal_video')) {
|
||||
return <Tag color="blue">看视频</Tag>;
|
||||
}
|
||||
if (!v) return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
const t = SCENE_TAG[v];
|
||||
return t ? <Tag color={t.color}>{t.text}</Tag> : <Tag>{v}</Tag>;
|
||||
@@ -436,14 +478,14 @@ export default function AdRevenueReportPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '预估收益(元)',
|
||||
title: '预估收益',
|
||||
dataIndex: 'revenue_yuan',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
// 一次比价/领券聚合行用 row_revenue_yuan(该次发奖广告 eCPM 折算之和),其它行用展示侧 revenue_yuan
|
||||
render: (v: number, r: AdRevenueRow) => {
|
||||
const rev = r.row_revenue_yuan ?? v;
|
||||
return r.has_impression || rev > 0 ? rev.toFixed(4) : '-';
|
||||
return rev.toFixed(4);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -451,9 +493,9 @@ export default function AdRevenueReportPage() {
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string | null) => {
|
||||
if (!s) return <Tag>仅展示</Tag>;
|
||||
if (!s) return <Tooltip title="仅记录广告展示,没有对应发奖事件。"><Tag>仅展示</Tag></Tooltip>;
|
||||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
return <Tooltip title={STATUS_HINT[s]}><Tag color={t.color}>{t.label}</Tag></Tooltip>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -473,7 +515,7 @@ export default function AdRevenueReportPage() {
|
||||
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '广告位ID',
|
||||
title: '广告位',
|
||||
dataIndex: 'our_code_id',
|
||||
width: 110,
|
||||
render: (v: string | null) =>
|
||||
@@ -487,6 +529,26 @@ export default function AdRevenueReportPage() {
|
||||
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
];
|
||||
const columnOrder = [
|
||||
'report_date',
|
||||
'created_at',
|
||||
'user_phone',
|
||||
'feed_scene',
|
||||
'ecpm_yuan',
|
||||
'revenue_yuan',
|
||||
'actual_coin',
|
||||
'status',
|
||||
'ad_type',
|
||||
'app_env',
|
||||
'our_code_id',
|
||||
];
|
||||
const columns = columnOrder.flatMap((identity) => {
|
||||
const found = rawColumns.find((column) => {
|
||||
const dataIndex = 'dataIndex' in column ? column.dataIndex : undefined;
|
||||
return String(column.key ?? dataIndex ?? '') === identity;
|
||||
});
|
||||
return found ? [found] : [];
|
||||
}) as ColumnsType<AdRevenueRow>;
|
||||
|
||||
// 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
|
||||
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
|
||||
@@ -525,11 +587,24 @@ export default function AdRevenueReportPage() {
|
||||
|
||||
// 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。
|
||||
const items = data?.items ?? [];
|
||||
const sessionAdCounts = useMemo(() => {
|
||||
const summarize = (sceneName: 'coupon' | 'comparison') => {
|
||||
const counts = allFlowItems
|
||||
.filter((item) => item.feed_scene === sceneName)
|
||||
.map((item) => item.sub_count ?? 1);
|
||||
return {
|
||||
p5: percentile(counts, 0.05),
|
||||
p50: percentile(counts, 0.5),
|
||||
p95: percentile(counts, 0.95),
|
||||
};
|
||||
};
|
||||
return { coupon: summarize('coupon'), comparison: summarize('comparison') };
|
||||
}, [allFlowItems]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>收益报表</h2>
|
||||
<h2 style={{ margin: 0 }}>广告收益</h2>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -573,6 +648,7 @@ export default function AdRevenueReportPage() {
|
||||
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
|
||||
allowClear={false}
|
||||
presets={[
|
||||
{ label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
|
||||
{ label: '今天', value: [dayjs(), dayjs()] },
|
||||
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
|
||||
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
|
||||
@@ -597,9 +673,9 @@ export default function AdRevenueReportPage() {
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'reward_video', label: '激励视频' },
|
||||
{ value: 'reward_video', label: '看视频' },
|
||||
{ value: 'draw', label: 'Draw 信息流' },
|
||||
{ value: 'withdrawal_video', label: '提现激励视频' },
|
||||
{ value: 'withdrawal_video', label: '提现看视频' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
@@ -803,15 +879,28 @@ export default function AdRevenueReportPage() {
|
||||
<Statistic title="Draw 信息流条数" value={drawStat?.impressions ?? 0} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="激励视频收益(元)" value={rvStat?.revenue_yuan ?? 0} precision={4} />
|
||||
<Statistic title="看视频收益(元)" value={rvStat?.revenue_yuan ?? 0} precision={4} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="激励视频 eCPM(元/千次)" value={ecpmOf(rvStat)} precision={2} />
|
||||
<Statistic title="看视频 eCPM(元/千次)" value={ecpmOf(rvStat)} precision={2} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Statistic title="激励视频条数" value={rvStat?.impressions ?? 0} />
|
||||
<Statistic title="看视频条数" value={rvStat?.impressions ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
单次流程观看广告数
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col span={4}><Statistic title="单次领券广告数 P5" value={sessionAdCounts.coupon.p5 ?? '-'} precision={1} /></Col>
|
||||
<Col span={4}><Statistic title="单次领券广告数 P50" value={sessionAdCounts.coupon.p50 ?? '-'} precision={1} /></Col>
|
||||
<Col span={4}><Statistic title="单次领券广告数 P95" value={sessionAdCounts.coupon.p95 ?? '-'} precision={1} /></Col>
|
||||
<Col span={4}><Statistic title="单次比价广告数 P5" value={sessionAdCounts.comparison.p5 ?? '-'} precision={1} /></Col>
|
||||
<Col span={4}><Statistic title="单次比价广告数 P50" value={sessionAdCounts.comparison.p50 ?? '-'} precision={1} /></Col>
|
||||
<Col span={4}><Statistic title="单次比价广告数 P95" value={sessionAdCounts.comparison.p95 ?? '-'} precision={1} /></Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -901,11 +990,11 @@ export default function AdRevenueReportPage() {
|
||||
<Typography.Text type="secondary">
|
||||
(共 {r.sub_count ?? r.sub_rewards.length} 条 · 应发 {r.expected_coin} / 实发 {r.actual_coin})
|
||||
</Typography.Text>
|
||||
<Table<AdRevenueRecord>
|
||||
<Table<AdRevenueDetailRow>
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="record_id"
|
||||
columns={DETAIL_COLUMNS}
|
||||
dataSource={r.sub_rewards}
|
||||
dataSource={r.sub_rewards.map((detail) => ({ ...detail, source_adn: r.adn }))}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
@@ -917,11 +1006,11 @@ export default function AdRevenueReportPage() {
|
||||
<Typography.Text type="secondary">
|
||||
(本条广告 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin})
|
||||
</Typography.Text>
|
||||
<Table<AdRevenueRecord>
|
||||
<Table<AdRevenueDetailRow>
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="record_id"
|
||||
columns={DETAIL_COLUMNS}
|
||||
dataSource={[r.reward_detail]}
|
||||
dataSource={[{ ...r.reward_detail, source_adn: r.adn }]}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
|
||||
@@ -22,6 +22,10 @@ function genPassword(len = 10): string {
|
||||
|
||||
type View = 'list' | 'person' | 'roles' | 'roleForm';
|
||||
|
||||
// 「自定义」哨兵角色:不是共享角色,选它时可见页由本人逐页勾选(存 pages_override),与后端一致。
|
||||
const CUSTOM_ROLE = 'custom';
|
||||
const CUSTOM_LABEL = '自定义';
|
||||
|
||||
// ===== 只读权限矩阵:角色详情 / 人员「可见页面」预览共用(深色=有,灰色=无)=====
|
||||
function PermMatrix({ catalog, pages }: { catalog: PermissionGroup[]; pages: string[] }) {
|
||||
const has = useMemo(() => new Set(pages), [pages]);
|
||||
@@ -109,6 +113,11 @@ export default function AdminsPage() {
|
||||
return m;
|
||||
}, [roles]);
|
||||
const roleOptions = roles.map((r) => ({ value: r.name, label: r.label }));
|
||||
// 人员表单角色下拉:真实角色 + 末尾「自定义」。选「自定义」→ 下方可见页改为逐页勾选。
|
||||
const personRoleOptions = [...roleOptions, { value: CUSTOM_ROLE, label: CUSTOM_LABEL }];
|
||||
// 角色 key → 展示名(custom 显示「自定义」;查不到回退 key 原文)
|
||||
const roleLabelOf = (name: string) =>
|
||||
(name === CUSTOM_ROLE ? CUSTOM_LABEL : roleByName[name]?.label ?? name);
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
@@ -134,11 +143,14 @@ export default function AdminsPage() {
|
||||
const [personName, setPersonName] = useState('');
|
||||
const [personRole, setPersonRole] = useState('');
|
||||
const [initPw, setInitPw] = useState('');
|
||||
// 「自定义」角色时逐页勾选的可见页集(role==custom 才用到)
|
||||
const [personPages, setPersonPages] = useState<string[]>([]);
|
||||
|
||||
const openCreatePerson = () => {
|
||||
setPersonEditing(null);
|
||||
setPersonName('');
|
||||
setPersonRole(roles.find((r) => !r.is_builtin)?.name ?? roles[0]?.name ?? 'operator');
|
||||
setPersonPages([]);
|
||||
setInitPw(genPassword());
|
||||
setView('person');
|
||||
};
|
||||
@@ -146,6 +158,8 @@ export default function AdminsPage() {
|
||||
setPersonEditing(a);
|
||||
setPersonName(a.username);
|
||||
setPersonRole(a.role);
|
||||
// 编辑「自定义」用户时,回填其已勾选的可见页(供继续增删)
|
||||
setPersonPages(a.role === CUSTOM_ROLE ? (a.pages_override ?? []) : []);
|
||||
setInitPw(''); // 编辑态密码只读展示 a.password(已确定的登录密码),不用 initPw
|
||||
setView('person');
|
||||
};
|
||||
@@ -160,18 +174,30 @@ export default function AdminsPage() {
|
||||
<Button onClick={() => copyText(value)}>复制</Button>
|
||||
</div>
|
||||
);
|
||||
const isCustom = personRole === CUSTOM_ROLE;
|
||||
const submitPerson = async () => {
|
||||
if (isCustom && personPages.length === 0) { message.error('自定义角色请至少勾选一个可见页面'); return; }
|
||||
try {
|
||||
if (personEditing) {
|
||||
// 密码只读、不改;只提交变更的角色
|
||||
if (personRole !== personEditing.role) {
|
||||
await api.patch(`/admin/api/admins/${personEditing.id}`, { role: personRole });
|
||||
// 密码只读、不改;提交变更的角色 +(自定义时)勾选的可见页
|
||||
const roleChanged = personRole !== personEditing.role;
|
||||
const pagesChanged = isCustom
|
||||
&& JSON.stringify(personPages) !== JSON.stringify(personEditing.pages_override ?? []);
|
||||
if (roleChanged || pagesChanged) {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (roleChanged) payload.role = personRole;
|
||||
// 自定义:带上勾选集(切到 custom 或改现有 custom 勾选都要);切回普通角色不带,后端会清空 override
|
||||
if (isCustom) payload.pages_override = personPages;
|
||||
await api.patch(`/admin/api/admins/${personEditing.id}`, payload);
|
||||
}
|
||||
message.success('已保存');
|
||||
} else {
|
||||
if (personName.trim().length < 3) { message.error('用户名至少 3 位'); return; }
|
||||
await api.post('/admin/api/admins', {
|
||||
username: personName.trim(), password: initPw, role: personRole,
|
||||
username: personName.trim(),
|
||||
password: initPw,
|
||||
role: personRole,
|
||||
...(isCustom ? { pages_override: personPages } : {}),
|
||||
});
|
||||
message.success('已创建,请把初始密码转交本人');
|
||||
}
|
||||
@@ -184,8 +210,10 @@ export default function AdminsPage() {
|
||||
// ===== 列表操作 =====
|
||||
const changeRole = (a: AdminInfo, role: string) => {
|
||||
if (role === a.role) return;
|
||||
// 改成「自定义」需要逐页勾选 → 转去编辑页(列表里没有页选择器,直接切会得到空可见页)
|
||||
if (role === CUSTOM_ROLE) { openEditPerson({ ...a, role: CUSTOM_ROLE }); return; }
|
||||
modal.confirm({
|
||||
title: `把 ${a.username} 的角色改为 ${role}?`,
|
||||
title: `把 ${a.username} 的角色改为 ${roleLabelOf(role)}?`,
|
||||
onOk: async () => {
|
||||
try { await api.patch(`/admin/api/admins/${a.id}`, { role }); message.success('已更新'); loadAll(); }
|
||||
catch (e) { message.error(errMsg(e)); }
|
||||
@@ -266,8 +294,9 @@ export default function AdminsPage() {
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{
|
||||
title: '角色', dataIndex: 'role', width: 170,
|
||||
// options 含「自定义」→ custom 用户在列表里显示为「自定义」;选它会转去编辑页勾选(见 changeRole)
|
||||
render: (r: string, a: AdminInfo) => (
|
||||
<Select size="small" value={r} style={{ width: 150 }} options={roleOptions} onChange={(v) => changeRole(a, v)} />
|
||||
<Select size="small" value={r} style={{ width: 150 }} options={personRoleOptions} onChange={(v) => changeRole(a, v)} />
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? 'active' : '已禁用'}</Tag> },
|
||||
@@ -341,16 +370,26 @@ export default function AdminsPage() {
|
||||
<span>选择角色<span style={{ color: '#cf1322' }}> *</span></span>
|
||||
{isSuper && <a onClick={openRoles}>设置角色权限 ›</a>}
|
||||
</div>
|
||||
<Select value={personRole} options={roleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
|
||||
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>可见页面完全由所选角色决定;如需调整请到「角色设置」修改该角色</div>
|
||||
<Select value={personRole} options={personRoleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
|
||||
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>
|
||||
{isCustom
|
||||
? '「自定义」:下方逐页勾选该成员可见的页面,仅对本人生效(不影响任何角色)'
|
||||
: '可见页面完全由所选角色决定;如需调整请到「角色设置」修改该角色'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>可见页面</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>跟随所选角色,此处只读(深色=可见)</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>
|
||||
{isCustom ? '勾选本成员可见的页面' : '跟随所选角色,此处只读(深色=可见)'}
|
||||
</span>
|
||||
</div>
|
||||
<PermMatrix catalog={catalog} pages={personRole === 'super_admin' ? catalog.flatMap((g) => g.pages.map((p) => p.key)) : rolePagesPreview} />
|
||||
{isCustom ? (
|
||||
<PermEditor catalog={catalog} value={personPages} onChange={setPersonPages} />
|
||||
) : (
|
||||
<PermMatrix catalog={catalog} pages={personRole === 'super_admin' ? catalog.flatMap((g) => g.pages.map((p) => p.key)) : rolePagesPreview} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import {
|
||||
App, Button, Card, Collapse, Descriptions, Drawer, Input, InputNumber,
|
||||
Select, Space, Spin, Table, Tag, Typography,
|
||||
App, Button, Card, Col, Collapse, DatePicker, Descriptions, Divider, Drawer, Input, InputNumber,
|
||||
Row, Select, Space, Spin, Statistic, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatWallTime, yuan } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { ComparisonRecordDetail, ComparisonRecordListItem } from '@/lib/types';
|
||||
import type { ComparisonRecordDetail, ComparisonRecordListItem, CursorPage } from '@/lib/types';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { success: 'green', failed: 'red', cancelled: 'default' };
|
||||
const STATUS_LABEL: Record<string, string> = { success: '成功', failed: '失败', cancelled: '已终止' };
|
||||
const STATUS_LABEL: Record<string, string> = { success: '成功', failed: '失败', cancelled: '中途退出' };
|
||||
|
||||
// 「卡在哪一步」: platform_results[*].status 翻成人话(找店/加菜/起送/读价)
|
||||
const STUCK_LABEL: Record<string, string> = {
|
||||
@@ -37,6 +39,24 @@ const calcCost = (inTok: number | null, outTok: number | null, price: number | n
|
||||
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}`;
|
||||
const percentile = (values: number[], q: number) => {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const idx = (sorted.length - 1) * q;
|
||||
const lo = Math.floor(idx);
|
||||
const hi = Math.min(lo + 1, sorted.length - 1);
|
||||
return Math.round(sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo));
|
||||
};
|
||||
const fmtRate = (value: number | null) => (value == null ? '-' : `${(value * 100).toFixed(1)}%`);
|
||||
|
||||
// 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 {
|
||||
@@ -83,12 +103,20 @@ const preStyle: CSSProperties = {
|
||||
export default function ComparisonRecordsPage() {
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')]);
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [store, setStore] = useState('');
|
||||
const [product, setProduct] = useState('');
|
||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||
const [applied, setApplied] = useState<Record<string, unknown>>({
|
||||
date_from: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
|
||||
date_to: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
|
||||
});
|
||||
const [allItems, setAllItems] = useState<ComparisonRecordListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
// LLM 单价(元/百万 token),本地持久化;仅用于前端估算成本,不入库、不影响其它页面
|
||||
const [pricePerMTok, setPricePerMTok] = useState<number | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
@@ -102,14 +130,13 @@ export default function ComparisonRecordsPage() {
|
||||
else localStorage.setItem('llm_price_per_mtok', String(v));
|
||||
};
|
||||
|
||||
const { items, total, page, pageSize, loading, onChange } =
|
||||
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
|
||||
|
||||
const [detail, setDetail] = useState<ComparisonRecordDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const search = () =>
|
||||
setApplied({
|
||||
date_from: range[0].format('YYYY-MM-DD'),
|
||||
date_to: range[1].format('YYYY-MM-DD'),
|
||||
user_id: userId ?? undefined,
|
||||
phone: phone || undefined,
|
||||
status,
|
||||
@@ -123,7 +150,62 @@ export default function ComparisonRecordsPage() {
|
||||
setStatus(undefined);
|
||||
setStore('');
|
||||
setProduct('');
|
||||
setApplied({});
|
||||
const yesterday = dayjs().subtract(1, 'day');
|
||||
setRange([yesterday, yesterday]);
|
||||
setApplied({
|
||||
date_from: yesterday.format('YYYY-MM-DD'),
|
||||
date_to: yesterday.format('YYYY-MM-DD'),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const serverFilters = { ...applied };
|
||||
delete serverFilters.date_from;
|
||||
delete serverFilters.date_to;
|
||||
const rows: ComparisonRecordListItem[] = [];
|
||||
let cursor = 0;
|
||||
for (let pageIndex = 0; pageIndex < 200; pageIndex += 1) {
|
||||
const { data } = await api.get<CursorPage<ComparisonRecordListItem>>(
|
||||
'/admin/api/comparison-records',
|
||||
{ params: { ...serverFilters, limit: 100, cursor } },
|
||||
);
|
||||
rows.push(...data.items);
|
||||
if (data.next_cursor == null || data.items.length === 0) break;
|
||||
cursor = data.next_cursor;
|
||||
}
|
||||
if (alive) {
|
||||
setAllItems(rows);
|
||||
setPage(1);
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) message.error(errMsg(e));
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
};
|
||||
loadAll();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [applied, message]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const from = String(applied.date_from ?? '');
|
||||
const to = String(applied.date_to ?? '');
|
||||
return allItems.filter((item) => {
|
||||
const date = dayjs(item.created_at).format('YYYY-MM-DD');
|
||||
return (!from || date >= from) && (!to || date <= to);
|
||||
});
|
||||
}, [allItems, applied]);
|
||||
const total = filteredItems.length;
|
||||
const items = filteredItems.slice((page - 1) * pageSize, page * pageSize);
|
||||
const onChange = (nextPage: number, nextPageSize: number) => {
|
||||
setPageSize(nextPageSize);
|
||||
setPage(nextPageSize === pageSize ? nextPage : 1);
|
||||
};
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
@@ -144,8 +226,48 @@ export default function ComparisonRecordsPage() {
|
||||
setDetailLoading(false);
|
||||
};
|
||||
|
||||
const overview = useMemo(() => {
|
||||
const completed = filteredItems.filter(
|
||||
(item) => item.status === 'success' || item.status === 'failed',
|
||||
);
|
||||
const cancelled = filteredItems.filter((item) => item.status === 'cancelled');
|
||||
const success = filteredItems.filter((item) => item.status === 'success');
|
||||
const successRateDenominator = filteredItems.length - cancelled.length;
|
||||
const completedDurations = completed
|
||||
.map((item) => item.total_ms)
|
||||
.filter((value): value is number => value != null);
|
||||
const cancelledDurations = cancelled
|
||||
.map((item) => item.total_ms)
|
||||
.filter((value): value is number => value != null);
|
||||
const costs = filteredItems
|
||||
.map((item) => item.llm_cost_yuan)
|
||||
.filter((value): value is number => value != null);
|
||||
const avg = (values: number[]) =>
|
||||
values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
|
||||
return {
|
||||
started: filteredItems.length,
|
||||
completed: completed.length,
|
||||
success: success.length,
|
||||
successRate: successRateDenominator ? success.length / successRateDenominator : null,
|
||||
avgTokenCost: avg(costs),
|
||||
lowerPriceRate: success.length
|
||||
? success.filter((item) => (item.saved_amount_cents ?? 0) > 0).length / success.length
|
||||
: null,
|
||||
avgMs: avg(completedDurations),
|
||||
p5Ms: percentile(completedDurations, 0.05),
|
||||
p50Ms: percentile(completedDurations, 0.5),
|
||||
p95Ms: percentile(completedDurations, 0.95),
|
||||
p99Ms: percentile(completedDurations, 0.99),
|
||||
cancelled: cancelled.length,
|
||||
cancelledRate: filteredItems.length ? cancelled.length / filteredItems.length : null,
|
||||
cancelledP5Ms: percentile(cancelledDurations, 0.05),
|
||||
cancelledP50Ms: percentile(cancelledDurations, 0.5),
|
||||
cancelledP95Ms: percentile(cancelledDurations, 0.95),
|
||||
};
|
||||
}, [filteredItems]);
|
||||
|
||||
const columns: ColumnsType<ComparisonRecordListItem> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 64 },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
@@ -158,7 +280,7 @@ export default function ComparisonRecordsPage() {
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 72, render: (s: string) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s] || s}</Tag> },
|
||||
{ title: '源平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
|
||||
{ title: '原平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
|
||||
{
|
||||
title: '店',
|
||||
dataIndex: 'store_name',
|
||||
@@ -174,6 +296,33 @@ export default function ComparisonRecordsPage() {
|
||||
onCell: () => ({ style: { whiteSpace: 'normal', wordBreak: 'break-word', verticalAlign: 'top' } }),
|
||||
render: (v: string | null) => highlightMatch(v, applied.product),
|
||||
},
|
||||
{ title: '耗时', dataIndex: 'total_ms', width: 72, render: fmtMs },
|
||||
{
|
||||
title: '广告收益',
|
||||
key: 'ad_revenue',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
render: (_, r) => (
|
||||
<span style={{ color: r.ad_revenue_yuan > 0 ? '#3f8600' : '#999' }}>
|
||||
¥{r.ad_revenue_yuan.toFixed(4)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>;
|
||||
},
|
||||
},
|
||||
{ title: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' },
|
||||
{
|
||||
title: '省',
|
||||
@@ -184,27 +333,13 @@ export default function ComparisonRecordsPage() {
|
||||
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
|
||||
),
|
||||
},
|
||||
{ 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 ?? '-' },
|
||||
{
|
||||
title: '重试',
|
||||
dataIndex: 'retry_count',
|
||||
width: 50,
|
||||
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
|
||||
},
|
||||
{
|
||||
title: 'Token(入/出)',
|
||||
title: 'TOKEN',
|
||||
key: 'tokens',
|
||||
width: 110,
|
||||
render: (_, r) => fmtTok(r.input_tokens, r.output_tokens),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
key: 'cost',
|
||||
width: 90,
|
||||
render: (_, r) => fmtCost(calcCost(r.input_tokens, r.output_tokens, pricePerMTok)),
|
||||
},
|
||||
{
|
||||
title: '机型/ROM',
|
||||
key: 'device',
|
||||
@@ -212,9 +347,8 @@ export default function ComparisonRecordsPage() {
|
||||
ellipsis: true,
|
||||
render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-',
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
|
||||
{
|
||||
title: '操作',
|
||||
title: 'traceid',
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 80,
|
||||
@@ -232,57 +366,95 @@ export default function ComparisonRecordsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>比价记录(debug)</h2>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<InputNumber placeholder="user_id" value={userId} onChange={setUserId} style={{ width: 110 }} />
|
||||
<Input
|
||||
placeholder="手机号前缀"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索店名"
|
||||
value={store}
|
||||
onChange={(e) => setStore(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索商品"
|
||||
value={product}
|
||||
onChange={(e) => setProduct(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'cancelled', label: '已终止' },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" onClick={search}>查询</Button>
|
||||
<Button onClick={reset}>重置</Button>
|
||||
<InputNumber
|
||||
placeholder="LLM 单价"
|
||||
value={pricePerMTok}
|
||||
onChange={onPriceChange}
|
||||
min={0}
|
||||
step={0.1}
|
||||
style={{ width: 210 }}
|
||||
addonAfter="元/百万token"
|
||||
/>
|
||||
</Space>
|
||||
<h2>比价记录</h2>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap size="middle" align="center">
|
||||
<RangePicker
|
||||
value={range}
|
||||
allowClear={false}
|
||||
onChange={(value) => value?.[0] && value[1] && setRange([value[0], value[1]])}
|
||||
presets={[
|
||||
{ label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
|
||||
{ label: '今天', value: [dayjs(), dayjs()] },
|
||||
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
|
||||
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
|
||||
]}
|
||||
/>
|
||||
<InputNumber placeholder="user_id" value={userId} onChange={setUserId} style={{ width: 110 }} />
|
||||
<Input
|
||||
placeholder="手机号前缀"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索店名"
|
||||
value={store}
|
||||
onChange={(e) => setStore(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索商品"
|
||||
value={product}
|
||||
onChange={(e) => setProduct(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'cancelled', label: '中途退出' },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" onClick={search}>查询</Button>
|
||||
<Button onClick={reset}>重置</Button>
|
||||
<InputNumber
|
||||
placeholder="LLM 单价"
|
||||
value={pricePerMTok}
|
||||
onChange={onPriceChange}
|
||||
min={0}
|
||||
step={0.1}
|
||||
style={{ width: 210 }}
|
||||
suffix="元/百万token"
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }} loading={loading}>
|
||||
<Divider orientation="left" plain style={{ marginTop: 0 }}>数据概览</Divider>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={6}><Statistic title="比价发起数" value={overview.started} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="比价完成数" value={overview.completed} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="比价成功次数" value={overview.success} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="比价成功率" value={fmtRate(overview.successRate)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="平均 TOKEN 成本" value={overview.avgTokenCost ?? '-'} precision={4} prefix="¥" /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="比出更低价率" value={fmtRate(overview.lowerPriceRate)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="耗时平均数" value={fmtMs(overview.avgMs)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="耗时 5 分位" value={fmtMs(overview.p5Ms)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="耗时 50 分位" value={fmtMs(overview.p50Ms)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="耗时 95 分位" value={fmtMs(overview.p95Ms)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="耗时 99 分位" value={fmtMs(overview.p99Ms)} /></Col>
|
||||
</Row>
|
||||
<Divider orientation="left" plain>中途退出</Divider>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={6}><Statistic title="中途退出次数" value={overview.cancelled} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="中途退出率" value={fmtRate(overview.cancelledRate)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="退出耗时 5 分位" value={fmtMs(overview.cancelledP5Ms)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="退出耗时 50 分位" value={fmtMs(overview.cancelledP50Ms)} /></Col>
|
||||
<Col xs={12} md={6}><Statistic title="退出耗时 95 分位" value={fmtMs(overview.cancelledP95Ms)} /></Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
@@ -297,7 +469,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 +491,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 +607,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
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,43 @@ 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.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 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) },
|
||||
@@ -283,9 +344,11 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
// 数据源 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 [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')]);
|
||||
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 +357,11 @@ export default function CouponDataPage() {
|
||||
const [queriedMultiDay, setQueriedMultiDay] = useState(false);
|
||||
const [queriedLimit, setQueriedLimit] = useState<number>(100);
|
||||
const [data, setData] = useState<CouponDataReport | null>(null);
|
||||
const [overviewSummary, setOverviewSummary] = useState<CouponDataSummary | null>(null);
|
||||
const [abandonedCount, setAbandonedCount] = useState(0);
|
||||
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);
|
||||
@@ -312,19 +380,55 @@ export default function CouponDataPage() {
|
||||
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,
|
||||
granularity: gran,
|
||||
limit: targetLimit,
|
||||
offset: (targetPage - 1) * targetLimit,
|
||||
sort: targetSort,
|
||||
},
|
||||
});
|
||||
const baseParams = {
|
||||
date_from: from,
|
||||
date_to: to,
|
||||
user: user.trim() || undefined,
|
||||
app_env: appEnv === 'all' ? undefined : appEnv,
|
||||
granularity: gran,
|
||||
};
|
||||
const [res, overviewRes, abandonedRes] = await Promise.all([
|
||||
api.get<CouponDataReport>('/admin/api/coupon-data', {
|
||||
params: {
|
||||
...baseParams,
|
||||
status: statuses,
|
||||
limit: targetLimit,
|
||||
offset: (targetPage - 1) * targetLimit,
|
||||
sort: targetSort,
|
||||
},
|
||||
paramsSerializer: { indexes: null },
|
||||
}),
|
||||
api.get<CouponDataReport>('/admin/api/coupon-data', {
|
||||
params: {
|
||||
...baseParams,
|
||||
status: ['started', 'completed', 'failed', 'abandoned'],
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
sort: 'time',
|
||||
},
|
||||
paramsSerializer: { indexes: null },
|
||||
}),
|
||||
api.get<CouponDataReport>('/admin/api/coupon-data', {
|
||||
params: {
|
||||
...baseParams,
|
||||
status: ['abandoned'],
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
sort: 'time',
|
||||
},
|
||||
paramsSerializer: { indexes: null },
|
||||
}),
|
||||
]);
|
||||
setData(res.data);
|
||||
setOverviewSummary(overviewRes.data.summary);
|
||||
setAbandonedCount(abandonedRes.data.summary.started_count);
|
||||
// 并行拉「按券成功率」(独立端点,失败不影响主报表)
|
||||
api
|
||||
.get<CouponSlotsOut>('/admin/api/coupon-data/coupons', {
|
||||
params: { date_from: from, date_to: to, app_env: appEnv === 'all' ? undefined : appEnv },
|
||||
})
|
||||
.then((r) => setCouponSlots(r.data.items))
|
||||
.catch(() => setCouponSlots([]));
|
||||
setPage(targetPage);
|
||||
setQueriedLimit(targetLimit);
|
||||
setQueriedGranularity(gran);
|
||||
@@ -335,48 +439,37 @@ export default function CouponDataPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[range, user, appEnv, granularity, limit, sortBy, message],
|
||||
[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: 'started_at',
|
||||
width: 165,
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
},
|
||||
{
|
||||
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: '用户',
|
||||
key: 'user',
|
||||
width: 150,
|
||||
render: (_: unknown, r: CouponDataRow) => {
|
||||
if (r.user_id == null) return <Typography.Text type="secondary">游客</Typography.Text>;
|
||||
return (
|
||||
<a onClick={() => openUserRecords(r)}>
|
||||
<div>{r.user_phone || `#${r.user_id}`}</div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
{r.user_nickname || '未设置昵称'}
|
||||
</Typography.Text>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -400,6 +493,29 @@ export default function CouponDataPage() {
|
||||
align: 'right',
|
||||
render: (v: number | null) => fmtSec(v),
|
||||
},
|
||||
{
|
||||
title: '点位成功率',
|
||||
key: 'point_success_rate',
|
||||
width: 110,
|
||||
render: (_: unknown, r: CouponDataRow) => (
|
||||
<Tooltip title="当前接口未返回本场应领点位及逐点结果,前端不使用全局均值冒充本场成功率。">
|
||||
{r.trace_url ? (
|
||||
<a href={r.trace_url} target="_blank" rel="noreferrer">
|
||||
查看明细
|
||||
</a>
|
||||
) : (
|
||||
<Typography.Text type="secondary">待埋点</Typography.Text>
|
||||
)}
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '广告收益',
|
||||
dataIndex: 'ad_revenue_yuan',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: number) => fmtYuan(v),
|
||||
},
|
||||
{
|
||||
title: '美团耗时',
|
||||
key: 'mt_elapsed',
|
||||
@@ -431,15 +547,10 @@ export default function CouponDataPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 165,
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
},
|
||||
{
|
||||
title: '领券 trace',
|
||||
title: 'traceid',
|
||||
dataIndex: 'trace_id',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
// 有 trace_url(pricebot 上云,仅 completed)→ 可点链接打开完整现场;否则降级为可复制 trace_id。
|
||||
render: (v: string, r: CouponDataRow) =>
|
||||
r.trace_url ? (
|
||||
@@ -454,13 +565,41 @@ export default function CouponDataPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const summary = data?.summary;
|
||||
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 = overviewSummary ?? data?.summary;
|
||||
const successDenominator = summary ? Math.max(0, summary.started_count - abandonedCount) : 0;
|
||||
const couponSuccessRate =
|
||||
summary && successDenominator > 0 ? summary.completed_count / successDenominator : null;
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>领券数据</h2>
|
||||
<h2 style={{ margin: 0 }}>领券记录</h2>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
发起→完成耗时口径为客户端全程计时;均值/分位仅统计「完成」的领券
|
||||
</Typography.Text>
|
||||
@@ -475,6 +614,7 @@ export default function CouponDataPage() {
|
||||
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
|
||||
allowClear={false}
|
||||
presets={[
|
||||
{ label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
|
||||
{ label: '今天', value: [dayjs(), dayjs()] },
|
||||
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
|
||||
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
|
||||
@@ -505,6 +645,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
|
||||
@@ -555,6 +707,32 @@ export default function CouponDataPage() {
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col flex="1 1 0">
|
||||
<Statistic
|
||||
title={
|
||||
<span>
|
||||
领券成功率{' '}
|
||||
<Tooltip title="领券完成数 ÷(领券发起数-中途退出数)">
|
||||
<InfoCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={fmtPct(couponSuccessRate)}
|
||||
/>
|
||||
</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>
|
||||
<Col flex="1 1 0">
|
||||
<Statistic title="领券发起数" value={summary.started_count} />
|
||||
</Col>
|
||||
@@ -580,6 +758,85 @@ export default function CouponDataPage() {
|
||||
<Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} />
|
||||
</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 +913,7 @@ export default function CouponDataPage() {
|
||||
onChange: (p) => load(p, queriedLimit),
|
||||
}}
|
||||
size="small"
|
||||
scroll={{ x: 1450 }}
|
||||
scroll={{ x: 1560 }}
|
||||
/>
|
||||
|
||||
<UserRecordsDrawer<CouponDataRow>
|
||||
|
||||
+281
-107
@@ -1,11 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Alert, App, Select, Spin, Tag, Tooltip } from 'antd';
|
||||
import { Alert, App, DatePicker, Select, Spin, Tag, Tooltip } from 'antd';
|
||||
import { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AdRevenueReport, DashboardOverview } from '@/lib/types';
|
||||
import type {
|
||||
AdRevenueReport,
|
||||
ComparisonRecordListItem,
|
||||
CursorPage,
|
||||
DashboardOverview,
|
||||
} from '@/lib/types';
|
||||
|
||||
type PeriodKey = 'yesterday' | '7d' | '30d';
|
||||
type TrendSeriesKey = 'dau' | 'newUsers' | 'comparisons';
|
||||
@@ -37,6 +42,18 @@ const fmtYuan = (yuan: number | null | undefined) => (yuan == null ? '--' : `¥$
|
||||
const fmtMsAsSeconds = (ms: number | null | undefined) => (ms == null ? '--' : (ms / 1000).toFixed(1));
|
||||
const pct = (v: number | null | undefined) => (v == null ? '--' : `${(v * 100).toFixed(1)}%`);
|
||||
const compact = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v));
|
||||
const percentile = (values: number[], q: number) => {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const idx = (sorted.length - 1) * q;
|
||||
const lo = Math.floor(idx);
|
||||
const hi = Math.min(lo + 1, sorted.length - 1);
|
||||
return Math.round(sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo));
|
||||
};
|
||||
const inDateRange = (value: string, start: Dayjs, end: Dayjs) => {
|
||||
const date = dayjs(value).format('YYYY-MM-DD');
|
||||
return date >= start.format('YYYY-MM-DD') && date <= end.format('YYYY-MM-DD');
|
||||
};
|
||||
const fmtCoinAmount = (v: number | null | undefined) => {
|
||||
if (v == null) return '--';
|
||||
if (Math.abs(v) >= 10000) return `${(v / 10000).toFixed(2).replace(/\.?0+$/, '')} 万`;
|
||||
@@ -124,11 +141,6 @@ function retentionTitle(period: PeriodKey) {
|
||||
return '前日用户新增留存';
|
||||
}
|
||||
|
||||
function avgEcpm(report: AdRevenueReport | null) {
|
||||
if (!report || report.total_impressions <= 0) return null;
|
||||
return (report.total_revenue_yuan / report.total_impressions) * 1000;
|
||||
}
|
||||
|
||||
function adTypeSummary(report: AdRevenueReport | null, adType: string) {
|
||||
if (!report) return null;
|
||||
if (report.by_ad_type?.[adType]) return report.by_ad_type[adType];
|
||||
@@ -345,8 +357,6 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
|
||||
.join(' ');
|
||||
return `M${chart.left},${chart.bottom} ${line.replace(/^L/, 'L')} L${chart.right},${chart.bottom} Z`;
|
||||
};
|
||||
const labelStep = Math.max(1, Math.ceil(points.length / 6));
|
||||
|
||||
const toggleSeries = (key: TrendSeriesKey) => {
|
||||
setVisibleSeries((prev) => {
|
||||
const visibleCount = Object.values(prev).filter(Boolean).length;
|
||||
@@ -378,7 +388,7 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-empty">
|
||||
<svg viewBox="0 0 900 240" width="100%" height="220" preserveAspectRatio="none" aria-hidden>
|
||||
<svg viewBox="0 0 900 262" width="100%" height="240" preserveAspectRatio="none" aria-hidden>
|
||||
<g stroke="#edf0f5" strokeWidth="1">
|
||||
<line x1="44" y1="28" x2="880" y2="28" />
|
||||
<line x1="44" y1="76" x2="880" y2="76" />
|
||||
@@ -431,14 +441,18 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
|
||||
</g>
|
||||
) : null,
|
||||
)}
|
||||
<g fill="#97A3B8" fontSize="12" fontWeight="600">
|
||||
{points.map((p, idx) =>
|
||||
idx === 0 || idx === points.length - 1 || idx % labelStep === 0 ? (
|
||||
<text key={p.date} x={xFor(idx)} y="235" textAnchor={idx === points.length - 1 ? 'end' : 'middle'}>
|
||||
{dayjs(p.date).format('MM-DD')}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
<g fill="#97A3B8" fontSize="10" fontWeight="600">
|
||||
{points.map((p, idx) => (
|
||||
<text
|
||||
key={p.date}
|
||||
x={xFor(idx)}
|
||||
y="236"
|
||||
textAnchor="end"
|
||||
transform={`rotate(-45 ${xFor(idx)} 236)`}
|
||||
>
|
||||
{dayjs(p.date).format('MM-DD')}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
</>
|
||||
)}
|
||||
@@ -452,6 +466,8 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
|
||||
export default function DashboardPage() {
|
||||
const { message } = App.useApp();
|
||||
const [period, setPeriod] = useState<PeriodKey>('yesterday');
|
||||
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
const [data, setData] = useState<DashboardOverview | null>(null);
|
||||
const [previousData, setPreviousData] = useState<DashboardOverview | null>(null);
|
||||
const [adReport, setAdReport] = useState<AdRevenueReport | null>(null);
|
||||
@@ -463,12 +479,24 @@ export default function DashboardPage() {
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [cpsSyncing, setCpsSyncing] = useState(false);
|
||||
const [comparisonRecords, setComparisonRecords] = useState<ComparisonRecordListItem[]>([]);
|
||||
const [comparisonRecordsComplete, setComparisonRecordsComplete] = useState(false);
|
||||
const [couponP95Ms, setCouponP95Ms] = useState<number | null>(null);
|
||||
|
||||
const range = useMemo(() => periodRange(period), [period]);
|
||||
const range = useMemo(() => {
|
||||
if (!customRange) return periodRange(period);
|
||||
const start = customRange[0].startOf('day');
|
||||
const end = customRange[1].startOf('day');
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
label: `${start.format('MM-DD')} ~ ${end.format('MM-DD')}`,
|
||||
days: end.diff(start, 'day') + 1,
|
||||
};
|
||||
}, [period, customRange]);
|
||||
const previousRange = useMemo(() => previousPeriodRange(range), [range]);
|
||||
const periodData = data?.period ?? null;
|
||||
const previousPeriodData = previousData?.period ?? null;
|
||||
const adAvgEcpm = avgEcpm(adReport);
|
||||
const rewardVideoAdSummary = adTypeSummary(adReport, 'reward_video');
|
||||
const couponAdSummary = adSceneSummary(adReport, 'coupon');
|
||||
const comparisonAdSummary = adSceneSummary(adReport, 'comparison');
|
||||
@@ -477,8 +505,54 @@ export default function DashboardPage() {
|
||||
rewardVideoAdSummary && rewardVideoAdSummary.impressions > 0
|
||||
? fmtYuan((rewardVideoAdSummary.revenue_yuan / rewardVideoAdSummary.impressions) * 1000)
|
||||
: '--';
|
||||
const compareSuccessRate = periodData?.comparison.success_rate ?? null;
|
||||
const averageDurationMs = periodData?.comparison.average_duration_ms ?? null;
|
||||
const comparisonMetrics = useMemo(() => {
|
||||
const current = comparisonRecords.filter((r) => inDateRange(r.created_at, range.start, range.end));
|
||||
const previous = comparisonRecords.filter((r) =>
|
||||
inDateRange(r.created_at, previousRange.start, previousRange.end),
|
||||
);
|
||||
const summarize = (rows: ComparisonRecordListItem[]) => {
|
||||
const cancelled = rows.filter((r) => r.status === 'cancelled').length;
|
||||
const completedRows = rows.filter((r) => r.status === 'success' || r.status === 'failed');
|
||||
const completed = completedRows.length;
|
||||
const success = rows.filter((r) => r.status === 'success').length;
|
||||
const successRateDenominator = rows.length - cancelled;
|
||||
const durations = completedRows
|
||||
.filter((r) => r.total_ms != null)
|
||||
.map((r) => r.total_ms as number);
|
||||
const tokenCosts = rows
|
||||
.map((r) => r.llm_cost_yuan)
|
||||
.filter((v): v is number => v != null);
|
||||
return {
|
||||
total: rows.length,
|
||||
completed,
|
||||
cancelled,
|
||||
success,
|
||||
successRate: successRateDenominator > 0 ? success / successRateDenominator : null,
|
||||
medianMs: percentile(durations, 0.5),
|
||||
p95Ms: percentile(durations, 0.95),
|
||||
tokenCostTotal: tokenCosts.reduce((sum, value) => sum + value, 0),
|
||||
};
|
||||
};
|
||||
return { current: summarize(current), previous: summarize(previous) };
|
||||
}, [comparisonRecords, range.start, range.end, previousRange.start, previousRange.end]);
|
||||
const compareSuccessRate = comparisonRecordsComplete
|
||||
? comparisonMetrics.current.successRate
|
||||
: null;
|
||||
const comparisonTotal = comparisonRecordsComplete
|
||||
? comparisonMetrics.current.total
|
||||
: periodData?.comparison.total;
|
||||
const comparisonCompleted = comparisonRecordsComplete ? comparisonMetrics.current.completed : null;
|
||||
const comparisonSuccess = comparisonRecordsComplete
|
||||
? comparisonMetrics.current.success
|
||||
: periodData?.comparison.success;
|
||||
const comparisonSuccessRateDenominator = comparisonRecordsComplete
|
||||
? comparisonMetrics.current.total - comparisonMetrics.current.cancelled
|
||||
: null;
|
||||
const comparisonMedianMs = comparisonRecordsComplete
|
||||
? comparisonMetrics.current.medianMs
|
||||
: null;
|
||||
const comparisonP95Ms = comparisonRecordsComplete ? comparisonMetrics.current.p95Ms : null;
|
||||
const tokenCostYuan = comparisonRecordsComplete ? comparisonMetrics.current.tokenCostTotal : null;
|
||||
const couponRewardCoinTotal = periodData?.coins.coupon_reward_coin_total;
|
||||
const comparisonRewardCoinTotal = periodData?.coins.comparison_reward_coin_total;
|
||||
const signinCoinTotal = periodData?.coins.signin_coin_total;
|
||||
@@ -494,12 +568,21 @@ export default function DashboardPage() {
|
||||
const previousTotalCpsCommissionCents = cpsCommissionCents(previousData);
|
||||
const totalRevenueYuan = revenueYuan(adReport, totalCpsCommissionCents);
|
||||
const previousTotalRevenueYuan = revenueYuan(previousAdReport, previousTotalCpsCommissionCents);
|
||||
const yesterdayTrendPoint = periodData?.trend?.[periodData.trend.length - 1];
|
||||
const yesterdayActiveUsers = yesterdayTrendPoint?.active_users ?? null;
|
||||
const periodActiveUsers = periodData?.users.active ?? null;
|
||||
const arpuYuan =
|
||||
totalRevenueYuan == null || yesterdayActiveUsers == null || yesterdayActiveUsers <= 0
|
||||
totalRevenueYuan == null || periodActiveUsers == null || periodActiveUsers <= 0
|
||||
? null
|
||||
: totalRevenueYuan / yesterdayActiveUsers;
|
||||
: totalRevenueYuan / periodActiveUsers;
|
||||
const coinCostYuan =
|
||||
periodData?.coins.granted_total == null ? null : periodData.coins.granted_total / 10000;
|
||||
const grossArpuYuan =
|
||||
totalRevenueYuan == null ||
|
||||
tokenCostYuan == null ||
|
||||
coinCostYuan == null ||
|
||||
periodActiveUsers == null ||
|
||||
periodActiveUsers <= 0
|
||||
? null
|
||||
: (totalRevenueYuan - tokenCostYuan - coinCostYuan) / periodActiveUsers;
|
||||
const cpsRevenueText = cpsAvailable ? fmtCents(totalCpsCommissionCents) : '--';
|
||||
const meituanCpsRevenueText = cpsAvailable ? fmtCents(meituanCpsCommissionCents) : '--';
|
||||
const jdCpsRevenueText = cpsAvailable ? fmtCents(jdCpsCommissionCents) : '--';
|
||||
@@ -511,20 +594,18 @@ export default function DashboardPage() {
|
||||
const activeUserDelta = percentDelta(periodData?.users.active, previousPeriodData?.users.active);
|
||||
const newUserDelta = percentDelta(periodData?.users.new, previousPeriodData?.users.new);
|
||||
const retentionDelta = pointDelta(periodData?.users.retention_rate, previousPeriodData?.users.retention_rate);
|
||||
const retentionSpark: 'up' | 'down' | 'flat' | undefined =
|
||||
periodData?.users.retention_rate == null
|
||||
? undefined
|
||||
: retentionDelta.tone === 'down'
|
||||
? 'down'
|
||||
: retentionDelta.tone === 'up' || periodData.users.retention_rate >= 1
|
||||
? 'up'
|
||||
: 'flat';
|
||||
const comparisonTotalDelta = percentDelta(periodData?.comparison.total, previousPeriodData?.comparison.total);
|
||||
const comparisonSuccessDelta = percentDelta(periodData?.comparison.success, previousPeriodData?.comparison.success);
|
||||
const comparisonTotalDelta = percentDelta(
|
||||
comparisonRecordsComplete ? comparisonMetrics.current.total : periodData?.comparison.total,
|
||||
comparisonRecordsComplete ? comparisonMetrics.previous.total : previousPeriodData?.comparison.total,
|
||||
);
|
||||
const comparisonSuccessDelta = percentDelta(
|
||||
comparisonRecordsComplete ? comparisonMetrics.current.success : periodData?.comparison.success,
|
||||
comparisonRecordsComplete ? comparisonMetrics.previous.success : previousPeriodData?.comparison.success,
|
||||
);
|
||||
const comparisonOrderedDelta = percentDelta(periodData?.comparison.ordered, previousPeriodData?.comparison.ordered);
|
||||
const durationDeltaInfo = durationDelta(
|
||||
periodData?.comparison.average_duration_ms,
|
||||
previousPeriodData?.comparison.average_duration_ms,
|
||||
comparisonRecordsComplete ? comparisonMetrics.current.medianMs : null,
|
||||
comparisonRecordsComplete ? comparisonMetrics.previous.medianMs : null,
|
||||
);
|
||||
// 领券核心数据(period.coupon;旧后端无此字段时整节显示 '--')
|
||||
const couponPeriod = periodData?.coupon ?? null;
|
||||
@@ -548,14 +629,75 @@ export default function DashboardPage() {
|
||||
previousPeriodData?.comparison.average_saved_cents,
|
||||
);
|
||||
const totalRevenueDelta = percentDelta(totalRevenueYuan, previousTotalRevenueYuan);
|
||||
const adRevenueDelta = percentDelta(adReport?.total_revenue_yuan, previousAdReport?.total_revenue_yuan);
|
||||
const cpsRevenueDelta = percentDelta(totalCpsCommissionCents, previousTotalCpsCommissionCents);
|
||||
const grantedCoinDelta = percentDelta(periodData?.coins.granted_total, previousPeriodData?.coins.granted_total);
|
||||
const couponCoinRatio = ratioBadge(couponRewardCoinTotal, periodData?.coins.granted_total);
|
||||
const comparisonCoinRatio = ratioBadge(comparisonRewardCoinTotal, periodData?.coins.granted_total);
|
||||
const rewardVideoCoinRatio = ratioBadge(periodData?.coins.reward_video_coin_total, periodData?.coins.granted_total);
|
||||
const regularTaskCoinRatio = ratioBadge(regularTaskCoinTotal, periodData?.coins.granted_total);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const loadComparisonRecords = async () => {
|
||||
const collected: ComparisonRecordListItem[] = [];
|
||||
let cursor = 0;
|
||||
let complete = false;
|
||||
for (let pageIndex = 0; pageIndex < 100; pageIndex += 1) {
|
||||
const { data: pageData } = await api.get<CursorPage<ComparisonRecordListItem>>(
|
||||
'/admin/api/comparison-records',
|
||||
{ params: { limit: 100, cursor } },
|
||||
);
|
||||
collected.push(...pageData.items);
|
||||
const oldest = pageData.items.at(-1);
|
||||
if (
|
||||
pageData.next_cursor == null ||
|
||||
pageData.items.length === 0 ||
|
||||
(oldest && dayjs(oldest.created_at).isBefore(previousRange.start.startOf('day')))
|
||||
) {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
cursor = pageData.next_cursor;
|
||||
}
|
||||
if (!alive) return;
|
||||
setComparisonRecords(collected);
|
||||
setComparisonRecordsComplete(complete);
|
||||
};
|
||||
loadComparisonRecords().catch(() => {
|
||||
if (!alive) return;
|
||||
setComparisonRecords([]);
|
||||
setComparisonRecordsComplete(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range.end, previousRange.start, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.get<{ summary: { p95_ms: number | null } }>('/admin/api/coupon-data', {
|
||||
params: {
|
||||
date_from: range.start.format('YYYY-MM-DD'),
|
||||
date_to: range.end.format('YYYY-MM-DD'),
|
||||
app_env: 'prod',
|
||||
status: ['started', 'completed', 'failed', 'abandoned'],
|
||||
granularity: 'day',
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
},
|
||||
paramsSerializer: { indexes: null },
|
||||
})
|
||||
.then(({ data: couponData }) => {
|
||||
if (alive) setCouponP95Ms(couponData.summary.p95_ms);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setCouponP95Ms(null);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [range.start, range.end, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
@@ -674,17 +816,33 @@ export default function DashboardPage() {
|
||||
{PERIODS.map((it) => (
|
||||
<button
|
||||
key={it.key}
|
||||
className={period === it.key ? 'on' : undefined}
|
||||
onClick={() => setPeriod(it.key)}
|
||||
className={!customRange && period === it.key ? 'on' : undefined}
|
||||
onClick={() => {
|
||||
setPeriod(it.key);
|
||||
setCustomRange(null);
|
||||
}}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="date-pill">
|
||||
<CalendarOutlined />
|
||||
{range.label}
|
||||
</div>
|
||||
<DatePicker.RangePicker
|
||||
className="dashboard-date-picker"
|
||||
value={[range.start, range.end]}
|
||||
format="YYYY-MM-DD"
|
||||
allowClear={false}
|
||||
open={datePickerOpen}
|
||||
disabledDate={(date) => date.isAfter(dayjs().subtract(1, 'day'), 'day')}
|
||||
suffixIcon={<CalendarOutlined />}
|
||||
onClick={() => setDatePickerOpen(true)}
|
||||
onOpenChange={setDatePickerOpen}
|
||||
onChange={(value) => {
|
||||
if (value?.[0] && value[1]) {
|
||||
setCustomRange([value[0], value[1]]);
|
||||
setDatePickerOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value="prev"
|
||||
style={{ width: 132 }}
|
||||
@@ -706,7 +864,6 @@ export default function DashboardPage() {
|
||||
delta={activeUserDelta.value}
|
||||
deltaTone={activeUserDelta.tone}
|
||||
hint="当前按本期内登录、开始比价、开始领券任一满足去重;比价按 real_compare_start,领券按 real_coupon_start/claim_started。"
|
||||
spark="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="新增用户"
|
||||
@@ -714,7 +871,6 @@ export default function DashboardPage() {
|
||||
delta={newUserDelta.value}
|
||||
deltaTone={newUserDelta.tone}
|
||||
hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。"
|
||||
spark="up"
|
||||
/>
|
||||
<StatCard
|
||||
title={retentionTitle(period)}
|
||||
@@ -723,14 +879,12 @@ export default function DashboardPage() {
|
||||
delta={retentionDelta.value}
|
||||
deltaTone={retentionDelta.tone}
|
||||
hint={periodData?.users.retention_note ?? '暂无留存数据'}
|
||||
spark={retentionSpark}
|
||||
/>
|
||||
<StatCard
|
||||
title="比价次数"
|
||||
value={fmtInt(periodData?.comparison.total)}
|
||||
value={fmtInt(comparisonTotal)}
|
||||
delta={comparisonTotalDelta.value}
|
||||
deltaTone={comparisonTotalDelta.tone}
|
||||
spark="up"
|
||||
/>
|
||||
</div>
|
||||
<TrendChart rangeLabel={range.label} points={periodData?.trend ?? []} />
|
||||
@@ -742,37 +896,53 @@ export default function DashboardPage() {
|
||||
<h3>比价核心数据</h3>
|
||||
<span>发起、成功、下单与效率</span>
|
||||
</div>
|
||||
<div className="grid g5">
|
||||
<div className="grid g4">
|
||||
<StatCard
|
||||
title="发起比价"
|
||||
value={fmtInt(periodData?.comparison.total)}
|
||||
title="比价发起次数"
|
||||
value={fmtInt(comparisonTotal)}
|
||||
unit="次"
|
||||
delta={comparisonTotalDelta.value}
|
||||
deltaTone={comparisonTotalDelta.tone}
|
||||
/>
|
||||
<StatCard
|
||||
title="比价成功"
|
||||
value={fmtInt(periodData?.comparison.success)}
|
||||
title="比价完成次数"
|
||||
value={fmtInt(comparisonCompleted)}
|
||||
unit="次"
|
||||
hint="成功次数 + 失败次数,不包含中途退出。"
|
||||
/>
|
||||
<StatCard
|
||||
title="比价成功次数"
|
||||
value={fmtInt(comparisonSuccess)}
|
||||
unit="次"
|
||||
delta={comparisonSuccessDelta.value}
|
||||
deltaTone={comparisonSuccessDelta.tone}
|
||||
hint={`当前成功率 ${pct(compareSuccessRate)}`}
|
||||
/>
|
||||
<StatCard
|
||||
title="成功下单"
|
||||
title="比价成功率"
|
||||
value={pct(compareSuccessRate)}
|
||||
hint={`成功次数 ÷(比价发起次数-中途退出次数);当前 ${fmtInt(comparisonSuccess)} ÷ ${fmtInt(comparisonSuccessRateDenominator)}。`}
|
||||
/>
|
||||
<StatCard
|
||||
title="下单次数"
|
||||
value={fmtInt(periodData?.comparison.ordered)}
|
||||
unit="单"
|
||||
unit="次"
|
||||
delta={comparisonOrderedDelta.value}
|
||||
deltaTone={comparisonOrderedDelta.tone}
|
||||
hint="按比价记录页同一口径:用户有通过比价产生的下单记录,且店名与比价记录店名一致,则这条比价记录记为已下单。"
|
||||
/>
|
||||
<StatCard
|
||||
title="平均耗时"
|
||||
value={fmtMsAsSeconds(averageDurationMs)}
|
||||
title="耗时中位数"
|
||||
value={fmtMsAsSeconds(comparisonMedianMs)}
|
||||
unit="秒"
|
||||
delta={durationDeltaInfo.value}
|
||||
deltaTone={durationDeltaInfo.tone}
|
||||
hint="按比价记录页同一口径:取所选日期范围内已上报耗时的比价记录,求平均后换算为秒。"
|
||||
hint="只统计成功和失败的比价记录,中途退出不参与耗时统计。"
|
||||
/>
|
||||
<StatCard
|
||||
title="耗时 95% 分位数"
|
||||
value={fmtMsAsSeconds(comparisonP95Ms)}
|
||||
unit="秒"
|
||||
hint="只统计成功和失败的比价记录;95% 的有效比价耗时不超过该值。"
|
||||
/>
|
||||
<StatCard
|
||||
title="平均节省金额"
|
||||
@@ -789,7 +959,7 @@ export default function DashboardPage() {
|
||||
<h3>领券核心数据</h3>
|
||||
<span>发起、整场成功、点位成功与效率</span>
|
||||
</div>
|
||||
<div className="grid g4">
|
||||
<div className="grid g5">
|
||||
<StatCard
|
||||
title="领券发起数"
|
||||
value={fmtInt(couponPeriod?.started)}
|
||||
@@ -825,21 +995,27 @@ export default function DashboardPage() {
|
||||
deltaTone={couponMedianDelta.tone}
|
||||
hint="仅统计「完成」的领券(同领券数据页口径),中位数比均值更抗中途暂停等待的长尾。"
|
||||
/>
|
||||
<StatCard
|
||||
title="耗时 95% 分位数"
|
||||
value={fmtMsAsSeconds(couponP95Ms)}
|
||||
unit="秒"
|
||||
hint="仅统计「完成」的领券;95% 的完成场次耗时不超过该值。"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<span className="bar" />
|
||||
<h3>变现</h3>
|
||||
<h3>商业化</h3>
|
||||
<span>
|
||||
{cpsAvailable
|
||||
? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入')
|
||||
: (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sub-label">总收益</div>
|
||||
<div className="grid g4">
|
||||
<div className="sub-label">核心数据</div>
|
||||
<div className="grid g5">
|
||||
<RevenueCard
|
||||
title="总收入"
|
||||
value={adLoading ? '--' : fmtYuan(totalRevenueYuan)}
|
||||
@@ -851,44 +1027,36 @@ export default function DashboardPage() {
|
||||
{ label: 'CPS 收入', value: cpsRevenueText },
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="广告收入"
|
||||
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
|
||||
delta={adRevenueDelta.value}
|
||||
deltaTone={adRevenueDelta.tone}
|
||||
meta={[
|
||||
{
|
||||
label: '占总收入',
|
||||
value:
|
||||
totalRevenueYuan && totalRevenueYuan > 0 && adReport?.total_revenue_yuan != null
|
||||
? `${((adReport.total_revenue_yuan / totalRevenueYuan) * 100).toFixed(1)}%`
|
||||
: '--',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="CPS 收入"
|
||||
value={cpsRevenueText}
|
||||
delta={cpsRevenueDelta.value}
|
||||
deltaTone={cpsRevenueDelta.tone}
|
||||
meta={[
|
||||
{
|
||||
label: '占总收入',
|
||||
value:
|
||||
totalRevenueYuan && totalRevenueYuan > 0 && totalCpsCommissionCents != null
|
||||
? `${(((totalCpsCommissionCents / 100) / totalRevenueYuan) * 100).toFixed(1)}%`
|
||||
: '--',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="ARPU"
|
||||
value={adLoading ? '--' : fmtYuan(arpuYuan)}
|
||||
meta={[
|
||||
{ label: '口径', value: '总收入 / 昨日活跃用户' },
|
||||
{ label: '分母', value: yesterdayActiveUsers == null ? '--' : `${fmtInt(yesterdayActiveUsers)} 人` },
|
||||
{ label: '口径', value: '总收入 / 区间活跃用户' },
|
||||
{ label: '分母', value: periodActiveUsers == null ? '--' : `${fmtInt(periodActiveUsers)} 人` },
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="TOKEN 成本"
|
||||
value={fmtYuan(tokenCostYuan)}
|
||||
meta={[
|
||||
{ label: '口径', value: '区间比价记录 LLM 实际成本合计' },
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="发放金币成本"
|
||||
value={fmtYuan(coinCostYuan)}
|
||||
meta={[
|
||||
{ label: '口径', value: '本期发放金币 / 10000' },
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="毛利 ARPU"
|
||||
value={fmtYuan(grossArpuYuan)}
|
||||
meta={[
|
||||
{ label: '口径', value: '(总收入-TOKEN 成本-金币成本)/ DAU' },
|
||||
]}
|
||||
accent={grossArpuYuan != null && grossArpuYuan < 0 ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sub-label cps-sub-label">
|
||||
@@ -934,10 +1102,6 @@ export default function DashboardPage() {
|
||||
<RevenueCard
|
||||
title="总广告收益"
|
||||
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
|
||||
meta={[
|
||||
{ label: '平均 eCPM', value: adLoading ? '--' : fmtYuan(adAvgEcpm ?? undefined) },
|
||||
{ label: '展示数', value: adLoading ? '--' : fmtInt(adReport?.total_impressions) },
|
||||
]}
|
||||
accent="green"
|
||||
/>
|
||||
<RevenueCard
|
||||
@@ -957,7 +1121,7 @@ export default function DashboardPage() {
|
||||
]}
|
||||
/>
|
||||
<RevenueCard
|
||||
title="激励视频广告"
|
||||
title="看视频广告"
|
||||
value={rewardVideoAdText}
|
||||
meta={[
|
||||
{ label: '平均 eCPM', value: rewardVideoEcpm },
|
||||
@@ -995,11 +1159,11 @@ export default function DashboardPage() {
|
||||
deltaTone={comparisonCoinRatio.tone}
|
||||
/>
|
||||
<StatCard
|
||||
title="激励视频金币"
|
||||
title="看视频金币"
|
||||
value={fmtInt(periodData?.coins.reward_video_coin_total)}
|
||||
delta={rewardVideoCoinRatio.value}
|
||||
deltaTone={rewardVideoCoinRatio.tone}
|
||||
hint="独立统计激励视频发放,不再重复计入领券奖励。"
|
||||
hint="独立统计看视频发放,不再重复计入领券奖励。"
|
||||
/>
|
||||
<StatCard
|
||||
title="常规任务金币"
|
||||
@@ -1018,7 +1182,7 @@ export default function DashboardPage() {
|
||||
⚠️
|
||||
<div>
|
||||
本期 4 项奖励合计发放 <b>{fmtCoinAmount(periodData?.coins.granted_total)}</b> 金币
|
||||
(账面负债 ≈ <b>{fmtYuan((periodData?.coins.granted_total ?? 0) / 1000)}</b>),
|
||||
(账面负债 ≈ <b>{fmtYuan((periodData?.coins.granted_total ?? 0) / 10000)}</b>),
|
||||
实际提现兑付 <b>{fmtCents(periodData?.cash.withdraw_success_cents)}</b>。
|
||||
累计已发放 <b>{fmtCoinAmount(data.coins.granted_total)}</b> 金币但累计真实提现仅
|
||||
<b>{fmtCents(data.cash.withdraw_success_cents)}</b>,发放与现金兑付背离属正常(金币沉淀/过期);
|
||||
@@ -1111,6 +1275,16 @@ export default function DashboardPage() {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.dashboard-date-picker {
|
||||
min-width: 240px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dashboard-date-picker:hover {
|
||||
border-color: var(--brand-blue);
|
||||
}
|
||||
.dashboard-date-picker input {
|
||||
cursor: pointer;
|
||||
}
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
|
||||
+10
-13
@@ -10,6 +10,7 @@ import {
|
||||
FlagOutlined,
|
||||
GiftOutlined,
|
||||
HeartOutlined,
|
||||
LineChartOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MoneyCollectOutlined,
|
||||
@@ -52,11 +53,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: '看板',
|
||||
children: [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||
{ key: '/coupon-data', icon: <GiftOutlined />, label: '领券数据' },
|
||||
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
|
||||
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
||||
{ key: '/coupon-data', icon: <GiftOutlined />, label: '领券记录' },
|
||||
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
|
||||
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
||||
{ key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -143,19 +145,14 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
collapsedWidth={72}
|
||||
width={220}
|
||||
onCollapse={setCollapsed}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: '100vh',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 48,
|
||||
margin: 16,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
lineHeight: '48px',
|
||||
}}
|
||||
>
|
||||
运营后台
|
||||
</div>
|
||||
<nav className={`side-nav${collapsed ? ' side-nav-collapsed' : ''}`} aria-label="后台导航">
|
||||
{collapsed
|
||||
? flatNavItems.map((item) => (
|
||||
|
||||
@@ -624,12 +624,27 @@ export default function WithdrawsPage() {
|
||||
const rejectAmount = rejectTargets.reduce((sum, item) => sum + item.amount_cents, 0);
|
||||
const ledgerIssues = ledger
|
||||
? [
|
||||
ledger.balance_diff_cents ? `余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
|
||||
ledger.missing_withdraw_txn_count ? `缺扣款流水 ${ledger.missing_withdraw_txn_count} 笔` : '',
|
||||
ledger.missing_refund_txn_count ? `缺退款流水 ${ledger.missing_refund_txn_count} 笔` : '',
|
||||
ledger.duplicate_refund_txn_count ? `重复退款流水 ${ledger.duplicate_refund_txn_count} 类` : '',
|
||||
// 普通现金账(coin_cash)
|
||||
ledger.balance_diff_cents ? `现金余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
|
||||
ledger.missing_withdraw_txn_count ? `现金缺扣款流水 ${ledger.missing_withdraw_txn_count} 笔` : '',
|
||||
ledger.missing_refund_txn_count ? `现金缺退款流水 ${ledger.missing_refund_txn_count} 笔` : '',
|
||||
ledger.duplicate_refund_txn_count ? `现金重复退款流水 ${ledger.duplicate_refund_txn_count} 类` : '',
|
||||
ledger.refund_txn_on_non_terminal_count
|
||||
? `非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count} 笔`
|
||||
? `现金非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count} 笔`
|
||||
: '',
|
||||
// 邀请奖励金账(invite_cash)
|
||||
ledger.invite_balance_diff_cents ? `邀请金余额差额 ${yuan(ledger.invite_balance_diff_cents)}` : '',
|
||||
ledger.invite_missing_withdraw_txn_count
|
||||
? `邀请金缺扣款流水 ${ledger.invite_missing_withdraw_txn_count} 笔`
|
||||
: '',
|
||||
ledger.invite_missing_refund_txn_count
|
||||
? `邀请金缺退款流水 ${ledger.invite_missing_refund_txn_count} 笔`
|
||||
: '',
|
||||
ledger.invite_duplicate_refund_txn_count
|
||||
? `邀请金重复退款流水 ${ledger.invite_duplicate_refund_txn_count} 类`
|
||||
: '',
|
||||
ledger.invite_refund_txn_on_non_terminal_count
|
||||
? `邀请金非退款终态存在退款流水 ${ledger.invite_refund_txn_on_non_terminal_count} 笔`
|
||||
: '',
|
||||
].filter(Boolean)
|
||||
: [];
|
||||
@@ -669,7 +684,7 @@ export default function WithdrawsPage() {
|
||||
message={ledger.ok ? '现金账本校验正常' : '现金账本存在异常'}
|
||||
description={
|
||||
ledger.ok
|
||||
? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}`
|
||||
? `现金 余额 ${yuan(ledger.cash_balance_total_cents)} / 流水 ${yuan(ledger.cash_transaction_total_cents)};邀请金 余额 ${yuan(ledger.invite_cash_balance_total_cents)} / 流水 ${yuan(ledger.invite_cash_transaction_total_cents)}`
|
||||
: ledgerIssues.join(';')
|
||||
}
|
||||
action={
|
||||
|
||||
+40
-2
@@ -3,11 +3,12 @@
|
||||
export interface AdminInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string; // super_admin / finance / operator / 自定义角色名
|
||||
role: string; // super_admin / finance / operator / custom(自定义) / 自定义角色名
|
||||
status: string;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
pages?: string[]; // 当前角色有效可见页 key(登录 / /me 下发);super_admin = 全部页。左侧导航按此过滤
|
||||
pages?: string[]; // 当前有效可见页 key(登录 / /me 下发);super_admin = 全部页;role==custom = pages_override。左侧导航按此过滤
|
||||
pages_override?: string[] | null; // 「自定义」勾选的可见页原始集(role==custom 时非空);列表接口下发,供编辑回填勾选
|
||||
password?: string | null; // 明文登录密码(仅账号列表下发);无留存(脚本建的超管)为 null → 不显示
|
||||
}
|
||||
|
||||
@@ -233,6 +234,7 @@ export interface WithdrawBulkResult {
|
||||
|
||||
export interface WithdrawLedgerCheck {
|
||||
ok: boolean;
|
||||
// 普通现金账(coin_cash)
|
||||
cash_balance_total_cents: number;
|
||||
cash_transaction_total_cents: number;
|
||||
balance_diff_cents: number;
|
||||
@@ -240,6 +242,14 @@ export interface WithdrawLedgerCheck {
|
||||
missing_refund_txn_count: number;
|
||||
duplicate_refund_txn_count: number;
|
||||
refund_txn_on_non_terminal_count: number;
|
||||
// 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
|
||||
invite_cash_balance_total_cents: number;
|
||||
invite_cash_transaction_total_cents: number;
|
||||
invite_balance_diff_cents: number;
|
||||
invite_missing_withdraw_txn_count: number;
|
||||
invite_missing_refund_txn_count: number;
|
||||
invite_duplicate_refund_txn_count: number;
|
||||
invite_refund_txn_on_non_terminal_count: number;
|
||||
}
|
||||
|
||||
export interface WxpayHealthCheck {
|
||||
@@ -344,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;
|
||||
}
|
||||
|
||||
@@ -385,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 {
|
||||
@@ -717,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)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user