1991 lines
63 KiB
TypeScript
1991 lines
63 KiB
TypeScript
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>
|
||
);
|
||
}
|