Files
shaguabijia-admin-web/src/app/(main)/withdraws/UserRewardPanel.tsx
T
zzhyyyyy 0e8921eca8 feat(ops): 反馈二维码配置 + 提现用户奖励面板 + 媒体展示
- 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>
2026-06-20 23:34:37 +08:00

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