## 详情页重排(4 张卡,消除重复) 用户反馈「设计太乱、数据不好看、布局也乱」——根因是**同一份数据在一屏出现两次**: 积分/鱼翅/兑换余额在状态条和资产区各一次,角色绑定在状态条和信息表各一次。 新布局:**账号信息**(身份+状态+归属,一张 Descriptions)→ **资产与统计** (数字只出现一次,上面"现在有多少"、下面"历史累计")→ **高价值套装与兑换记录** (从最顶上移到这里,它是补充信息,不该抢主视觉)→ **账号时间线**。 零散的 `flex + gap` 统计行改成 `Row/Col` 栅格,列宽对齐。 ## 高价值套装:颜色按**价值**口径,别标反 用户澄清的业务语义与直觉相反: - **没兑换过** = 还能用来绑定兑换 = **高价值**(绿色「可兑换」) - 兑换过 1 个 → 价值降低;两个都兑换过 → 价值最低(橙色「已兑换」) 判定依据是**斗鱼官方兑换记录**(账号级)。⚠️ 不能用 `query_limited_goods` 的 `count`/`storeStatus` —— 那是**全局库存**(同一时刻不同账号看到的都一样), 用它判"这个账号有没有"必然给错结论。这个结论是推翻过一版才得到的: 第一版拿相隔两个月的两条快照对比,把**时间差异**当成了**账号差异**。 ## 列表 - 新增「高价值套装」列:🟢两个都可兑换 / 🟠某套装已兑换 / ⚪未查询 (「未查询」必须与「没兑换」分开,否则会把未知当成高价值) - 「兑换记录」列只显示官方口径,**点击弹窗**看全部(按作用域分组, 含商品/积分/真实兑换时间/当时用的昵称与平台),不用进详情页 ## 三个作用域的记录展示 面板与预览都按 精英宝典 / 电竞手册 / 和平小店 分三块展示, 每条带**真实兑换时间**与"使用昵称"(排查时有用)。 ## 顺带修 `exchange_records` 从数组改成按作用域分组的对象后,面板同步适配; `formatTime` 对 `0` 显示 `-`(斗鱼用 0 表示"没有这个时间", 直接格式化会显示成 1970/1/1)。
691 lines
25 KiB
TypeScript
691 lines
25 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
import {
|
||
Button, Card, Col, Descriptions, Divider, Empty, Input, Popconfirm, Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
|
||
} from 'antd';
|
||
import type { TableProps } from 'antd';
|
||
import {
|
||
ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined,
|
||
} from '@ant-design/icons';
|
||
import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
|
||
import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel';
|
||
import { getErrorMessage } from '../utils/error';
|
||
import { message } from '../utils/antdMessage';
|
||
import { formatTime } from '../utils/time';
|
||
|
||
const { Text, Title } = Typography;
|
||
|
||
/**
|
||
* 事件类型 → 展示样式。
|
||
* 颜色用来一眼区分「钱进来 / 分出去 / 状态变化」三类,而不是按类型各给一个颜色。
|
||
*/
|
||
const EVENT_STYLE: Record<string, { label: string; color: string }> = {
|
||
imported: { label: '导入', color: 'default' },
|
||
login_ok: { label: '登录', color: 'blue' },
|
||
ck_invalid: { label: 'CK 失效', color: 'red' },
|
||
ck_cleared: { label: 'CK 已清除', color: 'red' },
|
||
relogin_ok: { label: '重登成功', color: 'green' },
|
||
relogin_failed: { label: '重登转人工', color: 'orange' },
|
||
status_changed: { label: '状态变更', color: 'geekblue' },
|
||
bind_ok: { label: '绑定', color: 'cyan' },
|
||
recharge: { label: '充值', color: 'green' },
|
||
exchange: { label: '兑换', color: 'purple' },
|
||
points_credit: { label: '积分到账', color: 'gold' },
|
||
elite_opened: { label: '精英宝典', color: 'magenta' },
|
||
esports_opened: { label: '电竞手册', color: 'volcano' },
|
||
assigned: { label: '分配', color: 'default' },
|
||
tag_changed: { label: '标签', color: 'default' },
|
||
remark_changed: { label: '备注', color: 'default' },
|
||
};
|
||
|
||
/** 事件粗分类(与后端 `account_events_service.CATEGORY_*` 对应)。 */
|
||
const CATEGORY_OPTIONS = [
|
||
{ value: 'lifecycle', label: '状态变迁' },
|
||
{ value: 'auth', label: '登录与 CK' },
|
||
{ value: 'business', label: '业务流水' },
|
||
{ value: 'ops', label: '人工操作' },
|
||
];
|
||
|
||
/** 生命周期状态 → 展示色(与账号列表页保持一致,取值域见后端 account_lifecycle)。 */
|
||
const STATUS_TONES: Record<string, string> = {
|
||
imported: 'default',
|
||
active: 'green',
|
||
invalid: 'red',
|
||
manual: 'orange',
|
||
disabled: 'default',
|
||
archived: 'default',
|
||
};
|
||
|
||
/** 绑定状态 → 展示色(与后端 account_binding 的四个枚举对应)。 */
|
||
const BIND_TONES: Record<string, string> = {
|
||
bound: 'green',
|
||
pending: 'orange',
|
||
unbound: 'red',
|
||
unknown: 'default',
|
||
};
|
||
|
||
function eventTag(type: string) {
|
||
const style = EVENT_STYLE[type] ?? { label: type, color: 'default' };
|
||
return <Tag color={style.color}>{style.label}</Tag>;
|
||
}
|
||
|
||
/** 金额展示:充值为正(绿色 ¥),兑换消耗为负(红色积分)。 */
|
||
function amountText(item: AccountEventItem) {
|
||
if (item.amount === null || item.amount === undefined) return null;
|
||
if (item.event_type === 'recharge') {
|
||
return <Text type="success">+¥{item.amount}</Text>;
|
||
}
|
||
if (item.event_type === 'exchange') {
|
||
return <Text type="danger">{item.amount} 分</Text>;
|
||
}
|
||
return (
|
||
<Text type={item.amount >= 0 ? 'success' : 'danger'}>
|
||
{item.amount > 0 ? '+' : ''}
|
||
{item.amount}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
export default function AccountDetailPage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const accountId = Number(id);
|
||
const navigate = useNavigate();
|
||
|
||
const [overview, setOverview] = useState<AccountOverview | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [events, setEvents] = useState<AccountEventItem[]>([]);
|
||
const [eventTotal, setEventTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(20);
|
||
const [eventType, setEventType] = useState('');
|
||
const [category, setCategory] = useState('');
|
||
const [eventsLoading, setEventsLoading] = useState(false);
|
||
const [tagDraft, setTagDraft] = useState('');
|
||
// 一键查询:向斗鱼实时查一次该账号的全部关键信息(异步任务 + 轮询结果)
|
||
const [ovLoading, setOvLoading] = useState(false);
|
||
const [ovError, setOvError] = useState('');
|
||
const [tagEditing, setTagEditing] = useState(false);
|
||
const [supportUsers, setSupportUsers] = useState<UserInfo[]>([]);
|
||
const [remarkEditing, setRemarkEditing] = useState(false);
|
||
const [remarkDraft, setRemarkDraft] = useState('');
|
||
|
||
/** 加载客服列表(分配用)。只在需要时拉一次。 */
|
||
useEffect(() => {
|
||
let alive = true;
|
||
void (async () => {
|
||
try {
|
||
const users = await userApi.list();
|
||
if (alive) setSupportUsers((users || []).filter((u) => u.role === 'support'));
|
||
} catch {
|
||
// 拉不到客服列表不影响页面其它部分
|
||
}
|
||
})();
|
||
return () => { alive = false; };
|
||
}, []);
|
||
|
||
|
||
// 一键查询:建一个只读聚合任务 → 轮询**账号自身**直到快照更新。
|
||
//
|
||
// 结果写在账号上(`overview_snapshot`),所以轮询账号就够了。
|
||
//
|
||
// ⚠️ 轮询必须**能自证在推进**。之前的 bug:详情接口忘了返回
|
||
// `overview_queried_at`(字段加到了列表接口上),于是比较永远相等、
|
||
// 轮询满 120 秒 —— 用户看到的就是"点一下就一直转圈刷请求"。
|
||
// 所以这里加了:拿不到标记字段就**立刻报错退出**,而不是傻等。
|
||
const runOverview = useCallback(async () => {
|
||
if (!id) return;
|
||
const accountId = Number(id);
|
||
const before = overview?.overview_queried_at ?? '';
|
||
setOvLoading(true);
|
||
setOvError('');
|
||
try {
|
||
await douyuApi.createTasks({
|
||
account_ids: [accountId],
|
||
task_type: 'query_account_overview',
|
||
handbook_scope: 'elite',
|
||
});
|
||
const deadline = Date.now() + 120_000;
|
||
let first = true;
|
||
while (Date.now() < deadline) {
|
||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||
const fresh = await accountApi.overview(accountId);
|
||
const marker = fresh.overview_queried_at;
|
||
if (first && marker === undefined) {
|
||
// 接口没返回标记字段 —— 再怎么轮询也不可能变化,直接如实报错
|
||
setOvError('接口未返回查询时间标记,无法判断是否完成(请检查后端详情接口)');
|
||
return;
|
||
}
|
||
first = false;
|
||
if ((marker ?? '') !== before) {
|
||
setOverview(fresh);
|
||
message.success('查询完成');
|
||
return;
|
||
}
|
||
}
|
||
setOvError('查询超时(120 秒未出结果),稍后刷新页面看看');
|
||
} catch (error) {
|
||
setOvError(getErrorMessage(error));
|
||
} finally {
|
||
setOvLoading(false);
|
||
}
|
||
}, [id, overview?.overview_queried_at]);
|
||
|
||
const loadOverview = useCallback(async () => {
|
||
if (!accountId) return;
|
||
setLoading(true);
|
||
try {
|
||
setOverview(await accountApi.overview(accountId));
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [accountId]);
|
||
|
||
const loadEvents = useCallback(async () => {
|
||
if (!accountId) return;
|
||
setEventsLoading(true);
|
||
try {
|
||
const data = await accountApi.events(accountId, {
|
||
page,
|
||
page_size: pageSize,
|
||
event_type: eventType,
|
||
category,
|
||
});
|
||
setEvents(data.items);
|
||
setEventTotal(data.total);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setEventsLoading(false);
|
||
}
|
||
}, [accountId, page, pageSize, eventType, category]);
|
||
|
||
useEffect(() => {
|
||
void loadOverview();
|
||
}, [loadOverview]);
|
||
|
||
useEffect(() => {
|
||
void loadEvents();
|
||
}, [loadEvents]);
|
||
|
||
const columns: TableProps<AccountEventItem>['columns'] = [
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'occurred_at',
|
||
width: 165,
|
||
render: (val: string) => formatTime(val),
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'event_type',
|
||
width: 100,
|
||
render: (val: string) => eventTag(val),
|
||
},
|
||
{ title: '说明', dataIndex: 'title', ellipsis: true },
|
||
{
|
||
title: '金额 / 变动',
|
||
dataIndex: 'amount',
|
||
width: 110,
|
||
align: 'right',
|
||
render: (_val, record) => amountText(record) ?? <Text type="secondary">-</Text>,
|
||
},
|
||
{
|
||
title: '变动后余额',
|
||
dataIndex: 'balance_after',
|
||
width: 110,
|
||
align: 'right',
|
||
render: (val: number | null) =>
|
||
val === null || val === undefined ? <Text type="secondary">-</Text> : val,
|
||
},
|
||
];
|
||
|
||
if (!accountId) {
|
||
return <Empty description="账号 ID 无效" />;
|
||
}
|
||
|
||
const stats = overview?.stats;
|
||
const status = overview?.status || '';
|
||
|
||
/** 人工切换生命周期状态:disabled / archived / auto(恢复自动推导)。 */
|
||
const handleLifecycle = async (next: 'disabled' | 'archived' | 'auto') => {
|
||
if (!accountId) return;
|
||
try {
|
||
await accountApi.setLifecycle(accountId, next);
|
||
message.success('状态已更新');
|
||
void loadOverview();
|
||
void loadEvents();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
|
||
const handleAssign = async (assignedTo: number | null) => {
|
||
if (!accountId) return;
|
||
try {
|
||
await accountApi.assign(accountId, assignedTo);
|
||
message.success(assignedTo ? '已分配' : '已取消分配');
|
||
void loadOverview();
|
||
void loadEvents();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const handleSaveTag = async () => {
|
||
if (!accountId) return;
|
||
try {
|
||
await accountApi.setTag(accountId, tagDraft.trim());
|
||
message.success('标签已更新');
|
||
setTagEditing(false);
|
||
void loadOverview();
|
||
void loadEvents();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
/** 保存备注:备注是自由文本,走单个接口(标签那种短枚举才有批量)。 */
|
||
const handleSaveRemark = async () => {
|
||
if (!accountId) return;
|
||
try {
|
||
await accountApi.setRemark(accountId, remarkDraft.trim());
|
||
message.success('备注已更新');
|
||
setRemarkEditing(false);
|
||
void loadOverview();
|
||
void loadEvents();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const handleAutomation = async (autoManaged: boolean) => {
|
||
if (!accountId) return;
|
||
try {
|
||
await accountApi.setAutomation(accountId, autoManaged);
|
||
message.success(autoManaged ? '已恢复自动化' : '已设为不参与自动化');
|
||
void loadOverview();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{ padding: 4 }}>
|
||
<Space style={{ marginBottom: 12 }} wrap>
|
||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/accounts')}>
|
||
返回账号列表
|
||
</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={() => { void loadOverview(); void loadEvents(); }}>
|
||
刷新
|
||
</Button>
|
||
{status === 'disabled' || status === 'archived' ? (
|
||
<Popconfirm
|
||
title="恢复这个账号?将按当前 CK 情况重新判定状态"
|
||
onConfirm={() => void handleLifecycle('auto')}
|
||
>
|
||
<Button>恢复</Button>
|
||
</Popconfirm>
|
||
) : (
|
||
<>
|
||
<Popconfirm
|
||
title="停用这个账号?停用后不再参与任何自动化"
|
||
onConfirm={() => void handleLifecycle('disabled')}
|
||
>
|
||
<Button danger>停用</Button>
|
||
</Popconfirm>
|
||
<Popconfirm
|
||
title="归档这个账号?默认列表将不再显示它"
|
||
onConfirm={() => void handleLifecycle('archived')}
|
||
>
|
||
<Button>归档</Button>
|
||
</Popconfirm>
|
||
</>
|
||
)}
|
||
{overview?.auto_managed === false ? (
|
||
<Button onClick={() => void handleAutomation(true)}>恢复自动化</Button>
|
||
) : (
|
||
<Popconfirm
|
||
title="设为不参与自动化?账号仍可用,只是巡检/重登不再碰它"
|
||
onConfirm={() => void handleAutomation(false)}
|
||
>
|
||
<Button>不参与自动化</Button>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
|
||
<Spin spinning={loading}>
|
||
{/* ── 卡片 1:账号信息 ──────────────────────────────
|
||
身份 + 状态 + 归属,一张表看完。**不重复展示资产**(见卡片 2)。 */}
|
||
<Card
|
||
size="small"
|
||
style={{ marginBottom: 12 }}
|
||
title={
|
||
<Space wrap>
|
||
<Title level={5} style={{ margin: 0 }}>
|
||
{overview?.nickname || overview?.username || '—'}
|
||
</Title>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
#{overview?.id ?? '-'} · {overview?.username || '—'}
|
||
</Text>
|
||
<Tag color={STATUS_TONES[status] ?? 'default'}>
|
||
{overview?.status_label || '—'}
|
||
</Tag>
|
||
{overview?.tag && <Tag color="blue">{overview.tag}</Tag>}
|
||
{overview?.auto_managed === false && <Tag color="orange">不参与自动化</Tag>}
|
||
</Space>
|
||
}
|
||
>
|
||
<Descriptions column={3} size="small" bordered>
|
||
<Descriptions.Item label="斗鱼 UID">{overview?.uid || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="游戏角色">
|
||
{overview?.game_name || '-'}
|
||
{overview?.game_channel ? (
|
||
<Text type="secondary">({overview.game_channel})</Text>
|
||
) : null}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="导入时间">
|
||
{formatTime(overview?.created_at)}
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="生命周期">
|
||
<Space direction="vertical" size={0}>
|
||
<Text>
|
||
{formatTime(overview?.status_changed_at)}
|
||
{overview?.status_reason ? (
|
||
<Text type="secondary">({overview.status_reason})</Text>
|
||
) : null}
|
||
</Text>
|
||
</Space>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="CK">
|
||
<Space size={6} wrap>
|
||
{overview?.has_cookie === false ? (
|
||
<Tag>无 CK</Tag>
|
||
) : (
|
||
<Tag color={overview?.ck_check_status === 'invalid' ? 'red' : 'green'}>
|
||
{overview?.ck_check_status === 'invalid'
|
||
? '已失效'
|
||
: overview?.ck_check_status === 'valid'
|
||
? '正常'
|
||
: '未检测'}
|
||
</Tag>
|
||
)}
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{formatTime(stats?.last_ck_check_at)}
|
||
</Text>
|
||
</Space>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="角色绑定">
|
||
{/* 三个作用域各一列,取值 unknown/unbound/pending/bound */}
|
||
<Space size={4} wrap>
|
||
<Tag color={BIND_TONES[overview?.role_bind_status ?? 'unknown']}>
|
||
精英:{overview?.role_bind_label || '未知'}
|
||
</Tag>
|
||
<Tag color={BIND_TONES[overview?.esports_bind_status ?? 'unknown']}>
|
||
电竞:{overview?.esports_bind_label || '未知'}
|
||
</Tag>
|
||
<Tag color={BIND_TONES[overview?.xpd_bind_status ?? 'unknown']}>
|
||
小店:{overview?.xpd_bind_label || '未知'}
|
||
</Tag>
|
||
</Space>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="归属">
|
||
<Select
|
||
size="small"
|
||
style={{ width: 140 }}
|
||
placeholder="未分配"
|
||
value={overview?.assigned_to ?? undefined}
|
||
allowClear
|
||
onChange={(val) => void handleAssign(val ?? null)}
|
||
options={supportUsers.map((u) => ({ value: u.id, label: u.username }))}
|
||
/>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="标签">
|
||
{tagEditing ? (
|
||
<Space.Compact>
|
||
<Input
|
||
size="small"
|
||
style={{ width: 120 }}
|
||
maxLength={32}
|
||
value={tagDraft}
|
||
onChange={(e) => setTagDraft(e.target.value)}
|
||
onPressEnter={() => void handleSaveTag()}
|
||
/>
|
||
<Button size="small" type="primary" onClick={() => void handleSaveTag()}>
|
||
保存
|
||
</Button>
|
||
<Button size="small" onClick={() => setTagEditing(false)}>
|
||
取消
|
||
</Button>
|
||
</Space.Compact>
|
||
) : (
|
||
<Space size={4}>
|
||
<span>{overview?.tag || '-'}</span>
|
||
<Tooltip title="编辑标签">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
onClick={() => {
|
||
setTagDraft(overview?.tag || '');
|
||
setTagEditing(true);
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
</Space>
|
||
)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="备注">
|
||
{remarkEditing ? (
|
||
<Space.Compact style={{ width: '100%' }}>
|
||
<Input
|
||
size="small"
|
||
maxLength={256}
|
||
value={remarkDraft}
|
||
placeholder="给这个账号写点备注"
|
||
onChange={(e) => setRemarkDraft(e.target.value)}
|
||
onPressEnter={() => void handleSaveRemark()}
|
||
/>
|
||
<Button size="small" type="primary" onClick={() => void handleSaveRemark()}>
|
||
保存
|
||
</Button>
|
||
<Button size="small" onClick={() => setRemarkEditing(false)}>
|
||
取消
|
||
</Button>
|
||
</Space.Compact>
|
||
) : (
|
||
<Space size={4}>
|
||
<span>{overview?.remark || '-'}</span>
|
||
<Tooltip title="编辑备注">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
onClick={() => {
|
||
setRemarkDraft(overview?.remark || '');
|
||
setRemarkEditing(true);
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
</Space>
|
||
)}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
|
||
{/* ── 卡片 2:资产与统计 ────────────────────────────
|
||
资产就**只在这里**展示一次(之前状态条里还重复了一遍)。
|
||
两组分开:上面是「现在有多少」,下面是「历史累计」。 */}
|
||
<Card size="small" style={{ marginBottom: 12 }} title="资产与统计">
|
||
<Row gutter={[24, 16]}>
|
||
<Col span={4}>
|
||
<Statistic title="当前积分" value={overview?.points ?? '-'} />
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic
|
||
title="积分到期"
|
||
value={
|
||
// 斗鱼用 0 表示「没有到期时间」;不处理会显示成 1970/1/1
|
||
overview?.points_expire_at ? formatTime(overview.points_expire_at) : '-'
|
||
}
|
||
valueStyle={{ fontSize: 18 }}
|
||
/>
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic title="鱼翅余额" value={overview?.gold_balance ?? '-'} />
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic title="兑换余额" value={overview?.exchange_balance ?? '-'} />
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic
|
||
title="精英宝典"
|
||
value={overview?.elite_opened ? '已开通' : '未开通'}
|
||
valueStyle={{
|
||
fontSize: 18,
|
||
color: overview?.elite_opened ? '#3f8600' : undefined,
|
||
}}
|
||
/>
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic
|
||
title="电竞手册"
|
||
value={overview?.esports_opened ? '已开通' : '未开通'}
|
||
valueStyle={{ fontSize: 18 }}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Divider style={{ margin: '16px 0 12px' }} />
|
||
|
||
<Row gutter={[24, 16]}>
|
||
<Col span={4}>
|
||
<Statistic title="累计充值" value={stats?.recharge_total ?? 0} prefix="¥" />
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic title="兑换次数" value={stats?.exchange_count ?? 0} />
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic title="累计消耗积分" value={stats?.points_spent ?? 0} />
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic title="CK 失效次数" value={stats?.ck_invalid_count ?? 0} />
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic title="登录成功次数" value={stats?.login_count ?? 0} />
|
||
</Col>
|
||
<Col span={4}>
|
||
<Statistic title="事件总数" value={stats?.event_count ?? 0} />
|
||
</Col>
|
||
</Row>
|
||
|
||
<Text type="secondary" style={{ display: 'block', marginTop: 12, fontSize: 12 }}>
|
||
最近登录 {formatTime(stats?.last_login_at)} · 精英开通{' '}
|
||
{formatTime(overview?.opened_at?.elite)} · 电竞开通{' '}
|
||
{formatTime(overview?.opened_at?.esports)}
|
||
<br />
|
||
开通时间与累计统计来自「账号事件」,从该功能上线起记录;
|
||
更早的历史请到「登录任务 / 精英宝典 / 电竞手册 / 和平小店」页查看。
|
||
</Text>
|
||
</Card>
|
||
|
||
{/* ── 卡片 3:一键查询结果 ──────────────────────────
|
||
放在账号信息**下面** —— 它是补充信息(账号表里没有的两项),
|
||
不该压在最上面抢主视觉。 */}
|
||
<Card
|
||
size="small"
|
||
style={{ marginBottom: 12 }}
|
||
title={
|
||
<Space>
|
||
<ThunderboltOutlined />
|
||
<span>高价值套装与兑换记录</span>
|
||
{overview?.overview_queried_at && (
|
||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal' }}>
|
||
查询于 {formatTime(overview.overview_queried_at)}
|
||
</Text>
|
||
)}
|
||
</Space>
|
||
}
|
||
extra={
|
||
<Button
|
||
icon={<ThunderboltOutlined />}
|
||
loading={ovLoading}
|
||
onClick={runOverview}
|
||
>
|
||
一键查询
|
||
</Button>
|
||
}
|
||
>
|
||
{ovError && (
|
||
<Text type="danger" style={{ display: 'block', marginBottom: 8 }}>
|
||
{ovError}
|
||
</Text>
|
||
)}
|
||
{overview?.overview_snapshot ? (
|
||
<AccountOverviewPanel result={overview.overview_snapshot} />
|
||
) : (
|
||
<Text type="secondary">
|
||
点右上角「一键查询」实时向斗鱼查一次:鱼翅 / 积分 / 开通状态会就地刷新到上面的
|
||
「资产与统计」,这里只展示账号表里没有的两项 ——
|
||
高价值套装是否已获得,以及官方兑换记录。
|
||
</Text>
|
||
)}
|
||
</Card>
|
||
</Spin>
|
||
|
||
|
||
<Card
|
||
title="账号时间线"
|
||
extra={
|
||
<Space>
|
||
<Select
|
||
size="small"
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
placeholder="全部分类"
|
||
value={category || undefined}
|
||
onChange={(v) => { setCategory(v || ''); setPage(1); }}
|
||
options={CATEGORY_OPTIONS}
|
||
/>
|
||
<Select
|
||
size="small"
|
||
style={{ width: 140 }}
|
||
value={eventType}
|
||
onChange={(v) => { setEventType(v); setPage(1); }}
|
||
options={[
|
||
{ value: '', label: '全部事件' },
|
||
...Object.entries(EVENT_STYLE).map(([value, s]) => ({ value, label: s.label })),
|
||
]}
|
||
/>
|
||
<Tooltip title="从上线起累积,不回填历史">
|
||
<Text type="secondary" style={{ fontSize: 12 }}>共 {eventTotal} 条</Text>
|
||
</Tooltip>
|
||
</Space>
|
||
}
|
||
>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={events}
|
||
rowKey="id"
|
||
size="small"
|
||
loading={eventsLoading}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: eventTotal,
|
||
size: 'small',
|
||
showSizeChanger: true,
|
||
pageSizeOptions: ['20', '50', '100'],
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
onChange: (p, s) => { setPage(p); setPageSize(s); },
|
||
}}
|
||
locale={{ emptyText: <Empty description="暂无事件(功能上线后开始记录)" /> }}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|