Files
live-hub-py/web/frontend/src/pages/AccountsPage.tsx
T
yml2213 c9fbbdb828 feat(douyu): 支持账号批量撤回并刷新未分配状态
新增按勾选或标签批量撤回账号归属,撤回只处理确实已分配的账号。账号管理和分配管理增加可见时自动刷新、窗口聚焦刷新及手动刷新,解决批量登录完成后未分配账号延迟出现。
2026-11-06 13:25:32 +08:00

1180 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useMemo, useCallback, useRef } 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 { AccountNameBatchSelector } from './accounts/components/AccountNameBatchSelector';
import type { AccountNameMatchInfo } from './accounts/components/AccountNameBatchSelector';
import { StatusCards } from './accounts/components/StatusCards';
import { FilterPanel } from './accounts/components/FilterPanel';
import { buildAccountColumns } from './accounts/components/AccountColumns';
import {
BatchImportModal,
BatchTagModal,
BatchAssignModal,
BatchUnassignModal,
type BatchUnassignMode,
ExchangeRecordsModal,
} from './accounts/components/AccountModals';
function parseAccountNames(raw: string): string[] {
const names: string[] = [];
const seen = new Set<string>();
raw
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.forEach((line) => {
const name = line.trim();
if (name && !seen.has(name)) {
names.push(name);
seen.add(name);
}
});
return names;
}
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 [unassignModalOpen, setUnassignModalOpen] = useState(false);
const [unassignMode, setUnassignMode] = useState<BatchUnassignMode>('selected');
const [unassignTag, setUnassignTag] = useState('');
const [unassignTagCount, setUnassignTagCount] = useState<number | null>(null);
const [unassignTagCountLoading, setUnassignTagCountLoading] = useState(false);
const [unassigning, setUnassigning] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [searchText, setSearchText] = useState(() => initialFilters.searchText || '');
const [exporting, setExporting] = useState(false);
const accountRequestRef = useRef(0);
const unassignTagCountRequestRef = useRef(0);
// 按账号名批量选号:粘贴名单 -> 匹配 ID -> 自动勾选 -> 批量改标签
const [accountNameText, setAccountNameText] = useState('');
const [accountNamesFilter, setAccountNamesFilter] = useState('');
const [accountNameMatchInfo, setAccountNameMatchInfo] = useState<AccountNameMatchInfo | null>(null);
const [accountNameMatching, setAccountNameMatching] = 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('');
setAccountNameText('');
setAccountNamesFilter('');
setAccountNameMatchInfo(null);
resetAdvancedFiltersState();
setCurrentPage(1);
clearSelection();
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 (silent = false) => {
const requestId = ++accountRequestRef.current;
if (!silent) 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 (accountNamesFilter) params.account_names = accountNamesFilter;
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);
if (requestId !== accountRequestRef.current) return;
setAccounts(data.items);
setTotal(data.total);
} catch (e: unknown) {
if (requestId === accountRequestRef.current && !silent) {
message.error(getErrorMessage(e));
}
} finally {
if (requestId === accountRequestRef.current) {
setLoading(false);
}
}
}, [
canViewFull,
currentPage,
pageSize,
searchText,
sensitiveVisible,
tagFilter,
accountNamesFilter,
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(() => {
const refreshVisibleData = () => {
if (document.visibilityState !== 'visible') return;
void loadAccounts(true);
void loadSummary();
if (canAssign) void loadAssignUsers();
};
const timer = window.setInterval(refreshVisibleData, 15000);
const handleVisibility = () => {
if (document.visibilityState === 'visible') refreshVisibleData();
};
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', refreshVisibleData);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', refreshVisibleData);
};
}, [canAssign, loadAccounts, loadAssignUsers, loadSummary]);
useEffect(() => {
loadTags();
loadSummary();
}, [loadTags, loadSummary]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await Promise.allSettled([
loadAccounts(),
loadSummary(),
loadTags(),
...(canAssign ? [loadAssignUsers()] : []),
]);
} finally {
setRefreshing(false);
}
};
useEffect(() => {
if (!unassignModalOpen || unassignMode !== 'tag' || !unassignTag) {
unassignTagCountRequestRef.current += 1;
setUnassignTagCount(null);
setUnassignTagCountLoading(false);
return;
}
const requestId = ++unassignTagCountRequestRef.current;
setUnassignTagCountLoading(true);
setUnassignTagCount(null);
accountApi
.listIds({
assign_status: 'assigned',
tag: unassignTag,
})
.then((result) => {
if (requestId === unassignTagCountRequestRef.current) {
setUnassignTagCount(result.total);
}
})
.catch(() => {
if (requestId === unassignTagCountRequestRef.current) {
setUnassignTagCount(null);
}
})
.finally(() => {
if (requestId === unassignTagCountRequestRef.current) {
setUnassignTagCountLoading(false);
}
});
}, [unassignMode, unassignModalOpen, unassignTag]);
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,
account_names: accountNamesFilter || undefined,
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,
accountNamesFilter,
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 handleApplyAccountNames = async () => {
const names = parseAccountNames(accountNameText);
if (names.length === 0) {
message.warning('请先粘贴斗鱼账号名,每行一个');
return;
}
setAccountNameMatching(true);
try {
const result = await accountApi.listIds({
search: searchText.trim() || undefined,
tag: tagFilter || undefined,
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,
account_names: names.join('\n'),
});
if (result.total === 0) {
message.warning('没有匹配到账号,请检查账号名或当前筛选条件');
return;
}
setAccountNamesFilter(names.join('\n'));
setAccountNameMatchInfo({ submitted: names.length, matched: result.total });
setSelectedRowKeys(result.account_ids);
setSelectedAllMatching(false);
setCurrentPage(1);
const unmatched = Math.max(0, names.length - result.total);
message.success(
`已匹配并勾选 ${result.total} 个账号${unmatched > 0 ? `,${unmatched} 个账号名未匹配` : ''}`,
);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAccountNameMatching(false);
}
};
const handleClearAccountNames = () => {
setAccountNameText('');
setAccountNamesFilter('');
setAccountNameMatchInfo(null);
clearSelection();
setCurrentPage(1);
};
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 handleBatchUnassign = async () => {
if (unassignMode === 'selected' && selectedCount === 0) {
message.warning('请先勾选账号,或先选择要撤回的标签');
return;
}
if (unassignMode === 'tag' && !unassignTag.trim()) {
message.warning('请选择要撤回的账号标签');
return;
}
setUnassigning(true);
try {
const result =
unassignMode === 'tag'
? await accountApi.batchAssignSelection({
all_matching: true,
assign_status: 'assigned',
tag: unassignTag,
assigned_to: null,
})
: await accountApi.batchAssignSelection({
...selectionPayload,
assigned_to: null,
});
message.success(result.message);
setUnassignModalOpen(false);
setUnassignTag('');
clearSelection();
await Promise.all([loadAccounts(), loadSummary()]);
if (canAssign) await loadAssignUsers();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setUnassigning(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(
accountNamesFilter ||
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(
() =>
// 这些操作回调只会在点击/提交时读取请求序号 ref,渲染阶段不会访问。
// eslint-disable-next-line react-hooks/refs
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);
}}
onOpenBatchUnassign={() => {
setUnassignMode(selectedCount > 0 ? 'selected' : 'tag');
setUnassignTag(tagFilter || '');
setUnassignModalOpen(true);
}}
canDelete={canDelete}
selectedAllMatching={selectedAllMatching}
onBatchDelete={handleBatchDelete}
selectedRowKeys={selectedRowKeys}
batchQuerying={batchQuerying}
onBatchOverview={handleBatchOverview}
canImport={canImport}
onOpenImport={() => {
setImportTag('');
setImportOpen(true);
}}
onRefresh={handleRefresh}
refreshing={refreshing}
setCurrentPage={setCurrentPage}
/>
<AccountNameBatchSelector
value={accountNameText}
onChange={setAccountNameText}
onApply={handleApplyAccountNames}
onClear={handleClearAccountNames}
onOpenBatchTag={() => {
setBatchTagInput('');
setBatchTagVisible(true);
}}
loading={accountNameMatching}
canTag={canTag}
selectedCount={selectedCount}
matchInfo={accountNameMatchInfo}
/>
<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}
/>
<BatchUnassignModal
open={unassignModalOpen}
onCancel={() => {
setUnassignModalOpen(false);
setUnassignTag('');
}}
onConfirm={handleBatchUnassign}
unassigning={unassigning}
selectedCount={selectedCount}
selectedAllMatching={selectedAllMatching}
mode={unassignMode}
setMode={setUnassignMode}
tag={unassignTag}
setTag={setUnassignTag}
tags={tags}
tagCount={unassignTagCount}
tagCountLoading={unassignTagCountLoading}
/>
<ExchangeRecordsModal
exAccount={exAccount}
exLoading={exLoading}
exRecords={exRecords}
onClose={() => setExAccount(null)}
/>
</div>
);
}