Files
live-hub-py/web/frontend/src/pages/AccountDetailPage.tsx
T
yml2213 18bc7be4b1 feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)
在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条
「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。
旧路径只做并集与可选参数扩展,行为不变(有用例守着)。

- 归属与使用权分离:新增 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
2026-10-07 12:29:08 +08:00

806 lines
30 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, 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>
);
}