Compare commits

..

3 Commits

Author SHA1 Message Date
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
5 changed files with 783 additions and 356 deletions
+140 -51
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,
@@ -46,11 +46,11 @@ const { RangePicker } = DatePicker;
// 广告类型标签
const TYPE_TAG: Record<string, { color: string; label: string }> = {
reward_video: { color: 'blue', label: '激励视频' },
reward_video: { color: 'blue', label: '视频' },
// 历史误标 feed(领券/比价广告修 adType 之前上报)一律按 Draw 信息流显示——业务已全切 Draw
feed: { color: 'geekblue', label: 'Draw 信息流' },
draw: { color: 'geekblue', label: 'Draw 信息流' },
withdrawal_video: { color: 'gold', label: '提现激励视频' },
withdrawal_video: { color: 'gold', label: '提现视频' },
};
// Draw 信息流投放场景标签(后端 AdRevenueRow.feed_scene:comparison/coupon/welfare)
@@ -74,6 +74,13 @@ const STATUS_TAG: Record<string, { color: string; label: string }> = {
too_short: { color: 'gold', label: '未满10秒' },
closed_early: { color: 'default', label: '提前关闭' },
};
const STATUS_HINT: Record<string, string> = {
granted: '已满足当前客户端发奖条件并完成金币发放。',
too_short: '旧版 Draw 信息流观看不足 10 秒,不发金币;新版按观看比例发放时会记为已发。',
closed_early: '用户在达到发奖条件前主动关闭,不发金币。',
capped: '已达到次数上限,不再发金币。',
ecpm_missing: '缺少有效 eCPM,无法计算金币。',
};
const fmtFactorRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
@@ -84,6 +91,14 @@ const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`;
};
const percentile = (values: number[], q: number) => {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const idx = (sorted.length - 1) * q;
const lo = Math.floor(idx);
const hi = Math.min(lo + 1, sorted.length - 1);
return sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo);
};
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
@@ -104,35 +119,37 @@ const LT_FACTOR_ROWS = [
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
];
type AdRevenueDetailRow = AdRevenueRecord & { source_adn?: string | null };
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
const DETAIL_COLUMNS: ColumnsType<AdRevenueDetailRow> = [
{ title: '来源广告网络', dataIndex: 'source_adn', width: 110, render: (value: string | null) => value || '-' },
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (s: string) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
{ title: '份数', dataIndex: 'units', width: 60 },
{
title: 'LT累计条数',
key: 'lt_index',
title: 'eCPM(元)',
dataIndex: 'ecpm',
width: 100,
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100).toFixed(2)),
},
{
title: '单次 eCPM(元)',
dataIndex: 'ecpm',
width: 120,
render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100 / 1000).toFixed(4)),
},
{ title: '实发金币数', dataIndex: 'actual_coin', width: 100 },
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
{
title: '因子2',
key: 'lt_factor',
width: 90,
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
render: (_: unknown, r: AdRevenueDetailRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
},
{
title: 'LT累计条数',
key: 'lt_index',
width: 100,
render: (_: unknown, r: AdRevenueDetailRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
},
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
];
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴),
@@ -290,19 +307,20 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
// 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。
export default function AdRevenueReportPage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')]);
const [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>();
// 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。
const [scene, setScene] = useState<string | undefined>();
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序
const [limit, setLimit] = useState<number>(500); // 每页条数(分页大小)
const [limit, setLimit] = useState<number>(1000); // 默认拉满接口单页上限,供单次流程广告数分位统计
const [page, setPage] = useState<number>(1); // 当前页码(后端分页;1 起)
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
const [data, setData] = useState<AdRevenueReport | null>(null);
const [queriedLimit, setQueriedLimit] = useState<number>(500);
const [allFlowItems, setAllFlowItems] = useState<AdRevenueRow[]>([]);
const [queriedLimit, setQueriedLimit] = useState<number>(1000);
const [loading, setLoading] = useState(false);
const [formulaOpen, setFormulaOpen] = useState(false);
// 点用户手机号弹出的「用户广告收益详情」半屏抽屉(userId + 手机号;null=关闭)
@@ -320,17 +338,20 @@ export default function AdRevenueReportPage() {
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
setLoading(true);
try {
const baseParams = {
date_from: from,
date_to: to,
user_id: userId ?? undefined,
ad_type: adType ?? undefined,
feed_scene: scene ?? undefined,
granularity: gran,
sort: targetSort,
};
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: {
date_from: from,
date_to: to,
user_id: userId ?? undefined,
ad_type: adType ?? undefined,
feed_scene: scene ?? undefined,
granularity: gran,
...baseParams,
limit: targetLimit,
offset: (targetPage - 1) * targetLimit,
sort: targetSort,
},
});
setData(res.data);
@@ -338,6 +359,24 @@ export default function AdRevenueReportPage() {
setQueriedLimit(targetLimit);
setQueriedGranularity(gran);
setQueriedMultiDay(multiDay);
// 单次流程广告数分位必须覆盖完整查询结果,不能只统计当前表格分页。
let firstOverviewPage = res.data;
if (targetPage !== 1 || targetLimit !== 1000) {
const overviewRes = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: { ...baseParams, limit: 1000, offset: 0 },
});
firstOverviewPage = overviewRes.data;
}
const flowItems = [...firstOverviewPage.items];
while (flowItems.length < firstOverviewPage.total) {
const nextRes = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: { ...baseParams, limit: 1000, offset: flowItems.length },
});
if (nextRes.data.items.length === 0) break;
flowItems.push(...nextRes.data.items);
}
setAllFlowItems(flowItems);
} catch (e) {
message.error(errMsg(e));
} finally {
@@ -349,11 +388,11 @@ export default function AdRevenueReportPage() {
useEffect(() => {
load();
// 仅首次自动拉今天;之后由「查询」按钮触发
// 仅首次自动拉昨日;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<AdRevenueRow> = [
const rawColumns: ColumnsType<AdRevenueRow> = [
...(queriedMultiDay
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
: []),
@@ -401,7 +440,10 @@ export default function AdRevenueReportPage() {
title: '场景',
dataIndex: 'feed_scene',
width: 80,
render: (v: string | null | undefined) => {
render: (v: string | null | undefined, row: AdRevenueRow) => {
if (!v && (row.ad_type === 'reward_video' || row.ad_type === 'withdrawal_video')) {
return <Tag color="blue"></Tag>;
}
if (!v) return <Typography.Text type="secondary">-</Typography.Text>;
const t = SCENE_TAG[v];
return t ? <Tag color={t.color}>{t.text}</Tag> : <Tag>{v}</Tag>;
@@ -436,14 +478,14 @@ export default function AdRevenueReportPage() {
},
},
{
title: '预估收益(元)',
title: '预估收益',
dataIndex: 'revenue_yuan',
width: 110,
align: 'right',
// 一次比价/领券聚合行用 row_revenue_yuan(该次发奖广告 eCPM 折算之和),其它行用展示侧 revenue_yuan
render: (v: number, r: AdRevenueRow) => {
const rev = r.row_revenue_yuan ?? v;
return r.has_impression || rev > 0 ? rev.toFixed(4) : '-';
return rev.toFixed(4);
},
},
{
@@ -451,9 +493,9 @@ export default function AdRevenueReportPage() {
dataIndex: 'status',
width: 100,
render: (s: string | null) => {
if (!s) return <Tag></Tag>;
if (!s) return <Tooltip title="仅记录广告展示,没有对应发奖事件。"><Tag></Tag></Tooltip>;
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
return <Tooltip title={STATUS_HINT[s]}><Tag color={t.color}>{t.label}</Tag></Tooltip>;
},
},
{
@@ -473,7 +515,7 @@ export default function AdRevenueReportPage() {
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '广告位ID',
title: '广告位',
dataIndex: 'our_code_id',
width: 110,
render: (v: string | null) =>
@@ -487,6 +529,26 @@ export default function AdRevenueReportPage() {
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
},
];
const columnOrder = [
'report_date',
'created_at',
'user_phone',
'feed_scene',
'ecpm_yuan',
'revenue_yuan',
'actual_coin',
'status',
'ad_type',
'app_env',
'our_code_id',
];
const columns = columnOrder.flatMap((identity) => {
const found = rawColumns.find((column) => {
const dataIndex = 'dataIndex' in column ? column.dataIndex : undefined;
return String(column.key ?? dataIndex ?? '') === identity;
});
return found ? [found] : [];
}) as ColumnsType<AdRevenueRow>;
// 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
@@ -525,11 +587,24 @@ export default function AdRevenueReportPage() {
// 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。
const items = data?.items ?? [];
const sessionAdCounts = useMemo(() => {
const summarize = (sceneName: 'coupon' | 'comparison') => {
const counts = allFlowItems
.filter((item) => item.feed_scene === sceneName)
.map((item) => item.sub_count ?? 1);
return {
p5: percentile(counts, 0.05),
p50: percentile(counts, 0.5),
p95: percentile(counts, 0.95),
};
};
return { coupon: summarize('coupon'), comparison: summarize('comparison') };
}, [allFlowItems]);
return (
<div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}></h2>
<h2 style={{ margin: 0 }}>广</h2>
<Button
type="link"
size="small"
@@ -573,6 +648,7 @@ export default function AdRevenueReportPage() {
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false}
presets={[
{ label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
@@ -597,9 +673,9 @@ export default function AdRevenueReportPage() {
allowClear
style={{ width: 150 }}
options={[
{ value: 'reward_video', label: '激励视频' },
{ value: 'reward_video', label: '视频' },
{ value: 'draw', label: 'Draw 信息流' },
{ value: 'withdrawal_video', label: '提现激励视频' },
{ value: 'withdrawal_video', label: '提现视频' },
]}
/>
</Space>
@@ -803,15 +879,28 @@ export default function AdRevenueReportPage() {
<Statistic title="Draw 信息流条数" value={drawStat?.impressions ?? 0} />
</Col>
<Col span={4}>
<Statistic title="激励视频收益(元)" value={rvStat?.revenue_yuan ?? 0} precision={4} />
<Statistic title="视频收益(元)" value={rvStat?.revenue_yuan ?? 0} precision={4} />
</Col>
<Col span={4}>
<Statistic title="激励视频 eCPM(元/千次)" value={ecpmOf(rvStat)} precision={2} />
<Statistic title="视频 eCPM(元/千次)" value={ecpmOf(rvStat)} precision={2} />
</Col>
<Col span={4}>
<Statistic title="激励视频条数" value={rvStat?.impressions ?? 0} />
<Statistic title="视频条数" value={rvStat?.impressions ?? 0} />
</Col>
</Row>
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
广
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
<Col span={4}><Statistic title="单次领券广告数 P5" value={sessionAdCounts.coupon.p5 ?? '-'} precision={1} /></Col>
<Col span={4}><Statistic title="单次领券广告数 P50" value={sessionAdCounts.coupon.p50 ?? '-'} precision={1} /></Col>
<Col span={4}><Statistic title="单次领券广告数 P95" value={sessionAdCounts.coupon.p95 ?? '-'} precision={1} /></Col>
<Col span={4}><Statistic title="单次比价广告数 P5" value={sessionAdCounts.comparison.p5 ?? '-'} precision={1} /></Col>
<Col span={4}><Statistic title="单次比价广告数 P50" value={sessionAdCounts.comparison.p50 ?? '-'} precision={1} /></Col>
<Col span={4}><Statistic title="单次比价广告数 P95" value={sessionAdCounts.comparison.p95 ?? '-'} precision={1} /></Col>
</Row>
</Card>
)}
@@ -901,11 +990,11 @@ export default function AdRevenueReportPage() {
<Typography.Text type="secondary">
( {r.sub_count ?? r.sub_rewards.length} · {r.expected_coin} / {r.actual_coin})
</Typography.Text>
<Table<AdRevenueRecord>
<Table<AdRevenueDetailRow>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={r.sub_rewards}
dataSource={r.sub_rewards.map((detail) => ({ ...detail, source_adn: r.adn }))}
pagination={false}
size="small"
scroll={{ x: 900 }}
@@ -917,11 +1006,11 @@ export default function AdRevenueReportPage() {
<Typography.Text type="secondary">
(广 {r.reward_detail.expected_coin} / {r.reward_detail.actual_coin})
</Typography.Text>
<Table<AdRevenueRecord>
<Table<AdRevenueDetailRow>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={[r.reward_detail]}
dataSource={[{ ...r.reward_detail, source_adn: r.adn }]}
pagination={false}
size="small"
scroll={{ x: 900 }}
+239 -97
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 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,15 @@ const calcCost = (inTok: number | null, outTok: number | null, price: number | n
const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`);
const fmtTok = (inTok: number | null, outTok: number | null) =>
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
const percentile = (values: number[], q: number) => {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const idx = (sorted.length - 1) * q;
const lo = Math.floor(idx);
const hi = Math.min(lo + 1, sorted.length - 1);
return Math.round(sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo));
};
const fmtRate = (value: number | null) => (value == null ? '-' : `${(value * 100).toFixed(1)}%`);
// LLM 价格快照只展示 prices 里各模型的单价(元/每百万 token),忽略 mode/_source 等元字段。
function llmPriceRows(snapshot: Record<string, unknown> | null): string[] {
@@ -92,12 +103,20 @@ const preStyle: CSSProperties = {
export default function ComparisonRecordsPage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')]);
const [userId, setUserId] = useState<number | null>(null);
const [phone, setPhone] = useState('');
const [status, setStatus] = useState<string | undefined>();
const [store, setStore] = useState('');
const [product, setProduct] = useState('');
const [applied, setApplied] = useState<Record<string, unknown>>({});
const [applied, setApplied] = useState<Record<string, unknown>>({
date_from: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
date_to: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
});
const [allItems, setAllItems] = useState<ComparisonRecordListItem[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// LLM 单价(元/百万 token),本地持久化;仅用于前端估算成本,不入库、不影响其它页面
const [pricePerMTok, setPricePerMTok] = useState<number | null>(() => {
if (typeof window === 'undefined') return null;
@@ -111,14 +130,13 @@ export default function ComparisonRecordsPage() {
else localStorage.setItem('llm_price_per_mtok', String(v));
};
const { items, total, page, pageSize, loading, onChange } =
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
const [detail, setDetail] = useState<ComparisonRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const search = () =>
setApplied({
date_from: range[0].format('YYYY-MM-DD'),
date_to: range[1].format('YYYY-MM-DD'),
user_id: userId ?? undefined,
phone: phone || undefined,
status,
@@ -132,7 +150,62 @@ export default function ComparisonRecordsPage() {
setStatus(undefined);
setStore('');
setProduct('');
setApplied({});
const yesterday = dayjs().subtract(1, 'day');
setRange([yesterday, yesterday]);
setApplied({
date_from: yesterday.format('YYYY-MM-DD'),
date_to: yesterday.format('YYYY-MM-DD'),
});
};
useEffect(() => {
let alive = true;
const loadAll = async () => {
setLoading(true);
try {
const serverFilters = { ...applied };
delete serverFilters.date_from;
delete serverFilters.date_to;
const rows: ComparisonRecordListItem[] = [];
let cursor = 0;
for (let pageIndex = 0; pageIndex < 200; pageIndex += 1) {
const { data } = await api.get<CursorPage<ComparisonRecordListItem>>(
'/admin/api/comparison-records',
{ params: { ...serverFilters, limit: 100, cursor } },
);
rows.push(...data.items);
if (data.next_cursor == null || data.items.length === 0) break;
cursor = data.next_cursor;
}
if (alive) {
setAllItems(rows);
setPage(1);
}
} catch (e) {
if (alive) message.error(errMsg(e));
} finally {
if (alive) setLoading(false);
}
};
loadAll();
return () => {
alive = false;
};
}, [applied, message]);
const filteredItems = useMemo(() => {
const from = String(applied.date_from ?? '');
const to = String(applied.date_to ?? '');
return allItems.filter((item) => {
const date = dayjs(item.created_at).format('YYYY-MM-DD');
return (!from || date >= from) && (!to || date <= to);
});
}, [allItems, applied]);
const total = filteredItems.length;
const items = filteredItems.slice((page - 1) * pageSize, page * pageSize);
const onChange = (nextPage: number, nextPageSize: number) => {
setPageSize(nextPageSize);
setPage(nextPageSize === pageSize ? nextPage : 1);
};
const openDetail = async (id: number) => {
@@ -153,8 +226,48 @@ export default function ComparisonRecordsPage() {
setDetailLoading(false);
};
const overview = useMemo(() => {
const completed = filteredItems.filter(
(item) => item.status === 'success' || item.status === 'failed',
);
const cancelled = filteredItems.filter((item) => item.status === 'cancelled');
const success = filteredItems.filter((item) => item.status === 'success');
const successRateDenominator = filteredItems.length - cancelled.length;
const completedDurations = completed
.map((item) => item.total_ms)
.filter((value): value is number => value != null);
const cancelledDurations = cancelled
.map((item) => item.total_ms)
.filter((value): value is number => value != null);
const costs = filteredItems
.map((item) => item.llm_cost_yuan)
.filter((value): value is number => value != null);
const avg = (values: number[]) =>
values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
return {
started: filteredItems.length,
completed: completed.length,
success: success.length,
successRate: successRateDenominator ? success.length / successRateDenominator : null,
avgTokenCost: avg(costs),
lowerPriceRate: success.length
? success.filter((item) => (item.saved_amount_cents ?? 0) > 0).length / success.length
: null,
avgMs: avg(completedDurations),
p5Ms: percentile(completedDurations, 0.05),
p50Ms: percentile(completedDurations, 0.5),
p95Ms: percentile(completedDurations, 0.95),
p99Ms: percentile(completedDurations, 0.99),
cancelled: cancelled.length,
cancelledRate: filteredItems.length ? cancelled.length / filteredItems.length : null,
cancelledP5Ms: percentile(cancelledDurations, 0.05),
cancelledP50Ms: percentile(cancelledDurations, 0.5),
cancelledP95Ms: percentile(cancelledDurations, 0.95),
};
}, [filteredItems]);
const columns: ColumnsType<ComparisonRecordListItem> = [
{ title: 'ID', dataIndex: 'id', width: 64 },
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
{
title: '用户',
key: 'user',
@@ -167,7 +280,7 @@ export default function ComparisonRecordsPage() {
),
},
{ title: '状态', dataIndex: 'status', width: 72, render: (s: string) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s] || s}</Tag> },
{ title: '平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
{ title: '平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
{
title: '店',
dataIndex: 'store_name',
@@ -183,42 +296,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 +323,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 +347,8 @@ export default function ComparisonRecordsPage() {
ellipsis: true,
render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-',
},
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
{
title: '操作',
title: 'traceid',
key: 'op',
fixed: 'right',
width: 80,
@@ -262,57 +366,95 @@ export default function ComparisonRecordsPage() {
return (
<div>
<h2>(debug)</h2>
<Space style={{ marginBottom: 16 }} wrap>
<InputNumber placeholder="user_id" value={userId} onChange={setUserId} style={{ width: 110 }} />
<Input
placeholder="手机号前缀"
value={phone}
onChange={(e) => setPhone(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Input
placeholder="搜索店名"
value={store}
onChange={(e) => setStore(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Input
placeholder="搜索商品"
value={product}
onChange={(e) => setProduct(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 110 }}
options={[
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已终止' },
]}
/>
<Button type="primary" onClick={search}></Button>
<Button onClick={reset}></Button>
<InputNumber
placeholder="LLM 单价"
value={pricePerMTok}
onChange={onPriceChange}
min={0}
step={0.1}
style={{ width: 210 }}
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"
+115 -88
View File
@@ -126,7 +126,7 @@ const fmtPct = (v: number | null | undefined): string =>
// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位。
const fmtYuan = (v: number | null | undefined): string =>
v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`;
v == null ? '-' : `¥${v.toFixed(4)}`;
// 一组按券行的 success_rate 算术平均(每张券等权;空值券跳过);无有效券 → null(显示 -)。
// 汇总卡「分平台/合计点位成功率」据此由按券明细上卷,口径与下方按券表一致。
@@ -138,8 +138,6 @@ const slotRateMean = (rows: CouponSlotRow[]): number | null => {
};
// 汇总卡成功率口径 tooltip 文案
const FULL_RATE_HINT =
'整单成功率:一次领券勾选的平台全部至少领到一张的场次 ÷ 领券发起数;基数含未完成/失败/中途退出。';
const POINT_RATE_HINT =
'点位成功率(合计):当前区间内全部券成功率的算术平均(每张券等权);' +
'单券成功率=成功(含已领)÷尝试(不含跳过),源自领券每券记录。';
@@ -346,7 +344,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 +357,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 +380,52 @@ 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);
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 +444,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 +486,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,6 +493,22 @@ 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',
@@ -522,9 +547,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 +590,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 ?? [];
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 +614,7 @@ export default function CouponDataPage() {
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false}
presets={[
{ label: '昨日', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
@@ -677,6 +707,32 @@ export default function CouponDataPage() {
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic
title={
<span>
{' '}
<Tooltip title="领券完成数 ÷(领券发起数-中途退出数)">
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(couponSuccessRate)}
/>
</Col>
<Col flex="1 1 0">
<Statistic
title={
<span>
{' '}
<Tooltip title={POINT_RATE_HINT}>
<InfoCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
value={fmtPct(slotRateMean(couponSlots))}
/>
</Col>
<Col flex="1 1 0">
<Statistic title="领券发起数" value={summary.started_count} />
</Col>
@@ -703,35 +759,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;
+281 -107
View File
@@ -1,11 +1,16 @@
'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Alert, App, Select, Spin, Tag, Tooltip } from 'antd';
import { Alert, App, DatePicker, Select, Spin, Tag, Tooltip } from 'antd';
import { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import dayjs, { type Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import type { AdRevenueReport, DashboardOverview } from '@/lib/types';
import type {
AdRevenueReport,
ComparisonRecordListItem,
CursorPage,
DashboardOverview,
} from '@/lib/types';
type PeriodKey = 'yesterday' | '7d' | '30d';
type TrendSeriesKey = 'dau' | 'newUsers' | 'comparisons';
@@ -37,6 +42,18 @@ const fmtYuan = (yuan: number | null | undefined) => (yuan == null ? '--' : `¥$
const fmtMsAsSeconds = (ms: number | null | undefined) => (ms == null ? '--' : (ms / 1000).toFixed(1));
const pct = (v: number | null | undefined) => (v == null ? '--' : `${(v * 100).toFixed(1)}%`);
const compact = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : String(v));
const percentile = (values: number[], q: number) => {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const idx = (sorted.length - 1) * q;
const lo = Math.floor(idx);
const hi = Math.min(lo + 1, sorted.length - 1);
return Math.round(sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo));
};
const inDateRange = (value: string, start: Dayjs, end: Dayjs) => {
const date = dayjs(value).format('YYYY-MM-DD');
return date >= start.format('YYYY-MM-DD') && date <= end.format('YYYY-MM-DD');
};
const fmtCoinAmount = (v: number | null | undefined) => {
if (v == null) return '--';
if (Math.abs(v) >= 10000) return `${(v / 10000).toFixed(2).replace(/\.?0+$/, '')}`;
@@ -124,11 +141,6 @@ function retentionTitle(period: PeriodKey) {
return '前日用户新增留存';
}
function avgEcpm(report: AdRevenueReport | null) {
if (!report || report.total_impressions <= 0) return null;
return (report.total_revenue_yuan / report.total_impressions) * 1000;
}
function adTypeSummary(report: AdRevenueReport | null, adType: string) {
if (!report) return null;
if (report.by_ad_type?.[adType]) return report.by_ad_type[adType];
@@ -345,8 +357,6 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
.join(' ');
return `M${chart.left},${chart.bottom} ${line.replace(/^L/, 'L')} L${chart.right},${chart.bottom} Z`;
};
const labelStep = Math.max(1, Math.ceil(points.length / 6));
const toggleSeries = (key: TrendSeriesKey) => {
setVisibleSeries((prev) => {
const visibleCount = Object.values(prev).filter(Boolean).length;
@@ -378,7 +388,7 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
</div>
</div>
<div className="chart-empty">
<svg viewBox="0 0 900 240" width="100%" height="220" preserveAspectRatio="none" aria-hidden>
<svg viewBox="0 0 900 262" width="100%" height="240" preserveAspectRatio="none" aria-hidden>
<g stroke="#edf0f5" strokeWidth="1">
<line x1="44" y1="28" x2="880" y2="28" />
<line x1="44" y1="76" x2="880" y2="76" />
@@ -431,14 +441,18 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
</g>
) : null,
)}
<g fill="#97A3B8" fontSize="12" fontWeight="600">
{points.map((p, idx) =>
idx === 0 || idx === points.length - 1 || idx % labelStep === 0 ? (
<text key={p.date} x={xFor(idx)} y="235" textAnchor={idx === points.length - 1 ? 'end' : 'middle'}>
{dayjs(p.date).format('MM-DD')}
</text>
) : null,
)}
<g fill="#97A3B8" fontSize="10" fontWeight="600">
{points.map((p, idx) => (
<text
key={p.date}
x={xFor(idx)}
y="236"
textAnchor="end"
transform={`rotate(-45 ${xFor(idx)} 236)`}
>
{dayjs(p.date).format('MM-DD')}
</text>
))}
</g>
</>
)}
@@ -452,6 +466,8 @@ function TrendChart({ rangeLabel, points }: { rangeLabel: string; points: Dashbo
export default function DashboardPage() {
const { message } = App.useApp();
const [period, setPeriod] = useState<PeriodKey>('yesterday');
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [data, setData] = useState<DashboardOverview | null>(null);
const [previousData, setPreviousData] = useState<DashboardOverview | null>(null);
const [adReport, setAdReport] = useState<AdRevenueReport | null>(null);
@@ -463,12 +479,24 @@ export default function DashboardPage() {
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [cpsSyncing, setCpsSyncing] = useState(false);
const [comparisonRecords, setComparisonRecords] = useState<ComparisonRecordListItem[]>([]);
const [comparisonRecordsComplete, setComparisonRecordsComplete] = useState(false);
const [couponP95Ms, setCouponP95Ms] = useState<number | null>(null);
const range = useMemo(() => periodRange(period), [period]);
const range = useMemo(() => {
if (!customRange) return periodRange(period);
const start = customRange[0].startOf('day');
const end = customRange[1].startOf('day');
return {
start,
end,
label: `${start.format('MM-DD')} ~ ${end.format('MM-DD')}`,
days: end.diff(start, 'day') + 1,
};
}, [period, customRange]);
const previousRange = useMemo(() => previousPeriodRange(range), [range]);
const periodData = data?.period ?? null;
const previousPeriodData = previousData?.period ?? null;
const adAvgEcpm = avgEcpm(adReport);
const rewardVideoAdSummary = adTypeSummary(adReport, 'reward_video');
const couponAdSummary = adSceneSummary(adReport, 'coupon');
const comparisonAdSummary = adSceneSummary(adReport, 'comparison');
@@ -477,8 +505,54 @@ export default function DashboardPage() {
rewardVideoAdSummary && rewardVideoAdSummary.impressions > 0
? fmtYuan((rewardVideoAdSummary.revenue_yuan / rewardVideoAdSummary.impressions) * 1000)
: '--';
const compareSuccessRate = periodData?.comparison.success_rate ?? null;
const averageDurationMs = periodData?.comparison.average_duration_ms ?? null;
const comparisonMetrics = useMemo(() => {
const current = comparisonRecords.filter((r) => inDateRange(r.created_at, range.start, range.end));
const previous = comparisonRecords.filter((r) =>
inDateRange(r.created_at, previousRange.start, previousRange.end),
);
const summarize = (rows: ComparisonRecordListItem[]) => {
const cancelled = rows.filter((r) => r.status === 'cancelled').length;
const completedRows = rows.filter((r) => r.status === 'success' || r.status === 'failed');
const completed = completedRows.length;
const success = rows.filter((r) => r.status === 'success').length;
const successRateDenominator = rows.length - cancelled;
const durations = completedRows
.filter((r) => r.total_ms != null)
.map((r) => r.total_ms as number);
const tokenCosts = rows
.map((r) => r.llm_cost_yuan)
.filter((v): v is number => v != null);
return {
total: rows.length,
completed,
cancelled,
success,
successRate: successRateDenominator > 0 ? success / successRateDenominator : null,
medianMs: percentile(durations, 0.5),
p95Ms: percentile(durations, 0.95),
tokenCostTotal: tokenCosts.reduce((sum, value) => sum + value, 0),
};
};
return { current: summarize(current), previous: summarize(previous) };
}, [comparisonRecords, range.start, range.end, previousRange.start, previousRange.end]);
const compareSuccessRate = comparisonRecordsComplete
? comparisonMetrics.current.successRate
: null;
const comparisonTotal = comparisonRecordsComplete
? comparisonMetrics.current.total
: periodData?.comparison.total;
const comparisonCompleted = comparisonRecordsComplete ? comparisonMetrics.current.completed : null;
const comparisonSuccess = comparisonRecordsComplete
? comparisonMetrics.current.success
: periodData?.comparison.success;
const comparisonSuccessRateDenominator = comparisonRecordsComplete
? comparisonMetrics.current.total - comparisonMetrics.current.cancelled
: null;
const comparisonMedianMs = comparisonRecordsComplete
? comparisonMetrics.current.medianMs
: null;
const comparisonP95Ms = comparisonRecordsComplete ? comparisonMetrics.current.p95Ms : null;
const tokenCostYuan = comparisonRecordsComplete ? comparisonMetrics.current.tokenCostTotal : null;
const couponRewardCoinTotal = periodData?.coins.coupon_reward_coin_total;
const comparisonRewardCoinTotal = periodData?.coins.comparison_reward_coin_total;
const signinCoinTotal = periodData?.coins.signin_coin_total;
@@ -494,12 +568,21 @@ export default function DashboardPage() {
const previousTotalCpsCommissionCents = cpsCommissionCents(previousData);
const totalRevenueYuan = revenueYuan(adReport, totalCpsCommissionCents);
const previousTotalRevenueYuan = revenueYuan(previousAdReport, previousTotalCpsCommissionCents);
const yesterdayTrendPoint = periodData?.trend?.[periodData.trend.length - 1];
const yesterdayActiveUsers = yesterdayTrendPoint?.active_users ?? null;
const periodActiveUsers = periodData?.users.active ?? null;
const arpuYuan =
totalRevenueYuan == null || yesterdayActiveUsers == null || yesterdayActiveUsers <= 0
totalRevenueYuan == null || periodActiveUsers == null || periodActiveUsers <= 0
? null
: totalRevenueYuan / yesterdayActiveUsers;
: totalRevenueYuan / periodActiveUsers;
const coinCostYuan =
periodData?.coins.granted_total == null ? null : periodData.coins.granted_total / 10000;
const grossArpuYuan =
totalRevenueYuan == null ||
tokenCostYuan == null ||
coinCostYuan == null ||
periodActiveUsers == null ||
periodActiveUsers <= 0
? null
: (totalRevenueYuan - tokenCostYuan - coinCostYuan) / periodActiveUsers;
const cpsRevenueText = cpsAvailable ? fmtCents(totalCpsCommissionCents) : '--';
const meituanCpsRevenueText = cpsAvailable ? fmtCents(meituanCpsCommissionCents) : '--';
const jdCpsRevenueText = cpsAvailable ? fmtCents(jdCpsCommissionCents) : '--';
@@ -511,20 +594,18 @@ export default function DashboardPage() {
const activeUserDelta = percentDelta(periodData?.users.active, previousPeriodData?.users.active);
const newUserDelta = percentDelta(periodData?.users.new, previousPeriodData?.users.new);
const retentionDelta = pointDelta(periodData?.users.retention_rate, previousPeriodData?.users.retention_rate);
const retentionSpark: 'up' | 'down' | 'flat' | undefined =
periodData?.users.retention_rate == null
? undefined
: retentionDelta.tone === 'down'
? 'down'
: retentionDelta.tone === 'up' || periodData.users.retention_rate >= 1
? 'up'
: 'flat';
const comparisonTotalDelta = percentDelta(periodData?.comparison.total, previousPeriodData?.comparison.total);
const comparisonSuccessDelta = percentDelta(periodData?.comparison.success, previousPeriodData?.comparison.success);
const comparisonTotalDelta = percentDelta(
comparisonRecordsComplete ? comparisonMetrics.current.total : periodData?.comparison.total,
comparisonRecordsComplete ? comparisonMetrics.previous.total : previousPeriodData?.comparison.total,
);
const comparisonSuccessDelta = percentDelta(
comparisonRecordsComplete ? comparisonMetrics.current.success : periodData?.comparison.success,
comparisonRecordsComplete ? comparisonMetrics.previous.success : previousPeriodData?.comparison.success,
);
const comparisonOrderedDelta = percentDelta(periodData?.comparison.ordered, previousPeriodData?.comparison.ordered);
const durationDeltaInfo = durationDelta(
periodData?.comparison.average_duration_ms,
previousPeriodData?.comparison.average_duration_ms,
comparisonRecordsComplete ? comparisonMetrics.current.medianMs : null,
comparisonRecordsComplete ? comparisonMetrics.previous.medianMs : null,
);
// 领券核心数据(period.coupon;旧后端无此字段时整节显示 '--')
const couponPeriod = periodData?.coupon ?? null;
@@ -548,14 +629,75 @@ export default function DashboardPage() {
previousPeriodData?.comparison.average_saved_cents,
);
const totalRevenueDelta = percentDelta(totalRevenueYuan, previousTotalRevenueYuan);
const adRevenueDelta = percentDelta(adReport?.total_revenue_yuan, previousAdReport?.total_revenue_yuan);
const cpsRevenueDelta = percentDelta(totalCpsCommissionCents, previousTotalCpsCommissionCents);
const grantedCoinDelta = percentDelta(periodData?.coins.granted_total, previousPeriodData?.coins.granted_total);
const couponCoinRatio = ratioBadge(couponRewardCoinTotal, periodData?.coins.granted_total);
const comparisonCoinRatio = ratioBadge(comparisonRewardCoinTotal, periodData?.coins.granted_total);
const rewardVideoCoinRatio = ratioBadge(periodData?.coins.reward_video_coin_total, periodData?.coins.granted_total);
const regularTaskCoinRatio = ratioBadge(regularTaskCoinTotal, periodData?.coins.granted_total);
useEffect(() => {
let alive = true;
const loadComparisonRecords = async () => {
const collected: ComparisonRecordListItem[] = [];
let cursor = 0;
let complete = false;
for (let pageIndex = 0; pageIndex < 100; pageIndex += 1) {
const { data: pageData } = await api.get<CursorPage<ComparisonRecordListItem>>(
'/admin/api/comparison-records',
{ params: { limit: 100, cursor } },
);
collected.push(...pageData.items);
const oldest = pageData.items.at(-1);
if (
pageData.next_cursor == null ||
pageData.items.length === 0 ||
(oldest && dayjs(oldest.created_at).isBefore(previousRange.start.startOf('day')))
) {
complete = true;
break;
}
cursor = pageData.next_cursor;
}
if (!alive) return;
setComparisonRecords(collected);
setComparisonRecordsComplete(complete);
};
loadComparisonRecords().catch(() => {
if (!alive) return;
setComparisonRecords([]);
setComparisonRecordsComplete(false);
});
return () => {
alive = false;
};
}, [range.end, previousRange.start, refreshKey]);
useEffect(() => {
let alive = true;
api
.get<{ summary: { p95_ms: number | null } }>('/admin/api/coupon-data', {
params: {
date_from: range.start.format('YYYY-MM-DD'),
date_to: range.end.format('YYYY-MM-DD'),
app_env: 'prod',
status: ['started', 'completed', 'failed', 'abandoned'],
granularity: 'day',
limit: 1,
offset: 0,
},
paramsSerializer: { indexes: null },
})
.then(({ data: couponData }) => {
if (alive) setCouponP95Ms(couponData.summary.p95_ms);
})
.catch(() => {
if (alive) setCouponP95Ms(null);
});
return () => {
alive = false;
};
}, [range.start, range.end, refreshKey]);
useEffect(() => {
let alive = true;
setLoading(true);
@@ -674,17 +816,33 @@ export default function DashboardPage() {
{PERIODS.map((it) => (
<button
key={it.key}
className={period === it.key ? 'on' : undefined}
onClick={() => setPeriod(it.key)}
className={!customRange && period === it.key ? 'on' : undefined}
onClick={() => {
setPeriod(it.key);
setCustomRange(null);
}}
>
{it.label}
</button>
))}
</div>
<div className="date-pill">
<CalendarOutlined />
{range.label}
</div>
<DatePicker.RangePicker
className="dashboard-date-picker"
value={[range.start, range.end]}
format="YYYY-MM-DD"
allowClear={false}
open={datePickerOpen}
disabledDate={(date) => date.isAfter(dayjs().subtract(1, 'day'), 'day')}
suffixIcon={<CalendarOutlined />}
onClick={() => setDatePickerOpen(true)}
onOpenChange={setDatePickerOpen}
onChange={(value) => {
if (value?.[0] && value[1]) {
setCustomRange([value[0], value[1]]);
setDatePickerOpen(false);
}
}}
/>
<Select
value="prev"
style={{ width: 132 }}
@@ -706,7 +864,6 @@ export default function DashboardPage() {
delta={activeUserDelta.value}
deltaTone={activeUserDelta.tone}
hint="当前按本期内登录、开始比价、开始领券任一满足去重;比价按 real_compare_start,领券按 real_coupon_start/claim_started。"
spark="up"
/>
<StatCard
title="新增用户"
@@ -714,7 +871,6 @@ export default function DashboardPage() {
delta={newUserDelta.value}
deltaTone={newUserDelta.tone}
hint="当前按注册时间落在所选日期范围统计;若产品要按首次进入 App/首次流程启动定义新增,需要补首次行为聚合。"
spark="up"
/>
<StatCard
title={retentionTitle(period)}
@@ -723,14 +879,12 @@ export default function DashboardPage() {
delta={retentionDelta.value}
deltaTone={retentionDelta.tone}
hint={periodData?.users.retention_note ?? '暂无留存数据'}
spark={retentionSpark}
/>
<StatCard
title="比价次数"
value={fmtInt(periodData?.comparison.total)}
value={fmtInt(comparisonTotal)}
delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone}
spark="up"
/>
</div>
<TrendChart rangeLabel={range.label} points={periodData?.trend ?? []} />
@@ -742,37 +896,53 @@ export default function DashboardPage() {
<h3></h3>
<span></span>
</div>
<div className="grid g5">
<div className="grid g4">
<StatCard
title="发起比价"
value={fmtInt(periodData?.comparison.total)}
title="比价发起次数"
value={fmtInt(comparisonTotal)}
unit="次"
delta={comparisonTotalDelta.value}
deltaTone={comparisonTotalDelta.tone}
/>
<StatCard
title="比价成功"
value={fmtInt(periodData?.comparison.success)}
title="比价完成次数"
value={fmtInt(comparisonCompleted)}
unit="次"
hint="成功次数 + 失败次数,不包含中途退出。"
/>
<StatCard
title="比价成功次数"
value={fmtInt(comparisonSuccess)}
unit="次"
delta={comparisonSuccessDelta.value}
deltaTone={comparisonSuccessDelta.tone}
hint={`当前成功率 ${pct(compareSuccessRate)}`}
/>
<StatCard
title="成功下单"
title="比价成功率"
value={pct(compareSuccessRate)}
hint={`成功次数 ÷(比价发起次数-中途退出次数);当前 ${fmtInt(comparisonSuccess)} ÷ ${fmtInt(comparisonSuccessRateDenominator)}`}
/>
<StatCard
title="下单次数"
value={fmtInt(periodData?.comparison.ordered)}
unit=""
unit=""
delta={comparisonOrderedDelta.value}
deltaTone={comparisonOrderedDelta.tone}
hint="按比价记录页同一口径:用户有通过比价产生的下单记录,且店名与比价记录店名一致,则这条比价记录记为已下单。"
/>
<StatCard
title="平均耗时"
value={fmtMsAsSeconds(averageDurationMs)}
title="耗时中位数"
value={fmtMsAsSeconds(comparisonMedianMs)}
unit="秒"
delta={durationDeltaInfo.value}
deltaTone={durationDeltaInfo.tone}
hint="按比价记录页同一口径:取所选日期范围内已上报耗时的比价记录,求平均后换算为秒。"
hint="只统计成功和失败的比价记录,中途退出不参与耗时统计。"
/>
<StatCard
title="耗时 95% 分位数"
value={fmtMsAsSeconds(comparisonP95Ms)}
unit="秒"
hint="只统计成功和失败的比价记录;95% 的有效比价耗时不超过该值。"
/>
<StatCard
title="平均节省金额"
@@ -789,7 +959,7 @@ export default function DashboardPage() {
<h3></h3>
<span></span>
</div>
<div className="grid g4">
<div className="grid g5">
<StatCard
title="领券发起数"
value={fmtInt(couponPeriod?.started)}
@@ -825,21 +995,27 @@ export default function DashboardPage() {
deltaTone={couponMedianDelta.tone}
hint="仅统计「完成」的领券(同领券数据页口径),中位数比均值更抗中途暂停等待的长尾。"
/>
<StatCard
title="耗时 95% 分位数"
value={fmtMsAsSeconds(couponP95Ms)}
unit="秒"
hint="仅统计「完成」的领券;95% 的完成场次耗时不超过该值。"
/>
</div>
</section>
<section className="section">
<div className="section-head">
<span className="bar" />
<h3></h3>
<h3></h3>
<span>
{cpsAvailable
? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入')
: (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')}
</span>
</div>
<div className="sub-label"></div>
<div className="grid g4">
<div className="sub-label"></div>
<div className="grid g5">
<RevenueCard
title="总收入"
value={adLoading ? '--' : fmtYuan(totalRevenueYuan)}
@@ -851,44 +1027,36 @@ export default function DashboardPage() {
{ label: 'CPS 收入', value: cpsRevenueText },
]}
/>
<RevenueCard
title="广告收入"
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
delta={adRevenueDelta.value}
deltaTone={adRevenueDelta.tone}
meta={[
{
label: '占总收入',
value:
totalRevenueYuan && totalRevenueYuan > 0 && adReport?.total_revenue_yuan != null
? `${((adReport.total_revenue_yuan / totalRevenueYuan) * 100).toFixed(1)}%`
: '--',
},
]}
/>
<RevenueCard
title="CPS 收入"
value={cpsRevenueText}
delta={cpsRevenueDelta.value}
deltaTone={cpsRevenueDelta.tone}
meta={[
{
label: '占总收入',
value:
totalRevenueYuan && totalRevenueYuan > 0 && totalCpsCommissionCents != null
? `${(((totalCpsCommissionCents / 100) / totalRevenueYuan) * 100).toFixed(1)}%`
: '--',
},
]}
/>
<RevenueCard
title="ARPU"
value={adLoading ? '--' : fmtYuan(arpuYuan)}
meta={[
{ label: '口径', value: '总收入 / 昨日活跃用户' },
{ label: '分母', value: yesterdayActiveUsers == null ? '--' : `${fmtInt(yesterdayActiveUsers)}` },
{ label: '口径', value: '总收入 / 区间活跃用户' },
{ label: '分母', value: periodActiveUsers == null ? '--' : `${fmtInt(periodActiveUsers)}` },
]}
/>
<RevenueCard
title="TOKEN 成本"
value={fmtYuan(tokenCostYuan)}
meta={[
{ label: '口径', value: '区间比价记录 LLM 实际成本合计' },
]}
/>
<RevenueCard
title="发放金币成本"
value={fmtYuan(coinCostYuan)}
meta={[
{ label: '口径', value: '本期发放金币 / 10000' },
]}
/>
<RevenueCard
title="毛利 ARPU"
value={fmtYuan(grossArpuYuan)}
meta={[
{ label: '口径', value: '(总收入-TOKEN 成本-金币成本)/ DAU' },
]}
accent={grossArpuYuan != null && grossArpuYuan < 0 ? 'red' : 'green'}
/>
</div>
<div className="sub-label cps-sub-label">
@@ -934,10 +1102,6 @@ export default function DashboardPage() {
<RevenueCard
title="总广告收益"
value={adLoading ? '--' : fmtYuan(adReport?.total_revenue_yuan)}
meta={[
{ label: '平均 eCPM', value: adLoading ? '--' : fmtYuan(adAvgEcpm ?? undefined) },
{ label: '展示数', value: adLoading ? '--' : fmtInt(adReport?.total_impressions) },
]}
accent="green"
/>
<RevenueCard
@@ -957,7 +1121,7 @@ export default function DashboardPage() {
]}
/>
<RevenueCard
title="激励视频广告"
title="视频广告"
value={rewardVideoAdText}
meta={[
{ label: '平均 eCPM', value: rewardVideoEcpm },
@@ -995,11 +1159,11 @@ export default function DashboardPage() {
deltaTone={comparisonCoinRatio.tone}
/>
<StatCard
title="激励视频金币"
title="视频金币"
value={fmtInt(periodData?.coins.reward_video_coin_total)}
delta={rewardVideoCoinRatio.value}
deltaTone={rewardVideoCoinRatio.tone}
hint="独立统计激励视频发放,不再重复计入领券奖励。"
hint="独立统计视频发放,不再重复计入领券奖励。"
/>
<StatCard
title="常规任务金币"
@@ -1018,7 +1182,7 @@ export default function DashboardPage() {
<div>
4 <b>{fmtCoinAmount(periodData?.coins.granted_total)}</b>
<b>{fmtYuan((periodData?.coins.granted_total ?? 0) / 1000)}</b>
<b>{fmtYuan((periodData?.coins.granted_total ?? 0) / 10000)}</b>
<b>{fmtCents(periodData?.cash.withdraw_success_cents)}</b>
<b>{fmtCoinAmount(data.coins.granted_total)}</b>
<b>{fmtCents(data.cash.withdraw_success_cents)}</b>/
@@ -1111,6 +1275,16 @@ export default function DashboardPage() {
flex-wrap: wrap;
gap: 10px;
}
.dashboard-date-picker {
min-width: 240px;
cursor: pointer;
}
.dashboard-date-picker:hover {
border-color: var(--brand-blue);
}
.dashboard-date-picker input {
cursor: pointer;
}
.segment {
display: inline-flex;
gap: 2px;
+8 -13
View File
@@ -53,9 +53,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: '埋点成功率' },
@@ -145,19 +145,14 @@ export default function MainLayout({ children }: { children: React.ReactNode })
collapsedWidth={72}
width={220}
onCollapse={setCollapsed}
style={{
position: 'sticky',
top: 0,
height: '100vh',
overflowY: 'auto',
overflowX: 'hidden',
}}
>
<div
style={{
height: 48,
margin: 16,
color: '#fff',
fontWeight: 600,
textAlign: 'center',
lineHeight: '48px',
}}
>
</div>
<nav className={`side-nav${collapsed ? ' side-nav-collapsed' : ''}`} aria-label="后台导航">
{collapsed
? flatNavItems.map((item) => (