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
@@ -508,7 +508,7 @@ WHERE a.cookie <> src.cookie;
| **S3** ✅ | 状态收口(**已完成 2026-10-04**):`account_lifecycle` 提供 `derive_status()` / `sync_status()` / `set_manual_state()` / `on_login_success()`;9 处判据写入点全部接线;6 状态状态机上线;自动化选行排除 `disabled`/`archived`;新增 `account:manage` 权限与停用/归档/恢复接口;前端列表加状态列与筛选、详情页加状态卡与操作入口 | `status` 可信 | S2 |
| **S4** ✅ | 事件发射点补齐(**已完成 2026-10-04**):`assigned` / `tag_changed` 接上(此前定义了却从没发过)、新增 `ck_cleared`、`ck_invalid` 改为只在转变时发;`audit_logs.account_id` 在分配/标签/检测/清 CK 处打通;新增 `emit_bulk`(批量操作逐账号写事件)与 `record_account_action`(审计+事件一次写完);时间线支持 `category` 筛选;前端补 `status_changed` / `ck_cleared` 样式与分类筛选;补上改备注接口 `PUT /api/accounts/{id}/remark`(`remark_changed` 从此有发射点)| 时间线完整 | S2 |
| **S5** ✅ | 业务事实直发事件(**已完成 2026-10-04**):新增 `services/business_events.py` 命名构造函数(`record_recharge` / `record_exchange` / `record_points_credit` / `record_bind`);9 处成功点直发(含**此前从未收录**的电竞兑换、和平小店兑换、锁单支付、电竞礼物积分);修掉「到账判据 `!=`」与「回调晚于超时」两个洞;幂等键 `douyu_task:{id}` 让直发与旧收集器/回调互相去重;**删除收集器**(含映射、水位线、测试,迁移 `0043` 清作业行);新增 `douyu_tasks` 保留策略作业(噪声 7 天 / 其余 90 天,默认关闭) | 台账可用,687MB 可控 | S4 |
| **S6** ◐ | `bind_status` 三列拆解(**已完成 2026-10-04**):新增 `services/account_binding.py`(四值枚举 + 唯一写入口 + 旧值映射)、迁移 `0044`(新增 `role_bind_status` 与 `esports_opened`、归一化三列、收窄列宽)、删除全部 29 处自由文本写入、前端改为读账号字段(不再从任务日志反推状态)、详情页显示三列绑定状态。**账号页重构(列表关键列 / 生命周期主页)尚未做**,`bind_status` 旧列待 S8 drop | 账号管理真正独立 | S3、S5 |
| **S6** ✅ | 绑定三列拆解 + 账号页重构(**已完成 2026-10-04**):①`services/account_binding.py` + 迁移 `0044`(新增 `role_bind_status` 与 `esports_opened`、归一化三列、收窄列宽、可回退),删除全部 29 处自由文本写入;②前端不再从任务日志反推账号状态,改为读账号字段;③**账号列表**加关键列(CK 健康 + 检测时间、精/电/店绑定、积分/鱼翅)与三个筛选(状态 / CK / 绑定);④**详情页做成生命周期主页**:顶部状态卡(生命周期状态 + 原因 + 变更时间 + CK + 三角色绑定 + 积分/鱼翅/兑换余额 + 归属与标注操作)、业务台账(补登录次数/最近登录)、分类时间线、生命周期操作入口。`bind_status` 旧列待 S8 drop | 账号管理真正独立 | S3、S5 |
| **S7** | 账号检测并入账号域(`account-check` 从「粘贴文本」改为「按账号选」,结果落事件) | 检测不再是孤岛 | S6 |
| **S8** | 清理:drop `login_tasks` 旧列与 `accounts` 三个 `*_bind_status` 列(**不可逆,前置:每日备份 + 反向回填脚本**) | 包袱丢干净 | S6 |
+41 -5
View File
@@ -113,17 +113,51 @@ class TestEnum(_Base):
acc = self.account()
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
payload = binding.bind_payload(acc)
assert payload["elite_bind_status"] == "bound"
assert payload["elite_bind_label"] == "已绑定"
assert payload["role_bind_status"] == "bound"
assert payload["role_bind_label"] == "已绑定"
assert payload["esports_bind_status"] == "unknown"
assert payload["peace_bind_label"] == "未知"
assert payload["xpd_bind_label"] == "未知"
assert set(payload) == {
f"{scope}_{suffix}"
f"{binding.SCOPE_PAYLOAD_PREFIX[scope]}_{suffix}"
for scope in binding.ALL_SCOPES
for suffix in ("bind_status", "bind_label")
}
class TestPayloadMatchesSchemas:
"""`bind_payload()` 的字段名必须与 API schema 声明的名字**逐字一致**。
这是被真实 bug 逼出来的:第一版按作用域名拼成了 `elite_bind_status` /
`peace_bind_status`,而 schema 声明的是 `role_bind_status` / `xpd_bind_status`。
Pydantic 拿到不认识的键会**静默用默认值**(或者在有 `**kwargs` 展开时直接报错),
表现是「接口永远返回 unknown」却没有任何报错。
"""
def test_keys_are_declared_by_schemas(self):
from web.backend.schemas import DouyuTaskAccountOut
payload = binding.bind_payload(Account())
declared = set(DouyuTaskAccountOut.model_fields)
missing = set(payload) - declared
assert not missing, f"bind_payload 产出了 schema 不认识的字段:{missing}"
def test_key_prefix_matches_column_name(self):
for scope, column in binding.SCOPE_COLUMNS.items():
prefix = binding.SCOPE_PAYLOAD_PREFIX[scope]
assert column == f"{prefix}_bind_status", (
f"{scope}: 列名 {column} 与载荷前缀 {prefix} 不一致"
)
def test_list_schema_declares_every_payload_field(self):
"""列表接口(AccountOut)也必须有这些字段,否则关键列会是空的。"""
from web.backend.schemas import AccountOut
payload = binding.bind_payload(Account())
declared = set(AccountOut.model_fields)
missing = set(payload) - declared
assert not missing, f"AccountOut 缺少绑定字段:{missing}"
class TestLegacyMapping:
"""只映射明确陈述绑定关系的取值,其余 unknown。"""
@@ -292,12 +326,14 @@ class TestNoFreeTextWriters:
root = Path(__file__).resolve().parents[1]
backend = root / "web" / "backend"
# ⚠️ 两个模式都**要求前面有个点**(属性赋值)。不带点会把关键字参数
# (`bind_status=bind_status,`)也算进来 —— 那是传参,不是写列。
patterns = (
re.compile(
r"\.(role_bind_status|esports_bind_status|xpd_bind_status)\s*=(?!=)"
),
# 旧的垃圾场列:任何人都不能再写
re.compile(r"\bbind_status\s*=(?!=)"),
re.compile(r"\.bind_status\s*=(?!=)"),
)
offenders: list[str] = []
for path in backend.rglob("*.py"):
+96
View File
@@ -711,3 +711,99 @@ class TestStatusWriteFunnel:
"请改用 sync_status() / set_manual_state()。违规点:\n"
+ "\n".join(offenders)
)
class TestListViewKeyColumns(_Base):
"""账号列表的「关键列」:客服要一眼看出「能不能用、要不要我处理」。
这些字段全部直接来自 `accounts` 行 —— 不能为了列表去 JOIN 或聚合
(列表是全站最热的接口之一)。
"""
def _items(self, **kwargs):
from web.backend.routers.accounts import list_accounts
return list_accounts(
assigned_only=False,
tag="",
has_cookie=False,
search="",
page=1,
page_size=50,
include_sensitive=False,
status=kwargs.get("status", ""),
ck_status=kwargs.get("ck_status", ""),
bind_status=kwargs.get("bind_status", ""),
db=self.session,
current=self.user,
)["items"]
def _item(self, username: str, **kwargs):
"""按用户名取自己那条 —— 库里会累积前面用例建的账号,不能假设顺序。"""
for item in self._items(**kwargs):
if item.username == username:
return item
raise AssertionError(f"列表里没有 {username}")
def test_item_carries_key_columns(self):
from web.backend.services.account_binding import (
BIND_BOUND,
SCOPE_ELITE,
set_bind_status,
)
account = self.make_account("key", cookie="ck", ck_check_status="valid")
account.points = 1234
account.gold_balance = 56
set_bind_status(account, SCOPE_ELITE, BIND_BOUND)
self.session.commit()
item = self._item("key")
assert item.has_cookie is True
assert item.ck_check_status == "valid"
assert item.points == 1234
assert item.gold_balance == 56
assert item.role_bind_status == BIND_BOUND
assert item.role_bind_label == "已绑定"
# 另外两个作用域没绑,但字段必须在(前端要显示「?」而不是崩)
assert item.esports_bind_status == "unknown"
assert item.xpd_bind_label == "未知"
def test_no_cookie_is_reported(self):
self.make_account("nocookie", cookie="")
assert self._item("nocookie").has_cookie is False
def test_filter_by_ck_status(self):
self.make_account("bad", cookie="ck", ck_check_status="invalid")
self.make_account("good", cookie="ck", ck_check_status="valid")
names = [i.username for i in self._items(ck_status="invalid")]
assert "bad" in names
assert "good" not in names
def test_filter_by_bind_status_matches_any_scope(self):
from web.backend.services.account_binding import (
BIND_BOUND,
BIND_UNBOUND,
SCOPE_ELITE,
SCOPE_PEACE,
set_bind_status,
)
elite_bound = self.make_account("elite-bound")
set_bind_status(elite_bound, SCOPE_ELITE, BIND_BOUND)
peace_unbound = self.make_account("peace-unbound")
set_bind_status(peace_unbound, SCOPE_PEACE, BIND_UNBOUND)
self.make_account("all-unknown")
self.session.commit()
bound = {i.username for i in self._items(bind_status=BIND_BOUND)}
assert "elite-bound" in bound
assert "peace-unbound" not in bound
unbound = {i.username for i in self._items(bind_status=BIND_UNBOUND)}
assert "peace-unbound" in unbound
assert "elite-bound" not in unbound
# unknown 会把没绑过的也算进来(默认值就是 unknown)
unknown = {i.username for i in self._items(bind_status="unknown")}
assert "all-unknown" in unknown
+34
View File
@@ -88,6 +88,8 @@ def _filter_accounts_query(
has_cookie: bool = False,
search: str = "",
status: str = "",
ck_status: str = "",
bind_status: str = "",
):
"""复用列表筛选条件,供分页列表和全量批量操作保持一致。"""
if has_cookie:
@@ -104,6 +106,19 @@ def _filter_accounts_query(
# 归档账号默认不出现 —— 「归档」本身就是「收起来别烦我」的意思。
# 要看它们必须显式带 status=archived。
query = query.filter(Account.status != STATUS_ARCHIVED)
ck_value = _query_str(ck_status)
if ck_value:
query = query.filter(Account.ck_check_status == ck_value)
bind_value = _query_str(bind_status)
if bind_value:
# 三个作用域任一命中即算(列表上「绑定」是一列,不分开筛)
query = query.filter(
or_(
Account.role_bind_status == bind_value,
Account.esports_bind_status == bind_value,
Account.xpd_bind_status == bind_value,
)
)
search_text = _query_str(search)
if search_text:
pattern = f"%{search_text}%"
@@ -173,6 +188,12 @@ def list_accounts(
has_cookie: bool = Query(False),
search: str = Query(""),
status: str = Query("", description="按生命周期状态筛选;空=全部(不含归档)"),
ck_status: str = Query(
"", description="按 CK 健康结论筛选:valid / invalid / error"
),
bind_status: str = Query(
"", description="按角色绑定筛选:unknown/unbound/pending/bound"
),
page: int | None = Query(None, ge=1),
page_size: int = Query(20, ge=1, le=200),
include_sensitive: bool = Query(False),
@@ -189,6 +210,8 @@ def list_accounts(
has_cookie=has_cookie,
search=search,
status=status,
ck_status=ck_status,
bind_status=bind_status,
)
total = None
@@ -225,6 +248,13 @@ def list_accounts(
status_label=STATUS_LABELS.get(acc.status or "", acc.status or ""),
status_reason=acc.status_reason or "",
auto_managed=bool(acc.auto_managed),
# 关键列:全部是 accounts 行上的字段,无 JOIN / 无聚合
has_cookie=bool(acc.cookie),
ck_check_status=acc.ck_check_status or "",
ck_checked_at=acc.ck_checked_at,
points=acc.points,
gold_balance=acc.gold_balance,
**bind_payload(acc),
)
# 只有管理员可看完整字段(密码、邮箱等)
if can_include_sensitive:
@@ -880,6 +910,10 @@ def account_overview(
"status_reason": acc.status_reason or "",
"status_changed_at": acc.status_changed_at,
"auto_managed": bool(acc.auto_managed),
# CK 的持有与健康(详情页状态卡要一眼看出「能不能用」)
"has_cookie": bool(acc.cookie),
"ck_check_status": acc.ck_check_status or "",
"ck_checked_at": acc.ck_checked_at,
# 当前状态(覆盖式字段)
"points": acc.points,
"gold_balance": acc.gold_balance,
+24
View File
@@ -185,6 +185,19 @@ class AccountOut(BaseModel):
status_label: str = ""
status_reason: str = ""
auto_managed: bool = True
# 列表页的「关键列」:全部直接来自 accounts 行,不需要任何 JOIN 或聚合
# (客服一眼要能看出「这账号能不能用、要不要我处理」)。
has_cookie: bool = False
ck_check_status: str = ""
ck_checked_at: datetime | None = None
points: int | None = None
gold_balance: int | None = None
role_bind_status: str = "unknown"
role_bind_label: str = ""
esports_bind_status: str = "unknown"
esports_bind_label: str = ""
xpd_bind_status: str = "unknown"
xpd_bind_label: str = ""
model_config = ConfigDict(from_attributes=True)
@@ -205,6 +218,17 @@ class AccountOut(BaseModel):
"status_label": self.status_label,
"status_reason": self.status_reason,
"auto_managed": self.auto_managed,
"has_cookie": self.has_cookie,
"ck_check_status": self.ck_check_status,
"ck_checked_at": _isoformat(self.ck_checked_at),
"points": self.points,
"gold_balance": self.gold_balance,
"role_bind_status": self.role_bind_status,
"role_bind_label": self.role_bind_label,
"esports_bind_status": self.esports_bind_status,
"esports_bind_label": self.esports_bind_label,
"xpd_bind_status": self.xpd_bind_status,
"xpd_bind_label": self.xpd_bind_label,
}
+30 -2
View File
@@ -62,6 +62,17 @@ SCOPE_COLUMNS = {
SCOPE_PEACE: "xpd_bind_status",
}
#: 作用域 → 接口载荷里的字段前缀。
#: ⚠️ **必须与列名一致**(`role_bind_status` / `esports_bind_status` / `xpd_bind_status`)。
#: 第一版这里按作用域名拼成了 `elite_bind_status` / `peace_bind_status`,
#: 与 schema 声明的字段名对不上 —— Pydantic 会静默用默认值,
#: 表现为「接口永远返回 unknown」,且**不报错**。有一条用例比对 schema 字段名。
SCOPE_PAYLOAD_PREFIX = {
SCOPE_ELITE: "role",
SCOPE_ESPORTS: "esports",
SCOPE_PEACE: "xpd",
}
SCOPE_LABELS = {
SCOPE_ELITE: "精英宝典角色",
SCOPE_ESPORTS: "电竞手册角色",
@@ -114,6 +125,22 @@ def map_legacy_bind_status(scope: str, legacy: str | None) -> str:
return LEGACY_MAPS.get(scope, {}).get((legacy or "").strip(), BIND_UNKNOWN)
def _assert_prefixes_match_columns() -> None:
"""自检:载荷前缀必须等于列名前缀(`role_bind_status` ↔ `role_bind_*`)。
这是被真实 bug 逼出来的 —— 前缀写错只会让接口静默返回默认值。
"""
for scope, column in SCOPE_COLUMNS.items():
expected = column.removesuffix("_bind_status")
assert SCOPE_PAYLOAD_PREFIX[scope] == expected, (
f"{scope}: 载荷前缀 {SCOPE_PAYLOAD_PREFIX[scope]!r} "
f"与列名 {column!r} 不一致"
)
_assert_prefixes_match_columns()
def get_bind_status(account: Account, scope: str) -> str:
column = SCOPE_COLUMNS.get(scope)
if not column:
@@ -147,7 +174,8 @@ def bind_payload(account: Account) -> dict[str, str]:
"""给接口/前端用的三列快照(值 + 中文标签)。"""
payload: dict[str, str] = {}
for scope in ALL_SCOPES:
prefix = SCOPE_PAYLOAD_PREFIX[scope]
state = get_bind_status(account, scope)
payload[f"{scope}_bind_status"] = state
payload[f"{scope}_bind_label"] = BIND_LABELS[state]
payload[f"{prefix}_bind_status"] = state
payload[f"{prefix}_bind_label"] = BIND_LABELS[state]
return payload
+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"