diff --git a/src/app/(main)/ad-revenue-report/UserAdRevenueDrawer.tsx b/src/app/(main)/ad-revenue-report/UserAdRevenueDrawer.tsx new file mode 100644 index 0000000..236aefd --- /dev/null +++ b/src/app/(main)/ad-revenue-report/UserAdRevenueDrawer.tsx @@ -0,0 +1,84 @@ +'use client'; + +// 点收益报表里的用户手机号 → 半屏抽屉展示该用户的广告收益详情(统计卡 + 金币记录)。 +// 复用「提现详情」里的 UserRewardPanel(数据走 /users/{id}/reward-stats + /coin-records); +// 用户基本信息(昵称/注册时间/钱包)取自 /users/{id} 概览。抽屉样式(width 50% 半屏)与提现详情一致。 +import { useEffect, useState } from 'react'; +import { Drawer } from 'antd'; +import { api } from '@/lib/api'; +import type { WithdrawUserSnapshot } from '@/lib/types'; +import UserRewardPanel from '../withdraws/UserRewardPanel'; + +// GET /admin/api/users/{id} 概览里我们要用到的子集(前端未单独定义 AdminUserOverview 类型)。 +interface UserOverviewResp { + user: { + id: number; + phone: string; + nickname: string | null; + status: string; + wechat_nickname: string | null; + created_at: string; + last_login_at: string; + }; + cash_balance_cents: number; + withdraw_total: number; + withdraw_success_cents: number; +} + +interface Props { + open: boolean; + onClose: () => void; + userId: number | null; + phone: string | null; // 报表行已有的手机号:概览拉取失败时兜底展示 +} + +export default function UserAdRevenueDrawer({ open, onClose, userId, phone }: Props) { + // UserRewardPanel 的 user 快照:它只读 phone/nickname/wechat_nickname/created_at,其余按 snapshot 形状补齐。 + const [user, setUser] = useState(null); + + useEffect(() => { + if (!open || userId == null) return; + let alive = true; + setUser(null); + api + .get(`/admin/api/users/${userId}`) + .then((r) => { + if (!alive) return; + const o = r.data; + setUser({ + id: o.user.id, + phone: o.user.phone, + nickname: o.user.nickname, + status: o.user.status, + wechat_nickname: o.user.wechat_nickname, // 概览接口已返回微信昵称 + wechat_avatar_url: null, + created_at: o.user.created_at, + last_login_at: o.user.last_login_at, + cash_balance_cents: o.cash_balance_cents, + withdraw_total: o.withdraw_total, + withdraw_success_cents: o.withdraw_success_cents, + }); + }) + .catch(() => { + // 概览失败:退回只带手机号,昵称/注册天数显示「-」,不阻塞统计/金币记录(它们由 userId 独立拉) + if (alive) setUser(phone ? ({ phone } as WithdrawUserSnapshot) : null); + }); + return () => { + alive = false; + }; + }, [open, userId, phone]); + + return ( + + {/* userId 就绪即渲染;UserRewardPanel 内部按 userId 自行拉统计与金币记录,user 基本信息随后补上。 + statsVariant="ad":统计区只显示 6 项看广告统计(累计提现/现金余额/激励视频观看数+eCPM/draw观看数+eCPM) */} + {userId != null && } + + ); +} diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index 9d91ea2..ad69746 100644 --- a/src/app/(main)/ad-revenue-report/page.tsx +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -40,13 +40,15 @@ import type { AdRevenueRow, AdRevenueTypeStat, } from '@/lib/types'; +import UserAdRevenueDrawer from './UserAdRevenueDrawer'; const { RangePicker } = DatePicker; // 广告类型标签 const TYPE_TAG: Record = { reward_video: { color: 'blue', label: '激励视频' }, - feed: { color: 'purple', label: '信息流' }, + // 历史误标 feed(领券/比价广告修 adType 之前上报)一律按 Draw 信息流显示——业务已全切 Draw + feed: { color: 'geekblue', label: 'Draw 信息流' }, draw: { color: 'geekblue', label: 'Draw 信息流' }, withdrawal_video: { color: 'gold', label: '提现激励视频' }, }; @@ -131,12 +133,6 @@ const DETAIL_COLUMNS: ColumnsType = [ }, { 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,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴), @@ -309,6 +305,8 @@ export default function AdRevenueReportPage() { const [queriedLimit, setQueriedLimit] = useState(500); const [loading, setLoading] = useState(false); const [formulaOpen, setFormulaOpen] = useState(false); + // 点用户手机号弹出的「用户广告收益详情」半屏抽屉(userId + 手机号;null=关闭) + const [userDrawer, setUserDrawer] = useState<{ userId: number; phone: string | null } | null>(null); // 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天 const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD'); @@ -364,25 +362,39 @@ export default function AdRevenueReportPage() { title: '用户', dataIndex: 'user_phone', width: 150, - render: (phone: string | null, r: AdRevenueRow) => - phone ? ( - - {phone} - - #{r.user_id} - - - ) : ( - #{r.user_id}(无手机号) - ), + render: (phone: string | null, r: AdRevenueRow) => ( + // 点手机号/用户 → 打开该用户「广告收益详情」半屏抽屉(统计卡 + 金币记录) + setUserDrawer({ userId: r.user_id, phone })}> + {phone ? ( + + {phone} + + #{r.user_id} + + + ) : ( + #{r.user_id}(无手机号) + )} + + ), }, { title: '广告类型', dataIndex: 'ad_type', - width: 110, - render: (s: string) => { + width: 130, + render: (s: string, r: AdRevenueRow) => { const t = TYPE_TAG[s] ?? { color: 'default', label: s }; - return {t.label}; + return ( + + {t.label} + {/* 一次比价/领券聚合了多条广告时标出条数,点「+」展开看逐条 */} + {r.sub_count && r.sub_count > 1 ? ( + + {r.sub_count} 条 + + ) : null} + + ); }, }, { @@ -428,7 +440,11 @@ export default function AdRevenueReportPage() { dataIndex: 'revenue_yuan', width: 110, align: 'right', - render: (v: number, r: AdRevenueRow) => (r.has_impression ? v.toFixed(4) : '-'), + // 一次比价/领券聚合行用 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) : '-'; + }, }, { title: '发奖状态', @@ -456,18 +472,6 @@ export default function AdRevenueReportPage() { 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: '广告位ID', dataIndex: 'our_code_id', @@ -527,6 +531,10 @@ export default function AdRevenueReportPage() { title="收益口径说明" content={
+ 主表每行 = 一次广告行为(激励视频一次观看 / 一次比价 / 一次领券);比价·领券的 Draw + 逐条展示不单独占行(其展示数 / eCPM / 预估收益已计入上方合计与大盘),点行左侧「+」展开看该次金币复算。 +
+
「预估收益」为客户端在广告展示(onAdShow)时上报 eCPM 折算的预估值(每千次展示 ÷1000 累加),只要广告展示就计入、不论是否看完发奖;穿山甲会过滤无效/过短曝光,故预估值可能偏高, 实际收益一律以穿山甲后台结算为准。测试应用多为 0。「广告位ID / 来源应用」为本期新增,历史记录留空。 @@ -578,7 +586,6 @@ export default function AdRevenueReportPage() { style={{ width: 150 }} options={[ { value: 'reward_video', label: '激励视频' }, - { value: 'feed', label: '信息流' }, { value: 'draw', label: 'Draw 信息流' }, { value: 'withdrawal_video', label: '提现激励视频' }, ]} @@ -792,22 +799,6 @@ export default function AdRevenueReportPage() { )} - {data && ( - - {data.mismatch_count > 0 ? ( - = 0 ? '+' : '' - }${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`} - /> - ) : ( - - )} - - )} - {data && (queriedMultiDay ? (data.daily?.length ?? 0) > 0 @@ -884,12 +875,27 @@ export default function AdRevenueReportPage() { }} size="small" scroll={{ x: 1380 }} - rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')} expandable={{ // 每行都可展开(左侧恒有 + 号):有发奖看「金币复算因子」;纯展示看「展示明细」。 rowExpandable: () => true, expandedRowRender: (r) => - r.reward_detail ? ( + r.sub_rewards && r.sub_rewards.length > 0 ? ( +
+ 本次广告逐条明细{' '} + + (共 {r.sub_count ?? r.sub_rewards.length} 条 · 应发 {r.expected_coin} / 实发 {r.actual_coin}) + + + style={{ marginTop: 8 }} + rowKey="record_id" + columns={DETAIL_COLUMNS} + dataSource={r.sub_rewards} + pagination={false} + size="small" + scroll={{ x: 900 }} + /> +
+ ) : r.reward_detail ? (
金币复算明细{' '} @@ -903,7 +909,6 @@ export default function AdRevenueReportPage() { pagination={false} size="small" scroll={{ x: 900 }} - rowClassName={(d) => (d.matched ? '' : 'row-mismatch')} />
) : ( @@ -979,11 +984,12 @@ export default function AdRevenueReportPage() { - + setUserDrawer(null)} + />
); } diff --git a/src/app/(main)/withdraws/UserRewardPanel.tsx b/src/app/(main)/withdraws/UserRewardPanel.tsx index 108d4d2..27a0a5e 100644 --- a/src/app/(main)/withdraws/UserRewardPanel.tsx +++ b/src/app/(main)/withdraws/UserRewardPanel.tsx @@ -39,10 +39,12 @@ const PAGE_SIZE = 10; // 金币记录每页条数 interface Props { userId: number; user: WithdrawUserSnapshot | null; + // 统计区字段集:withdraw(默认,提现详情:含提现细分) / ad(广告收益详情:只看看广告观看统计,对齐参考图 6 项) + statsVariant?: 'withdraw' | 'ad'; } -/** 提现详情抽屉:用户基本信息 + 互斥时间筛选 + 看广告/提现统计区 + 金币发放记录表。 */ -export default function UserRewardPanel({ userId, user }: Props) { +/** 用户看广告/提现详情:基本信息 + 互斥时间筛选 + 统计区(按 statsVariant 取字段集)+ 金币发放记录表。 */ +export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw' }: Props) { const [mode, setMode] = useState<'all' | 'range'>('all'); // all=注册至今 / range=自定义区间 const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null); const [stats, setStats] = useState(null); @@ -164,7 +166,7 @@ export default function UserRewardPanel({ userId, user }: Props) { - {/* 统计区(受时间筛选;现金余额为当前快照) */} + {/* 统计区(受时间筛选;现金余额为当前快照)。ad 视图只保留看广告观看统计(对齐参考图 6 项) */} {stats ? yuan(stats.withdraw_success_cents) : '-'} @@ -172,29 +174,51 @@ export default function UserRewardPanel({ userId, user }: Props) { {stats ? yuan(stats.cash_balance_cents) : '-'} - {stats?.withdraw_total ?? '-'} - - {stats ? yuan(stats.traditional_task_cash_cents) : '-'} - - - {stats?.reward_video_count ?? '-'} - - - {stats ? `${stats.reward_video_avg_ecpm} 分/千` : '-'} - - - {stats ? yuan(stats.reward_video_cash_cents) : '-'} - - {stats?.feed_count ?? '-'} - - {stats ? `${stats.feed_avg_ecpm} 分/千` : '-'} - - - {stats ? yuan(stats.feed_cash_cents) : '-'} - + {statsVariant === 'ad' ? ( + <> + + {stats?.reward_video_count ?? '-'} + + {/* eCPM 均按元展示(reward_video / feed avg_ecpm 原始为分/千次,÷100 转元) */} + + {stats ? `${(stats.reward_video_avg_ecpm / 100).toFixed(2)} 元/千` : '-'} + + + {stats?.feed_count ?? '-'} + + + {stats ? `${(stats.feed_avg_ecpm / 100).toFixed(2)} 元/千` : '-'} + + + ) : ( + <> + {stats?.withdraw_total ?? '-'} + + {stats ? yuan(stats.traditional_task_cash_cents) : '-'} + + + {stats?.reward_video_count ?? '-'} + + + {stats ? `${stats.reward_video_avg_ecpm} 分/千` : '-'} + + + {stats ? yuan(stats.reward_video_cash_cents) : '-'} + + {stats?.feed_count ?? '-'} + + {stats ? `${stats.feed_avg_ecpm} 分/千` : '-'} + + + {stats ? yuan(stats.feed_cash_cents) : '-'} + + + )} - 统计随上方时间筛选(现金余额除外,为当前快照);eCPM 单位分/千次,同金币审计。 + {statsVariant === 'ad' + ? '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 均按元展示(原始分/千次 ÷100)。' + : '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 单位分/千次,同金币审计。'} {/* 金币记录 */} diff --git a/src/lib/types.ts b/src/lib/types.ts index 05e79a8..7c3e587 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -425,6 +425,7 @@ export interface AdRevenueRow { impressions: number; // 本行展示条数 1/0(供日汇总、趋势图复用) ecpm: string | null; // 分/千次;展示行取展示值,纯发奖行取发奖采用值 revenue_yuan: number; // 本次展示预估收益(元);纯发奖行=0 + row_revenue_yuan?: number | null; // 主表逐行显示用:一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;其它行空(回退 revenue_yuan) adn: string | null; // 实际填充 ADN;纯发奖行为空 slot_id: string | null; // 底层 mediation rit;纯发奖行为空 // ── 发奖侧 ── @@ -434,6 +435,8 @@ export interface AdRevenueRow { actual_coin: number; // 实发金币;纯展示=0 matched: boolean; // 本条应发==实发;纯展示恒 true(不计对账) reward_detail: AdRevenueRecord | null; // 发奖复算明细(点行展开下钻);纯展示为空 + sub_rewards?: AdRevenueRecord[]; // 一次比价/领券聚合行的组内逐条明细(同整场 ad_session_id 的多条广告);展开渲染多行 + sub_count?: number; // 本行聚合的发奖条数:一次比价/领券=该次广告条数(≥1);激励视频/纯展示=1 } export interface AdRevenueReport {