Files
live-hub-py/web/frontend/src/pages/AccountsPage.tsx
T
yml2213 f101414f5e feat(accounts): 新增登录异常分型统计、多选/全量导出、筛选记忆及页面组件模块化拆分
- 新增登录失败异常分型统计:支持「必须绑定手机号」、「极验滑块」、「凭证错误」、「邮箱取码失败」、「邮箱配额已满」、「网络异常」分型识别、生命周期联动(待人工)与一键筛选;
- 新增账号名单与明细导出功能:支持勾选多选或按当前筛选条件全选,可导出纯用户名名单 (TXT) 或 UTF-8 BOM 结构化明细报表 (CSV),并支持一键复制到剪贴板;
- 优化筛选条件记忆机制:支持页面切换/返回后自动保留所有筛选与排序状态,新增顶部「重置全部筛选」快捷按钮;
- 模块化拆分 AccountsPage:将 2100+ 行的大文件拆解至 pages/accounts/ 子目录(包含状态卡片、筛选面板、工具栏、操作弹窗与表格构建器),提升可维护性;
- 验证方式:新增 5 个后端自动化单测 (test_account_failure_and_export.py),全量 694 个测试用例通过,前端 TypeScript 编译与 pre-commit 代码规范检查全部通过。
2026-10-07 17:23:39 +08:00

929 lines
28 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 } 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>
);
}