Files
live-hub-py/web/frontend/src/pages/AccountDetailPage.tsx
T
yml2213 578e259eea fix(douyu): 加固高价值三态迁移与状态口径
修复迁移中断重跑、旧快照回填覆盖、降级将 unknown 误写成可兑换,以及迁移前 CK 已失效账号未作废旧结论的问题。统一 CK error 的展示与筛选口径,补齐旧快照迁移和兑换兜底反向用例,并同步高价值结论时间语义。同时纳入 .gitignore 更新。
2026-11-06 13:25:45 +08:00

767 lines
28 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 HighValueAssetState, type HighValueStatus, type UserInfo } from '../api/modules';
import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime, formatUnixSeconds } 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 highValueShortTag(label: string, status: HighValueAssetState) {
if (status === 'owned') return <Tag color="orange">{label}已达上限</Tag>;
if (status === 'available') return <Tag color="green">{label}可换</Tag>;
return <Tag>{label}待确认</Tag>;
}
function CurrentHighValue({ value }: { value: HighValueStatus }) {
if (value.state === 'never') {
return <Text type="secondary">尚未形成高价值准入结论</Text>;
}
if (value.state === 'stale') {
return (
<Tooltip title={`上次检查:${formatTime(value.checked_at)}`}>
<Tag>官方状态已过期,需要重新查询</Tag>
</Tooltip>
);
}
return (
<Tooltip title={`官方 storehome 检查于 ${formatTime(value.checked_at)}`}>
<Space wrap size={4}>
{highValueShortTag('白日梦', value.bairimeng)}
{highValueShortTag('萌兔', value.mengtu)}
</Space>
</Tooltip>
);
}
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。
全 unknown 时把「没查过」和「查了推不出」分开说 ——
混成一个"未知"会让人不知道下一步该干什么。 */}
{[
overview?.role_bind_status,
overview?.esports_bind_status,
overview?.xpd_bind_status,
].every((s) => !s || s === 'unknown') ? (
<Text type="secondary">
{overview?.overview_queried_at ? (
<>
未能判定
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
已查询过,但斗鱼这次没给出绑定结论
</Text>
</>
) : (
<>
未查询
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
点上方「一键查询」即可查出来
</Text>
</>
)}
</Text>
) : (
<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,
}}
/>
{/* 已开通才显示时间。开通时间来自事件(**上线后才有**);
更早的历史查不到 —— 官方兑换记录里只有商品兑换,没有开通记录。 */}
{overview?.elite_opened && (
<Text type="secondary" style={{ fontSize: 12 }}>
开通 {formatTime(overview?.opened_at?.elite)}
<br />
到期 {formatUnixSeconds(overview?.elite_expire_time)}
</Text>
)}
</Col>
<Col span={4}>
<Statistic
title="电竞手册"
value={overview?.esports_opened ? '已开通' : '未开通'}
valueStyle={{ fontSize: 18 }}
/>
{overview?.esports_opened && (
<Text type="secondary" style={{ fontSize: 12 }}>
开通 {formatTime(overview?.opened_at?.esports)}
<br />
到期 {formatUnixSeconds(overview?.esports_expire_time)}
</Text>
)}
</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)}。
开通时间与累计统计来自「账号事件」,从该功能上线起记录;
更早的开通历史**查不到**(官方兑换记录里只有商品兑换,没有开通记录)。
</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?.high_value && (
<div style={{ marginBottom: 10 }}>
<Text type="secondary" style={{ marginRight: 8 }}>
当前官方准入:
</Text>
<CurrentHighValue value={overview.high_value} />
</div>
)}
{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>
);
}