初步增加 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
+42
View File
@@ -0,0 +1,42 @@
import { useState, useCallback } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import LoginPage from './pages/LoginPage';
import MainLayout from './layouts/MainLayout';
import DashboardPage from './pages/DashboardPage';
import AccountsPage from './pages/AccountsPage';
import LoginTasksPage from './pages/LoginTasksPage';
import ProxyPage from './pages/ProxyPage';
import UsersPage from './pages/UsersPage';
import { getToken } from './store/auth';
function App() {
// 用 state 驱动重渲染,登录/登出时调 refreshAuth()
const [authVersion, setAuthVersion] = useState(0);
const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []);
const isLoggedIn = !!getToken();
return (
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage onLogin={refreshAuth} />} />
<Route
path="/"
element={isLoggedIn ? <MainLayout key={authVersion} onLogout={refreshAuth} /> : <Navigate to="/login" replace />}
>
<Route index element={<DashboardPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="login-tasks" element={<LoginTasksPage />} />
<Route path="proxy" element={<ProxyPage />} />
<Route path="users" element={<UsersPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
);
}
export default App;
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 30000,
});
// 请求拦截:携带 token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截:统一错误处理
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
const msg = error.response?.data?.detail || error.message || '请求失败';
return Promise.reject(new Error(msg));
}
);
export default api;
+60
View File
@@ -0,0 +1,60 @@
import api from './index';
export interface LoginResult {
access_token: string;
token_type: string;
role: string;
username: string;
permissions: string[];
}
export interface UserInfo {
id: number;
username: string;
role: string;
is_active: boolean;
remark: string;
permissions: string[];
}
export const authApi = {
login: (username: string, password: string) =>
api.post<any, LoginResult>('/auth/login', { username, password }),
me: () => api.get<any, any>('/auth/me'),
logout: () => api.post<any, any>('/auth/logout'),
};
export const userApi = {
list: () => api.get<any, UserInfo[]>('/users'),
create: (data: { username: string; password: string; role: string; remark?: string }) =>
api.post<any, UserInfo>('/users', data),
update: (id: number, data: { password?: string; role?: string; is_active?: boolean; remark?: string }) =>
api.put<any, UserInfo>(`/users/${id}`, data),
delete: (id: number) => api.delete<any, any>(`/users/${id}`),
};
export const accountApi = {
list: (assigned_only?: boolean) =>
api.get<any, any[]>('/accounts', { params: assigned_only ? { assigned_only: true } : {} }),
import: (text: string) => api.post<any, any>('/accounts/import', { text }),
assign: (id: number, assigned_to: number | null) =>
api.put<any, any>(`/accounts/${id}/assign`, { assigned_to }),
delete: (id: number) => api.delete<any, any>(`/accounts/${id}`),
};
export const loginApi = {
createBatch: (account_ids: number[], max_geetest_retries?: number) =>
api.post<any, any>('/login/batch', { account_ids, max_geetest_retries }),
listTasks: (batch_id?: string) =>
api.get<any, any[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
stop: (batch_id: string) => api.post<any, any>(`/login/stop/${batch_id}`),
};
export const proxyApi = {
get: () => api.get<any, any>('/proxy'),
update: (data: any) => api.put<any, any>('/proxy', data),
test: () => api.post<any, any>('/proxy/test'),
testWhitelist: () => api.post<any, any>('/proxy/whitelist/test'),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+9
View File
@@ -0,0 +1,9 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react';
import { Layout, Menu, Dropdown, Avatar, Space, Typography } from 'antd';
import {
DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth';
import { authApi } from '../api/modules';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const ROLE_LABELS: Record<string, string> = {
super_admin: '超级管理员',
operation: '运营',
support: '客服',
};
export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
const navigate = useNavigate();
const location = useLocation();
const [user] = useState<AuthUser | null>(getUser());
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
if (!user) navigate('/login');
}, [user]);
if (!user) return null;
const menuItems: any[] = [];
// Dashboard - 所有人可见
menuItems.push({ key: '/', label: '概览', icon: <DashboardOutlined /> });
// 账号管理
if (hasPerm(user, 'account:view_all') || hasPerm(user, 'account:view_assigned')) {
menuItems.push({ key: '/accounts', label: '账号管理', icon: <UserOutlined /> });
}
// 登录任务
if (hasPerm(user, 'login:batch') || hasPerm(user, 'login:view_assigned')) {
menuItems.push({ key: '/login-tasks', label: '登录任务', icon: <ApiOutlined /> });
}
// 代理配置
if (hasPerm(user, 'proxy:manage')) {
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
}
// 用户管理
if (hasPerm(user, 'user:view')) {
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
}
const handleLogout = async () => {
try {
await authApi.logout();
} catch {}
clearAuth();
onLogout?.(); // 触发 App 重渲染
navigate('/login', { replace: true });
};
const userMenu = {
items: [
{
key: 'logout',
label: '退出登录',
icon: <LogoutOutlined />,
onClick: handleLogout,
},
],
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider collapsible collapsed={collapsed} onCollapse={setCollapsed}>
<div style={{
height: 48, margin: 12, color: '#fff', fontSize: 16,
textAlign: 'center', lineHeight: '48px', fontWeight: 'bold',
whiteSpace: 'nowrap', overflow: 'hidden',
}}>
{collapsed ? '鱼' : '斗鱼登录后台'}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header style={{
background: '#fff', padding: '0 24px',
display: 'flex', justifyContent: 'flex-end', alignItems: 'center',
}}>
<Dropdown menu={userMenu} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} />
<Text>{user.username}</Text>
<Text type="secondary">({ROLE_LABELS[user.role] || user.role})</Text>
</Space>
</Dropdown>
</Header>
<Content style={{ margin: 16, padding: 24, background: '#fff', borderRadius: 8 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+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>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { authApi } from '../api/modules';
export interface AuthUser {
id: number;
username: string;
role: string;
role_label?: string;
permissions: string[];
}
const TOKEN_KEY = 'token';
const USER_KEY = 'user';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getUser(): AuthUser | null {
const raw = localStorage.getItem(USER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export function setAuth(token: string, user: AuthUser) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export async function fetchCurrentUser(): Promise<AuthUser | null> {
const token = getToken();
if (!token) return null;
try {
const data = await authApi.me();
return {
id: data.id,
username: data.username,
role: data.role,
role_label: data.role_label,
permissions: data.permissions,
};
} catch {
clearAuth();
return null;
}
}
export function hasPerm(user: AuthUser | null, perm: string): boolean {
if (!user) return false;
return user.permissions.includes(perm);
}