diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index 5288f92..fc25b8f 100644 --- a/src/app/(main)/ad-revenue-report/page.tsx +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import { App, @@ -46,11 +46,11 @@ const { RangePicker } = DatePicker; // 广告类型标签 const TYPE_TAG: Record = { - reward_video: { color: 'blue', label: '激励视频' }, + reward_video: { color: 'blue', label: '看视频' }, // 历史误标 feed(领券/比价广告修 adType 之前上报)一律按 Draw 信息流显示——业务已全切 Draw feed: { color: 'geekblue', label: 'Draw 信息流' }, draw: { color: 'geekblue', label: 'Draw 信息流' }, - withdrawal_video: { color: 'gold', label: '提现激励视频' }, + withdrawal_video: { color: 'gold', label: '提现看视频' }, }; // Draw 信息流投放场景标签(后端 AdRevenueRow.feed_scene:comparison/coupon/welfare) @@ -74,6 +74,13 @@ const STATUS_TAG: Record = { too_short: { color: 'gold', label: '未满10秒' }, closed_early: { color: 'default', label: '提前关闭' }, }; +const STATUS_HINT: Record = { + granted: '已满足当前客户端发奖条件并完成金币发放。', + too_short: '旧版 Draw 信息流观看不足 10 秒,不发金币;新版按观看比例发放时会记为已发。', + closed_early: '用户在达到发奖条件前主动关闭,不发金币。', + capped: '已达到次数上限,不再发金币。', + ecpm_missing: '缺少有效 eCPM,无法计算金币。', +}; const fmtFactorRange = (a: number | null, b: number | null) => { if (a == null) return '-'; @@ -84,6 +91,14 @@ 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 金币,四舍五入取整。 @@ -104,35 +119,37 @@ const LT_FACTOR_ROWS = [ { key: '5', lt: '第 11 条及以后', factor: 1.0 }, ]; +type AdRevenueDetailRow = AdRevenueRecord & { source_adn?: string | null }; + // 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列) -const DETAIL_COLUMNS: ColumnsType = [ - { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 }, +const DETAIL_COLUMNS: ColumnsType = [ + { title: '来源广告网络', dataIndex: 'source_adn', width: 110, render: (value: string | null) => value || '-' }, { - title: '状态', - dataIndex: 'status', - width: 110, - render: (s: string) => { - const t = STATUS_TAG[s] ?? { color: 'default', label: s }; - return {t.label}; - }, - }, - { 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', + title: 'eCPM(元)', + dataIndex: 'ecpm', width: 100, - render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end), + render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100).toFixed(2)), }, + { + title: '单次 eCPM(元)', + dataIndex: 'ecpm', + width: 120, + render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100 / 1000).toFixed(4)), + }, + { title: '实发金币数', dataIndex: 'actual_coin', width: 100 }, + { title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' }, { title: '因子2', key: 'lt_factor', width: 90, - render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), + render: (_: unknown, r: AdRevenueDetailRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), + }, + { + title: 'LT累计条数', + key: 'lt_index', + width: 100, + render: (_: unknown, r: AdRevenueDetailRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end), }, - { title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => {v} }, - { title: '实发金币', dataIndex: 'actual_coin', width: 100 }, ]; // 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴), @@ -290,19 +307,20 @@ function TrendChart({ points }: { points: TrendPoint[] }) { // 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。 export default function AdRevenueReportPage() { const { message } = App.useApp(); - const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); + const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')]); const [userId, setUserId] = useState(null); const [adType, setAdType] = useState(); // 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。 const [scene, setScene] = useState(); const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序 - const [limit, setLimit] = useState(500); // 每页条数(分页大小) + const [limit, setLimit] = useState(1000); // 默认拉满接口单页上限,供单次流程广告数分位统计 const [page, setPage] = useState(1); // 当前页码(后端分页;1 起) const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 const [data, setData] = useState(null); - const [queriedLimit, setQueriedLimit] = useState(500); + const [allFlowItems, setAllFlowItems] = useState([]); + const [queriedLimit, setQueriedLimit] = useState(1000); const [loading, setLoading] = useState(false); const [formulaOpen, setFormulaOpen] = useState(false); // 点用户手机号弹出的「用户广告收益详情」半屏抽屉(userId + 手机号;null=关闭) @@ -320,17 +338,20 @@ export default function AdRevenueReportPage() { const gran = multiDay ? 'day' : granularity; // 跨多天强制按天 setLoading(true); try { + const baseParams = { + date_from: from, + date_to: to, + user_id: userId ?? undefined, + ad_type: adType ?? undefined, + feed_scene: scene ?? undefined, + granularity: gran, + sort: targetSort, + }; 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, + ...baseParams, limit: targetLimit, offset: (targetPage - 1) * targetLimit, - sort: targetSort, }, }); setData(res.data); @@ -338,6 +359,24 @@ export default function AdRevenueReportPage() { setQueriedLimit(targetLimit); setQueriedGranularity(gran); setQueriedMultiDay(multiDay); + + // 单次流程广告数分位必须覆盖完整查询结果,不能只统计当前表格分页。 + let firstOverviewPage = res.data; + if (targetPage !== 1 || targetLimit !== 1000) { + const overviewRes = await api.get('/admin/api/ad-revenue-report', { + params: { ...baseParams, limit: 1000, offset: 0 }, + }); + firstOverviewPage = overviewRes.data; + } + const flowItems = [...firstOverviewPage.items]; + while (flowItems.length < firstOverviewPage.total) { + const nextRes = await api.get('/admin/api/ad-revenue-report', { + params: { ...baseParams, limit: 1000, offset: flowItems.length }, + }); + if (nextRes.data.items.length === 0) break; + flowItems.push(...nextRes.data.items); + } + setAllFlowItems(flowItems); } catch (e) { message.error(errMsg(e)); } finally { @@ -349,11 +388,11 @@ export default function AdRevenueReportPage() { useEffect(() => { load(); - // 仅首次自动拉今天;之后由「查询」按钮触发 + // 仅首次自动拉昨日;之后由「查询」按钮触发 // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const columns: ColumnsType = [ + const rawColumns: ColumnsType = [ ...(queriedMultiDay ? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType[number]] : []), @@ -401,7 +440,10 @@ export default function AdRevenueReportPage() { title: '场景', dataIndex: 'feed_scene', width: 80, - render: (v: string | null | undefined) => { + render: (v: string | null | undefined, row: AdRevenueRow) => { + if (!v && (row.ad_type === 'reward_video' || row.ad_type === 'withdrawal_video')) { + return 看视频; + } if (!v) return -; const t = SCENE_TAG[v]; return t ? {t.text} : {v}; @@ -436,14 +478,14 @@ export default function AdRevenueReportPage() { }, }, { - title: '预估收益(元)', + title: '预估收益', dataIndex: 'revenue_yuan', width: 110, align: 'right', // 一次比价/领券聚合行用 row_revenue_yuan(该次发奖广告 eCPM 折算之和),其它行用展示侧 revenue_yuan render: (v: number, r: AdRevenueRow) => { const rev = r.row_revenue_yuan ?? v; - return r.has_impression || rev > 0 ? rev.toFixed(4) : '-'; + return rev.toFixed(4); }, }, { @@ -451,9 +493,9 @@ export default function AdRevenueReportPage() { dataIndex: 'status', width: 100, render: (s: string | null) => { - if (!s) return 仅展示; + if (!s) return 仅展示; const t = STATUS_TAG[s] ?? { color: 'default', label: s }; - return {t.label}; + return {t.label}; }, }, { @@ -473,7 +515,7 @@ export default function AdRevenueReportPage() { r.has_reward ? v : -, }, { - title: '广告位ID', + title: '广告位', dataIndex: 'our_code_id', width: 110, render: (v: string | null) => @@ -487,6 +529,26 @@ export default function AdRevenueReportPage() { v ? {v} : -, }, ]; + const columnOrder = [ + 'report_date', + 'created_at', + 'user_phone', + 'feed_scene', + 'ecpm_yuan', + 'revenue_yuan', + 'actual_coin', + 'status', + 'ad_type', + 'app_env', + 'our_code_id', + ]; + const columns = columnOrder.flatMap((identity) => { + const found = rawColumns.find((column) => { + const dataIndex = 'dataIndex' in column ? column.dataIndex : undefined; + return String(column.key ?? dataIndex ?? '') === identity; + }); + return found ? [found] : []; + }) as ColumnsType; // 派生指标(全部基于全量 total_* 字段,不受分页影响,准): // 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益; @@ -525,11 +587,24 @@ export default function AdRevenueReportPage() { // 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。 const items = data?.items ?? []; + const sessionAdCounts = useMemo(() => { + const summarize = (sceneName: 'coupon' | 'comparison') => { + const counts = allFlowItems + .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), + }; + }; + return { coupon: summarize('coupon'), comparison: summarize('comparison') }; + }, [allFlowItems]); return (
-

收益报表

+

广告收益

-
+
+ + + 领券核心数据 发起、整场成功、点位成功与效率
-
+
+
-

变现

+

商业化

{cpsAvailable ? (adError ? '广告收益接口当前后端不可用,美团/京东 CPS 已接入' : '广告收益与美团/京东 CPS 已接入') : (adError ? '广告收益接口当前后端不可用,订单/佣金数据待接' : '广告收益已接入,订单/佣金数据待接')}
-
总收益
-
+
核心数据
+
- 0 && adReport?.total_revenue_yuan != null - ? `${((adReport.total_revenue_yuan / totalRevenueYuan) * 100).toFixed(1)}%` - : '--', - }, - ]} - /> - 0 && totalCpsCommissionCents != null - ? `${(((totalCpsCommissionCents / 100) / totalRevenueYuan) * 100).toFixed(1)}%` - : '--', - }, - ]} - /> + + +
@@ -934,10 +1102,6 @@ export default function DashboardPage() { 本期 4 项奖励合计发放 {fmtCoinAmount(periodData?.coins.granted_total)} 金币 - (账面负债 ≈ {fmtYuan((periodData?.coins.granted_total ?? 0) / 1000)}), + (账面负债 ≈ {fmtYuan((periodData?.coins.granted_total ?? 0) / 10000)}), 实际提现兑付 {fmtCents(periodData?.cash.withdraw_success_cents)}。 累计已发放 {fmtCoinAmount(data.coins.granted_total)} 金币但累计真实提现仅 {fmtCents(data.cash.withdraw_success_cents)},发放与现金兑付背离属正常(金币沉淀/过期); @@ -1111,6 +1275,16 @@ export default function DashboardPage() { flex-wrap: wrap; gap: 10px; } + .dashboard-date-picker { + min-width: 240px; + cursor: pointer; + } + .dashboard-date-picker:hover { + border-color: var(--brand-blue); + } + .dashboard-date-picker input { + cursor: pointer; + } .segment { display: inline-flex; gap: 2px; diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index b2c7c8d..736a6f1 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -53,9 +53,9 @@ const NAV_GROUPS: NavGroup[] = [ label: '看板', children: [ { key: '/dashboard', icon: , label: '数据大盘' }, - { key: '/coupon-data', icon: , label: '领券数据' }, { key: '/ad-revenue-report', icon: , label: '广告收益' }, { key: '/comparison-records', icon: , label: '比价记录' }, + { key: '/coupon-data', icon: , label: '领券记录' }, { key: '/cps', icon: , label: 'CPS收益' }, { key: '/device-liveness', icon: , label: '设备存活' }, { key: '/analytics-health', icon: , label: '埋点成功率' }, @@ -145,19 +145,14 @@ export default function MainLayout({ children }: { children: React.ReactNode }) collapsedWidth={72} width={220} onCollapse={setCollapsed} + style={{ + position: 'sticky', + top: 0, + height: '100vh', + overflowY: 'auto', + overflowX: 'hidden', + }} > -
- 运营后台 -