Compare commits

...

5 Commits

Author SHA1 Message Date
linkeyu f25d5f0b17 fix(dashboard): consume backend comparison aggregates 2026-07-20 20:01:38 +08:00
linkeyu 30dad5d4e0 fix(admin): address dashboard review feedback 2026-07-20 10:36:06 +08:00
linkeyu 7ab623d6aa feat(admin): 优化后台看板及数据记录页 2026-07-19 11:58:36 +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 740 additions and 364 deletions
+133 -53
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
App, App,
@@ -31,7 +31,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api'; import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format'; import { formatUtcTime, percentile } from '@/lib/format';
import type { import type {
AdRevenueDaily, AdRevenueDaily,
AdRevenueHourly, AdRevenueHourly,
@@ -46,11 +46,11 @@ const { RangePicker } = DatePicker;
// 广告类型标签 // 广告类型标签
const TYPE_TAG: Record<string, { color: string; label: string }> = { const TYPE_TAG: Record<string, { color: string; label: string }> = {
reward_video: { color: 'blue', label: '激励视频' }, reward_video: { color: 'blue', label: '视频' },
// 历史误标 feed(领券/比价广告修 adType 之前上报)一律按 Draw 信息流显示——业务已全切 Draw // 历史误标 feed(领券/比价广告修 adType 之前上报)一律按 Draw 信息流显示——业务已全切 Draw
feed: { color: 'geekblue', label: 'Draw 信息流' }, feed: { color: 'geekblue', label: 'Draw 信息流' },
draw: { 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) // 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秒' }, too_short: { color: 'gold', label: '未满10秒' },
closed_early: { color: 'default', label: '提前关闭' }, 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) => { const fmtFactorRange = (a: number | null, b: number | null) => {
if (a == null) return '-'; if (a == null) return '-';
@@ -84,7 +91,6 @@ const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-'; if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`; return a === b ? String(a) : `${a}${b}`;
}; };
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。 // 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。 // 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
const COIN_PER_YUAN = 10000; const COIN_PER_YUAN = 10000;
@@ -104,35 +110,37 @@ const LT_FACTOR_ROWS = [
{ key: '5', lt: '第 11 条及以后', factor: 1.0 }, { key: '5', lt: '第 11 条及以后', factor: 1.0 },
]; ];
type AdRevenueDetailRow = AdRevenueRecord & { source_adn?: string | null };
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列) // 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [ const DETAIL_COLUMNS: ColumnsType<AdRevenueDetailRow> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 }, { title: '来源广告网络', dataIndex: 'source_adn', width: 110, render: (value: string | null) => value || '-' },
{ {
title: '状态', title: 'eCPM(元)',
dataIndex: 'status', dataIndex: 'ecpm',
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',
width: 100, 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', title: '因子2',
key: 'lt_factor', key: 'lt_factor',
width: 90, 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,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴), // 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴),
@@ -290,19 +298,20 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
// 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。 // 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。
export default function AdRevenueReportPage() { export default function AdRevenueReportPage() {
const { message } = App.useApp(); 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 [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>(); const [adType, setAdType] = useState<string | undefined>();
// 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。 // 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。
const [scene, setScene] = useState<string | undefined>(); const [scene, setScene] = useState<string | undefined>();
const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序 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 [page, setPage] = useState<number>(1); // 当前页码(后端分页;1 起)
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
const [data, setData] = useState<AdRevenueReport | null>(null); 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 [loading, setLoading] = useState(false);
const [formulaOpen, setFormulaOpen] = useState(false); const [formulaOpen, setFormulaOpen] = useState(false);
// 点用户手机号弹出的「用户广告收益详情」半屏抽屉(userId + 手机号;null=关闭) // 点用户手机号弹出的「用户广告收益详情」半屏抽屉(userId + 手机号;null=关闭)
@@ -320,17 +329,20 @@ export default function AdRevenueReportPage() {
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天 const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
setLoading(true); setLoading(true);
try { 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', { const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: { params: {
date_from: from, ...baseParams,
date_to: to,
user_id: userId ?? undefined,
ad_type: adType ?? undefined,
feed_scene: scene ?? undefined,
granularity: gran,
limit: targetLimit, limit: targetLimit,
offset: (targetPage - 1) * targetLimit, offset: (targetPage - 1) * targetLimit,
sort: targetSort,
}, },
}); });
setData(res.data); setData(res.data);
@@ -338,6 +350,24 @@ export default function AdRevenueReportPage() {
setQueriedLimit(targetLimit); setQueriedLimit(targetLimit);
setQueriedGranularity(gran); setQueriedGranularity(gran);
setQueriedMultiDay(multiDay); 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) { } catch (e) {
message.error(errMsg(e)); message.error(errMsg(e));
} finally { } finally {
@@ -349,11 +379,11 @@ export default function AdRevenueReportPage() {
useEffect(() => { useEffect(() => {
load(); load();
// 仅首次自动拉今天;之后由「查询」按钮触发 // 仅首次自动拉昨日;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const columns: ColumnsType<AdRevenueRow> = [ const rawColumns: ColumnsType<AdRevenueRow> = [
...(queriedMultiDay ...(queriedMultiDay
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]] ? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
: []), : []),
@@ -401,7 +431,10 @@ export default function AdRevenueReportPage() {
title: '场景', title: '场景',
dataIndex: 'feed_scene', dataIndex: 'feed_scene',
width: 80, 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>; if (!v) return <Typography.Text type="secondary">-</Typography.Text>;
const t = SCENE_TAG[v]; const t = SCENE_TAG[v];
return t ? <Tag color={t.color}>{t.text}</Tag> : <Tag>{v}</Tag>; 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', dataIndex: 'revenue_yuan',
width: 110, width: 110,
align: 'right', align: 'right',
// 一次比价/领券聚合行用 row_revenue_yuan(该次发奖广告 eCPM 折算之和),其它行用展示侧 revenue_yuan // 一次比价/领券聚合行用 row_revenue_yuan(该次发奖广告 eCPM 折算之和),其它行用展示侧 revenue_yuan
render: (v: number, r: AdRevenueRow) => { render: (v: number, r: AdRevenueRow) => {
const rev = r.row_revenue_yuan ?? v; 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', dataIndex: 'status',
width: 100, width: 100,
render: (s: string | null) => { 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 }; 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>, r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
}, },
{ {
title: '广告位ID', title: '广告位',
dataIndex: 'our_code_id', dataIndex: 'our_code_id',
width: 110, width: 110,
render: (v: string | null) => render: (v: string | null) =>
@@ -487,6 +520,26 @@ export default function AdRevenueReportPage() {
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>, 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_* 字段,不受分页影响,准): // 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益; // 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
@@ -525,11 +578,24 @@ export default function AdRevenueReportPage() {
// 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。 // 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。
const items = data?.items ?? []; 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 ( return (
<div> <div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}> <Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}></h2> <h2 style={{ margin: 0 }}>广</h2>
<Button <Button
type="link" type="link"
size="small" size="small"
@@ -573,6 +639,7 @@ export default function AdRevenueReportPage() {
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])} onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false} allowClear={false}
presets={[ presets={[
{ label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
{ label: '今天', value: [dayjs(), dayjs()] }, { label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] }, { label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] }, { label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
@@ -597,9 +664,9 @@ export default function AdRevenueReportPage() {
allowClear allowClear
style={{ width: 150 }} style={{ width: 150 }}
options={[ options={[
{ value: 'reward_video', label: '激励视频' }, { value: 'reward_video', label: '视频' },
{ value: 'draw', label: 'Draw 信息流' }, { value: 'draw', label: 'Draw 信息流' },
{ value: 'withdrawal_video', label: '提现激励视频' }, { value: 'withdrawal_video', label: '提现视频' },
]} ]}
/> />
</Space> </Space>
@@ -803,15 +870,28 @@ export default function AdRevenueReportPage() {
<Statistic title="Draw 信息流条数" value={drawStat?.impressions ?? 0} /> <Statistic title="Draw 信息流条数" value={drawStat?.impressions ?? 0} />
</Col> </Col>
<Col span={4}> <Col span={4}>
<Statistic title="激励视频收益(元)" value={rvStat?.revenue_yuan ?? 0} precision={4} /> <Statistic title="视频收益(元)" value={rvStat?.revenue_yuan ?? 0} precision={4} />
</Col> </Col>
<Col span={4}> <Col span={4}>
<Statistic title="激励视频 eCPM(元/千次)" value={ecpmOf(rvStat)} precision={2} /> <Statistic title="视频 eCPM(元/千次)" value={ecpmOf(rvStat)} precision={2} />
</Col> </Col>
<Col span={4}> <Col span={4}>
<Statistic title="激励视频条数" value={rvStat?.impressions ?? 0} /> <Statistic title="视频条数" value={rvStat?.impressions ?? 0} />
</Col> </Col>
</Row> </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> </Card>
)} )}
@@ -901,11 +981,11 @@ export default function AdRevenueReportPage() {
<Typography.Text type="secondary"> <Typography.Text type="secondary">
( {r.sub_count ?? r.sub_rewards.length} · {r.expected_coin} / {r.actual_coin}) ( {r.sub_count ?? r.sub_rewards.length} · {r.expected_coin} / {r.actual_coin})
</Typography.Text> </Typography.Text>
<Table<AdRevenueRecord> <Table<AdRevenueDetailRow>
style={{ marginTop: 8 }} style={{ marginTop: 8 }}
rowKey="record_id" rowKey="record_id"
columns={DETAIL_COLUMNS} columns={DETAIL_COLUMNS}
dataSource={r.sub_rewards} dataSource={r.sub_rewards.map((detail) => ({ ...detail, source_adn: r.adn }))}
pagination={false} pagination={false}
size="small" size="small"
scroll={{ x: 900 }} scroll={{ x: 900 }}
@@ -917,11 +997,11 @@ export default function AdRevenueReportPage() {
<Typography.Text type="secondary"> <Typography.Text type="secondary">
(广 {r.reward_detail.expected_coin} / {r.reward_detail.actual_coin}) (广 {r.reward_detail.expected_coin} / {r.reward_detail.actual_coin})
</Typography.Text> </Typography.Text>
<Table<AdRevenueRecord> <Table<AdRevenueDetailRow>
style={{ marginTop: 8 }} style={{ marginTop: 8 }}
rowKey="record_id" rowKey="record_id"
columns={DETAIL_COLUMNS} columns={DETAIL_COLUMNS}
dataSource={[r.reward_detail]} dataSource={[{ ...r.reward_detail, source_adn: r.adn }]}
pagination={false} pagination={false}
size="small" size="small"
scroll={{ x: 900 }} scroll={{ x: 900 }}
+268 -100
View File
@@ -1,19 +1,21 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { import {
App, Button, Card, Collapse, Descriptions, Drawer, Input, InputNumber, App, Button, Card, Col, Collapse, DatePicker, Descriptions, Divider, Drawer, Input, InputNumber,
Select, Space, Spin, Table, Tag, Typography, Row, Select, Space, Spin, Statistic, Table, Tag, Typography,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api'; import { api, errMsg } from '@/lib/api';
import { formatWallTime, yuan } from '@/lib/format'; import { formatWallTime, percentile, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList'; import type { ComparisonRecordDetail, ComparisonRecordListItem, CursorPage } from '@/lib/types';
import type { ComparisonRecordDetail, ComparisonRecordListItem } from '@/lib/types';
const { RangePicker } = DatePicker;
const STATUS_COLOR: Record<string, string> = { success: 'green', failed: 'red', cancelled: 'default' }; 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 翻成人话(找店/加菜/起送/读价) // 「卡在哪一步」: platform_results[*].status 翻成人话(找店/加菜/起送/读价)
const STUCK_LABEL: Record<string, string> = { const STUCK_LABEL: Record<string, string> = {
@@ -37,6 +39,16 @@ const calcCost = (inTok: number | null, outTok: number | null, price: number | n
const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`); const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`);
const fmtTok = (inTok: number | null, outTok: number | null) => const fmtTok = (inTok: number | null, outTok: number | null) =>
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`; inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
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 就缩进展开,否则原样。 // LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
function pretty(s: string | null): string { function pretty(s: string | null): string {
@@ -83,12 +95,20 @@ const preStyle: CSSProperties = {
export default function ComparisonRecordsPage() { export default function ComparisonRecordsPage() {
const { message } = App.useApp(); 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 [userId, setUserId] = useState<number | null>(null);
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [status, setStatus] = useState<string | undefined>(); const [status, setStatus] = useState<string | undefined>();
const [store, setStore] = useState(''); const [store, setStore] = useState('');
const [product, setProduct] = 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),本地持久化;仅用于前端估算成本,不入库、不影响其它页面 // LLM 单价(元/百万 token),本地持久化;仅用于前端估算成本,不入库、不影响其它页面
const [pricePerMTok, setPricePerMTok] = useState<number | null>(() => { const [pricePerMTok, setPricePerMTok] = useState<number | null>(() => {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
@@ -102,14 +122,13 @@ export default function ComparisonRecordsPage() {
else localStorage.setItem('llm_price_per_mtok', String(v)); 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 [detail, setDetail] = useState<ComparisonRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const search = () => const search = () =>
setApplied({ setApplied({
date_from: range[0].format('YYYY-MM-DD'),
date_to: range[1].format('YYYY-MM-DD'),
user_id: userId ?? undefined, user_id: userId ?? undefined,
phone: phone || undefined, phone: phone || undefined,
status, status,
@@ -123,7 +142,62 @@ export default function ComparisonRecordsPage() {
setStatus(undefined); setStatus(undefined);
setStore(''); setStore('');
setProduct(''); 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) => { const openDetail = async (id: number) => {
@@ -144,8 +218,48 @@ export default function ComparisonRecordsPage() {
setDetailLoading(false); 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> = [ const columns: ColumnsType<ComparisonRecordListItem> = [
{ title: 'ID', dataIndex: 'id', width: 64 }, { title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
{ {
title: '用户', title: '用户',
key: 'user', key: 'user',
@@ -158,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: '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: '店', title: '店',
dataIndex: 'store_name', dataIndex: 'store_name',
@@ -174,6 +289,33 @@ export default function ComparisonRecordsPage() {
onCell: () => ({ style: { whiteSpace: 'normal', wordBreak: 'break-word', verticalAlign: 'top' } }), onCell: () => ({ style: { whiteSpace: 'normal', wordBreak: 'break-word', verticalAlign: 'top' } }),
render: (v: string | null) => highlightMatch(v, applied.product), 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: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' },
{ {
title: '省', title: '省',
@@ -184,39 +326,13 @@ export default function ComparisonRecordsPage() {
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b> <b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
), ),
}, },
{
title: '广告收益',
key: 'ad_revenue',
width: 96,
align: 'right',
render: (_, r) =>
r.ad_revenue_yuan > 0 ? (
<span style={{ color: '#3f8600' }}>¥{r.ad_revenue_yuan.toFixed(4)}</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
),
},
{ title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs },
{ title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' },
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' }, { title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
{ {
title: '重试', title: 'TOKEN',
dataIndex: 'retry_count',
width: 50,
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
},
{
title: 'Token(入/出)',
key: 'tokens', key: 'tokens',
width: 110, width: 110,
render: (_, r) => fmtTok(r.input_tokens, r.output_tokens), 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', title: '机型/ROM',
key: 'device', key: 'device',
@@ -224,9 +340,8 @@ export default function ComparisonRecordsPage() {
ellipsis: true, ellipsis: true,
render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-', 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', key: 'op',
fixed: 'right', fixed: 'right',
width: 80, width: 80,
@@ -244,57 +359,95 @@ export default function ComparisonRecordsPage() {
return ( return (
<div> <div>
<h2>(debug)</h2> <h2></h2>
<Space style={{ marginBottom: 16 }} wrap> <Card size="small" style={{ marginBottom: 16 }}>
<InputNumber placeholder="user_id" value={userId} onChange={setUserId} style={{ width: 110 }} /> <Space wrap size="middle" align="center">
<Input <RangePicker
placeholder="手机号前缀" value={range}
value={phone} allowClear={false}
onChange={(e) => setPhone(e.target.value)} onChange={(value) => value?.[0] && value[1] && setRange([value[0], value[1]])}
onPressEnter={search} presets={[
allowClear { label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
style={{ width: 150 }} { label: '今天', value: [dayjs(), dayjs()] },
/> { label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
<Input { label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
placeholder="搜索店名" ]}
value={store} />
onChange={(e) => setStore(e.target.value)} <InputNumber placeholder="user_id" value={userId} onChange={setUserId} style={{ width: 110 }} />
onPressEnter={search} <Input
allowClear placeholder="手机号前缀"
style={{ width: 150 }} value={phone}
/> onChange={(e) => setPhone(e.target.value)}
<Input onPressEnter={search}
placeholder="搜索商品" allowClear
value={product} style={{ width: 150 }}
onChange={(e) => setProduct(e.target.value)} />
onPressEnter={search} <Input
allowClear placeholder="搜索店名"
style={{ width: 150 }} value={store}
/> onChange={(e) => setStore(e.target.value)}
<Select onPressEnter={search}
placeholder="状态" allowClear
value={status} style={{ width: 150 }}
onChange={setStatus} />
allowClear <Input
style={{ width: 110 }} placeholder="搜索商品"
options={[ value={product}
{ value: 'success', label: '成功' }, onChange={(e) => setProduct(e.target.value)}
{ value: 'failed', label: '失败' }, onPressEnter={search}
{ value: 'cancelled', label: '已终止' }, allowClear
]} style={{ width: 150 }}
/> />
<Button type="primary" onClick={search}></Button> <Select
<Button onClick={reset}></Button> placeholder="状态"
<InputNumber value={status}
placeholder="LLM 单价" onChange={setStatus}
value={pricePerMTok} allowClear
onChange={onPriceChange} style={{ width: 110 }}
min={0} options={[
step={0.1} { value: 'success', label: '成功' },
style={{ width: 210 }} { value: 'failed', label: '失败' },
addonAfter="元/百万token" { value: 'cancelled', label: '中途退出' },
/> ]}
</Space> />
<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 <Table
rowKey="id" rowKey="id"
@@ -331,12 +484,27 @@ export default function ComparisonRecordsPage() {
? '-' ? '-'
: `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`} : `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="估算成本"> <Descriptions.Item label="LLM 成本">
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))} {detail.llm_cost_yuan != null ? (
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null) <>
? <span style={{ color: '#999', fontSize: 12 }}> LLM </span> {fmtCost(detail.llm_cost_yuan)}
: null} <Tag color="green" style={{ marginLeft: 6 }}>·</Tag>
</>
) : (
<>
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))}
<span style={{ color: '#999', fontSize: 12, marginLeft: 6 }}></span>
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null)
? <span style={{ color: '#999', fontSize: 12 }}> LLM </span>
: null}
</>
)}
</Descriptions.Item> </Descriptions.Item>
{detail.llm_price_snapshot ? (
<Descriptions.Item label="LLM价格快照" span={2}>
{llmPriceRows(detail.llm_price_snapshot).map((r) => <div key={r}>{r}</div>)}
</Descriptions.Item>
) : null}
<Descriptions.Item label="源平台">{detail.source_platform_name || '-'}{cents(detail.source_price_cents)}</Descriptions.Item> <Descriptions.Item label="源平台">{detail.source_platform_name || '-'}{cents(detail.source_price_cents)}</Descriptions.Item>
<Descriptions.Item label="最优">{detail.best_platform_name || '-'}{cents(detail.best_price_cents)}</Descriptions.Item> <Descriptions.Item label="最优">{detail.best_platform_name || '-'}{cents(detail.best_price_cents)}</Descriptions.Item>
<Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item> <Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
@@ -432,7 +600,7 @@ export default function ComparisonRecordsPage() {
), ),
children: ( children: (
<div> <div>
{c.input_messages.map((m, j) => ( {(c.input_messages ?? []).map((m, j) => (
<div key={j} style={{ marginBottom: 8 }}> <div key={j} style={{ marginBottom: 8 }}>
<Tag>{m.role}</Tag> <Tag>{m.role}</Tag>
<pre style={preStyle}>{pretty(m.content)}</pre> <pre style={preStyle}>{pretty(m.content)}</pre>
+3 -3
View File
@@ -11,7 +11,7 @@ interface ConfigItem {
key: string; key: string;
label: string; label: string;
group: string; group: string;
type: string; // int / int_list / dict_str_int / bool type: string; // int / int_list / dict_str_int / bool / json
help: string | null; help: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
default: any; default: any;
@@ -24,7 +24,7 @@ interface ConfigItem {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function toEdit(item: ConfigItem): any { function toEdit(item: ConfigItem): any {
if (item.type === 'int_list') return (item.value as number[]).join(', '); if (item.type === 'int_list') return (item.value as number[]).join(', ');
if (item.type === 'dict_str_int') return JSON.stringify(item.value); if (item.type === 'dict_str_int' || item.type === 'json') return JSON.stringify(item.value);
return item.value; return item.value;
} }
@@ -37,7 +37,7 @@ function fromEdit(type: string, raw: any): any {
.split(',') .split(',')
.map((s) => parseInt(s.trim(), 10)); .map((s) => parseInt(s.trim(), 10));
} }
if (type === 'dict_str_int') return JSON.parse(raw); if (type === 'dict_str_int' || type === 'json') return JSON.parse(raw);
return raw; return raw;
} }
+119 -89
View File
@@ -124,9 +124,9 @@ const fmtSec = (ms: number | null | undefined): string =>
const fmtPct = (v: number | null | undefined): string => const fmtPct = (v: number | null | undefined): string =>
v == null ? '-' : `${(v * 100).toFixed(1)}%`; v == null ? '-' : `${(v * 100).toFixed(1)}%`;
// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位 // 元(小数)→ "¥0.0050";空值显示 -,真实 0 显示 ¥0.0000,以区分“零收益”和“无数据”
const fmtYuan = (v: number | null | undefined): string => const fmtYuan = (v: number | null | undefined): string =>
v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`; v == null ? '-' : `¥${v.toFixed(4)}`;
// 一组按券行的 success_rate 算术平均(每张券等权;空值券跳过);无有效券 → null(显示 -)。 // 一组按券行的 success_rate 算术平均(每张券等权;空值券跳过);无有效券 → null(显示 -)。
// 汇总卡「分平台/合计点位成功率」据此由按券明细上卷,口径与下方按券表一致。 // 汇总卡「分平台/合计点位成功率」据此由按券明细上卷,口径与下方按券表一致。
@@ -138,8 +138,6 @@ const slotRateMean = (rows: CouponSlotRow[]): number | null => {
}; };
// 汇总卡成功率口径 tooltip 文案 // 汇总卡成功率口径 tooltip 文案
const FULL_RATE_HINT =
'整单成功率:一次领券勾选的平台全部至少领到一张的场次 ÷ 领券发起数;基数含未完成/失败/中途退出。';
const POINT_RATE_HINT = const POINT_RATE_HINT =
'点位成功率(合计):当前区间内全部券成功率的算术平均(每张券等权);' + '点位成功率(合计):当前区间内全部券成功率的算术平均(每张券等权);' +
'单券成功率=成功(含已领)÷尝试(不含跳过),源自领券每券记录。'; '单券成功率=成功(含已领)÷尝试(不含跳过),源自领券每券记录。';
@@ -346,7 +344,7 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
// 数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报);耗时统计只算 completed。 // 数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报);耗时统计只算 completed。
export default function CouponDataPage() { export default function CouponDataPage() {
const { message } = App.useApp(); 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 [user, setUser] = useState<string>('');
const [appEnv, setAppEnv] = useState<string>('prod'); const [appEnv, setAppEnv] = useState<string>('prod');
// 领券状态多选(方案 A:整视图联动),默认全选四态(= 现状,不改口径)。 // 领券状态多选(方案 A:整视图联动),默认全选四态(= 现状,不改口径)。
@@ -359,6 +357,8 @@ export default function CouponDataPage() {
const [queriedMultiDay, setQueriedMultiDay] = useState(false); const [queriedMultiDay, setQueriedMultiDay] = useState(false);
const [queriedLimit, setQueriedLimit] = useState<number>(100); const [queriedLimit, setQueriedLimit] = useState<number>(100);
const [data, setData] = useState<CouponDataReport | null>(null); const [data, setData] = useState<CouponDataReport | null>(null);
const [overviewSummary, setOverviewSummary] = useState<CouponDataSummary | null>(null);
const [abandonedCount, setAbandonedCount] = useState(0);
const [couponSlots, setCouponSlots] = useState<CouponSlotRow[]>([]); const [couponSlots, setCouponSlots] = useState<CouponSlotRow[]>([]);
// 按券表:默认隐藏,点某平台点位成功率卡 → 展开该平台的券表(null=隐藏)。 // 按券表:默认隐藏,点某平台点位成功率卡 → 展开该平台的券表(null=隐藏)。
const [selectedSlotPlatform, setSelectedSlotPlatform] = useState<string | null>(null); const [selectedSlotPlatform, setSelectedSlotPlatform] = useState<string | null>(null);
@@ -380,26 +380,55 @@ export default function CouponDataPage() {
const gran = multiDay ? 'day' : granularity; const gran = multiDay ? 'day' : granularity;
setLoading(true); setLoading(true);
try { try {
const res = await api.get<CouponDataReport>('/admin/api/coupon-data', { const baseParams = {
params: { date_from: from,
date_from: from, date_to: to,
date_to: to, user: user.trim() || undefined,
user: user.trim() || undefined, app_env: appEnv === 'all' ? undefined : appEnv,
app_env: appEnv, granularity: gran,
status: statuses, };
granularity: gran, const [res, overviewRes, abandonedRes] = await Promise.all([
limit: targetLimit, api.get<CouponDataReport>('/admin/api/coupon-data', {
offset: (targetPage - 1) * targetLimit, params: {
sort: targetSort, ...baseParams,
}, status: statuses,
// status 是数组:repeat 无方括号序列化(status=a&status=b),对齐 FastAPI list[str] Query limit: targetLimit,
paramsSerializer: { indexes: null }, 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); 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 api
.get<CouponSlotsOut>('/admin/api/coupon-data/coupons', { .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)) .then((r) => setCouponSlots(r.data.items))
.catch(() => setCouponSlots([])); .catch(() => setCouponSlots([]));
@@ -418,43 +447,32 @@ export default function CouponDataPage() {
useEffect(() => { useEffect(() => {
load(); load();
// 仅首次自动拉近 7 天;之后由「查询」按钮触发 // 仅首次自动拉昨日;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const columns: ColumnsType<CouponDataRow> = [ const columns: ColumnsType<CouponDataRow> = [
{ {
title: '用户', title: '时间',
dataIndex: 'user_nickname', dataIndex: 'started_at',
width: 140, width: 165,
render: (_: unknown, r: CouponDataRow) => { render: (v: string) => formatUtcTime(v),
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: '手机号', title: '用户',
dataIndex: 'user_phone', key: 'user',
width: 130, width: 150,
// 点手机号 → 抽屉看该用户全部领券(参考低价审核/反馈页)。游客(无 user_id)不可点。 render: (_: unknown, r: CouponDataRow) => {
render: (phone: string | null, r: CouponDataRow) => if (r.user_id == null) return <Typography.Text type="secondary"></Typography.Text>;
r.user_id != null ? ( return (
<a onClick={() => openUserRecords(r)}>{phone || `#${r.user_id}`}</a> <a onClick={() => openUserRecords(r)}>
) : ( <div>{r.user_phone || `#${r.user_id}`}</div>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
), {r.user_nickname || '未设置昵称'}
</Typography.Text>
</a>
);
},
}, },
{ {
title: '状态', title: '状态',
@@ -471,12 +489,6 @@ export default function CouponDataPage() {
width: 110, width: 110,
render: (pkg: string | null) => originLabel(pkg), render: (pkg: string | null) => originLabel(pkg),
}, },
{
title: '时间',
dataIndex: 'started_at',
width: 165,
render: (v: string) => formatUtcTime(v),
},
{ {
title: '耗时', title: '耗时',
dataIndex: 'elapsed_ms', dataIndex: 'elapsed_ms',
@@ -484,6 +496,22 @@ export default function CouponDataPage() {
align: 'right', align: 'right',
render: (v: number | null) => fmtSec(v), 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: '广告收益', title: '广告收益',
dataIndex: 'ad_revenue_yuan', dataIndex: 'ad_revenue_yuan',
@@ -522,9 +550,10 @@ export default function CouponDataPage() {
}, },
}, },
{ {
title: '领券 trace', title: 'traceid',
dataIndex: 'trace_id', dataIndex: 'trace_id',
width: 150, width: 150,
fixed: 'right',
// 有 trace_url(pricebot 上云,仅 completed)→ 可点链接打开完整现场;否则降级为可复制 trace_id。 // 有 trace_url(pricebot 上云,仅 completed)→ 可点链接打开完整现场;否则降级为可复制 trace_id。
render: (v: string, r: CouponDataRow) => render: (v: string, r: CouponDataRow) =>
r.trace_url ? ( r.trace_url ? (
@@ -564,13 +593,16 @@ 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 items = data?.items ?? []; const items = data?.items ?? [];
return ( return (
<div> <div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}> <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 type="secondary" style={{ fontSize: 12 }}>
;/ ;/
</Typography.Text> </Typography.Text>
@@ -585,6 +617,7 @@ export default function CouponDataPage() {
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])} onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false} allowClear={false}
presets={[ presets={[
{ label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
{ label: '今天', value: [dayjs(), dayjs()] }, { label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] }, { label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] }, { label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
@@ -677,6 +710,32 @@ export default function CouponDataPage() {
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[16, 12]}> <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"> <Col flex="1 1 0">
<Statistic title="领券发起数" value={summary.started_count} /> <Statistic title="领券发起数" value={summary.started_count} />
</Col> </Col>
@@ -703,35 +762,6 @@ export default function CouponDataPage() {
</Col> </Col>
</Row> </Row>
<Divider style={{ marginTop: 16, marginBottom: 16 }} /> <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]}> <Row gutter={[16, 12]}>
{(['meituan-waimai', 'taobao-shanguang', 'jd-waimai'] as const).map((pid) => { {(['meituan-waimai', 'taobao-shanguang', 'jd-waimai'] as const).map((pid) => {
const active = selectedSlotPlatform === pid; const active = selectedSlotPlatform === pid;
+186 -105
View File
@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react'; 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 { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { AdRevenueReport, DashboardOverview } from '@/lib/types'; import type { AdRevenueReport, DashboardOverview } from '@/lib/types';
@@ -124,11 +124,6 @@ function retentionTitle(period: PeriodKey) {
return '前日用户新增留存'; 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) { function adTypeSummary(report: AdRevenueReport | null, adType: string) {
if (!report) return null; if (!report) return null;
if (report.by_ad_type?.[adType]) return report.by_ad_type[adType]; 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(' '); .join(' ');
return `M${chart.left},${chart.bottom} ${line.replace(/^L/, 'L')} L${chart.right},${chart.bottom} Z`; 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) => { const toggleSeries = (key: TrendSeriesKey) => {
setVisibleSeries((prev) => { setVisibleSeries((prev) => {
const visibleCount = Object.values(prev).filter(Boolean).length; const visibleCount = Object.values(prev).filter(Boolean).length;
@@ -378,7 +371,7 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
</div> </div>
</div> </div>
<div className="chart-empty"> <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"> <g stroke="#edf0f5" strokeWidth="1">
<line x1="44" y1="28" x2="880" y2="28" /> <line x1="44" y1="28" x2="880" y2="28" />
<line x1="44" y1="76" x2="880" y2="76" /> <line x1="44" y1="76" x2="880" y2="76" />
@@ -431,14 +424,18 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
</g> </g>
) : null, ) : null,
)} )}
<g fill="#97A3B8" fontSize="12" fontWeight="600"> <g fill="#97A3B8" fontSize="10" fontWeight="600">
{points.map((p, idx) => {points.map((p, idx) => (
idx === 0 || idx === points.length - 1 || idx % labelStep === 0 ? ( <text
<text key={p.date} x={xFor(idx)} y="235" textAnchor={idx === points.length - 1 ? 'end' : 'middle'}> key={p.date}
{dayjs(p.date).format('MM-DD')} x={xFor(idx)}
</text> y="236"
) : null, textAnchor="end"
)} transform={`rotate(-45 ${xFor(idx)} 236)`}
>
{dayjs(p.date).format('MM-DD')}
</text>
))}
</g> </g>
</> </>
)} )}
@@ -452,6 +449,8 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
export default function DashboardPage() { export default function DashboardPage() {
const { message } = App.useApp(); const { message } = App.useApp();
const [period, setPeriod] = useState<PeriodKey>('yesterday'); 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 [data, setData] = useState<DashboardOverview | null>(null);
const [previousData, setPreviousData] = useState<DashboardOverview | null>(null); const [previousData, setPreviousData] = useState<DashboardOverview | null>(null);
const [adReport, setAdReport] = useState<AdRevenueReport | 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 [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [cpsSyncing, setCpsSyncing] = useState(false); 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 previousRange = useMemo(() => previousPeriodRange(range), [range]);
const periodData = data?.period ?? null; const periodData = data?.period ?? null;
const previousPeriodData = previousData?.period ?? null; const previousPeriodData = previousData?.period ?? null;
const adAvgEcpm = avgEcpm(adReport);
const rewardVideoAdSummary = adTypeSummary(adReport, 'reward_video'); const rewardVideoAdSummary = adTypeSummary(adReport, 'reward_video');
const couponAdSummary = adSceneSummary(adReport, 'coupon'); const couponAdSummary = adSceneSummary(adReport, 'coupon');
const comparisonAdSummary = adSceneSummary(adReport, 'comparison'); const comparisonAdSummary = adSceneSummary(adReport, 'comparison');
@@ -478,7 +487,16 @@ export default function DashboardPage() {
? fmtYuan((rewardVideoAdSummary.revenue_yuan / rewardVideoAdSummary.impressions) * 1000) ? fmtYuan((rewardVideoAdSummary.revenue_yuan / rewardVideoAdSummary.impressions) * 1000)
: '--'; : '--';
const compareSuccessRate = periodData?.comparison.success_rate ?? null; 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 couponRewardCoinTotal = periodData?.coins.coupon_reward_coin_total;
const comparisonRewardCoinTotal = periodData?.coins.comparison_reward_coin_total; const comparisonRewardCoinTotal = periodData?.coins.comparison_reward_coin_total;
const signinCoinTotal = periodData?.coins.signin_coin_total; const signinCoinTotal = periodData?.coins.signin_coin_total;
@@ -494,12 +512,21 @@ export default function DashboardPage() {
const previousTotalCpsCommissionCents = cpsCommissionCents(previousData); const previousTotalCpsCommissionCents = cpsCommissionCents(previousData);
const totalRevenueYuan = revenueYuan(adReport, totalCpsCommissionCents); const totalRevenueYuan = revenueYuan(adReport, totalCpsCommissionCents);
const previousTotalRevenueYuan = revenueYuan(previousAdReport, previousTotalCpsCommissionCents); const previousTotalRevenueYuan = revenueYuan(previousAdReport, previousTotalCpsCommissionCents);
const yesterdayTrendPoint = periodData?.trend?.[periodData.trend.length - 1]; const periodActiveUsers = periodData?.users.active ?? null;
const yesterdayActiveUsers = yesterdayTrendPoint?.active_users ?? null;
const arpuYuan = const arpuYuan =
totalRevenueYuan == null || yesterdayActiveUsers == null || yesterdayActiveUsers <= 0 totalRevenueYuan == null || periodActiveUsers == null || periodActiveUsers <= 0
? null ? 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 cpsRevenueText = cpsAvailable ? fmtCents(totalCpsCommissionCents) : '--';
const meituanCpsRevenueText = cpsAvailable ? fmtCents(meituanCpsCommissionCents) : '--'; const meituanCpsRevenueText = cpsAvailable ? fmtCents(meituanCpsCommissionCents) : '--';
const jdCpsRevenueText = cpsAvailable ? fmtCents(jdCpsCommissionCents) : '--'; const jdCpsRevenueText = cpsAvailable ? fmtCents(jdCpsCommissionCents) : '--';
@@ -511,20 +538,18 @@ export default function DashboardPage() {
const activeUserDelta = percentDelta(periodData?.users.active, previousPeriodData?.users.active); const activeUserDelta = percentDelta(periodData?.users.active, previousPeriodData?.users.active);
const newUserDelta = percentDelta(periodData?.users.new, previousPeriodData?.users.new); const newUserDelta = percentDelta(periodData?.users.new, previousPeriodData?.users.new);
const retentionDelta = pointDelta(periodData?.users.retention_rate, previousPeriodData?.users.retention_rate); const retentionDelta = pointDelta(periodData?.users.retention_rate, previousPeriodData?.users.retention_rate);
const retentionSpark: 'up' | 'down' | 'flat' | undefined = const comparisonTotalDelta = percentDelta(
periodData?.users.retention_rate == null periodData?.comparison.total,
? undefined previousPeriodData?.comparison.total,
: retentionDelta.tone === 'down' );
? 'down' const comparisonSuccessDelta = percentDelta(
: retentionDelta.tone === 'up' || periodData.users.retention_rate >= 1 periodData?.comparison.success,
? 'up' previousPeriodData?.comparison.success,
: 'flat'; );
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 comparisonOrderedDelta = percentDelta(periodData?.comparison.ordered, previousPeriodData?.comparison.ordered);
const durationDeltaInfo = durationDelta( const durationDeltaInfo = durationDelta(
periodData?.comparison.average_duration_ms, periodData?.comparison.median_duration_ms,
previousPeriodData?.comparison.average_duration_ms, previousPeriodData?.comparison.median_duration_ms,
); );
// 领券核心数据(period.coupon;旧后端无此字段时整节显示 '--') // 领券核心数据(period.coupon;旧后端无此字段时整节显示 '--')
const couponPeriod = periodData?.coupon ?? null; const couponPeriod = periodData?.coupon ?? null;
@@ -548,14 +573,38 @@ export default function DashboardPage() {
previousPeriodData?.comparison.average_saved_cents, previousPeriodData?.comparison.average_saved_cents,
); );
const totalRevenueDelta = percentDelta(totalRevenueYuan, previousTotalRevenueYuan); 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 grantedCoinDelta = percentDelta(periodData?.coins.granted_total, previousPeriodData?.coins.granted_total);
const couponCoinRatio = ratioBadge(couponRewardCoinTotal, periodData?.coins.granted_total); const couponCoinRatio = ratioBadge(couponRewardCoinTotal, periodData?.coins.granted_total);
const comparisonCoinRatio = ratioBadge(comparisonRewardCoinTotal, 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 rewardVideoCoinRatio = ratioBadge(periodData?.coins.reward_video_coin_total, periodData?.coins.granted_total);
const regularTaskCoinRatio = ratioBadge(regularTaskCoinTotal, 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(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
@@ -674,17 +723,33 @@ export default function DashboardPage() {
{PERIODS.map((it) => ( {PERIODS.map((it) => (
<button <button
key={it.key} key={it.key}
className={period === it.key ? 'on' : undefined} className={!customRange && period === it.key ? 'on' : undefined}
onClick={() => setPeriod(it.key)} onClick={() => {
setPeriod(it.key);
setCustomRange(null);
}}
> >
{it.label} {it.label}
</button> </button>
))} ))}
</div> </div>
<div className="date-pill"> <DatePicker.RangePicker
<CalendarOutlined /> className="dashboard-date-picker"
{range.label} value={[range.start, range.end]}
</div> 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 <Select
value="prev" value="prev"
style={{ width: 132 }} style={{ width: 132 }}
@@ -706,7 +771,6 @@ export default function DashboardPage() {
delta={activeUserDelta.value} delta={activeUserDelta.value}
deltaTone={activeUserDelta.tone} deltaTone={activeUserDelta.tone}
hint="当前按本期内登录、开始比价、开始领券任一满足去重;比价按 real_compare_start,领券按 real_coupon_start/claim_started。" hint="当前按本期内登录、开始比价、开始领券任一满足去重;比价按 real_compare_start,领券按 real_coupon_start/claim_started。"
spark="up"
/> />
<StatCard <StatCard
title="新增用户" title="新增用户"
@@ -714,7 +778,6 @@ export default function DashboardPage() {
delta={newUserDelta.value} delta={newUserDelta.value}
deltaTone={newUserDelta.tone} deltaTone={newUserDelta.tone}
hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。" hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。"
spark="up"
/> />
<StatCard <StatCard
title={retentionTitle(period)} title={retentionTitle(period)}
@@ -723,14 +786,12 @@ export default function DashboardPage() {
delta={retentionDelta.value} delta={retentionDelta.value}
deltaTone={retentionDelta.tone} deltaTone={retentionDelta.tone}
hint={periodData?.users.retention_note ?? '暂无留存数据'} hint={periodData?.users.retention_note ?? '暂无留存数据'}
spark={retentionSpark}
/> />
<StatCard <StatCard
title="比价次数" title="比价次数"
value={fmtInt(periodData?.comparison.total)} value={fmtInt(comparisonTotal)}
delta={comparisonTotalDelta.value} delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone} deltaTone={comparisonTotalDelta.tone}
spark="up"
/> />
</div> </div>
<TrendChart rangeLabel={range.label} points={periodData?.trend ?? []} /> <TrendChart rangeLabel={range.label} points={periodData?.trend ?? []} />
@@ -742,37 +803,53 @@ export default function DashboardPage() {
<h3></h3> <h3></h3>
<span></span> <span></span>
</div> </div>
<div className="grid g5"> <div className="grid g4">
<StatCard <StatCard
title="发起比价" title="比价发起次数"
value={fmtInt(periodData?.comparison.total)} value={fmtInt(comparisonTotal)}
unit="次" unit="次"
delta={comparisonTotalDelta.value} delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone} deltaTone={comparisonTotalDelta.tone}
/> />
<StatCard <StatCard
title="比价成功" title="比价完成次数"
value={fmtInt(periodData?.comparison.success)} value={fmtInt(comparisonCompleted)}
unit="次"
hint="成功次数 + 失败次数,不包含中途退出。"
/>
<StatCard
title="比价成功次数"
value={fmtInt(comparisonSuccess)}
unit="次" unit="次"
delta={comparisonSuccessDelta.value} delta={comparisonSuccessDelta.value}
deltaTone={comparisonSuccessDelta.tone} deltaTone={comparisonSuccessDelta.tone}
hint={`当前成功率 ${pct(compareSuccessRate)}`}
/> />
<StatCard <StatCard
title="成功下单" title="比价成功率"
value={pct(compareSuccessRate)}
hint={`成功次数 ÷(比价发起次数-中途退出次数);当前 ${fmtInt(comparisonSuccess)} ÷ ${fmtInt(comparisonSuccessRateDenominator)}`}
/>
<StatCard
title="下单次数"
value={fmtInt(periodData?.comparison.ordered)} value={fmtInt(periodData?.comparison.ordered)}
unit="" unit=""
delta={comparisonOrderedDelta.value} delta={comparisonOrderedDelta.value}
deltaTone={comparisonOrderedDelta.tone} deltaTone={comparisonOrderedDelta.tone}
hint="按比价记录页同一口径:用户有通过比价产生的下单记录,且店名与比价记录店名一致,则这条比价记录记为已下单。" hint="按比价记录页同一口径:用户有通过比价产生的下单记录,且店名与比价记录店名一致,则这条比价记录记为已下单。"
/> />
<StatCard <StatCard
title="平均耗时" title="耗时中位数"
value={fmtMsAsSeconds(averageDurationMs)} value={fmtMsAsSeconds(comparisonMedianMs)}
unit="秒" unit="秒"
delta={durationDeltaInfo.value} delta={durationDeltaInfo.value}
deltaTone={durationDeltaInfo.tone} deltaTone={durationDeltaInfo.tone}
hint="按比价记录页同一口径:取所选日期范围内已上报耗时的比价记录,求平均后换算为秒。" hint="只统计成功和失败的比价记录,中途退出不参与耗时统计。"
/>
<StatCard
title="耗时 95% 分位数"
value={fmtMsAsSeconds(comparisonP95Ms)}
unit="秒"
hint="只统计成功和失败的比价记录;95% 的有效比价耗时不超过该值。"
/> />
<StatCard <StatCard
title="平均节省金额" title="平均节省金额"
@@ -789,7 +866,7 @@ export default function DashboardPage() {
<h3></h3> <h3></h3>
<span></span> <span></span>
</div> </div>
<div className="grid g4"> <div className="grid g5">
<StatCard <StatCard
title="领券发起数" title="领券发起数"
value={fmtInt(couponPeriod?.started)} value={fmtInt(couponPeriod?.started)}
@@ -825,21 +902,27 @@ export default function DashboardPage() {
deltaTone={couponMedianDelta.tone} deltaTone={couponMedianDelta.tone}
hint="仅统计「完成」的领券(同领券数据页口径),中位数比均值更抗中途暂停等待的长尾。" hint="仅统计「完成」的领券(同领券数据页口径),中位数比均值更抗中途暂停等待的长尾。"
/> />
<StatCard
title="耗时 95% 分位数"
value={fmtMsAsSeconds(couponP95Ms)}
unit="秒"
hint="仅统计「完成」的领券;95% 的完成场次耗时不超过该值。"
/>
</div> </div>
</section> </section>
<section className="section"> <section className="section">
<div className="section-head"> <div className="section-head">
<span className="bar" /> <span className="bar" />
<h3></h3> <h3></h3>
<span> <span>
{cpsAvailable {cpsAvailable
? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入') ? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入')
: (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')} : (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')}
</span> </span>
</div> </div>
<div className="sub-label"></div> <div className="sub-label"></div>
<div className="grid g4"> <div className="grid g5">
<RevenueCard <RevenueCard
title="总收入" title="总收入"
value={adLoading ? '--' : fmtYuan(totalRevenueYuan)} value={adLoading ? '--' : fmtYuan(totalRevenueYuan)}
@@ -851,44 +934,36 @@ export default function DashboardPage() {
{ label: 'CPS 收入', value: cpsRevenueText }, { 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 <RevenueCard
title="ARPU" title="ARPU"
value={adLoading ? '--' : fmtYuan(arpuYuan)} value={adLoading ? '--' : fmtYuan(arpuYuan)}
meta={[ meta={[
{ label: '口径', value: '总收入 / 昨日活跃用户' }, { label: '口径', value: '总收入 / 区间活跃用户' },
{ label: '分母', value: yesterdayActiveUsers == null ? '--' : `${fmtInt(yesterdayActiveUsers)}` }, { 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>
<div className="sub-label cps-sub-label"> <div className="sub-label cps-sub-label">
@@ -934,10 +1009,6 @@ export default function DashboardPage() {
<RevenueCard <RevenueCard
title="总广告收益" title="总广告收益"
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)} value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
meta={[
{ label: '平均 eCPM', value: adLoading ? '--' : fmtYuan(adAvgEcpm ?? undefined) },
{ label: '展示数', value: adLoading ? '--' : fmtInt(adReport?.total_impressions) },
]}
accent="green" accent="green"
/> />
<RevenueCard <RevenueCard
@@ -957,7 +1028,7 @@ export default function DashboardPage() {
]} ]}
/> />
<RevenueCard <RevenueCard
title="激励视频广告" title="视频广告"
value={rewardVideoAdText} value={rewardVideoAdText}
meta={[ meta={[
{ label: '平均 eCPM', value: rewardVideoEcpm }, { label: '平均 eCPM', value: rewardVideoEcpm },
@@ -995,11 +1066,11 @@ export default function DashboardPage() {
deltaTone={comparisonCoinRatio.tone} deltaTone={comparisonCoinRatio.tone}
/> />
<StatCard <StatCard
title="激励视频金币" title="视频金币"
value={fmtInt(periodData?.coins.reward_video_coin_total)} value={fmtInt(periodData?.coins.reward_video_coin_total)}
delta={rewardVideoCoinRatio.value} delta={rewardVideoCoinRatio.value}
deltaTone={rewardVideoCoinRatio.tone} deltaTone={rewardVideoCoinRatio.tone}
hint="独立统计激励视频发放,不再重复计入领券奖励。" hint="独立统计视频发放,不再重复计入领券奖励。"
/> />
<StatCard <StatCard
title="常规任务金币" title="常规任务金币"
@@ -1018,7 +1089,7 @@ export default function DashboardPage() {
<div> <div>
4 <b>{fmtCoinAmount(periodData?.coins.granted_total)}</b> 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>{fmtCents(periodData?.cash.withdraw_success_cents)}</b>
<b>{fmtCoinAmount(data.coins.granted_total)}</b> <b>{fmtCoinAmount(data.coins.granted_total)}</b>
<b>{fmtCents(data.cash.withdraw_success_cents)}</b>/ <b>{fmtCents(data.cash.withdraw_success_cents)}</b>/
@@ -1111,6 +1182,16 @@ export default function DashboardPage() {
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; 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 { .segment {
display: inline-flex; display: inline-flex;
gap: 2px; gap: 2px;
+8 -13
View File
@@ -53,9 +53,9 @@ const NAV_GROUPS: NavGroup[] = [
label: '看板', label: '看板',
children: [ children: [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' }, { key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
{ key: '/coupon-data', icon: <GiftOutlined />, label: '领券数据' },
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' }, { key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' }, { key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/coupon-data', icon: <GiftOutlined />, label: '领券记录' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' }, { key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' }, { key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
{ key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' }, { key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' },
@@ -145,19 +145,14 @@ export default function MainLayout({ children }: { children: React.ReactNode })
collapsedWidth={72} collapsedWidth={72}
width={220} width={220}
onCollapse={setCollapsed} 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="后台导航"> <nav className={`side-nav${collapsed ? ' side-nav-collapsed' : ''}`} aria-label="后台导航">
{collapsed {collapsed
? flatNavItems.map((item) => ( ? flatNavItems.map((item) => (
+14
View File
@@ -23,6 +23,20 @@ const TZ = 'Asia/Shanghai';
/** 金额:分 → ¥元(两位小数)。 */ /** 金额:分 → ¥元(两位小数)。 */
export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`; 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); const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */ /** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */
+9 -1
View File
@@ -354,6 +354,7 @@ export interface ComparisonRecordListItem {
retry_count: number | null; retry_count: number | null;
input_tokens: number | null; input_tokens: number | null;
output_tokens: number | null; output_tokens: number | null;
llm_cost_yuan: number | null; // 后端按「当时价」冻结的实际成本(元);旧记录 null → 「成本」列回退估算
device_model: string | null; device_model: string | null;
rom_vendor: string | null; rom_vendor: string | null;
rom_name: string | null; rom_name: string | null;
@@ -396,6 +397,8 @@ export interface ComparisonRecordDetail extends ComparisonRecordListItem {
latitude: number | null; latitude: number | null;
llm_calls: LlmCall[] | null; llm_calls: LlmCall[] | null;
raw_payload: Record<string, unknown> | null; raw_payload: Record<string, unknown> | null;
// 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。
llm_price_snapshot: Record<string, unknown> | null;
} }
export interface AdRevenueImpression { export interface AdRevenueImpression {
@@ -553,11 +556,16 @@ export interface DashboardOverview {
}; };
comparison: { comparison: {
total: number; total: number;
completed?: number;
cancelled?: number;
success: number; success: number;
success_rate: number; success_rate: number | null;
ordered: number; ordered: number;
average_duration_ms: number | null; average_duration_ms: number | null;
median_duration_ms?: number | null;
p95_duration_ms?: number | null;
average_saved_cents: number | null; average_saved_cents: number | null;
token_cost_total_yuan?: number;
}; };
// 领券核心数据(2026-07-05 新增;旧后端无此字段,前端 `?.` 探测)。 // 领券核心数据(2026-07-05 新增;旧后端无此字段,前端 `?.` 探测)。
// 点位=一张券;成功口径 success+already_claimed;点位成功率分母=发起数×应领点位数(未跑到视为失败)。 // 点位=一张券;成功口径 success+already_claimed;点位成功率分母=发起数×应领点位数(未跑到视为失败)。