- 新增登录失败异常分型统计:支持「必须绑定手机号」、「极验滑块」、「凭证错误」、「邮箱取码失败」、「邮箱配额已满」、「网络异常」分型识别、生命周期联动(待人工)与一键筛选; - 新增账号名单与明细导出功能:支持勾选多选或按当前筛选条件全选,可导出纯用户名名单 (TXT) 或 UTF-8 BOM 结构化明细报表 (CSV),并支持一键复制到剪贴板; - 优化筛选条件记忆机制:支持页面切换/返回后自动保留所有筛选与排序状态,新增顶部「重置全部筛选」快捷按钮; - 模块化拆分 AccountsPage:将 2100+ 行的大文件拆解至 pages/accounts/ 子目录(包含状态卡片、筛选面板、工具栏、操作弹窗与表格构建器),提升可维护性; - 验证方式:新增 5 个后端自动化单测 (test_account_failure_and_export.py),全量 694 个测试用例通过,前端 TypeScript 编译与 pre-commit 代码规范检查全部通过。
929 lines
28 KiB
TypeScript
929 lines
28 KiB
TypeScript
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Table, Alert, Space, Button, App, type MenuProps } from 'antd';
|
||
import { message } from '../utils/antdMessage';
|
||
import { DownloadOutlined, CopyOutlined } from '@ant-design/icons';
|
||
import {
|
||
accountApi,
|
||
douyuApi,
|
||
type AccountExchangeRecords,
|
||
type AccountItem,
|
||
type BasicSummary,
|
||
type SupportUserItem,
|
||
} from '../api/modules';
|
||
import { usePermissions } from '../hooks/usePermissions';
|
||
import { getErrorMessage } from '../utils/error';
|
||
|
||
import {
|
||
type AccountSortKey,
|
||
ACCOUNT_SORT_KEYS,
|
||
TAG_COLORS,
|
||
} from './accounts/types';
|
||
import {
|
||
loadStoredFilters,
|
||
saveStoredFilters,
|
||
clearStoredFilters,
|
||
} from './accounts/storage';
|
||
import { AccountToolbar } from './accounts/components/AccountToolbar';
|
||
import { StatusCards } from './accounts/components/StatusCards';
|
||
import { FilterPanel } from './accounts/components/FilterPanel';
|
||
import { buildAccountColumns } from './accounts/components/AccountColumns';
|
||
import {
|
||
BatchImportModal,
|
||
BatchTagModal,
|
||
BatchAssignModal,
|
||
ExchangeRecordsModal,
|
||
} from './accounts/components/AccountModals';
|
||
|
||
export default function AccountsPage() {
|
||
const navigate = useNavigate();
|
||
const { modal } = App.useApp();
|
||
const initialFilters = useMemo(() => loadStoredFilters(), []);
|
||
|
||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||
const [tags, setTags] = useState<string[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
// 弹窗状态
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
const [importText, setImportText] = useState('');
|
||
const [importTag, setImportTag] = useState('');
|
||
const [importing, setImporting] = useState(false);
|
||
|
||
// 筛选状态
|
||
const [tagFilter, setTagFilter] = useState<string>(() => initialFilters.tagFilter || '');
|
||
const [statusFilter, setStatusFilter] = useState<string>(() => initialFilters.statusFilter || '');
|
||
const [ckFilter, setCkFilter] = useState<string>(() => initialFilters.ckFilter || '');
|
||
const [failureKindFilter, setFailureKindFilter] = useState<string>(() => initialFilters.failureKindFilter || '');
|
||
const [bindFilter, setBindFilter] = useState<string>(() => initialFilters.bindFilter || '');
|
||
const [assignFilter, setAssignFilter] = useState<string>(() => initialFilters.assignFilter || '');
|
||
const [assignUsers, setAssignUsers] = useState<SupportUserItem[]>([]);
|
||
const [assignModalOpen, setAssignModalOpen] = useState(false);
|
||
const [assignTargetId, setAssignTargetId] = useState<number | undefined>(undefined);
|
||
const [assigning, setAssigning] = useState(false);
|
||
const [searchText, setSearchText] = useState(() => initialFilters.searchText || '');
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
// 高价值资产与高级自定义筛选状态
|
||
const [highValueFilter, setHighValueFilter] = useState<string>(() => initialFilters.highValueFilter || '');
|
||
const [minPoints, setMinPoints] = useState<number | undefined>(() => initialFilters.minPoints);
|
||
const [maxPoints, setMaxPoints] = useState<number | undefined>(() => initialFilters.maxPoints);
|
||
const [minGold, setMinGold] = useState<number | undefined>(() => initialFilters.minGold);
|
||
const [maxGold, setMaxGold] = useState<number | undefined>(() => initialFilters.maxGold);
|
||
const [eliteOpened, setEliteOpened] = useState<boolean | undefined>(() => initialFilters.eliteOpened);
|
||
const [esportsOpened, setEsportsOpened] = useState<boolean | undefined>(() => initialFilters.esportsOpened);
|
||
const [roleBindStatus, setRoleBindStatus] = useState<string>(() => initialFilters.roleBindStatus || '');
|
||
const [advFilterOpen, setAdvFilterOpen] = useState<boolean>(() => Boolean(initialFilters.advFilterOpen));
|
||
|
||
const resetAdvancedFiltersState = () => {
|
||
setHighValueFilter('');
|
||
setMinPoints(undefined);
|
||
setMaxPoints(undefined);
|
||
setMinGold(undefined);
|
||
setMaxGold(undefined);
|
||
setEliteOpened(undefined);
|
||
setEsportsOpened(undefined);
|
||
setRoleBindStatus('');
|
||
};
|
||
|
||
const resetAdvancedFilters = () => {
|
||
resetAdvancedFiltersState();
|
||
setCurrentPage(1);
|
||
};
|
||
|
||
const resetAllFilters = () => {
|
||
setTagFilter('');
|
||
setStatusFilter('');
|
||
setCkFilter('');
|
||
setFailureKindFilter('');
|
||
setBindFilter('');
|
||
setAssignFilter('');
|
||
setSearchText('');
|
||
resetAdvancedFiltersState();
|
||
setCurrentPage(1);
|
||
clearStoredFilters();
|
||
message.success('已重置所有筛选条件');
|
||
};
|
||
|
||
// 兑换记录预览弹窗状态
|
||
const [exRecords, setExRecords] = useState<AccountExchangeRecords | null>(null);
|
||
const [exLoading, setExLoading] = useState(false);
|
||
const [exAccount, setExAccount] = useState<AccountItem | null>(null);
|
||
const openExchangeRecords = async (record: AccountItem) => {
|
||
setExAccount(record);
|
||
setExRecords(null);
|
||
setExLoading(true);
|
||
try {
|
||
setExRecords(await accountApi.exchangeRecords(record.id));
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setExLoading(false);
|
||
}
|
||
};
|
||
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
|
||
const [batchTagInput, setBatchTagInput] = useState('');
|
||
const [batchTagVisible, setBatchTagVisible] = useState(false);
|
||
const [sensitiveVisible, setSensitiveVisible] = useState(false);
|
||
const [total, setTotal] = useState(0);
|
||
const [summary, setSummary] = useState<BasicSummary>({
|
||
total: 0,
|
||
assigned_count: 0,
|
||
unassigned_count: 0,
|
||
tag_count: 0,
|
||
});
|
||
|
||
const [pageSize, setPageSize] = useState(() => {
|
||
if (initialFilters.pageSize) return initialFilters.pageSize;
|
||
const v = localStorage.getItem('account_page_size');
|
||
return v ? Number(v) || 20 : 20;
|
||
});
|
||
const [currentPage, setCurrentPage] = useState<number>(() => initialFilters.currentPage || 1);
|
||
const [sortKey, setSortKey] = useState<AccountSortKey | ''>(() =>
|
||
initialFilters.sortKey !== undefined ? initialFilters.sortKey : 'high_value',
|
||
);
|
||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(() =>
|
||
initialFilters.sortOrder !== undefined ? initialFilters.sortOrder : 'ascend',
|
||
);
|
||
|
||
const { can } = usePermissions();
|
||
const canViewFull = can('account:view_full');
|
||
const canImport = can('account:import');
|
||
const canTag = can('account:tag') || canImport;
|
||
const canDelete = can('account:delete');
|
||
const canAssign = can('account:assign');
|
||
const canSelectRows = canTag || canDelete || canAssign;
|
||
|
||
const assignStatusParam =
|
||
assignFilter === 'unassigned' || assignFilter === 'assigned' ? assignFilter : '';
|
||
const assignedUserIdParam = assignFilter.startsWith('user:')
|
||
? Number(assignFilter.slice(5))
|
||
: undefined;
|
||
|
||
const loadAccounts = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
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 (failureKindFilter) params.failure_kind = failureKindFilter;
|
||
if (bindFilter) params.bind_status = bindFilter;
|
||
if (assignStatusParam) params.assign_status = assignStatusParam;
|
||
if (assignedUserIdParam !== undefined) params.assigned_user_id = assignedUserIdParam;
|
||
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;
|
||
if (sortKey && sortOrder) {
|
||
params.sort = sortKey;
|
||
params.order = sortOrder === 'descend' ? 'desc' : 'asc';
|
||
}
|
||
|
||
const data = await accountApi.listPaged(params);
|
||
setAccounts(data.items);
|
||
setTotal(data.total);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [
|
||
canViewFull,
|
||
currentPage,
|
||
pageSize,
|
||
searchText,
|
||
sensitiveVisible,
|
||
tagFilter,
|
||
statusFilter,
|
||
ckFilter,
|
||
failureKindFilter,
|
||
bindFilter,
|
||
assignStatusParam,
|
||
assignedUserIdParam,
|
||
highValueFilter,
|
||
minPoints,
|
||
maxPoints,
|
||
minGold,
|
||
maxGold,
|
||
sortKey,
|
||
sortOrder,
|
||
eliteOpened,
|
||
esportsOpened,
|
||
roleBindStatus,
|
||
]);
|
||
|
||
const loadSummary = useCallback(async () => {
|
||
try {
|
||
const data = await accountApi.summary();
|
||
setSummary(data);
|
||
} catch {
|
||
// 统计加载失败时不影响主列表操作
|
||
}
|
||
}, []);
|
||
|
||
const loadTags = useCallback(async () => {
|
||
try {
|
||
const data = await accountApi.listTags();
|
||
setTags(data);
|
||
} catch {
|
||
// 忽略标签加载失败
|
||
}
|
||
}, []);
|
||
|
||
const loadAssignUsers = useCallback(async () => {
|
||
try {
|
||
const data = await accountApi.assignmentsSummary();
|
||
setAssignUsers(data.support_users);
|
||
} catch {
|
||
// 忽略客服列表拉取失败
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (canAssign) loadAssignUsers();
|
||
}, [canAssign, loadAssignUsers]);
|
||
|
||
useEffect(() => {
|
||
loadAccounts();
|
||
}, [loadAccounts]);
|
||
|
||
useEffect(() => {
|
||
loadTags();
|
||
loadSummary();
|
||
}, [loadTags, loadSummary]);
|
||
|
||
const selectedCount = selectedAllMatching ? total : selectedRowKeys.length;
|
||
const selectionPayload = useMemo(
|
||
() => ({
|
||
account_ids: selectedAllMatching ? [] : selectedRowKeys.map((key) => Number(key)),
|
||
all_matching: selectedAllMatching,
|
||
search: searchText.trim(),
|
||
tag: tagFilter,
|
||
status: statusFilter || undefined,
|
||
ck_status: ckFilter || undefined,
|
||
failure_kind: failureKindFilter || undefined,
|
||
bind_status: bindFilter || undefined,
|
||
assign_status: assignStatusParam || undefined,
|
||
assigned_user_id: assignedUserIdParam,
|
||
high_value_filter: highValueFilter || undefined,
|
||
min_points: minPoints,
|
||
max_points: maxPoints,
|
||
min_gold: minGold,
|
||
max_gold: maxGold,
|
||
elite_opened: eliteOpened,
|
||
esports_opened: esportsOpened,
|
||
role_bind_status: roleBindStatus || undefined,
|
||
}),
|
||
[
|
||
searchText,
|
||
selectedAllMatching,
|
||
selectedRowKeys,
|
||
tagFilter,
|
||
statusFilter,
|
||
ckFilter,
|
||
failureKindFilter,
|
||
bindFilter,
|
||
assignStatusParam,
|
||
assignedUserIdParam,
|
||
highValueFilter,
|
||
minPoints,
|
||
maxPoints,
|
||
minGold,
|
||
maxGold,
|
||
eliteOpened,
|
||
esportsOpened,
|
||
roleBindStatus,
|
||
],
|
||
);
|
||
|
||
const clearSelection = () => {
|
||
setSelectedRowKeys([]);
|
||
setSelectedAllMatching(false);
|
||
};
|
||
|
||
const handleExport = async (format: 'txt' | 'csv') => {
|
||
const isExportingSelection = selectedCount > 0;
|
||
setExporting(true);
|
||
try {
|
||
const payload = isExportingSelection
|
||
? selectionPayload
|
||
: { ...selectionPayload, all_matching: true };
|
||
const response = await accountApi.export({
|
||
...payload,
|
||
format,
|
||
});
|
||
const blob = new Blob([response as unknown as BlobPart], {
|
||
type: format === 'txt' ? 'text/plain;charset=utf-8' : 'text/csv;charset=utf-8',
|
||
});
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `accounts_${format === 'txt' ? 'names' : 'detail'}_${new Date().toISOString().slice(0, 10)}.${format}`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
message.success(`导出成功`);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const handleCopyUsernames = async () => {
|
||
if (!selectedAllMatching && selectedRowKeys.length > 0) {
|
||
const selectedKeysSet = new Set(selectedRowKeys);
|
||
const names = accounts
|
||
.filter((a) => selectedKeysSet.has(a.id))
|
||
.map((a) => a.username)
|
||
.filter(Boolean);
|
||
if (names.length === selectedRowKeys.length) {
|
||
await navigator.clipboard.writeText(names.join('\n'));
|
||
message.success(`已复制 ${names.length} 个账号名到剪贴板`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setExporting(true);
|
||
try {
|
||
const payload = selectedCount > 0
|
||
? selectionPayload
|
||
: { ...selectionPayload, all_matching: true };
|
||
const response = await accountApi.export({
|
||
...payload,
|
||
format: 'txt',
|
||
});
|
||
const blob = new Blob([response as unknown as BlobPart]);
|
||
const text = await blob.text();
|
||
await navigator.clipboard.writeText(text);
|
||
const lineCount = text.trim() ? text.trim().split('\n').length : 0;
|
||
message.success(`已复制 ${lineCount} 个账号名到剪贴板`);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const exportMenuItems: MenuProps['items'] = [
|
||
{
|
||
key: 'copy',
|
||
icon: <CopyOutlined />,
|
||
label: selectedCount > 0 ? `复制选中账号名 (${selectedCount} 个)` : `复制当前筛选账号名 (${total} 个)`,
|
||
onClick: handleCopyUsernames,
|
||
},
|
||
{
|
||
key: 'txt',
|
||
icon: <DownloadOutlined />,
|
||
label: `导出纯账号名名单 (TXT)`,
|
||
onClick: () => handleExport('txt'),
|
||
},
|
||
{
|
||
key: 'csv',
|
||
icon: <DownloadOutlined />,
|
||
label: `导出结构化明细报表 (CSV)`,
|
||
onClick: () => handleExport('csv'),
|
||
},
|
||
];
|
||
|
||
// 筛选持久化同步
|
||
useEffect(() => {
|
||
saveStoredFilters({
|
||
tagFilter,
|
||
statusFilter,
|
||
ckFilter,
|
||
failureKindFilter,
|
||
bindFilter,
|
||
assignFilter,
|
||
searchText,
|
||
highValueFilter,
|
||
minPoints,
|
||
maxPoints,
|
||
minGold,
|
||
maxGold,
|
||
eliteOpened,
|
||
esportsOpened,
|
||
roleBindStatus,
|
||
currentPage,
|
||
pageSize,
|
||
sortKey,
|
||
sortOrder,
|
||
advFilterOpen,
|
||
});
|
||
}, [
|
||
tagFilter,
|
||
statusFilter,
|
||
ckFilter,
|
||
failureKindFilter,
|
||
bindFilter,
|
||
assignFilter,
|
||
searchText,
|
||
highValueFilter,
|
||
minPoints,
|
||
maxPoints,
|
||
minGold,
|
||
maxGold,
|
||
eliteOpened,
|
||
esportsOpened,
|
||
roleBindStatus,
|
||
currentPage,
|
||
pageSize,
|
||
sortKey,
|
||
sortOrder,
|
||
advFilterOpen,
|
||
]);
|
||
|
||
// 筛选条件变化时清空选中项
|
||
useEffect(() => {
|
||
clearSelection();
|
||
}, [
|
||
searchText,
|
||
tagFilter,
|
||
statusFilter,
|
||
ckFilter,
|
||
failureKindFilter,
|
||
bindFilter,
|
||
assignStatusParam,
|
||
assignedUserIdParam,
|
||
highValueFilter,
|
||
minPoints,
|
||
maxPoints,
|
||
minGold,
|
||
maxGold,
|
||
eliteOpened,
|
||
esportsOpened,
|
||
roleBindStatus,
|
||
]);
|
||
|
||
const handleImport = async () => {
|
||
if (!importText.trim()) {
|
||
message.warning('请输入账号数据');
|
||
return;
|
||
}
|
||
setImporting(true);
|
||
try {
|
||
const result = await accountApi.import(importText, importTag);
|
||
message.success(result.message);
|
||
setImportOpen(false);
|
||
setImportText('');
|
||
setImportTag('');
|
||
loadAccounts();
|
||
loadTags();
|
||
loadSummary();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setImporting(false);
|
||
}
|
||
};
|
||
|
||
const tagColorMap = useMemo(() => {
|
||
const map: Record<string, string> = {};
|
||
tags.forEach((tg, i) => {
|
||
map[tg] = TAG_COLORS[i % TAG_COLORS.length];
|
||
});
|
||
return map;
|
||
}, [tags]);
|
||
|
||
const handleBatchTag = async () => {
|
||
if (selectedCount === 0) {
|
||
message.warning('请先选择账号');
|
||
return;
|
||
}
|
||
try {
|
||
const result = await accountApi.batchTagSelection({ ...selectionPayload, tag_value: batchTagInput });
|
||
message.success(result.message);
|
||
setBatchTagVisible(false);
|
||
setBatchTagInput('');
|
||
clearSelection();
|
||
loadAccounts();
|
||
loadTags();
|
||
loadSummary();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const handleBatchAssign = async (target: number | null) => {
|
||
if (selectedCount === 0) {
|
||
message.warning('请先选择账号,或先设置筛选条件后点「全选当前筛选」');
|
||
return;
|
||
}
|
||
setAssigning(true);
|
||
try {
|
||
const result = await accountApi.batchAssignSelection({
|
||
...selectionPayload,
|
||
assigned_to: target,
|
||
});
|
||
message.success(result.message);
|
||
setAssignModalOpen(false);
|
||
setAssignTargetId(undefined);
|
||
clearSelection();
|
||
loadAccounts();
|
||
loadSummary();
|
||
if (canAssign) loadAssignUsers();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setAssigning(false);
|
||
}
|
||
};
|
||
|
||
const handleSetTag = async (accountId: number, tag: string) => {
|
||
try {
|
||
await accountApi.setTag(accountId, tag);
|
||
message.success('标签已更新');
|
||
loadAccounts();
|
||
loadTags();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await accountApi.delete(id);
|
||
message.success('已删除');
|
||
setSelectedRowKeys((prev) => prev.filter((k) => k !== id));
|
||
setSelectedAllMatching(false);
|
||
loadAccounts();
|
||
loadTags();
|
||
loadSummary();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const [batchQuerying, setBatchQuerying] = useState(false);
|
||
const handleBatchOverview = async () => {
|
||
const ids = selectedRowKeys.map((key) => Number(key));
|
||
if (ids.length === 0) {
|
||
message.warning('请先勾选要查询的账号');
|
||
return;
|
||
}
|
||
setBatchQuerying(true);
|
||
try {
|
||
const created = await douyuApi.createTasks({
|
||
account_ids: ids,
|
||
task_type: 'query_account_overview',
|
||
handbook_scope: 'elite',
|
||
});
|
||
message.success(
|
||
`已创建 ${created.count} 条一键查询任务(批次 ${created.batch_id}),可到「斗鱼工作台 → 精英宝典」查看结果`,
|
||
);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setBatchQuerying(false);
|
||
}
|
||
};
|
||
|
||
const handleBatchDelete = async () => {
|
||
if (selectedCount === 0) {
|
||
message.warning('请先选择账号');
|
||
return;
|
||
}
|
||
try {
|
||
const result = await accountApi.batchDeleteSelection(selectionPayload);
|
||
message.success(result.message);
|
||
clearSelection();
|
||
loadAccounts();
|
||
loadTags();
|
||
loadSummary();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const handleSensitiveVisibility = () => {
|
||
if (sensitiveVisible) {
|
||
setAccounts((previous) =>
|
||
previous.map((account) => ({
|
||
...account,
|
||
password: undefined,
|
||
email: undefined,
|
||
email_password: undefined,
|
||
})),
|
||
);
|
||
setSensitiveVisible(false);
|
||
return;
|
||
}
|
||
modal.confirm({
|
||
title: '显示账号凭据?',
|
||
content: '密码、邮箱和邮箱密码将临时显示在当前页面。关闭或刷新页面后会恢复脱敏。',
|
||
okText: '显示',
|
||
cancelText: '取消',
|
||
onOk: () => setSensitiveVisible(true),
|
||
});
|
||
};
|
||
|
||
const hasCustomFilters = Boolean(
|
||
highValueFilter ||
|
||
minPoints !== undefined ||
|
||
maxPoints !== undefined ||
|
||
minGold !== undefined ||
|
||
maxGold !== undefined ||
|
||
eliteOpened !== undefined ||
|
||
esportsOpened !== undefined ||
|
||
roleBindStatus,
|
||
);
|
||
|
||
const hasAnyFilter = Boolean(
|
||
tagFilter ||
|
||
statusFilter ||
|
||
ckFilter ||
|
||
failureKindFilter ||
|
||
bindFilter ||
|
||
assignFilter ||
|
||
searchText.trim() ||
|
||
hasCustomFilters,
|
||
);
|
||
|
||
const assignFilterOptions = useMemo(
|
||
() => [
|
||
{ value: 'unassigned', label: '未分配' },
|
||
{ value: 'assigned', label: '已分配' },
|
||
...assignUsers.map((user) => ({
|
||
value: `user:${user.id}`,
|
||
label: `客服:${user.username}(${user.assigned_count})`,
|
||
})),
|
||
],
|
||
[assignUsers],
|
||
);
|
||
|
||
const columns = useMemo(
|
||
() =>
|
||
buildAccountColumns({
|
||
navigate,
|
||
sortKey,
|
||
sortOrder,
|
||
canViewFull,
|
||
canTag,
|
||
canDelete,
|
||
canAssign,
|
||
sensitiveVisible,
|
||
tags,
|
||
tagColorMap,
|
||
onOpenExchangeRecords: openExchangeRecords,
|
||
onOpenAssignModal: (recordId: number) => {
|
||
setSelectedRowKeys([recordId]);
|
||
setSelectedAllMatching(false);
|
||
setAssignTargetId(undefined);
|
||
setAssignModalOpen(true);
|
||
},
|
||
onSetTag: handleSetTag,
|
||
onDelete: handleDelete,
|
||
}),
|
||
[
|
||
navigate,
|
||
sortKey,
|
||
sortOrder,
|
||
canViewFull,
|
||
canTag,
|
||
canDelete,
|
||
canAssign,
|
||
sensitiveVisible,
|
||
tags,
|
||
tagColorMap,
|
||
],
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<AccountToolbar
|
||
tags={tags}
|
||
tagFilter={tagFilter}
|
||
setTagFilter={setTagFilter}
|
||
ckFilter={ckFilter}
|
||
setCkFilter={setCkFilter}
|
||
failureKindFilter={failureKindFilter}
|
||
setFailureKindFilter={setFailureKindFilter}
|
||
bindFilter={bindFilter}
|
||
setBindFilter={setBindFilter}
|
||
assignFilter={assignFilter}
|
||
setAssignFilter={setAssignFilter}
|
||
assignFilterOptions={assignFilterOptions}
|
||
searchText={searchText}
|
||
setSearchText={setSearchText}
|
||
hasAnyFilter={hasAnyFilter}
|
||
onResetAllFilters={resetAllFilters}
|
||
exportMenuItems={exportMenuItems}
|
||
exporting={exporting}
|
||
selectedCount={selectedCount}
|
||
canViewFull={canViewFull}
|
||
sensitiveVisible={sensitiveVisible}
|
||
onToggleSensitive={handleSensitiveVisibility}
|
||
canTag={canTag}
|
||
onOpenBatchTag={() => {
|
||
setBatchTagInput('');
|
||
setBatchTagVisible(true);
|
||
}}
|
||
canAssign={canAssign}
|
||
onOpenBatchAssign={() => {
|
||
setAssignTargetId(undefined);
|
||
setAssignModalOpen(true);
|
||
}}
|
||
canDelete={canDelete}
|
||
selectedAllMatching={selectedAllMatching}
|
||
onBatchDelete={handleBatchDelete}
|
||
selectedRowKeys={selectedRowKeys}
|
||
batchQuerying={batchQuerying}
|
||
onBatchOverview={handleBatchOverview}
|
||
canImport={canImport}
|
||
onOpenImport={() => {
|
||
setImportTag('');
|
||
setImportOpen(true);
|
||
}}
|
||
setCurrentPage={setCurrentPage}
|
||
/>
|
||
|
||
<StatusCards
|
||
summary={summary}
|
||
statusFilter={statusFilter}
|
||
ckFilter={ckFilter}
|
||
onSelectStatus={(status) => {
|
||
setStatusFilter(status);
|
||
setCkFilter('');
|
||
setCurrentPage(1);
|
||
}}
|
||
onClearAllStatus={() => {
|
||
setStatusFilter('');
|
||
setCkFilter('');
|
||
setCurrentPage(1);
|
||
}}
|
||
/>
|
||
|
||
<FilterPanel
|
||
summary={summary}
|
||
failureKindFilter={failureKindFilter}
|
||
setFailureKindFilter={setFailureKindFilter}
|
||
highValueFilter={highValueFilter}
|
||
setHighValueFilter={setHighValueFilter}
|
||
minPoints={minPoints}
|
||
setMinPoints={setMinPoints}
|
||
maxPoints={maxPoints}
|
||
setMaxPoints={setMaxPoints}
|
||
minGold={minGold}
|
||
setMinGold={setMinGold}
|
||
maxGold={maxGold}
|
||
setMaxGold={setMaxGold}
|
||
eliteOpened={eliteOpened}
|
||
setEliteOpened={setEliteOpened}
|
||
esportsOpened={esportsOpened}
|
||
setEsportsOpened={setEsportsOpened}
|
||
roleBindStatus={roleBindStatus}
|
||
setRoleBindStatus={setRoleBindStatus}
|
||
advFilterOpen={advFilterOpen}
|
||
setAdvFilterOpen={setAdvFilterOpen}
|
||
hasCustomFilters={hasCustomFilters}
|
||
onResetAdvancedFilters={resetAdvancedFilters}
|
||
onResetAdvancedFiltersState={resetAdvancedFiltersState}
|
||
setCurrentPage={setCurrentPage}
|
||
/>
|
||
|
||
<Table
|
||
tableLayout="fixed"
|
||
title={() =>
|
||
selectedRowKeys.length > 0 ? (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message={
|
||
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||
<Space wrap>
|
||
<span>
|
||
{selectedAllMatching
|
||
? `已选择当前筛选下全部 ${total} 个账号`
|
||
: `已选择 ${selectedRowKeys.length} 个账号`}
|
||
</span>
|
||
{!selectedAllMatching && total > selectedRowKeys.length ? (
|
||
<Button size="small" type="link" onClick={() => setSelectedAllMatching(true)}>
|
||
选择当前筛选下全部 {total} 个
|
||
</Button>
|
||
) : null}
|
||
<Button size="small" type="link" onClick={clearSelection}>
|
||
取消选择
|
||
</Button>
|
||
</Space>
|
||
<Space wrap>
|
||
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyUsernames}>
|
||
复制名单
|
||
</Button>
|
||
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleExport('txt')}>
|
||
导出 TXT
|
||
</Button>
|
||
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleExport('csv')}>
|
||
导出 CSV
|
||
</Button>
|
||
</Space>
|
||
</Space>
|
||
}
|
||
/>
|
||
) : undefined
|
||
}
|
||
rowSelection={
|
||
canSelectRows
|
||
? {
|
||
selectedRowKeys,
|
||
onChange: (keys) => {
|
||
setSelectedRowKeys(keys);
|
||
setSelectedAllMatching(false);
|
||
},
|
||
preserveSelectedRowKeys: true,
|
||
}
|
||
: undefined
|
||
}
|
||
columns={columns}
|
||
dataSource={accounts}
|
||
rowKey="id"
|
||
loading={loading}
|
||
size="small"
|
||
onChange={(_pagination, _filters, sorter, extra) => {
|
||
if (extra.action !== 'sort') return;
|
||
const current = Array.isArray(sorter) ? sorter[0] : sorter;
|
||
const nextOrder = current?.order ?? null;
|
||
const nextKey = nextOrder ? String(current?.columnKey ?? '') : '';
|
||
if (ACCOUNT_SORT_KEYS.has(nextKey)) {
|
||
setSortKey(nextKey as AccountSortKey);
|
||
setSortOrder(nextOrder);
|
||
} else {
|
||
setSortKey('');
|
||
setSortOrder(null);
|
||
}
|
||
setCurrentPage(1);
|
||
}}
|
||
pagination={{
|
||
current: currentPage,
|
||
pageSize,
|
||
total,
|
||
showSizeChanger: true,
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
onChange: (page, size) => {
|
||
setCurrentPage(page);
|
||
if (size !== pageSize) {
|
||
setPageSize(size);
|
||
localStorage.setItem('account_page_size', String(size));
|
||
setCurrentPage(1);
|
||
}
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<BatchImportModal
|
||
open={importOpen}
|
||
onCancel={() => setImportOpen(false)}
|
||
onOk={handleImport}
|
||
importing={importing}
|
||
importText={importText}
|
||
setImportText={setImportText}
|
||
importTag={importTag}
|
||
setImportTag={setImportTag}
|
||
tags={tags}
|
||
/>
|
||
|
||
<BatchTagModal
|
||
open={batchTagVisible}
|
||
onCancel={() => setBatchTagVisible(false)}
|
||
onOk={handleBatchTag}
|
||
selectedCount={selectedCount}
|
||
selectedAllMatching={selectedAllMatching}
|
||
batchTagInput={batchTagInput}
|
||
setBatchTagInput={setBatchTagInput}
|
||
tags={tags}
|
||
/>
|
||
|
||
<BatchAssignModal
|
||
open={assignModalOpen}
|
||
onCancel={() => setAssignModalOpen(false)}
|
||
assigning={assigning}
|
||
selectedCount={selectedCount}
|
||
selectedAllMatching={selectedAllMatching}
|
||
assignTargetId={assignTargetId}
|
||
setAssignTargetId={setAssignTargetId}
|
||
assignUsers={assignUsers}
|
||
onBatchAssign={handleBatchAssign}
|
||
/>
|
||
|
||
<ExchangeRecordsModal
|
||
exAccount={exAccount}
|
||
exLoading={exLoading}
|
||
exRecords={exRecords}
|
||
onClose={() => setExAccount(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|