82ffc73be5
- 报表从「广告管理」页迁出为独立「广告收益」页(ad-revenue-report),ad-revenue 页只留广告配置; layout 侧边栏拆成「广告配置」+「广告收益」两个入口 - ad-revenue 配置页新增「提现激励视频开关」(withdrawal_ad_enabled),控制客户端提现是否走看视频 - 广告收益报表新增「提现激励视频」类型标签与筛选项(ad_type=withdrawal_video) --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #18 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
750 lines
28 KiB
TypeScript
750 lines
28 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
App,
|
||
Alert,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
DatePicker,
|
||
Divider,
|
||
InputNumber,
|
||
Modal,
|
||
Popover,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
} 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,
|
||
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 信息流' },
|
||
withdrawal_video: { color: 'gold', label: '提现激励视频' },
|
||
};
|
||
|
||
// 我们的应用环境标签
|
||
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/因子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;
|
||
}
|
||
|
||
// 按小时聚合(0–23),数据源是 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>
|
||
);
|
||
}
|
||
|
||
// 广告收益报表页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
|
||
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
|
||
// 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。
|
||
export default function AdRevenueReportPage() {
|
||
const { message } = App.useApp();
|
||
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: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
|
||
: []),
|
||
{ title: '时间', dataIndex: 'created_at', width: 165, render: (v: string) => formatUtcTime(v) },
|
||
{
|
||
title: '用户',
|
||
dataIndex: 'user_phone',
|
||
width: 150,
|
||
render: (phone: string | null, r: AdRevenueRow) =>
|
||
phone ? (
|
||
<span>
|
||
{phone}
|
||
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
|
||
#{r.user_id}
|
||
</Typography.Text>
|
||
</span>
|
||
) : (
|
||
<Typography.Text type="secondary">#{r.user_id}(无手机号)</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '广告类型',
|
||
dataIndex: 'ad_type',
|
||
width: 110,
|
||
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: 140,
|
||
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: 110,
|
||
render: (v: string | null) =>
|
||
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
|
||
},
|
||
{
|
||
title: 'eCPM(分)',
|
||
dataIndex: 'ecpm',
|
||
width: 100,
|
||
align: 'right',
|
||
render: (v: string | null) => v ?? '-',
|
||
},
|
||
{
|
||
title: '预估收益(元)',
|
||
dataIndex: 'revenue_yuan',
|
||
width: 110,
|
||
align: 'right',
|
||
render: (v: number, r: AdRevenueRow) => (r.has_impression ? v.toFixed(4) : '-'),
|
||
},
|
||
{
|
||
title: '发奖状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (s: string | null) => {
|
||
if (!s) return <Tag>仅展示</Tag>;
|
||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||
return <Tag color={t.color}>{t.label}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '应发金币',
|
||
dataIndex: 'expected_coin',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (v: number, r: AdRevenueRow) =>
|
||
r.has_reward ? <b>{v}</b> : <Typography.Text type="secondary">-</Typography.Text>,
|
||
},
|
||
{
|
||
title: '实发金币',
|
||
dataIndex: 'actual_coin',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (v: number, r: AdRevenueRow) =>
|
||
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
|
||
},
|
||
{
|
||
title: '一致',
|
||
dataIndex: 'matched',
|
||
width: 70,
|
||
align: 'center',
|
||
render: (m: boolean, r: AdRevenueRow) =>
|
||
r.has_reward ? (
|
||
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>
|
||
) : (
|
||
<Typography.Text type="secondary">-</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '底层 ADN',
|
||
dataIndex: 'adn',
|
||
width: 100,
|
||
render: (v: string | null) =>
|
||
v ? <Tag>{v}</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 信息流' },
|
||
{ value: 'withdrawal_video', label: '提现激励视频' },
|
||
]}
|
||
/>
|
||
</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="event_key"
|
||
columns={columns}
|
||
dataSource={data?.items ?? []}
|
||
loading={loading}
|
||
pagination={false}
|
||
size="small"
|
||
scroll={{ x: 1200 }}
|
||
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
|
||
expandable={{
|
||
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
|
||
rowExpandable: (r) => r.reward_detail != null,
|
||
expandedRowRender: (r) =>
|
||
r.reward_detail ? (
|
||
<div>
|
||
<Typography.Text strong>金币复算明细</Typography.Text>{' '}
|
||
<Typography.Text type="secondary">
|
||
(本条广告 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin})
|
||
</Typography.Text>
|
||
<Table<AdRevenueRecord>
|
||
style={{ marginTop: 8 }}
|
||
rowKey="record_id"
|
||
columns={DETAIL_COLUMNS}
|
||
dataSource={[r.reward_detail]}
|
||
pagination={false}
|
||
size="small"
|
||
scroll={{ x: 900 }}
|
||
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
||
/>
|
||
</div>
|
||
) : null,
|
||
}}
|
||
/>
|
||
<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 />· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 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>
|
||
);
|
||
}
|