Files
shaguabijia-admin-web/src/app/(main)/withdraws/UserRewardPanel.tsx
T

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>
);
}