feat(ad-revenue): 收益报表按一次比价/领券聚合展开 + 用户收益详情抽屉

- 主表按「单次广告行为」展示:一次比价/领券聚成父行、广告类型标「N 条」,点「+」展开看该次逐条
  发奖复算;信息流统一显示「Draw 信息流」,类型筛选去掉「信息流」选项。
- 新增点用户手机号弹半屏抽屉(UserAdRevenueDrawer,复用 withdraws 的 UserRewardPanel):展示该用户
  看广告统计(6 项)+ 金币记录;抽屉统计区 eCPM 改「元/千」,并补微信昵称。
- 一次比价/领券行显示预估收益(row_revenue_yuan,发奖侧折算);去掉「一致」列 + 应发≠实发行标红 +
  顶部对账 Alert(发奖走「所见即所得」display_coin,与公式复算本就对不上,属噪音)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zzhyyyyy
2026-07-01 20:19:11 +08:00
parent 3102a96bd3
commit 639d8a14c7
4 changed files with 200 additions and 83 deletions
@@ -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<WithdrawUserSnapshot | null>(null);
useEffect(() => {
if (!open || userId == null) return;
let alive = true;
setUser(null);
api
.get<UserOverviewResp>(`/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 (
<Drawer
title={`用户广告收益详情${phone ? ` · ${phone}` : ''}`}
width="50%"
open={open}
onClose={onClose}
destroyOnHidden
>
{/* userId 就绪即渲染;UserRewardPanel 内部按 userId 自行拉统计与金币记录,user 基本信息随后补上。
statsVariant="ad":统计区只显示 6 项看广告统计(累计提现/现金余额/激励视频观看数+eCPM/draw观看数+eCPM) */}
{userId != null && <UserRewardPanel userId={userId} user={user} statsVariant="ad" />}
</Drawer>
);
}
+65 -59
View File
@@ -40,13 +40,15 @@ import type {
AdRevenueRow,
AdRevenueTypeStat,
} from '@/lib/types';
import UserAdRevenueDrawer from './UserAdRevenueDrawer';
const { RangePicker } = DatePicker;
// 广告类型标签
const TYPE_TAG: Record<string, { color: string; label: string }> = {
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<AdRevenueRecord> = [
},
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
{
title: '一致',
dataIndex: 'matched',
width: 80,
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
},
];
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴),
@@ -309,6 +305,8 @@ export default function AdRevenueReportPage() {
const [queriedLimit, setQueriedLimit] = useState<number>(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 ? (
<span>
{phone}
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
#{r.user_id}
</Typography.Text>
</span>
) : (
<Typography.Text type="secondary">#{r.user_id}</Typography.Text>
),
render: (phone: string | null, r: AdRevenueRow) => (
// 点手机号/用户 → 打开该用户「广告收益详情」半屏抽屉(统计卡 + 金币记录)
<a onClick={() => setUserDrawer({ userId: r.user_id, phone })}>
{phone ? (
<span>
{phone}
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
#{r.user_id}
</Typography.Text>
</span>
) : (
<span>#{r.user_id}</span>
)}
</a>
),
},
{
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 <Tag color={t.color}>{t.label}</Tag>;
return (
<span>
<Tag color={t.color}>{t.label}</Tag>
{/* 一次比价/领券聚合了多条广告时标出条数,点「+」展开看逐条 */}
{r.sub_count && r.sub_count > 1 ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{r.sub_count}
</Typography.Text>
) : null}
</span>
);
},
},
{
@@ -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 : <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '一致',
dataIndex: 'matched',
width: 70,
align: 'center',
render: (m: boolean, r: AdRevenueRow) =>
r.has_reward ? (
m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
},
{
title: '广告位ID',
dataIndex: 'our_code_id',
@@ -527,6 +531,10 @@ export default function AdRevenueReportPage() {
title="收益口径说明"
content={
<div style={{ maxWidth: 360, fontSize: 13, lineHeight: 1.7 }}>
<b> = 广</b>( / / );· Draw
( / eCPM / ),+
<br />
<br />
广(onAdShow) eCPM ( ÷1000
),<b>广</b>;穿/,,
<b>穿</b> 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() {
</Card>
)}
{data && (
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
{data.mismatch_count > 0 ? (
<Alert
type="error"
showIcon
message={`${data.total} 条广告事件,其中 ${data.mismatch_count} 条应发≠实发(✗);应发−实发 合计 ${
derived && derived.coinGap >= 0 ? '+' : ''
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
/>
) : (
<Alert type="success" showIcon message={`${data.total} 条广告事件,应发与实发全部一致。`} />
)}
</Space>
)}
{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 ? (
<div>
<Typography.Text strong>广</Typography.Text>{' '}
<Typography.Text type="secondary">
( {r.sub_count ?? r.sub_rewards.length} · {r.expected_coin} / {r.actual_coin})
</Typography.Text>
<Table<AdRevenueRecord>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={r.sub_rewards}
pagination={false}
size="small"
scroll={{ x: 900 }}
/>
</div>
) : r.reward_detail ? (
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">
@@ -903,7 +909,6 @@ export default function AdRevenueReportPage() {
pagination={false}
size="small"
scroll={{ x: 900 }}
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
/>
</div>
) : (
@@ -979,11 +984,12 @@ export default function AdRevenueReportPage() {
</Typography.Paragraph>
</Modal>
<style jsx global>{`
.row-mismatch > td {
background: #fff1f0 !important;
}
`}</style>
<UserAdRevenueDrawer
open={!!userDrawer}
userId={userDrawer?.userId ?? null}
phone={userDrawer?.phone ?? null}
onClose={() => setUserDrawer(null)}
/>
</div>
);
}
+48 -24
View File
@@ -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<UserRewardStats | null>(null);
@@ -164,7 +166,7 @@ export default function UserRewardPanel({ userId, user }: Props) {
</Space>
</Space>
{/* 统计区(受时间筛选;现金余额为当前快照) */}
{/* 统计区(受时间筛选;现金余额为当前快照)。ad 视图只保留看广告观看统计(对齐参考图 6 项) */}
<Descriptions bordered size="small" column={2} style={{ marginBottom: 8 }}>
<Descriptions.Item label="累计提现">
{stats ? yuan(stats.withdraw_success_cents) : '-'}
@@ -172,29 +174,51 @@ export default function UserRewardPanel({ userId, user }: Props) {
<Descriptions.Item label="现金余额">
{stats ? yuan(stats.cash_balance_cents) : '-'}
</Descriptions.Item>
<Descriptions.Item label="提现总次数">{stats?.withdraw_total ?? '-'}</Descriptions.Item>
<Descriptions.Item label="传统任务提现">
{stats ? yuan(stats.traditional_task_cash_cents) : '-'}
</Descriptions.Item>
<Descriptions.Item label="累计激励视频数">
{stats?.reward_video_count ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="平均激励视频ECPM">
{stats ? `${stats.reward_video_avg_ecpm} 分/千` : '-'}
</Descriptions.Item>
<Descriptions.Item label="激励视频提现">
{stats ? yuan(stats.reward_video_cash_cents) : '-'}
</Descriptions.Item>
<Descriptions.Item label="累计信息流广告数">{stats?.feed_count ?? '-'}</Descriptions.Item>
<Descriptions.Item label="平均信息流广告ECPM">
{stats ? `${stats.feed_avg_ecpm} 分/千` : '-'}
</Descriptions.Item>
<Descriptions.Item label="信息流广告提现">
{stats ? yuan(stats.feed_cash_cents) : '-'}
</Descriptions.Item>
{statsVariant === 'ad' ? (
<>
<Descriptions.Item label="激励视频观看数">
{stats?.reward_video_count ?? '-'}
</Descriptions.Item>
{/* eCPM 均按元展示(reward_video / feed avg_ecpm 原始为分/千次,÷100 转元) */}
<Descriptions.Item label="平均激励视频ECPM">
{stats ? `${(stats.reward_video_avg_ecpm / 100).toFixed(2)} 元/千` : '-'}
</Descriptions.Item>
<Descriptions.Item label="draw信息流视频观看数">
{stats?.feed_count ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="平均draw信息流ECPM">
{stats ? `${(stats.feed_avg_ecpm / 100).toFixed(2)} 元/千` : '-'}
</Descriptions.Item>
</>
) : (
<>
<Descriptions.Item label="提现总次数">{stats?.withdraw_total ?? '-'}</Descriptions.Item>
<Descriptions.Item label="传统任务提现">
{stats ? yuan(stats.traditional_task_cash_cents) : '-'}
</Descriptions.Item>
<Descriptions.Item label="累计激励视频数">
{stats?.reward_video_count ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="平均激励视频ECPM">
{stats ? `${stats.reward_video_avg_ecpm} 分/千` : '-'}
</Descriptions.Item>
<Descriptions.Item label="激励视频提现">
{stats ? yuan(stats.reward_video_cash_cents) : '-'}
</Descriptions.Item>
<Descriptions.Item label="累计信息流广告数">{stats?.feed_count ?? '-'}</Descriptions.Item>
<Descriptions.Item label="平均信息流广告ECPM">
{stats ? `${stats.feed_avg_ecpm} 分/千` : '-'}
</Descriptions.Item>
<Descriptions.Item label="信息流广告提现">
{stats ? yuan(stats.feed_cash_cents) : '-'}
</Descriptions.Item>
</>
)}
</Descriptions>
<Text type="secondary" style={{ fontSize: 12 }}>
(,);eCPM /,
{statsVariant === 'ad'
? '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 均按元展示(原始分/千次 ÷100)。'
: '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 单位分/千次,同金币审计。'}
</Text>
{/* 金币记录 */}
+3
View File
@@ -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 {