cosmo/frontend/src/pages/admin/AdminLayout.tsx

333 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* Admin Layout with Sidebar
*/
import { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Avatar, Dropdown, Modal, Form, Input, Button, message } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
DashboardOutlined,
DatabaseOutlined,
DownloadOutlined,
UserOutlined,
LogoutOutlined,
RocketOutlined,
AppstoreOutlined,
SettingOutlined,
TeamOutlined,
ControlOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { authAPI, request } from '../../utils/request';
import { auth } from '../../utils/auth';
import { useToast } from '../../contexts/ToastContext';
const { Header, Sider, Content } = Layout;
// Icon mapping
const iconMap: Record<string, any> = {
dashboard: <DashboardOutlined />,
database: <DatabaseOutlined />,
planet: <RocketOutlined />,
data: <DatabaseOutlined />,
download: <DownloadOutlined />,
settings: <SettingOutlined />,
users: <TeamOutlined />,
sliders: <ControlOutlined />,
};
export function AdminLayout() {
const [collapsed, setCollapsed] = useState(false);
const [menus, setMenus] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [profileForm] = Form.useForm();
const [passwordForm] = Form.useForm();
const [userProfile, setUserProfile] = useState<any>(null);
const navigate = useNavigate();
const location = useLocation();
const user = auth.getUser();
const toast = useToast();
// Load menus from backend
useEffect(() => {
loadMenus();
}, []);
const loadMenus = async () => {
try {
const { data } = await authAPI.getMenus();
setMenus(data);
} catch (error) {
toast.error('加载菜单失败');
} finally {
setLoading(false);
}
};
// Convert backend menu to Ant Design menu format
const convertMenus = (menus: any[], isChild = false): MenuProps['items'] => {
return menus.map((menu) => {
const item: any = {
key: menu.path || menu.name,
icon: isChild ? null : (iconMap[menu.icon || ''] || null),
label: menu.title,
};
if (menu.children && menu.children.length > 0) {
item.children = convertMenus(menu.children, true);
}
return item;
});
};
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
};
const handleLogout = async () => {
try {
await authAPI.logout();
auth.logout();
toast.success('登出成功');
navigate('/login');
} catch (error) {
// Even if API fails, clear local auth
auth.logout();
navigate('/login');
}
};
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 || '密码修改失败');
}
};
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人信息',
onClick: handleProfileClick,
},
{
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,
overflow: 'auto',
maxHeight: 'calc(100vh - 64px - 32px)',
}}
>
<Outlet />
</Content>
</Layout>
{/* 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>
</Layout>
);
}