import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Avatar, Button, Card, Input, InputNumber, Modal, Popconfirm, Progress, Select, Space, Table, Tag, Tooltip, Typography, theme, } from 'antd'; import { message } from '../utils/antdMessage'; import type { TableProps } from 'antd'; import { CheckCircleOutlined, ClockCircleOutlined, CopyOutlined, DeleteOutlined, FilterOutlined, GiftOutlined, ImportOutlined, KeyOutlined, LoginOutlined, MailOutlined, MobileOutlined, ReloadOutlined, SafetyCertificateOutlined, SearchOutlined, TagOutlined, TrophyOutlined, UserOutlined, } from '@ant-design/icons'; import { huyaApi, type HuyaAccountItem, type HuyaAccountSummary, type HuyaPasswordLoginBatchItem, type SupportUserItem, } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTime } from '../utils/time'; import { getErrorMessage } from '../utils/error'; const { Text, Paragraph } = Typography; const { TextArea } = Input; const STATUS_LABELS: Record = { imported: '已导入', updated: '已更新', password_imported: '待登录', login_success: '登录成功', password_changed: '已改密', login_failed: '登录失败', active: '正常', invalid: '失效', exchange_record: '已兑换', }; // 登录渠道展示 (对应后端 HuyaAccount.login_channel: web/app/sms) const LOGIN_CHANNEL_LABELS: Record = { app: { label: 'App 协议', color: 'processing' }, web: { label: 'Web 旧版', color: 'warning' }, sms: { label: '短信', color: 'cyan' }, }; const STATUS_COLORS: Record = { imported: 'blue', updated: 'cyan', password_imported: 'warning', login_success: 'success', password_changed: 'success', login_failed: 'error', active: 'success', invalid: 'error', exchange_record: 'purple', }; const HUYA_LOGIN_INTERVAL_KEY = 'huya_login_interval_seconds'; const HUYA_LOGIN_DEFAULT_INTERVAL = 8; const HUYA_LOGIN_MAX_ATTEMPTS = 2; function readLoginIntervalSeconds(): number { const stored = Number(localStorage.getItem(HUYA_LOGIN_INTERVAL_KEY)); if (!Number.isFinite(stored)) return HUYA_LOGIN_DEFAULT_INTERVAL; return Math.min(30, Math.max(0, stored)); } function sleepWithCountdown( totalMs: number, aborted: () => boolean, onTick: (secondsLeft: number) => void, ): Promise { return new Promise((resolve) => { const started = Date.now(); const tick = () => { if (aborted()) { resolve(); return; } const left = Math.max(0, totalMs - (Date.now() - started)); onTick(Math.ceil(left / 1000)); if (left <= 0) { resolve(); return; } window.setTimeout(tick, Math.min(250, left)); }; tick(); }); } function copyToClipboard(text: string): Promise { if (navigator.clipboard?.writeText) { return navigator.clipboard.writeText(text); } return new Promise((resolve, reject) => { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); try { const ok = document.execCommand('copy'); document.body.removeChild(ta); if (ok) resolve(); else reject(new Error('copy failed')); } catch (e) { document.body.removeChild(ta); reject(e); } }); } export default function HuyaAccountsPage() { const { token } = theme.useToken(); const [accounts, setAccounts] = useState([]); const [users, setUsers] = 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 [passwordImportOpen, setPasswordImportOpen] = useState(false); const [passwordImportText, setPasswordImportText] = useState(''); const [passwordImportTag, setPasswordImportTag] = useState([]); const [passwordImporting, setPasswordImporting] = useState(false); const [passwordLoginResultOpen, setPasswordLoginResultOpen] = useState(false); const [passwordLogging, setPasswordLogging] = useState(false); const [appPasswordLogging, setAppPasswordLogging] = useState(false); const [loginModalTitle, setLoginModalTitle] = useState('虎牙 App 协议登录结果 (推荐)'); const [passwordLoginResults, setPasswordLoginResults] = useState([]); const [loginQueuePhase, setLoginQueuePhase] = useState<'idle' | 'login' | 'wait' | 'done' | 'stopped'>('idle'); const [loginQueueIndex, setLoginQueueIndex] = useState(0); const [loginQueueTotal, setLoginQueueTotal] = useState(0); const [loginQueueUsername, setLoginQueueUsername] = useState(''); const [loginWaitLeft, setLoginWaitLeft] = useState(0); const [loginIntervalSeconds, setLoginIntervalSeconds] = useState(readLoginIntervalSeconds); const loginAbortRef = useRef(false); const loginRunningRef = useRef(false); const loginChannelRef = useRef<'app' | 'web'>('app'); const loginIntervalRef = useRef(loginIntervalSeconds); const [smsLoginOpen, setSmsLoginOpen] = useState(false); const [smsPhone, setSmsPhone] = useState(''); const [smsCode, setSmsCode] = useState(''); const [smsTag, setSmsTag] = useState([]); const [smsState, setSmsState] = useState(''); const [smsSending, setSmsSending] = useState(false); const [smsLogging, setSmsLogging] = useState(false); const [searchText, setSearchText] = useState(''); const [tagFilter, setTagFilter] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [selectedAllMatching, setSelectedAllMatching] = useState(false); const [batchTagInput, setBatchTagInput] = useState(''); const [batchTagVisible, setBatchTagVisible] = useState(false); const [total, setTotal] = useState(0); const [summary, setSummary] = useState({ total: 0, assigned_count: 0, unassigned_count: 0, tag_count: 0, password_ready_count: 0, point_count: 0, bound_count: 0, }); const [pageSize, setPageSize] = useState(() => { const v = localStorage.getItem('huya_account_page_size'); return v ? Number(v) || 20 : 20; }); const [currentPage, setCurrentPage] = useState(1); const { can } = usePermissions(); const canManage = can('huya:account'); const canImport = can('huya:import') || canManage; const canTag = can('huya:tag') || canImport; const canAssign = can('huya:assign') || canManage; const canDelete = can('huya:delete') || canManage; const canViewCookie = can('huya:cookie:view') || can('huya:cookie:export') || canManage; const loadAccounts = useCallback(async () => { setLoading(true); try { const params: { tag?: string } = {}; if (tagFilter) params.tag = tagFilter; const data = await huyaApi.listAccountsPaged({ ...params, page: currentPage, page_size: pageSize, search: searchText.trim() || undefined, include_cookie: false, }); setAccounts(data.items); setTotal(data.total); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setLoading(false); } }, [currentPage, pageSize, searchText, tagFilter]); const loadSummary = useCallback(async () => { try { const data = await huyaApi.accountsSummary(); setSummary(data); } catch { // 统计加载失败时不影响主列表操作。 } }, []); const loadUsers = useCallback(async () => { try { const data = await huyaApi.assignmentsSummary(); setUsers(data.support_users); } catch { // 忽略客服列表加载失败,账号列表仍可继续使用。 } }, []); const loadTags = useCallback(async () => { try { const data = await huyaApi.listTags(); setTags(data); } catch { // 忽略标签加载失败,页面会退化为无标签筛选。 } }, []); useEffect(() => { loadAccounts(); }, [loadAccounts]); useEffect(() => { if (canAssign) loadUsers(); loadTags(); loadSummary(); }, [canAssign, loadUsers, loadTags, loadSummary]); const tagColorMap = useMemo(() => { const map: Record = {}; tags.forEach((tag, index) => { map[tag] = ['blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano'][index % 8]; }); return map; }, [tags]); const selectedCount = selectedAllMatching ? total : selectedRowKeys.length; const loginQueueBusy = loginQueuePhase === 'login' || loginQueuePhase === 'wait'; const selectionPayload = useMemo(() => ({ account_ids: selectedAllMatching ? [] : selectedRowKeys.map((key) => Number(key)), all_matching: selectedAllMatching, search: searchText.trim(), tag: tagFilter, }), [searchText, selectedAllMatching, selectedRowKeys, tagFilter]); const clearSelection = () => { setSelectedRowKeys([]); setSelectedAllMatching(false); }; useEffect(() => { loginIntervalRef.current = loginIntervalSeconds; }, [loginIntervalSeconds]); useEffect(() => { clearSelection(); }, [searchText, tagFilter]); const handleImport = async () => { if (!importText.trim()) { message.warning('请先粘贴虎牙 CK'); return; } setImporting(true); try { const result = await huyaApi.importCookies(importText, importTag); message.success(result.message); setImportOpen(false); setImportText(''); setImportTag(''); loadAccounts(); loadTags(); loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setImporting(false); } }; const openPasswordImport = () => { setPasswordImportText(''); setPasswordImportTag([]); setPasswordLoginResults([]); setPasswordImportOpen(true); }; const openSmsLogin = () => { setSmsPhone(''); setSmsCode(''); setSmsTag([]); setSmsState(''); setSmsLoginOpen(true); }; const handleSendSmsCode = async () => { const phone = smsPhone.trim(); if (!phone) { message.warning('请先输入手机号'); return; } setSmsSending(true); try { const result = await huyaApi.smsCode({ phone }); setSmsState(result.state); message.success(result.message || '短信已发送'); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setSmsSending(false); } }; const handleSmsLogin = async () => { const phone = smsPhone.trim(); const authcode = smsCode.trim(); if (!smsState) { message.warning('请先发送短信验证码'); return; } if (!authcode) { message.warning('请先输入短信验证码'); return; } setSmsLogging(true); try { const tag = smsTag.length > 0 ? smsTag[smsTag.length - 1].trim() : ''; const result = await huyaApi.smsLogin({ phone, authcode, state: smsState, tag, }); message.success(result.message || '登录成功,Cookie 已保存'); setSmsLoginOpen(false); setSmsPhone(''); setSmsCode(''); setSmsTag([]); setSmsState(''); loadAccounts(); loadTags(); loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setSmsLogging(false); } }; const handleImportPasswordAccounts = async () => { const rawLines = passwordImportText .split('\n') .map((l) => l.trim()) .filter((l) => l && !l.startsWith('#')); if (rawLines.length === 0) { message.warning('请先粘贴虎牙账号密码'); return; } const invalidLines: number[] = []; rawLines.forEach((line, idx) => { const parts = line.includes('----') ? line.split('----').map((p) => p.trim()) : line.split(/\s+/).map((p) => p.trim()); if ( parts.length < 4 || !parts[0] || !parts[1] || !parts[2] || !parts[3] || !/^https?:\/\//i.test(parts[3]) ) { invalidLines.push(idx + 1); } }); if (invalidLines.length > 0) { message.error( `格式错误:仅允许四段格式(账号----密码----手机号----接码链接)。第 ${invalidLines.slice(0, 5).join('、')}${invalidLines.length > 5 ? ` 等共 ${invalidLines.length}` : ''} 行不符合要求` ); return; } setPasswordImporting(true); try { const tag = passwordImportTag.length > 0 ? passwordImportTag[passwordImportTag.length - 1].trim() : ''; const result = await huyaApi.importPasswordAccounts(passwordImportText, tag); message.success(result.message); setPasswordImportOpen(false); setPasswordImportText(''); setPasswordImportTag([]); loadAccounts(); loadTags(); loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setPasswordImporting(false); } }; const resolveLoginAccountIds = async (): Promise => { if (selectedAllMatching) { const data = await huyaApi.listAccountIds({ search: searchText.trim() || undefined, tag: tagFilter || undefined, }); return data.account_ids; } return selectedRowKeys.map((key) => Number(key)); }; const runLoginQueue = async ( ids: number[], channel: 'app' | 'web', options?: { append?: boolean }, ) => { if (ids.length === 0) { message.warning('没有可登录的账号'); return; } if (loginRunningRef.current) { message.warning('已有登录队列在执行'); return; } loginAbortRef.current = false; loginRunningRef.current = true; loginChannelRef.current = channel; const channelLabel = channel === 'app' ? 'App 协议' : 'Web'; if (channel === 'app') { setAppPasswordLogging(true); } else { setPasswordLogging(true); } setLoginQueueTotal(ids.length); setLoginQueueIndex(0); setLoginQueueUsername(''); setLoginWaitLeft(0); setLoginQueuePhase('login'); setLoginModalTitle(`${channelLabel}登录中`); if (!options?.append) { setPasswordLoginResults([]); } setPasswordLoginResultOpen(true); const usernameById = new Map( accounts.map((account) => [ account.id, account.username || account.nickname || String(account.id), ]), ); let successCount = 0; let failedCount = 0; for (let index = 0; index < ids.length; index += 1) { if (loginAbortRef.current) { const remaining = ids.slice(index).map((accountId) => ({ line: accountId, username: usernameById.get(accountId) || '', success: false, message: '队列已停止,未登录', })); setPasswordLoginResults((prev) => [...prev, ...remaining]); failedCount += remaining.length; setLoginQueuePhase('stopped'); break; } const accountId = ids[index]; setLoginQueueIndex(index + 1); setLoginQueueUsername(usernameById.get(accountId) || `#${accountId}`); setLoginQueuePhase('login'); try { const payload = { account_ids: [accountId], all_matching: false, search: '', tag: '', max_attempts: HUYA_LOGIN_MAX_ATTEMPTS, interval_seconds: 0, }; const result = channel === 'app' ? await huyaApi.appPasswordLoginSelected(payload) : await huyaApi.passwordLoginSelected(payload); const item = result.results?.[0] || { line: accountId, username: usernameById.get(accountId) || '', success: false, message: result.message || '登录失败', }; if (item.success) successCount += 1; else failedCount += 1; setPasswordLoginResults((prev) => [...prev, item]); } catch (e: unknown) { failedCount += 1; setPasswordLoginResults((prev) => [ ...prev, { line: accountId, username: usernameById.get(accountId) || '', success: false, message: getErrorMessage(e), }, ]); } const intervalSec = loginIntervalRef.current; if (index < ids.length - 1 && !loginAbortRef.current && intervalSec > 0) { setLoginQueuePhase('wait'); await sleepWithCountdown( intervalSec * 1000, () => loginAbortRef.current, setLoginWaitLeft, ); setLoginWaitLeft(0); } } if (!loginAbortRef.current) { setLoginQueuePhase('done'); } loginRunningRef.current = false; setAppPasswordLogging(false); setPasswordLogging(false); loadAccounts(); loadTags(); loadSummary(); if (loginAbortRef.current) { message.warning(`${channelLabel}登录已停止:成功 ${successCount},失败/未执行 ${failedCount}`); } else if (failedCount > 0) { message.warning(`${channelLabel}登录完成:成功 ${successCount},失败 ${failedCount}`); } else { message.success(`${channelLabel}登录完成:成功 ${successCount} 条`); } }; const handleStopLoginQueue = () => { if (!loginRunningRef.current) return; loginAbortRef.current = true; message.info('将在当前账号结束后停止队列'); }; const handleRetryFailedLogins = async () => { const failedIds = passwordLoginResults .filter((item) => !item.success) .map((item) => Number(item.line)) .filter((id) => Number.isFinite(id) && id > 0); if (failedIds.length === 0) { message.warning('没有失败的账号可重试'); return; } setPasswordLoginResults((prev) => prev.filter((item) => item.success)); await runLoginQueue(failedIds, loginChannelRef.current, { append: true }); }; const handleAppLoginSelected = async () => { if (selectedCount === 0) { message.warning('请先选择虎牙账号'); return; } try { const ids = await resolveLoginAccountIds(); await runLoginQueue(ids, 'app'); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; // 行内单账号登录: 复用批量端点, 只传该行 id (需已导入密码) const [rowLoginKey, setRowLoginKey] = useState(''); const handleRowLogin = async (record: HuyaAccountItem, channel: 'app' | 'web') => { if (loginRunningRef.current) { message.warning('已有登录队列在执行,请等待完成或先停止'); return; } setRowLoginKey(`${channel}-${record.id}`); loginChannelRef.current = channel; try { const result = channel === 'app' ? await huyaApi.appPasswordLoginSelected({ account_ids: [record.id], all_matching: false, search: '', tag: '', max_attempts: HUYA_LOGIN_MAX_ATTEMPTS, interval_seconds: 0, }) : await huyaApi.passwordLoginSelected({ account_ids: [record.id], all_matching: false, search: '', tag: '', max_attempts: HUYA_LOGIN_MAX_ATTEMPTS, interval_seconds: 0, }); setPasswordLoginResults(result.results || []); setLoginQueuePhase('done'); setLoginQueueIndex(1); setLoginQueueTotal(1); setLoginQueueUsername(record.username || record.nickname || String(record.id)); setLoginModalTitle(channel === 'app' ? 'App 协议登录结果' : 'Web 旧版登录结果'); setPasswordLoginResultOpen(true); const ok = (result.results || []).some((item) => item.success); if (ok) { message.success('登录成功,Cookie 已保存'); } else { message.warning(result.message || '登录失败'); } loadAccounts(); loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setRowLoginKey(''); } }; const [fetchingSmsId, setFetchingSmsId] = useState(null); const handleFetchSmsCode = async (record: HuyaAccountItem) => { setFetchingSmsId(record.id); try { const res = await huyaApi.fetchSmsCode(record.id); if (res.success && res.code) { Modal.success({ title: '已获取最新短信验证码', content: (
验证码: {res.code}
{res.raw}
), }); } else { Modal.info({ title: '接码链接查询结果', content: (

{res.message}

{res.raw || '尚未收到新短信'}
), }); } } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setFetchingSmsId(null); } }; const handleWebLoginSelected = async () => { if (selectedCount === 0) { message.warning('请先选择虎牙账号'); return; } try { const ids = await resolveLoginAccountIds(); await runLoginQueue(ids, 'web'); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; const handleDelete = async (id: number) => { try { await huyaApi.deleteAccount(id); message.success('已删除'); setSelectedRowKeys((prev) => prev.filter((key) => key !== id)); setSelectedAllMatching(false); loadAccounts(); loadTags(); loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; const handleDeleteSelected = async () => { if (selectedCount === 0) { message.warning('请先选择虎牙账号'); return; } try { const result = await huyaApi.deleteAccountsSelection(selectionPayload); message.success(result.message); clearSelection(); loadAccounts(); loadTags(); loadSummary(); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; const handleAssign = async (accountId: number, assignedTo: number | null) => { try { await huyaApi.assign(accountId, assignedTo); message.success('已分配'); loadAccounts(); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; const handleSetTag = async (accountId: number, tag: string) => { try { await huyaApi.setTag(accountId, tag); message.success('标签已更新'); loadAccounts(); loadTags(); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; const handleBatchTag = async () => { if (selectedCount === 0) { message.warning('请先选择虎牙账号'); return; } try { const result = await huyaApi.batchTagSelection({ ...selectionPayload, tag_value: batchTagInput }); message.success(result.message); setBatchTagVisible(false); setBatchTagInput(''); clearSelection(); loadAccounts(); loadTags(); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; const passwordLoginResultColumns: TableProps['columns'] = [ { title: '账号ID', dataIndex: 'line', width: 80, align: 'center' }, { title: '账号', dataIndex: 'username', width: 160, ellipsis: true }, { title: '状态', dataIndex: 'success', width: 90, align: 'center', render: (success: boolean) => ( {success ? '成功' : '失败'} ), }, { title: '尝试', dataIndex: 'attempts', width: 70, align: 'center', render: (value?: number) => value || '-', }, { title: '保存账号', width: 160, ellipsis: true, render: (_: unknown, record) => record.account?.nickname || record.account?.username || record.account?.uid || '-', }, { title: '提示', dataIndex: 'message', ellipsis: true }, ]; const columns: TableProps['columns'] = [ { title: 'ID', dataIndex: 'id', width: 60, align: 'center', render: (id: number) => ( {id} ), }, { title: '虎牙账号', width: 210, render: (_: unknown, record) => { const name = record.nickname || record.username || '-'; const initial = (name.replace(/^hy_/, '') || 'H').slice(0, 1).toUpperCase(); return ( {initial}
{name} {record.has_password ? ( 密 ) : null}
UID {record.uid || record.yyuid || '未同步'} {(record.uid || record.yyuid) && (
); }, }, { title: '标签', dataIndex: 'tag', width: 150, render: (tag: string, record) => { if (canTag) { return ( handleAssign(record.id, value ?? null)} options={users.map((user) => ({ value: user.id, label: user.username }))} /> ); } return record.assigned_username ? ( {record.assigned_username} ) : ( 未分配 ); }, }, { title: '账号状态', dataIndex: 'status', width: 100, align: 'center', render: (status: string) => ( {STATUS_LABELS[status] || status || '-'} ), }, { title: '登录渠道', dataIndex: 'login_channel', width: 95, align: 'center', render: (channel: string) => { const info = LOGIN_CHANNEL_LABELS[channel]; return info ? ( {info.label} ) : ( 仅导入 ); }, }, { title: '更新时间', dataIndex: 'updated_at', width: 150, render: (value: string | null) => value ? ( {formatTime(value)} ) : ( - ), }, { title: '操作', width: 160, fixed: 'right', align: 'center', render: (_: unknown, record) => ( {canImport && record.has_password && ( )} {canImport && ( )} {canImport && ( )} {/* KPI 数据卡片栏 (整齐等宽响应式栅格,彻底告别空缺折行) */}
账号总数
{summary.total}
已存密码
{summary.password_ready_count}
已查积分
{summary.point_count}
已绑礼盒
{summary.bound_count}
标签数
{summary.tag_count || tags.length}
已分配
{summary.assigned_count}
未分配
{summary.unassigned_count}
{/* 搜索过滤与快捷标签面板 */}
{ setSearchText(e.target.value); setCurrentPage(1); }} style={{ width: 300 }} prefix={} /> setImportTag(e.target.value)} />