refactor(ui): 账号列表页重做 —— 状态分布当筛选入口,砍掉重复与噪声
这页还是 S6 之前的老壳子:4 张统计卡有 3 张是装饰、状态与 CK 两列在说同一件事、
ID 单独占一列、绑定列 12 行全是「精? 电? 店?」。
## 统计卡 → 生命周期分布(可点击筛选)
原来:账号总数 / **标签数** / **已分配** / **未分配**。
标签数在筛选里就能看;已分配与未分配是一对冗余;而且它们都回答不了
「我该干什么」。现在按**生命周期状态**分布:总数 / 正常 / CK失效 / 待人工 /
未登录 / 已停用,**点一下就是「只看这些」**(`statusFilter`),计数为 0 的自动变淡。
后端 `accounts_summary` 相应扩出 `by_status` / `visible_total` /
`ck_invalid_count` / `no_cookie_count`。口径与列表一致:**归档不计入**。
## 表格列收敛
| 改动 | 理由 |
| --- | --- |
| **ID 列 → 并入「账号」小字** | 客服不看 ID,但报障时还要念得出来;合并后省一列宽度 |
| **CK 列 → 并入「状态」副行** | 生命周期状态里的「CK 失效」就是 CK 的结论,两列在说同一件事。合并后主标签给结论、副行小字给细节(检测时间 / `error` 这种「状态正常但没查明白」) |
| **绑定列:三个都 unknown 时显示「—」** | 满屏问号只是噪声,不如一眼看出「还没查过绑定」 |
| **「上传时间」→「导入时间」,精确到日** | 名字一直叫错;列表里只关心「哪一批」,时分秒是噪声(精确值挪进 tooltip) |
| **去掉「按状态筛选」下拉** | 上面卡片就是筛选入口,两处并存是重复(`STATUS_OPTIONS` 随之删除) |
## 验证
- `TZ=Asia/Shanghai`:**500 passed / 6 skipped**(+3 条 summary 用例)
- `TZ=UTC`(相关文件)82 passed;ruff 全绿
- 前端 build 通过、`npm run lint` **0 errors**
- 真实开发库:`by_status = {active: 4, imported: 8}`、`ck_invalid_count=0`、
`no_cookie_count=8`、`visible_total=12`(与库内实际一致)
未动(待确认):「显示凭据」开关与密码/邮箱/邮箱密码三列 —— 这是老「卖号」视图的残留,
默认关闭所以不占地方,但不确定现在的重登/检测流程还需不需要人工读凭据。
This commit is contained in:
@@ -807,3 +807,50 @@ class TestListViewKeyColumns(_Base):
|
|||||||
# unknown 会把没绑过的也算进来(默认值就是 unknown)
|
# unknown 会把没绑过的也算进来(默认值就是 unknown)
|
||||||
unknown = {i.username for i in self._items(bind_status="unknown")}
|
unknown = {i.username for i in self._items(bind_status="unknown")}
|
||||||
assert "all-unknown" in unknown
|
assert "all-unknown" in unknown
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummaryByStatus(_Base):
|
||||||
|
"""账号列表顶部的卡片是**快捷筛选入口**,不是统计装饰。
|
||||||
|
|
||||||
|
所以它必须按**生命周期状态**分布 —— 「有几个要处理」才可行动,
|
||||||
|
原来的「标签数 / 已分配 / 未分配」看了也不知道该干什么。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _summary(self):
|
||||||
|
from web.backend.routers.accounts import accounts_summary
|
||||||
|
|
||||||
|
return accounts_summary(db=self.session, current=self.user)
|
||||||
|
|
||||||
|
def test_distribution_counts_each_status(self):
|
||||||
|
self.make_account("act", cookie="ck", ck_check_status="valid")
|
||||||
|
self.make_account("imp")
|
||||||
|
invalid = self.make_account("bad", cookie="ck", ck_check_status="invalid")
|
||||||
|
off = self.make_account("off", cookie="ck", ck_check_status="valid")
|
||||||
|
life.set_manual_state(self.session, off, life.STATUS_DISABLED)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
summary = self._summary()
|
||||||
|
by = summary["by_status"]
|
||||||
|
# 库里可能有其它用例留下的账号,所以按「至少」断言
|
||||||
|
assert by.get("active", 0) >= 1
|
||||||
|
assert by.get("imported", 0) >= 1
|
||||||
|
assert by.get("invalid", 0) >= 1
|
||||||
|
assert by.get("disabled", 0) >= 1
|
||||||
|
assert invalid.id # 用掉变量
|
||||||
|
|
||||||
|
def test_archived_excluded_from_distribution(self):
|
||||||
|
"""归档默认不出现在列表里,卡片口径必须与列表一致。"""
|
||||||
|
arch = self.make_account("arch", cookie="ck", ck_check_status="valid")
|
||||||
|
life.set_manual_state(self.session, arch, life.STATUS_ARCHIVED)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
summary = self._summary()
|
||||||
|
assert "archived" not in summary["by_status"]
|
||||||
|
assert summary["visible_total"] == sum(summary["by_status"].values())
|
||||||
|
|
||||||
|
def test_ck_invalid_and_no_cookie_counts(self):
|
||||||
|
self.make_account("bad", cookie="ck", ck_check_status="invalid")
|
||||||
|
self.make_account("nocookie")
|
||||||
|
summary = self._summary()
|
||||||
|
assert summary["ck_invalid_count"] >= 1
|
||||||
|
assert summary["no_cookie_count"] >= 1
|
||||||
|
|||||||
@@ -298,8 +298,34 @@ def accounts_summary(
|
|||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
# 按**生命周期状态**分布:这才是可行动的(「有几个要处理」比「有几个标签」有用得多)。
|
||||||
|
# 归档默认不计入(与列表默认口径一致 —— 归档就是「收起来别烦我」)。
|
||||||
|
visible = query.filter(Account.status != STATUS_ARCHIVED)
|
||||||
|
status_rows = (
|
||||||
|
visible.with_entities(Account.status, func.count(Account.id))
|
||||||
|
.group_by(Account.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_status = {str(row[0] or ""): int(row[1] or 0) for row in status_rows}
|
||||||
|
visible_total = sum(by_status.values())
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
|
"visible_total": visible_total,
|
||||||
|
# 生命周期分布(键是 6 状态的枚举值)
|
||||||
|
"by_status": by_status,
|
||||||
|
# 需要人关注的:CK 已被判失效的账号数(含待人工)
|
||||||
|
"ck_invalid_count": int(
|
||||||
|
visible.filter(Account.ck_check_status == "invalid")
|
||||||
|
.with_entities(func.count(Account.id))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
),
|
||||||
|
"no_cookie_count": int(
|
||||||
|
visible.filter(or_(Account.cookie.is_(None), Account.cookie == ""))
|
||||||
|
.with_entities(func.count(Account.id))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
),
|
||||||
"assigned_count": assigned_count,
|
"assigned_count": assigned_count,
|
||||||
"unassigned_count": max(0, total - assigned_count),
|
"unassigned_count": max(0, total - assigned_count),
|
||||||
"tag_count": tag_count,
|
"tag_count": tag_count,
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ export interface AuditLogQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BasicSummary {
|
export interface BasicSummary {
|
||||||
|
/** 生命周期状态分布(键是 6 状态枚举值);归档默认不计入 */
|
||||||
|
by_status?: Record<string, number>;
|
||||||
|
/** 不含归档的账号数 */
|
||||||
|
visible_total?: number;
|
||||||
|
/** CK 已被判失效的账号数 */
|
||||||
|
ck_invalid_count?: number;
|
||||||
|
/** 没有 CK 的账号数 */
|
||||||
|
no_cookie_count?: number;
|
||||||
total: number;
|
total: number;
|
||||||
assigned_count: number;
|
assigned_count: number;
|
||||||
unassigned_count: number;
|
unassigned_count: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
|
Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
|
||||||
Row, Col, Card, Statistic, App,
|
Row, Col, Card, Statistic, App, Tooltip,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { message } from '../utils/antdMessage';
|
import { message } from '../utils/antdMessage';
|
||||||
import type { TableProps } from 'antd';
|
import type { TableProps } from 'antd';
|
||||||
@@ -48,16 +48,16 @@ const BIND_OPTIONS = [
|
|||||||
{ value: 'unknown', label: '绑定未知' },
|
{ value: 'unknown', label: '绑定未知' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 状态筛选项:与后端 ALL_STATES 保持一致。 */
|
/** 状态分布卡片:点击即按该状态筛选。 */
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_CARDS = [
|
||||||
{ value: 'active', label: '正常' },
|
{ value: 'active', label: '正常', color: '#52c41a' },
|
||||||
{ value: 'invalid', label: 'CK 失效' },
|
{ value: 'invalid', label: 'CK 失效', color: '#ff4d4f' },
|
||||||
{ value: 'manual', label: '待人工' },
|
{ value: 'manual', label: '待人工', color: '#fa8c16' },
|
||||||
{ value: 'imported', label: '未登录' },
|
{ value: 'imported', label: '未登录', color: '#8c8c8c' },
|
||||||
{ value: 'disabled', label: '已停用' },
|
{ value: 'disabled', label: '已停用', color: '#8c8c8c' },
|
||||||
{ value: 'archived', label: '已归档' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
/** 单个作用域的绑定小标签:精/电/店。 */
|
/** 单个作用域的绑定小标签:精/电/店。 */
|
||||||
function BindChip({ scope, status }: { scope: string; status?: string }) {
|
function BindChip({ scope, status }: { scope: string; status?: string }) {
|
||||||
const tone = status === 'bound' ? 'green'
|
const tone = status === 'bound' ? 'green'
|
||||||
@@ -310,74 +310,79 @@ export default function AccountsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: TableProps<AccountItem>['columns'] = [
|
const columns: TableProps<AccountItem>['columns'] = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
|
||||||
{
|
{
|
||||||
title: '用户名',
|
// ID 不再单独占一列(客服不看),降到用户名下面做小字 —— 报障时还能念出来。
|
||||||
|
title: '账号',
|
||||||
dataIndex: 'username',
|
dataIndex: 'username',
|
||||||
// 点进账号详情:那里有状态卡、累计统计与时间线
|
|
||||||
render: (username: string, record: AccountItem) => (
|
render: (username: string, record: AccountItem) => (
|
||||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/accounts/${record.id}`)}>
|
<Space direction="vertical" size={0}>
|
||||||
{username}
|
<Button
|
||||||
</Button>
|
type="link"
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
onClick={() => navigate(`/accounts/${record.id}`)}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</Button>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>#{record.id}</Text>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 「状态」与原来的「CK」两列是重复的 —— 生命周期状态里的 `CK 失效` 就是 CK 的结论。
|
||||||
|
// 合并成一列:主标签是生命周期状态,副行小字补 CK 的细节
|
||||||
|
// (检测时间、以及 `error` 这种「状态正常但检测没查明白」的情况)。
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 110,
|
width: 150,
|
||||||
render: (status: string, record: AccountItem) => {
|
render: (status: string, record: AccountItem) => {
|
||||||
const tone = STATUS_TONES[status] ?? 'default';
|
const tone = STATUS_TONES[status] ?? 'default';
|
||||||
|
const ckNote = !record.has_cookie
|
||||||
|
? '无 CK'
|
||||||
|
: record.ck_check_status === 'error'
|
||||||
|
? '检测异常'
|
||||||
|
: record.ck_checked_at
|
||||||
|
? `检测 ${formatTime(record.ck_checked_at)}`
|
||||||
|
: '未检测';
|
||||||
return (
|
return (
|
||||||
<Space size={4}>
|
<Space direction="vertical" size={0}>
|
||||||
<Tag color={tone}>{record.status_label || status || '-'}</Tag>
|
<Space size={4}>
|
||||||
{record.auto_managed === false && <Tag color="orange">不自动</Tag>}
|
<Tag color={tone} style={{ marginInlineEnd: 0 }}>
|
||||||
|
{record.status_label || status || '-'}
|
||||||
|
</Tag>
|
||||||
|
{record.auto_managed === false && <Tag color="orange" style={{ marginInlineEnd: 0 }}>不自动</Tag>}
|
||||||
|
</Space>
|
||||||
|
<Text
|
||||||
|
type={status === 'active' && record.ck_check_status === 'error' ? 'warning' : 'secondary'}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{ckNote}
|
||||||
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// 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: '绑定',
|
title: '绑定',
|
||||||
dataIndex: 'role_bind_status',
|
dataIndex: 'role_bind_status',
|
||||||
width: 190,
|
width: 190,
|
||||||
render: (_, record: AccountItem) => (
|
render: (_, record: AccountItem) => {
|
||||||
<Space size={2} wrap>
|
// 三个作用域全都没查过时,显示「—」而不是「精? 电? 店?」——
|
||||||
<BindChip scope="精" status={record.role_bind_status} />
|
// 满屏问号只是噪声,不如一眼看出「这账号还没查过绑定」。
|
||||||
<BindChip scope="电" status={record.esports_bind_status} />
|
const allUnknown = [record.role_bind_status, record.esports_bind_status, record.xpd_bind_status]
|
||||||
<BindChip scope="店" status={record.xpd_bind_status} />
|
.every((s) => !s || s === 'unknown');
|
||||||
</Space>
|
if (allUnknown) {
|
||||||
),
|
return <Tooltip title="还没有查询过绑定"><Text type="secondary">—</Text></Tooltip>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<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: '积分 / 鱼翅',
|
title: '积分 / 鱼翅',
|
||||||
@@ -419,10 +424,13 @@ export default function AccountsPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '上传时间',
|
title: '导入时间',
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
width: 180,
|
width: 110,
|
||||||
render: (val: string | null) => val ? formatTime(val) : <Text type="secondary">-</Text>,
|
// 列表里只关心「哪一批导入的」,时分秒是噪声(需要精确时间看详情页)
|
||||||
|
render: (val: string | null) => val
|
||||||
|
? <Tooltip title={formatTime(val)}>{formatTime(val).slice(0, 10)}</Tooltip>
|
||||||
|
: <Text type="secondary">-</Text>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -485,18 +493,6 @@ export default function AccountsPage() {
|
|||||||
options={tags.map((t) => ({ value: t, label: t }))}
|
options={tags.map((t) => ({ value: t, label: t }))}
|
||||||
prefix={<FilterOutlined />}
|
prefix={<FilterOutlined />}
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
size="small"
|
|
||||||
placeholder="按状态筛选"
|
|
||||||
style={{ width: 130 }}
|
|
||||||
value={statusFilter || undefined}
|
|
||||||
onChange={(val) => {
|
|
||||||
setStatusFilter(val || '');
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
options={STATUS_OPTIONS}
|
|
||||||
/>
|
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
size="small"
|
size="small"
|
||||||
@@ -572,29 +568,46 @@ export default function AccountsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
{/* 状态分布卡片:这不是「统计」,是**快捷筛选入口** —— 点一下就是「只看这些」。
|
||||||
<Col span={6}>
|
比原来的「标签数 / 已分配 / 未分配」有用得多:客服要的是「有几个要处理」。 */}
|
||||||
<Card size="small"><Statistic title="账号总数" value={summary.total} /></Card>
|
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||||
</Col>
|
<Col flex="120px">
|
||||||
<Col span={6}>
|
<Card
|
||||||
<Card size="small"><Statistic title="标签数" value={summary.tag_count || tags.length} /></Card>
|
size="small"
|
||||||
</Col>
|
hoverable
|
||||||
<Col span={6}>
|
onClick={() => { setStatusFilter(''); setCkFilter(''); setCurrentPage(1); }}
|
||||||
<Card size="small">
|
style={{ borderColor: statusFilter || ckFilter ? undefined : '#1677ff' }}
|
||||||
<Statistic
|
>
|
||||||
title="已分配"
|
<Statistic title="账号总数" value={summary.visible_total ?? summary.total} />
|
||||||
value={summary.assigned_count}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<Card size="small">
|
|
||||||
<Statistic
|
|
||||||
title="未分配"
|
|
||||||
value={summary.unassigned_count}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
{STATUS_CARDS.map((card) => {
|
||||||
|
const count = summary.by_status?.[card.value] ?? 0;
|
||||||
|
const active = statusFilter === card.value;
|
||||||
|
return (
|
||||||
|
<Col flex="120px" key={card.value}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
hoverable
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(active ? '' : card.value);
|
||||||
|
setCkFilter('');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
borderColor: active ? card.color : undefined,
|
||||||
|
opacity: count === 0 && !active ? 0.55 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={card.label}
|
||||||
|
value={count}
|
||||||
|
valueStyle={{ color: count > 0 ? card.color : undefined, fontSize: 20 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
Reference in New Issue
Block a user