From f82f511b84a99db4b8a659d8e13634626bfe26fe Mon Sep 17 00:00:00 2001 From: ouzhou Date: Mon, 15 Jun 2026 23:13:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(ad-revenue):=20=E5=B9=BF=E5=91=8A=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=8A=A5=E8=A1=A8=E9=A1=B5(=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E9=87=91=E5=B8=81=E5=AE=A1=E8=AE=A1=E9=A1=B5)=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /ad-revenue:聚合表 + 派生指标(平均 eCPM/毛利/发奖占比)+ 零依赖 SVG 趋势图 + 行展开下钻展示明细/发奖复算明细 - 删除旧 /ad-audit 页;侧栏「金币审计」→「广告数据」 - feedbacks 由「加载更多」改 antd 页码分页(usePagedList),内容列 3 行省略可展开 - types 用 AdRevenue* 替换 AdCoinAudit*;providers 统一主题圆角/页面底色 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/11 Co-authored-by: ouzhou Co-committed-by: ouzhou --- src/app/(main)/ad-audit/page.tsx | 247 ---------- src/app/(main)/ad-revenue/page.tsx | 746 +++++++++++++++++++++++++++++ src/app/(main)/feedbacks/page.tsx | 41 +- src/app/(main)/layout.tsx | 4 +- src/app/providers.tsx | 12 +- src/lib/types.ts | 76 ++- 6 files changed, 841 insertions(+), 285 deletions(-) delete mode 100644 src/app/(main)/ad-audit/page.tsx create mode 100644 src/app/(main)/ad-revenue/page.tsx diff --git a/src/app/(main)/ad-audit/page.tsx b/src/app/(main)/ad-audit/page.tsx deleted file mode 100644 index 3d5cdad..0000000 --- a/src/app/(main)/ad-audit/page.tsx +++ /dev/null @@ -1,247 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import type { ColumnsType } from 'antd/es/table'; -import { - Alert, - Button, - Card, - Checkbox, - DatePicker, - InputNumber, - Select, - Space, - Table, - Tag, - Typography, - message, -} from 'antd'; -import dayjs, { type Dayjs } from 'dayjs'; -import { api, errMsg } from '@/lib/api'; -import { formatUtcTime } from '@/lib/format'; -import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types'; - -const SCENE_TAG: Record = { - reward_video: { color: 'blue', label: '看视频' }, - feed: { color: 'purple', label: '信息流' }, -}; - -const STATUS_TAG: Record = { - granted: 'green', - capped: 'orange', - ecpm_missing: 'red', -}; - -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}`; -}; - -export default function AdAuditPage() { - const [date, setDate] = useState(dayjs()); - const [userId, setUserId] = useState(null); - const [scene, setScene] = useState(); - const [limit, setLimit] = useState(100); - const [data, setData] = useState(null); - const [queriedLimit, setQueriedLimit] = useState(100); // 本次结果对应的 limit,截断提示里展示 - const [onlyMismatch, setOnlyMismatch] = useState(false); // 只看不一致(✗)行 - const [loading, setLoading] = useState(false); - - const load = useCallback(async () => { - setLoading(true); - try { - const res = await api.get('/admin/api/ad-coin-audit', { - params: { - date: date.format('YYYY-MM-DD'), - user_id: userId ?? undefined, - scene: scene ?? undefined, - limit, - only_mismatch: onlyMismatch || undefined, - }, - }); - setData(res.data); - setQueriedLimit(limit); - } catch (e) { - message.error(errMsg(e)); - } finally { - setLoading(false); - } - }, [date, userId, scene, limit, onlyMismatch]); - - useEffect(() => { - load(); - // 仅首次自动拉今天;之后由「查询」按钮触发 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const columns: ColumnsType = [ - { - title: '场景', - dataIndex: 'scene', - width: 90, - render: (s: string) => { - const t = SCENE_TAG[s] ?? { color: 'default', label: s }; - return {t.label}; - }, - }, - { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 175 }, - { title: '用户', dataIndex: 'user_id', width: 70 }, - { - title: '状态', - dataIndex: 'status', - width: 110, - render: (s: string) => {s}, - }, - { 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: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end), - }, - { - title: '因子2', - key: 'lt_factor', - width: 90, - render: (_: unknown, r: AdCoinAuditRow) => 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: 70, - render: (m: boolean) => - m ? : ✗ 不符, - }, - ]; - - return ( -
-

看广告金币审计

