Files
live-hub-py/web/frontend/src/pages/AccountDetailPage.tsx
T
yml2213 f416397cc8 feat(account): 详情页重排 + 列表高价值套装列 + 兑换记录点击预览
## 详情页重排(4 张卡,消除重复)

用户反馈「设计太乱、数据不好看、布局也乱」——根因是**同一份数据在一屏出现两次**:
积分/鱼翅/兑换余额在状态条和资产区各一次,角色绑定在状态条和信息表各一次。

新布局:**账号信息**(身份+状态+归属,一张 Descriptions)→ **资产与统计**
(数字只出现一次,上面"现在有多少"、下面"历史累计")→ **高价值套装与兑换记录**
(从最顶上移到这里,它是补充信息,不该抢主视觉)→ **账号时间线**。
零散的 `flex + gap` 统计行改成 `Row/Col` 栅格,列宽对齐。

## 高价值套装:颜色按**价值**口径,别标反

用户澄清的业务语义与直觉相反:
- **没兑换过** = 还能用来绑定兑换 = **高价值**(绿色「可兑换」)
- 兑换过 1 个 → 价值降低;两个都兑换过 → 价值最低(橙色「已兑换」)

判定依据是**斗鱼官方兑换记录**(账号级)。⚠️ 不能用 `query_limited_goods` 的
`count`/`storeStatus` —— 那是**全局库存**(同一时刻不同账号看到的都一样),
用它判"这个账号有没有"必然给错结论。这个结论是推翻过一版才得到的:
第一版拿相隔两个月的两条快照对比,把**时间差异**当成了**账号差异**。

## 列表

- 新增「高价值套装」列:🟢两个都可兑换 / 🟠某套装已兑换 / ⚪未查询
  (「未查询」必须与「没兑换」分开,否则会把未知当成高价值)
- 「兑换记录」列只显示官方口径,**点击弹窗**看全部(按作用域分组,
  含商品/积分/真实兑换时间/当时用的昵称与平台),不用进详情页

## 三个作用域的记录展示

面板与预览都按 精英宝典 / 电竞手册 / 和平小店 分三块展示,
每条带**真实兑换时间**与"使用昵称"(排查时有用)。

## 顺带修

`exchange_records` 从数组改成按作用域分组的对象后,面板同步适配;
`formatTime` 对 `0` 显示 `-`(斗鱼用 0 表示"没有这个时间",
直接格式化会显示成 1970/1/1)。
2026-10-04 20:03:52 +08:00

691 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}