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:
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user