fix(admin): address dashboard review feedback
This commit is contained in:
@@ -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') };
|
||||
|
||||
@@ -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: '店',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)。 */
|
||||
|
||||
Reference in New Issue
Block a user