From 8a8c637db0da708dda39e5efbfcede65409d029f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 16:55:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20=E8=B4=A6=E5=8F=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=A1=B5=E9=87=8D=E5=81=9A=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=88=86=E5=B8=83=E5=BD=93=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=8C=E7=A0=8D=E6=8E=89=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E4=B8=8E=E5=99=AA=E5=A3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这页还是 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`(与库内实际一致) 未动(待确认):「显示凭据」开关与密码/邮箱/邮箱密码三列 —— 这是老「卖号」视图的残留, 默认关闭所以不占地方,但不确定现在的重登/检测流程还需不需要人工读凭据。 --- tests/test_account_lifecycle.py | 47 ++++++ web/backend/routers/accounts.py | 26 ++++ web/frontend/src/api/types.ts | 8 + web/frontend/src/pages/AccountsPage.tsx | 199 +++++++++++++----------- 4 files changed, 187 insertions(+), 93 deletions(-) diff --git a/tests/test_account_lifecycle.py b/tests/test_account_lifecycle.py index b99987b..a12e298 100644 --- a/tests/test_account_lifecycle.py +++ b/tests/test_account_lifecycle.py @@ -807,3 +807,50 @@ class TestListViewKeyColumns(_Base): # unknown 会把没绑过的也算进来(默认值就是 unknown) unknown = {i.username for i in self._items(bind_status="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 diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index f621029..db3f86b 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -298,8 +298,34 @@ def accounts_summary( .scalar() 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 { "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, "unassigned_count": max(0, total - assigned_count), "tag_count": tag_count, diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index c844cdc..234fdeb 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -65,6 +65,14 @@ export interface AuditLogQuery { } export interface BasicSummary { + /** 生命周期状态分布(键是 6 状态枚举值);归档默认不计入 */ + by_status?: Record; + /** 不含归档的账号数 */ + visible_total?: number; + /** CK 已被判失效的账号数 */ + ck_invalid_count?: number; + /** 没有 CK 的账号数 */ + no_cookie_count?: number; total: number; assigned_count: number; unassigned_count: number; diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 1bf73c0..2842ac6 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space, - Row, Col, Card, Statistic, App, + Row, Col, Card, Statistic, App, Tooltip, } from 'antd'; import { message } from '../utils/antdMessage'; import type { TableProps } from 'antd'; @@ -48,16 +48,16 @@ const BIND_OPTIONS = [ { value: 'unknown', label: '绑定未知' }, ]; -/** 状态筛选项:与后端 ALL_STATES 保持一致。 */ -const STATUS_OPTIONS = [ - { value: 'active', label: '正常' }, - { value: 'invalid', label: 'CK 失效' }, - { value: 'manual', label: '待人工' }, - { value: 'imported', label: '未登录' }, - { value: 'disabled', label: '已停用' }, - { value: 'archived', label: '已归档' }, +/** 状态分布卡片:点击即按该状态筛选。 */ +const STATUS_CARDS = [ + { value: 'active', label: '正常', color: '#52c41a' }, + { value: 'invalid', label: 'CK 失效', color: '#ff4d4f' }, + { value: 'manual', label: '待人工', color: '#fa8c16' }, + { value: 'imported', label: '未登录', color: '#8c8c8c' }, + { value: 'disabled', label: '已停用', color: '#8c8c8c' }, ]; + /** 单个作用域的绑定小标签:精/电/店。 */ function BindChip({ scope, status }: { scope: string; status?: string }) { const tone = status === 'bound' ? 'green' @@ -310,74 +310,79 @@ export default function AccountsPage() { }; const columns: TableProps['columns'] = [ - { title: 'ID', dataIndex: 'id', width: 60 }, { - title: '用户名', + // ID 不再单独占一列(客服不看),降到用户名下面做小字 —— 报障时还能念出来。 + title: '账号', dataIndex: 'username', - // 点进账号详情:那里有状态卡、累计统计与时间线 render: (username: string, record: AccountItem) => ( - + + + #{record.id} + ), }, { + // 「状态」与原来的「CK」两列是重复的 —— 生命周期状态里的 `CK 失效` 就是 CK 的结论。 + // 合并成一列:主标签是生命周期状态,副行小字补 CK 的细节 + // (检测时间、以及 `error` 这种「状态正常但检测没查明白」的情况)。 title: '状态', dataIndex: 'status', - width: 110, + width: 150, render: (status: string, record: AccountItem) => { 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 ( - - {record.status_label || status || '-'} - {record.auto_managed === false && 不自动} + + + + {record.status_label || status || '-'} + + {record.auto_managed === false && 不自动} + + + {ckNote} + ); }, }, - { - // CK 健康:客服排查的第一入口。「没有 CK」和「CK 失效」是两种完全不同的处境。 - title: 'CK', - dataIndex: 'ck_check_status', - width: 140, - render: (_, record: AccountItem) => { - if (!record.has_cookie) return 无 CK; - if (record.ck_check_status === 'invalid') { - return ( - - 已失效 - - {formatTime(record.ck_checked_at)} - - - ); - } - if (record.ck_check_status === 'error') return 检测异常; - if (record.ck_check_status === 'valid') { - return ( - - 正常 - - {formatTime(record.ck_checked_at)} - - - ); - } - return 未检测; - }, - }, { // 三角色绑定:三个作用域各一列,合成一格紧凑展示 title: '绑定', dataIndex: 'role_bind_status', width: 190, - render: (_, record: AccountItem) => ( - - - - - - ), + render: (_, record: AccountItem) => { + // 三个作用域全都没查过时,显示「—」而不是「精? 电? 店?」—— + // 满屏问号只是噪声,不如一眼看出「这账号还没查过绑定」。 + const allUnknown = [record.role_bind_status, record.esports_bind_status, record.xpd_bind_status] + .every((s) => !s || s === 'unknown'); + if (allUnknown) { + return —; + } + return ( + + + + + + ); + }, }, { title: '积分 / 鱼翅', @@ -419,10 +424,13 @@ export default function AccountsPage() { }, }, { - title: '上传时间', + title: '导入时间', dataIndex: 'created_at', - width: 180, - render: (val: string | null) => val ? formatTime(val) : -, + width: 110, + // 列表里只关心「哪一批导入的」,时分秒是噪声(需要精确时间看详情页) + render: (val: string | null) => val + ? {formatTime(val).slice(0, 10)} + : -, }, ]; @@ -485,18 +493,6 @@ export default function AccountsPage() { options={tags.map((t) => ({ value: t, label: t }))} prefix={} /> - - - - - - - - - - - - - - - - + {/* 状态分布卡片:这不是「统计」,是**快捷筛选入口** —— 点一下就是「只看这些」。 + 比原来的「标签数 / 已分配 / 未分配」有用得多:客服要的是「有几个要处理」。 */} + + + { setStatusFilter(''); setCkFilter(''); setCurrentPage(1); }} + style={{ borderColor: statusFilter || ckFilter ? undefined : '#1677ff' }} + > + + {STATUS_CARDS.map((card) => { + const count = summary.by_status?.[card.value] ?? 0; + const active = statusFilter === card.value; + return ( + + { + setStatusFilter(active ? '' : card.value); + setCkFilter(''); + setCurrentPage(1); + }} + style={{ + borderColor: active ? card.color : undefined, + opacity: count === 0 && !active ? 0.55 : 1, + }} + > + 0 ? card.color : undefined, fontSize: 20 }} + /> + + + ); + })}