feat(account): S6 账号页重构 —— 列表关键列 + 详情页生命周期状态卡

S6 的展示层部分(数据模型部分见上一个提交)。目标:客服打开账号页要能一眼回答
「这账号能不能用、要不要我处理、账上还有多少」。

## 账号列表:加关键列 + 三个筛选

新增四列,**全部直接来自 `accounts` 行**(不 JOIN、不聚合 —— 列表是全站最热接口):

| 列 | 内容 |
| --- | --- |
| **CK** | 有无 CK / 正常 / 已失效 / 检测异常 + 最近检测时间 |
| **绑定** | 精/电/店 三个作用域各一个小标签(✓ / … / ✗ / ?) |
| **积分 / 鱼翅** | 两行小字 |

新增筛选:**CK 健康**、**角色绑定**(三个作用域任一命中即算)。
`AccountOut` 相应补齐 `has_cookie` / `ck_check_status` / `ck_checked_at` /
`points` / `gold_balance` / 三组绑定状态与标签。

## 账号详情页:生命周期主页

顶部新增**生命周期状态卡**,把之前散落的信息聚成一眼可读的几格:

- **生命周期状态**:状态标签 + 不参与自动化标记 + 变更时间与原因
- **CK**:有无 / 健康 / 最近检测时间
- **角色绑定**:精/电/店三格
- **账上还有多少**:积分 / 鱼翅 / 兑换余额
- **归属与标注**:分配客服(下拉)+ 标签(就地编辑)—— 之前详情页只能看不能改

业务台账补上**登录成功次数**与**最近登录**(`stats` 里本来就有,只是没展示)。
时间线已有分类筛选,生命周期操作入口(停用/归档/恢复/自动化)S3 已加。

## ⚠️ 顺带修掉一个静默 bug

`bind_payload()` 的字段名按**作用域名**拼成了 `elite_bind_status` / `peace_bind_status`,
而 schema 声明的是 `role_bind_status` / `xpd_bind_status`。Pydantic 默认
`extra='ignore'`,于是多出来的键**被静默丢弃**、声明过的字段保持默认值 ——
**接口永远返回 unknown 而不报任何错**(这正是它躲过前面几轮测试的原因)。

修法:前缀改为由**列名**推导(`SCOPE_PAYLOAD_PREFIX`),并在模块导入时自检
「载荷前缀 == 列名前缀」。另加两条用例:`bind_payload` 的键必须都被
`DouyuTaskAccountOut` / `AccountOut` 声明(这是能抓到这类错的那种用例)。

顺带发现我自己之前那条 payload 用例**把错误的前缀当成了预期**,一并修正。

## 自己踩的坑

详情页加客服列表时又把 `useEffect` 写在了早期 `return` 之后 ——
和本次会话前面修 `LoginTasksPage` 的是同一个错。eslint(`react-hooks/rules-of-hooks`)
当场抓住,已上移。**`tsc` 与 `vite build` 都不会报这个**。

另外:列表关键列那处编辑我曾以为已生效(脚本打印了成功),实际没落到文件里 ——
后面是靠「读回文件确认」和用例失败才发现。已改为每次都读回核对。

## 验证

- `TZ=Asia/Shanghai`:**497 passed / 6 skipped**(S6 前半 490 → +7)
- `TZ=UTC`(相关文件)通过;ruff 全绿;前端 build 通过、lint **0 errors**
- **真实开发库端到端**:列表与工作台接口都返回了真实值
  (`has_cookie=True` / `ck_check_status=valid` / `role_bind_label=未知`),
  修 bug 前这两处返回的是被丢弃后的默认值

