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([]); const [tags, setTags] = useState([]); 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(() => initialFilters.tagFilter || ''); const [statusFilter, setStatusFilter] = useState(() => initialFilters.statusFilter || ''); const [ckFilter, setCkFilter] = useState(() => initialFilters.ckFilter || ''); const [failureKindFilter, setFailureKindFilter] = useState(() => initialFilters.failureKindFilter || ''); const [bindFilter, setBindFilter] = useState(() => initialFilters.bindFilter || ''); const [assignFilter, setAssignFilter] = useState(() => initialFilters.assignFilter || ''); const [assignUsers, setAssignUsers] = useState([]); const [assignModalOpen, setAssignModalOpen] = useState(false); const [assignTargetId, setAssignTargetId] = useState(undefined); const [assigning, setAssigning] = useState(false); const [searchText, setSearchText] = useState(() => initialFilters.searchText || ''); const [exporting, setExporting] = useState(false); // 高价值资产与高级自定义筛选状态 const [highValueFilter, setHighValueFilter] = useState(() => initialFilters.highValueFilter || ''); const [minPoints, setMinPoints] = useState(() => initialFilters.minPoints); const [maxPoints, setMaxPoints] = useState(() => initialFilters.maxPoints); const [minGold, setMinGold] = useState(() => initialFilters.minGold); const [maxGold, setMaxGold] = useState(() => initialFilters.maxGold); const [eliteOpened, setEliteOpened] = useState(() => initialFilters.eliteOpened); const [esportsOpened, setEsportsOpened] = useState(() => initialFilters.esportsOpened); const [roleBindStatus, setRoleBindStatus] = useState(() => initialFilters.roleBindStatus || ''); const [advFilterOpen, setAdvFilterOpen] = useState(() => 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(null); const [exLoading, setExLoading] = useState(false); const [exAccount, setExAccount] = useState(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([]); 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({ 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(() => initialFilters.currentPage || 1); const [sortKey, setSortKey] = useState(() => 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[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: , label: selectedCount > 0 ? `复制选中账号名 (${selectedCount} 个)` : `复制当前筛选账号名 (${total} 个)`, onClick: handleCopyUsernames, }, { key: 'txt', icon: , label: `导出纯账号名名单 (TXT)`, onClick: () => handleExport('txt'), }, { key: 'csv', icon: , 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 = {}; 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 (
{ 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} /> { setStatusFilter(status); setCkFilter(''); setCurrentPage(1); }} onClearAllStatus={() => { setStatusFilter(''); setCkFilter(''); setCurrentPage(1); }} /> selectedRowKeys.length > 0 ? ( {selectedAllMatching ? `已选择当前筛选下全部 ${total} 个账号` : `已选择 ${selectedRowKeys.length} 个账号`} {!selectedAllMatching && total > selectedRowKeys.length ? ( ) : null} } /> ) : 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); } }, }} /> setImportOpen(false)} onOk={handleImport} importing={importing} importText={importText} setImportText={setImportText} importTag={importTag} setImportTag={setImportTag} tags={tags} /> setBatchTagVisible(false)} onOk={handleBatchTag} selectedCount={selectedCount} selectedAllMatching={selectedAllMatching} batchTagInput={batchTagInput} setBatchTagInput={setBatchTagInput} tags={tags} /> setAssignModalOpen(false)} assigning={assigning} selectedCount={selectedCount} selectedAllMatching={selectedAllMatching} assignTargetId={assignTargetId} setAssignTargetId={setAssignTargetId} assignUsers={assignUsers} onBatchAssign={handleBatchAssign} /> setExAccount(null)} /> ); }