From 30dad5d4e06735f7f8838a3f58872565e2c2f470 Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Mon, 20 Jul 2026 10:36:06 +0800 Subject: [PATCH] fix(admin): address dashboard review feedback --- src/app/(main)/ad-revenue-report/page.tsx | 17 ++++---------- src/app/(main)/comparison-records/page.tsx | 11 ++------- src/app/(main)/coupon-data/page.tsx | 5 +++- src/app/(main)/dashboard/page.tsx | 27 ++++++++-------------- src/lib/format.ts | 14 +++++++++++ 5 files changed, 34 insertions(+), 40 deletions(-) diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index fc25b8f..a6eb426 100644 --- a/src/app/(main)/ad-revenue-report/page.tsx +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -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') }; diff --git a/src/app/(main)/comparison-records/page.tsx b/src/app/(main)/comparison-records/page.tsx index 10db7f7..5a06e7c 100644 --- a/src/app/(main)/comparison-records/page.tsx +++ b/src/app/(main)/comparison-records/page.tsx @@ -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) => {STATUS_LABEL[s] || s} }, + // 产品文档指定展示“原平台”;字段名仍沿用后端 source_platform_name。 { title: '原平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' }, { title: '店', diff --git a/src/app/(main)/coupon-data/page.tsx b/src/app/(main)/coupon-data/page.tsx index 240f93b..9ab3319 100644 --- a/src/app/(main)/coupon-data/page.tsx +++ b/src/app/(main)/coupon-data/page.tsx @@ -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 diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx index d0ee87a..3a7dbb0 100644 --- a/src/app/(main)/dashboard/page.tsx +++ b/src/app/(main)/dashboard/page.tsx @@ -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, diff --git a/src/lib/format.ts b/src/lib/format.ts index 8a621e3..a1d7af3 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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)。 */