未做:`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」已可下探到 7 天档(待确认)。
This commit is contained in:
yml2213
2026-10-04 16:45:39 +08:00
parent a64380fbcc
commit 885a780bd3
10 changed files with 496 additions and 12 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ import type {
export const accountApi = {
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) =>
api.get<AccountItem[], AccountItem[]>('/accounts', { params }),
listPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean; status?: string }) =>
listPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean; status?: string; ck_status?: string; bind_status?: string }) =>
api.get<PaginatedResponse<AccountItem>, PaginatedResponse<AccountItem>>('/accounts', { params }),
summary: () => api.get<BasicSummary, BasicSummary>('/accounts/summary'),
import: (text: string, tag?: string) =>
+15
View File
@@ -141,6 +141,9 @@ export interface AccountOverview {
status_reason: string;
status_changed_at: string | null;
auto_managed: boolean;
has_cookie: boolean;
ck_check_status: string;
ck_checked_at: string | null;
points: number | null;
gold_balance: number | null;
exchange_balance: number | null;
@@ -246,6 +249,18 @@ export interface AccountItem {
status_label: string;
status_reason: string;
auto_managed: boolean;
// 列表页关键列(都直接来自 accounts 行)
has_cookie: boolean;
ck_check_status: string;
ck_checked_at: string | null;
points: number | null;
gold_balance: number | null;
role_bind_status: BindStatus;
role_bind_label: string;
esports_bind_status: BindStatus;
esports_bind_label: string;
xpd_bind_status: BindStatus;
xpd_bind_label: string;
}
export interface AssignmentsSummary {
+147 -1
View File
@@ -7,7 +7,7 @@ import type { TableProps } from 'antd';
import {
ArrowLeftOutlined, EditOutlined, ReloadOutlined,
} from '@ant-design/icons';
import { accountApi, type AccountEventItem, type AccountOverview } from '../api/modules';
import { accountApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
@@ -55,6 +55,19 @@ const STATUS_TONES: Record<string, string> = {
archived: 'default',
};
/** 单个作用域的绑定小标签:精/电/店。 */
function BindChip({ scope, status }: { scope: string; status?: string }) {
const tone = status === 'bound' ? 'green'
: status === 'pending' ? 'orange'
: status === 'unbound' ? 'red'
: 'default';
const mark = status === 'bound' ? '✓'
: status === 'pending' ? '…'
: status === 'unbound' ? '✗'
: '?';
return <Tag color={tone} style={{ marginInlineEnd: 0 }}>{scope}{mark}</Tag>;
}
/** 绑定状态 → 展示色(与后端 account_binding 的四个枚举对应)。 */
const BIND_TONES: Record<string, string> = {
bound: 'green',
@@ -99,9 +112,27 @@ export default function AccountDetailPage() {
const [eventType, setEventType] = useState('');
const [category, setCategory] = useState('');
const [eventsLoading, setEventsLoading] = useState(false);
const [tagDraft, setTagDraft] = 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; };
}, []);
const loadOverview = useCallback(async () => {
if (!accountId) return;
setLoading(true);
@@ -193,6 +224,31 @@ export default function AccountDetailPage() {
};
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;
@@ -263,6 +319,94 @@ export default function AccountDetailPage() {
</Space>
<Spin spinning={loading}>
{/* ── 生命周期状态卡 ────────────────────────────────
这一屏要回答客服最关心的三个问题:
1. 这账号现在能不能用?(状态 + CK)
2. 要不要我处理?(原因 + 变更时间 + 自动化是否在管)
3. 账上还有多少?(积分 / 鱼翅 / 兑换余额) */}
<Card size="small" style={{ marginBottom: 12 }}>
<Space size={24} wrap align="start">
<Space direction="vertical" size={4}>
<Text type="secondary">生命周期状态</Text>
<Space size={6}>
<Tag color={STATUS_TONES[status] ?? 'default'} style={{ fontSize: 14, padding: '2px 8px' }}>
{overview?.status_label || '—'}
</Tag>
{overview?.auto_managed === false && <Tag color="orange">不参与自动化</Tag>}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(overview?.status_changed_at)}
{overview?.status_reason ? `(${overview.status_reason})` : ''}
</Text>
</Space>
<Space direction="vertical" size={4}>
<Text type="secondary">CK</Text>
{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(overview?.stats?.last_ck_check_at)}
</Text>
</Space>
<Space direction="vertical" size={4}>
<Text type="secondary">角色绑定</Text>
<Space size={2} wrap>
<BindChip scope="精" status={overview?.role_bind_status} />
<BindChip scope="电" status={overview?.esports_bind_status} />
<BindChip scope="店" status={overview?.xpd_bind_status} />
</Space>
</Space>
<Statistic title="积分" value={overview?.points ?? '-'} />
<Statistic title="鱼翅" value={overview?.gold_balance ?? '-'} />
<Statistic title="兑换余额" value={overview?.exchange_balance ?? '-'} />
{/* 人工操作:与工具栏的生命周期动作分开 —— 那些是「账号可用性」,
这些是「归属与标注」。 */}
<Space direction="vertical" size={4}>
<Text type="secondary">归属与标注</Text>
<Space size={6} wrap>
<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 }))}
/>
{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>
) : (
<Button
size="small"
icon={<EditOutlined />}
onClick={() => { setTagDraft(overview?.tag || ''); setTagEditing(true); }}
>
{overview?.tag || '设置标签'}
</Button>
)}
</Space>
</Space>
</Space>
</Card>
<Card
title={
<Space>
@@ -374,6 +518,8 @@ export default function AccountDetailPage() {
<Statistic title="兑换次数" value={stats?.exchange_count ?? 0} />
<Statistic title="累计消耗积分" value={stats?.points_spent ?? 0} />
<Statistic title="CK 失效次数" value={stats?.ck_invalid_count ?? 0} />
<Statistic title="登录成功次数" value={stats?.login_count ?? 0} />
<Statistic title="最近登录" value={formatTime(stats?.last_login_at)} />
<Statistic title="事件总数" value={stats?.event_count ?? 0} />
</div>
+107 -2
View File
@@ -33,6 +33,21 @@ const STATUS_TONES: Record<string, string> = {
archived: 'default',
};
/** CK 健康筛选项:客服最常用的排查入口(「哪些 CK 已经失效了」)。 */
const CK_OPTIONS = [
{ value: 'invalid', label: 'CK 失效' },
{ value: 'valid', label: 'CK 正常' },
{ value: 'error', label: '检测异常' },
];
/** 绑定筛选项:三个作用域任一命中即算。 */
const BIND_OPTIONS = [
{ value: 'bound', label: '已绑定' },
{ value: 'pending', label: '待确认' },
{ value: 'unbound', label: '未绑定' },
{ value: 'unknown', label: '绑定未知' },
];
/** 状态筛选项:与后端 ALL_STATES 保持一致。 */
const STATUS_OPTIONS = [
{ value: 'active', label: '正常' },
@@ -43,6 +58,19 @@ const STATUS_OPTIONS = [
{ value: 'archived', label: '已归档' },
];
/** 单个作用域的绑定小标签:精/电/店。 */
function BindChip({ scope, status }: { scope: string; status?: string }) {
const tone = status === 'bound' ? 'green'
: status === 'pending' ? 'orange'
: status === 'unbound' ? 'red'
: 'default';
const mark = status === 'bound' ? '✓'
: status === 'pending' ? '…'
: status === 'unbound' ? '✗'
: '?';
return <Tag color={tone} style={{ marginInlineEnd: 0 }}>{scope}{mark}</Tag>;
}
export default function AccountsPage() {
const navigate = useNavigate();
// App.useApp() 的 modal 实例继承主题,静态 Modal.confirm 在暗色模式下是白底
@@ -57,6 +85,8 @@ export default function AccountsPage() {
const [importing, setImporting] = useState(false);
const [tagFilter, setTagFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [ckFilter, setCkFilter] = useState<string>('');
const [bindFilter, setBindFilter] = useState<string>('');
const [searchText, setSearchText] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
@@ -82,9 +112,11 @@ export default function AccountsPage() {
const loadAccounts = useCallback(async () => {
setLoading(true);
try {
const params: { tag?: string; status?: string } = {};
const params: { tag?: string; status?: string; ck_status?: string; bind_status?: string } = {};
if (tagFilter) params.tag = tagFilter;
if (statusFilter) params.status = statusFilter;
if (ckFilter) params.ck_status = ckFilter;
if (bindFilter) params.bind_status = bindFilter;
const data = await accountApi.listPaged({
...params,
page: currentPage,
@@ -99,7 +131,7 @@ export default function AccountsPage() {
} finally {
setLoading(false);
}
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter, statusFilter]);
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter, statusFilter, ckFilter, bindFilter]);
const loadSummary = useCallback(async () => {
try {
@@ -303,6 +335,61 @@ export default function AccountsPage() {
);
},
},
{
// CK 健康:客服排查的第一入口。「没有 CK」和「CK 失效」是两种完全不同的处境。
title: 'CK',
dataIndex: 'ck_check_status',
width: 140,
render: (_, record: AccountItem) => {
if (!record.has_cookie) return <Tag>无 CK</Tag>;
if (record.ck_check_status === 'invalid') {
return (
<Space direction="vertical" size={0}>
<Tag color="red">已失效</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(record.ck_checked_at)}
</Text>
</Space>
);
}
if (record.ck_check_status === 'error') return <Tag color="orange">检测异常</Tag>;
if (record.ck_check_status === 'valid') {
return (
<Space direction="vertical" size={0}>
<Tag color="green">正常</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(record.ck_checked_at)}
</Text>
</Space>
);
}
return <Tag>未检测</Tag>;
},
},
{
// 三角色绑定:三个作用域各一列,合成一格紧凑展示
title: '绑定',
dataIndex: 'role_bind_status',
width: 190,
render: (_, record: AccountItem) => (
<Space size={2} wrap>
<BindChip scope="精" status={record.role_bind_status} />
<BindChip scope="电" status={record.esports_bind_status} />
<BindChip scope="店" status={record.xpd_bind_status} />
</Space>
),
},
{
title: '积分 / 鱼翅',
dataIndex: 'points',
width: 110,
render: (_, record: AccountItem) => (
<Space direction="vertical" size={0}>
<Text>积分 {record.points ?? '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>鱼翅 {record.gold_balance ?? '-'}</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
@@ -410,6 +497,24 @@ export default function AccountsPage() {
}}
options={STATUS_OPTIONS}
/>
<Select
allowClear
size="small"
placeholder="按 CK 筛选"
style={{ width: 120 }}
value={ckFilter || undefined}
onChange={(val) => { setCkFilter(val || ''); setCurrentPage(1); }}
options={CK_OPTIONS}
/>
<Select
allowClear
size="small"
placeholder="按绑定筛选"
style={{ width: 120 }}
value={bindFilter || undefined}
onChange={(val) => { setBindFilter(val || ''); setCurrentPage(1); }}
options={BIND_OPTIONS}
/>
<Input.Search
allowClear
size="small"