- - 把「看视频赚金币」和「比价信息流广告」的发奖记录,用与正式发奖相同的公式复算一遍,核对实发金币是否按公式计算。纯只读对账。 - - - {data && ( - - 金币公式:{' '} - {data.formula.description} -
-
- eCPM 口径:{data.formula.ecpm_unit},计算时 ÷100 转元;金币:元 ={' '} - {data.formula.coin_per_yuan}:1;信息流每 {data.formula.feed_unit_seconds} 秒折 1 条。两个点位「LT累计条数」各自独立计数。 -
-
- 因子1(按 eCPM 元判档,阈值单位:元): - {data.formula.ecpm_factor_tiers - .map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`) - .join(' | ')} -
-
- 因子2(LT 累计条数): - {data.formula.lt_factor_tiers - .map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}→${f}`) - .join(' | ')} -
-
-
- )} - - - d && setDate(d)} allowClear={false} /> - setUserId(v ?? null)} - style={{ width: 140 }} - /> - ({ value: n, label: `${n} 条` }))} - /> - setOnlyMismatch(e.target.checked)}> - 只看不一致(✗) - - - - - {data && ( -
- - {data.mismatch_count > 0 ? ( - - ) : ( - - )} - {onlyMismatch && ( - - )} - {data.truncated && ( - - )} - -
- )} - - `${r.scene}-${r.record_id}`} - columns={columns} - dataSource={data?.items ?? []} - loading={loading} - pagination={false} - size="small" - scroll={{ x: 1100 }} - rowClassName={(r) => (r.matched ? '' : 'row-mismatch')} - /> - - - ); -} diff --git a/src/app/(main)/ad-revenue/page.tsx b/src/app/(main)/ad-revenue/page.tsx new file mode 100644 index 0000000..e5d2e04 --- /dev/null +++ b/src/app/(main)/ad-revenue/page.tsx @@ -0,0 +1,746 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { ColumnsType } from 'antd/es/table'; +import { + Alert, + Button, + Card, + Col, + DatePicker, + Divider, + InputNumber, + Modal, + Popover, + Row, + Select, + Space, + Statistic, + Table, + Tag, + Typography, + message, +} 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, + AdRevenueImpression, + 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 信息流' }, +}; + +// 我们的应用环境标签 +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/收益/adn/底层位) +const IMPRESSION_COLUMNS: ColumnsType = [ + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 }, + { title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 }, + { title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) }, + { + title: 'adn', + dataIndex: 'adn', + width: 90, + render: (v: string | null) => v ?? '-', + }, + { + title: '底层代码位ID', + dataIndex: 'slot_id', + render: (v: string | null) => v ?? '-', + }, +]; + +// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 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, + )} + + ); +} + +// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出 +// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。 +export default function AdRevenuePage() { + 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: 110, fixed: 'left' } as ColumnsType[number]] + : []), + { title: '用户', dataIndex: 'user_id', width: 80 }, + ...(queriedGranularity === 'hour' + ? [ + { + title: '小时', + dataIndex: 'hour', + width: 90, + render: (h: number | null) => + h == null ? '-' : `${String(h).padStart(2, '0')}:00`, + } as ColumnsType[number], + ] + : []), + { + title: '广告类型', + dataIndex: 'ad_type', + width: 120, + render: (s: string) => { + const t = TYPE_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { + title: '来源应用', + dataIndex: 'app_env', + width: 150, + 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: 120, + render: (v: string | null) => + v ? {v} : -, + }, + { title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' }, + { + title: '预估收益(元)', + dataIndex: 'revenue_yuan', + width: 120, + align: 'right', + render: (v: number) => v.toFixed(4), + }, + { + title: '应发金币', + dataIndex: 'expected_coin', + width: 100, + align: 'right', + render: (v: number) => {v}, + }, + { title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' }, + { + title: '一致', + dataIndex: 'matched', + width: 80, + align: 'center', + render: (m: boolean) => (m ? : ✗ 不符), + }, + { + title: '底层 ADN', + dataIndex: 'adns', + render: (v: string[]) => + v.length ? v.map((a) => {a}) : -, + }, + ]; + + // 派生指标(全部基于全量 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 }} + /> + + + 类型 + + + + 条数 +
`${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`} + columns={columns} + dataSource={data?.items ?? []} + loading={loading} + pagination={false} + size="small" + scroll={{ x: 1100 }} + rowClassName={(r) => (r.matched ? '' : 'row-mismatch')} + expandable={{ + rowExpandable: (r) => + (r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0, + expandedRowRender: (r) => { + const imps = r.impression_records ?? []; + const recs = r.records ?? []; + return ( + +
+ 展示明细{' '} + ({imps.length} 条) + + style={{ marginTop: 8 }} + rowKey="id" + columns={IMPRESSION_COLUMNS} + dataSource={imps} + pagination={false} + size="small" + scroll={{ x: 700 }} + /> +
+
+ 发奖明细{' '} + ({recs.length} 条) + + style={{ marginTop: 8 }} + rowKey="record_id" + columns={DETAIL_COLUMNS} + dataSource={recs} + pagination={false} + size="small" + scroll={{ x: 900 }} + rowClassName={(d) => (d.matched ? '' : 'row-mismatch')} + locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }} + /> +
+
+ ); + }, + }} + /> + 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)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index 61091bb..4e0d253 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -21,10 +21,10 @@ import type { Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatUtcTime } from '@/lib/format'; -import { useCursorList } from '@/lib/useCursorList'; +import { usePagedList } from '@/lib/usePagedList'; import type { Feedback } from '@/lib/types'; -const { Text } = Typography; +const { Text, Paragraph } = Typography; const { RangePicker } = DatePicker; // 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。 @@ -46,10 +46,8 @@ export default function FeedbacksPage() { const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; const filterKey = JSON.stringify(filters); - const { items, nextCursor, loading, loadMore, reload } = useCursorList( - '/admin/api/feedbacks', - filters, - ); + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/feedbacks', filters); const canHandle = canDo(['operator']); const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -136,7 +134,22 @@ export default function FeedbacksPage() { const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') }, { title: '用户', dataIndex: 'user_id', width: 80 }, - { title: '内容', dataIndex: 'content' }, + { + title: '内容', + dataIndex: 'content', + width: 360, + render: (v: string) => + v ? ( + + {v} + + ) : ( + - + ), + }, { title: '截图', dataIndex: 'images', @@ -257,14 +270,16 @@ export default function FeedbacksPage() { columns={columns} dataSource={items} loading={loading} - pagination={false} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条反馈`, + onChange: onPageChange, + }} onChange={onTableChange} /> -
- -
); } diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index cd4b4c5..efb8ba3 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -3,10 +3,10 @@ import { useEffect, useState } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { + BarChartOutlined, DashboardOutlined, FileSearchOutlined, FlagOutlined, - FundProjectionScreenOutlined, LogoutOutlined, MessageOutlined, MobileOutlined, @@ -28,7 +28,7 @@ const MENU = [ { key: '/withdraws', icon: , label: '提现管理' }, { key: '/price-reports', icon: , label: '上报审核' }, { key: '/feedbacks', icon: , label: '反馈工单' }, - { key: '/ad-audit', icon: , label: '金币审计' }, + { key: '/ad-revenue', icon: , label: '广告数据' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, { key: '/audit-logs', icon: , label: '审计日志' }, diff --git a/src/app/providers.tsx b/src/app/providers.tsx index a5493c3..62e2100 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -12,7 +12,17 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( {/* wave 禁用:消除 antd 5 波纹在 Next15 dev 下的 React 版本兼容误报(纯噪音,后台不需要波纹) */} - + {/* 全局主题:统一圆角到 8、页面底色用偏冷的浅灰,让白色卡片更有层次(全站生效) */} + {children} diff --git a/src/lib/types.ts b/src/lib/types.ts index 3cbd1b1..63e6bdd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -188,40 +188,72 @@ export interface AuditLog { created_at: string; } -export interface AdCoinAuditRow { - scene: string; // reward_video / feed - record_id: number; - user_id: number; +// 广告收益报表聚合行下钻的单条展示明细 +export interface AdRevenueImpression { + id: number; created_at: string; - status: string; // granted / capped / ecpm_missing + ecpm: string; // 分/千次展示 + revenue_yuan: number; // 本次展示预估收益(元) + adn: string | null; + slot_id: string | null; // 底层 mediation rit +} + +// 广告收益报表聚合行下钻的单条发奖复算明细 +export interface AdRevenueRecord { + record_id: number; + created_at: string; + status: string; // granted / capped / ecpm_missing / too_short / closed_early ecpm: string | null; - ecpm_factor: number | null; - units: number; + ecpm_factor: number | null; // 因子1 + units: number; // 份数 lt_index_start: number | null; lt_index_end: number | null; - lt_factor_start: number | null; + lt_factor_start: number | null; // 因子2 lt_factor_end: number | null; expected_coin: number; actual_coin: number; matched: boolean; } -export interface AdCoinFormula { - description: string; - coin_per_yuan: number; - ecpm_unit: string; - feed_unit_seconds: number; - ecpm_factor_tiers: [number, number, number | null][]; - lt_factor_tiers: [number, number, number | null][]; +// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响) +export interface AdRevenueDaily { + date: string; // 北京时间 YYYY-MM-DD + impressions: number; + revenue_yuan: number; + expected_coin: number; + actual_coin: number; } -export interface AdCoinAudit { - date: string; - formula: AdCoinFormula; - total: number; // 全量条数(不受 limit/only_mismatch 影响) - mismatch_count: number; // 全量不一致条数 - truncated: boolean; // 展示集是否被 limit 截断 - items: AdCoinAuditRow[]; +// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合 +export interface AdRevenueRow { + report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD + user_id: number; + ad_type: string; // reward_video / feed / draw + app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空 + our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空 + hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null) + impressions: number; // 展示条数(轮播每条各计一次) + revenue_yuan: number; // 收益(元),预估口径 + expected_coin: number; // 应发金币(公式复算,与金币审计同源) + actual_coin: number; // 实发金币(实际入账) + matched: boolean; // 该组应发==实发 + adns: string[]; // 底层填充 ADN 子渠道集合 + impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻) + records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻) +} + +export interface AdRevenueReport { + date_from: string; // 起始日 北京时间 YYYY-MM-DD + date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同) + daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图) + total: number; // 聚合组总数(全量,不受 limit 影响) + truncated: boolean; + total_impressions: number; + total_revenue_yuan: number; + total_expected_coin: number; + total_actual_coin: number; + mismatch_count: number; // 应发≠实发的组数 + items: AdRevenueRow[]; } export interface DashboardOverview {