278 lines
9.9 KiB
TypeScript
278 lines
9.9 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
DatePicker,
|
|
Descriptions,
|
|
Input,
|
|
Radio,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
Tooltip,
|
|
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;
|
|
withdrawSource?: 'coin_cash' | 'invite_cash';
|
|
// 统计区字段集:withdraw(默认,提现详情:含提现细分) / ad(广告收益详情:只看看广告观看统计,对齐参考图 6 项)
|
|
statsVariant?: 'withdraw' | 'ad';
|
|
}
|
|
|
|
/** 用户看广告/提现详情:基本信息 + 互斥时间筛选 + 统计区(按 statsVariant 取字段集)+ 金币发放记录表。 */
|
|
export default function UserRewardPanel({
|
|
userId,
|
|
user,
|
|
withdrawSource,
|
|
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),
|
|
...(withdrawSource ? { withdraw_source: withdrawSource } : {}),
|
|
},
|
|
});
|
|
setStats(data);
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
} finally {
|
|
setStatsLoading(false);
|
|
}
|
|
}, [userId, paramsKey, withdrawSource]);
|
|
|
|
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>
|
|
{user && (
|
|
<Tooltip title={user.is_high_risk ? user.high_risk_note || '暂无高风险备注' : undefined}>
|
|
<Tag color={user.is_high_risk ? 'red' : 'default'} style={{ marginInlineStart: 8 }}>
|
|
{user.is_high_risk ? '高风险' : '非高风险'}
|
|
</Tag>
|
|
</Tooltip>
|
|
)}
|
|
</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'}
|
|
allowEmpty={[true, true]}
|
|
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 为预估值。'
|
|
: '统计随上方时间筛选(现金余额除外,为当前快照);提现按申请提交时间统计;累计收益为已发金币按当前汇率折算,不代表实际已提现金额;eCPM 为预估值。'}
|
|
</Text>
|
|
|
|
{user?.is_high_risk && (
|
|
<div style={{ marginTop: 16 }}>
|
|
<Text strong>高风险备注</Text>
|
|
<Input.TextArea
|
|
readOnly
|
|
value={user.high_risk_note || ''}
|
|
rows={3}
|
|
style={{ marginTop: 8 }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 主要金币发放记录 */}
|
|
<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>
|
|
);
|
|
}
|