Compare commits

...

2 Commits

Author SHA1 Message Date
OuYingJun1024 24293982bd feat(ad-revenue): 状态标签补不发原因 + eCPM 列标注单位(分)
- 发奖明细状态映射加 closed_early→「提前关闭」/ too_short→「未满10秒」,并全部转中文
- 展示/发奖明细 eCPM 列名改「eCPM(分)」(穿山甲原值单位为分,非元)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:42:01 +08:00
OuYingJun1024 c73cd8dabd feat(ad-revenue): 广告数据报表页(替换金币审计页)
- 新增 /ad-revenue:聚合表 + 派生指标(平均 eCPM/毛利/发奖占比)+ 零依赖 SVG 趋势图
  + 行展开下钻展示明细/发奖复算明细
- 删除旧 /ad-audit 页;侧栏「金币审计」→「广告数据」
- feedbacks 由「加载更多」改 antd 页码分页(usePagedList),内容列 3 行省略可展开
- types 用 AdRevenue* 替换 AdCoinAudit*;providers 统一主题圆角/页面底色

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:56:02 +08:00
6 changed files with 841 additions and 285 deletions
-247
View File
@@ -1,247 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
Alert,
Button,
Card,
Checkbox,
DatePicker,
InputNumber,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
const SCENE_TAG: Record<string, { color: string; label: string }> = {
reward_video: { color: 'blue', label: '看视频' },
feed: { color: 'purple', label: '信息流' },
};
const STATUS_TAG: Record<string, string> = {
granted: 'green',
capped: 'orange',
ecpm_missing: 'red',
};
const fmtFactorRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b || b == null ? String(a) : `${a}${b}`;
};
const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`;
};
export default function AdAuditPage() {
const [date, setDate] = useState<Dayjs>(dayjs());
const [userId, setUserId] = useState<number | null>(null);
const [scene, setScene] = useState<string | undefined>();
const [limit, setLimit] = useState<number>(100);
const [data, setData] = useState<AdCoinAudit | null>(null);
const [queriedLimit, setQueriedLimit] = useState<number>(100); // 本次结果对应的 limit,截断提示里展示
const [onlyMismatch, setOnlyMismatch] = useState(false); // 只看不一致(✗)行
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<AdCoinAudit>('/admin/api/ad-coin-audit', {
params: {
date: date.format('YYYY-MM-DD'),
user_id: userId ?? undefined,
scene: scene ?? undefined,
limit,
only_mismatch: onlyMismatch || undefined,
},
});
setData(res.data);
setQueriedLimit(limit);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, [date, userId, scene, limit, onlyMismatch]);
useEffect(() => {
load();
// 仅首次自动拉今天;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<AdCoinAuditRow> = [
{
title: '场景',
dataIndex: 'scene',
width: 90,
render: (s: string) => {
const t = SCENE_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 175 },
{ title: '用户', dataIndex: 'user_id', width: 70 },
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (s: string) => <Tag color={STATUS_TAG[s] ?? 'default'}>{s}</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,
render: (_: unknown, r: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
},
{
title: '因子2',
key: 'lt_factor',
width: 90,
render: (_: unknown, r: AdCoinAuditRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
},
{
title: '应发金币',
dataIndex: 'expected_coin',
width: 100,
render: (v: number) => <b>{v}</b>,
},
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
{
title: '一致',
dataIndex: 'matched',
width: 70,
render: (m: boolean) =>
m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>,
},
];
return (
<div>
<h2>广</h2>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
广,,
</Typography.Paragraph>
{data && (
<Card size="small" style={{ marginBottom: 16 }}>
<Typography.Text strong>:</Typography.Text>{' '}
<Typography.Text code>{data.formula.description}</Typography.Text>
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
<div>
eCPM :{data.formula.ecpm_unit}, ÷100 ;金币: ={' '}
{data.formula.coin_per_yuan}:1; {data.formula.feed_unit_seconds} 1 LT累计条数
</div>
<div style={{ marginTop: 4 }}>
1( eCPM ,阈值单位:):
{data.formula.ecpm_factor_tiers
.map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`)
.join(' | ')}
</div>
<div style={{ marginTop: 4 }}>
2(LT ):
{data.formula.lt_factor_tiers
.map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}${f}`)
.join(' | ')}
</div>
</div>
</Card>
)}
<Space style={{ marginBottom: 16 }} wrap>
<DatePicker value={date} onChange={(d) => d && setDate(d)} allowClear={false} />
<InputNumber
placeholder="用户 ID(可空)"
value={userId ?? undefined}
onChange={(v) => setUserId(v ?? null)}
style={{ width: 140 }}
/>
<Select
placeholder="场景(全部)"
value={scene}
onChange={setScene}
allowClear
style={{ width: 140 }}
options={[
{ value: 'reward_video', label: '看视频' },
{ value: 'feed', label: '信息流' },
]}
/>
<Select
value={limit}
onChange={setLimit}
style={{ width: 120 }}
options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n}` }))}
/>
<Checkbox checked={onlyMismatch} onChange={(e) => setOnlyMismatch(e.target.checked)}>
()
</Checkbox>
<Button type="primary" onClick={load} loading={loading}>
</Button>
</Space>
{data && (
<div style={{ marginBottom: 12 }}>
<Space direction="vertical" style={{ width: '100%' }}>
{data.mismatch_count > 0 ? (
<Alert
type="error"
message={`${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
/>
) : (
<Alert type="success" message={`${data.total} 条,全部按公式发放(无不一致)`} />
)}
{onlyMismatch && (
<Alert
type="info"
showIcon
message={`当前只展示不一致(✗)行;上方统计仍为全量(共 ${data.total} 条)。`}
/>
)}
{data.truncated && (
<Alert
type="warning"
showIcon
message={`展示明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/场景/日期缩小范围,或调大「条数」。统计数字不受影响。`}
/>
)}
</Space>
</div>
)}
<Table
rowKey={(r) => `${r.scene}-${r.record_id}`}
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
/>
<style jsx global>{`
.row-mismatch > td {
background: #fff1f0 !important;
}
`}</style>
</div>
);
}
+746
View File
@@ -0,0 +1,746 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
Alert,
Button,
Card,
Col,
DatePicker,
Divider,
InputNumber,
Modal,
Popover,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import {
FallOutlined,
InfoCircleOutlined,
QuestionCircleOutlined,
RiseOutlined,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type {
AdRevenueDaily,
AdRevenueImpression,
AdRevenueRecord,
AdRevenueReport,
AdRevenueRow,
} from '@/lib/types';
const { RangePicker } = DatePicker;
// 广告类型标签
const TYPE_TAG: Record<string, { color: string; label: string }> = {
reward_video: { color: 'blue', label: '激励视频' },
feed: { color: 'purple', label: '信息流' },
draw: { color: 'geekblue', label: 'Draw 信息流' },
};
// 我们的应用环境标签
const APP_TAG: Record<string, { color: string; label: string }> = {
prod: { color: 'green', label: '傻瓜比价(正式)' },
test: { color: 'default', label: '测试应用' },
};
// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限)
const STATUS_TAG: Record<string, { color: string; label: string }> = {
granted: { color: 'green', label: '已发' },
capped: { color: 'orange', label: '次数超限' },
ecpm_missing: { color: 'red', label: '缺 eCPM' },
too_short: { color: 'gold', label: '未满10秒' },
closed_early: { color: 'default', label: '提前关闭' },
};
const fmtFactorRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b || b == null ? String(a) : `${a}${b}`;
};
const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`;
};
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
const COIN_PER_YUAN = 10000;
// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE
const ECPM_FACTOR_ROWS = [
{ key: '4', range: '> 400', factor: 0.6 },
{ key: '3', range: '201 400', factor: 0.4 },
{ key: '2', range: '101 200', factor: 0.3 },
{ key: '1', range: '0 100', factor: 0.1 },
];
// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE
const LT_FACTOR_ROWS = [
{ key: '1', lt: '第 1 条', factor: 2.0 },
{ key: '2', lt: '第 2 条', factor: 1.5 },
{ key: '3', lt: '第 3 条', factor: 1.3 },
{ key: '4', lt: '4 10 条', factor: 1.1 },
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
];
// 展开行 - 展示明细:每条广告展示一行(时间/eCPM/收益/adn/底层位)
const IMPRESSION_COLUMNS: ColumnsType<AdRevenueImpression> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 },
{ title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) },
{
title: 'adn',
dataIndex: 'adn',
width: 90,
render: (v: string | null) => v ?? '-',
},
{
title: '底层代码位ID',
dataIndex: 'slot_id',
render: (v: string | null) => v ?? '-',
},
];
// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{
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',
width: 100,
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
},
{
title: '因子2',
key: 'lt_factor',
width: 90,
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
},
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
{
title: '一致',
dataIndex: 'matched',
width: 80,
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
},
];
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
const CHART_BAR = '#69b1ff';
const CHART_LINE = '#fa8c16';
interface TrendPoint {
label: string; // x 轴刻度文案(小时数 / MM-DD)
tip: string; // hover 原生 tooltip 全文
impressions: number;
revenue: number;
}
// 按小时聚合(023),数据源是 items(被 limit 截断时会偏少,调用处给警告)
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
for (const r of rows) {
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
byHour[r.hour].impressions += r.impressions;
byHour[r.hour].revenue += r.revenue_yuan;
}
return byHour.map((b) => ({
label: String(b.hour),
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)}`,
impressions: b.impressions,
revenue: b.revenue,
}));
}
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] {
const map = new Map(daily.map((d) => [d.date, d]));
const out: TrendPoint[] = [];
let cur = dayjs(dateFrom);
let guard = 0;
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
const ds = cur.format('YYYY-MM-DD');
const d = map.get(ds);
const impressions = d?.impressions ?? 0;
const revenue = d?.revenue_yuan ?? 0;
out.push({
label: ds.slice(5),
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)}`,
impressions,
revenue,
});
cur = cur.add(1, 'day');
guard += 1;
}
return out;
}
function TrendChart({ points }: { points: TrendPoint[] }) {
const n = points.length;
const maxImp = Math.max(1, ...points.map((p) => p.impressions));
const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue));
const W = 960;
const H = 280;
const padL = 48;
const padR = 56;
const padT = 16;
const padB = 32;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const step = plotW / Math.max(1, n);
const barW = Math.min(28, step * 0.55);
const yBase = padT + plotH;
const barX = (i: number) => padL + i * step + (step - barW) / 2;
const cx = (i: number) => padL + i * step + step / 2;
const impH = (v: number) => (v / maxImp) * plotH;
const revY = (v: number) => yBase - (v / maxRev) * plotH;
const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' ');
const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = yBase - t * plotH;
return (
<g key={t}>
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR}>
{Math.round(maxImp * t)}
</text>
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
{(maxRev * t).toFixed(2)}
</text>
</g>
);
})}
{points.map((p, i) => (
<rect
key={i}
x={barX(i)}
y={yBase - impH(p.impressions)}
width={barW}
height={impH(p.impressions)}
fill={CHART_BAR}
rx={2}
>
<title>{p.tip}</title>
</rect>
))}
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
{points.map((p, i) => (
<circle key={i} cx={cx(i)} cy={revY(p.revenue)} r={2.5} fill={CHART_LINE}>
<title>{p.tip}</title>
</circle>
))}
{points.map((p, i) =>
i % labelEvery === 0 || i === n - 1 ? (
<text key={i} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
{p.label}
</text>
) : null,
)}
</svg>
);
}
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
export default function AdRevenuePage() {
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>();
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [limit, setLimit] = useState<number>(500);
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 [loading, setLoading] = useState(false);
const [formulaOpen, setFormulaOpen] = useState(false);
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
const load = useCallback(async () => {
const from = range[0].format('YYYY-MM-DD');
const to = range[1].format('YYYY-MM-DD');
const multiDay = from !== to;
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
setLoading(true);
try {
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,
granularity: gran,
limit,
},
});
setData(res.data);
setQueriedLimit(limit);
setQueriedGranularity(gran);
setQueriedMultiDay(multiDay);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, [range, userId, adType, granularity, limit]);
useEffect(() => {
load();
// 仅首次自动拉今天;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<AdRevenueRow> = [
...(queriedMultiDay
? [{ title: '日期', dataIndex: 'report_date', width: 110, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
: []),
{ title: '用户', dataIndex: 'user_id', width: 80 },
...(queriedGranularity === 'hour'
? [
{
title: '小时',
dataIndex: 'hour',
width: 90,
render: (h: number | null) =>
h == null ? '-' : `${String(h).padStart(2, '0')}:00`,
} as ColumnsType<AdRevenueRow>[number],
]
: []),
{
title: '广告类型',
dataIndex: 'ad_type',
width: 120,
render: (s: string) => {
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '来源应用',
dataIndex: 'app_env',
width: 150,
render: (v: string | null) => {
if (!v) return <Typography.Text type="secondary"></Typography.Text>;
const t = APP_TAG[v] ?? { color: 'default', label: v };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '广告位ID',
dataIndex: 'our_code_id',
width: 120,
render: (v: string | null) =>
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
},
{ title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' },
{
title: '预估收益(元)',
dataIndex: 'revenue_yuan',
width: 120,
align: 'right',
render: (v: number) => v.toFixed(4),
},
{
title: '应发金币',
dataIndex: 'expected_coin',
width: 100,
align: 'right',
render: (v: number) => <b>{v}</b>,
},
{ title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' },
{
title: '一致',
dataIndex: 'matched',
width: 80,
align: 'center',
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
},
{
title: '底层 ADN',
dataIndex: 'adns',
render: (v: string[]) =>
v.length ? v.map((a) => <Tag key={a}>{a}</Tag>) : <Typography.Text type="secondary">-</Typography.Text>,
},
];
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
const derived = data
? {
avgEcpm: data.total_impressions
? (data.total_revenue_yuan / data.total_impressions) * 1000
: 0,
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
payoutRatioPct:
data.total_revenue_yuan > 0
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
: null,
coinGap: data.total_expected_coin - data.total_actual_coin,
}
: null;
return (
<div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>广</h2>
<Button
type="link"
size="small"
icon={<QuestionCircleOutlined />}
onClick={() => setFormulaOpen(true)}
>
</Button>
<Popover
placement="bottomLeft"
title="收益口径说明"
content={
<div style={{ maxWidth: 360, fontSize: 13, lineHeight: 1.7 }}>
广(onAdShow) eCPM ( ÷1000
),<b>广</b>;穿/,,
<b>穿</b> 0广ID / ,
</div>
}
>
<Button type="link" size="small" icon={<InfoCircleOutlined />}>
</Button>
</Popover>
</Space>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap size="middle" align="center">
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<RangePicker
value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false}
presets={[
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<InputNumber
placeholder="可空"
value={userId ?? undefined}
onChange={(v) => setUserId(v ?? null)}
style={{ width: 130 }}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
placeholder="全部"
value={adType}
onChange={setAdType}
allowClear
style={{ width: 150 }}
options={[
{ value: 'reward_video', label: '激励视频' },
{ value: 'feed', label: '信息流' },
{ value: 'draw', label: 'Draw 信息流' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={rangeMultiDay ? 'day' : granularity}
onChange={setGranularity}
disabled={rangeMultiDay}
style={{ width: 110 }}
title={rangeMultiDay ? '跨多天仅支持按天' : undefined}
options={[
{ value: 'day', label: '按天' },
{ value: 'hour', label: '按小时' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={limit}
onChange={setLimit}
style={{ width: 110 }}
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n}` }))}
/>
</Space>
<Button type="primary" onClick={load} loading={loading}>
</Button>
</Space>
</Card>
{data && derived && (
<Card size="small" style={{ marginBottom: 16 }}>
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
广
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
<Col span={6}>
<Statistic title="展示条数合计" value={data.total_impressions} />
</Col>
<Col span={6}>
<Statistic title="平均 eCPM(元/千次)" value={derived.avgEcpm} precision={2} />
</Col>
<Col span={6}>
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
</Col>
<Col span={6}>
<Statistic title="聚合组数" value={data.total} />
</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={6}>
<Statistic title="应发金币合计" value={data.total_expected_coin} />
</Col>
<Col span={6}>
<Statistic title="实发金币合计" value={data.total_actual_coin} />
</Col>
<Col span={6}>
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
</Col>
<Col span={6}>
<Statistic
title="预估毛利(元)"
value={derived.grossProfit}
precision={4}
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
valueStyle={{ color: derived.grossProfit >= 0 ? '#3f8600' : '#cf1322' }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{' '}
{derived.payoutRatioPct == null ? '—' : `${derived.payoutRatioPct.toFixed(0)}%`}
</Typography.Text>
</Col>
</Row>
</Card>
)}
{data && (
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
{data.mismatch_count > 0 ? (
<Alert
type="error"
showIcon
message={`${data.total} 组,其中 ${data.mismatch_count} 组应发≠实发(✗);应发−实发 合计 ${
derived && derived.coinGap >= 0 ? '+' : ''
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
/>
) : (
<Alert type="success" showIcon message={`${data.total} 组,应发与实发全部一致。`} />
)}
{data.truncated && (
<Alert
type="warning"
showIcon
message={`聚合明细已截断到 ${queriedLimit} 组,还有更多未显示——请按用户/类型/日期缩小范围,或调大「组数」。合计数字不受影响。`}
/>
)}
</Space>
)}
{data &&
(queriedMultiDay
? (data.daily?.length ?? 0) > 0
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
<Card
size="small"
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
style={{ marginBottom: 16 }}
extra={
<Space size={16}>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
background: CHART_BAR,
borderRadius: 2,
marginRight: 4,
}}
/>
</span>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 14,
borderTop: `2px solid ${CHART_LINE}`,
marginRight: 4,
verticalAlign: 'middle',
}}
/>
()
</span>
</Space>
}
>
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
{!queriedMultiDay && data.truncated && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
/>
)}
{queriedMultiDay ? (
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
) : (
<TrendChart points={aggregateHourly(data.items)} />
)}
</Card>
)}
<Table
rowKey={(r) => `${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`}
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
expandable={{
rowExpandable: (r) =>
(r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0,
expandedRowRender: (r) => {
const imps = r.impression_records ?? [];
const recs = r.records ?? [];
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">({imps.length} )</Typography.Text>
<Table<AdRevenueImpression>
style={{ marginTop: 8 }}
rowKey="id"
columns={IMPRESSION_COLUMNS}
dataSource={imps}
pagination={false}
size="small"
scroll={{ x: 700 }}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">({recs.length} )</Typography.Text>
<Table<AdRevenueRecord>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={recs}
pagination={false}
size="small"
scroll={{ x: 900 }}
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }}
/>
</div>
</Space>
);
},
}}
/>
<Modal
title="金币计算规则"
open={formulaOpen}
onCancel={() => setFormulaOpen(false)}
footer={null}
width={680}
>
<Typography.Paragraph>
<b>()</b> eCPM<sub></sub> ÷ 1000)× <b>1</b>(eCPM )× <b>2</b>(LT )
</Typography.Paragraph>
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
· eCPM 穿 SDK <Typography.Text code>getEcpm()</Typography.Text> ,/, ÷100 ; ÷1000
<br />· :<b>1 {COIN_PER_YUAN.toLocaleString()} </b>,
<br />· 2LT (); 1 , 10 1
<br />· ,
</Typography.Paragraph>
<Row gutter={16}>
<Col span={12}>
<Typography.Text strong>1 · eCPM(/)</Typography.Text>
<Table
style={{ marginTop: 8 }}
size="small"
pagination={false}
dataSource={ECPM_FACTOR_ROWS}
columns={[
{ title: 'eCPM 范围(元)', dataIndex: 'range' },
{ title: '因子1', dataIndex: 'factor', width: 80 },
]}
/>
</Col>
<Col span={12}>
<Typography.Text strong>2 · LT </Typography.Text>
<Table
style={{ marginTop: 8 }}
size="small"
pagination={false}
dataSource={LT_FACTOR_ROWS}
columns={[
{ title: 'LT 累计条数', dataIndex: 'lt' },
{ title: '因子2', dataIndex: 'factor', width: 80 },
]}
/>
</Col>
</Row>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginTop: 12, marginBottom: 0 }}>
示例:eCPM 300 / 1 0.3 × 0.4 × 2.0 0.24 2,400
</Typography.Paragraph>
</Modal>
<style jsx global>{`
.row-mismatch > td {
background: #fff1f0 !important;
}
`}</style>
</div>
);
}
+28 -13
View File
@@ -21,10 +21,10 @@ import type { Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import { usePagedList } from '@/lib/usePagedList';
import type { Feedback } from '@/lib/types';
const { Text } = Typography;
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
@@ -46,10 +46,8 @@ export default function FeedbacksPage() {
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const filterKey = JSON.stringify(filters);
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
'/admin/api/feedbacks',
filters,
);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<Feedback>('/admin/api/feedbacks', filters);
const canHandle = canDo(['operator']);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
@@ -136,7 +134,22 @@ export default function FeedbacksPage() {
const columns: ColumnsType<Feedback> = [
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '用户', dataIndex: 'user_id', width: 80 },
{ title: '内容', dataIndex: 'content' },
{
title: '内容',
dataIndex: 'content',
width: 360,
render: (v: string) =>
v ? (
<Paragraph
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
ellipsis={{ rows: 3, expandable: true, symbol: '展开', tooltip: true }}
>
{v}
</Paragraph>
) : (
<Text type="secondary">-</Text>
),
},
{
title: '截图',
dataIndex: 'images',
@@ -257,14 +270,16 @@ export default function FeedbacksPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条反馈`,
onChange: onPageChange,
}}
onChange={onTableChange}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
</Button>
</div>
</div>
);
}
+2 -2
View File
@@ -3,10 +3,10 @@
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
BarChartOutlined,
DashboardOutlined,
FileSearchOutlined,
FlagOutlined,
FundProjectionScreenOutlined,
LogoutOutlined,
MessageOutlined,
MobileOutlined,
@@ -28,7 +28,7 @@ const MENU = [
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/ad-audit', icon: <FundProjectionScreenOutlined />, label: '金币审计' },
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告数据' },
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
+11 -1
View File
@@ -12,7 +12,17 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<AntdRegistry>
{/* wave 禁用:消除 antd 5 波纹在 Next15 dev 下的 React 版本兼容误报(纯噪音,后台不需要波纹) */}
<ConfigProvider locale={zhCN} wave={{ disabled: true }}>
{/* 全局主题:统一圆角到 8、页面底色用偏冷的浅灰,让白色卡片更有层次(全站生效) */}
<ConfigProvider
locale={zhCN}
wave={{ disabled: true }}
theme={{
token: {
borderRadius: 8,
colorBgLayout: '#f0f2f5',
},
}}
>
{children}
</ConfigProvider>
</AntdRegistry>
+54 -22
View File
@@ -188,40 +188,72 @@ export interface AuditLog {
created_at: string;
}
export interface AdCoinAuditRow {
scene: string; // reward_video / feed
record_id: number;
user_id: number;
// 广告收益报表聚合行下钻的单条展示明细
export interface AdRevenueImpression {
id: number;
created_at: string;
status: string; // granted / capped / ecpm_missing
ecpm: string; // 分/千次展示
revenue_yuan: number; // 本次展示预估收益(元)
adn: string | null;
slot_id: string | null; // 底层 mediation rit
}
// 广告收益报表聚合行下钻的单条发奖复算明细
export interface AdRevenueRecord {
record_id: number;
created_at: string;
status: string; // granted / capped / ecpm_missing / too_short / closed_early
ecpm: string | null;
ecpm_factor: number | null;
units: number;
ecpm_factor: number | null; // 因子1
units: number; // 份数
lt_index_start: number | null;
lt_index_end: number | null;
lt_factor_start: number | null;
lt_factor_start: number | null; // 因子2
lt_factor_end: number | null;
expected_coin: number;
actual_coin: number;
matched: boolean;
}
export interface AdCoinFormula {
description: string;
coin_per_yuan: number;
ecpm_unit: string;
feed_unit_seconds: number;
ecpm_factor_tiers: [number, number, number | null][];
lt_factor_tiers: [number, number, number | null][];
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响)
export interface AdRevenueDaily {
date: string; // 北京时间 YYYY-MM-DD
impressions: number;
revenue_yuan: number;
expected_coin: number;
actual_coin: number;
}
export interface AdCoinAudit {
date: string;
formula: AdCoinFormula;
total: number; // 全量条数(不受 limit/only_mismatch 影响)
mismatch_count: number; // 全量不一致条数
truncated: boolean; // 展示集是否被 limit 截断
items: AdCoinAuditRow[];
// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合
export interface AdRevenueRow {
report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD
user_id: number;
ad_type: string; // reward_video / feed / draw
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
impressions: number; // 展示条数(轮播每条各计一次)
revenue_yuan: number; // 收益(元),预估口径
expected_coin: number; // 应发金币(公式复算,与金币审计同源)
actual_coin: number; // 实发金币(实际入账)
matched: boolean; // 该组应发==实发
adns: string[]; // 底层填充 ADN 子渠道集合
impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻)
records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻)
}
export interface AdRevenueReport {
date_from: string; // 起始日 北京时间 YYYY-MM-DD
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
total: number; // 聚合组总数(全量,不受 limit 影响)
truncated: boolean;
total_impressions: number;
total_revenue_yuan: number;
total_expected_coin: number;
total_actual_coin: number;
mismatch_count: number; // 应发≠实发的组数
items: AdRevenueRow[];
}
export interface DashboardOverview {