Files
live-hub-py/web/frontend/src/pages/HuyaAccountsPage.tsx
T
2026-07-05 11:12:36 +08:00

416 lines
13 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 {
Button, Card, Col, Form, Input, message, Modal, Popconfirm, Row, Space, Statistic, Table, Tag, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem } 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: '已更新',
login_success: '登录成功',
active: '正常',
invalid: '失效',
};
const STATUS_COLORS: Record<string, string> = {
imported: 'blue',
updated: 'cyan',
login_success: 'success',
active: 'success',
invalid: 'error',
};
interface PasswordLoginFormValues {
username: string;
password: string;
tag?: string;
cookie?: string;
}
export default function HuyaAccountsPage() {
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [loading, setLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState('');
const [importTag, setImportTag] = useState('');
const [importing, setImporting] = useState(false);
const [passwordLoginOpen, setPasswordLoginOpen] = useState(false);
const [passwordLogging, setPasswordLogging] = useState(false);
const [searchText, setSearchText] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [pageSize, setPageSize] = useState(() => {
const v = localStorage.getItem('huya_account_page_size');
return v ? Number(v) || 20 : 20;
});
const [currentPage, setCurrentPage] = useState(1);
const [passwordLoginForm] = Form.useForm<PasswordLoginFormValues>();
const { can } = usePermissions();
const canManage = can('huya:account');
const loadAccounts = useCallback(async () => {
setLoading(true);
try {
const data = await huyaApi.listAccounts();
setAccounts(data);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
const tags = useMemo(() => {
return [...new Set(accounts.map((item) => item.tag.trim()).filter(Boolean))].sort();
}, [accounts]);
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();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setImporting(false);
}
};
const openPasswordLogin = () => {
passwordLoginForm.resetFields();
setPasswordLoginOpen(true);
};
const closePasswordLogin = () => {
if (passwordLogging) return;
setPasswordLoginOpen(false);
passwordLoginForm.resetFields();
};
const handlePasswordLogin = async (values: PasswordLoginFormValues) => {
setPasswordLogging(true);
try {
const result = await huyaApi.passwordLogin({
username: values.username.trim(),
password: values.password,
tag: values.tag?.trim() || '',
cookie: values.cookie?.trim() || '',
});
message.success(result.message || '登录成功,Cookie 已保存');
setPasswordLoginOpen(false);
passwordLoginForm.resetFields();
loadAccounts();
} 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();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙 CK');
return;
}
try {
const result = await huyaApi.deleteAccounts(selectedRowKeys.map((key) => Number(key)));
message.success(result.message);
setSelectedRowKeys([]);
loadAccounts();
} 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 columns: TableProps<HuyaAccountItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
{
title: '虎牙账号',
width: 180,
render: (_: unknown, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.nickname || record.username || record.uid || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {record.uid || record.yyuid || '-'}
</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 110,
render: (tag: string) => tag ? <Tag color="blue">{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 direction="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 }}>
{value || '-'}
</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) => (
<Popconfirm title="确认删除这条虎牙 CK?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}>虎牙 CK 管理</h2>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
刷新
</Button>
{selectedRowKeys.length > 0 && (
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙 CK?`} onConfirm={handleDeleteSelected}>
<Button danger icon={<DeleteOutlined />}>
删除选中 ({selectedRowKeys.length})
</Button>
</Popconfirm>
)}
{canManage && (
<Button icon={<LoginOutlined />} onClick={openPasswordLogin}>
密码登录
</Button>
)}
{canManage && (
<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="CK 总数" value={accounts.length} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></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={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
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 direction="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={passwordLoginOpen}
onCancel={closePasswordLogin}
onOk={() => passwordLoginForm.submit()}
okText="登录并保存"
cancelButtonProps={{ disabled: passwordLogging }}
confirmLoading={passwordLogging}
maskClosable={!passwordLogging}
closable={!passwordLogging}
width={560}
>
<Form
form={passwordLoginForm}
layout="vertical"
requiredMark={false}
onFinish={handlePasswordLogin}
disabled={passwordLogging}
>
<Form.Item
name="username"
label="账号"
rules={[{ required: true, message: '请输入虎牙账号' }]}
>
<Input autoComplete="username" placeholder="手机号、邮箱或虎牙账号" />
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password autoComplete="current-password" placeholder="虎牙登录密码" />
</Form.Item>
<Form.Item name="tag" label="标签">
<Input placeholder="可选,保存到账号标签" />
</Form.Item>
<Form.Item name="cookie" label="预置 Cookie">
<TextArea
rows={3}
placeholder="可选;若已有 hdid/sdid 等风控 Cookie,可粘贴在这里"
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}