feat(accounts): 增加高价值资产一键筛选与高级自定义筛选功能

1. 后端新增高价值套装状态筛选(双套均可换/可换白日梦/可换萌兔/已拥有/高价值待救/未查资料),兼容 MySQL 原生 JSON 与 SQLite unicode 转义存储;
2. 后端新增多维资产与状态区间筛选(积分范围、鱼翅范围、宝典开通、电竞开通、角色绑定状态),并支持与批量打标签/删除联动;
3. 统计接口 accounts_summary 新增 high_value 聚合数据,毫秒级返回各类高价值资产数量;
4. 前端账号管理页面新增「高价值一键筛选」胶囊区(带数量徽标与激活状态,即点即滤);
5. 前端新增可折叠「高级自定义筛选」面板,支持套装状态下拉、积分快捷区间(≥300、≥600)、鱼翅快捷区间(>0、≥6)、宝典与角色绑定状态多条件组合;
6. 补充 6 个高价值筛选与资产区间单元测试,全量用例通过。
This commit is contained in:
yml2213
2026-10-05 11:23:48 +08:00
parent 19f1cbceba
commit 512ea3db06
7 changed files with 967 additions and 18 deletions
+177
View File
@@ -1610,3 +1610,180 @@ class TestPeaceScopeCanBeTurnedOff:
marked, called = self._run(peace_enabled=True)
assert "xpd_records" in called
assert "exchange_records_peace" in marked["result"].get("errors", {})
class TestHighValueAccountFilters:
"""高价值账号筛选与自定义范围筛选测试。"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="hv-admin", password_hash="h", role="super_admin")
self.session.add(self.admin)
self.session.commit()
# 构造不同特征的账号
now = datetime.now(UTC)
# 1. 优质皇冠号:双可换 + 积分 500 + 鱼翅 10 + 宝典开通 + CK valid
self.acc_crown = Account(
username="acc_crown",
password="p",
email="e1@test.com",
email_password="p",
overview_queried_at=now,
high_value_owned=[],
points=500,
gold_balance=10,
elite_opened=True,
ck_check_status="valid",
status="normal",
)
# 2. 换过白日梦(可换萌兔)+ 积分 100 + 鱼翅 0 + 宝典未开通
self.acc_bairimeng_owned = Account(
username="acc_bairimeng_owned",
password="p",
email="e2@test.com",
email_password="p",
overview_queried_at=now,
high_value_owned=["套装-兔兔白日梦"],
points=100,
gold_balance=0,
elite_opened=False,
ck_check_status="valid",
status="normal",
)
# 3. 换过萌兔(可换白日梦)+ 积分 50 + 鱼翅 20 + 宝典开通
self.acc_mengtu_owned = Account(
username="acc_mengtu_owned",
password="p",
email="e3@test.com",
email_password="p",
overview_queried_at=now,
high_value_owned=["套装-狂战萌兔"],
points=50,
gold_balance=20,
elite_opened=True,
ck_check_status="valid",
status="normal",
)
# 4. 双已换 + 高价值已空
self.acc_both_owned = Account(
username="acc_both_owned",
password="p",
email="e4@test.com",
email_password="p",
overview_queried_at=now,
high_value_owned=["套装-兔兔白日梦", "套装-狂战萌兔"],
points=0,
gold_balance=0,
elite_opened=False,
ck_check_status="valid",
status="normal",
)
# 5. 未查询资料的账号
self.acc_unqueried = Account(
username="acc_unqueried",
password="p",
email="e5@test.com",
email_password="p",
overview_queried_at=None,
high_value_owned=None,
points=None,
gold_balance=None,
elite_opened=None,
ck_check_status="valid",
status="normal",
)
# 6. 高价值但 CK 坏掉(待抢救)
self.acc_broken = Account(
username="acc_broken",
password="p",
email="e6@test.com",
email_password="p",
overview_queried_at=now,
high_value_owned=[],
points=800,
gold_balance=0,
elite_opened=True,
ck_check_status="invalid",
status="normal",
)
self.session.add_all(
[
self.acc_crown,
self.acc_bairimeng_owned,
self.acc_mengtu_owned,
self.acc_both_owned,
self.acc_unqueried,
self.acc_broken,
]
)
self.session.commit()
def _query(self, **kwargs):
from web.backend.routers.accounts import list_accounts
return list_accounts(
assigned_only=False,
has_cookie=False,
search="acc_",
page=1,
page_size=20,
db=self.session,
current=self.admin,
**kwargs,
)["items"]
def test_both_available_filter(self):
items = self._query(high_value_filter="both_available")
names = [it.username for it in items]
assert "acc_crown" in names
assert "acc_broken" in names
assert "acc_bairimeng_owned" not in names
assert "acc_mengtu_owned" not in names
assert "acc_both_owned" not in names
assert "acc_unqueried" not in names
def test_can_bairimeng_filter(self):
items = self._query(high_value_filter="can_bairimeng")
names = [it.username for it in items]
assert "acc_crown" in names
assert "acc_mengtu_owned" in names # 拥有萌兔但没拥有白日梦,所以可以换白日梦
assert "acc_bairimeng_owned" not in names
assert "acc_both_owned" not in names
def test_can_mengtu_filter(self):
items = self._query(high_value_filter="can_mengtu")
names = [it.username for it in items]
assert "acc_crown" in names
assert "acc_bairimeng_owned" in names # 拥有白日梦但没拥有萌兔,所以可以换萌兔
assert "acc_mengtu_owned" not in names
assert "acc_both_owned" not in names
def test_numeric_ranges_and_opened_filter(self):
# 积分 >= 300 且 鱼翅 >= 5
items = self._query(min_points=300, min_gold=5)
names = [it.username for it in items]
assert names == ["acc_crown"]
# 宝典已开通
items = self._query(elite_opened=True)
names = [it.username for it in items]
assert set(names) == {"acc_crown", "acc_mengtu_owned", "acc_broken"}
def test_high_value_broken_filter(self):
# 高价值但 CK 异常待抢救
items = self._query(high_value_filter="high_value_broken")
names = [it.username for it in items]
assert names == ["acc_broken"]
def test_summary_returns_high_value_breakdown(self):
from web.backend.routers.accounts import accounts_summary
res = accounts_summary(db=self.session, current=self.admin)
hv = res.get("high_value")
assert hv is not None
assert hv["both_available"] == 2 # acc_crown + acc_broken
assert hv["unqueried"] == 1 # acc_unqueried
assert hv["high_points"] == 2 # 500, 800
assert hv["high_value_broken"] == 1 # acc_broken
@@ -9,13 +9,11 @@ from web.backend.services.douyu_service import create_douyu_planned_tasks
def test_douyu_task_assigned_to_flow():
from tests.support.db import get_test_engine
init_test_schema()
DouyuTask.__table__.drop(get_test_engine(), checkfirst=True)
DouyuTask.__table__.create(get_test_engine(), checkfirst=True)
db: Session = make_test_session()
try:
db.query(DouyuTask).delete()
db.commit()
# 创建测试客服用户与账号
user_a = User(
username="test_kefu_a",
+171 -1
View File
@@ -3,7 +3,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select, text
from sqlalchemy import String, and_, cast, func, or_, select, text
from sqlalchemy.orm import Session, defer, joinedload
from ..database import get_db
@@ -79,6 +79,17 @@ def _query_str(value) -> str:
return value.strip() if isinstance(value, str) else ""
def _high_value_contains_expr(column, text: str):
"""匹配 JSON 字段中是否包含特定商品名(同时兼容 MySQL 与 SQLite 字符存储)。"""
import json
escaped = json.dumps(text).strip('"')
return or_(
cast(column, String).like(f"%{text}%"),
cast(column, String).like(f"%{escaped}%"),
)
def _filter_accounts_query(
db: Session,
query,
@@ -91,6 +102,14 @@ def _filter_accounts_query(
status: str = "",
ck_status: str = "",
bind_status: str = "",
high_value_filter: str = "",
min_points: int | None = None,
max_points: int | None = None,
min_gold: int | None = None,
max_gold: int | None = None,
elite_opened: bool | None = None,
esports_opened: bool | None = None,
role_bind_status: str = "",
):
"""复用列表筛选条件,供分页列表和全量批量操作保持一致。"""
if has_cookie:
@@ -120,6 +139,70 @@ def _filter_accounts_query(
Account.xpd_bind_status == bind_value,
)
)
role_bind = _query_str(role_bind_status)
if role_bind:
query = query.filter(Account.role_bind_status == role_bind)
# 高价值套装筛选
hv = _query_str(high_value_filter)
if hv:
bairimeng_expr = _high_value_contains_expr(
Account.high_value_owned, "兔兔白日梦"
)
mengtu_expr = _high_value_contains_expr(Account.high_value_owned, "狂战萌兔")
if hv == "both_available":
query = query.filter(
Account.overview_queried_at.isnot(None),
~bairimeng_expr,
~mengtu_expr,
)
elif hv == "can_bairimeng":
query = query.filter(
Account.overview_queried_at.isnot(None),
~bairimeng_expr,
)
elif hv == "can_mengtu":
query = query.filter(
Account.overview_queried_at.isnot(None),
~mengtu_expr,
)
elif hv == "owned_bairimeng":
query = query.filter(bairimeng_expr)
elif hv == "owned_mengtu":
query = query.filter(mengtu_expr)
elif hv == "both_owned":
query = query.filter(bairimeng_expr, mengtu_expr)
elif hv == "unqueried":
query = query.filter(Account.overview_queried_at.is_(None))
elif hv == "high_value_broken":
query = query.filter(
or_(
and_(
Account.overview_queried_at.isnot(None),
~bairimeng_expr,
~mengtu_expr,
),
Account.points >= 300,
),
Account.ck_check_status.in_(["invalid", "error"]),
)
# 资产数值区间过滤
if isinstance(min_points, int):
query = query.filter(Account.points >= min_points)
if isinstance(max_points, int):
query = query.filter(Account.points <= max_points)
if isinstance(min_gold, int):
query = query.filter(Account.gold_balance >= min_gold)
if isinstance(max_gold, int):
query = query.filter(Account.gold_balance <= max_gold)
# 开通状态过滤
if isinstance(elite_opened, bool):
query = query.filter(Account.elite_opened.is_(elite_opened))
if isinstance(esports_opened, bool):
query = query.filter(Account.esports_opened.is_(esports_opened))
search_text = _query_str(search)
if search_text:
pattern = f"%{search_text}%"
@@ -147,6 +230,17 @@ def _selected_account_ids(
tag=req.tag,
has_cookie=req.has_cookie,
search=req.search,
status=req.status,
ck_status=req.ck_status,
bind_status=req.bind_status,
high_value_filter=req.high_value_filter,
min_points=req.min_points,
max_points=req.max_points,
min_gold=req.min_gold,
max_gold=req.max_gold,
elite_opened=req.elite_opened,
esports_opened=req.esports_opened,
role_bind_status=req.role_bind_status,
)
.with_entities(Account.id)
.order_by(None)
@@ -195,6 +289,19 @@ def list_accounts(
bind_status: str = Query(
"", description="按角色绑定筛选:unknown/unbound/pending/bound"
),
high_value_filter: str = Query(
"",
description="按高价值套装状态筛选:both_available/can_bairimeng/can_mengtu/owned_bairimeng/owned_mengtu/both_owned/unqueried/high_value_broken",
),
min_points: int | None = Query(None, description="最低积分"),
max_points: int | None = Query(None, description="最高积分"),
min_gold: int | None = Query(None, description="最低鱼翅"),
max_gold: int | None = Query(None, description="最高鱼翅"),
elite_opened: bool | None = Query(None, description="是否开通精英宝典"),
esports_opened: bool | None = Query(None, description="是否开通电竞手册"),
role_bind_status: str = Query(
"", description="精英角色绑定状态: unknown/unbound/pending/bound"
),
page: int | None = Query(None, ge=1),
page_size: int = Query(20, ge=1, le=200),
include_sensitive: bool = Query(False),
@@ -213,6 +320,14 @@ def list_accounts(
status=status,
ck_status=ck_status,
bind_status=bind_status,
high_value_filter=high_value_filter,
min_points=min_points,
max_points=max_points,
min_gold=min_gold,
max_gold=max_gold,
elite_opened=elite_opened,
esports_opened=esports_opened,
role_bind_status=role_bind_status,
)
total = None
@@ -378,11 +493,66 @@ 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())
# 高价值及核心资产分布(单次聚合,供前端快捷胶囊展示)
bairimeng_expr = _high_value_contains_expr(Account.high_value_owned, "兔兔白日梦")
mengtu_expr = _high_value_contains_expr(Account.high_value_owned, "狂战萌兔")
high_value_broken_expr = and_(
or_(
and_(
Account.overview_queried_at.isnot(None),
~bairimeng_expr,
~mengtu_expr,
),
Account.points >= 300,
),
Account.ck_check_status.in_(["invalid", "error"]),
)
hv_row = visible.with_entities(
func.count(
func.nullif(
Account.overview_queried_at.isnot(None)
& ~bairimeng_expr
& ~mengtu_expr,
False,
)
),
func.count(
func.nullif(
Account.overview_queried_at.isnot(None) & ~bairimeng_expr,
False,
)
),
func.count(
func.nullif(
Account.overview_queried_at.isnot(None) & ~mengtu_expr,
False,
)
),
func.count(func.nullif(Account.overview_queried_at.is_(None), False)),
func.count(func.nullif(Account.points >= 300, False)),
func.count(func.nullif(Account.gold_balance > 0, False)),
func.count(func.nullif(Account.elite_opened.is_(True), False)),
func.count(func.nullif(high_value_broken_expr, False)),
).first()
high_value_summary = {
"both_available": int(hv_row[0] or 0) if hv_row else 0,
"can_bairimeng": int(hv_row[1] or 0) if hv_row else 0,
"can_mengtu": int(hv_row[2] or 0) if hv_row else 0,
"unqueried": int(hv_row[3] or 0) if hv_row else 0,
"high_points": int(hv_row[4] or 0) if hv_row else 0,
"has_gold": int(hv_row[5] or 0) if hv_row else 0,
"elite_opened": int(hv_row[6] or 0) if hv_row else 0,
"high_value_broken": int(hv_row[7] or 0) if hv_row else 0,
}
return {
"total": total,
"visible_total": visible_total,
# 生命周期分布(键是 6 状态的枚举值)
"by_status": by_status,
# 高价值与核心资产统计
"high_value": high_value_summary,
# 需要人关注的:CK 已被判失效的账号数(含待人工)
"ck_invalid_count": int(
visible.filter(Account.ck_check_status == "invalid")
+11
View File
@@ -137,6 +137,17 @@ class AccountBulkSelection(BaseModel):
tag: str = ""
assigned_only: bool = False
has_cookie: bool = False
status: str = ""
ck_status: str = ""
bind_status: str = ""
high_value_filter: str = ""
min_points: int | None = None
max_points: int | None = None
min_gold: int | None = None
max_gold: int | None = None
elite_opened: bool | None = None
esports_opened: bool | None = None
role_bind_status: str = ""
class AccountBulkTag(AccountBulkSelection):
+19 -1
View File
@@ -18,7 +18,25 @@ import type {
export const accountApi = {
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) =>
api.get<AccountItem[], AccountItem[]>('/accounts', { params }),
listPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean; status?: string; ck_status?: string; bind_status?: string }) =>
listPaged: (
params: PageParams & {
assigned_only?: boolean;
tag?: string;
has_cookie?: boolean;
include_sensitive?: boolean;
status?: string;
ck_status?: string;
bind_status?: string;
high_value_filter?: string;
min_points?: number;
max_points?: number;
min_gold?: number;
max_gold?: number;
elite_opened?: boolean;
esports_opened?: boolean;
role_bind_status?: string;
},
) =>
api.get<PaginatedResponse<AccountItem>, PaginatedResponse<AccountItem>>('/accounts', { params }),
summary: () => api.get<BasicSummary, BasicSummary>('/accounts/summary'),
import: (text: string, tag?: string) =>
+24
View File
@@ -31,6 +31,17 @@ export interface AccountBulkSelection {
tag?: string;
assigned_only?: boolean;
has_cookie?: boolean;
status?: string;
ck_status?: string;
bind_status?: string;
high_value_filter?: string;
min_points?: number;
max_points?: number;
min_gold?: number;
max_gold?: number;
elite_opened?: boolean;
esports_opened?: boolean;
role_bind_status?: string;
}
export interface AccountBulkTagRequest extends AccountBulkSelection {
@@ -64,11 +75,24 @@ export interface AuditLogQuery {
success?: boolean;
}
export interface HighValueSummary {
both_available: number;
can_bairimeng: number;
can_mengtu: number;
unqueried: number;
high_points: number;
has_gold: number;
elite_opened: number;
high_value_broken: number;
}
export interface BasicSummary {
/** 生命周期状态分布(键是 6 状态枚举值);归档默认不计入 */
by_status?: Record<string, number>;
/** 不含归档的账号数 */
visible_total?: number;
/** 高价值及核心资产统计 */
high_value?: HighValueSummary;
/** CK 已被判失效的账号数 */
ck_invalid_count?: number;
/** 没有 CK 的账号数 */
+563 -12
View File
@@ -6,6 +6,7 @@ import {
Button,
Modal,
Input,
InputNumber,
Select,
Popconfirm,
Typography,
@@ -21,7 +22,18 @@ import {
} from 'antd';
import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd';
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import {
ImportOutlined,
DeleteOutlined,
TagOutlined,
FilterOutlined,
EyeOutlined,
EyeInvisibleOutlined,
ThunderboltOutlined,
DownOutlined,
UpOutlined,
ClearOutlined,
} from '@ant-design/icons';
import { accountApi, douyuApi, type AccountExchangeRecord, type AccountExchangeRecords, type AccountItem, type BasicSummary } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime, formatTimeShort } from '../utils/time';
@@ -73,6 +85,18 @@ const STATUS_CARDS = [
{ value: 'disabled', label: '已停用', color: '#8c8c8c' },
];
/** 高价值套装筛选项 */
const HIGH_VALUE_OPTIONS = [
{ value: 'both_available', label: '🌟 双套均可换 (高价值)' },
{ value: 'can_bairimeng', label: '👗 可换白日梦爱丽丝' },
{ value: 'can_mengtu', label: '🐰 可换狂战萌兔' },
{ value: 'owned_bairimeng', label: '✓ 已拥有白日梦' },
{ value: 'owned_mengtu', label: '✓ 已拥有萌兔' },
{ value: 'both_owned', label: '✓ 两套均已拥有' },
{ value: 'high_value_broken', label: '⚠️ 高价值但CK失效' },
{ value: 'unqueried', label: '❓ 未查过资料' },
];
/** 单个作用域的绑定小标签:精/电/店。 */
function BindChip({ scope, status }: { scope: string; status?: string }) {
@@ -141,6 +165,34 @@ export default function AccountsPage() {
const [ckFilter, setCkFilter] = useState<string>('');
const [bindFilter, setBindFilter] = useState<string>('');
const [searchText, setSearchText] = useState('');
// 高价值资产与高级自定义筛选状态
const [highValueFilter, setHighValueFilter] = useState<string>('');
const [minPoints, setMinPoints] = useState<number | undefined>(undefined);
const [maxPoints, setMaxPoints] = useState<number | undefined>(undefined);
const [minGold, setMinGold] = useState<number | undefined>(undefined);
const [maxGold, setMaxGold] = useState<number | undefined>(undefined);
const [eliteOpened, setEliteOpened] = useState<boolean | undefined>(undefined);
const [esportsOpened, setEsportsOpened] = useState<boolean | undefined>(undefined);
const [roleBindStatus, setRoleBindStatus] = useState<string>('');
const [advFilterOpen, setAdvFilterOpen] = useState(false);
const resetAdvancedFiltersState = () => {
setHighValueFilter('');
setMinPoints(undefined);
setMaxPoints(undefined);
setMinGold(undefined);
setMaxGold(undefined);
setEliteOpened(undefined);
setEsportsOpened(undefined);
setRoleBindStatus('');
};
const resetAdvancedFilters = () => {
resetAdvancedFiltersState();
setCurrentPage(1);
};
// 兑换记录预览:点列表那一格**按需拉取**(不进列表响应),弹窗按作用域分组
const [exRecords, setExRecords] = useState<AccountExchangeRecords | null>(null);
const [exLoading, setExLoading] = useState(false);
@@ -181,18 +233,26 @@ export default function AccountsPage() {
const loadAccounts = useCallback(async () => {
setLoading(true);
try {
const params: { tag?: string; status?: string; ck_status?: string; bind_status?: string } = {};
if (tagFilter) params.tag = tagFilter;
if (statusFilter) params.status = statusFilter;
if (ckFilter) params.ck_status = ckFilter;
if (bindFilter) params.bind_status = bindFilter;
const data = await accountApi.listPaged({
...params,
const params: Parameters<typeof accountApi.listPaged>[0] = {
page: currentPage,
page_size: pageSize,
search: searchText.trim() || undefined,
include_sensitive: canViewFull && sensitiveVisible,
});
};
if (tagFilter) params.tag = tagFilter;
if (statusFilter) params.status = statusFilter;
if (ckFilter) params.ck_status = ckFilter;
if (bindFilter) params.bind_status = bindFilter;
if (highValueFilter) params.high_value_filter = highValueFilter;
if (minPoints !== undefined) params.min_points = minPoints;
if (maxPoints !== undefined) params.max_points = maxPoints;
if (minGold !== undefined) params.min_gold = minGold;
if (maxGold !== undefined) params.max_gold = maxGold;
if (eliteOpened !== undefined) params.elite_opened = eliteOpened;
if (esportsOpened !== undefined) params.esports_opened = esportsOpened;
if (roleBindStatus) params.role_bind_status = roleBindStatus;
const data = await accountApi.listPaged(params);
setAccounts(data.items);
setTotal(data.total);
} catch (e: unknown) {
@@ -200,7 +260,25 @@ export default function AccountsPage() {
} finally {
setLoading(false);
}
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter, statusFilter, ckFilter, bindFilter]);
}, [
canViewFull,
currentPage,
pageSize,
searchText,
sensitiveVisible,
tagFilter,
statusFilter,
ckFilter,
bindFilter,
highValueFilter,
minPoints,
maxPoints,
minGold,
maxGold,
eliteOpened,
esportsOpened,
roleBindStatus,
]);
const loadSummary = useCallback(async () => {
try {
@@ -235,7 +313,34 @@ export default function AccountsPage() {
all_matching: selectedAllMatching,
search: searchText.trim(),
tag: tagFilter,
}), [searchText, selectedAllMatching, selectedRowKeys, tagFilter]);
status: statusFilter || undefined,
ck_status: ckFilter || undefined,
bind_status: bindFilter || undefined,
high_value_filter: highValueFilter || undefined,
min_points: minPoints,
max_points: maxPoints,
min_gold: minGold,
max_gold: maxGold,
elite_opened: eliteOpened,
esports_opened: esportsOpened,
role_bind_status: roleBindStatus || undefined,
}), [
searchText,
selectedAllMatching,
selectedRowKeys,
tagFilter,
statusFilter,
ckFilter,
bindFilter,
highValueFilter,
minPoints,
maxPoints,
minGold,
maxGold,
eliteOpened,
esportsOpened,
roleBindStatus,
]);
const clearSelection = () => {
setSelectedRowKeys([]);
@@ -244,7 +349,21 @@ export default function AccountsPage() {
useEffect(() => {
clearSelection();
}, [searchText, tagFilter]);
}, [
searchText,
tagFilter,
statusFilter,
ckFilter,
bindFilter,
highValueFilter,
minPoints,
maxPoints,
minGold,
maxGold,
eliteOpened,
esportsOpened,
roleBindStatus,
]);
const handleImport = async () => {
if (!importText.trim()) {
@@ -619,6 +738,157 @@ export default function AccountsPage() {
});
}
const hasCustomFilters = Boolean(
highValueFilter ||
minPoints !== undefined ||
maxPoints !== undefined ||
minGold !== undefined ||
maxGold !== undefined ||
eliteOpened !== undefined ||
esportsOpened !== undefined ||
roleBindStatus
);
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 {
resetAdvancedFiltersState();
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 {
resetAdvancedFiltersState();
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 {
resetAdvancedFiltersState();
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 {
resetAdvancedFiltersState();
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 {
resetAdvancedFiltersState();
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 {
resetAdvancedFiltersState();
setEliteOpened(true);
}
setCurrentPage(1);
},
},
{
key: 'high_value_broken',
label: '高价值待救',
icon: '⚠️',
count: summary.high_value?.high_value_broken,
color: '#f5222d',
tooltip: '可换高价值套装但 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 {
resetAdvancedFiltersState();
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 {
resetAdvancedFiltersState();
setHighValueFilter('unqueried');
}
setCurrentPage(1);
},
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
@@ -765,6 +1035,287 @@ export default function AccountsPage() {
})}
</Row>
{/* 高价值资产一键筛选 & 高级自定义筛选面板 */}
<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 }}>
<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={resetAdvancedFilters} style={{ padding: 0 }}>
清除高价值筛选
</Button>
)}
<Button
size="small"
type={advFilterOpen ? 'primary' : 'default'}
ghost={advFilterOpen}
icon={advFilterOpen ? <UpOutlined /> : <DownOutlined />}
onClick={() => setAdvFilterOpen(!advFilterOpen)}
>
{advFilterOpen ? '收起高级筛选' : '高级自定义筛选'}
{hasCustomFilters && !advFilterOpen && (
<Tag color="blue" style={{ marginLeft: 4, marginRight: 0, padding: '0 4px', fontSize: 11 }}>
已生效
</Tag>
)}
</Button>
</Space>
</div>
{/* 折叠展开的高级筛选面板 */}
{advFilterOpen && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px dashed #f0f0f0' }}>
<Row gutter={[16, 10]} align="middle">
<Col>
<Space size={6} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>套装状态:</Text>
<Select
allowClear
size="small"
placeholder="全部状态"
style={{ width: 180 }}
value={highValueFilter || undefined}
onChange={(val) => {
setHighValueFilter(val || '');
setCurrentPage(1);
}}
options={HIGH_VALUE_OPTIONS}
/>
</Space>
</Col>
<Col>
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>积分范围:</Text>
<InputNumber
size="small"
placeholder="最小"
min={0}
value={minPoints}
onChange={(v) => {
setMinPoints(v ?? undefined);
setCurrentPage(1);
}}
style={{ width: 75 }}
/>
<span>~</span>
<InputNumber
size="small"
placeholder="最大"
min={0}
value={maxPoints}
onChange={(v) => {
setMaxPoints(v ?? undefined);
setCurrentPage(1);
}}
style={{ width: 75 }}
/>
<Button
size="small"
type="dashed"
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => {
setMinPoints(300);
setCurrentPage(1);
}}
>
≥300
</Button>
<Button
size="small"
type="dashed"
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => {
setMinPoints(600);
setCurrentPage(1);
}}
>
≥600
</Button>
</Space>
</Col>
<Col>
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>鱼翅余额:</Text>
<InputNumber
size="small"
placeholder="最小"
min={0}
step={1}
value={minGold}
onChange={(v) => {
setMinGold(v ?? undefined);
setCurrentPage(1);
}}
style={{ width: 75 }}
/>
<span>~</span>
<InputNumber
size="small"
placeholder="最大"
min={0}
step={1}
value={maxGold}
onChange={(v) => {
setMaxGold(v ?? undefined);
setCurrentPage(1);
}}
style={{ width: 75 }}
/>
<Button
size="small"
type="dashed"
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => {
setMinGold(0.01);
setCurrentPage(1);
}}
>
&gt;0
</Button>
<Button
size="small"
type="dashed"
style={{ fontSize: 11, padding: '0 6px' }}
onClick={() => {
setMinGold(6);
setCurrentPage(1);
}}
>
≥6
</Button>
</Space>
</Col>
<Col>
<Space size={6} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>宝典:</Text>
<Select
size="small"
allowClear
placeholder="全部"
style={{ width: 95 }}
value={eliteOpened === undefined ? undefined : String(eliteOpened)}
onChange={(val) => {
setEliteOpened(val === undefined ? undefined : val === 'true');
setCurrentPage(1);
}}
options={[
{ value: 'true', label: '宝典已开' },
{ value: 'false', label: '宝典未开' },
]}
/>
</Space>
</Col>
<Col>
<Space size={6} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>电竞:</Text>
<Select
size="small"
allowClear
placeholder="全部"
style={{ width: 95 }}
value={esportsOpened === undefined ? undefined : String(esportsOpened)}
onChange={(val) => {
setEsportsOpened(val === undefined ? undefined : val === 'true');
setCurrentPage(1);
}}
options={[
{ value: 'true', label: '电竞已开' },
{ value: 'false', label: '电竞未开' },
]}
/>
</Space>
</Col>
<Col>
<Space size={6} align="center">
<Text type="secondary" style={{ fontSize: 13 }}>角色绑定:</Text>
<Select
size="small"
allowClear
placeholder="全部"
style={{ width: 100 }}
value={roleBindStatus || undefined}
onChange={(val) => {
setRoleBindStatus(val || '');
setCurrentPage(1);
}}
options={BIND_OPTIONS}
/>
</Space>
</Col>
<Col>
<Button
size="small"
icon={<ClearOutlined />}
onClick={resetAdvancedFilters}
disabled={!hasCustomFilters}
>
重置自定义条件
</Button>
</Col>
</Row>
</div>
)}
</Card>
<Table
tableLayout="fixed"
title={() => selectedRowKeys.length > 0 ? (