初步增加 web 界面

This commit is contained in:
yml2213
2026-06-22 13:11:15 +08:00
parent 4c924375aa
commit 347edb8103
66 changed files with 6816 additions and 21 deletions
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react';
import {
Table, Button, Modal, Input, Select, message, Popconfirm, Typography,
} from 'antd';
import { ImportOutlined, DeleteOutlined } from '@ant-design/icons';
import { accountApi, userApi } from '../api/modules';
import { getUser, hasPerm } from '../store/auth';
const { TextArea } = Input;
const { Text } = Typography;
export default function AccountsPage() {
const [accounts, setAccounts] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState('');
const [importing, setImporting] = useState(false);
const user = getUser();
const canViewAll = hasPerm(user, 'account:view_all');
const canImport = hasPerm(user, 'account:import');
const canAssign = hasPerm(user, 'account:assign');
const canDelete = hasPerm(user, 'account:delete');
const loadAccounts = async () => {
setLoading(true);
try {
const data = await accountApi.list();
setAccounts(data);
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
const loadUsers = async () => {
try {
const data = await userApi.list();
setUsers(data.filter((u: any) => u.role === 'support'));
} catch {}
};
useEffect(() => {
loadAccounts();
if (canAssign) loadUsers();
}, []);
const handleImport = async () => {
if (!importText.trim()) {
message.warning('请输入账号数据');
return;
}
setImporting(true);
try {
const result = await accountApi.import(importText);
message.success(result.message);
setImportOpen(false);
setImportText('');
loadAccounts();
} catch (e: any) {
message.error(e.message);
} finally {
setImporting(false);
}
};
const handleAssign = async (accountId: number, assignedTo: number | null) => {
try {
await accountApi.assign(accountId, assignedTo);
message.success('已分配');
loadAccounts();
} catch (e: any) {
message.error(e.message);
}
};
const handleDelete = async (id: number) => {
try {
await accountApi.delete(id);
message.success('已删除');
loadAccounts();
} catch (e: any) {
message.error(e.message);
}
};
const columns: any[] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' },
];
if (canViewAll) {
columns.push(
{ title: '密码', dataIndex: 'password', width: 120 },
{ title: '邮箱', dataIndex: 'email' },
{ title: '邮箱密码', dataIndex: 'email_password', width: 120 },
);
}
columns.push({
title: '分配给',
dataIndex: 'assigned_username',
render: (_: any, record: any) => {
if (canAssign) {
return (
<Select
style={{ width: 140 }}
allowClear
placeholder="未分配"
value={record.assigned_to}
onChange={(val) => handleAssign(record.id, val ?? null)}
options={users.map((u: any) => ({ value: u.id, label: u.username }))}
/>
);
}
return record.assigned_username || <Text type="secondary"></Text>;
},
});
if (canDelete) {
columns.push({
title: '操作',
width: 80,
render: (_: any, record: any) => (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
),
});
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
{canImport && (
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
</Button>
)}
</div>
<Table
columns={columns}
dataSource={accounts}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 20 }}
/>
<Modal
title="批量导入账号"
open={importOpen}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
confirmLoading={importing}
okText="导入"
width={600}
>
<Text type="secondary">
|||
</Text>
<TextArea
rows={10}
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="用户名|密码|邮箱|邮箱密码&#10;用户名|密码|邮箱|邮箱密码"
style={{ marginTop: 8 }}
/>
</Modal>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Card, Col, Row, Statistic } from 'antd';
import { useEffect, useState } from 'react';
import { accountApi, loginApi } from '../api/modules';
export default function DashboardPage() {
const [stats, setStats] = useState({ accounts: 0, tasks: 0, success: 0, failed: 0 });
useEffect(() => {
Promise.all([accountApi.list(), loginApi.listTasks()])
.then(([accounts, tasks]) => {
setStats({
accounts: accounts.length,
tasks: tasks.length,
success: tasks.filter((t: any) => t.status === 'success').length,
failed: tasks.filter((t: any) => ['failed', 'error'].includes(t.status)).length,
});
})
.catch(() => {});
}, []);
return (
<div>
<h2></h2>
<Row gutter={16}>
<Col span={6}>
<Card>
<Statistic title="账号总数" value={stats.accounts} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="登录任务总数" value={stats.tasks} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="成功" value={stats.success} valueStyle={{ color: '#3f8600' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="失败" value={stats.failed} valueStyle={{ color: '#cf1322' }} />
</Card>
</Col>
</Row>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { Card, Form, Input, Button, message, Typography } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../api/modules';
import { setAuth, type AuthUser } from '../store/auth';
const { Title } = Typography;
export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const result = await authApi.login(values.username, values.password);
const user: AuthUser = {
id: 0,
username: result.username,
role: result.role,
permissions: result.permissions,
};
setAuth(result.access_token, user);
message.success('登录成功');
onLogin?.(); // 触发 App 重渲染
navigate('/', { replace: true });
} catch (e: any) {
message.error(e.message || '登录失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Card style={{ width: 380, boxShadow: '0 8px 24px rgba(0,0,0,0.15)' }}>
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
</Title>
<Form onFinish={onFinish} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
默认管理员: admin / admin123
</div>
</Card>
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
import { useEffect, useState, useRef } from 'react';
import {
Table, Button, Select, message, Tag, Space, Card, Row, Col, Statistic, Spin,
} from 'antd';
import { PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
import { accountApi, loginApi } from '../api/modules';
import { getUser, hasPerm } from '../store/auth';
const STATUS_COLORS: Record<string, string> = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
};
const STATUS_LABELS: Record<string, string> = {
pending: '等待中',
running: '登录中',
success: '成功',
failed: '失败',
error: '异常',
};
export default function LoginTasksPage() {
const [accounts, setAccounts] = useState<any[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [tasks, setTasks] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [batchId, setBatchId] = useState<string | null>(null);
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const user = getUser();
const canBatch = hasPerm(user, 'login:batch');
const loadAccounts = async () => {
try {
const data = await accountApi.list();
setAccounts(data);
} catch (e: any) {
message.error(e.message);
}
};
const loadTasks = async () => {
try {
const data = await loginApi.listTasks(batchId || undefined);
setTasks(data);
} catch {}
};
useEffect(() => {
loadAccounts();
}, []);
useEffect(() => {
const timer = setInterval(loadTasks, 3000);
return () => clearInterval(timer);
}, [batchId]);
const handleBatchLogin = async () => {
if (selectedIds.length === 0) {
message.warning('请选择账号');
return;
}
setLoading(true);
setLogs([]);
try {
const result = await loginApi.createBatch(selectedIds);
setBatchId(result.batch_id);
message.success(`已创建登录任务,共 ${result.count} 个账号`);
// 连接 WebSocket
const wsUrl = `ws://${window.location.hostname}:8000/api/login/ws/login/${result.batch_id}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.level === 'heartbeat') return;
setLogs((prev) => [...prev, msg]);
};
ws.onclose = () => {
wsRef.current = null;
};
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
const handleStop = async () => {
if (batchId) {
try {
await loginApi.stop(batchId);
message.success('已发送停止信号');
} catch (e: any) {
message.error(e.message);
}
}
};
const successCount = tasks.filter((t) => t.status === 'success').length;
const failedCount = tasks.filter((t) => ['failed', 'error'].includes(t.status)).length;
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '账号', dataIndex: 'account_username' },
{
title: '状态',
dataIndex: 'status',
render: (status: string) => <Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status] || status}</Tag>,
},
{ title: '消息', dataIndex: 'message', ellipsis: true },
{ title: '时间', dataIndex: 'created_at', width: 180 },
];
return (
<div>
<h2></h2>
{canBatch && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space>
<Select
mode="multiple"
style={{ minWidth: 400 }}
placeholder="选择要登录的账号"
value={selectedIds}
onChange={setSelectedIds}
options={accounts.map((a) => ({ value: a.id, label: a.username }))}
maxTagCount="responsive"
/>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={loading}
onClick={handleBatchLogin}
>
</Button>
{batchId && (
<Button danger icon={<StopOutlined />} onClick={handleStop}>
</Button>
)}
</Space>
</Card>
)}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card size="small"><Statistic title="任务总数" value={tasks.length} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="成功" value={successCount} valueStyle={{ color: '#3f8600' }} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="失败" value={failedCount} valueStyle={{ color: '#cf1322' }} /></Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="当前批次" value={batchId || '-'} /></Card>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Card title="任务列表" size="small">
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
pagination={{ pageSize: 15 }}
/>
</Card>
</Col>
<Col span={10}>
<Card
title="实时日志"
size="small"
bodyStyle={{ maxHeight: 500, overflow: 'auto', fontFamily: 'monospace', fontSize: 12 }}
>
{logs.length === 0 ? (
<Spin spinning={!!batchId} size="small" />
) : (
logs.map((log, i) => (
<div
key={i}
style={{
color:
log.level === 'error' ? '#ff0000' :
log.level === 'success' ? '#008000' :
log.level === 'warning' ? '#FF8C00' :
'#333',
}}
>
{log.message}
</div>
))
)}
</Card>
</Col>
</Row>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Form, Input, Switch, Button, Card, message, Divider, Space } from 'antd';
import { proxyApi } from '../api/modules';
export default function ProxyPage() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testingWl, setTestingWl] = useState(false);
const loadConfig = async () => {
try {
const data = await proxyApi.get();
form.setFieldsValue(data);
} catch (e: any) {
message.error(e.message);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
setLoading(true);
try {
const values = await form.validateFields();
await proxyApi.update(values);
message.success('已保存');
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
const handleTestProxy = async () => {
setTesting(true);
try {
const result = await proxyApi.test();
if (result.success) {
message.success(result.message);
} else {
message.warning(result.message);
}
} catch (e: any) {
message.error(e.message);
} finally {
setTesting(false);
}
};
const handleTestWhitelist = async () => {
setTestingWl(true);
try {
const result = await proxyApi.testWhitelist();
if (result.success) {
message.success(result.message);
} else {
message.warning(result.message);
}
} catch (e: any) {
message.error(e.message);
} finally {
setTestingWl(false);
}
};
return (
<div>
<h2></h2>
<Form form={form} layout="vertical" style={{ maxWidth: 600 }}>
<Card title="代理设置" size="small" style={{ marginBottom: 16 }}>
<Form.Item name="enabled" label="启用代理" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="api_url" label="代理API地址">
<Input placeholder="http://op.xiequ.cn/...?act=get" />
</Form.Item>
<Form.Item name="http" label="静态HTTP代理">
<Input placeholder="http://ip:port" />
</Form.Item>
<Form.Item name="https" label="静态HTTPS代理">
<Input placeholder="http://ip:port" />
</Form.Item>
<Button onClick={handleTestProxy} loading={testing}></Button>
</Card>
<Card title="白名单管理" size="small">
<Form.Item name="whitelist_enabled" label="启用白名单自动管理" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="whitelist_uid" label="协固UID">
<Input placeholder="如: 99769" />
</Form.Item>
<Form.Item name="whitelist_ukey" label="协固UKEY">
<Input placeholder="如: C99371082B965B70F46DCAA87A04618B" />
</Form.Item>
<Button onClick={handleTestWhitelist} loading={testingWl}></Button>
</Card>
<Divider />
<Space>
<Button type="primary" onClick={handleSave} loading={loading}></Button>
</Space>
</Form>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import {
Table, Button, Modal, Form, Input, Select, Tag, Popconfirm, message, Space,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { userApi, type UserInfo } from '../api/modules';
const ROLE_OPTIONS = [
{ value: 'super_admin', label: '超级管理员' },
{ value: 'operation', label: '运营' },
{ value: 'support', label: '客服' },
];
const ROLE_COLORS: Record<string, string> = {
super_admin: 'red',
operation: 'blue',
support: 'green',
};
export default function UsersPage() {
const [users, setUsers] = useState<UserInfo[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<UserInfo | null>(null);
const [form] = Form.useForm();
const loadUsers = async () => {
setLoading(true);
try {
const data = await userApi.list();
setUsers(data);
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const handleCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ role: 'support' });
setModalOpen(true);
};
const handleEdit = (user: UserInfo) => {
setEditing(user);
form.setFieldsValue({
username: user.username,
role: user.role,
remark: user.remark,
});
setModalOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
if (editing) {
const updateData: any = { role: values.role, remark: values.remark };
if (values.password) updateData.password = values.password;
await userApi.update(editing.id, updateData);
message.success('已更新');
} else {
await userApi.create(values);
message.success('已创建');
}
setModalOpen(false);
loadUsers();
} catch (e: any) {
message.error(e.message);
}
};
const handleDelete = async (id: number) => {
try {
await userApi.delete(id);
message.success('已删除');
loadUsers();
} catch (e: any) {
message.error(e.message);
}
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' },
{
title: '角色',
dataIndex: 'role',
render: (role: string) => {
const label = ROLE_OPTIONS.find((r) => r.value === role)?.label || role;
return <Tag color={ROLE_COLORS[role] || 'default'}>{label}</Tag>;
},
},
{
title: '状态',
dataIndex: 'is_active',
render: (active: boolean) => active ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{ title: '备注', dataIndex: 'remark' },
{
title: '操作',
width: 160,
render: (_: any, record: UserInfo) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
{record.role !== 'super_admin' && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 20 }}
/>
<Modal
title={editing ? '编辑用户' : '新建用户'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
okText="保存"
>
<Form form={form} layout="vertical">
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item
name="password"
label={editing ? '新密码(留空不修改)' : '密码'}
rules={editing ? [] : [{ required: true, message: '请输入密码' }]}
>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
}