'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 }} /> 类型 条数