2727eaa929
- 主表按「单次广告行为」展示:一次比价/领券聚成父行、广告类型标「N 条」,点「+」展开看该次逐条 发奖复算;信息流统一显示「Draw 信息流」,类型筛选去掉「信息流」选项。 - 新增点用户手机号弹半屏抽屉(UserAdRevenueDrawer,复用 withdraws 的 UserRewardPanel):展示该用户 看广告统计(6 项)+ 金币记录;抽屉统计区 eCPM 改「元/千」,并补微信昵称。 - 一次比价/领券行显示预估收益(row_revenue_yuan,发奖侧折算);去掉「一致」列 + 应发≠实发行标红 + 顶部对账 Alert(发奖走「所见即所得」display_coin,与公式复算本就对不上,属噪音)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #33 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
DatePicker,
|
|
Descriptions,
|
|
Radio,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import dayjs, { type Dayjs } from 'dayjs';
|
|
import type { CursorPage, UserCoinRecord, UserRewardStats, WithdrawUserSnapshot } from '@/lib/types';
|
|
import { api, errMsg } from '@/lib/api';
|
|
|
|
const { Text } = Typography;
|
|
const { RangePicker } = DatePicker;
|
|
|
|
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
|
// 后端时间为 UTC(无时区串补 Z),按北京时区展示(与提现列表口径一致)
|
|
const apiTime = (v: string) => {
|
|
const hasTz = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
|
|
return dayjs(hasTz ? v : `${v}Z`);
|
|
};
|
|
const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm');
|
|
|
|
const SOURCE_COLOR: Record<string, string> = {
|
|
reward_video: 'blue',
|
|
signin_boost: 'cyan',
|
|
feed: 'purple',
|
|
signin: 'green',
|
|
};
|
|
|
|
const PAGE_SIZE = 10; // 金币记录每页条数
|
|
|
|
interface Props {
|
|
userId: number;
|
|
user: WithdrawUserSnapshot | null;
|
|
// 统计区字段集:withdraw(默认,提现详情:含提现细分) / ad(广告收益详情:只看看广告观看统计,对齐参考图 6 项)
|
|
statsVariant?: 'withdraw' | 'ad';
|
|
}
|
|
|
|
/** 用户看广告/提现详情:基本信息 + 互斥时间筛选 + 统计区(按 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);
|
|
const [statsLoading, setStatsLoading] = useState(false);
|
|
|
|
const [records, setRecords] = useState<UserCoinRecord[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [recordsLoading, setRecordsLoading] = useState(false);
|
|
|
|
// 时间窗口参数:注册至今=不传;自定义区间(选齐两端)=date_from/date_to。
|
|
const params =
|
|
mode === 'range' && range?.[0] && range?.[1]
|
|
? {
|
|
date_from: range[0].startOf('day').toISOString(),
|
|
date_to: range[1].endOf('day').toISOString(),
|
|
}
|
|
: {};
|
|
const paramsKey = JSON.stringify(params);
|
|
|
|
const loadStats = useCallback(async () => {
|
|
setStatsLoading(true);
|
|
try {
|
|
const { data } = await api.get<UserRewardStats>(`/admin/api/users/${userId}/reward-stats`, {
|
|
params: JSON.parse(paramsKey),
|
|
});
|
|
setStats(data);
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
} finally {
|
|
setStatsLoading(false);
|
|
}
|
|
}, [userId, paramsKey]);
|
|
|
|
const loadRecords = useCallback(
|
|
async (p: number) => {
|
|
setRecordsLoading(true);
|
|
try {
|
|
const { data } = await api.get<CursorPage<UserCoinRecord>>(
|
|
`/admin/api/users/${userId}/coin-records`,
|
|
{ params: { ...JSON.parse(paramsKey), limit: PAGE_SIZE, cursor: (p - 1) * PAGE_SIZE } },
|
|
);
|
|
setRecords(data.items);
|
|
setTotal(data.total ?? 0);
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
} finally {
|
|
setRecordsLoading(false);
|
|
}
|
|
},
|
|
[userId, paramsKey],
|
|
);
|
|
|
|
// 用户或时间筛选变化:回到第 1 页重新拉取
|
|
useEffect(() => {
|
|
loadStats();
|
|
setPage(1);
|
|
loadRecords(1);
|
|
}, [loadStats, loadRecords]);
|
|
|
|
const regDays = user?.created_at ? dayjs().diff(apiTime(user.created_at), 'day') : null;
|
|
|
|
const columns: ColumnsType<UserCoinRecord> = [
|
|
{ title: '日期时间', dataIndex: 'created_at', width: 150, render: dt },
|
|
{
|
|
title: '赚取途径',
|
|
dataIndex: 'source_label',
|
|
width: 120,
|
|
render: (label: string, r) => <Tag color={SOURCE_COLOR[r.source] ?? 'default'}>{label}</Tag>,
|
|
},
|
|
{
|
|
title: 'ECPM',
|
|
dataIndex: 'ecpm',
|
|
width: 100,
|
|
render: (v: string | null) => v ?? <Text type="secondary">-</Text>,
|
|
},
|
|
{ title: '发放金币数', dataIndex: 'coin', width: 100, render: (v: number) => <b>{v}</b> },
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
{/* 用户基本信息 + 互斥时间筛选(同一行) */}
|
|
<Space
|
|
wrap
|
|
align="center"
|
|
style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}
|
|
>
|
|
<Space size="large" wrap>
|
|
<span>
|
|
手机号 <Text strong>{user?.phone || '-'}</Text>
|
|
</span>
|
|
<span>
|
|
昵称 <Text strong>{user?.nickname || '-'}</Text>
|
|
</span>
|
|
<span>
|
|
微信昵称 <Text strong>{user?.wechat_nickname || '-'}</Text>
|
|
</span>
|
|
<span>
|
|
注册天数 <Text strong>{regDays != null ? `${regDays} 天` : '-'}</Text>
|
|
</span>
|
|
</Space>
|
|
<Space wrap>
|
|
<Radio.Group
|
|
size="small"
|
|
value={mode}
|
|
onChange={(e) => setMode(e.target.value)}
|
|
optionType="button"
|
|
buttonStyle="solid"
|
|
>
|
|
<Radio.Button value="all">注册至今</Radio.Button>
|
|
<Radio.Button value="range">自定义区间</Radio.Button>
|
|
</Radio.Group>
|
|
<RangePicker
|
|
size="small"
|
|
disabled={mode !== 'range'}
|
|
value={range}
|
|
onChange={(v) => setRange(v as [Dayjs, Dayjs] | null)}
|
|
/>
|
|
</Space>
|
|
</Space>
|
|
|
|
{/* 统计区(受时间筛选;现金余额为当前快照)。ad 视图只保留看广告观看统计(对齐参考图 6 项) */}
|
|
<Descriptions bordered size="small" column={2} style={{ marginBottom: 8 }}>
|
|
<Descriptions.Item label="累计提现">
|
|
{stats ? yuan(stats.withdraw_success_cents) : '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="现金余额">
|
|
{stats ? yuan(stats.cash_balance_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 }}>
|
|
{statsVariant === 'ad'
|
|
? '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 均按元展示(原始分/千次 ÷100)。'
|
|
: '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 单位分/千次,同金币审计。'}
|
|
</Text>
|
|
|
|
{/* 金币记录 */}
|
|
<h4 style={{ margin: '16px 0 8px' }}>金币记录</h4>
|
|
<Table
|
|
rowKey={(r) => `${r.source}-${r.created_at}-${r.coin}-${r.ecpm ?? ''}`}
|
|
size="small"
|
|
columns={columns}
|
|
dataSource={records}
|
|
loading={recordsLoading || statsLoading}
|
|
pagination={{
|
|
current: page,
|
|
pageSize: PAGE_SIZE,
|
|
total,
|
|
showSizeChanger: false,
|
|
onChange: (p) => {
|
|
setPage(p);
|
|
loadRecords(p);
|
|
},
|
|
showTotal: (t) => `共 ${t} 条`,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|