Files
live-hub-py/web/frontend/src/pages/HuyaAccountsPage.tsx
T

1991 lines
63 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 { 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<string, string> = {
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<string, { label: string; color: string }> = {
app: { label: 'App 协议', color: 'processing' },
web: { label: 'Web 旧版', color: 'warning' },
sms: { label: '短信', color: 'cyan' },
};
const STATUS_COLORS: Record<string, string> = {
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<void> {
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<void> {
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<HuyaAccountItem[]>([]);
const [users, setUsers] = useState<SupportUserItem[]>([]);
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 [passwordImportOpen, setPasswordImportOpen] = useState(false);
const [passwordImportText, setPasswordImportText] = useState('');
const [passwordImportTag, setPasswordImportTag] = useState<string[]>([]);
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<HuyaPasswordLoginBatchItem[]>([]);
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<string[]>([]);
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<React.Key[]>([]);
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
const [batchTagInput, setBatchTagInput] = useState('');
const [batchTagVisible, setBatchTagVisible] = useState(false);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<HuyaAccountSummary>({
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<string, string> = {};
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<number[]> => {
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<number | null>(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: (
<div style={{ marginTop: 12 }}>
<div style={{ marginBottom: 8, fontSize: 16 }}>
验证码: <b style={{ color: '#1677ff', fontSize: 22 }}>{res.code}</b>
</div>
<Paragraph type="secondary" copyable>{res.raw}</Paragraph>
</div>
),
});
} else {
Modal.info({
title: '接码链接查询结果',
content: (
<div style={{ marginTop: 12 }}>
<p>{res.message}</p>
<Paragraph type="secondary">{res.raw || '尚未收到新短信'}</Paragraph>
</div>
),
});
}
} 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<HuyaPasswordLoginBatchItem>['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) => (
<Tag color={success ? 'success' : 'error'}>{success ? '成功' : '失败'}</Tag>
),
},
{
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<HuyaAccountItem>['columns'] = [
{
title: 'ID',
dataIndex: 'id',
width: 60,
align: 'center',
render: (id: number) => (
<Text type="secondary" style={{ fontSize: 12 }}>
{id}
</Text>
),
},
{
title: '虎牙账号',
width: 210,
render: (_: unknown, record) => {
const name = record.nickname || record.username || '-';
const initial = (name.replace(/^hy_/, '') || 'H').slice(0, 1).toUpperCase();
return (
<Space size={10} align="center">
<Avatar
size={32}
style={{
backgroundColor: token.colorPrimaryBg,
color: token.colorPrimary,
fontWeight: 600,
fontSize: 14,
border: `1px solid ${token.colorPrimaryBorder}`,
flexShrink: 0,
}}
>
{initial}
</Avatar>
<Space orientation="vertical" size={1} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Text strong style={{ fontSize: 13 }}>
{name}
</Text>
{record.has_password ? (
<Tooltip title="已导入密码">
<Tag
color="gold"
style={{
margin: 0,
fontSize: 10,
lineHeight: '16px',
padding: '0 4px',
borderRadius: 4,
}}
>
密
</Tag>
</Tooltip>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Text type="secondary" style={{ fontSize: 11, fontFamily: 'monospace' }}>
UID {record.uid || record.yyuid || '未同步'}
</Text>
{(record.uid || record.yyuid) && (
<Tooltip title="复制 UID">
<Button
type="text"
size="small"
icon={<CopyOutlined style={{ fontSize: 11, color: '#8c8c8c' }} />}
style={{ padding: 0, width: 16, height: 16, minWidth: 16 }}
onClick={() => {
copyToClipboard(record.uid || record.yyuid || '');
message.success('UID 已复制');
}}
/>
</Tooltip>
)}
</div>
</Space>
</Space>
);
},
},
{
title: '标签',
dataIndex: 'tag',
width: 150,
render: (tag: string, record) => {
if (canTag) {
return (
<Select
mode="tags"
maxCount={1}
allowClear
showSearch
optionFilterProp="label"
size="small"
placeholder="+ 标签"
style={{ width: 130 }}
value={tag ? [tag] : []}
onChange={(values) => {
const value = values.at(-1)?.trim() || '';
if (value !== tag) handleSetTag(record.id, value);
}}
options={tags.map((value) => ({ value, label: value }))}
/>
);
}
return tag ? (
<Tag color={tagColorMap[tag]} style={{ borderRadius: 10, padding: '1px 8px' }}>
{tag}
</Tag>
) : (
<Text type="secondary">-</Text>
);
},
},
{
title: '积分',
dataIndex: 'points',
width: 85,
align: 'center',
render: (points: number | null) =>
points !== null && points !== undefined ? (
<Text strong style={{ color: points > 0 ? '#52c41a' : undefined, fontSize: 13 }}>
{points}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
未查
</Text>
),
},
{
title: '游戏角色',
dataIndex: 'game_name',
width: 150,
ellipsis: true,
render: (value: string, record) =>
value ? (
<Space orientation="vertical" size={1}>
<Text style={{ fontSize: 13 }}>{value}</Text>
{record.game_channel ? (
<Text type="secondary" style={{ fontSize: 11 }}>
{record.game_channel}
</Text>
) : null}
</Space>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
未查
</Text>
),
},
{
title: '手机号 / 接码',
width: 170,
render: (_: unknown, record) => (
<Space orientation="vertical" size={2}>
<Text style={{ fontFamily: 'monospace', fontSize: 13 }}>
{record.game_phone || '-'}
</Text>
{record.sms_url ? (
<Tooltip title={`接码链接: ${record.sms_url}`}>
<Tag
color="cyan"
style={{
margin: 0,
fontSize: 11,
borderRadius: 10,
padding: '0 8px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 3,
}}
icon={<SafetyCertificateOutlined />}
>
自动接码
</Tag>
</Tooltip>
) : (
<Text type="secondary" style={{ fontSize: 11 }}>
未绑接码
</Text>
)}
</Space>
),
},
{
title: 'Cookie 凭据',
width: 110,
align: 'center',
render: (_: unknown, record) => {
const hasCookie = Boolean(record.has_cookie || record.cookie_preview || record.cookie);
if (!hasCookie) {
return (
<Tag color="default" style={{ borderRadius: 10, fontSize: 11 }}>
未获取
</Tag>
);
}
return (
<Tooltip title={canViewCookie ? '已就绪,点击复制 Cookie 预览' : '已获取登录凭据'}>
<Tag
color="success"
style={{
borderRadius: 10,
fontSize: 11,
cursor: canViewCookie ? 'pointer' : 'default',
padding: '1px 8px',
}}
onClick={() => {
if (canViewCookie && (record.cookie_preview || record.cookie)) {
copyToClipboard(record.cookie_preview || record.cookie || '');
message.success('Cookie 凭据已复制到剪贴板');
}
}}
>
已就绪 {canViewCookie ? <CopyOutlined style={{ fontSize: 10, marginLeft: 2 }} /> : null}
</Tag>
</Tooltip>
);
},
},
{
title: '分配客服',
dataIndex: 'assigned_username',
width: 130,
render: (_: unknown, record) => {
if (canAssign) {
return (
<Select
size="small"
style={{ width: 110 }}
allowClear
placeholder="未分配"
value={record.assigned_to}
onChange={(value) => handleAssign(record.id, value ?? null)}
options={users.map((user) => ({ value: user.id, label: user.username }))}
/>
);
}
return record.assigned_username ? (
<Tag color="blue" style={{ borderRadius: 10, padding: '1px 8px' }}>
{record.assigned_username}
</Tag>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
未分配
</Text>
);
},
},
{
title: '账号状态',
dataIndex: 'status',
width: 100,
align: 'center',
render: (status: string) => (
<Tag
color={STATUS_COLORS[status] || 'default'}
style={{ borderRadius: 10, padding: '1px 8px', fontSize: 12 }}
>
{STATUS_LABELS[status] || status || '-'}
</Tag>
),
},
{
title: '登录渠道',
dataIndex: 'login_channel',
width: 95,
align: 'center',
render: (channel: string) => {
const info = LOGIN_CHANNEL_LABELS[channel];
return info ? (
<Tag color={info.color} style={{ borderRadius: 10, padding: '1px 8px', fontSize: 12 }}>
{info.label}
</Tag>
) : (
<Tag style={{ borderRadius: 10, padding: '1px 8px', fontSize: 12 }}>仅导入</Tag>
);
},
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 150,
render: (value: string | null) =>
value ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(value)}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
-
</Text>
),
},
{
title: '操作',
width: 160,
fixed: 'right',
align: 'center',
render: (_: unknown, record) => (
<Space size={6}>
{canImport && record.has_password && (
<Tooltip title="一键 App 协议登录 (推荐)">
<Button
size="small"
type="primary"
icon={<MobileOutlined />}
loading={rowLoginKey === `app-${record.id}`}
disabled={Boolean(rowLoginKey) || appPasswordLogging || passwordLogging}
onClick={() => handleRowLogin(record, 'app')}
/>
</Tooltip>
)}
{record.sms_url && (
<Tooltip title="提取该账号最新短信验证码">
<Button
size="small"
icon={<MailOutlined style={{ color: '#13c2c2' }} />}
loading={fetchingSmsId === record.id}
onClick={() => handleFetchSmsCode(record)}
/>
</Tooltip>
)}
{canImport && record.has_password && (
<Tooltip title="Web 旧版登录 (备用)">
<Button
size="small"
icon={<LoginOutlined />}
loading={rowLoginKey === `web-${record.id}`}
disabled={Boolean(rowLoginKey) || appPasswordLogging || passwordLogging}
onClick={() => handleRowLogin(record, 'web')}
/>
</Tooltip>
)}
{canDelete && (
<Popconfirm
title="确认删除该账号?"
description="删除后数据将无法恢复。"
onConfirm={() => handleDelete(record.id)}
>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
{/* 页面头部操作栏 */}
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
}}
>
<Space align="center" size={10}>
<span style={{ fontSize: 20, fontWeight: 700, color: token.colorTextHeading }}>
虎牙账号管理
</span>
<Tag
color="blue"
style={{
borderRadius: 12,
padding: '2px 10px',
fontSize: 12,
fontWeight: 500,
border: 'none',
background: '#e6f4ff',
color: '#0958d9',
}}
>
共 {total} 个账号
</Tag>
</Space>
<Space wrap size={8}>
{canImport && (
<Button
type="primary"
icon={<ImportOutlined />}
onClick={openPasswordImport}
style={{ fontWeight: 500 }}
>
导入账号密码
</Button>
)}
{canImport && (
<Button icon={<KeyOutlined />} onClick={() => setImportOpen(true)}>
导入 Cookie
</Button>
)}
{canImport && (
<Button
icon={<MobileOutlined />}
loading={smsSending || smsLogging}
onClick={openSmsLogin}
>
短信登录
</Button>
)}
<Button
icon={<ReloadOutlined />}
onClick={() => {
loadAccounts();
loadSummary();
}}
loading={loading}
>
刷新
</Button>
</Space>
</div>
{/* KPI 数据卡片栏 (整齐等宽响应式栅格,彻底告别空缺折行) */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: 12,
marginBottom: 16,
}}
>
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '12px 14px',
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
}}
>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>账号总数</span>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
background: '#e6f4ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#1677ff',
fontSize: 12,
}}
>
<UserOutlined />
</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: token.colorTextHeading, lineHeight: 1.2 }}>
{summary.total}
</div>
</div>
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '12px 14px',
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
}}
>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>已存密码</span>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
background: '#fffbe6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#faad14',
fontSize: 12,
}}
>
<KeyOutlined />
</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#d48806', lineHeight: 1.2 }}>
{summary.password_ready_count}
</div>
</div>
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '12px 14px',
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
}}
>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>已查积分</span>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
background: '#f9f0ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#722ed1',
fontSize: 12,
}}
>
<TrophyOutlined />
</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#531dab', lineHeight: 1.2 }}>
{summary.point_count}
</div>
</div>
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '12px 14px',
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
}}
>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>已绑礼盒</span>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
background: '#f0f5ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#2f54eb',
fontSize: 12,
}}
>
<GiftOutlined />
</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#1d39c4', lineHeight: 1.2 }}>
{summary.bound_count}
</div>
</div>
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '12px 14px',
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
}}
>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>标签数</span>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
background: '#e6fffb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#13c2c2',
fontSize: 12,
}}
>
<TagOutlined />
</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#08979c', lineHeight: 1.2 }}>
{summary.tag_count || tags.length}
</div>
</div>
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '12px 14px',
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
}}
>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>已分配</span>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
background: '#f6ffed',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#52c41a',
fontSize: 12,
}}
>
<CheckCircleOutlined />
</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#389e0d', lineHeight: 1.2 }}>
{summary.assigned_count}
</div>
</div>
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '12px 14px',
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
}}
>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>未分配</span>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
background: '#fafafa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#8c8c8c',
fontSize: 12,
}}
>
<ClockCircleOutlined />
</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: token.colorTextSecondary, lineHeight: 1.2 }}>
{summary.unassigned_count}
</div>
</div>
</div>
{/* 搜索过滤与快捷标签面板 */}
<Card
size="small"
style={{
marginBottom: 14,
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.02)',
}}
styles={{ body: { padding: '12px 16px' } }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 12,
}}
>
<Space wrap size={10}>
<Input
placeholder="搜索 UID、昵称、标签、游戏名、手机号..."
allowClear
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setCurrentPage(1);
}}
style={{ width: 300 }}
prefix={<SearchOutlined style={{ color: token.colorTextSecondary }} />}
/>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="按标签筛选"
style={{ width: 160 }}
value={tagFilter || undefined}
onChange={(value) => {
setTagFilter(value || '');
setCurrentPage(1);
}}
options={tags.map((tag) => ({ value: tag, label: tag }))}
prefix={<FilterOutlined style={{ color: token.colorTextSecondary }} />}
/>
</Space>
{tags.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>快捷标签:</span>
<Tag
color={!tagFilter ? 'processing' : 'default'}
style={{
cursor: 'pointer',
borderRadius: 10,
padding: '1px 8px',
fontWeight: !tagFilter ? 600 : 400,
}}
onClick={() => {
setTagFilter('');
setCurrentPage(1);
}}
>
全部
</Tag>
{tags.slice(0, 8).map((tag) => {
const active = tagFilter === tag;
return (
<Tag
key={tag}
color={active ? 'primary' : tagColorMap[tag]}
style={{
cursor: 'pointer',
borderRadius: 10,
padding: '1px 8px',
fontWeight: active ? 600 : 400,
border: active ? undefined : `1px solid ${token.colorBorderSecondary}`,
}}
onClick={() => {
setTagFilter(active ? '' : tag);
setCurrentPage(1);
}}
>
{tag}
</Tag>
);
})}
</div>
)}
</div>
</Card>
{/* 批量操作浮动条 */}
{selectedCount > 0 && (
<Alert
type="info"
showIcon
style={{
marginBottom: 12,
borderRadius: 8,
border: `1px solid ${token.colorPrimaryBorder}`,
background: token.colorPrimaryBg,
}}
message={
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 10,
}}
>
<Space wrap>
<span style={{ fontWeight: 600, color: token.colorPrimaryText }}>
{selectedAllMatching
? `已选中当前筛选下全部 ${total} 个账号`
: `已选中 ${selectedCount} 个账号`}
</span>
{!selectedAllMatching && total > selectedRowKeys.length && (
<Button size="small" type="link" onClick={() => setSelectedAllMatching(true)}>
选择全部 {total} 个
</Button>
)}
<Button size="small" type="link" onClick={clearSelection}>
取消选择
</Button>
</Space>
<Space wrap size={8}>
{canImport && (
<Tooltip title="逐个登录,同时只登一个号;失败 2 次后跳下一个,号间默认间隔 8 秒">
<Button
type="primary"
size="small"
icon={<MobileOutlined />}
loading={appPasswordLogging}
disabled={passwordLogging}
onClick={handleAppLoginSelected}
>
App 协议登录 ({selectedCount})
</Button>
</Tooltip>
)}
{canImport && (
<Tooltip title="逐个登录,同时只登一个号;失败 2 次后跳下一个,号间默认间隔 8 秒">
<Button
size="small"
icon={<LoginOutlined />}
loading={passwordLogging}
disabled={appPasswordLogging}
onClick={handleWebLoginSelected}
>
Web 登录
</Button>
</Tooltip>
)}
{canTag && (
<Button
size="small"
icon={<TagOutlined />}
onClick={() => {
setBatchTagInput('');
setBatchTagVisible(true);
}}
>
批量打标签
</Button>
)}
{canDelete && (
<Popconfirm
title={`确认删除选中的 ${selectedCount} 个账号?`}
description="删除后数据将无法恢复。"
onConfirm={handleDeleteSelected}
>
<Button danger size="small" icon={<DeleteOutlined />}>
批量删除
</Button>
</Popconfirm>
)}
</Space>
</div>
}
/>
)}
{/* 表格容器 */}
<Card
style={{
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
boxShadow: '0 1px 2px 0 rgba(0,0,0,0.02)',
}}
styles={{ body: { padding: 0 } }}
>
<Table
rowSelection={
canTag || canDelete || canAssign
? {
selectedRowKeys,
onChange: (keys) => {
setSelectedRowKeys(keys);
setSelectedAllMatching(false);
},
preserveSelectedRowKeys: true,
}
: undefined
}
columns={columns}
dataSource={accounts}
rowKey="id"
loading={loading}
size="middle"
pagination={{
current: currentPage,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 个账号`,
onChange: (page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem('huya_account_page_size', String(size));
setCurrentPage(1);
}
},
}}
scroll={{ x: 1160 }}
/>
</Card>
<Modal
title="粘贴虎牙 CK"
open={importOpen}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
okText="导入"
confirmLoading={importing}
width={720}
>
<Space orientation="vertical" style={{ width: '100%' }} size={12}>
<Input
placeholder="标签,可选"
value={importTag}
onChange={(e) => setImportTag(e.target.value)}
/>
<TextArea
rows={12}
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="每行一条,支持纯 CK、账号----密码----CK、CK----手机号"
/>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
当前阶段会解析并保存 UID、YYUID、账号名、手机号和 CK,绑定二维码、积分、游戏名等动作在虎牙任务页创建计划任务。
</Paragraph>
</Space>
</Modal>
<Modal
title="批量设置标签"
open={batchTagVisible}
onCancel={() => setBatchTagVisible(false)}
onOk={handleBatchTag}
okText="确定"
width={400}
>
<p>为{selectedAllMatching ? '当前筛选下' : '选中的'} {selectedCount} 个虎牙账号设置标签:</p>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="输入或选择标签"
value={batchTagInput ? [batchTagInput] : []}
onChange={(values) => setBatchTagInput(values.length > 0 ? values[values.length - 1] : '')}
options={tags.map((tag) => ({ value: tag, label: tag }))}
/>
</Modal>
<Modal
title="导入虎牙账号密码"
open={passwordImportOpen}
onCancel={() => {
if (passwordImporting) return;
setPasswordImportOpen(false);
}}
onOk={handleImportPasswordAccounts}
okText="导入"
cancelButtonProps={{ disabled: passwordImporting }}
confirmLoading={passwordImporting}
maskClosable={!passwordImporting}
closable={!passwordImporting}
width={720}
>
<Space orientation="vertical" style={{ width: '100%' }} size={14}>
<Alert
type="info"
showIcon
message="仅支持四段格式导入:账号----密码----手机号----接码链接"
description="手机号与接码链接将同步保存入库。后续 App 密码登录如触发短信二次验证(sms_auth2),系统将自动从接码链接提取验证码完成全自动无人值守过验。"
/>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}>
账号标签 <span style={{ color: '#8c8c8c', fontWeight: 'normal', fontSize: 12 }}>(可选,批量设置账号标签)</span>
</div>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="输入或选择账号标签"
maxCount={1}
value={passwordImportTag}
onChange={setPasswordImportTag}
options={tags.map((tag) => ({ value: tag, label: tag }))}
disabled={passwordImporting}
placement="bottomLeft"
/>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}>
账号数据 <span style={{ color: '#ff4d4f', fontSize: 12 }}>* 仅允许四段格式(账号----密码----手机号----接码链接)</span>
</div>
<TextArea
rows={9}
value={passwordImportText}
onChange={(e) => setPasswordImportText(e.target.value)}
placeholder={`每行一条四段格式:\n账号----密码----手机号----接码链接\n\n例如:\nhy_299112483----aa778899----+14504409332----http://194.163.45.144/api/get_sms?key=c16fe33219924363fa362ed69dc6fd4f`}
disabled={passwordImporting}
/>
</div>
</Space>
</Modal>
<Modal
title="虎牙短信登录"
open={smsLoginOpen}
onCancel={() => {
if (smsSending || smsLogging) return;
setSmsLoginOpen(false);
}}
footer={[
<Button key="cancel" disabled={smsSending || smsLogging} onClick={() => setSmsLoginOpen(false)}>
取消
</Button>,
<Button key="send" icon={<MobileOutlined />} loading={smsSending} disabled={smsLogging} onClick={handleSendSmsCode}>
{smsState ? '重新发码' : '发送短信'}
</Button>,
<Button key="login" type="primary" icon={<LoginOutlined />} loading={smsLogging} disabled={!smsState || smsSending} onClick={handleSmsLogin}>
登录并保存
</Button>,
]}
maskClosable={!(smsSending || smsLogging)}
closable={!(smsSending || smsLogging)}
width={520}
>
<Space orientation="vertical" style={{ width: '100%' }} size={12}>
<Input
value={smsPhone}
onChange={(e) => setSmsPhone(e.target.value)}
placeholder="手机号"
disabled={smsSending || smsLogging || Boolean(smsState)}
prefix={<MobileOutlined />}
/>
<Input
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="短信验证码"
maxLength={8}
disabled={!smsState || smsSending || smsLogging}
onPressEnter={handleSmsLogin}
/>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="可选,保存到账号标签"
maxCount={1}
value={smsTag}
onChange={setSmsTag}
options={tags.map((tag) => ({ value: tag, label: tag }))}
disabled={smsSending || smsLogging}
/>
{smsState ? (
<Text type="secondary">短信已发送,输入验证码后提交登录。</Text>
) : (
<Text type="secondary">发码时会自动处理滑块验证,完成后继续输入短信验证码。</Text>
)}
</Space>
</Modal>
<Modal
title={loginModalTitle}
open={passwordLoginResultOpen}
onCancel={() => {
if (loginQueueBusy) return;
setPasswordLoginResultOpen(false);
}}
footer={[
loginQueueBusy ? (
<Button key="stop" danger onClick={handleStopLoginQueue}>
停止队列
</Button>
) : null,
(loginQueuePhase === 'done' || loginQueuePhase === 'stopped')
&& passwordLoginResults.some((item) => !item.success) ? (
<Button
key="retry"
icon={<ReloadOutlined />}
onClick={() => { void handleRetryFailedLogins(); }}
>
重试失败 ({passwordLoginResults.filter((item) => !item.success).length})
</Button>
) : null,
<Button
key="ok"
type="primary"
disabled={loginQueueBusy}
onClick={() => setPasswordLoginResultOpen(false)}
>
确定
</Button>,
].filter(Boolean)}
maskClosable={!loginQueueBusy}
closable={!loginQueueBusy}
width={860}
>
<Alert
type={loginQueuePhase === 'stopped' ? 'warning' : 'info'}
showIcon
style={{ marginBottom: 12 }}
message={
loginQueuePhase === 'login'
? `正在登录 ${loginQueueIndex}/${loginQueueTotal} · ${loginQueueUsername || '—'}`
: loginQueuePhase === 'wait'
? `等待 ${loginWaitLeft}s 后登录下一个(${loginQueueIndex}/${loginQueueTotal})`
: loginQueuePhase === 'stopped'
? '队列已停止'
: '登录说明'
}
description={
loginQueueBusy
? `默认单线程,同时只登录一个号;失败最多尝试 ${HUYA_LOGIN_MAX_ATTEMPTS} 次再跳下一个。号与号之间间隔 ${loginIntervalSeconds} 秒。`
: '使用 App 协议登录时,系统已自动识别并通过 safe_auth 乱序滑块;若个别新账号提示“要求扫码验证 (qr_auth)”,则需在手机虎牙 App 上正常登录一次以建立设备信任。失败的账号可一键重试。'
}
/>
<Space wrap style={{ marginBottom: 12 }} align="center">
<Text type="secondary">号间间隔(秒)</Text>
<InputNumber
size="small"
min={0}
max={30}
value={loginIntervalSeconds}
disabled={loginQueueBusy}
onChange={(value) => {
const next = Math.min(30, Math.max(0, Number(value ?? HUYA_LOGIN_DEFAULT_INTERVAL)));
setLoginIntervalSeconds(next);
localStorage.setItem(HUYA_LOGIN_INTERVAL_KEY, String(next));
}}
style={{ width: 88 }}
/>
<Text type="secondary">
成功 {passwordLoginResults.filter((item) => item.success).length} · 失败 {passwordLoginResults.filter((item) => !item.success).length}
</Text>
</Space>
{loginQueueBusy && loginQueueTotal > 0 && (
<Progress
percent={Math.round(
((loginQueuePhase === 'login'
? Math.max(0, loginQueueIndex - 1)
: loginQueueIndex) / loginQueueTotal) * 100,
)}
status="active"
style={{ marginBottom: 12 }}
/>
)}
{passwordLoginResults.length > 0 && (
<Table
columns={passwordLoginResultColumns}
dataSource={passwordLoginResults}
rowKey={(record, index) => `${record.line}-${record.username || 'empty'}-${index}`}
size="small"
pagination={false}
scroll={{ x: 720, y: 260 }}
/>
)}
</Modal>
</div>
);
}