diff --git a/docs/设计-账号全生命周期.md b/docs/设计-账号全生命周期.md index b742fc4..ca80cd1 100644 --- a/docs/设计-账号全生命周期.md +++ b/docs/设计-账号全生命周期.md @@ -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 | diff --git a/tests/test_account_binding.py b/tests/test_account_binding.py index 8cb35ce..ce83e87 100644 --- a/tests/test_account_binding.py +++ b/tests/test_account_binding.py @@ -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"): diff --git a/tests/test_account_lifecycle.py b/tests/test_account_lifecycle.py index 38b2d21..b99987b 100644 --- a/tests/test_account_lifecycle.py +++ b/tests/test_account_lifecycle.py @@ -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 diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 62467aa..f621029 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 475b300..dbfd302 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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, } diff --git a/web/backend/services/account_binding.py b/web/backend/services/account_binding.py index ff3805a..82e1eeb 100644 --- a/web/backend/services/account_binding.py +++ b/web/backend/services/account_binding.py @@ -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 diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index 3a93dda..6eb4b14 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -17,7 +17,7 @@ import type { export const accountApi = { list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) => api.get('/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>('/accounts', { params }), summary: () => api.get('/accounts/summary'), import: (text: string, tag?: string) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index eef3bd5..c844cdc 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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 { diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index 0de64ab..c52d0b9 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -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 = { 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 {scope}{mark}; +} + /** 绑定状态 → 展示色(与后端 account_binding 的四个枚举对应)。 */ const BIND_TONES: Record = { 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([]); 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() { + {/* ── 生命周期状态卡 ──────────────────────────────── + 这一屏要回答客服最关心的三个问题: + 1. 这账号现在能不能用?(状态 + CK) + 2. 要不要我处理?(原因 + 变更时间 + 自动化是否在管) + 3. 账上还有多少?(积分 / 鱼翅 / 兑换余额) */} + + + + 生命周期状态 + + + {overview?.status_label || '—'} + + {overview?.auto_managed === false && 不参与自动化} + + + {formatTime(overview?.status_changed_at)} + {overview?.status_reason ? `(${overview.status_reason})` : ''} + + + + + CK + {overview?.has_cookie === false + ? 无 CK + : + {overview?.ck_check_status === 'invalid' ? '已失效' + : overview?.ck_check_status === 'valid' ? '正常' : '未检测'} + } + + 最近检测 {formatTime(overview?.stats?.last_ck_check_at)} + + + + + 角色绑定 + + + + + + + + + + + + {/* 人工操作:与工具栏的生命周期动作分开 —— 那些是「账号可用性」, + 这些是「归属与标注」。 */} + + 归属与标注 + + setTagDraft(e.target.value)} + onPressEnter={() => void handleSaveTag()} + /> + + + + ) : ( + + )} + + + + + @@ -374,6 +518,8 @@ export default function AccountDetailPage() { + + diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index f4f3074..1bf73c0 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -33,6 +33,21 @@ const STATUS_TONES: Record = { 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 {scope}{mark}; +} + 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(''); const [statusFilter, setStatusFilter] = useState(''); + const [ckFilter, setCkFilter] = useState(''); + const [bindFilter, setBindFilter] = useState(''); const [searchText, setSearchText] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); 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 无 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) => ( + + + + + + ), + }, + { + title: '积分 / 鱼翅', + dataIndex: 'points', + width: 110, + render: (_, record: AccountItem) => ( + + 积分 {record.points ?? '-'} + 鱼翅 {record.gold_balance ?? '-'} + + ), + }, { title: '标签', dataIndex: 'tag', @@ -410,6 +497,24 @@ export default function AccountsPage() { }} options={STATUS_OPTIONS} /> + { setBindFilter(val || ''); setCurrentPage(1); }} + options={BIND_OPTIONS} + />