在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决), assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」 - account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、 供给与成本预估、租约领取/释放/回收 - 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换; 运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过) - 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还; account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次 - 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道 与租赁记录 - 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台) - 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空), 会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本 - 迁移 20261007_0052;权限 account:pool_use / account:pool_admin 测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。 设计稿与实施记录:docs/设计-账号自动分配与回收.md
806 lines
30 KiB
TypeScript
806 lines
30 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, douyuPoolApi, userApi, type AccountEventItem, type AccountOverview, type DouyuPoolLease, 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 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 [leases, setLeases] = useState<DouyuPoolLease[]>([]);
|
||
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]);
|
||
|
||
// 租赁记录:池化后「这个账号被谁租过、用完还回去没有」。
|
||
// 没有 `account:pool_use` 权限的账号会 403 —— 静默留空,不影响页面其它部分。
|
||
useEffect(() => {
|
||
if (!accountId) return;
|
||
let alive = true;
|
||
(async () => {
|
||
try {
|
||
const rows = await douyuPoolApi.leasesOfAccount(accountId);
|
||
if (alive) setLeases(rows);
|
||
} catch {
|
||
if (alive) setLeases([]);
|
||
}
|
||
})();
|
||
return () => { alive = false; };
|
||
}, [accountId]);
|
||
|
||
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,
|
||
},
|
||
{
|
||
// 池化后客服不再长期持有账号,「这件事是谁做的」必须一眼看得到。
|
||
// 历史事件(占 71%)没有操作人 —— 如实显示「-」,不要假装是系统做的。
|
||
title: '操作人 / 渠道',
|
||
width: 170,
|
||
render: (_val, record) => {
|
||
const channel = (record.detail?.channel as string | undefined) || '';
|
||
const channelLabel = channel === 'supplier_api'
|
||
? 'API 直充'
|
||
: channel === 'wechat_qr'
|
||
? '微信扫码'
|
||
: channel;
|
||
return (
|
||
<Space size={4} wrap>
|
||
{record.actor_name
|
||
? <Tag color="blue">{record.actor_name}</Tag>
|
||
: <Text type="secondary">-</Text>}
|
||
{channelLabel && <Tag>{channelLabel}</Tag>}
|
||
</Space>
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
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?.overview_snapshot ? (
|
||
<AccountOverviewPanel result={overview.overview_snapshot} />
|
||
) : (
|
||
<Text type="secondary">
|
||
点右上角「一键查询」实时向斗鱼查一次:鱼翅 / 积分 / 开通状态会就地刷新到上面的
|
||
「资产与统计」,这里只展示账号表里没有的两项 ——
|
||
高价值套装是否已获得,以及官方兑换记录。
|
||
</Text>
|
||
)}
|
||
</Card>
|
||
</Spin>
|
||
|
||
|
||
<Card
|
||
title="租赁记录"
|
||
extra={<Text type="secondary" style={{ fontSize: 12 }}>客服按需租用,用完自动收回</Text>}
|
||
>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
dataSource={leases}
|
||
pagination={false}
|
||
locale={{ emptyText: <Empty description="暂无租赁记录" /> }}
|
||
columns={[
|
||
{
|
||
title: '客服', dataIndex: 'holder_name', width: 120,
|
||
render: (value: string, row: DouyuPoolLease) => value || `#${row.holder_user_id}`,
|
||
},
|
||
{ title: '商品', dataIndex: 'commodity_id', width: 200 },
|
||
{ title: '批次', dataIndex: 'batch_id', width: 130 },
|
||
{
|
||
title: '状态', dataIndex: 'status', width: 100,
|
||
render: (value: string) => (
|
||
<Tag color={value === 'active' ? 'green' : 'default'}>{value}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '租用时间', dataIndex: 'leased_at', width: 170,
|
||
render: (value: string | null) => (value ? formatTime(value) : '-'),
|
||
},
|
||
{
|
||
title: '释放时间', dataIndex: 'expires_at', width: 170,
|
||
render: (value: string | null) => (value ? formatTime(value) : '-'),
|
||
},
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
<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>
|
||
);
|
||
}
|