feat(accounts): 新增登录异常分型统计、多选/全量导出、筛选记忆及页面组件模块化拆分

- 新增登录失败异常分型统计:支持「必须绑定手机号」、「极验滑块」、「凭证错误」、「邮箱取码失败」、「邮箱配额已满」、「网络异常」分型识别、生命周期联动(待人工)与一键筛选;
- 新增账号名单与明细导出功能:支持勾选多选或按当前筛选条件全选,可导出纯用户名名单 (TXT) 或 UTF-8 BOM 结构化明细报表 (CSV),并支持一键复制到剪贴板;
- 优化筛选条件记忆机制:支持页面切换/返回后自动保留所有筛选与排序状态,新增顶部「重置全部筛选」快捷按钮;
- 模块化拆分 AccountsPage:将 2100+ 行的大文件拆解至 pages/accounts/ 子目录(包含状态卡片、筛选面板、工具栏、操作弹窗与表格构建器),提升可维护性;
- 验证方式:新增 5 个后端自动化单测 (test_account_failure_and_export.py),全量 694 个测试用例通过,前端 TypeScript 编译与 pre-commit 代码规范检查全部通过。
This commit is contained in:
yml2213
2026-10-07 17:23:39 +08:00
parent 44b762ea8c
commit f101414f5e
19 changed files with 2697 additions and 1282 deletions
+1 -1
View File
@@ -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:
"""捕获旋转验证码的所有图片和元数据。"""
+1 -1
View File
@@ -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
+197
View File
@@ -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"
+2 -1
View File
@@ -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
+129 -5
View File
@@ -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,
+14
View File
@@ -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),
+1 -1
View File
@@ -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: "未登录",
+7 -3
View File
@@ -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:
+4 -1
View File
@@ -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,
+4
View File
@@ -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<AccountItem>, PaginatedResponse<AccountItem>>('/accounts', { params }),
export: (data: AccountExportRequest) =>
api.post('/accounts/export', data, { responseType: 'blob' }),
summary: () => api.get<BasicSummary, BasicSummary>('/accounts/summary'),
import: (text: string, tag?: string) =>
api.post<MessageCountResponse, MessageCountResponse>('/accounts/import', { text, tag: tag || '' }),
+11
View File
@@ -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<string, number>;
/** 登录失败分型统计 */
login_failures?: Record<string, number>;
/** 不含归档的账号数 */
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;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,351 @@
import type { TableProps } from 'antd';
import { Space, Button, Typography, Tag, Tooltip, Select, Popconfirm } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import type { NavigateFunction } from 'react-router-dom';
import type { AccountItem } from '../../../api/modules';
import { formatTimeShort } from '../../../utils/time';
import {
type AccountSortKey,
STATUS_TONES,
FAILURE_KIND_LABELS,
} from '../types';
const { Text } = Typography;
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 interface UseAccountColumnsOptions {
navigate: NavigateFunction;
sortKey: AccountSortKey | '';
sortOrder: 'ascend' | 'descend' | null;
canViewFull: boolean;
canTag: boolean;
canDelete: boolean;
canAssign: boolean;
sensitiveVisible: boolean;
tags: string[];
tagColorMap: Record<string, string>;
onOpenExchangeRecords: (record: AccountItem) => void;
onOpenAssignModal: (recordId: number) => void;
onSetTag: (accountId: number, tag: string) => void;
onDelete: (id: number) => void;
}
export function buildAccountColumns({
navigate,
sortKey,
sortOrder,
canViewFull,
canTag,
canDelete,
canAssign,
sensitiveVisible,
tags,
tagColorMap,
onOpenExchangeRecords,
onOpenAssignModal,
onSetTag,
onDelete,
}: UseAccountColumnsOptions): TableProps<AccountItem>['columns'] {
const sortProps = (key: AccountSortKey, tip?: string) => ({
key,
sorter: true,
sortOrder: sortKey === key ? sortOrder : null,
showSorterTooltip: tip ? { title: tip } : true,
});
const columns: TableProps<AccountItem>['columns'] = [
{
title: '账号',
dataIndex: 'username',
...sortProps('username'),
width: 200,
ellipsis: true,
render: (username: string, record: AccountItem) => (
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Button
type="link"
size="small"
style={{ padding: 0, maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
title={username}
onClick={() => navigate(`/accounts/${record.id}`)}
>
{username}
</Button>
<Text type="secondary" style={{ fontSize: 12 }}>
#{record.id}
</Text>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
...sortProps('status', '正常在前'),
width: 190,
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)}`
: '未检测';
const fkMeta = record.relogin_failure_kind
? FAILURE_KIND_LABELS[record.relogin_failure_kind]
: null;
return (
<Space direction="vertical" size={2}>
<Space size={4} wrap>
<Tag color={tone} style={{ marginInlineEnd: 0 }}>
{record.status_label || status || '-'}
</Tag>
{record.auto_managed === false && (
<Tag color="orange" style={{ marginInlineEnd: 0 }}>
不自动
</Tag>
)}
{record.relogin_failure_kind && (
<Tooltip
title={
record.relogin_last_error
? `失败原因: ${record.relogin_last_error}`
: record.relogin_failure_label || record.relogin_failure_kind
}
>
<Tag
color={fkMeta?.color || 'red'}
style={{ marginInlineEnd: 0, cursor: 'pointer' }}
>
{record.relogin_failure_label || fkMeta?.label || record.relogin_failure_kind}
</Tag>
</Tooltip>
)}
</Space>
<Text
type={status === 'active' && record.ck_check_status === 'error' ? 'warning' : 'secondary'}
style={{ fontSize: 12 }}
>
{ckNote}
</Text>
</Space>
);
},
},
{
title: '绑定',
dataIndex: 'role_bind_status',
...sortProps('bind', '已绑定在前,先看精英,再看电竞和小店'),
width: 150,
render: (_, record: AccountItem) => {
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 (
<Tooltip
title={
queried
? '已查询过,但斗鱼这次没给出绑定结论(既非已绑也非未绑)'
: '还没查询过绑定 —— 点工具栏「批量一键查询」'
}
>
<Text type="secondary">{queried ? '未能判定' : '未查询'}</Text>
</Tooltip>
);
}
return (
<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: 'assigned_username',
width: 110,
render: (name: string | null, record: AccountItem) => {
if (name) {
return (
<Tag color="green" style={{ marginInlineEnd: 0 }}>
{name}
</Tag>
);
}
return canAssign ? (
<Button
type="link"
size="small"
style={{ padding: 0 }}
onClick={() => onOpenAssignModal(record.id)}
>
未分配
</Button>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
未分配
</Text>
);
},
},
{
title: '积分 / 鱼翅',
dataIndex: 'points',
...sortProps('points', '按积分排,没查过的在后;积分相同再看鱼翅'),
width: 120,
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: 'high_value_owned',
...sortProps('high_value', '两个都可兑换在前,未查询在后'),
width: 170,
render: (owned: string[] | null | undefined, record: AccountItem) => {
if (owned === null || owned === undefined || !record.overview_queried_at) {
return (
<Tooltip title="尚未查询高价值套装准入状态,点工具栏「批量一键查询」">
<Text type="secondary">未查询</Text>
</Tooltip>
);
}
if (owned.length === 0) {
return <Tag color="green">两个都可兑换</Tag>;
}
return (
<Space size={4} wrap>
{owned.map((n) => (
<Tag key={n} color="orange">
{n.replace(/^套装-/, '')} 已达上限
</Tag>
))}
</Space>
);
},
},
{
title: '兑换记录',
dataIndex: 'exchange_count',
...sortProps('exchange', '未兑换在前'),
width: 170,
render: (_, record: AccountItem) => {
if (!record.exchange_count) {
return <Text type="secondary">未兑换</Text>;
}
return (
<Tooltip title="点击查看全部官方兑换记录">
<div
role="button"
tabIndex={0}
style={{ cursor: 'pointer' }}
onClick={() => onOpenExchangeRecords(record)}
onKeyDown={(event) => {
if (event.key === 'Enter') onOpenExchangeRecords(record);
}}
>
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Text>{record.exchange_count} 次</Text>
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{formatTimeShort(record.last_exchange_at)}
{record.last_exchange_name
? ` ${record.last_exchange_name.replace(/^兑换\s*/, '')}`
: ''}
</Text>
</Space>
</div>
</Tooltip>
);
},
},
{
title: '标签',
dataIndex: 'tag',
...sortProps('tag'),
width: 190,
render: (tag: string, record: AccountItem) => {
if (canTag) {
return (
<Select
mode="tags"
maxCount={1}
allowClear
showSearch
optionFilterProp="label"
size="small"
placeholder="选择或输入标签"
style={{ width: 150 }}
value={tag ? [tag] : []}
onChange={(values) => {
const value = values.at(-1)?.trim() || '';
if (value !== tag) onSetTag(record.id, value);
}}
options={tags.map((value) => ({ value, label: value }))}
/>
);
}
return tag ? <Tag color={tagColorMap[tag]}>{tag}</Tag> : <Text type="secondary">-</Text>;
},
},
];
if (canViewFull && sensitiveVisible) {
columns.push(
{ title: '密码', dataIndex: 'password', width: 120 },
{ title: '邮箱', dataIndex: 'email' },
{ title: '邮箱密码', dataIndex: 'email_password', width: 120 },
);
}
if (canDelete) {
columns.push({
title: '操作',
width: 90,
align: 'center',
render: (_: unknown, record: AccountItem) => (
<Popconfirm title="确认删除?" onConfirm={() => onDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
),
});
}
return columns;
}
@@ -0,0 +1,244 @@
import { Modal, Button, Input, Select, Typography, Table, Spin, Space } from 'antd';
import type { AccountItem, AccountExchangeRecords, SupportUserItem } from '../../../api/modules';
import { EXCHANGE_SCOPES, EXCHANGE_COLUMNS } from '../types';
const { TextArea } = Input;
const { Text } = Typography;
export interface ImportModalProps {
open: boolean;
onCancel: () => void;
onOk: () => void;
importing: boolean;
importText: string;
setImportText: (val: string) => void;
importTag: string;
setImportTag: (val: string) => void;
tags: string[];
}
export function BatchImportModal({
open,
onCancel,
onOk,
importing,
importText,
setImportText,
importTag,
setImportTag,
tags,
}: ImportModalProps) {
return (
<Modal
title="批量导入账号"
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={importing}
okText="导入"
width={600}
>
<Text type="secondary">格式:用户名|密码|邮箱|邮箱密码|标签(可选,每行一个)</Text>
<TextArea
rows={10}
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder={`用户名|密码|邮箱|邮箱密码|标签\n用户名|密码|邮箱|邮箱密码|标签`}
style={{ marginTop: 8 }}
/>
<div style={{ marginTop: 12 }}>
<Text type="secondary">统一标签(可选):未在行内写标签的账号将使用此标签</Text>
<Select
mode="tags"
style={{ width: '100%', marginTop: 4 }}
placeholder="输入或选择标签"
value={importTag ? [importTag] : []}
onChange={(vals) => setImportTag(vals.length > 0 ? vals[vals.length - 1] : '')}
options={tags.map((t) => ({ value: t, label: t }))}
/>
</div>
</Modal>
);
}
export interface BatchTagModalProps {
open: boolean;
onCancel: () => void;
onOk: () => void;
selectedCount: number;
selectedAllMatching: boolean;
batchTagInput: string;
setBatchTagInput: (val: string) => void;
tags: string[];
}
export function BatchTagModal({
open,
onCancel,
onOk,
selectedCount,
selectedAllMatching,
batchTagInput,
setBatchTagInput,
tags,
}: BatchTagModalProps) {
return (
<Modal
title="批量设置标签"
open={open}
onCancel={onCancel}
onOk={onOk}
okText="确定"
width={400}
>
<p>为{selectedAllMatching ? '当前筛选下' : '选中的'} {selectedCount} 个账号设置标签:</p>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="输入或选择标签"
value={batchTagInput ? [batchTagInput] : []}
onChange={(vals) => setBatchTagInput(vals.length > 0 ? vals[vals.length - 1] : '')}
options={tags.map((t) => ({ value: t, label: t }))}
/>
</Modal>
);
}
export interface BatchAssignModalProps {
open: boolean;
onCancel: () => void;
assigning: boolean;
selectedCount: number;
selectedAllMatching: boolean;
assignTargetId?: number;
setAssignTargetId: (val?: number) => void;
assignUsers: SupportUserItem[];
onBatchAssign: (target: number | null) => void;
}
export function BatchAssignModal({
open,
onCancel,
assigning,
selectedCount,
selectedAllMatching,
assignTargetId,
setAssignTargetId,
assignUsers,
onBatchAssign,
}: BatchAssignModalProps) {
return (
<Modal
title="批量分配账号"
open={open}
onCancel={onCancel}
confirmLoading={assigning}
footer={[
<Button key="cancel" onClick={onCancel}>
取消
</Button>,
<Button
key="unassign"
danger
loading={assigning}
onClick={() => onBatchAssign(null)}
>
取消分配
</Button>,
<Button
key="assign"
type="primary"
loading={assigning}
disabled={assignTargetId === undefined}
onClick={() => onBatchAssign(assignTargetId ?? null)}
>
确定分配
</Button>,
]}
width={420}
>
<p>
为{selectedAllMatching ? '当前筛选下' : '选中的'} {selectedCount} 个账号分配:
</p>
<Select
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
placeholder="选择要分配给哪个客服"
value={assignTargetId}
onChange={(val) => setAssignTargetId(val ?? undefined)}
options={assignUsers.map((user) => ({
value: user.id,
label: `${user.username}(现 ${user.assigned_count} 个)`,
}))}
/>
<Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
只有仍持有 CK 的账号能被分配;取消分配会清空这些账号的归属。
</Text>
</Modal>
);
}
export interface ExchangeRecordsModalProps {
exAccount: AccountItem | null;
exLoading: boolean;
exRecords: AccountExchangeRecords | null;
onClose: () => void;
}
export function ExchangeRecordsModal({
exAccount,
exLoading,
exRecords,
onClose,
}: ExchangeRecordsModalProps) {
return (
<Modal
title={
<Space>
<span>官方兑换记录</span>
{exAccount && (
<Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal' }}>
{exAccount.username}
</Text>
)}
</Space>
}
open={Boolean(exAccount)}
onCancel={onClose}
footer={<Button onClick={onClose}>关闭</Button>}
width={760}
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
>
{exLoading ? (
<Spin />
) : !exRecords || exRecords.total === 0 ? (
<Text type="secondary">
没有官方兑换记录。若还没查过这个账号,先点工具栏「批量一键查询」。
</Text>
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{EXCHANGE_SCOPES.map(({ key, label }) => {
const rows = exRecords.groups[key] ?? [];
if (rows.length === 0) return null;
return (
<div key={key}>
<Text strong>
{label}({rows.length} 条)
</Text>
<Table
size="small"
style={{ marginTop: 8 }}
rowKey={(row) => `${row.commodity_id}-${row.occurred_at ?? ''}`}
columns={EXCHANGE_COLUMNS}
dataSource={rows}
pagination={false}
/>
</div>
);
})}
</Space>
)}
</Modal>
);
}
@@ -0,0 +1,262 @@
import React from 'react';
import { Space, Select, Input, Dropdown, Button, Tooltip, Popconfirm, type MenuProps } from 'antd';
import {
FilterOutlined,
DownloadOutlined,
DownOutlined,
EyeInvisibleOutlined,
EyeOutlined,
TagOutlined,
UsergroupAddOutlined,
DeleteOutlined,
ThunderboltOutlined,
ImportOutlined,
ClearOutlined,
} from '@ant-design/icons';
import {
CK_OPTIONS,
FAILURE_KIND_OPTIONS,
BIND_OPTIONS,
} from '../types';
export interface AccountToolbarProps {
tags: string[];
tagFilter: string;
setTagFilter: (val: string) => void;
ckFilter: string;
setCkFilter: (val: string) => void;
failureKindFilter: string;
setFailureKindFilter: (val: string) => void;
bindFilter: string;
setBindFilter: (val: string) => void;
assignFilter: string;
setAssignFilter: (val: string) => void;
assignFilterOptions: Array<{ value: string; label: string }>;
searchText: string;
setSearchText: (val: string) => void;
hasAnyFilter: boolean;
onResetAllFilters: () => void;
exportMenuItems: MenuProps['items'];
exporting: boolean;
selectedCount: number;
canViewFull: boolean;
sensitiveVisible: boolean;
onToggleSensitive: () => void;
canTag: boolean;
onOpenBatchTag: () => void;
canAssign: boolean;
onOpenBatchAssign: () => void;
canDelete: boolean;
selectedAllMatching: boolean;
onBatchDelete: () => void;
selectedRowKeys: React.Key[];
batchQuerying: boolean;
onBatchOverview: () => void;
canImport: boolean;
onOpenImport: () => void;
setCurrentPage: (page: number) => void;
}
export function AccountToolbar({
tags,
tagFilter,
setTagFilter,
ckFilter,
setCkFilter,
failureKindFilter,
setFailureKindFilter,
bindFilter,
setBindFilter,
assignFilter,
setAssignFilter,
assignFilterOptions,
searchText,
setSearchText,
hasAnyFilter,
onResetAllFilters,
exportMenuItems,
exporting,
selectedCount,
canViewFull,
sensitiveVisible,
onToggleSensitive,
canTag,
onOpenBatchTag,
canAssign,
onOpenBatchAssign,
canDelete,
selectedAllMatching,
onBatchDelete,
selectedRowKeys,
batchQuerying,
onBatchOverview,
canImport,
onOpenImport,
setCurrentPage,
}: AccountToolbarProps) {
return (
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<h2>账号管理</h2>
<Space wrap>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="按标签筛选"
style={{ width: 140 }}
value={tagFilter || undefined}
onChange={(val) => {
setTagFilter(val || '');
setCurrentPage(1);
}}
options={tags.map((t) => ({ value: t, label: t }))}
prefix={<FilterOutlined />}
/>
<Select
allowClear
size="small"
placeholder="按 CK 筛选"
style={{ width: 110 }}
value={ckFilter || undefined}
onChange={(val) => {
setCkFilter(val || '');
setCurrentPage(1);
}}
options={CK_OPTIONS}
/>
<Select
allowClear
size="small"
placeholder="登录异常分类"
style={{ width: 150 }}
value={failureKindFilter || undefined}
onChange={(val) => {
setFailureKindFilter(val || '');
setCurrentPage(1);
}}
options={FAILURE_KIND_OPTIONS}
/>
<Select
allowClear
size="small"
placeholder="按绑定筛选"
style={{ width: 110 }}
value={bindFilter || undefined}
onChange={(val) => {
setBindFilter(val || '');
setCurrentPage(1);
}}
options={BIND_OPTIONS}
/>
<Select
allowClear
showSearch
optionFilterProp="label"
size="small"
placeholder="按分配筛选"
style={{ width: 130 }}
value={assignFilter || undefined}
onChange={(val) => {
setAssignFilter(val || '');
setCurrentPage(1);
}}
options={assignFilterOptions}
/>
<Input.Search
allowClear
size="small"
placeholder="搜索账号/标签/备注"
style={{ width: 200 }}
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setCurrentPage(1);
}}
/>
{hasAnyFilter && (
<Button
danger
icon={<ClearOutlined />}
onClick={onResetAllFilters}
title="清空所有筛选与搜索条件并恢复默认排序"
>
重置全部筛选
</Button>
)}
<Dropdown menu={{ items: exportMenuItems }} placement="bottomLeft">
<Button icon={<DownloadOutlined />} loading={exporting}>
导出账号 {selectedCount > 0 ? `(${selectedCount})` : ''}{' '}
<DownOutlined style={{ fontSize: 10 }} />
</Button>
</Dropdown>
{canViewFull && (
<Button
icon={sensitiveVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={onToggleSensitive}
>
{sensitiveVisible ? '隐藏凭据' : '显示凭据'}
</Button>
)}
{canTag && (
<Button
disabled={selectedCount === 0}
icon={<TagOutlined />}
onClick={onOpenBatchTag}
>
批量打标签
</Button>
)}
{canAssign && (
<Button
disabled={selectedCount === 0}
icon={<UsergroupAddOutlined />}
onClick={onOpenBatchAssign}
>
批量分配
</Button>
)}
{canDelete && (
<Popconfirm
title={`确认删除${selectedAllMatching ? '当前筛选下' : '选中的'} ${selectedCount} 个账号?`}
description="将同时删除关联的登录任务"
onConfirm={onBatchDelete}
okText="删除"
okButtonProps={{ danger: true }}
cancelText="取消"
>
<Button danger disabled={selectedCount === 0} icon={<DeleteOutlined />}>
批量删除
</Button>
</Popconfirm>
)}
<Tooltip title="实时向斗鱼查这些账号的鱼翅/积分/开通状态/兑换记录,并标出高价值套装是否已拥有">
<Button
disabled={selectedRowKeys.length === 0}
loading={batchQuerying}
icon={<ThunderboltOutlined />}
onClick={onBatchOverview}
>
批量一键查询
</Button>
</Tooltip>
{canImport && (
<Button
type="primary"
icon={<ImportOutlined />}
onClick={onOpenImport}
>
批量导入
</Button>
)}
</Space>
</div>
);
}
@@ -0,0 +1,745 @@
import { Card, Space, Typography, Button, Tooltip, Tag, Row, Col, Select, InputNumber } from 'antd';
import { WarningOutlined, ThunderboltOutlined, UpOutlined, DownOutlined, ClearOutlined } from '@ant-design/icons';
import type { BasicSummary } from '../../../api/modules';
import { HIGH_VALUE_OPTIONS, BIND_OPTIONS } from '../types';
const { Text } = Typography;
interface FilterPanelProps {
summary: BasicSummary;
failureKindFilter: string;
setFailureKindFilter: (val: string | ((prev: string) => string)) => void;
highValueFilter: string;
setHighValueFilter: (val: string | ((prev: string) => string)) => void;
minPoints?: number;
setMinPoints: (val?: number) => void;
maxPoints?: number;
setMaxPoints: (val?: number) => void;
minGold?: number;
setMinGold: (val?: number) => void;
maxGold?: number;
setMaxGold: (val?: number) => void;
eliteOpened?: boolean;
setEliteOpened: (val?: boolean) => void;
esportsOpened?: boolean;
setEsportsOpened: (val?: boolean) => void;
roleBindStatus: string;
setRoleBindStatus: (val: string) => void;
advFilterOpen: boolean;
setAdvFilterOpen: (val: boolean | ((prev: boolean) => boolean)) => void;
hasCustomFilters: boolean;
onResetAdvancedFilters: () => void;
onResetAdvancedFiltersState: () => void;
setCurrentPage: (page: number) => void;
}
export function FilterPanel({
summary,
failureKindFilter,
setFailureKindFilter,
highValueFilter,
setHighValueFilter,
minPoints,
setMinPoints,
maxPoints,
setMaxPoints,
minGold,
setMinGold,
maxGold,
setMaxGold,
eliteOpened,
setEliteOpened,
esportsOpened,
setEsportsOpened,
roleBindStatus,
setRoleBindStatus,
advFilterOpen,
setAdvFilterOpen,
hasCustomFilters,
onResetAdvancedFilters,
onResetAdvancedFiltersState,
setCurrentPage,
}: FilterPanelProps) {
const FAILURE_CHIPS = [
{
key: 'must_bind_mobile',
label: '必须绑定手机号',
icon: '📱',
count: summary.login_failures?.must_bind_mobile ?? 0,
color: '#ff4d4f',
tooltip: '登录提示必须绑定手机号(无法免绑跳过),需人工补充手机号',
active: failureKindFilter === 'must_bind_mobile',
onClick: () => {
setFailureKindFilter((prev) => (prev === 'must_bind_mobile' ? '' : 'must_bind_mobile'));
setCurrentPage(1);
},
},
{
key: 'captcha',
label: '极验滑块拦截',
icon: '🧩',
count: summary.login_failures?.captcha ?? 0,
color: '#fa8c16',
tooltip: '遭遇极验验证码/滑块拦截,需人工辅助或切换网络',
active: failureKindFilter === 'captcha',
onClick: () => {
setFailureKindFilter((prev) => (prev === 'captcha' ? '' : 'captcha'));
setCurrentPage(1);
},
},
{
key: 'credential',
label: '账号凭证问题',
icon: '🔑',
count: summary.login_failures?.credential ?? 0,
color: '#f5222d',
tooltip: '密码错误、账号不存在或缺少密码/邮箱',
active: failureKindFilter === 'credential',
onClick: () => {
setFailureKindFilter((prev) => (prev === 'credential' ? '' : 'credential'));
setCurrentPage(1);
},
},
{
key: 'email_code',
label: '邮箱取码失败',
icon: '✉️',
count: summary.login_failures?.email_code ?? 0,
color: '#1890ff',
tooltip: '未收到邮箱验证码或 IMAP 无法收取邮件',
active: failureKindFilter === 'email_code',
onClick: () => {
setFailureKindFilter((prev) => (prev === 'email_code' ? '' : 'email_code'));
setCurrentPage(1);
},
},
{
key: 'email_quota',
label: '邮箱配额已满',
icon: '📫',
count: summary.login_failures?.email_quota ?? 0,
color: '#722ed1',
tooltip: '斗鱼限制:该账号今日邮箱验证码发送次数已达上限',
active: failureKindFilter === 'email_quota',
onClick: () => {
setFailureKindFilter((prev) => (prev === 'email_quota' ? '' : 'email_quota'));
setCurrentPage(1);
},
},
{
key: 'network',
label: '网络/未知异常',
icon: '🌐',
count: summary.login_failures?.network ?? 0,
color: '#8c8c8c',
tooltip: '网络抖动、代理超时或未知异常,可自动重试',
active: failureKindFilter === 'network',
onClick: () => {
setFailureKindFilter((prev) => (prev === 'network' ? '' : 'network'));
setCurrentPage(1);
},
},
];
const PRESET_CHIPS = [
{
key: 'both_available',
label: '双套均可换',
icon: '🌟',
count: summary.high_value?.both_available,
color: '#52c41a',
active:
highValueFilter === 'both_available' &&
minPoints === undefined &&
maxPoints === undefined &&
minGold === undefined &&
maxGold === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (highValueFilter === 'both_available') {
setHighValueFilter('');
} else {
onResetAdvancedFiltersState();
setHighValueFilter('both_available');
}
setCurrentPage(1);
},
},
{
key: 'can_bairimeng',
label: '可换白日梦',
icon: '👗',
count: summary.high_value?.can_bairimeng,
color: '#1677ff',
active:
highValueFilter === 'can_bairimeng' &&
minPoints === undefined &&
maxPoints === undefined &&
minGold === undefined &&
maxGold === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (highValueFilter === 'can_bairimeng') {
setHighValueFilter('');
} else {
onResetAdvancedFiltersState();
setHighValueFilter('can_bairimeng');
}
setCurrentPage(1);
},
},
{
key: 'can_mengtu',
label: '可换萌兔',
icon: '🐰',
count: summary.high_value?.can_mengtu,
color: '#722ed1',
active:
highValueFilter === 'can_mengtu' &&
minPoints === undefined &&
maxPoints === undefined &&
minGold === undefined &&
maxGold === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (highValueFilter === 'can_mengtu') {
setHighValueFilter('');
} else {
onResetAdvancedFiltersState();
setHighValueFilter('can_mengtu');
}
setCurrentPage(1);
},
},
{
key: 'high_points',
label: '积分≥300',
icon: '💎',
count: summary.high_value?.high_points,
color: '#fa8c16',
active:
minPoints === 300 &&
maxPoints === undefined &&
!highValueFilter &&
minGold === undefined &&
maxGold === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (minPoints === 300) {
setMinPoints(undefined);
} else {
onResetAdvancedFiltersState();
setMinPoints(300);
}
setCurrentPage(1);
},
},
{
key: 'has_gold',
label: '有鱼翅',
icon: '💰',
count: summary.high_value?.has_gold,
color: '#faad14',
active:
minGold === 0.01 &&
maxGold === undefined &&
!highValueFilter &&
minPoints === undefined &&
maxPoints === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (minGold === 0.01) {
setMinGold(undefined);
} else {
onResetAdvancedFiltersState();
setMinGold(0.01);
}
setCurrentPage(1);
},
},
{
key: 'elite_opened',
label: '宝典已开通',
icon: '📜',
count: summary.high_value?.elite_opened,
color: '#13c2c2',
active:
eliteOpened === true &&
!highValueFilter &&
minPoints === undefined &&
maxPoints === undefined &&
minGold === undefined &&
maxGold === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (eliteOpened === true) {
setEliteOpened(undefined);
} else {
onResetAdvancedFiltersState();
setEliteOpened(true);
}
setCurrentPage(1);
},
},
{
key: 'high_value_broken',
label: '高价值待救',
icon: '⚠️',
count: summary.high_value?.high_value_broken,
color: '#f5222d',
tooltip: '两套还能换、只剩一套,或积分≥300,且 CK 已失效',
active:
highValueFilter === 'high_value_broken' &&
minPoints === undefined &&
maxPoints === undefined &&
minGold === undefined &&
maxGold === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (highValueFilter === 'high_value_broken') {
setHighValueFilter('');
} else {
onResetAdvancedFiltersState();
setHighValueFilter('high_value_broken');
}
setCurrentPage(1);
},
},
{
key: 'unqueried',
label: '未查资料',
icon: '🔍',
count: summary.high_value?.unqueried,
color: '#8c8c8c',
active:
highValueFilter === 'unqueried' &&
minPoints === undefined &&
maxPoints === undefined &&
minGold === undefined &&
maxGold === undefined &&
eliteOpened === undefined &&
esportsOpened === undefined &&
!roleBindStatus,
onClick: () => {
if (highValueFilter === 'unqueried') {
setHighValueFilter('');
} else {
onResetAdvancedFiltersState();
setHighValueFilter('unqueried');
}
setCurrentPage(1);
},
},
];
return (
<Card
size="small"
style={{ marginBottom: 12, borderRadius: 6 }}
styles={{ body: { padding: '8px 12px' } }}
>
{/* 登录异常分类一键筛选 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
paddingBottom: 8,
marginBottom: 8,
borderBottom: '1px solid #f0f0f0',
}}
>
<Space wrap size={[6, 6]} style={{ alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 13, marginRight: 4 }}>
<WarningOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />
登录异常一键筛选:
</Text>
{FAILURE_CHIPS.map((chip) => {
const btn = (
<Button
key={chip.key}
size="small"
type={chip.active ? 'primary' : 'default'}
style={{
borderRadius: 14,
fontSize: 12,
height: 26,
padding: '0 10px',
display: 'inline-flex',
alignItems: 'center',
borderColor: chip.active ? chip.color : undefined,
backgroundColor: chip.active ? chip.color : undefined,
color: chip.active ? '#fff' : undefined,
opacity: chip.count === 0 && !chip.active ? 0.5 : 1,
}}
onClick={chip.onClick}
>
<span>
{chip.icon} {chip.label}
</span>
<span
style={{
marginLeft: 5,
padding: '0 5px',
borderRadius: 10,
fontSize: 11,
lineHeight: '16px',
background: chip.active ? 'rgba(255,255,255,0.25)' : '#f0f0f0',
color: chip.active ? '#fff' : chip.count > 0 ? chip.color : '#666',
fontWeight: chip.count > 0 ? 600 : 400,
}}
>
{chip.count ?? 0}
</span>
</Button>
);
return chip.tooltip ? (
<Tooltip key={chip.key} title={chip.tooltip}>
{btn}
</Tooltip>
) : (
btn
);
})}
</Space>
{failureKindFilter && (
<Button
size="small"
type="link"
danger
onClick={() => {
setFailureKindFilter('');
setCurrentPage(1);
}}
style={{ padding: 0 }}
>
清除异常分类筛选
</Button>
)}
</div>
{/* 高价值套装一键筛选 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap size={[6, 6]} style={{ alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 13, marginRight: 4 }}>
<ThunderboltOutlined style={{ color: '#fa8c16', marginRight: 4 }} />
高价值一键筛选:
</Text>
{PRESET_CHIPS.map((chip) => {
const btn = (
<Button
key={chip.key}
size="small"
type={chip.active ? 'primary' : 'default'}
style={{
borderRadius: 14,
fontSize: 12,
height: 26,
padding: '0 10px',
display: 'inline-flex',
alignItems: 'center',
borderColor: chip.active ? chip.color : undefined,
backgroundColor: chip.active ? chip.color : undefined,
color: chip.active ? '#fff' : undefined,
opacity: chip.count === 0 && !chip.active ? 0.5 : 1,
}}
onClick={chip.onClick}
>
<span>
{chip.icon} {chip.label}
</span>
<span
style={{
marginLeft: 5,
padding: '0 5px',
borderRadius: 10,
fontSize: 11,
lineHeight: '16px',
background: chip.active ? 'rgba(255,255,255,0.25)' : '#f0f0f0',
color: chip.active ? '#fff' : '#666',
fontWeight: 500,
}}
>
{chip.count ?? 0}
</span>
</Button>
);
return chip.tooltip ? (
<Tooltip key={chip.key} title={chip.tooltip}>
{btn}
</Tooltip>
) : (
btn
);
})}
</Space>
<Space size={8}>
{hasCustomFilters && (
<Button
size="small"
type="link"
danger
onClick={onResetAdvancedFilters}
style={{ padding: 0 }}
>
清除高价值筛选
</Button>
)}
<Button
size="small"
type={advFilterOpen ? 'primary' : 'default'}
ghost={advFilterOpen}
icon={advFilterOpen ? <UpOutlined /> : <DownOutlined />}
onClick={() => setAdvFilterOpen(!advFilterOpen)}
>
{advFilterOpen ? '收起高级筛选' : '高级自定义筛选'}
{hasCustomFilters && !advFilterOpen && (
<Tag
color="blue"
style={{ marginLeft: 4, marginRight: 0, padding: '0 4px', fontSize: 11 }}
>
已生效
</Tag>
)}
</Button>
</Space>
</div>
{/* 折叠展开的高级筛选面板 */}
{advFilterOpen && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px dashed #f0f0f0' }}>
<Row gutter={[16, 10]} align="middle">
<Col>
<Space size={6} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>
套装状态:
</Text>
<Select
allowClear
size="small"
placeholder="全部状态"
style={{ width: 180 }}
value={highValueFilter || undefined}
onChange={(val) => {
setHighValueFilter(val || '');
setCurrentPage(1);
}}
options={HIGH_VALUE_OPTIONS}
/>
</Space>
</Col>
<Col>
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>
积分范围:
</Text>
<InputNumber
size="small"
placeholder="最小"
min={0}
value={minPoints}
onChange={(v) => {
setMinPoints(v ?? undefined);
setCurrentPage(1);
}}
style={{ width: 75 }}
/>
<span>~</span>
<InputNumber
size="small"
placeholder="最大"
min={0}
value={maxPoints}
onChange={(v) => {
setMaxPoints(v ?? undefined);
setCurrentPage(1);
}}
style={{ width: 75 }}
/>
<Button
size="small"
type="dashed"
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => {
setMinPoints(300);
setCurrentPage(1);
}}
>
≥300
</Button>
<Button
size="small"
type="dashed"
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => {
setMinPoints(600);
setCurrentPage(1);
}}
>
≥600
</Button>
</Space>
</Col>
<Col>
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>
鱼翅余额:
</Text>
<InputNumber
size="small"
placeholder="最小"
min={0}
step={1}
value={minGold}
onChange={(v) => {
setMinGold(v ?? undefined);
setCurrentPage(1);
}}
style={{ width: 75 }}
/>
<span>~</span>
<InputNumber
size="small"
placeholder="最大"
min={0}
step={1}
value={maxGold}
onChange={(v) => {
setMaxGold(v ?? undefined);
setCurrentPage(1);
}}
style={{ width: 75 }}
/>
<Button
size="small"
type="dashed"
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => {
setMinGold(0.01);
setCurrentPage(1);
}}
>
&gt;0
</Button>
<Button
size="small"
type="dashed"
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => {
setMinGold(6);
setCurrentPage(1);
}}
>
≥6
</Button>
</Space>
</Col>
<Col>
<Space size={6} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>
宝典:
</Text>
<Select
size="small"
allowClear
placeholder="全部"
style={{ width: 95 }}
value={eliteOpened === undefined ? undefined : String(eliteOpened)}
onChange={(val) => {
setEliteOpened(val === undefined ? undefined : val === 'true');
setCurrentPage(1);
}}
options={[
{ value: 'true', label: '宝典已开' },
{ value: 'false', label: '宝典未开' },
]}
/>
</Space>
</Col>
<Col>
<Space size={6} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>
电竞:
</Text>
<Select
size="small"
allowClear
placeholder="全部"
style={{ width: 95 }}
value={esportsOpened === undefined ? undefined : String(esportsOpened)}
onChange={(val) => {
setEsportsOpened(val === undefined ? undefined : val === 'true');
setCurrentPage(1);
}}
options={[
{ value: 'true', label: '电竞已开' },
{ value: 'false', label: '电竞未开' },
]}
/>
</Space>
</Col>
<Col>
<Space size={6} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>
角色绑定:
</Text>
<Select
size="small"
allowClear
placeholder="全部"
style={{ width: 100 }}
value={roleBindStatus || undefined}
onChange={(val) => {
setRoleBindStatus(val || '');
setCurrentPage(1);
}}
options={BIND_OPTIONS}
/>
</Space>
</Col>
<Col>
<Button
size="small"
icon={<ClearOutlined />}
onClick={onResetAdvancedFilters}
disabled={!hasCustomFilters}
>
重置自定义条件
</Button>
</Col>
</Row>
</div>
)}
</Card>
);
}
@@ -0,0 +1,57 @@
import { Row, Col, Card, Statistic } from 'antd';
import type { BasicSummary } from '../../../api/modules';
import { STATUS_CARDS } from '../types';
interface StatusCardsProps {
summary: BasicSummary;
statusFilter: string;
ckFilter: string;
onSelectStatus: (status: string) => void;
onClearAllStatus: () => void;
}
export function StatusCards({
summary,
statusFilter,
ckFilter,
onSelectStatus,
onClearAllStatus,
}: StatusCardsProps) {
return (
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col flex="120px">
<Card
size="small"
hoverable
onClick={onClearAllStatus}
style={{ borderColor: statusFilter || ckFilter ? undefined : '#1677ff' }}
>
<Statistic title="账号总数" value={summary.visible_total ?? summary.total} />
</Card>
</Col>
{STATUS_CARDS.map((card) => {
const count = summary.by_status?.[card.value] ?? 0;
const active = statusFilter === card.value;
return (
<Col flex="120px" key={card.value}>
<Card
size="small"
hoverable
onClick={() => onSelectStatus(active ? '' : card.value)}
style={{
borderColor: active ? card.color : undefined,
opacity: count === 0 && !active ? 0.55 : 1,
}}
>
<Statistic
title={card.label}
value={count}
valueStyle={{ color: count > 0 ? card.color : undefined, fontSize: 20 }}
/>
</Card>
</Col>
);
})}
</Row>
);
}
@@ -0,0 +1,34 @@
import type { AccountsFilterState } from './types';
export const FILTER_STORAGE_KEY = 'accounts_page_filters_v1';
export function loadStoredFilters(): Partial<AccountsFilterState> {
try {
const raw = sessionStorage.getItem(FILTER_STORAGE_KEY) || localStorage.getItem(FILTER_STORAGE_KEY);
if (raw) {
return JSON.parse(raw);
}
} catch {
// 容错:解析失败回退空
}
return {};
}
export function saveStoredFilters(state: AccountsFilterState) {
try {
const json = JSON.stringify(state);
sessionStorage.setItem(FILTER_STORAGE_KEY, json);
localStorage.setItem(FILTER_STORAGE_KEY, json);
} catch {
// 容错
}
}
export function clearStoredFilters() {
try {
sessionStorage.removeItem(FILTER_STORAGE_KEY);
localStorage.removeItem(FILTER_STORAGE_KEY);
} catch {
// 容错
}
}
+144
View File
@@ -0,0 +1,144 @@
import type { TableProps } from 'antd';
import type { AccountExchangeRecord } from '../../api/modules';
import { formatTime } from '../../utils/time';
export const TAG_COLORS = [
'blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano',
];
export const FAILURE_KIND_OPTIONS = [
{ value: 'must_bind_mobile', label: '📱 必须绑定手机号' },
{ value: 'captcha', label: '🧩 极验/验证码拦截' },
{ value: 'credential', label: '🔑 账号凭证问题' },
{ value: 'email_code', label: '✉️ 邮箱取码失败' },
{ value: 'email_quota', label: '📫 邮箱配额已满' },
{ value: 'network', label: '🌐 网络/未知异常' },
];
export const FAILURE_KIND_LABELS: Record<string, { label: string; color: string }> = {
must_bind_mobile: { label: '必须绑定手机号', color: 'red' },
captcha: { label: '极验滑块', color: 'orange' },
credential: { label: '凭证错误', color: 'error' },
email_code: { label: '邮箱取码失败', color: 'processing' },
email_quota: { label: '邮箱配额已满', color: 'purple' },
network: { label: '网络异常', color: 'default' },
interrupted: { label: '被中断', color: 'default' },
};
export type AccountSortKey =
| 'username'
| 'status'
| 'bind'
| 'points'
| 'high_value'
| 'exchange'
| 'tag';
export const ACCOUNT_SORT_KEYS = new Set<string>([
'username',
'status',
'bind',
'points',
'high_value',
'exchange',
'tag',
]);
export const STATUS_TONES: Record<string, string> = {
imported: 'default',
active: 'green',
invalid: 'red',
manual: 'orange',
disabled: 'default',
archived: 'default',
};
export const CK_OPTIONS = [
{ value: 'invalid', label: 'CK 失效' },
{ value: 'valid', label: 'CK 正常' },
{ value: 'error', label: '检测异常' },
];
export const BIND_OPTIONS = [
{ value: 'bound', label: '已绑定' },
{ value: 'pending', label: '待确认' },
{ value: 'unbound', label: '未绑定' },
{ value: 'unknown', label: '绑定未知' },
];
export 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' },
];
export 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: '❓ 未查过资料' },
];
export const EXCHANGE_SCOPES: Array<{ key: string; label: string }> = [
{ key: 'elite', label: '精英宝典' },
{ key: 'esports', label: '电竞手册' },
{ key: 'peace', label: '和平小店' },
];
export const EXCHANGE_COLUMNS: TableProps<AccountExchangeRecord>['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 || '-',
},
];
export interface AccountsFilterState {
tagFilter: string;
statusFilter: string;
ckFilter: string;
failureKindFilter: string;
bindFilter: string;
assignFilter: string;
searchText: string;
highValueFilter: string;
minPoints?: number;
maxPoints?: number;
minGold?: number;
maxGold?: number;
eliteOpened?: boolean;
esportsOpened?: boolean;
roleBindStatus: string;
currentPage: number;
pageSize: number;
sortKey: AccountSortKey | '';
sortOrder: 'ascend' | 'descend' | null;
advFilterOpen?: boolean;
}