From 27a96d443092a5bf191c823e32e46caeff4993b2 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Sat, 27 Jun 2026 16:25:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(ad-revenue):=20=E6=94=B6=E7=9B=8A=E6=8A=A5?= =?UTF-8?q?=E8=A1=A8=E5=88=86=E9=A1=B5=E3=80=81eCPM=20=E5=85=83=E5=88=97?= =?UTF-8?q?=E3=80=81=E5=9C=BA=E6=99=AF=E7=AD=9B=E9=80=89=E3=80=81=E9=80=90?= =?UTF-8?q?=E8=A1=8C=E5=B1=95=E5=BC=80=20+=20=E5=A4=A7=E7=9B=98=E6=94=B9?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列:eCPM 新增「元」列;广告位ID 移到「一致」列后。 - 分页:「每页条数」接后端 offset 真分页,可翻页看当前筛选下全部数据;新增「排序」(时间倒序 / eCPM 倒序)。 - 场景:「场景」下推后端作为全局筛选(随查询生效,同时影响明细/合计/趋势);按小时趋势改用后端全量 hourly。 - 展开:明细表每行都有展开号——发奖行看金币复算因子,纯展示行看展示明细。 - 大盘改版:第一行核心指标(预估收益 / ARPU(今日) / 今日活跃 DAU / 发放金币 / 预估广告毛利);第二行分广告类型(Draw 信息流、激励视频 各自 收益/eCPM/展示条数)。DAU/ARPU 仅查询=今日时有值,历史/多天显示「-」。 - 文案:修正金币规则弹窗「信息流每满10秒折1条」→「一条广告=1条」。 - types 新增 AdRevenueHourly / AdRevenueTypeStat + hourly/type_stats/dau。 Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/ad-revenue-report/page.tsx | 333 +++++++++++++--------- src/lib/types.ts | 24 +- 2 files changed, 217 insertions(+), 140 deletions(-) diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index 0a67d60..e43afaf 100644 --- a/src/app/(main)/ad-revenue-report/page.tsx +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -9,6 +9,7 @@ import { Card, Col, DatePicker, + Descriptions, Divider, InputNumber, Modal, @@ -19,6 +20,7 @@ import { Statistic, Table, Tag, + Tooltip, Typography, } from 'antd'; import { @@ -32,9 +34,11 @@ import { api, errMsg } from '@/lib/api'; import { formatUtcTime } from '@/lib/format'; import type { AdRevenueDaily, + AdRevenueHourly, AdRevenueRecord, AdRevenueReport, AdRevenueRow, + AdRevenueTypeStat, } from '@/lib/types'; const { RangePicker } = DatePicker; @@ -146,8 +150,8 @@ interface TrendPoint { revenue: number; } -// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告) -function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] { +// 按小时聚合(0–23),数据源是 hourly(后端全量聚合,不受分页影响) +function aggregateHourly(rows: AdRevenueHourly[]): 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; @@ -263,10 +267,12 @@ export default function AdRevenueReportPage() { const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); const [userId, setUserId] = useState(null); const [adType, setAdType] = useState(); - // 「场景」为纯前端过滤(后端 query 暂不支持 feed_scene),只作用于下方明细表,不重新请求、不影响趋势/日汇总。 + // 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。 const [scene, setScene] = useState(); const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); - const [limit, setLimit] = useState(500); + const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序 + const [limit, setLimit] = useState(500); // 每页条数(分页大小) + const [page, setPage] = useState(1); // 当前页码(后端分页;1 起) const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 const [data, setData] = useState(null); @@ -277,33 +283,41 @@ export default function AdRevenueReportPage() { // 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天 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('/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]); + // load(目标页, 每页条数):分页与筛选都经它。offset 由 页码×每页 算;场景(feed_scene)随筛选下推后端。 + const load = useCallback( + async (targetPage = 1, targetLimit = limit, targetSort = sortBy) => { + 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('/admin/api/ad-revenue-report', { + params: { + date_from: from, + date_to: to, + user_id: userId ?? undefined, + ad_type: adType ?? undefined, + feed_scene: scene ?? undefined, + granularity: gran, + limit: targetLimit, + offset: (targetPage - 1) * targetLimit, + sort: targetSort, + }, + }); + setData(res.data); + setPage(targetPage); + setQueriedLimit(targetLimit); + setQueriedGranularity(gran); + setQueriedMultiDay(multiDay); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLoading(false); + } + }, + [range, userId, adType, scene, granularity, limit, sortBy], + ); useEffect(() => { load(); @@ -361,13 +375,6 @@ export default function AdRevenueReportPage() { return {t.label}; }, }, - { - title: '广告位ID', - dataIndex: 'our_code_id', - width: 110, - render: (v: string | null) => - v ? {v} : -, - }, { title: 'eCPM(分)', dataIndex: 'ecpm', @@ -375,6 +382,17 @@ export default function AdRevenueReportPage() { align: 'right', render: (v: string | null) => v ?? '-', }, + { + title: 'eCPM(元)', + key: 'ecpm_yuan', + width: 100, + align: 'right', + render: (_: unknown, r: AdRevenueRow) => { + // eCPM 原始值单位「分/千次」,÷100 转「元/千次」(块);非数字 / 空显示 -。 + const cents = r.ecpm == null ? null : Number(r.ecpm); + return cents == null || Number.isNaN(cents) ? '-' : (cents / 100).toFixed(2); + }, + }, { title: '预估收益(元)', dataIndex: 'revenue_yuan', @@ -420,6 +438,13 @@ export default function AdRevenueReportPage() { - ), }, + { + title: '广告位ID', + dataIndex: 'our_code_id', + width: 110, + render: (v: string | null) => + v ? {v} : -, + }, { title: '底层 ADN', dataIndex: 'adn', @@ -429,14 +454,11 @@ export default function AdRevenueReportPage() { }, ]; - // 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准): - // 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本; - // 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。 + // 派生指标(全部基于全量 total_* 字段,不受分页影响,准): + // 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益; + // 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 预估收益÷今日DAU(仅今日)。 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: @@ -444,18 +466,19 @@ export default function AdRevenueReportPage() { ? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100 : null, coinGap: data.total_expected_coin - data.total_actual_coin, + // ARPU(今日)= 预估广告收益 ÷ 今日 DAU;dau 为 null(历史/多天)或 0 时不可算 → null,前端显示「-」。 + arpu: data.dau && data.dau > 0 ? data.total_revenue_yuan / data.dau : null, } : null; - // 「场景」纯前端过滤:只对已加载的明细 items 生效(后端 query 暂不支持 feed_scene);趋势图/日汇总仍用全量 daily/total_*。 - const allItems = data?.items ?? []; - const filteredItems = scene ? allItems.filter((r) => r.feed_scene === scene) : allItems; + // 第二行大盘「分广告类型」:draw / 激励视频 各自 收益 / eCPM / 展示条数;eCPM = 收益÷展示×1000。 + const drawStat = data?.type_stats?.draw; + const rvStat = data?.type_stats?.reward_video; + const ecpmOf = (s?: AdRevenueTypeStat) => + s && s.impressions > 0 ? (s.revenue_yuan / s.impressions) * 1000 : 0; - // 选中比价/领券且类型=draw 时,给出该场景在已加载明细内的实发金币小计(仅明细口径,可能受 limit 截断)。 - const sceneCoinSubtotal = - adType === 'draw' && (scene === 'comparison' || scene === 'coupon') - ? filteredItems.reduce((s, r) => s + (r.has_reward ? r.actual_coin : 0), 0) - : null; + // 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。 + const items = data?.items ?? []; return (
@@ -534,7 +557,6 @@ export default function AdRevenueReportPage() { onChange={setScene} allowClear style={{ width: 130 }} - title="仅前端过滤下方明细,不影响趋势图/汇总" options={[ { value: 'comparison', label: '比价' }, { value: 'coupon', label: '领券' }, @@ -557,15 +579,33 @@ export default function AdRevenueReportPage() { /> - 条数 + 排序 { + setLimit(v); + load(1, v); // 改每页大小:回到第 1 页并重查 + }} + style={{ width: 120 }} + options={[20, 50, 100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条/页` }))} + /> + + @@ -575,41 +615,46 @@ export default function AdRevenueReportPage() { - 广告展示与收益 + 核心指标 - - - - - - - + - - - - - - - 发奖与对账 - - - - - - - - - - - - - + + + ARPU(今日) + + + + } + value={derived.arpu ?? '-'} + precision={4} + /> + + + + + 今日活跃 DAU + + + + } + value={data.dau ?? '-'} + /> + + + + + + = 0 ? : } @@ -621,6 +666,31 @@ export default function AdRevenueReportPage() { + + + 分广告类型 + + + + + + + + + + + + + + + + + + + + + + )} @@ -637,36 +707,13 @@ export default function AdRevenueReportPage() { ) : ( )} - {data.truncated && ( - - )} - {sceneCoinSubtotal != null && ( - - 当前已加载明细中, - - {SCENE_TAG[scene!].text} - - Draw 信息流 实发金币小计 {sceneCoinSubtotal}(共 {filteredItems.length} 条; - 仅明细口径,若已截断可能偏少;上方汇总/趋势仍为全量)。 - - } - /> - )} )} {data && (queriedMultiDay ? (data.daily?.length ?? 0) > 0 - : queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && ( + : queriedGranularity === 'hour' && (data.hourly?.length ?? 0) > 0) && ( } > - {/* 趋势图与日汇总恒为全量:按天图用 daily(全量),按小时图用全量 items;「场景」前端过滤只作用于下方明细表,不影响此图。 */} - {!queriedMultiDay && data.truncated && ( - - )} - {scene && ( - - )} + {/* 趋势图恒为全量(不受分页影响):按天图用 daily,按小时图用 hourly;「场景」已下推后端,趋势随场景筛选一并变化。 */} {queriedMultiDay ? ( ) : ( - + )} )} @@ -729,15 +760,22 @@ export default function AdRevenueReportPage() { `共 ${t} 条`, + onChange: (p) => load(p, queriedLimit), + }} size="small" - scroll={{ x: 1280 }} + scroll={{ x: 1380 }} rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')} expandable={{ - // 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。 - rowExpandable: (r) => r.reward_detail != null, + // 每行都可展开(左侧恒有 + 号):有发奖看「金币复算因子」;纯展示看「展示明细」。 + rowExpandable: () => true, expandedRowRender: (r) => r.reward_detail ? (
@@ -756,7 +794,28 @@ export default function AdRevenueReportPage() { rowClassName={(d) => (d.matched ? '' : 'row-mismatch')} />
- ) : null, + ) : ( +
+ 展示明细{' '} + (纯展示事件,未单独发奖,无金币复算) + + {formatUtcTime(r.created_at)} + {r.ecpm ?? '-'} + + {r.ecpm != null && !Number.isNaN(Number(r.ecpm)) + ? (Number(r.ecpm) / 100).toFixed(2) + : '-'} + + + {r.has_impression ? r.revenue_yuan.toFixed(4) : '-'} + + {r.adn ?? '-'} + {r.slot_id ?? '-'} + {r.our_code_id ?? '-'} + {r.app_env ?? '-'} + +
+ ), }} /> · eCPM 取自穿山甲 SDK getEcpm() 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。
· 金币汇率:1 元 = {COIN_PER_YUAN.toLocaleString()} 金币,最终四舍五入取整。 -
· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。 +
· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);一条广告 = 1 条(激励视频每次 1 条;信息流看满 10 秒即发该条满额,不按时长折多份)。
· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。 diff --git a/src/lib/types.ts b/src/lib/types.ts index 127a7de..5cd9ad7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -365,7 +365,7 @@ export interface AdRevenueRecord { matched: boolean; } -// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响) +// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受分页影响) export interface AdRevenueDaily { date: string; // 北京时间 YYYY-MM-DD impressions: number; @@ -374,6 +374,21 @@ export interface AdRevenueDaily { actual_coin: number; } +// 广告收益报表:按北京小时(0–23)汇总的一小时(按小时趋势图用;全量,不受分页影响;按天查询时为空) +export interface AdRevenueHourly { + hour: number; // 北京时间小时 0–23 + impressions: number; + revenue_yuan: number; + expected_coin: number; + actual_coin: number; +} + +// 广告收益报表:按广告类型(ad_type)的小计(展示条数 + 预估收益;eCPM 前端用 收益÷展示×1000 算) +export interface AdRevenueTypeStat { + impressions: number; // 该类型展示条数合计 + revenue_yuan: number; // 该类型预估收益合计(元) +} + // 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。 export interface AdRevenueRow { event_key: string; // 事件稳定唯一键(前端 rowKey) @@ -406,8 +421,11 @@ export interface AdRevenueReport { date_from: string; // 起始日 北京时间 YYYY-MM-DD date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同) daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图) - total: number; // 广告事件总数(全量,不受 limit 影响) - truncated: boolean; + hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空) + type_stats: Record; // 按广告类型小计;前端取 draw / reward_video + dau: number | null; // 今日活跃(复用大盘 last_login_at);仅查询=今日时有值,否则 null + total: number; // 当前筛选下的分页总条数(全量,不受分页影响) + truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警) total_impressions: number; total_revenue_yuan: number; total_expected_coin: number; -- 2.52.0