初步增加 web 界面
This commit is contained in:
@@ -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="用户名|密码|邮箱|邮箱密码 用户名|密码|邮箱|邮箱密码"
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user