From f101414f5ec5b7034016d3e7d386c30933764f54 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 7 Oct 2026 17:23:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(accounts):=20=E6=96=B0=E5=A2=9E=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=BC=82=E5=B8=B8=E5=88=86=E5=9E=8B=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E3=80=81=E5=A4=9A=E9=80=89/=E5=85=A8=E9=87=8F=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E3=80=81=E7=AD=9B=E9=80=89=E8=AE=B0=E5=BF=86=E5=8F=8A?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E7=BB=84=E4=BB=B6=E6=A8=A1=E5=9D=97=E5=8C=96?= =?UTF-8?q?=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增登录失败异常分型统计:支持「必须绑定手机号」、「极验滑块」、「凭证错误」、「邮箱取码失败」、「邮箱配额已满」、「网络异常」分型识别、生命周期联动(待人工)与一键筛选; - 新增账号名单与明细导出功能:支持勾选多选或按当前筛选条件全选,可导出纯用户名名单 (TXT) 或 UTF-8 BOM 结构化明细报表 (CSV),并支持一键复制到剪贴板; - 优化筛选条件记忆机制:支持页面切换/返回后自动保留所有筛选与排序状态,新增顶部「重置全部筛选」快捷按钮; - 模块化拆分 AccountsPage:将 2100+ 行的大文件拆解至 pages/accounts/ 子目录(包含状态卡片、筛选面板、工具栏、操作弹窗与表格构建器),提升可维护性; - 验证方式:新增 5 个后端自动化单测 (test_account_failure_and_export.py),全量 694 个测试用例通过,前端 TypeScript 编译与 pre-commit 代码规范检查全部通过。 --- core/huya/verification/capture.py | 2 +- core/huya/verification/solver.py | 2 +- tests/test_account_failure_and_export.py | 197 ++ tests/test_account_overview.py | 3 +- web/backend/routers/accounts.py | 134 +- web/backend/schemas.py | 14 + web/backend/services/account_lifecycle.py | 2 +- web/backend/services/login_failure.py | 10 +- web/backend/services/login_service.py | 5 +- web/frontend/src/api/accounts.ts | 4 + web/frontend/src/api/types.ts | 11 + web/frontend/src/pages/AccountsPage.tsx | 1758 +++++------------ .../accounts/components/AccountColumns.tsx | 351 ++++ .../accounts/components/AccountModals.tsx | 244 +++ .../accounts/components/AccountToolbar.tsx | 262 +++ .../pages/accounts/components/FilterPanel.tsx | 745 +++++++ .../pages/accounts/components/StatusCards.tsx | 57 + web/frontend/src/pages/accounts/storage.ts | 34 + web/frontend/src/pages/accounts/types.ts | 144 ++ 19 files changed, 2697 insertions(+), 1282 deletions(-) create mode 100644 tests/test_account_failure_and_export.py create mode 100644 web/frontend/src/pages/accounts/components/AccountColumns.tsx create mode 100644 web/frontend/src/pages/accounts/components/AccountModals.tsx create mode 100644 web/frontend/src/pages/accounts/components/AccountToolbar.tsx create mode 100644 web/frontend/src/pages/accounts/components/FilterPanel.tsx create mode 100644 web/frontend/src/pages/accounts/components/StatusCards.tsx create mode 100644 web/frontend/src/pages/accounts/storage.ts create mode 100644 web/frontend/src/pages/accounts/types.ts diff --git a/core/huya/verification/capture.py b/core/huya/verification/capture.py index a95b1e6..0345b37 100644 --- a/core/huya/verification/capture.py +++ b/core/huya/verification/capture.py @@ -172,7 +172,7 @@ class CaptchaCaptureRecord: dtu_bytes: bytes, puzzle_bytes: bytes, restored_bg_bytes: bytes | None = None, - angle: int | float | None = None, + angle: float | None = None, raw_data: dict[str, Any] | None = None, ) -> Path: """捕获旋转验证码的所有图片和元数据。""" diff --git a/core/huya/verification/solver.py b/core/huya/verification/solver.py index 18b8eb2..f10ceeb 100644 --- a/core/huya/verification/solver.py +++ b/core/huya/verification/solver.py @@ -6,6 +6,7 @@ import base64 import io import json import random +import re import time import uuid from collections.abc import Mapping @@ -15,7 +16,6 @@ from pathlib import Path from typing import Any, cast from urllib.parse import parse_qs, quote, urlparse -import re import cv2 import execjs import numpy as np diff --git a/tests/test_account_failure_and_export.py b/tests/test_account_failure_and_export.py new file mode 100644 index 0000000..00fdaec --- /dev/null +++ b/tests/test_account_failure_and_export.py @@ -0,0 +1,197 @@ +"""账号登录失败分类统计、筛选与多选导出测试。 + +涵盖: +1. `list_accounts` 携带 `failure_kind` 筛选; +2. `AccountOut` 序列化包含 `relogin_failure_kind`、`relogin_failure_label`、`relogin_last_error`; +3. `accounts_summary` 聚合返回 `login_failures` 分类统计; +4. `POST /api/accounts/export` 导出纯文本账号名单 (txt) 与结构化报表 (csv); +5. `must_bind_mobile` 自动分型与进入人工态判定。 +""" + +from __future__ import annotations + +import csv +import io + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, User +from web.backend.routers.accounts import ( + accounts_summary, + export_accounts, + list_accounts, +) +from web.backend.schemas import AccountExportRequest +from web.backend.services.account_lifecycle import STATUS_MANUAL, derive_status +from web.backend.services.login_failure import classify_failure, failure_kind_label + + +class TestAccountFailureClassificationAndExport: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.admin = User( + username="fail-admin", password_hash="h", role="super_admin", is_active=True + ) + self.support = User( + username="fail-support", password_hash="h", role="support", is_active=True + ) + self.session.add_all([self.admin, self.support]) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def _create_account(self, name: str, **kwargs) -> Account: + acc = Account( + username=name, + password="pwd", + email=f"{name}@test.com", + email_password="epwd", + cookie=kwargs.pop("cookie", "acf_uid=123"), + ck_check_status=kwargs.pop("ck_check_status", "valid"), + **kwargs, + ) + self.session.add(acc) + self.session.commit() + return acc + + def test_classify_must_bind_mobile(self): + """测试必须绑定手机号分型判定。""" + assert ( + classify_failure("跳过手机号绑定失败: 必须绑定手机号") == "must_bind_mobile" + ) + assert classify_failure("登录失败: 必须绑定手机号") == "must_bind_mobile" + assert failure_kind_label("must_bind_mobile") == "必须绑定手机号" + + # 验证 must_bind_mobile 在 CK 失效时被 derive_status 推导为 manual 待人工 + acc = Account( + username="bind_test", + password="p", + email="e@e.com", + email_password="ep", + cookie="c", + ck_check_status="invalid", + relogin_failure_kind="must_bind_mobile", + relogin_attempts=1, + ) + assert derive_status(acc) == STATUS_MANUAL + + def test_list_accounts_filter_by_failure_kind_and_serialization(self): + """测试按 failure_kind 筛选与 AccountOut 序列化包含失败字段。""" + self._create_account( + "acc_mobile", + relogin_failure_kind="must_bind_mobile", + relogin_last_error="跳过手机号绑定失败: 必须绑定手机号", + ) + self._create_account( + "acc_captcha", + relogin_failure_kind="captcha", + relogin_last_error="极验验证码拦截", + ) + self._create_account("acc_normal", relogin_failure_kind="") + + # 1. 筛选 must_bind_mobile + res_mobile = list_accounts( + failure_kind="must_bind_mobile", + page=1, + page_size=20, + db=self.session, + current=self.admin, + ) + assert res_mobile["total"] == 1 + item = res_mobile["items"][0] + assert item.username == "acc_mobile" + + # 检查 Pydantic 序列化 (防静默丢字段) + dumped = item.model_dump() + assert dumped["relogin_failure_kind"] == "must_bind_mobile" + assert dumped["relogin_failure_label"] == "必须绑定手机号" + assert "必须绑定手机号" in dumped["relogin_last_error"] + + # 2. 筛选 captcha + res_captcha = list_accounts( + failure_kind="captcha", + page=1, + page_size=20, + db=self.session, + current=self.admin, + ) + assert res_captcha["total"] == 1 + assert res_captcha["items"][0].username == "acc_captcha" + + def test_accounts_summary_login_failures_aggregation(self): + """测试 accounts_summary 接口聚合各错误分类统计。""" + self._create_account("acc_m1", relogin_failure_kind="must_bind_mobile") + self._create_account("acc_m2", relogin_failure_kind="must_bind_mobile") + self._create_account("acc_c1", relogin_failure_kind="captcha") + self._create_account("acc_q1", relogin_failure_kind="email_quota") + self._create_account("acc_norm", relogin_failure_kind="") + + summary = accounts_summary(db=self.session, current=self.admin) + assert "login_failures" in summary + failures = summary["login_failures"] + assert failures.get("must_bind_mobile") == 2 + assert failures.get("captcha") == 1 + assert failures.get("email_quota") == 1 + + def test_export_accounts_txt_format(self): + """测试导出纯账号名名单 (txt)。""" + acc1 = self._create_account("user_alpha", tag="VIP") + acc2 = self._create_account("user_beta", tag="VIP") + self._create_account("user_gamma", tag="OTHER") + + # 1. 显式 ID 多选导出 + req1 = AccountExportRequest( + account_ids=[acc1.id, acc2.id], + format="txt", + ) + resp1 = export_accounts(req1, db=self.session, current=self.admin) + assert resp1.media_type.startswith("text/plain") + body1 = resp1.body.decode("utf-8") + assert body1.split("\n") == ["user_alpha", "user_beta"] + + # 2. 按筛选条件全选导出 (all_matching=True) + req2 = AccountExportRequest( + all_matching=True, + tag="VIP", + format="txt", + ) + resp2 = export_accounts(req2, db=self.session, current=self.admin) + body2 = resp2.body.decode("utf-8") + assert body2.split("\n") == ["user_alpha", "user_beta"] + + def test_export_accounts_csv_format(self): + """测试导出完整明细报表 (csv,带 BOM 和中文分型)。""" + acc = self._create_account( + "user_csv_test", + points=500, + gold_balance=10, + tag="TEST_TAG", + relogin_failure_kind="must_bind_mobile", + relogin_last_error="跳过手机号绑定失败: 必须绑定手机号", + assigned_to=self.support.id, + ) + + req = AccountExportRequest( + account_ids=[acc.id], + format="csv", + ) + resp = export_accounts(req, db=self.session, current=self.admin) + assert resp.media_type.startswith("text/csv") + content = resp.body.decode("utf-8") + assert content.startswith("\ufeff") # 校验 UTF-8 BOM + + reader = csv.reader(io.StringIO(content.lstrip("\ufeff"))) + rows = list(reader) + header = rows[0] + assert "账号" in header + assert "登录异常分类" in header + assert "最新报错" in header + assert "归属客服" in header + + data_row = rows[1] + assert data_row[0] == "user_csv_test" + assert data_row[2] == "必须绑定手机号" + assert "必须绑定手机号" in data_row[3] + assert data_row[4] == "TEST_TAG" + assert data_row[5] == "fail-support" diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index 54594d1..f7ac7a8 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -1996,7 +1996,8 @@ class TestHighValueAccountFilters: def test_null_high_value_owned_not_treated_as_both_available(self): """high_value_owned 为 None 的账号(如被重置过),绝不能误判为双套均可兑换。""" - from datetime import datetime, UTC + from datetime import UTC, datetime + from web.backend.models import Account from web.backend.routers.accounts import accounts_summary diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 2ef3c62..34e9441 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -1,8 +1,8 @@ -"""账号管理路由""" - +import csv +import io from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy import and_, case, func, or_, select, text from sqlalchemy.orm import Session, defer, joinedload @@ -23,6 +23,7 @@ from ..schemas import ( AccountAutomationUpdate, AccountBulkSelection, AccountBulkTag, + AccountExportRequest, AccountImport, AccountLifecycleUpdate, AccountOut, @@ -68,6 +69,7 @@ from ..services.account_value import ( ) from ..services.audit_service import record_account_action, record_audit from ..services.douyu_service import cookie_holding_filter +from ..services.login_failure import failure_kind_label router = APIRouter(prefix="/api/accounts", tags=["账号管理"]) @@ -92,6 +94,10 @@ def _query_str(value) -> str: return value.strip() if isinstance(value, str) else "" +def _query_bool(value) -> bool: + return value is True + + def _filter_accounts_query( db: Session, query, @@ -105,6 +111,7 @@ def _filter_accounts_query( search: str = "", status: str = "", ck_status: str = "", + failure_kind: str = "", bind_status: str = "", high_value_filter: str = "", min_points: int | None = None, @@ -116,9 +123,9 @@ def _filter_accounts_query( role_bind_status: str = "", ): """复用列表筛选条件,供分页列表和全量批量操作保持一致。""" - if has_cookie: + if _query_bool(has_cookie): query = query.filter(Account.id.in_(cookie_account_ids_query(db))) - if assigned_only and user_has_permission(current, "account:view_all"): + if _query_bool(assigned_only) and user_has_permission(current, "account:view_all"): query = query.filter(Account.assigned_to.isnot(None)) # 归属筛选:未分配 / 已分配,再叠加「指定客服」。与其它条件(标签、CK、 # 搜索…)是**同时生效**的,这正是「综合筛选」的用法。 @@ -143,6 +150,9 @@ def _filter_accounts_query( ck_value = _query_str(ck_status) if ck_value: query = query.filter(Account.ck_check_status == ck_value) + fk_value = _query_str(failure_kind) + if fk_value: + query = query.filter(Account.relogin_failure_kind == fk_value) bind_value = _query_str(bind_status) if bind_value: # 三个作用域任一命中即算(列表上「绑定」是一列,不分开筛) @@ -240,6 +250,7 @@ def _selected_account_ids( search=req.search, status=req.status, ck_status=req.ck_status, + failure_kind=req.failure_kind, bind_status=req.bind_status, high_value_filter=req.high_value_filter, min_points=req.min_points, @@ -393,6 +404,10 @@ def list_accounts( ck_status: str = Query( "", description="按 CK 健康结论筛选:valid / invalid / error" ), + failure_kind: str = Query( + "", + description="按最近登录失败分型筛选:must_bind_mobile/captcha/credential/email_code/email_quota/network", + ), bind_status: str = Query( "", description="按角色绑定筛选:unknown/unbound/pending/bound" ), @@ -433,6 +448,7 @@ def list_accounts( search=search, status=status, ck_status=ck_status, + failure_kind=failure_kind, bind_status=bind_status, high_value_filter=high_value_filter, min_points=min_points, @@ -565,6 +581,9 @@ 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), + relogin_failure_kind=acc.relogin_failure_kind or "", + relogin_failure_label=failure_kind_label(acc.relogin_failure_kind), + relogin_last_error=acc.relogin_last_error or "", # 关键列:全部是 accounts 行上的字段,无 JOIN / 无聚合 has_cookie=bool(acc.cookie), ck_check_status=acc.ck_check_status or "", @@ -634,6 +653,19 @@ def accounts_summary( ) by_status = {str(row[0] or ""): int(row[1] or 0) for row in status_rows} visible_total = sum(by_status.values()) + + # 登录失败分型统计(不含空值与归档) + failure_rows = ( + visible.filter( + Account.relogin_failure_kind != "", + Account.relogin_failure_kind.isnot(None), + ) + .with_entities(Account.relogin_failure_kind, func.count(Account.id)) + .group_by(Account.relogin_failure_kind) + .all() + ) + login_failures = {str(row[0] or ""): int(row[1] or 0) for row in failure_rows} + # 高价值及核心资产分布(单次聚合,供前端快捷胶囊展示) bairimeng_expr = suit_owned_expr(Account.high_value_owned, SUIT_BAIRIMENG) mengtu_expr = suit_owned_expr(Account.high_value_owned, SUIT_MENGTU) @@ -669,6 +701,8 @@ def accounts_summary( "visible_total": visible_total, # 生命周期分布(键是 6 状态的枚举值) "by_status": by_status, + # 登录异常分类统计 + "login_failures": login_failures, # 高价值与核心资产统计 "high_value": high_value_summary, # 需要人关注的:CK 已被判失效的账号数(含待人工) @@ -690,6 +724,96 @@ def accounts_summary( } +@router.post("/export") +def export_accounts( + req: AccountExportRequest, + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """导出账号:支持纯文本账号名单(txt)或完整详情(csv)。""" + selected_ids = _selected_account_ids(db, current, req) + if not selected_ids: + if req.format == "csv": + content = "\ufeff账号,生命周期状态,登录异常分类,最新报错,标签,归属客服,鱼翅,积分,精英宝典,电竞手册,CK状态,备注\n" + return Response( + content=content, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="accounts.csv"'}, + ) + return Response( + content="", + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="accounts.txt"'}, + ) + + accounts = ( + db.query(Account) + .options(joinedload(Account.assigned_user)) + .filter(Account.id.in_(selected_ids)) + .order_by(Account.id.asc()) + .all() + ) + + if req.format == "txt": + # 纯账号名字,换行分隔 + lines = [acc.username for acc in accounts if acc.username] + text_content = "\n".join(lines) + return Response( + content=text_content, + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="accounts.txt"'}, + ) + + # 导出 CSV (带 UTF-8 BOM,防止 Excel 打开乱码) + output = io.StringIO() + writer = csv.writer(output) + writer.writerow( + [ + "账号", + "生命周期状态", + "登录异常分类", + "最新报错", + "标签", + "归属客服", + "鱼翅", + "积分", + "精英宝典", + "电竞手册", + "CK状态", + "备注", + ] + ) + for acc in accounts: + writer.writerow( + [ + acc.username or "", + STATUS_LABELS.get(acc.status or "", acc.status or ""), + failure_kind_label(acc.relogin_failure_kind) + if acc.relogin_failure_kind + else "", + acc.relogin_last_error or "", + acc.tag or "", + acc.assigned_user.username if acc.assigned_user else "未分配", + acc.gold_balance if acc.gold_balance is not None else "", + acc.points if acc.points is not None else "", + "已开通" + if acc.elite_opened is True + else ("未开通" if acc.elite_opened is False else "未知"), + "已开通" + if acc.esports_opened is True + else ("未开通" if acc.esports_opened is False else "未知"), + acc.ck_check_status or "", + acc.remark or "", + ] + ) + csv_content = "\ufeff" + output.getvalue() + return Response( + content=csv_content, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="accounts.csv"'}, + ) + + @router.post("/import") def import_accounts( req: AccountImport, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index a17f78a..a4d562c 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -138,6 +138,7 @@ class AccountBulkSelection(BaseModel): has_cookie: bool = False status: str = "" ck_status: str = "" + failure_kind: str = "" bind_status: str = "" high_value_filter: str = "" min_points: int | None = None @@ -149,6 +150,12 @@ class AccountBulkSelection(BaseModel): role_bind_status: str = "" +class AccountExportRequest(AccountBulkSelection): + """账号导出请求:支持纯账号名单(txt)或结构化明细(csv)。""" + + format: str = Field("txt", pattern="^(txt|csv)$") + + class BatchAssign(AccountBulkSelection): """批量分配 / 取消分配。 @@ -206,6 +213,10 @@ class AccountOut(BaseModel): status_label: str = "" status_reason: str = "" auto_managed: bool = True + # 登录失败分型(2026-10-07):客服排查重登/登录异常 + relogin_failure_kind: str = "" + relogin_failure_label: str = "" + relogin_last_error: str = "" # 列表页的「关键列」:全部直接来自 accounts 行,不需要任何 JOIN 或聚合 # (客服一眼要能看出「这账号能不能用、要不要我处理」)。 has_cookie: bool = False @@ -250,6 +261,9 @@ class AccountOut(BaseModel): "status_label": self.status_label, "status_reason": self.status_reason, "auto_managed": self.auto_managed, + "relogin_failure_kind": self.relogin_failure_kind, + "relogin_failure_label": self.relogin_failure_label, + "relogin_last_error": self.relogin_last_error, "has_cookie": self.has_cookie, "ck_check_status": self.ck_check_status, "ck_checked_at": _isoformat(self.ck_checked_at), diff --git a/web/backend/services/account_lifecycle.py b/web/backend/services/account_lifecycle.py index 45417dc..fb43a1a 100644 --- a/web/backend/services/account_lifecycle.py +++ b/web/backend/services/account_lifecycle.py @@ -71,7 +71,7 @@ AUTOMATABLE_STATES: tuple[str, ...] = DERIVED_STATES # 重登失败分型 → 需要人工(自动重试只会加重风控 / 重试多少次都一样)。 # 放在这里而不是 job 里:`derive_status` 要用它,service 不该反过来依赖 job。 -MANUAL_KINDS = frozenset({"captcha", "credential"}) +MANUAL_KINDS = frozenset({"captcha", "credential", "must_bind_mobile"}) STATUS_LABELS = { STATUS_IMPORTED: "未登录", diff --git a/web/backend/services/login_failure.py b/web/backend/services/login_failure.py index c22a729..3a9f6b9 100644 --- a/web/backend/services/login_failure.py +++ b/web/backend/services/login_failure.py @@ -24,6 +24,7 @@ from __future__ import annotations #: 型别 → 中文标签(导出 CSV 与页面上用同一份,避免两处各写一遍漂移)。 FAILURE_KIND_LABELS: dict[str, str] = { + "must_bind_mobile": "必须绑定手机号", "captcha": "极验/验证码拦截", "credential": "账号凭证问题", "email_code": "邮箱取码失败", @@ -39,11 +40,14 @@ NOT_AN_ACCOUNT_FAULT: frozenset[str] = frozenset({"interrupted"}) def classify_failure(message: str | None) -> str: """把重登/登录失败消息映射成分型(见规划 §7.4.3)。 - 顺序很重要:「邮箱验证码发送次数已达上限」既含「验证码」也含「上限」, - 必须先在 ``email_quota`` 命中,否则会被归到可重试的 ``email_code`` 上, - 导致次日才该重试的账号被按小时反复撞配额。 + 顺序很重要: + 1. 「必须绑定手机号 / 跳过手机号绑定失败」优先归入 ``must_bind_mobile``; + 2. 「邮箱验证码发送次数已达上限」既含「验证码」也含「上限」,必须先在 ``email_quota`` 命中; + 3. 极验滑块命中 ``captcha``。 """ text = message or "" + if "必须绑定手机号" in text or "跳过手机号绑定失败" in text or "绑定手机" in text: + return "must_bind_mobile" if "上限" in text or "配额" in text: return "email_quota" if "极验" in text or "验证" in text and "滑块" in text: diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index 2643903..e7927f2 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -27,7 +27,7 @@ from .account_events_service import ( EVENT_RELOGIN_OK, emit, ) -from .account_lifecycle import on_ck_verdict, on_login_success +from .account_lifecycle import on_ck_verdict, on_login_success, sync_status from .cookie_check_service import check_douyu_cookie from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES from .login_failure import classify_failure, failure_kind_label, is_account_fault @@ -155,6 +155,9 @@ def _mark_attempt_failed( if db is None or account is None: # 没有会话(纯内存构造的用例)或没有账号:跳过旁路事件,绝不影响主流程。 return + account.relogin_failure_kind = kind + account.relogin_last_error = (message or "")[:512] + sync_status(db, account, occurred_at=completed_at) emit( db, account_id=account.id, diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index 935f63c..dae9410 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -5,6 +5,7 @@ import type { AccountItem, AccountBulkSelection, AccountBulkTagRequest, + AccountExportRequest, AccountOverview, AssignmentsSummary, BasicSummary, @@ -29,6 +30,7 @@ export const accountApi = { include_sensitive?: boolean; status?: string; ck_status?: string; + failure_kind?: string; bind_status?: string; high_value_filter?: string; min_points?: number; @@ -43,6 +45,8 @@ export const accountApi = { }, ) => api.get, PaginatedResponse>('/accounts', { params }), + export: (data: AccountExportRequest) => + api.post('/accounts/export', data, { responseType: 'blob' }), summary: () => api.get('/accounts/summary'), import: (text: string, tag?: string) => api.post('/accounts/import', { text, tag: tag || '' }), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index c3d0405..e2dedb2 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -37,6 +37,7 @@ export interface AccountBulkSelection { has_cookie?: boolean; status?: string; ck_status?: string; + failure_kind?: string; bind_status?: string; high_value_filter?: string; min_points?: number; @@ -48,6 +49,10 @@ export interface AccountBulkSelection { role_bind_status?: string; } +export interface AccountExportRequest extends AccountBulkSelection { + format?: 'txt' | 'csv'; +} + export interface AccountBulkTagRequest extends AccountBulkSelection { tag_value: string; } @@ -98,6 +103,8 @@ export interface HighValueSummary { export interface BasicSummary { /** 生命周期状态分布(键是 6 状态枚举值);归档默认不计入 */ by_status?: Record; + /** 登录失败分型统计 */ + login_failures?: Record; /** 不含归档的账号数 */ visible_total?: number; /** 高价值及核心资产统计 */ @@ -299,6 +306,10 @@ export interface AccountItem { status_label: string; status_reason: string; auto_managed: boolean; + // 登录失败分型(2026-10-07) + relogin_failure_kind?: string; + relogin_failure_label?: string; + relogin_last_error?: string; // 列表页关键列(都直接来自 accounts 行) has_cookie: boolean; ck_check_status: string; diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 9957375..3763996 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -1,208 +1,79 @@ import { useEffect, useState, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { - Alert, - Table, - Button, - Modal, - Input, - InputNumber, - Select, - Popconfirm, - Typography, - Tag, - Space, - Row, - Col, - Card, - Statistic, - App, - Tooltip, - Spin, -} from 'antd'; +import { Table, Alert, Space, Button, App, type MenuProps } from 'antd'; import { message } from '../utils/antdMessage'; -import type { TableProps } from 'antd'; +import { DownloadOutlined, CopyOutlined } from '@ant-design/icons'; import { - ImportOutlined, - DeleteOutlined, - TagOutlined, - FilterOutlined, - EyeOutlined, - EyeInvisibleOutlined, - ThunderboltOutlined, - DownOutlined, - UpOutlined, - ClearOutlined, - UsergroupAddOutlined, -} from '@ant-design/icons'; -import { accountApi, douyuApi, type AccountExchangeRecord, type AccountExchangeRecords, type AccountItem, type BasicSummary, type SupportUserItem } from '../api/modules'; + accountApi, + douyuApi, + type AccountExchangeRecords, + type AccountItem, + type BasicSummary, + type SupportUserItem, +} from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; -import { formatTime, formatTimeShort } from '../utils/time'; import { getErrorMessage } from '../utils/error'; -const TAG_COLORS = [ - 'blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano', -]; - -const { TextArea } = Input; -const { Text } = Typography; - - -/** - * 生命周期状态 → 展示色。 - * 取值域见后端 `services/account_lifecycle.py`(6 个状态,不含 relogin —— - * 「退避中」是 invalid 的调度细节,不单独成状态)。 - */ -type AccountSortKey = - | 'username' - | 'status' - | 'bind' - | 'points' - | 'high_value' - | 'exchange' - | 'tag'; - -const ACCOUNT_SORT_KEYS = new Set([ - 'username', - 'status', - 'bind', - 'points', - 'high_value', - 'exchange', - 'tag', -]); - -const STATUS_TONES: Record = { - imported: 'default', - active: 'green', - invalid: 'red', - manual: 'orange', - disabled: 'default', - 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: '绑定未知' }, -]; - -/** 状态分布卡片:点击即按该状态筛选。 */ -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' }, -]; - -/** 高价值套装筛选项 */ -const HIGH_VALUE_OPTIONS = [ - { value: 'both_available', label: '🌟 双套均可换 (高价值)' }, - { value: 'can_bairimeng', label: '👗 可换白日梦爱丽丝' }, - { value: 'can_mengtu', label: '🐰 可换狂战萌兔' }, - { value: 'owned_bairimeng', label: '✓ 已拥有白日梦' }, - { value: 'owned_mengtu', label: '✓ 已拥有萌兔' }, - { value: 'both_owned', label: '✓ 两套均已拥有' }, - { value: 'high_value_broken', label: '⚠️ 高价值但CK失效' }, - { value: 'unqueried', 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}; -} - -/** 三个作用域的展示顺序与中文名(后端按 scope 分组返回)。 */ -const EXCHANGE_SCOPES: Array<{ key: string; label: string }> = [ - { key: 'elite', label: '精英宝典' }, - { key: 'esports', label: '电竞手册' }, - { key: 'peace', label: '和平小店' }, -]; - -const EXCHANGE_COLUMNS: TableProps['columns'] = [ - { - title: '商品', - dataIndex: 'commodity_name', - key: 'commodity_name', - render: (value: string) => value || '-', - }, - { - title: '积分', - dataIndex: 'score', - key: 'score', - width: 90, - render: (value: number | string | null) => value ?? '-', - }, - { - // 官方记录带**真实兑换时间** - title: '兑换时间', - dataIndex: 'occurred_at', - key: 'occurred_at', - width: 170, - render: (value: string | null) => formatTime(value), - }, - { - // 当时用的斗鱼昵称 + 平台/支付渠道,排查时有用 - title: '使用昵称', - dataIndex: 'show_name', - key: 'show_name', - render: (value: string) => value || '-', - }, -]; +import { + type AccountSortKey, + ACCOUNT_SORT_KEYS, + TAG_COLORS, +} from './accounts/types'; +import { + loadStoredFilters, + saveStoredFilters, + clearStoredFilters, +} from './accounts/storage'; +import { AccountToolbar } from './accounts/components/AccountToolbar'; +import { StatusCards } from './accounts/components/StatusCards'; +import { FilterPanel } from './accounts/components/FilterPanel'; +import { buildAccountColumns } from './accounts/components/AccountColumns'; +import { + BatchImportModal, + BatchTagModal, + BatchAssignModal, + ExchangeRecordsModal, +} from './accounts/components/AccountModals'; export default function AccountsPage() { const navigate = useNavigate(); - // App.useApp() 的 modal 实例继承主题,静态 Modal.confirm 在暗色模式下是白底 const { modal } = App.useApp(); + const initialFilters = useMemo(() => loadStoredFilters(), []); + const [accounts, setAccounts] = useState([]); const [tags, setTags] = useState([]); const [loading, setLoading] = useState(false); + + // 弹窗状态 const [importOpen, setImportOpen] = useState(false); const [importText, setImportText] = useState(''); const [importTag, setImportTag] = useState(''); const [importing, setImporting] = useState(false); - const [tagFilter, setTagFilter] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); - const [ckFilter, setCkFilter] = useState(''); - const [bindFilter, setBindFilter] = useState(''); - // 归属筛选:'' | 'unassigned' | 'assigned' | 'user:' - // 单独一个 Select 而不是两个,是因为「未分配 / 已分配 / 具体某个客服」互斥。 - const [assignFilter, setAssignFilter] = useState(''); + + // 筛选状态 + const [tagFilter, setTagFilter] = useState(() => initialFilters.tagFilter || ''); + const [statusFilter, setStatusFilter] = useState(() => initialFilters.statusFilter || ''); + const [ckFilter, setCkFilter] = useState(() => initialFilters.ckFilter || ''); + const [failureKindFilter, setFailureKindFilter] = useState(() => initialFilters.failureKindFilter || ''); + const [bindFilter, setBindFilter] = useState(() => initialFilters.bindFilter || ''); + const [assignFilter, setAssignFilter] = useState(() => initialFilters.assignFilter || ''); const [assignUsers, setAssignUsers] = useState([]); const [assignModalOpen, setAssignModalOpen] = useState(false); const [assignTargetId, setAssignTargetId] = useState(undefined); const [assigning, setAssigning] = useState(false); - const [searchText, setSearchText] = useState(''); + const [searchText, setSearchText] = useState(() => initialFilters.searchText || ''); + const [exporting, setExporting] = useState(false); // 高价值资产与高级自定义筛选状态 - const [highValueFilter, setHighValueFilter] = useState(''); - const [minPoints, setMinPoints] = useState(undefined); - const [maxPoints, setMaxPoints] = useState(undefined); - const [minGold, setMinGold] = useState(undefined); - const [maxGold, setMaxGold] = useState(undefined); - const [eliteOpened, setEliteOpened] = useState(undefined); - const [esportsOpened, setEsportsOpened] = useState(undefined); - const [roleBindStatus, setRoleBindStatus] = useState(''); - const [advFilterOpen, setAdvFilterOpen] = useState(false); + const [highValueFilter, setHighValueFilter] = useState(() => initialFilters.highValueFilter || ''); + const [minPoints, setMinPoints] = useState(() => initialFilters.minPoints); + const [maxPoints, setMaxPoints] = useState(() => initialFilters.maxPoints); + const [minGold, setMinGold] = useState(() => initialFilters.minGold); + const [maxGold, setMaxGold] = useState(() => initialFilters.maxGold); + const [eliteOpened, setEliteOpened] = useState(() => initialFilters.eliteOpened); + const [esportsOpened, setEsportsOpened] = useState(() => initialFilters.esportsOpened); + const [roleBindStatus, setRoleBindStatus] = useState(() => initialFilters.roleBindStatus || ''); + const [advFilterOpen, setAdvFilterOpen] = useState(() => Boolean(initialFilters.advFilterOpen)); const resetAdvancedFiltersState = () => { setHighValueFilter(''); @@ -220,7 +91,21 @@ export default function AccountsPage() { setCurrentPage(1); }; - // 兑换记录预览:点列表那一格**按需拉取**(不进列表响应),弹窗按作用域分组 + const resetAllFilters = () => { + setTagFilter(''); + setStatusFilter(''); + setCkFilter(''); + setFailureKindFilter(''); + setBindFilter(''); + setAssignFilter(''); + setSearchText(''); + resetAdvancedFiltersState(); + setCurrentPage(1); + clearStoredFilters(); + message.success('已重置所有筛选条件'); + }; + + // 兑换记录预览弹窗状态 const [exRecords, setExRecords] = useState(null); const [exLoading, setExLoading] = useState(false); const [exAccount, setExAccount] = useState(null); @@ -243,25 +128,34 @@ export default function AccountsPage() { const [batchTagVisible, setBatchTagVisible] = useState(false); const [sensitiveVisible, setSensitiveVisible] = useState(false); const [total, setTotal] = useState(0); - const [summary, setSummary] = useState({ total: 0, assigned_count: 0, unassigned_count: 0, tag_count: 0 }); + const [summary, setSummary] = useState({ + total: 0, + assigned_count: 0, + unassigned_count: 0, + tag_count: 0, + }); + const [pageSize, setPageSize] = useState(() => { + if (initialFilters.pageSize) return initialFilters.pageSize; const v = localStorage.getItem('account_page_size'); return v ? Number(v) || 20 : 20; }); - const [currentPage, setCurrentPage] = useState(1); - // 打开列表就按套装价值排:两个都还能换的在前,没查过的在后。点表头可换方向。 - const [sortKey, setSortKey] = useState('high_value'); - const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>('ascend'); - const { can } = usePermissions(); + const [currentPage, setCurrentPage] = useState(() => initialFilters.currentPage || 1); + const [sortKey, setSortKey] = useState(() => + initialFilters.sortKey !== undefined ? initialFilters.sortKey : 'high_value', + ); + const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(() => + initialFilters.sortOrder !== undefined ? initialFilters.sortOrder : 'ascend', + ); + const { can } = usePermissions(); const canViewFull = can('account:view_full'); const canImport = can('account:import'); const canTag = can('account:tag') || canImport; const canDelete = can('account:delete'); const canAssign = can('account:assign'); - // 只有分配权限的人也要能勾选账号(否则没法批量分配)。 const canSelectRows = canTag || canDelete || canAssign; - // 一个 Select 的值拆成后端两个参数:归属状态 + 指定客服。 + const assignStatusParam = assignFilter === 'unassigned' || assignFilter === 'assigned' ? assignFilter : ''; const assignedUserIdParam = assignFilter.startsWith('user:') @@ -280,6 +174,7 @@ export default function AccountsPage() { if (tagFilter) params.tag = tagFilter; if (statusFilter) params.status = statusFilter; if (ckFilter) params.ck_status = ckFilter; + if (failureKindFilter) params.failure_kind = failureKindFilter; if (bindFilter) params.bind_status = bindFilter; if (assignStatusParam) params.assign_status = assignStatusParam; if (assignedUserIdParam !== undefined) params.assigned_user_id = assignedUserIdParam; @@ -313,6 +208,7 @@ export default function AccountsPage() { tagFilter, statusFilter, ckFilter, + failureKindFilter, bindFilter, assignStatusParam, assignedUserIdParam, @@ -333,7 +229,7 @@ export default function AccountsPage() { const data = await accountApi.summary(); setSummary(data); } catch { - // 统计加载失败时不影响主列表操作。 + // 统计加载失败时不影响主列表操作 } }, []); @@ -342,7 +238,7 @@ export default function AccountsPage() { const data = await accountApi.listTags(); setTags(data); } catch { - // 忽略标签加载失败,页面会退化为无标签筛选。 + // 忽略标签加载失败 } }, []); @@ -351,7 +247,7 @@ export default function AccountsPage() { const data = await accountApi.assignmentsSummary(); setAssignUsers(data.support_users); } catch { - // 客服列表拉不到不影响列表本身,只是分配时选不到人。 + // 忽略客服列表拉取失败 } }, []); @@ -369,34 +265,172 @@ export default function AccountsPage() { }, [loadTags, loadSummary]); const selectedCount = selectedAllMatching ? total : selectedRowKeys.length; - const selectionPayload = useMemo(() => ({ - account_ids: selectedAllMatching ? [] : selectedRowKeys.map((key) => Number(key)), - all_matching: selectedAllMatching, - search: searchText.trim(), - tag: tagFilter, - status: statusFilter || undefined, - ck_status: ckFilter || undefined, - bind_status: bindFilter || undefined, - assign_status: assignStatusParam || undefined, - assigned_user_id: assignedUserIdParam, - high_value_filter: highValueFilter || undefined, - min_points: minPoints, - max_points: maxPoints, - min_gold: minGold, - max_gold: maxGold, - elite_opened: eliteOpened, - esports_opened: esportsOpened, - role_bind_status: roleBindStatus || undefined, - }), [ - searchText, - selectedAllMatching, - selectedRowKeys, + const selectionPayload = useMemo( + () => ({ + account_ids: selectedAllMatching ? [] : selectedRowKeys.map((key) => Number(key)), + all_matching: selectedAllMatching, + search: searchText.trim(), + tag: tagFilter, + status: statusFilter || undefined, + ck_status: ckFilter || undefined, + failure_kind: failureKindFilter || undefined, + bind_status: bindFilter || undefined, + assign_status: assignStatusParam || undefined, + assigned_user_id: assignedUserIdParam, + high_value_filter: highValueFilter || undefined, + min_points: minPoints, + max_points: maxPoints, + min_gold: minGold, + max_gold: maxGold, + elite_opened: eliteOpened, + esports_opened: esportsOpened, + role_bind_status: roleBindStatus || undefined, + }), + [ + searchText, + selectedAllMatching, + selectedRowKeys, + tagFilter, + statusFilter, + ckFilter, + failureKindFilter, + bindFilter, + assignStatusParam, + assignedUserIdParam, + highValueFilter, + minPoints, + maxPoints, + minGold, + maxGold, + eliteOpened, + esportsOpened, + roleBindStatus, + ], + ); + + const clearSelection = () => { + setSelectedRowKeys([]); + setSelectedAllMatching(false); + }; + + const handleExport = async (format: 'txt' | 'csv') => { + const isExportingSelection = selectedCount > 0; + setExporting(true); + try { + const payload = isExportingSelection + ? selectionPayload + : { ...selectionPayload, all_matching: true }; + const response = await accountApi.export({ + ...payload, + format, + }); + const blob = new Blob([response as unknown as BlobPart], { + type: format === 'txt' ? 'text/plain;charset=utf-8' : 'text/csv;charset=utf-8', + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `accounts_${format === 'txt' ? 'names' : 'detail'}_${new Date().toISOString().slice(0, 10)}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + message.success(`导出成功`); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setExporting(false); + } + }; + + const handleCopyUsernames = async () => { + if (!selectedAllMatching && selectedRowKeys.length > 0) { + const selectedKeysSet = new Set(selectedRowKeys); + const names = accounts + .filter((a) => selectedKeysSet.has(a.id)) + .map((a) => a.username) + .filter(Boolean); + if (names.length === selectedRowKeys.length) { + await navigator.clipboard.writeText(names.join('\n')); + message.success(`已复制 ${names.length} 个账号名到剪贴板`); + return; + } + } + + setExporting(true); + try { + const payload = selectedCount > 0 + ? selectionPayload + : { ...selectionPayload, all_matching: true }; + const response = await accountApi.export({ + ...payload, + format: 'txt', + }); + const blob = new Blob([response as unknown as BlobPart]); + const text = await blob.text(); + await navigator.clipboard.writeText(text); + const lineCount = text.trim() ? text.trim().split('\n').length : 0; + message.success(`已复制 ${lineCount} 个账号名到剪贴板`); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setExporting(false); + } + }; + + const exportMenuItems: MenuProps['items'] = [ + { + key: 'copy', + icon: , + label: selectedCount > 0 ? `复制选中账号名 (${selectedCount} 个)` : `复制当前筛选账号名 (${total} 个)`, + onClick: handleCopyUsernames, + }, + { + key: 'txt', + icon: , + label: `导出纯账号名名单 (TXT)`, + onClick: () => handleExport('txt'), + }, + { + key: 'csv', + icon: , + label: `导出结构化明细报表 (CSV)`, + onClick: () => handleExport('csv'), + }, + ]; + + // 筛选持久化同步 + useEffect(() => { + saveStoredFilters({ + tagFilter, + statusFilter, + ckFilter, + failureKindFilter, + bindFilter, + assignFilter, + searchText, + highValueFilter, + minPoints, + maxPoints, + minGold, + maxGold, + eliteOpened, + esportsOpened, + roleBindStatus, + currentPage, + pageSize, + sortKey, + sortOrder, + advFilterOpen, + }); + }, [ tagFilter, statusFilter, ckFilter, + failureKindFilter, bindFilter, - assignStatusParam, - assignedUserIdParam, + assignFilter, + searchText, highValueFilter, minPoints, maxPoints, @@ -405,13 +439,14 @@ export default function AccountsPage() { eliteOpened, esportsOpened, roleBindStatus, + currentPage, + pageSize, + sortKey, + sortOrder, + advFilterOpen, ]); - const clearSelection = () => { - setSelectedRowKeys([]); - setSelectedAllMatching(false); - }; - + // 筛选条件变化时清空选中项 useEffect(() => { clearSelection(); }, [ @@ -419,6 +454,7 @@ export default function AccountsPage() { tagFilter, statusFilter, ckFilter, + failureKindFilter, bindFilter, assignStatusParam, assignedUserIdParam, @@ -481,9 +517,6 @@ export default function AccountsPage() { } }; - // 快速分配:`target` 为目标客服,显式传 null 才是取消分配(不做「留空即取消」, - // 免得点错一下就把一批账号的归属清掉)。支持「全选当前筛选」, - // 所以「筛出未分配 → 一键分给某人」不用先手动勾选。 const handleBatchAssign = async (target: number | null) => { if (selectedCount === 0) { message.warning('请先选择账号,或先设置筛选条件后点「全选当前筛选」'); @@ -534,8 +567,6 @@ export default function AccountsPage() { } }; - // 批量一键查询:给每个勾选的账号建一条只读聚合任务(后端就是一个批次 N 条任务)。 - // 结果在「斗鱼工作台」按批次查看,那里有专门的结果面板。 const [batchQuerying, setBatchQuerying] = useState(false); const handleBatchOverview = async () => { const ids = selectedRowKeys.map((key) => Number(key)); @@ -579,13 +610,14 @@ export default function AccountsPage() { const handleSensitiveVisibility = () => { if (sensitiveVisible) { - // 立即清除已加载的敏感内容,再由 effect 重新获取脱敏列表。 - setAccounts((previous) => previous.map((account) => ({ - ...account, - password: undefined, - email: undefined, - email_password: undefined, - }))); + setAccounts((previous) => + previous.map((account) => ({ + ...account, + password: undefined, + email: undefined, + email_password: undefined, + })), + ); setSensitiveVisible(false); return; } @@ -598,433 +630,28 @@ export default function AccountsPage() { }); }; - const sortProps = (key: AccountSortKey, tip?: string) => ({ - key, - sorter: true, - sortOrder: sortKey === key ? sortOrder : null, - showSorterTooltip: tip ? { title: tip } : true, - }); - - const columns: TableProps['columns'] = [ - { - // ID 不再单独占一列(客服不看),降到用户名下面做小字 —— 报障时还能念出来。 - title: '账号', - dataIndex: 'username', - ...sortProps('username'), - width: 200, - // 用户名长短不一:定宽 + 省略号,别让它把整个表格撑开 - ellipsis: true, - render: (username: string, record: AccountItem) => ( - - - #{record.id} - - ), - }, - { - // 「状态」与原来的「CK」两列是重复的 —— 生命周期状态里的 `CK 失效` 就是 CK 的结论。 - // 合并成一列:主标签是生命周期状态,副行小字补 CK 的细节 - // (检测时间、以及 `error` 这种「状态正常但检测没查明白」的情况)。 - title: '状态', - dataIndex: 'status', - ...sortProps('status', '正常在前'), - width: 180, - 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 - ? `检测 ${formatTimeShort(record.ck_checked_at)}` - : '未检测'; - return ( - - - - {record.status_label || status || '-'} - - {record.auto_managed === false && 不自动} - - - {ckNote} - - - ); - }, - }, - { - // 三角色绑定:三个作用域各一列,合成一格紧凑展示 - title: '绑定', - dataIndex: 'role_bind_status', - ...sortProps('bind', '已绑定在前,先看精英,再看电竞和小店'), - width: 150, - render: (_, record: AccountItem) => { - // 三个作用域全都 unknown 时不再显示「精? 电? 店?」(满屏问号只是噪声), - // 但要把两种情况**分开**: - // 没查过 → 「未查询」(有救,点一键查询就有了) - // 查过但推不出 → 「未能判定」(接口没给结论) - // 混成一个词会让人不知道下一步该干什么。 - const allUnknown = [ - record.role_bind_status, - record.esports_bind_status, - record.xpd_bind_status, - ].every((s) => !s || s === 'unknown'); - if (allUnknown) { - const queried = Boolean(record.overview_queried_at); - return ( - - {queried ? '未能判定' : '未查询'} - - ); - } - return ( - - - - - - ); - }, - }, - { - // 归属:看得见才管得着 —— 未分配的一眼可见,而且直接点就能分。 - title: '分配', - dataIndex: 'assigned_username', - width: 110, - render: (name: string | null, record: AccountItem) => { - if (name) { - return {name}; - } - return canAssign ? ( - - ) : ( - 未分配 - ); - }, - }, - { - title: '积分 / 鱼翅', - dataIndex: 'points', - ...sortProps('points', '按积分排,没查过的在后;积分相同再看鱼翅'), - width: 120, - render: (_, record: AccountItem) => ( - - 积分 {record.points ?? '-'} - 鱼翅 {record.gold_balance ?? '-'} - - ), - }, - { - // 高价值套装:这只关乎**账号价值** —— 没兑换过才值钱。 - // - // ⚠️ 语义与直觉相反,别标反了: - // **没兑换过** = 还能用来绑定兑换 = **高价值**(绿色) - // 兑换过 1 个 → 价值降低;两个都兑换过 → 价值最低 - // 数据来自一键查询拉的**斗鱼官方兑换记录**。 - title: '高价值套装', - dataIndex: 'high_value_owned', - ...sortProps('high_value', '两个都可兑换在前,未查询在后'), - width: 170, - render: (owned: string[] | null | undefined, record: AccountItem) => { - // 没查过 ≠ 没兑换: - // 只有 high_value_owned 明确为数组且有查询时间时,才算有效查过高价值 - if (owned === null || owned === undefined || !record.overview_queried_at) { - return ( - - 未查询 - - ); - } - if (owned.length === 0) { - return 两个都可兑换; - } - return ( - - {owned.map((n) => ( - - {n.replace(/^套装-/, '')} 已达上限 - - ))} - - ); - }, - }, - { - // 兑换记录:次数 + 最近一次。 - // - // 只反映**斗鱼官方记录**(后端聚合已按 source=official_sync 过滤)—— - // 我们当场记的那些留给详情页时间线,列表要的是"官方权威且完整"的口径。 - // 点这一格弹窗看全部(按作用域分组),不用进详情页。 - title: '兑换记录', - dataIndex: 'exchange_count', - ...sortProps('exchange', '未兑换在前'), - width: 170, - render: (_, record: AccountItem) => { - if (!record.exchange_count) { - return 未兑换; - } - return ( - -
void openExchangeRecords(record)} - onKeyDown={(event) => { - if (event.key === 'Enter') void openExchangeRecords(record); - }} - > - - {record.exchange_count} 次 - - {formatTimeShort(record.last_exchange_at)} - {/* 列标题已是「兑换记录」,事件 title 里的「兑换 」前缀在这儿重复 */} - {record.last_exchange_name - ? ` ${record.last_exchange_name.replace(/^兑换\s*/, '')}` - : ''} - - -
-
- ); - }, - }, - { - title: '标签', - dataIndex: 'tag', - ...sortProps('tag'), - width: 190, - render: (tag: string, record: AccountItem) => { - // 有权限时直接就地改(Select 输入新标签即保存);无权限则只读展示。 - if (canTag) { - return ( - { - setTagFilter(val || ''); - setCurrentPage(1); - }} - options={tags.map((t) => ({ value: t, label: t }))} - prefix={} - /> - { setBindFilter(val || ''); setCurrentPage(1); }} - options={BIND_OPTIONS} - /> - { - setHighValueFilter(val || ''); - setCurrentPage(1); - }} - options={HIGH_VALUE_OPTIONS} - /> - - - - - - 积分范围: - { - setMinPoints(v ?? undefined); - setCurrentPage(1); - }} - style={{ width: 75 }} - /> - ~ - { - setMaxPoints(v ?? undefined); - setCurrentPage(1); - }} - style={{ width: 75 }} - /> - - - - - - - - 鱼翅余额: - { - setMinGold(v ?? undefined); - setCurrentPage(1); - }} - style={{ width: 75 }} - /> - ~ - { - setMaxGold(v ?? undefined); - setCurrentPage(1); - }} - style={{ width: 75 }} - /> - - - - - - - - 宝典: - { - setEsportsOpened(val === undefined ? undefined : val === 'true'); - setCurrentPage(1); - }} - options={[ - { value: 'true', label: '电竞已开' }, - { value: 'false', label: '电竞未开' }, - ]} - /> - - - - - - 角色绑定: -