From 374fa8c7fe10029f80731923914287060d733ea1 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Wed, 24 Jun 2026 22:32:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(ad-revenue):=20=E5=B9=BF=E5=91=8A=E6=94=B6?= =?UTF-8?q?=E7=9B=8A=E6=8A=A5=E8=A1=A8=E8=BF=81=E5=87=BA=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E9=A1=B5=20+=20=E6=8E=A5=E5=85=A5=E6=8F=90=E7=8E=B0=E6=BF=80?= =?UTF-8?q?=E5=8A=B1=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 报表从「广告管理」页迁出为独立「广告收益」页(ad-revenue-report),ad-revenue 页只留广告配置; layout 侧边栏拆成「广告配置」+「广告收益」两个入口 - ad-revenue 配置页新增「提现激励视频开关」(withdrawal_ad_enabled),控制客户端提现是否走看视频 - 广告收益报表新增「提现激励视频」类型标签与筛选项(ad_type=withdrawal_video) --- src/app/(main)/ad-revenue-report/page.tsx | 749 +++++++++++++++++++++ src/app/(main)/ad-revenue/page.tsx | 759 +--------------------- src/app/(main)/layout.tsx | 4 +- 3 files changed, 763 insertions(+), 749 deletions(-) create mode 100644 src/app/(main)/ad-revenue-report/page.tsx diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx new file mode 100644 index 0000000..553dc8d --- /dev/null +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -0,0 +1,749 @@ +'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 = { + reward_video: { color: 'blue', label: '激励视频' }, + feed: { color: 'purple', label: '信息流' }, + draw: { color: 'geekblue', label: 'Draw 信息流' }, + withdrawal_video: { color: 'gold', label: '提现激励视频' }, +}; + +// 我们的应用环境标签 +const APP_TAG: Record = { + prod: { color: 'green', label: '傻瓜比价(正式)' }, + test: { color: 'default', label: '测试应用' }, +}; + +// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限) +const STATUS_TAG: Record = { + 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 = [ + { 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 {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', + 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) => {v} }, + { title: '实发金币', dataIndex: 'actual_coin', width: 100 }, + { + title: '一致', + dataIndex: 'matched', + width: 80, + render: (m: boolean) => (m ? : ✗ 不符), + }, +]; + +// 趋势图(纯 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 ( + + {[0, 0.25, 0.5, 0.75, 1].map((t) => { + const y = yBase - t * plotH; + return ( + + + + {Math.round(maxImp * t)} + + + {(maxRev * t).toFixed(2)} + + + ); + })} + {points.map((p, i) => ( + + {p.tip} + + ))} + + {points.map((p, i) => ( + + {p.tip} + + ))} + {points.map((p, i) => + i % labelEvery === 0 || i === n - 1 ? ( + + {p.label} + + ) : null, + )} + + ); +} + +// 广告收益报表页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出 +// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。 +// 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。 +export default function AdRevenueReportPage() { + const { message } = App.useApp(); + const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); + const [userId, setUserId] = useState(null); + const [adType, setAdType] = useState(); + const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); + const [limit, setLimit] = useState(500); + const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 + const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 + const [data, setData] = useState(null); + const [queriedLimit, setQueriedLimit] = useState(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('/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 = [ + ...(queriedMultiDay + ? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType[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 ? ( + + {phone} + + #{r.user_id} + + + ) : ( + #{r.user_id}(无手机号) + ), + }, + { + title: '广告类型', + dataIndex: 'ad_type', + width: 110, + render: (s: string) => { + const t = TYPE_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { + title: '来源应用', + dataIndex: 'app_env', + width: 140, + render: (v: string | null) => { + if (!v) return 未知; + const t = APP_TAG[v] ?? { color: 'default', label: v }; + return {t.label}; + }, + }, + { + title: '广告位ID', + dataIndex: 'our_code_id', + width: 110, + render: (v: string | null) => + v ? {v} : -, + }, + { + 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 仅展示; + const t = STATUS_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { + title: '应发金币', + dataIndex: 'expected_coin', + width: 90, + align: 'right', + render: (v: number, r: AdRevenueRow) => + r.has_reward ? {v} : -, + }, + { + title: '实发金币', + dataIndex: 'actual_coin', + width: 90, + align: 'right', + render: (v: number, r: AdRevenueRow) => + r.has_reward ? v : -, + }, + { + title: '一致', + dataIndex: 'matched', + width: 70, + align: 'center', + render: (m: boolean, r: AdRevenueRow) => + r.has_reward ? ( + m ? : ✗ 不符 + ) : ( + - + ), + }, + { + title: '底层 ADN', + dataIndex: 'adn', + width: 100, + render: (v: string | null) => + v ? {v} : -, + }, + ]; + + // 派生指标(全部基于全量 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 ( +
+ +

