0e8921eca8
- feedbacks:新增 FeedbackQrConfig 二维码配置 - withdraws:新增 UserRewardPanel,页面精简 - 新增 src/lib/media.ts,types 扩展;price-reports 小改 - .env.production 增加 NEXT_PUBLIC_MEDIA_BASE;nginx/README/deploy 配套 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
7.5 KiB
TypeScript
221 lines
7.5 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
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',
|
|
};
|
|
|
|
interface Props {
|
|
userId: number;
|
|
user: WithdrawUserSnapshot | null;
|
|
}
|
|
|
|
/** 提现详情抽屉:用户基本信息 + 互斥时间筛选 + 看广告/提现统计区 + 金币发放记录表。 */
|
|
export default function UserRewardPanel({ userId, user }: 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 [nextCursor, setNextCursor] = useState<number | null>(null);
|
|
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 (cursor: number | null) => {
|
|
setRecordsLoading(true);
|
|
try {
|
|
const reqParams: Record<string, unknown> = { ...JSON.parse(paramsKey), limit: 20 };
|
|
if (cursor != null) reqParams.cursor = cursor;
|
|
const { data } = await api.get<CursorPage<UserCoinRecord>>(
|
|
`/admin/api/users/${userId}/coin-records`,
|
|
{ params: reqParams },
|
|
);
|
|
setRecords((prev) => (cursor == null ? data.items : [...prev, ...data.items]));
|
|
setNextCursor(data.next_cursor);
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
} finally {
|
|
setRecordsLoading(false);
|
|
}
|
|
},
|
|
[userId, paramsKey],
|
|
);
|
|
|
|
useEffect(() => {
|
|
loadStats();
|
|
loadRecords(null);
|
|
}, [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>
|
|
|
|
{/* 统计区(受时间筛选;现金余额为当前快照) */}
|
|
<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>
|
|
<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 单位分/千次,同金币审计。
|
|
</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={false}
|
|
/>
|
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
|
<Button
|
|
size="small"
|
|
onClick={() => loadRecords(nextCursor)}
|
|
disabled={!nextCursor}
|
|
loading={recordsLoading}
|
|
>
|
|
{nextCursor ? '加载更多' : '没有更多了'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|