Compare commits

..

7 Commits

Author SHA1 Message Date
linkeyu 374ac1fd90 feat(admin): 审核菜单增加未处理数量徽标 2026-07-21 16:04:29 +08:00
linkeyu 3f5356ad87 feat(admin): 领券完成率和点位成功率展示计算明细 (#53)
## 变更内容
- 将汇总卡“领券成功率”明确为“领券完成率”
- 提示中动态展示完成数、发起数、中途退出数及本期代入计算结果
- 点位成功率提示逐券展示成功数、尝试数、单券成功率和最终等权平均结果
- 展示累计成功/尝试数作为辅助核对,并明确不作为当前加权公式

## 验证
- npx tsc --noEmit
- npm run build

---------

Co-authored-by: linkeyu <798648091@qq.com>
Reviewed-on: #53
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 11:59:50 +08:00
linkeyu c90775d09c fix(admin): 领券广告收益未填充时显示明确文案 (#54)
原 MR #52 已合并到功能分支,无法修改目标分支。本 MR 将同一项“领券广告收益未填充时显示明确文案”修复独立提交到 main,不包含后台看板功能分支的其他提交。

验证:npx tsc --noEmit;npm run build。

Reviewed-on: #54
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 11:59:35 +08:00
zuochenyong fb91dca192 feat(huawei-review): 华为审核开关配置页 (#51)
「数据配置」组下新增「华为审核开关」页,两态单选(不能关闭 / 可关闭)+ 保存,
展示当前生效值与最后修改时间。页面文案写清生效范围,避免过审后忘记切回:
仅华为 ROM 生效(荣耀 MagicOS 不受影响)、只影响快速设置步、客户端重进 App 才变。

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

---------

Co-authored-by: 左辰勇 <exinglang@gmail.com>
Reviewed-on: #51
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-21 10:49:10 +08:00
linkeyu 86086b1275 feat(admin): 优化后台看板及数据记录页(日期可选) (#50)
## 修改内容
- 数据大盘顶部日期改为可点击、可选择的日期范围控件
- 点击日期区域强制展开日历,选中后自动刷新看板
- 日期完整显示为 `YYYY-MM-DD`,禁选今日及未来日期
- 固定后台侧边栏并调整菜单顺序
- 优化数据大盘比价、领券、商业化核心指标及趋势
- 完善领券记录、比价记录、广告收益页面的筛选、统计口径和明细列

## 关键口径
- 比价完成次数:成功 + 失败,不包含中途退出
- 比价成功率:成功次数 /(发起次数 - 中途退出次数)
- 领券成功率:完成数 /(发起数 - 中途退出数)
- 比价耗时统计排除中途退出

## 验证
- `npm run build` 通过
- TypeScript、Lint、静态页面生成通过
- 旧合并请求 #49 已关闭且未合并

## 后端依赖
- 领券逐场点位成功率及逐点明细仍需接口返回相应字段
- 广告逐条明细的来源广告网络仍需 `sub_rewards` 返回 ADN 字段

---------

Co-authored-by: linkeyu <798648091@qq.com>
Reviewed-on: #50
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 10:11:11 +08:00
guke 327b043b89 feat(comparison-records): 比价记录展示 LLM 实际成本(列表列 + 详情 + 价格快照) (#48)
调整LLM价格快照展示样式

---------

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

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

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #47
2026-07-13 17:35:17 +08:00
8 changed files with 942 additions and 369 deletions
+133 -53
View File
@@ -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,
@@ -31,7 +31,7 @@ import {
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import { formatUtcTime, percentile } from '@/lib/format';
import type {
AdRevenueDaily,
AdRevenueHourly,
@@ -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,7 +91,6 @@ const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`;
};
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
const COIN_PER_YUAN = 10000;
@@ -104,35 +110,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 +298,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 +329,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 +350,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 +379,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 +431,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 +469,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 +484,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 +506,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 +520,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 +578,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, false),
p50: percentile(counts, 0.5, false),
p95: percentile(counts, 0.95, false),
};
};
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 +639,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 +664,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 +870,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 +981,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 +997,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 }}
+233 -98
View File
@@ -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 { formatWallTime, percentile, yuan } from '@/lib/format';
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,7 @@ 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 fmtRate = (value: number | null) => (value == null ? '-' : `${(value * 100).toFixed(1)}%`);
// LLM 价格快照只展示 prices 里各模型的单价(元/每百万 token),忽略 mode/_source 等元字段。
function llmPriceRows(snapshot: Record<string, unknown> | null): string[] {
@@ -92,12 +95,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;
@@ -111,14 +122,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,
@@ -132,7 +142,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) => {
@@ -153,8 +218,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',
@@ -167,7 +272,8 @@ 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 || '-' },
// 产品文档指定展示“原平台”;字段名仍沿用后端 source_platform_name。
{ title: '原平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
{
title: '店',
dataIndex: 'store_name',
@@ -183,42 +289,17 @@ export default function ComparisonRecordsPage() {
onCell: () => ({ style: { whiteSpace: 'normal', wordBreak: 'break-word', verticalAlign: 'top' } }),
render: (v: string | null) => highlightMatch(v, applied.product),
},
{ title: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' },
{
title: '省',
key: 'saved',
width: 84,
render: (_, r) =>
r.saved_amount_cents == null ? '-' : (
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
),
},
{ title: '耗时', dataIndex: 'total_ms', width: 72, render: fmtMs },
{
title: '广告收益',
key: 'ad_revenue',
width: 96,
align: 'right',
render: (_, r) =>
r.ad_revenue_yuan > 0 ? (
<span style={{ color: '#3f8600' }}>¥{r.ad_revenue_yuan.toFixed(4)}</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
),
},
{ title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs },
{ title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' },
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
{
title: '重试',
dataIndex: 'retry_count',
width: 50,
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
},
{
title: 'Token(入/出)',
key: 'tokens',
width: 110,
render: (_, r) => fmtTok(r.input_tokens, r.output_tokens),
render: (_, r) => (
<span style={{ color: r.ad_revenue_yuan > 0 ? '#3f8600' : '#999' }}>
¥{r.ad_revenue_yuan.toFixed(4)}
</span>
),
},
{
title: '成本',
@@ -235,6 +316,23 @@ export default function ComparisonRecordsPage() {
: <span title="估算(顶部 LLM 单价 × token" style={{ color: '#999' }}>{fmtCost(est)}</span>;
},
},
{ title: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' },
{
title: '省',
key: 'saved',
width: 84,
render: (_, r) =>
r.saved_amount_cents == null ? '-' : (
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
),
},
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
{
title: 'TOKEN',
key: 'tokens',
width: 110,
render: (_, r) => fmtTok(r.input_tokens, r.output_tokens),
},
{
title: '机型/ROM',
key: 'device',
@@ -242,9 +340,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,
@@ -262,57 +359,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 }}
suffix="元/百万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"
+157 -96
View File
@@ -73,7 +73,7 @@ interface CouponDataRow {
started_at: string;
claimed_count: number | null;
trace_url: string | null;
ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
ad_revenue_yuan: number | null; // 本次领券看的信息流广告预估收益(元);未填充为 null
}
interface CouponDataReport {
date_from: string;
@@ -124,9 +124,9 @@ const fmtSec = (ms: number | null | undefined): string =>
const fmtPct = (v: number | null | undefined): string =>
v == null ? '-' : `${(v * 100).toFixed(1)}%`;
// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位
// 元(小数)→ "¥0.0050";空值表示 eCPM 根本未填充,真实 0 显示 ¥0.0000
const fmtYuan = (v: number | null | undefined): string =>
v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`;
v == null ? '未填充' : `¥${v.toFixed(4)}`;
// 一组按券行的 success_rate 算术平均(每张券等权;空值券跳过);无有效券 → null(显示 -)。
// 汇总卡「分平台/合计点位成功率」据此由按券明细上卷,口径与下方按券表一致。
@@ -137,13 +137,6 @@ const slotRateMean = (rows: CouponSlotRow[]): number | null => {
return rates.length ? rates.reduce((a, b) => a + b, 0) / rates.length : null;
};
// 汇总卡成功率口径 tooltip 文案
const FULL_RATE_HINT =
'整单成功率:一次领券勾选的平台全部至少领到一张的场次 ÷ 领券发起数;基数含未完成/失败/中途退出。';
const POINT_RATE_HINT =
'点位成功率(合计):当前区间内全部券成功率的算术平均(每张券等权);' +
'单券成功率=成功(含已领)÷尝试(不含跳过),源自领券每券记录。';
// 按券表:coupon_id 平台 → 中文
const SLOT_PLATFORM: Record<string, string> = {
'meituan-waimai': '美团',
@@ -346,7 +339,7 @@ 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:整视图联动),默认全选四态(= 现状,不改口径)。
@@ -359,6 +352,8 @@ 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);
@@ -380,26 +375,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,
status: statuses,
granularity: gran,
limit: targetLimit,
offset: (targetPage - 1) * targetLimit,
sort: targetSort,
},
// status 是数组:repeat 无方括号序列化(status=a&status=b),对齐 FastAPI list[str] Query
paramsSerializer: { indexes: null },
});
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);
// 后端 summary 基于 status 过滤后的 rows 计算,started_count 实际就是该结果集行数;
// 因此 abandoned-only 请求的 started_count 等于中途退出数。主请求可能受页面状态筛选,
// 全状态汇总又不返回 abandoned_count,当前需单独请求才能保持成功率分母稳定。
setAbandonedCount(abandonedRes.data.summary.started_count);
// 并行拉「按券成功率」(独立端点,失败不影响主报表)
api
.get<CouponSlotsOut>('/admin/api/coupon-data/coupons', {
params: { date_from: from, date_to: to, app_env: appEnv },
params: { date_from: from, date_to: to, app_env: appEnv === 'all' ? undefined : appEnv },
})
.then((r) => setCouponSlots(r.data.items))
.catch(() => setCouponSlots([]));
@@ -418,43 +442,32 @@ export default function CouponDataPage() {
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: '状态',
@@ -471,12 +484,6 @@ export default function CouponDataPage() {
width: 110,
render: (pkg: string | null) => originLabel(pkg),
},
{
title: '时间',
dataIndex: 'started_at',
width: 165,
render: (v: string) => formatUtcTime(v),
},
{
title: '耗时',
dataIndex: 'elapsed_ms',
@@ -484,12 +491,28 @@ 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),
render: (v: number | null) => fmtYuan(v),
},
{
title: '美团耗时',
@@ -522,9 +545,10 @@ export default function CouponDataPage() {
},
},
{
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 ? (
@@ -564,13 +588,20 @@ export default function CouponDataPage() {
},
];
const summary = data?.summary;
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 validCouponSlots = couponSlots.filter((r) => r.success_rate != null);
const pointSuccessRate = slotRateMean(validCouponSlots);
const pointSucceededTotal = validCouponSlots.reduce((total, r) => total + r.succeeded, 0);
const pointTriedTotal = validCouponSlots.reduce((total, r) => total + r.tried, 0);
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>
@@ -585,6 +616,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()] },
@@ -677,6 +709,64 @@ export default function CouponDataPage() {
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic
title={
<span>
{' '}
<Tooltip
overlayStyle={{ maxWidth: 520 }}
title={
<div>
<div> ÷退</div>
<div style={{ marginTop: 6 }}>
{summary.completed_count} ÷{summary.started_count}{abandonedCount}
{fmtPct(couponSuccessRate)}
</div>
</div>
}
>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(couponSuccessRate)}
/>
</Col>
<Col flex="1 1 0">
<Statistic
title={
<span>
{' '}
<Tooltip
overlayStyle={{ maxWidth: 560 }}
title={
<div>
<div></div>
<div>÷ </div>
<div style={{ marginTop: 6 }}>
{validCouponSlots.length}
</div>
{validCouponSlots.map((slot) => (
<div key={slot.coupon_id}>
{slot.coupon_name || slot.coupon_id}{slot.succeeded} ÷ {slot.tried}
{fmtPct(slot.success_rate)}
</div>
))}
<div style={{ marginTop: 6 }}>
{fmtPct(pointSuccessRate)}/
{pointSucceededTotal}/{pointTriedTotal}
</div>
</div>
}
>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(pointSuccessRate)}
/>
</Col>
<Col flex="1 1 0">
<Statistic title="领券发起数" value={summary.started_count} />
</Col>
@@ -703,35 +793,6 @@ export default function CouponDataPage() {
</Col>
</Row>
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic
title={
<span>
{' '}
<Tooltip title={FULL_RATE_HINT}>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(summary.full_success_rate)}
/>
</Col>
<Col flex="1 1 0">
<Statistic
title={
<span>
(){' '}
<Tooltip title={POINT_RATE_HINT}>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(slotRateMean(couponSlots))}
/>
</Col>
</Row>
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
<Row gutter={[16, 12]}>
{(['meituan-waimai', 'taobao-shanguang', 'jd-waimai'] as const).map((pid) => {
const active = selectedSlotPlatform === pid;
+186 -105
View File
@@ -1,9 +1,9 @@
'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';
@@ -124,11 +124,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 +340,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 +371,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 +424,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 +449,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 +462,22 @@ export default function DashboardPage() {
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [cpsSyncing, setCpsSyncing] = 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');
@@ -478,7 +487,16 @@ export default function DashboardPage() {
? fmtYuan((rewardVideoAdSummary.revenue_yuan / rewardVideoAdSummary.impressions) * 1000)
: '--';
const compareSuccessRate = periodData?.comparison.success_rate ?? null;
const averageDurationMs = periodData?.comparison.average_duration_ms ?? null;
const comparisonTotal = periodData?.comparison.total ?? null;
const comparisonCompleted = periodData?.comparison.completed ?? null;
const comparisonSuccess = periodData?.comparison.success ?? null;
const comparisonSuccessRateDenominator =
periodData == null || periodData.comparison.cancelled == null
? null
: periodData.comparison.total - periodData.comparison.cancelled;
const comparisonMedianMs = periodData?.comparison.median_duration_ms ?? null;
const comparisonP95Ms = periodData?.comparison.p95_duration_ms ?? null;
const tokenCostYuan = periodData?.comparison.token_cost_total_yuan ?? 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 +512,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 +538,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(
periodData?.comparison.total,
previousPeriodData?.comparison.total,
);
const comparisonSuccessDelta = percentDelta(
periodData?.comparison.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,
periodData?.comparison.median_duration_ms,
previousPeriodData?.comparison.median_duration_ms,
);
// 领券核心数据(period.coupon;旧后端无此字段时整节显示 '--')
const couponPeriod = periodData?.coupon ?? null;
@@ -548,14 +573,38 @@ 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;
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'),
// 与数据大盘其余卡片一致,不限定应用环境。
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 +723,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 +771,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 +778,6 @@ export default function DashboardPage() {
delta={newUserDelta.value}
deltaTone={newUserDelta.tone}
hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。"
spark="up"
/>
<StatCard
title={retentionTitle(period)}
@@ -723,14 +786,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 +803,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 +866,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 +902,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 +934,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 +1009,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 +1028,7 @@ export default function DashboardPage() {
]}
/>
<RevenueCard
title="激励视频广告"
title="视频广告"
value={rewardVideoAdText}
meta={[
{ label: '平均 eCPM', value: rewardVideoEcpm },
@@ -995,11 +1066,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 +1089,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 +1182,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;
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Alert, App, Button, Card, Descriptions, Radio, Skeleton, Tag } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
// 华为审核开关:控制新手引导「快速设置」权限步能否被用户退出。
// 华为应用市场审核要求该页必须可关闭(引导视频页不在要求内),平时保持「不能关闭」以保住权限开启率,
// 送审期间切「可关闭」。落在 app_config 的 huawei_review 行,客户端经 /api/v1/platform/huawei-review 拉。
type Mode = 'default' | 'review';
interface HuaweiReview {
mode: Mode;
updated_at: string | null;
}
const MODE_LABEL: Record<Mode, string> = {
default: '不能关闭',
review: '可关闭',
};
export default function HuaweiReviewPage() {
const { message } = App.useApp();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// saved = 服务端当前值(用于「有没有改动」判断);mode = 编辑中的值
const [saved, setSaved] = useState<HuaweiReview | null>(null);
const [mode, setMode] = useState<Mode>('default');
const load = useCallback(async () => {
try {
const r = await api.get<HuaweiReview>('/admin/api/huawei-review');
setSaved(r.data);
setMode(r.data.mode);
} catch (e) {
message.error(errMsg(e, '华为审核开关加载失败'));
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
load();
}, [load]);
const save = async () => {
setSaving(true);
try {
const r = await api.patch<HuaweiReview>('/admin/api/huawei-review', { mode });
setSaved(r.data);
setMode(r.data.mode);
message.success('已保存。客户端下次进新手引导时拉取生效');
} catch (e) {
message.error(errMsg(e, '保存失败'));
} finally {
setSaving(false);
}
};
const dirty = saved !== null && saved.mode !== mode;
return (
<Card
title="华为审核开关"
extra={
<Button type="primary" loading={saving} disabled={loading || !dirty} onClick={save}>
</Button>
}
>
<Alert
type="info"
style={{ marginBottom: 16 }}
message="华为应用市场审核要求「快速设置」页必须可以关闭。切到「可关闭」后,该页左上角出现退出按钮(返回键同样可退出),用户点了直接进 App 首页、本次引导视为已完成。"
description="① 只对华为机型(HarmonyOS / EMUI)生效,荣耀 MagicOS 及其它机型不受影响;② 只影响快速设置权限步,引导视频页照旧必须看完;③ 客户端在进引导前拉一次并缓存,已经停在引导页的用户要重进 App 才会变;④ 审核通过后记得切回「不能关闭」,不然会一直损失权限开启率。"
/>
{loading ? (
<Skeleton active paragraph={{ rows: 3 }} />
) : (
<>
<Descriptions column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="当前生效">
{saved ? (
<Tag color={saved.mode === 'review' ? 'orange' : 'green'}>{MODE_LABEL[saved.mode]}</Tag>
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="最后修改">
{saved?.updated_at ? formatUtcTime(saved.updated_at) : '从未修改过(默认不能关闭)'}
</Descriptions.Item>
</Descriptions>
<div style={{ marginBottom: 8 }}></div>
<Radio.Group value={mode} onChange={(e) => setMode(e.target.value as Mode)}>
<Radio.Button value="default"></Radio.Button>
<Radio.Button value="review"></Radio.Button>
</Radio.Group>
</>
)}
</Card>
);
}
+110 -16
View File
@@ -16,6 +16,7 @@ import {
MoneyCollectOutlined,
NotificationOutlined,
ProfileOutlined,
SafetyCertificateOutlined,
SettingOutlined,
ShareAltOutlined,
TeamOutlined,
@@ -24,7 +25,12 @@ import {
import { Avatar, Dropdown, Layout, Tooltip } from 'antd';
import { api } from '@/lib/api';
import { clearAuth, getAdmin, getToken, setAuth } from '@/lib/auth';
import type { AdminInfo } from '@/lib/types';
import type {
AdminInfo,
FeedbackSummary,
PriceReportSummary,
WithdrawSummary,
} from '@/lib/types';
const { Sider, Header, Content } = Layout;
@@ -34,6 +40,8 @@ type NavItem = {
label: string;
};
type ReviewNavKey = '/withdraws' | '/price-reports' | '/feedbacks';
type NavGroup =
| (NavItem & { children?: never })
| {
@@ -53,9 +61,9 @@ 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: '埋点成功率' },
@@ -78,6 +86,7 @@ const NAV_GROUPS: NavGroup[] = [
children: [
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' },
{ key: '/huawei-review', icon: <SafetyCertificateOutlined />, label: '华为审核开关' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
],
},
@@ -94,6 +103,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
const pathname = usePathname();
const [admin, setAdmin] = useState<AdminInfo | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [pendingReviewCounts, setPendingReviewCounts] = useState<Partial<Record<ReviewNavKey, number>>>({});
useEffect(() => {
const token = getToken();
@@ -112,6 +122,49 @@ export default function MainLayout({ children }: { children: React.ReactNode })
.catch(() => {});
}, [router]);
useEffect(() => {
if (!admin) return;
let cancelled = false;
const canAccess = (page: string) =>
admin.role === 'super_admin' || !admin.pages || admin.pages.includes(page);
const refresh = async () => {
const [withdraws, priceReports, feedbacks] = await Promise.all([
canAccess('withdraws')
? api.get<WithdrawSummary>('/admin/api/withdraws/summary')
.then(({ data }) => data.reviewing_count)
.catch(() => null)
: Promise.resolve(null),
canAccess('price-reports')
? api.get<PriceReportSummary>('/admin/api/price-reports/summary')
.then(({ data }) => data.pending)
.catch(() => null)
: Promise.resolve(null),
canAccess('feedbacks')
? api.get<FeedbackSummary>('/admin/api/feedbacks/summary')
.then(({ data }) => data.pending)
.catch(() => null)
: Promise.resolve(null),
]);
if (cancelled) return;
setPendingReviewCounts((current) => ({
...current,
...(withdraws == null ? {} : { '/withdraws': withdraws }),
...(priceReports == null ? {} : { '/price-reports': priceReports }),
...(feedbacks == null ? {} : { '/feedbacks': feedbacks }),
}));
};
void refresh();
const timer = window.setInterval(refresh, 30_000);
window.addEventListener('focus', refresh);
return () => {
cancelled = true;
window.clearInterval(timer);
window.removeEventListener('focus', refresh);
};
}, [admin, pathname]);
if (!admin) return null; // 守卫期间不闪烁内容
// 选中态:取路径一级(/users/123 -> /users)
@@ -130,6 +183,20 @@ export default function MainLayout({ children }: { children: React.ReactNode })
hasChildren(group) ? group.children : [group],
);
const reviewBadge = (key: string) => {
const count = pendingReviewCounts[key as ReviewNavKey] ?? 0;
if (count <= 0) return null;
return (
<span
className="nav-review-badge"
aria-label={`${count} 条未审核`}
title={`${count} 条未审核`}
>
{count}
</span>
);
};
const logout = () => {
clearAuth();
router.replace('/login');
@@ -145,19 +212,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) => (
@@ -169,6 +231,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
aria-label={item.label}
>
{item.icon}
{reviewBadge(item.key)}
</button>
</Tooltip>
))
@@ -186,7 +249,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
onClick={() => router.push(group.key)}
>
<span className="nav-primary-icon">{group.icon}</span>
<span>{group.label}</span>
<span className="nav-item-label">{group.label}</span>
{reviewBadge(group.key)}
</button>
);
}
@@ -205,7 +269,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
onClick={() => router.push(child.key)}
>
<span className="nav-child-icon">{child.icon}</span>
<span>{child.label}</span>
<span className="nav-item-label">{child.label}</span>
{reviewBadge(child.key)}
</button>
))}
</div>
@@ -272,6 +337,29 @@ export default function MainLayout({ children }: { children: React.ReactNode })
color: rgba(255, 255, 255, 0.72);
font-size: 16px;
}
.nav-item-label {
min-width: 0;
flex: 1;
}
.nav-review-badge {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex: 0 0 auto;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ff4d4f;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.nav-direct,
.nav-child,
.nav-icon-button {
@@ -335,6 +423,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
padding: 0 8px 14px;
}
.nav-icon-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -345,6 +434,11 @@ export default function MainLayout({ children }: { children: React.ReactNode })
color: rgba(255, 255, 255, 0.72);
font-size: 17px;
}
.nav-icon-button .nav-review-badge {
position: absolute;
top: 1px;
right: -6px;
}
.nav-icon-button:hover,
.nav-icon-button.is-selected {
background: #1677ff;
+14
View File
@@ -23,6 +23,20 @@ const TZ = 'Asia/Shanghai';
/** 金额:分 → ¥元(两位小数)。 */
export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
/**
* 线性插值分位数。
* 耗时等整数指标默认四舍五入;计数分位需要保留插值小数时传 round=false。
*/
export function percentile(values: readonly number[], q: number, round = true): number | null {
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);
const result = sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo);
return round ? Math.round(result) : result;
}
const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */
+6 -1
View File
@@ -556,11 +556,16 @@ export interface DashboardOverview {
};
comparison: {
total: number;
completed?: number;
cancelled?: number;
success: number;
success_rate: number;
success_rate: number | null;
ordered: number;
average_duration_ms: number | null;
median_duration_ms?: number | null;
p95_duration_ms?: number | null;
average_saved_cents: number | null;
token_cost_total_yuan?: number;
};
// 领券核心数据(2026-07-05 新增;旧后端无此字段,前端 `?.` 探测)。
// 点位=一张券;成功口径 success+already_claimed;点位成功率分母=发起数×应领点位数(未跑到视为失败)。