Files
live-hub-py/web/frontend/src/pages/HuyaAccountsPage.tsx
T
yml2213 230d6c1918 虎牙人工兑换改用本机浏览器并新增扩展自动注入登录态
移除服务器浏览器方案(容器/Xvfb/noVNC/CDP 会话管理),后端仅下发
兑换材料;新增内置 Chrome/Edge 扩展一键写入 huya.com 登录态,
未安装扩展时回退控制台脚本手动注入,兑换始终由用户手动完成。
2026-09-05 17:55:31 +08:00

1123 lines
38 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, useState } from 'react';
import {
Alert, Button, Card, Col, Divider, Input, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Tooltip, Typography,
} from 'antd';
import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd';
import { CopyOutlined, DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, MobileOutlined, ReloadOutlined, SearchOutlined, TagOutlined, GlobalOutlined } from '@ant-design/icons';
import {
huyaApi,
type HuyaAccountItem,
type HuyaAccountSummary,
type HuyaBrowserExchangeKit,
type HuyaPasswordLoginBatchItem,
type SupportUserItem,
} from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
import { copyTextToClipboard } from '../utils/clipboard';
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: '失效',
};
// 登录渠道展示 (对应后端 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',
};
/** 解析后端 Cookie 字符串为 [name, value] 数组,供兑换扩展注入 */
function parseCookieString(cookieString: string): [string, string][] {
return cookieString
.split(';')
.map((part) => part.trim())
.filter((part) => part.includes('='))
.map((part) => {
const idx = part.indexOf('=');
return [part.slice(0, idx), part.slice(idx + 1)];
});
}
/** 内置扩展的内容脚本会在所有页面打上此标记 */
function huyaExchangeHelperReady(): boolean {
return document.documentElement.dataset.huyaExchangeHelper === '1';
}
/** 请求内置扩展自动注入登录态;同时留存到 window 供扩展图标点击兜底读取 */
function requestExtensionInject(kit: HuyaBrowserExchangeKit): void {
const payload = JSON.stringify({ target_url: kit.target_url, cookies: parseCookieString(kit.cookie_string) });
(window as unknown as { __huyaExchangeKit?: string }).__huyaExchangeKit = payload;
window.dispatchEvent(new CustomEvent('huya-exchange-kit', { detail: payload }));
}
export default function HuyaAccountsPage() {
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 [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 [exchangeKit, setExchangeKit] = useState<HuyaBrowserExchangeKit | null>(null);
const [exchangeTitle, setExchangeTitle] = useState('');
const [exchangeLoading, setExchangeLoading] = 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 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(() => {
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 () => {
if (!passwordImportText.trim()) {
message.warning('请先粘贴虎牙账号密码');
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 handleAppLoginSelected = async () => {
if (selectedCount === 0) {
message.warning('请先选择虎牙账号');
return;
}
setAppPasswordLogging(true);
try {
const result = await huyaApi.appPasswordLoginSelected({
...selectionPayload,
});
setPasswordLoginResults(result.results || []);
setLoginModalTitle('虎牙 App 协议登录结果 (推荐)');
setPasswordLoginResultOpen(true);
if (result.failed > 0) {
message.warning(result.message);
} else {
message.success(result.message || 'App 登录成功,Cookie 已保存');
}
loadAccounts();
loadTags();
loadSummary();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAppPasswordLogging(false);
}
};
// 行内单账号登录: 复用批量端点, 只传该行 id (需已导入密码)
const [rowLoginKey, setRowLoginKey] = useState('');
const handleRowLogin = async (record: HuyaAccountItem, channel: 'app' | 'web') => {
setRowLoginKey(`${channel}-${record.id}`);
try {
const payload = { account_ids: [record.id], all_matching: false, search: '', tag: '' };
const result = channel === 'app'
? await huyaApi.appPasswordLoginSelected(payload)
: await huyaApi.passwordLoginSelected(payload);
setPasswordLoginResults(result.results || []);
setLoginModalTitle(channel === 'app' ? 'App 协议登录结果' : 'Web 旧版登录结果');
setPasswordLoginResultOpen(true);
const ok = (result.results || []).some((r) => r.success);
if (ok) { message.success('登录成功,Cookie 已保存'); } else { message.warning(result.message || '登录失败'); }
loadAccounts();
loadSummary();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setRowLoginKey('');
}
};
const handleBrowserExchange = async (record: HuyaAccountItem) => {
setExchangeLoading(true);
try {
const kit = await huyaApi.createBrowserExchange(record.id);
requestExtensionInject(kit);
setExchangeKit(kit);
setExchangeTitle(record.nickname || record.username || record.yyuid || `账号#${record.id}`);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setExchangeLoading(false);
}
};
const openExchangePage = () => {
if (!exchangeKit) return;
window.open(exchangeKit.target_url, '_blank', 'noopener,noreferrer');
};
const copyExchangeText = async (text: string, tip: string) => {
try {
await copyTextToClipboard(text);
message.success(tip);
} catch {
message.error('复制失败,请重试');
}
};
const handleWebLoginSelected = async () => {
if (selectedCount === 0) {
message.warning('请先选择虎牙账号');
return;
}
setPasswordLogging(true);
try {
const result = await huyaApi.passwordLoginSelected({
...selectionPayload,
});
setPasswordLoginResults(result.results || []);
setLoginModalTitle('虎牙 Web 密码登录结果 (旧版)');
setPasswordLoginResultOpen(true);
if (result.failed > 0) {
message.warning(result.message);
} else {
message.success(result.message || 'Web 登录成功,Cookie 已保存');
}
loadAccounts();
loadTags();
loadSummary();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setPasswordLogging(false);
}
};
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: '保存账号',
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: 70, align: 'center' },
{
title: '虎牙账号',
width: 180,
render: (_: unknown, record) => (
<Space orientation="vertical" size={0}>
<Text strong>{record.nickname || record.username || record.uid || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {record.uid || record.yyuid || '-'}
</Text>
{record.has_password ? <Tag color="gold">已导入密码</Tag> : null}
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 170,
render: (tag: string, record) => {
if (canTag) {
return (
<Select
mode="tags"
maxCount={1}
allowClear
showSearch
optionFilterProp="label"
size="small"
placeholder="选择或输入标签"
style={{ width: 150 }}
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]}>{tag}</Tag> : <Text type="secondary">-</Text>;
},
},
{
title: '积分',
dataIndex: 'points',
width: 90,
align: 'center',
render: (points: number | null) => points ?? <Text type="secondary">未查</Text>,
},
{
title: '游戏名',
dataIndex: 'game_name',
width: 160,
ellipsis: true,
render: (value: string, record) => value ? (
<Space orientation="vertical" size={0}>
<Text>{value}</Text>
{record.game_channel ? (
<Text type="secondary" style={{ fontSize: 12 }}>{record.game_channel}</Text>
) : null}
</Space>
) : <Text type="secondary">未查</Text>,
},
{
title: '手机号',
dataIndex: 'game_phone',
width: 140,
ellipsis: true,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: 'Cookie',
dataIndex: 'cookie_preview',
ellipsis: true,
render: (value: string) => (
<Text code style={{ fontSize: 12 }}>
{canViewCookie ? (value || '-') : '***'}
</Text>
),
},
{
title: '分配给',
dataIndex: 'assigned_username',
width: 150,
render: (_: unknown, record) => {
if (canAssign) {
return (
<Select
style={{ width: 130 }}
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 || <Text type="secondary">未分配</Text>;
},
},
{
title: '状态',
dataIndex: 'status',
width: 100,
align: 'center',
render: (status: string) => (
<Tag color={STATUS_COLORS[status] || 'default'}>{STATUS_LABELS[status] || status || '-'}</Tag>
),
},
{
title: '登录渠道',
dataIndex: 'login_channel',
width: 100,
align: 'center',
render: (channel: string) => {
const info = LOGIN_CHANNEL_LABELS[channel];
return info
? <Tag color={info.color}>{info.label}</Tag>
: <Tag>仅导入</Tag>;
},
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
{
title: '操作',
width: 150,
fixed: 'right',
align: 'center',
render: (_: unknown, record) => (
<Space size={4}>
{canImport && record.has_password && (
<>
<Tooltip title="App 协议登录此账号 (推荐)">
<Button
size="small"
type="primary"
icon={<MobileOutlined />}
loading={rowLoginKey === `app-${record.id}`}
onClick={() => handleRowLogin(record, 'app')}
/>
</Tooltip>
<Tooltip title="Web 旧版协议登录此账号">
<Button
size="small"
icon={<LoginOutlined />}
loading={rowLoginKey === `web-${record.id}`}
onClick={() => handleRowLogin(record, 'web')}
/>
</Tooltip>
</>
)}
{canImport && record.has_cookie && (
<Tooltip title="在浏览器中打开兑换页面(用你自己的 Chrome/Edge 完成)">
<Button size="small" icon={<GlobalOutlined />} loading={exchangeLoading} onClick={() => void handleBrowserExchange(record)} />
</Tooltip>
)}
{canDelete && (
<Popconfirm title="确认删除这条虎牙账号?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
const exchangeHelperReady = huyaExchangeHelperReady();
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}>虎牙账号管理</h2>
<Space wrap>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="按标签筛选"
style={{ width: 150 }}
value={tagFilter || undefined}
onChange={(value) => {
setTagFilter(value || '');
setCurrentPage(1);
}}
options={tags.map((tag) => ({ value: tag, label: tag }))}
prefix={<FilterOutlined />}
/>
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
刷新
</Button>
{canTag && (
<Button
disabled={selectedCount === 0}
icon={<TagOutlined />}
onClick={() => {
setBatchTagInput('');
setBatchTagVisible(true);
}}
>
批量打标签
</Button>
)}
{canImport && <Divider type="vertical" />}
{canImport && (
<Tooltip title="App 协议登录: 自动注册设备环境并过滑块, 推荐; 登录渠道将记为 App">
<Button
type="primary"
disabled={selectedCount === 0}
icon={<MobileOutlined />}
loading={appPasswordLogging}
onClick={handleAppLoginSelected}
>
App 协议登录 ({selectedCount})
</Button>
</Tooltip>
)}
{canImport && (
<Tooltip title="旧版 Web 协议登录, 仅作兜底; 新账号请优先使用 App 协议登录">
<Button
disabled={selectedCount === 0}
icon={<LoginOutlined />}
loading={passwordLogging}
onClick={handleWebLoginSelected}
>
Web 登录 (旧版)
</Button>
</Tooltip>
)}
{canImport && <Divider type="vertical" />}
{canDelete && selectedCount > 0 && (
<Popconfirm title={`确认删除${selectedAllMatching ? '当前筛选下' : '选中的'} ${selectedCount} 条虎牙账号?`} onConfirm={handleDeleteSelected}>
<Button danger icon={<DeleteOutlined />}>
删除选中 ({selectedCount})
</Button>
</Popconfirm>
)}
{canImport && (
<Button
icon={<MobileOutlined />}
loading={smsSending || smsLogging}
onClick={openSmsLogin}
>
短信登录
</Button>
)}
{canImport && (
<Button icon={<ImportOutlined />} onClick={openPasswordImport}>
导入账号密码
</Button>
)}
{canImport && (
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
粘贴 CK
</Button>
)}
</Space>
</div>
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="账号总数" value={summary.total} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="标签数" value={summary.tag_count || tags.length} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已导入密码" value={summary.password_ready_count} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已查积分" value={summary.point_count} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已绑定信息" value={summary.bound_count} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已分配" value={summary.assigned_count} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="未分配" value={summary.unassigned_count} /></Card>
</Col>
</Row>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Input.Search
placeholder="搜索 UID、昵称、标签、游戏名、手机号"
allowClear
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
setCurrentPage(1);
}}
style={{ width: 300 }}
prefix={<SearchOutlined />}
/>
{tags.map((tag) => (
<Tag
key={tag}
color="blue"
onClick={() => {
setSearchText(tag);
setCurrentPage(1);
}}
style={{ cursor: 'pointer' }}
>
{tag}
</Tag>
))}
</div>
<Table
title={() => selectedRowKeys.length > 0 ? (
<Alert
type="info"
showIcon
message={(
<Space wrap>
<span>
{selectedAllMatching
? `已选择当前筛选下全部 ${total} 个虎牙账号`
: `已选择 ${selectedRowKeys.length} 个虎牙账号`}
</span>
{!selectedAllMatching && total > selectedRowKeys.length ? (
<Button size="small" type="link" onClick={() => setSelectedAllMatching(true)}>
选择当前筛选下全部 {total} 个
</Button>
) : null}
<Button size="small" type="link" onClick={clearSelection}>
取消选择
</Button>
</Space>
)}
/>
) : undefined}
rowSelection={(canTag || canDelete || canAssign) ? {
selectedRowKeys,
onChange: (keys) => {
setSelectedRowKeys(keys);
setSelectedAllMatching(false);
},
preserveSelectedRowKeys: true,
} : undefined}
columns={columns}
dataSource={accounts}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: currentPage,
pageSize,
total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
onChange: (page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem('huya_account_page_size', String(size));
setCurrentPage(1);
}
},
}}
scroll={{ x: 1120 }}
/>
<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={`浏览器兑换 · ${exchangeTitle}`}
open={exchangeKit !== null}
width={720}
centered
onCancel={() => setExchangeKit(null)}
footer={exchangeKit ? [
<Button key="open" type="primary" icon={<GlobalOutlined />} onClick={openExchangePage}>打开兑换页面</Button>,
<Button key="done" onClick={() => { setExchangeKit(null); message.success('兑换完成后可在精英宝典任务中刷新积分'); }}>完成</Button>,
] : null}
>
{exchangeKit && (
<Space orientation="vertical" style={{ width: '100%' }} size={12}>
{exchangeHelperReady ? (
<Alert
type="success"
showIcon
message="已检测到「虎牙兑换助手」扩展"
description="点击兑换按钮时已自动写入该账号登录态并在新标签页打开兑换页面。请确认页面右上角账号无误后手动完成兑换;若页面未自动打开,按下方手动步骤操作。"
/>
) : (
<Alert
type="warning"
showIcon
message="未检测到「虎牙兑换助手」扩展"
description="安装后可一键自动写入登录态(见 docs/HUYA_BROWSER_EXCHANGE.md,开发者模式加载 web/extension/huya-exchange-helper 并刷新本页)。当前请按下方手动步骤操作。"
/>
)}
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 1:</Text>
点击下方「打开兑换页面」,在新标签页打开兑换活动页。
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 2:</Text>
点击「复制登录脚本」,在兑换页面按 <Text code>F12</Text> 打开控制台,粘贴脚本并回车执行。
Chrome/Edge 首次粘贴需先手动输入 <Text code>allow pasting</Text> 再回车。脚本会写入该账号的登录态并自动跳转。
</Paragraph>
<Space wrap>
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeText(exchangeKit.cookie_script, '登录脚本已复制')}>复制登录脚本</Button>
<Button icon={<CopyOutlined />} onClick={() => void copyExchangeText(exchangeKit.cookie_string, 'Cookie 字符串已复制')}>复制 Cookie 字符串</Button>
</Space>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
备选:安装 Cookie-editor 类扩展后,在 .huya.com 域下导入 Cookie 字符串。脚本和 Cookie 等同账号凭证,请勿泄露给他人。
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>手动步骤 3:</Text>
确认页面右上角登录态为目标账号后完成兑换,回到此处点击「完成」。
</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={12}>
<TextArea
rows={10}
value={passwordImportText}
onChange={(e) => setPasswordImportText(e.target.value)}
placeholder="每行一条:账号----密码;也支持:虎牙号----密码----手机号----验证码链接"
disabled={passwordImporting}
/>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="可选,保存到账号标签"
maxCount={1}
value={passwordImportTag}
onChange={setPasswordImportTag}
options={tags.map((tag) => ({ value: tag, label: tag }))}
disabled={passwordImporting}
/>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
四段格式会保存手机号;验证码链接用于接码池兼容,不会写入账号表。
</Paragraph>
</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={() => setPasswordLoginResultOpen(false)}
footer={[
<Button key="ok" type="primary" onClick={() => setPasswordLoginResultOpen(false)}>
确定
</Button>,
]}
width={860}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="登录说明"
description="使用 App 协议登录时,系统已自动识别并通过 safe_auth 乱序滑块;若个别新账号提示“要求扫码验证 (qr_auth)”,则需在手机虎牙 App 上正常登录一次以建立设备信任。"
/>
{passwordLoginResults.length > 0 && (
<Table
columns={passwordLoginResultColumns}
dataSource={passwordLoginResults}
rowKey={(record) => `${record.line}-${record.username || 'empty'}`}
size="small"
pagination={false}
scroll={{ x: 720, y: 260 }}
/>
)}
</Modal>
</div>
);
}