Merge branch 'main' into dev: 同步分配管理优化、登录异常分型及历史数据刷正
This commit is contained in:
@@ -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:
|
||||
"""捕获旋转验证码的所有图片和元数据。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""账号登录失败分类统计、筛选与多选导出测试。
|
||||
|
||||
涵盖:
|
||||
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 (
|
||||
classify_failure(
|
||||
"重新登录失败: 跳过手机号绑定失败: 必须绑定手机号(旧 Cookie 已保留)"
|
||||
)
|
||||
== "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_classify_account_canceled_or_banned(self):
|
||||
"""测试账号已注销/封禁/冻结归入凭证/账号异常问题。"""
|
||||
assert (
|
||||
classify_failure(
|
||||
"重新登录失败: 第二次登录失败: 账号已注销(旧 Cookie 已保留)"
|
||||
)
|
||||
== "credential"
|
||||
)
|
||||
assert classify_failure("第二次登录失败: 账号已注销") == "credential"
|
||||
assert classify_failure("登录失败: 该账号已被封禁") == "credential"
|
||||
assert classify_failure("账号已冻结,请联系客服") == "credential"
|
||||
|
||||
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"
|
||||
@@ -2052,7 +2052,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
|
||||
|
||||
|
||||
@@ -454,8 +454,9 @@ class TestPointsExpireJob(_Base):
|
||||
def test_expired_points_are_recorded_once(self):
|
||||
from datetime import timedelta
|
||||
|
||||
now = datetime.now(UTC)
|
||||
self.account.points = 500
|
||||
self.account.points_expire_at = NOW - timedelta(days=1)
|
||||
self.account.points_expire_at = now - timedelta(days=1)
|
||||
self.session.commit()
|
||||
|
||||
out = self._run()
|
||||
@@ -472,8 +473,9 @@ class TestPointsExpireJob(_Base):
|
||||
def test_not_yet_expired_is_left_alone(self):
|
||||
from datetime import timedelta
|
||||
|
||||
now = datetime.now(UTC)
|
||||
self.account.points = 500
|
||||
self.account.points_expire_at = NOW + timedelta(days=3)
|
||||
self.account.points_expire_at = now + timedelta(days=3)
|
||||
self.session.commit()
|
||||
|
||||
assert self._run()["written"] == 0
|
||||
@@ -484,8 +486,9 @@ class TestPointsExpireJob(_Base):
|
||||
"""积分已经为 0 就没有东西可到期 —— 写一条 -0 只是噪声。"""
|
||||
from datetime import timedelta
|
||||
|
||||
now = datetime.now(UTC)
|
||||
self.account.points = 0
|
||||
self.account.points_expire_at = NOW - timedelta(days=1)
|
||||
self.account.points_expire_at = now - timedelta(days=1)
|
||||
self.session.commit()
|
||||
|
||||
out = self._run()
|
||||
|
||||
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261007_0052"
|
||||
HEAD_REVISION = "20261008_0053"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""同步历史重登失败分型:根据最新 classify_failure 规则刷正 accounts 和 login_tasks
|
||||
|
||||
## 背景
|
||||
|
||||
新增了「必须绑定手机号」与「账号注销/封禁」分型,历史已存在报错文案的账号需要回写正确的 failure_kind,
|
||||
使「登录异常一键筛选」与列表角标统计准确。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from web.backend.services.login_failure import classify_failure
|
||||
|
||||
revision: str = "20261008_0052"
|
||||
down_revision: str | None = "20261004_0051"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. 查询 accounts 中有 relogin_last_error 的记录
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, relogin_last_error, relogin_failure_kind "
|
||||
"FROM accounts "
|
||||
"WHERE relogin_last_error IS NOT NULL AND relogin_last_error != ''"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
acc_id, err_text, current_kind = row[0], row[1], row[2]
|
||||
new_kind = classify_failure(err_text)
|
||||
if new_kind and new_kind != current_kind:
|
||||
conn.execute(
|
||||
sa.text("UPDATE accounts SET relogin_failure_kind = :k WHERE id = :id"),
|
||||
{"k": new_kind, "id": acc_id},
|
||||
)
|
||||
|
||||
# 2. 查询 login_tasks 中有 message 的记录
|
||||
# 检查 login_tasks 是否有 relogin_failure_kind 列
|
||||
col_exists = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_schema = DATABASE() "
|
||||
"AND table_name = 'login_tasks' AND column_name = 'relogin_failure_kind'"
|
||||
)
|
||||
).scalar()
|
||||
if col_exists:
|
||||
task_rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, message, relogin_failure_kind "
|
||||
"FROM login_tasks "
|
||||
"WHERE message IS NOT NULL AND message != ''"
|
||||
)
|
||||
).fetchall()
|
||||
for row in task_rows:
|
||||
task_id, msg, current_kind = row[0], row[1], row[2]
|
||||
new_kind = classify_failure(msg)
|
||||
if new_kind and new_kind != current_kind:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE login_tasks SET relogin_failure_kind = :k WHERE id = :id"
|
||||
),
|
||||
{"k": new_kind, "id": task_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
+2
-2
@@ -25,8 +25,8 @@ from __future__ import annotations
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261007_0052"
|
||||
down_revision: str | None = "20261004_0051"
|
||||
revision: str = "20261008_0053"
|
||||
down_revision: str | None = "20261008_0052"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
+159
-16
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ from ..schemas import (
|
||||
AccountAutomationUpdate,
|
||||
AccountBulkSelection,
|
||||
AccountBulkTag,
|
||||
AccountExportRequest,
|
||||
AccountImport,
|
||||
AccountLifecycleUpdate,
|
||||
AccountOut,
|
||||
@@ -69,6 +70,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 classify_failure, failure_kind_label
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
||||
|
||||
@@ -104,6 +106,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,
|
||||
@@ -117,6 +123,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,
|
||||
@@ -128,9 +135,9 @@ def _filter_accounts_query(
|
||||
role_bind_status: str = "",
|
||||
):
|
||||
"""复用列表筛选条件,供分页列表和全量批量操作保持一致。"""
|
||||
if 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(has_cookie):
|
||||
query = query.filter(Account.cookie.isnot(None), Account.cookie != "")
|
||||
if _query_bool(assigned_only) and user_has_permission(current, "account:view_all"):
|
||||
query = query.filter(Account.assigned_to.isnot(None))
|
||||
# 归属筛选:未分配 / 已分配,再叠加「指定客服」。与其它条件(标签、CK、
|
||||
# 搜索…)是**同时生效**的,这正是「综合筛选」的用法。
|
||||
@@ -155,6 +162,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:
|
||||
# 三个作用域任一命中即算(列表上「绑定」是一列,不分开筛)
|
||||
@@ -252,6 +262,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,
|
||||
@@ -405,6 +416,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"
|
||||
),
|
||||
@@ -445,6 +460,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,12 @@ def list_accounts(
|
||||
|
||||
result = []
|
||||
for acc in accounts:
|
||||
eff_kind = acc.relogin_failure_kind or ""
|
||||
if acc.relogin_last_error:
|
||||
refined = classify_failure(acc.relogin_last_error)
|
||||
if not eff_kind or (eff_kind == "network" and refined != "network"):
|
||||
eff_kind = refined
|
||||
|
||||
item = AccountOut(
|
||||
id=acc.id,
|
||||
username=acc.username,
|
||||
@@ -577,6 +599,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=eff_kind,
|
||||
relogin_failure_label=failure_kind_label(eff_kind),
|
||||
relogin_last_error=acc.relogin_last_error or "",
|
||||
# 关键列:全部是 accounts 行上的字段,无 JOIN / 无聚合
|
||||
has_cookie=bool(acc.cookie),
|
||||
ck_check_status=acc.ck_check_status or "",
|
||||
@@ -646,6 +671,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)
|
||||
@@ -681,6 +719,8 @@ def accounts_summary(
|
||||
"visible_total": visible_total,
|
||||
# 生命周期分布(键是 6 状态的枚举值)
|
||||
"by_status": by_status,
|
||||
# 登录异常分类统计
|
||||
"login_failures": login_failures,
|
||||
# 高价值与核心资产统计
|
||||
"high_value": high_value_summary,
|
||||
# 需要人关注的:CK 已被判失效的账号数(含待人工)
|
||||
@@ -702,6 +742,99 @@ 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:
|
||||
eff_kind = acc.relogin_failure_kind or ""
|
||||
if acc.relogin_last_error:
|
||||
refined = classify_failure(acc.relogin_last_error)
|
||||
if not eff_kind or (eff_kind == "network" and refined != "network"):
|
||||
eff_kind = refined
|
||||
writer.writerow(
|
||||
[
|
||||
acc.username or "",
|
||||
STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
||||
failure_kind_label(eff_kind) if eff_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,
|
||||
@@ -897,30 +1030,40 @@ def assignments_summary(
|
||||
current: User = Depends(require_permission("account:assign")),
|
||||
):
|
||||
"""分配概览:每个客服分配了多少账号(仅统计当前仍持有 CK 的账号)。"""
|
||||
cookie_subq = select(Account.id).where(cookie_holding_filter())
|
||||
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
|
||||
|
||||
results = (
|
||||
db.query(
|
||||
User.id, User.username, func.count(cookie_accounts.c.id).label("count")
|
||||
db.query(User.id, User.username, func.count(Account.id).label("count"))
|
||||
.outerjoin(
|
||||
Account,
|
||||
and_(
|
||||
Account.assigned_to == User.id,
|
||||
Account.cookie.isnot(None),
|
||||
Account.cookie != "",
|
||||
),
|
||||
)
|
||||
.outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id)
|
||||
.filter(User.role == "support")
|
||||
.group_by(User.id, User.username)
|
||||
.order_by(func.count(cookie_accounts.c.id).desc())
|
||||
.order_by(func.count(Account.id).desc())
|
||||
.all()
|
||||
)
|
||||
total_unassigned = (
|
||||
db.query(func.count(cookie_accounts.c.id))
|
||||
.filter(cookie_accounts.c.assigned_to.is_(None))
|
||||
db.query(func.count(Account.id))
|
||||
.filter(
|
||||
Account.assigned_to.is_(None),
|
||||
Account.cookie.isnot(None),
|
||||
Account.cookie != "",
|
||||
)
|
||||
.scalar()
|
||||
) or 0
|
||||
total_assigned = sum(int(cnt or 0) for _, _, cnt in results)
|
||||
total_accounts = total_assigned + total_unassigned
|
||||
return {
|
||||
"support_users": [
|
||||
{"id": uid, "username": uname, "assigned_count": cnt}
|
||||
{"id": uid, "username": uname, "assigned_count": int(cnt or 0)}
|
||||
for uid, uname, cnt in results
|
||||
],
|
||||
"unassigned_count": total_unassigned,
|
||||
"assigned_count": total_assigned,
|
||||
"total_count": total_accounts,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: "未登录",
|
||||
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
|
||||
#: 型别 → 中文标签(导出 CSV 与页面上用同一份,避免两处各写一遍漂移)。
|
||||
FAILURE_KIND_LABELS: dict[str, str] = {
|
||||
"must_bind_mobile": "必须绑定手机号",
|
||||
"captcha": "极验/验证码拦截",
|
||||
"credential": "账号凭证问题",
|
||||
"email_code": "邮箱取码失败",
|
||||
@@ -39,20 +40,36 @@ 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:
|
||||
if "极验" in text or ("验证" in text and "滑块" in text):
|
||||
return "captcha"
|
||||
if "验证码" in text:
|
||||
if "验证码" in text or "取码" in text:
|
||||
return "email_code"
|
||||
if "中断" in text or "已停止" in text or "任务已停止" in text:
|
||||
return "interrupted"
|
||||
if "密码" in text or "账号不存在" in text or "缺少密码或邮箱" in text:
|
||||
if any(
|
||||
k in text
|
||||
for k in (
|
||||
"密码",
|
||||
"账号不存在",
|
||||
"缺少密码或邮箱",
|
||||
"注销",
|
||||
"已注销",
|
||||
"封禁",
|
||||
"冻结",
|
||||
"限制登录",
|
||||
"禁止登录",
|
||||
)
|
||||
):
|
||||
return "credential"
|
||||
# 请求失败/超时/连接异常,以及未知文案:都按可重试处理。
|
||||
# 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '' }),
|
||||
|
||||
@@ -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;
|
||||
/** 高价值及核心资产统计 */
|
||||
@@ -301,6 +308,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;
|
||||
@@ -326,6 +337,8 @@ export interface AccountItem {
|
||||
export interface AssignmentsSummary {
|
||||
support_users: SupportUserItem[];
|
||||
unassigned_count: number;
|
||||
assigned_count?: number;
|
||||
total_count?: number;
|
||||
}
|
||||
|
||||
export interface SupportUserItem {
|
||||
|
||||
+489
-1269
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,19 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Card, Table, Button, Select, Input, Tag, Row, Col, Statistic,
|
||||
Space, Tabs, Typography, Badge, theme,
|
||||
Space, Tabs, Typography, Badge, theme, Tooltip,
|
||||
} from 'antd';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import {
|
||||
UserOutlined, CheckCircleOutlined, TeamOutlined,
|
||||
SwapOutlined, ClearOutlined, UsergroupAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { accountApi, type AccountItem, type SupportUserItem } from '../api/modules';
|
||||
import {
|
||||
accountApi,
|
||||
type AccountItem,
|
||||
type SupportUserItem,
|
||||
type AssignmentsSummary,
|
||||
} from '../api/modules';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -17,13 +22,19 @@ const STORAGE_KEY_SELECTED_USER = 'assignments_selected_user_id';
|
||||
|
||||
export default function AssignmentsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [summary, setSummary] = useState<AssignmentsSummary | null>(null);
|
||||
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
|
||||
const [allTags, setAllTags] = useState<string[]>([]);
|
||||
|
||||
// 表格分页与筛选状态
|
||||
const [pagedAccounts, setPagedAccounts] = useState<AccountItem[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterTag, setFilterTag] = useState<string | undefined>(undefined);
|
||||
const [activeTab, setActiveTab] = useState('unassigned');
|
||||
const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned');
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const v = localStorage.getItem('assignment_page_size');
|
||||
@@ -31,83 +42,86 @@ export default function AssignmentsPage() {
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 加载分配概览
|
||||
const loadSummary = async () => {
|
||||
// 加载分配概览统计
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const data = await accountApi.assignmentsSummary();
|
||||
setSummary(data);
|
||||
setSupportUsers(data.support_users);
|
||||
return data.support_users;
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载账号(仅已成功登录过的)
|
||||
const loadAccounts = async () => {
|
||||
// 加载标签列表
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
const all = await accountApi.list({ has_cookie: true, include_sensitive: false });
|
||||
setAccounts(all);
|
||||
const tags = await accountApi.listTags();
|
||||
setAllTags(tags);
|
||||
} catch {
|
||||
// 忽略标签加载异常
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 分页按需加载账号(服务端过滤与分页)
|
||||
const loadPagedAccounts = useCallback(async () => {
|
||||
setTableLoading(true);
|
||||
try {
|
||||
const params: Parameters<typeof accountApi.listPaged>[0] = {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
has_cookie: true,
|
||||
include_sensitive: false,
|
||||
search: searchText.trim() || undefined,
|
||||
tag: filterTag || undefined,
|
||||
};
|
||||
|
||||
if (activeTab === 'unassigned') {
|
||||
params.assign_status = 'unassigned';
|
||||
} else if (selectedUser) {
|
||||
params.assigned_user_id = selectedUser.id;
|
||||
}
|
||||
|
||||
const data = await accountApi.listPaged(params);
|
||||
setPagedAccounts(data.items);
|
||||
setTotalCount(data.total);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setTableLoading(false);
|
||||
}
|
||||
};
|
||||
}, [activeTab, selectedUser, currentPage, pageSize, searchText, filterTag]);
|
||||
|
||||
// 初始化加载 + 自动选中客服
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
loadAccounts();
|
||||
loadTags();
|
||||
const users = await loadSummary();
|
||||
if (users.length > 0) {
|
||||
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
|
||||
const savedUser = savedId ? users.find((u: SupportUserItem) => u.id === Number(savedId)) : null;
|
||||
const savedUser = savedId ? users.find((u) => u.id === Number(savedId)) : null;
|
||||
setSelectedUser(savedUser || users[0]);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
}, [loadSummary, loadTags]);
|
||||
|
||||
// 标签列表
|
||||
const allTags = useMemo(() => {
|
||||
const tags = [...new Set(accounts.map((a) => (a.tag || '').trim()).filter(Boolean))];
|
||||
return tags.sort();
|
||||
}, [accounts]);
|
||||
|
||||
// 未分配账号
|
||||
const unassignedAccounts = useMemo(() => {
|
||||
return accounts.filter((a) => !a.assigned_to);
|
||||
}, [accounts]);
|
||||
|
||||
// 当前选中客服的已分配账号
|
||||
const assignedAccounts = useMemo(() => {
|
||||
if (!selectedUser) return [];
|
||||
return accounts.filter((a) => a.assigned_to === selectedUser.id);
|
||||
}, [accounts, selectedUser]);
|
||||
|
||||
// 当前显示的账号集合
|
||||
const displayAccounts = useMemo(() => {
|
||||
let list = activeTab === 'unassigned' ? unassignedAccounts : assignedAccounts;
|
||||
if (searchText) {
|
||||
const s = searchText.toLowerCase();
|
||||
list = list.filter((a) => a.username.toLowerCase().includes(s));
|
||||
}
|
||||
if (filterTag) {
|
||||
list = list.filter((a) => (a.tag || '').trim() === filterTag);
|
||||
}
|
||||
return list;
|
||||
}, [activeTab, unassignedAccounts, assignedAccounts, searchText, filterTag]);
|
||||
// 当筛选或选中客服变化时触发分页请求
|
||||
useEffect(() => {
|
||||
loadPagedAccounts();
|
||||
}, [loadPagedAccounts]);
|
||||
|
||||
// 切换客服
|
||||
const handleSelectUser = (user: SupportUserItem) => {
|
||||
setSelectedUser(user);
|
||||
setSelectedRowKeys([]);
|
||||
setActiveTab('unassigned');
|
||||
setSearchText('');
|
||||
setFilterTag(undefined);
|
||||
setCurrentPage(1);
|
||||
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
|
||||
};
|
||||
|
||||
// 批量分配
|
||||
// 批量分配选中账号
|
||||
const handleBatchAssign = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请先选择账号');
|
||||
@@ -122,10 +136,8 @@ export default function AssignmentsPage() {
|
||||
await accountApi.batchAssign(selectedRowKeys, selectedUser.id);
|
||||
message.success(`已将 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}`);
|
||||
setSelectedRowKeys([]);
|
||||
await loadAccounts();
|
||||
const users = await loadSummary();
|
||||
const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser.id);
|
||||
if (refreshed) setSelectedUser(refreshed);
|
||||
await loadSummary();
|
||||
await loadPagedAccounts();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
@@ -144,10 +156,8 @@ export default function AssignmentsPage() {
|
||||
await accountApi.batchAssign(selectedRowKeys, null);
|
||||
message.success(`已取消 ${selectedRowKeys.length} 个账号的分配`);
|
||||
setSelectedRowKeys([]);
|
||||
await loadAccounts();
|
||||
const users = await loadSummary();
|
||||
const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser?.id);
|
||||
if (refreshed) setSelectedUser(refreshed);
|
||||
await loadSummary();
|
||||
await loadPagedAccounts();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
@@ -155,31 +165,29 @@ export default function AssignmentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 一键将未分配账号全部分配给选中客服
|
||||
// 一键将当前未分配(含搜索/标签筛选)全部分配给选中客服
|
||||
const handleAssignAllUnassigned = async () => {
|
||||
if (!selectedUser) {
|
||||
message.warning('请先选择客服');
|
||||
return;
|
||||
}
|
||||
const ids = unassignedAccounts
|
||||
.filter((a) => {
|
||||
if (searchText && !a.username.toLowerCase().includes(searchText.toLowerCase())) return false;
|
||||
if (filterTag && (a.tag || '').trim() !== filterTag) return false;
|
||||
return true;
|
||||
})
|
||||
.map((a) => a.id);
|
||||
if (ids.length === 0) {
|
||||
message.info('没有未分配的账号');
|
||||
if (totalCount === 0) {
|
||||
message.info('当前筛选下没有未分配的账号');
|
||||
return;
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
await accountApi.batchAssign(ids, selectedUser.id);
|
||||
message.success(`已将 ${ids.length} 个账号分配给 ${selectedUser.username}`);
|
||||
await loadAccounts();
|
||||
const users = await loadSummary();
|
||||
const refreshed = users.find((u: SupportUserItem) => u.id === selectedUser.id);
|
||||
if (refreshed) setSelectedUser(refreshed);
|
||||
const res = await accountApi.batchAssignSelection({
|
||||
all_matching: true,
|
||||
assign_status: 'unassigned',
|
||||
tag: filterTag || undefined,
|
||||
search: searchText.trim() || undefined,
|
||||
assigned_to: selectedUser.id,
|
||||
});
|
||||
message.success(res.message || `已全部分配给 ${selectedUser.username}`);
|
||||
setSelectedRowKeys([]);
|
||||
await loadSummary();
|
||||
await loadPagedAccounts();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
@@ -187,18 +195,52 @@ export default function AssignmentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 统计
|
||||
const assignedCount = accounts.filter((a) => !!a.assigned_to).length;
|
||||
const unassignedCount = accounts.length - assignedCount;
|
||||
// 统计数据
|
||||
const assignedCount = summary?.assigned_count ?? 0;
|
||||
const unassignedCount = summary?.unassigned_count ?? 0;
|
||||
const totalAccountCount = summary?.total_count ?? (assignedCount + unassignedCount);
|
||||
|
||||
// 获取当前选中客服已分配总数
|
||||
const currentSelectedUserAssignedCount =
|
||||
(selectedUser ? supportUsers.find((u) => u.id === selectedUser.id)?.assigned_count : 0) ??
|
||||
selectedUser?.assigned_count ??
|
||||
0;
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
render: (text: string) => <Text strong>{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
width: 100,
|
||||
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
|
||||
width: 120,
|
||||
render: (tag: string) => (tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>),
|
||||
},
|
||||
{
|
||||
title: '积分 / 鱼翅',
|
||||
dataIndex: 'points',
|
||||
width: 140,
|
||||
render: (_: unknown, record: AccountItem) => (
|
||||
<Space size={4}>
|
||||
<span>积分 {record.points ?? '-'}</span>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
/ 鱼翅 {record.gold_balance ?? '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (status: string, record: AccountItem) => (
|
||||
<Tag color={status === 'active' ? 'green' : 'default'}>
|
||||
{record.status_label || status || '-'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -208,22 +250,38 @@ export default function AssignmentsPage() {
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={12} style={{ flexShrink: 0 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small"><Statistic title="总账号" value={accounts.length} prefix={<TeamOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="已分配" value={assignedCount} styles={{ content: { color: token.colorSuccess } }} prefix={<CheckCircleOutlined />} />
|
||||
<Statistic title="总账号 (持CK)" value={totalAccountCount} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="未分配" value={unassignedCount} styles={{ content: { color: token.colorError } }} prefix={<SwapOutlined />} />
|
||||
<Statistic
|
||||
title="已分配"
|
||||
value={assignedCount}
|
||||
styles={{ content: { color: token.colorSuccess } }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="客服人数" value={supportUsers.length} prefix={<UsergroupAddOutlined />} />
|
||||
<Statistic
|
||||
title="未分配"
|
||||
value={unassignedCount}
|
||||
styles={{ content: { color: token.colorError } }}
|
||||
prefix={<SwapOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="客服人数"
|
||||
value={supportUsers.length}
|
||||
prefix={<UsergroupAddOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -236,7 +294,7 @@ export default function AssignmentsPage() {
|
||||
title="客服列表"
|
||||
size="small"
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
bodyStyle={{ flex: 1, overflow: 'auto', padding: '8px' }}
|
||||
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px' } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{supportUsers.map((user) => {
|
||||
@@ -267,7 +325,14 @@ export default function AssignmentsPage() {
|
||||
<UserOutlined style={{ color: token.colorPrimary }} />
|
||||
<Text strong>{user.username}</Text>
|
||||
</Space>
|
||||
<Badge count={user.assigned_count} showZero style={{ backgroundColor: token.colorPrimary }} />
|
||||
<Tooltip title={`已分配 ${user.assigned_count} 个账号`}>
|
||||
<Badge
|
||||
count={user.assigned_count}
|
||||
overflowCount={999999}
|
||||
showZero
|
||||
style={{ backgroundColor: token.colorPrimary }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -290,7 +355,7 @@ export default function AssignmentsPage() {
|
||||
<>
|
||||
<span>已选中客服:</span>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
|
||||
{selectedUser.username}(已分配 {selectedUser.assigned_count} 个)
|
||||
{selectedUser.username}(已分配 {currentSelectedUserAssignedCount} 个)
|
||||
</Tag>
|
||||
</>
|
||||
) : (
|
||||
@@ -299,15 +364,27 @@ export default function AssignmentsPage() {
|
||||
</Space>
|
||||
}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 }}
|
||||
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 } }}
|
||||
>
|
||||
{/* 筛选栏 */}
|
||||
<div style={{ padding: '8px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder="搜索用户名"
|
||||
allowClear
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchText(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
size="small"
|
||||
/>
|
||||
@@ -316,7 +393,10 @@ export default function AssignmentsPage() {
|
||||
showSearch
|
||||
placeholder="按标签筛选"
|
||||
value={filterTag}
|
||||
onChange={(v) => setFilterTag(v || undefined)}
|
||||
onChange={(v) => {
|
||||
setFilterTag(v || undefined);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label || '').toLowerCase().includes(input.trim().toLowerCase())
|
||||
}
|
||||
@@ -330,17 +410,21 @@ export default function AssignmentsPage() {
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => { setActiveTab(key); setSelectedRowKeys([]); }}
|
||||
onChange={(key) => {
|
||||
setActiveTab(key as 'unassigned' | 'assigned');
|
||||
setSelectedRowKeys([]);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
|
||||
items={[
|
||||
{
|
||||
key: 'unassigned',
|
||||
label: `未分配 (${unassignedAccounts.length})`,
|
||||
label: `未分配 (${unassignedCount})`,
|
||||
},
|
||||
{
|
||||
key: 'assigned',
|
||||
label: selectedUser
|
||||
? `已分配给 ${selectedUser.username} (${assignedAccounts.length})`
|
||||
? `已分配给 ${selectedUser.username} (${currentSelectedUserAssignedCount})`
|
||||
: '已分配账号',
|
||||
disabled: !selectedUser,
|
||||
},
|
||||
@@ -389,12 +473,14 @@ export default function AssignmentsPage() {
|
||||
{/* 表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={displayAccounts}
|
||||
dataSource={pagedAccounts}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={tableLoading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total: totalCount,
|
||||
size: 'small',
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
@@ -403,7 +489,7 @@ export default function AssignmentsPage() {
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size);
|
||||
localStorage.setItem('assignment_page_size', String(size));
|
||||
setCurrentPage(1); // 切换 pageSize 时重置到第1页
|
||||
setCurrentPage(1);
|
||||
}
|
||||
},
|
||||
}}
|
||||
@@ -411,7 +497,7 @@ export default function AssignmentsPage() {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
}}
|
||||
scroll={{ y: 'calc(100vh - 500px)' }}
|
||||
scroll={{ y: 'calc(100vh - 460px)' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -253,7 +253,7 @@ export default function HuyaAssignmentsPage() {
|
||||
<UserOutlined style={{ color: token.colorPrimary }} />
|
||||
<Text strong>{user.username}</Text>
|
||||
</Space>
|
||||
<Badge count={user.assigned_count} showZero style={{ backgroundColor: token.colorPrimary }} />
|
||||
<Badge count={user.assigned_count} overflowCount={999999} showZero style={{ backgroundColor: token.colorPrimary }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
>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 {
|
||||
// 容错
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user