Files
live-hub-py/web/frontend/src/pages/HuyaAccountsPage.tsx
T
2026-07-24 12:48:02 +08:00

820 lines
27 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button, Card, Col, Input, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Typography,
} from 'antd';
import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd';
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, MobileOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
import {
huyaApi,
type HuyaAccountItem,
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: '失效',
};
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',
};
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 [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 [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchTagInput, setBatchTagInput] = useState('');
const [batchTagVisible, setBatchTagVisible] = useState(false);
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 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.listAccounts(params);
setAccounts(data);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, [tagFilter]);
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();
if (canAssign) loadUsers();
loadTags();
}, [loadAccounts, canAssign, loadUsers, loadTags]);
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 filteredAccounts = useMemo(() => {
const s = searchText.trim().toLowerCase();
if (!s) return accounts;
return accounts.filter((item) => (
item.uid.toLowerCase().includes(s) ||
item.yyuid.toLowerCase().includes(s) ||
item.username.toLowerCase().includes(s) ||
item.nickname.toLowerCase().includes(s) ||
item.tag.toLowerCase().includes(s) ||
item.game_name.toLowerCase().includes(s) ||
item.game_phone.toLowerCase().includes(s)
));
}, [accounts, searchText]);
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();
} 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();
} 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();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setPasswordImporting(false);
}
};
const handleLoginSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
setPasswordLogging(true);
try {
const result = await huyaApi.passwordLoginSelected({
account_ids: selectedRowKeys.map((key) => Number(key)),
});
setPasswordLoginResults(result.results || []);
setPasswordLoginResultOpen(true);
if (result.failed > 0) {
message.warning(result.message);
} else {
message.success(result.message || '登录成功,Cookie 已保存');
}
loadAccounts();
loadTags();
} 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));
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
try {
const result = await huyaApi.deleteAccounts(selectedRowKeys.map((key) => Number(key)));
message.success(result.message);
setSelectedRowKeys([]);
loadAccounts();
loadTags();
} 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 (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
try {
await huyaApi.batchTag(selectedRowKeys.map((key) => Number(key)), batchTagInput);
message.success(`已为 ${selectedRowKeys.length} 个虎牙账号设置标签`);
setBatchTagVisible(false);
setBatchTagInput('');
setSelectedRowKeys([]);
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const boundCount = accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length;
const pointCount = accounts.filter((item) => item.points !== null && item.points !== undefined).length;
const assignedCount = accounts.filter((item) => item.assigned_to).length;
const passwordReadyCount = accounts.filter((item) => item.has_password).length;
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: 110,
render: (tag: string, record) => {
if (!tag) {
if (canImport) {
return (
<Input
size="small"
placeholder="输入标签"
style={{ width: 90 }}
onPressEnter={(e) => {
const value = (e.target as HTMLInputElement).value.trim();
if (value) handleSetTag(record.id, value);
}}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) handleSetTag(record.id, value);
}}
/>
);
}
return <Text type="secondary">-</Text>;
}
if (canImport) {
return (
<Tag
color={tagColorMap[tag]}
closable
onClose={(e) => {
e.preventDefault();
handleSetTag(record.id, '');
}}
>
{tag}
</Tag>
);
}
return <Tag color={tagColorMap[tag]}>{tag}</Tag>;
},
},
{
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: 'updated_at',
width: 170,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
{
title: '操作',
width: 90,
fixed: 'right',
align: 'center',
render: (_: unknown, record) => (
canDelete ? (
<Popconfirm title="确认删除这条虎牙账号?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
) : null
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}>虎牙账号管理</h2>
<Space wrap>
<Select
allowClear
placeholder="按标签筛选"
style={{ width: 150 }}
value={tagFilter || undefined}
onChange={(value) => setTagFilter(value || '')}
options={tags.map((tag) => ({ value: tag, label: tag }))}
prefix={<FilterOutlined />}
/>
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
刷新
</Button>
{canImport && (
<Button
disabled={selectedRowKeys.length === 0}
icon={<TagOutlined />}
onClick={() => {
setBatchTagInput('');
setBatchTagVisible(true);
}}
>
批量打标签
</Button>
)}
{canImport && (
<Button
disabled={selectedRowKeys.length === 0}
icon={<LoginOutlined />}
loading={passwordLogging}
onClick={handleLoginSelected}
>
登录选中
</Button>
)}
{canDelete && selectedRowKeys.length > 0 && (
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙账号?`} onConfirm={handleDeleteSelected}>
<Button danger icon={<DeleteOutlined />}>
删除选中 ({selectedRowKeys.length})
</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={accounts.length} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="标签数" value={tags.length} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已导入密码" value={passwordReadyCount} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已分配" value={assignedCount} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="未分配" value={accounts.length - assignedCount} /></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)}
style={{ width: 300 }}
prefix={<SearchOutlined />}
/>
{tags.map((tag) => (
<Tag key={tag} color="blue" onClick={() => setSearchText(tag)} style={{ cursor: 'pointer' }}>
{tag}
</Tag>
))}
</div>
<Table
rowSelection={(canImport || canDelete || canAssign) ? {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
} : undefined}
columns={columns}
dataSource={filteredAccounts}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: currentPage,
pageSize,
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 }}>
当前阶段会解析并保存 UIDYYUID、账号名、手机号和 CK,绑定二维码、积分、游戏名等动作在虎牙任务页创建计划任务。
</Paragraph>
</Space>
</Modal>
<Modal
title="批量设置标签"
open={batchTagVisible}
onCancel={() => setBatchTagVisible(false)}
onOk={handleBatchTag}
okText="确定"
width={400}
>
<p>为选中的 {selectedRowKeys.length} 个虎牙账号设置标签:</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="虎牙登录结果"
open={passwordLoginResultOpen}
onCancel={() => setPasswordLoginResultOpen(false)}
footer={[
<Button key="ok" type="primary" onClick={() => setPasswordLoginResultOpen(false)}>
确定
</Button>,
]}
width={860}
>
{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>
);
}