fix(admin): address dashboard review feedback

This commit is contained in:
linkeyu
2026-07-20 10:36:06 +08:00
parent 7ab623d6aa
commit 30dad5d4e0
5 changed files with 34 additions and 40 deletions
+4 -13
View File
@@ -31,7 +31,7 @@ import {
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import { formatUtcTime, percentile } from '@/lib/format';
import type {
AdRevenueDaily,
AdRevenueHourly,
@@ -91,15 +91,6 @@ 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 金币,四舍五入取整。
const COIN_PER_YUAN = 10000;
@@ -593,9 +584,9 @@ export default function AdRevenueReportPage() {
.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),
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') };
+2 -9
View File
@@ -9,7 +9,7 @@ import {
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 { formatWallTime, percentile, yuan } from '@/lib/format';
import type { ComparisonRecordDetail, ComparisonRecordListItem, CursorPage } from '@/lib/types';
const { RangePicker } = DatePicker;
@@ -39,14 +39,6 @@ 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 等元字段。
@@ -280,6 +272,7 @@ export default function ComparisonRecordsPage() {
),
},
{ title: '状态', dataIndex: 'status', width: 72, render: (s: string) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s] || s}</Tag> },
// 产品文档指定展示“原平台”;字段名仍沿用后端 source_platform_name。
{ title: '原平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
{
title: '店',
+4 -1
View File
@@ -124,7 +124,7 @@ const fmtSec = (ms: number | null | undefined): string =>
const fmtPct = (v: number | null | undefined): string =>
v == null ? '-' : `${(v * 100).toFixed(1)}%`;
// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位
// 元(小数)→ "¥0.0050";空值显示 -,真实 0 显示 ¥0.0000,以区分“零收益”和“无数据”
const fmtYuan = (v: number | null | undefined): string =>
v == null ? '-' : `¥${v.toFixed(4)}`;
@@ -421,6 +421,9 @@ export default function CouponDataPage() {
]);
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
+10 -17
View File
@@ -5,6 +5,7 @@ import { Alert, App, DatePicker, Select, Spin, Tag, Tooltip } from 'antd';
import { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import { percentile } from '@/lib/format';
import type {
AdRevenueReport,
ComparisonRecordListItem,
@@ -42,14 +43,6 @@ 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');
@@ -538,13 +531,13 @@ export default function DashboardPage() {
const compareSuccessRate = comparisonRecordsComplete
? comparisonMetrics.current.successRate
: null;
const comparisonTotal = comparisonRecordsComplete
? comparisonMetrics.current.total
: periodData?.comparison.total;
// 新口径依赖完整明细(尤其 cancelled 分母)。拉取不完整时整组统一显示空值,
// 不与服务端旧口径的 total/success 混搭,避免同一组卡片来自两套数据源。
const comparisonTotal = comparisonRecordsComplete ? comparisonMetrics.current.total : null;
const comparisonCompleted = comparisonRecordsComplete ? comparisonMetrics.current.completed : null;
const comparisonSuccess = comparisonRecordsComplete
? comparisonMetrics.current.success
: periodData?.comparison.success;
: null;
const comparisonSuccessRateDenominator = comparisonRecordsComplete
? comparisonMetrics.current.total - comparisonMetrics.current.cancelled
: null;
@@ -595,12 +588,12 @@ export default function DashboardPage() {
const newUserDelta = percentDelta(periodData?.users.new, previousPeriodData?.users.new);
const retentionDelta = pointDelta(periodData?.users.retention_rate, previousPeriodData?.users.retention_rate);
const comparisonTotalDelta = percentDelta(
comparisonRecordsComplete ? comparisonMetrics.current.total : periodData?.comparison.total,
comparisonRecordsComplete ? comparisonMetrics.previous.total : previousPeriodData?.comparison.total,
comparisonRecordsComplete ? comparisonMetrics.current.total : null,
comparisonRecordsComplete ? comparisonMetrics.previous.total : null,
);
const comparisonSuccessDelta = percentDelta(
comparisonRecordsComplete ? comparisonMetrics.current.success : periodData?.comparison.success,
comparisonRecordsComplete ? comparisonMetrics.previous.success : previousPeriodData?.comparison.success,
comparisonRecordsComplete ? comparisonMetrics.current.success : null,
comparisonRecordsComplete ? comparisonMetrics.previous.success : null,
);
const comparisonOrderedDelta = percentDelta(periodData?.comparison.ordered, previousPeriodData?.comparison.ordered);
const durationDeltaInfo = durationDelta(
@@ -679,7 +672,7 @@ export default function DashboardPage() {
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,
+14
View File
@@ -23,6 +23,20 @@ const TZ = 'Asia/Shanghai';
/** 金额:分 → ¥元(两位小数)。 */
export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
/**
* 线
* round=false
*/
export function percentile(values: readonly number[], q: number, round = true): number | null {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const idx = (sorted.length - 1) * q;
const lo = Math.floor(idx);
const hi = Math.min(lo + 1, sorted.length - 1);
const result = sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo);
return round ? Math.round(result) : result;
}
const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */