416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|