2025-11-29 15:10:00 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Admin Layout with Sidebar
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
2025-12-02 11:20:33 +00:00
|
|
|
|
import { Layout, Menu, Avatar, Dropdown, Modal, Form, Input, Button, message } from 'antd';
|
2025-11-29 15:10:00 +00:00
|
|
|
|
import {
|
|
|
|
|
|
MenuFoldOutlined,
|
|
|
|
|
|
MenuUnfoldOutlined,
|
|
|
|
|
|
DashboardOutlined,
|
|
|
|
|
|
DatabaseOutlined,
|
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
|
UserOutlined,
|
|
|
|
|
|
LogoutOutlined,
|
|
|
|
|
|
RocketOutlined,
|
|
|
|
|
|
AppstoreOutlined,
|
2025-11-30 02:43:47 +00:00
|
|
|
|
SettingOutlined,
|
|
|
|
|
|
TeamOutlined,
|
|
|
|
|
|
ControlOutlined,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
} from '@ant-design/icons';
|
|
|
|
|
|
import type { MenuProps } from 'antd';
|
2025-12-02 11:20:33 +00:00
|
|
|
|
import { authAPI, request } from '../../utils/request';
|
2025-11-29 15:10:00 +00:00
|
|
|
|
import { auth } from '../../utils/auth';
|
2025-12-01 08:52:04 +00:00
|
|
|
|
import { useToast } from '../../contexts/ToastContext';
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
|
|
|
|
|
const { Header, Sider, Content } = Layout;
|
|
|
|
|
|
|
|
|
|
|
|
// Icon mapping
|
|
|
|
|
|
const iconMap: Record<string, any> = {
|
|
|
|
|
|
dashboard: <DashboardOutlined />,
|
|
|
|
|
|
database: <DatabaseOutlined />,
|
|
|
|
|
|
planet: <RocketOutlined />,
|
|
|
|
|
|
data: <DatabaseOutlined />,
|
|
|
|
|
|
download: <DownloadOutlined />,
|
2025-11-30 02:43:47 +00:00
|
|
|
|
settings: <SettingOutlined />,
|
|
|
|
|
|
users: <TeamOutlined />,
|
|
|
|
|
|
sliders: <ControlOutlined />,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export function AdminLayout() {
|
|
|
|
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
|
|
|
|
const [menus, setMenus] = useState<any[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2025-12-02 11:20:33 +00:00
|
|
|
|
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
|
|
|
|
|
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
|
|
|
|
|
const [profileForm] = Form.useForm();
|
|
|
|
|
|
const [passwordForm] = Form.useForm();
|
|
|
|
|
|
const [userProfile, setUserProfile] = useState<any>(null);
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const location = useLocation();
|
|
|
|
|
|
const user = auth.getUser();
|
2025-12-01 08:52:04 +00:00
|
|
|
|
const toast = useToast();
|
2025-11-29 15:10:00 +00:00
|
|
|
|
|
|
|
|
|
|
// Load menus from backend
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadMenus();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const loadMenus = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await authAPI.getMenus();
|
|
|
|
|
|
setMenus(data);
|
|
|
|
|
|
} catch (error) {
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.error('加载菜单失败');
|
2025-11-29 15:10:00 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Convert backend menu to Ant Design menu format
|
2025-11-30 02:43:47 +00:00
|
|
|
|
const convertMenus = (menus: any[], isChild = false): MenuProps['items'] => {
|
2025-11-29 15:10:00 +00:00
|
|
|
|
return menus.map((menu) => {
|
|
|
|
|
|
const item: any = {
|
|
|
|
|
|
key: menu.path || menu.name,
|
2025-11-30 02:43:47 +00:00
|
|
|
|
icon: isChild ? null : (iconMap[menu.icon || ''] || null),
|
2025-11-29 15:10:00 +00:00
|
|
|
|
label: menu.title,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (menu.children && menu.children.length > 0) {
|
2025-11-30 02:43:47 +00:00
|
|
|
|
item.children = convertMenus(menu.children, true);
|
2025-11-29 15:10:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return item;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
|
|
|
|
|
navigate(key);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleLogout = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await authAPI.logout();
|
|
|
|
|
|
auth.logout();
|
2025-12-01 08:52:04 +00:00
|
|
|
|
toast.success('登出成功');
|
2025-11-29 15:10:00 +00:00
|
|
|
|
navigate('/login');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// Even if API fails, clear local auth
|
|
|
|
|
|
auth.logout();
|
|
|
|
|
|
navigate('/login');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-02 11:20:33 +00:00
|
|
|
|
const handleProfileClick = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await request.get('/users/me');
|
|
|
|
|
|
setUserProfile(data);
|
|
|
|
|
|
profileForm.setFieldsValue({
|
|
|
|
|
|
username: data.username,
|
|
|
|
|
|
email: data.email || '',
|
|
|
|
|
|
full_name: data.full_name || '',
|
|
|
|
|
|
});
|
|
|
|
|
|
setProfileModalOpen(true);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error('获取用户信息失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleProfileUpdate = async (values: any) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await request.put('/users/me/profile', {
|
|
|
|
|
|
full_name: values.full_name,
|
|
|
|
|
|
email: values.email || null,
|
|
|
|
|
|
});
|
|
|
|
|
|
toast.success('个人信息更新成功');
|
|
|
|
|
|
setProfileModalOpen(false);
|
|
|
|
|
|
// Update local user info
|
|
|
|
|
|
const updatedUser = { ...user, full_name: values.full_name, email: values.email };
|
|
|
|
|
|
auth.setUser(updatedUser);
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
toast.error(error.response?.data?.detail || '更新失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handlePasswordChange = async (values: any) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await request.put('/users/me/password', {
|
|
|
|
|
|
old_password: values.old_password,
|
|
|
|
|
|
new_password: values.new_password,
|
|
|
|
|
|
});
|
|
|
|
|
|
toast.success('密码修改成功');
|
|
|
|
|
|
setPasswordModalOpen(false);
|
|
|
|
|
|
passwordForm.resetFields();
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
toast.error(error.response?.data?.detail || '密码修改失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const userMenuItems: MenuProps['items'] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'profile',
|
|
|
|
|
|
icon: <UserOutlined />,
|
|
|
|
|
|
label: '个人信息',
|
2025-12-02 11:20:33 +00:00
|
|
|
|
onClick: handleProfileClick,
|
2025-11-29 15:10:00 +00:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'divider',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'logout',
|
|
|
|
|
|
icon: <LogoutOutlined />,
|
|
|
|
|
|
label: '退出登录',
|
|
|
|
|
|
onClick: handleLogout,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
|
|
|
|
<Sider trigger={null} collapsible collapsed={collapsed}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
height: 64,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
fontSize: 20,
|
|
|
|
|
|
fontWeight: 'bold',
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{collapsed ? '🌌' : '🌌 Cosmo'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Menu
|
|
|
|
|
|
theme="dark"
|
|
|
|
|
|
mode="inline"
|
|
|
|
|
|
selectedKeys={[location.pathname]}
|
|
|
|
|
|
items={convertMenus(menus)}
|
|
|
|
|
|
onClick={handleMenuClick}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Sider>
|
|
|
|
|
|
<Layout>
|
|
|
|
|
|
<Header
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '0 16px',
|
|
|
|
|
|
background: '#fff',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
|
|
|
|
style={{ fontSize: 18, cursor: 'pointer' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
|
|
|
|
|
<Avatar icon={<UserOutlined />} style={{ marginRight: 8 }} />
|
|
|
|
|
|
<span>{user?.username || 'User'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Dropdown>
|
|
|
|
|
|
</Header>
|
|
|
|
|
|
<Content
|
|
|
|
|
|
style={{
|
|
|
|
|
|
margin: '16px',
|
|
|
|
|
|
padding: 24,
|
|
|
|
|
|
background: '#fff',
|
|
|
|
|
|
minHeight: 280,
|
2025-11-30 05:25:41 +00:00
|
|
|
|
overflow: 'auto',
|
|
|
|
|
|
maxHeight: 'calc(100vh - 64px - 32px)',
|
2025-11-29 15:10:00 +00:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Outlet />
|
|
|
|
|
|
</Content>
|
|
|
|
|
|
</Layout>
|
2025-12-02 11:20:33 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Profile Modal */}
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title="个人信息"
|
|
|
|
|
|
open={profileModalOpen}
|
|
|
|
|
|
onCancel={() => setProfileModalOpen(false)}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
width={500}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Form
|
|
|
|
|
|
form={profileForm}
|
|
|
|
|
|
layout="vertical"
|
|
|
|
|
|
onFinish={handleProfileUpdate}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Form.Item label="用户名" name="username">
|
|
|
|
|
|
<Input disabled />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
label="昵称"
|
|
|
|
|
|
name="full_name"
|
|
|
|
|
|
rules={[{ max: 50, message: '昵称最长50个字符' }]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input placeholder="请输入昵称" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
label="邮箱"
|
|
|
|
|
|
name="email"
|
|
|
|
|
|
rules={[
|
|
|
|
|
|
{ type: 'email', message: '请输入有效的邮箱地址' }
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input placeholder="请输入邮箱" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item>
|
|
|
|
|
|
<Button type="primary" htmlType="submit" style={{ marginRight: 8 }}>
|
|
|
|
|
|
保存
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button onClick={() => setPasswordModalOpen(true)}>
|
|
|
|
|
|
修改密码
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Password Change Modal */}
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title="修改密码"
|
|
|
|
|
|
open={passwordModalOpen}
|
|
|
|
|
|
onCancel={() => {
|
|
|
|
|
|
setPasswordModalOpen(false);
|
|
|
|
|
|
passwordForm.resetFields();
|
|
|
|
|
|
}}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
width={450}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Form
|
|
|
|
|
|
form={passwordForm}
|
|
|
|
|
|
layout="vertical"
|
|
|
|
|
|
onFinish={handlePasswordChange}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
label="当前密码"
|
|
|
|
|
|
name="old_password"
|
|
|
|
|
|
rules={[{ required: true, message: '请输入当前密码' }]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input.Password placeholder="请输入当前密码" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
label="新密码"
|
|
|
|
|
|
name="new_password"
|
|
|
|
|
|
rules={[
|
|
|
|
|
|
{ required: true, message: '请输入新密码' },
|
|
|
|
|
|
{ min: 6, message: '密码至少6位' }
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input.Password placeholder="请输入新密码(至少6位)" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
label="确认新密码"
|
|
|
|
|
|
name="confirm_password"
|
|
|
|
|
|
dependencies={['new_password']}
|
|
|
|
|
|
rules={[
|
|
|
|
|
|
{ required: true, message: '请确认新密码' },
|
|
|
|
|
|
({ getFieldValue }) => ({
|
|
|
|
|
|
validator(_, value) {
|
|
|
|
|
|
if (!value || getFieldValue('new_password') === value) {
|
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
|
}
|
|
|
|
|
|
return Promise.reject(new Error('两次输入的密码不一致'));
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input.Password placeholder="请再次输入新密码" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item>
|
|
|
|
|
|
<Button type="primary" htmlType="submit" block>
|
|
|
|
|
|
确认修改
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</Modal>
|
2025-11-29 15:10:00 +00:00
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|