收益报表

+ + + 「预估收益」为客户端在广告展示(onAdShow)时上报 eCPM 折算的预估值(每千次展示 ÷1000 + 累加),只要广告展示就计入、不论是否看完发奖;穿山甲会过滤无效/过短曝光,故预估值可能偏高, + 实际收益一律以穿山甲后台结算为准。测试应用多为 0。「广告位ID / 来源应用」为本期新增,历史记录留空。 +
+ } + > + + + + + + + + 日期 + 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()] }, + ]} + /> + + + 用户 + setUserId(v ?? null)} + style={{ width: 130 }} + /> + + + 类型 + + + + 条数 + - - - 粒度 - ({ value: n, label: `${n} 条` }))} - /> - - - - - - {data && derived && ( - - - - 广告展示与收益 - - - - - - - - - - - - - - - - - - - 发奖与对账 - - - - - - - - - - - - - - = 0 ? : } - valueStyle={{ color: derived.grossProfit >= 0 ? '#3f8600' : '#cf1322' }} - /> - - 发奖占收益{' '} - {derived.payoutRatioPct == null ? '—' : `${derived.payoutRatioPct.toFixed(0)}%`} - - - - - )} - - {data && ( - - {data.mismatch_count > 0 ? ( - = 0 ? '+' : '' - }${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`} - /> - ) : ( - - )} - {data.truncated && ( - - )} - - )} - - {data && - (queriedMultiDay - ? (data.daily?.length ?? 0) > 0 - : queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && ( - - - - 展示条数 - - - - 预估收益(元) - - - } - > - {/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */} - {!queriedMultiDay && data.truncated && ( - - )} - {queriedMultiDay ? ( - - ) : ( - - )} - - )} - - (r.has_reward && !r.matched ? 'row-mismatch' : '')} - expandable={{ - // 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。 - rowExpandable: (r) => r.reward_detail != null, - expandedRowRender: (r) => - r.reward_detail ? ( -
- 金币复算明细{' '} - - (本条广告 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin}) - - - 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')} - /> -
- ) : null, - }} - /> - setFormulaOpen(false)} - footer={null} - width={680} - > - - 单次奖励(元) =(单次 eCPM ÷ 1000)× 因子1(eCPM 档)× 因子2(LT 累计条数) - - - · eCPM 取自穿山甲 SDK getEcpm() 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。 -
· 金币汇率:1 元 = {COIN_PER_YUAN.toLocaleString()} 金币,最终四舍五入取整。 -
· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。 -
· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。 -
- -
- 因子1 ·按 eCPM(元/千次)判档 -
- - - 因子2 ·按账号 LT 累计条数 -
- - - - 示例:eCPM = 300 元/千次、账号第 1 条 → 0.3 × 0.4 × 2.0 = 0.24 元 = 2,400 金币。 - - - - - - ); -} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index c816b06..70bf4cf 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -11,6 +11,7 @@ import { MessageOutlined, MobileOutlined, MoneyCollectOutlined, + NotificationOutlined, ProfileOutlined, SettingOutlined, ShareAltOutlined, @@ -31,7 +32,8 @@ const MENU = [ { key: '/price-reports', icon: , label: '上报审核' }, { key: '/comparison-records', icon: , label: '比价记录' }, { key: '/feedbacks', icon: , label: '反馈工单' }, - { key: '/ad-revenue', icon: , label: '广告管理' }, + { key: '/ad-revenue', icon: , label: '广告配置' }, + { key: '/ad-revenue-report', icon: , label: '广告收益' }, { key: '/cps', icon: , label: 'CPS 分发' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, -- 2.52.0