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

333 lines
9.3 KiB
TypeScript
Raw Normal View History

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';
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();
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) {
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();
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>
);
}