431 lines
17 KiB
TypeScript
431 lines
17 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
|
|
import {
|
|
MenuOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
|
HomeOutlined, PlayCircleOutlined, WalletOutlined, RobotOutlined,
|
|
SettingOutlined, BellOutlined, UserOutlined, AppstoreOutlined,
|
|
FileTextOutlined, StarOutlined, HeartOutlined, CameraOutlined,
|
|
DashboardOutlined, CalculatorOutlined, DollarOutlined, GiftOutlined,
|
|
ThunderboltOutlined, FireOutlined, CloudOutlined, SmileOutlined,
|
|
TrophyOutlined, RocketOutlined, BulbOutlined, CodeOutlined,
|
|
PictureOutlined, VideoCameraOutlined, AudioOutlined,
|
|
MailOutlined, PhoneOutlined, GlobalOutlined, ShoppingCartOutlined,
|
|
TeamOutlined, BarChartOutlined, PieChartOutlined, LineChartOutlined,
|
|
SecurityScanOutlined, ApiOutlined, DatabaseOutlined, CloudServerOutlined,
|
|
LockOutlined, LogoutOutlined
|
|
} from '@ant-design/icons';
|
|
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
|
import { useAdminStore } from '../store';
|
|
import { getMenuConfigs, adminChangePassword, getSiteInfo } from '../api';
|
|
|
|
const { Sider, Content } = Layout;
|
|
|
|
const iconMap: Record<string, React.ReactNode> = {
|
|
HomeOutlined: <HomeOutlined />, PlayCircleOutlined: <PlayCircleOutlined />,
|
|
WalletOutlined: <WalletOutlined />, RobotOutlined: <RobotOutlined />,
|
|
SettingOutlined: <SettingOutlined />, BellOutlined: <BellOutlined />,
|
|
UserOutlined: <UserOutlined />, AppstoreOutlined: <AppstoreOutlined />,
|
|
FileTextOutlined: <FileTextOutlined />, StarOutlined: <StarOutlined />,
|
|
HeartOutlined: <HeartOutlined />, CameraOutlined: <CameraOutlined />,
|
|
DashboardOutlined: <DashboardOutlined />, CalculatorOutlined: <CalculatorOutlined />,
|
|
DollarOutlined: <DollarOutlined />, GiftOutlined: <GiftOutlined />,
|
|
ThunderboltOutlined: <ThunderboltOutlined />, FireOutlined: <FireOutlined />,
|
|
CloudOutlined: <CloudOutlined />, SmileOutlined: <SmileOutlined />,
|
|
TrophyOutlined: <TrophyOutlined />, RocketOutlined: <RocketOutlined />,
|
|
BulbOutlined: <BulbOutlined />, CodeOutlined: <CodeOutlined />,
|
|
PictureOutlined: <PictureOutlined />, VideoCameraOutlined: <VideoCameraOutlined />,
|
|
AudioOutlined: <AudioOutlined />, MailOutlined: <MailOutlined />,
|
|
PhoneOutlined: <PhoneOutlined />, GlobalOutlined: <GlobalOutlined />,
|
|
ShoppingCartOutlined: <ShoppingCartOutlined />, TeamOutlined: <TeamOutlined />,
|
|
BarChartOutlined: <BarChartOutlined />, PieChartOutlined: <PieChartOutlined />,
|
|
LineChartOutlined: <LineChartOutlined />, SecurityScanOutlined: <SecurityScanOutlined />,
|
|
ApiOutlined: <ApiOutlined />, DatabaseOutlined: <DatabaseOutlined />,
|
|
CloudServerOutlined: <CloudServerOutlined />, MenuOutlined: <MenuOutlined />,
|
|
PlusOutlined: <PlusOutlined />, EditOutlined: <EditOutlined />, DeleteOutlined: <DeleteOutlined />,
|
|
LockOutlined: <LockOutlined />, LogoutOutlined: <LogoutOutlined />
|
|
};
|
|
|
|
const AdminLayout: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { user, loading, logout } = useAdminStore();
|
|
const [menuItems, setMenuItems] = useState<any[]>([]);
|
|
const [pwdModal, setPwdModal] = useState(false);
|
|
const [pwdForm] = Form.useForm();
|
|
const [siteName, setSiteName] = useState(() => {
|
|
const cached = localStorage.getItem('siteInfo');
|
|
return cached ? JSON.parse(cached).siteName || '管理后台' : '管理后台';
|
|
});
|
|
const [siteLogo, setSiteLogo] = useState(() => {
|
|
const cached = localStorage.getItem('siteInfo');
|
|
return cached ? JSON.parse(cached).siteLogo || '' : '';
|
|
});
|
|
|
|
useEffect(() => {
|
|
getSiteInfo().then(info => {
|
|
const name = info.siteName || '管理后台';
|
|
const logo = info.siteLogo || '';
|
|
setSiteName(name);
|
|
setSiteLogo(logo);
|
|
document.title = name === '管理后台' ? name : name + ' - 管理后台';
|
|
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
|
|
if (logo) {
|
|
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
|
|
if (!faviconLink) {
|
|
faviconLink = document.createElement('link');
|
|
faviconLink.rel = 'icon';
|
|
document.head.appendChild(faviconLink);
|
|
}
|
|
faviconLink.href = logo;
|
|
faviconLink.type = 'image/png';
|
|
}
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
getMenuConfigs().then(data => {
|
|
let menus = data.filter((m: any) => {
|
|
const target = m.menu_target ?? m.menuTarget ?? 'admin';
|
|
const active = m.is_active ?? m.isActive ?? true;
|
|
return active && (target === 'admin' || target === 'both');
|
|
});
|
|
// Non-admin backend users: filter by allowedMenus
|
|
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
|
if (user && !isAdminUser) {
|
|
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
|
|
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
|
|
const allowedSet = new Set(allowed);
|
|
menus = menus.filter((m: any) => {
|
|
const menuType = m.menu_type ?? m.menuType;
|
|
if (menuType === 'group') {
|
|
return menus.some((c: any) => {
|
|
const pid = c.parent_id ?? c.parentId;
|
|
return pid === m.id && allowedSet.has(c.path);
|
|
});
|
|
}
|
|
return allowedSet.has(m.path);
|
|
});
|
|
} else {
|
|
// No allowed menus set and not admin = show nothing
|
|
menus = [];
|
|
}
|
|
}
|
|
setMenuItems(menus);
|
|
}).catch(() => {});
|
|
}, [user]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
|
<Spin size="large" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return <Navigate to="/admin/login" replace />;
|
|
}
|
|
|
|
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
|
|
const childMap: Record<string, any[]> = {};
|
|
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
|
|
const pid = m.parent_id ?? m.parentId;
|
|
if (!childMap[pid]) childMap[pid] = [];
|
|
childMap[pid].push(m);
|
|
});
|
|
|
|
// Sort all menu items by sortOrder, then build ant menu items
|
|
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
|
|
const antMenuItems: any[] = [];
|
|
|
|
sorted.forEach(m => {
|
|
const menuType = m.menu_type ?? m.menuType;
|
|
const parentId = m.parent_id ?? m.parentId;
|
|
if (menuType === 'group') {
|
|
const children = (childMap[m.id] || [])
|
|
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
|
|
.map(c => ({
|
|
key: c.path,
|
|
icon: iconMap[c.icon] || undefined,
|
|
label: c.label,
|
|
}));
|
|
if (children.length > 0) {
|
|
antMenuItems.push({
|
|
key: `group-${m.id}`,
|
|
icon: iconMap[m.icon] || undefined,
|
|
label: m.label,
|
|
children,
|
|
});
|
|
}
|
|
} else if (!parentId) {
|
|
antMenuItems.push({
|
|
key: m.path,
|
|
icon: iconMap[m.icon] || undefined,
|
|
label: m.label,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Fallback only for admin users — non-admin users with no permissions see nothing
|
|
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
|
if (antMenuItems.length === 0 && isAdminUser) {
|
|
antMenuItems.push(
|
|
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
|
|
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
|
);
|
|
}
|
|
|
|
const selectedKey = location.pathname;
|
|
|
|
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
|
|
let activeKey = selectedKey;
|
|
const allLeafKeys: string[] = [];
|
|
antMenuItems.forEach(item => {
|
|
if (item.children) {
|
|
item.children.forEach((c: any) => allLeafKeys.push(c.key));
|
|
} else {
|
|
allLeafKeys.push(item.key);
|
|
}
|
|
});
|
|
// Exact match or prefix match
|
|
if (!allLeafKeys.includes(activeKey)) {
|
|
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
|
|
}
|
|
|
|
// Find open keys for sub-menus
|
|
const openKeys: string[] = [];
|
|
antMenuItems.forEach(item => {
|
|
if (item.children) {
|
|
if (item.children.some((c: any) => c.key === activeKey)) {
|
|
openKeys.push(item.key);
|
|
}
|
|
}
|
|
});
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh', background: '#f1f5f9' }}>
|
|
<Sider
|
|
width={240}
|
|
theme="light"
|
|
style={{
|
|
position: 'fixed',
|
|
left: 16,
|
|
top: 16,
|
|
bottom: 16,
|
|
width: 240,
|
|
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
|
|
borderRadius: '20px',
|
|
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
|
|
border: '1px solid rgba(0, 0, 0, 0.06)',
|
|
zIndex: 100,
|
|
}}
|
|
>
|
|
{/* Logo */}
|
|
<div style={{
|
|
height: 80,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 12,
|
|
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
|
|
}}>
|
|
<div style={{
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 14,
|
|
background: '#ffffff',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
|
overflow: 'hidden',
|
|
}}>
|
|
{siteLogo ? (
|
|
<img src={siteLogo} alt="logo" style={{ width: 36, height: 36, objectFit: 'contain' }} />
|
|
) : (
|
|
<ThunderboltOutlined style={{ fontSize: 20, color: '#ffffff' }} />
|
|
)}
|
|
</div>
|
|
<span style={{
|
|
color: '#1e293b',
|
|
fontSize: 17,
|
|
fontWeight: 700,
|
|
letterSpacing: -0.02,
|
|
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
|
WebkitBackgroundClip: 'text',
|
|
WebkitTextFillColor: 'transparent',
|
|
backgroundClip: 'text',
|
|
}}>
|
|
{siteName}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Menu */}
|
|
<div style={{ overflowY: 'auto', maxHeight: 'calc(100vh - 200px)', paddingRight: 8 }}>
|
|
<Menu
|
|
mode="inline"
|
|
selectedKeys={[activeKey]}
|
|
defaultOpenKeys={openKeys}
|
|
items={antMenuItems}
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
paddingTop: 8,
|
|
marginTop: 8,
|
|
}}
|
|
onClick={({ key }) => {
|
|
if (key.startsWith('group-')) return;
|
|
navigate(key);
|
|
}}
|
|
theme="light"
|
|
/>
|
|
</div>
|
|
|
|
{/* User Actions - moved to bottom of sidebar */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
bottom: 16,
|
|
left: 16,
|
|
right: 16,
|
|
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
|
|
borderRadius: 12,
|
|
border: '1px solid rgba(0, 0, 0, 0.06)',
|
|
}}>
|
|
<Dropdown menu={{
|
|
items: [
|
|
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
|
|
{ type: 'divider' as const },
|
|
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
|
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
|
|
],
|
|
onClick: ({ key }) => {
|
|
if (key === 'logout') { logout(); navigate('/login'); }
|
|
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
|
|
},
|
|
}} placement="topRight" arrow>
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
cursor: 'pointer',
|
|
padding: '8px 12px',
|
|
borderRadius: 12,
|
|
transition: 'all 0.25s ease',
|
|
background: 'transparent',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = '#ffffff';
|
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.08)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = 'transparent';
|
|
e.currentTarget.style.boxShadow = 'none';
|
|
}}
|
|
>
|
|
<Avatar size={32} icon={<UserOutlined />}
|
|
style={{
|
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
|
|
}} />
|
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
|
<Typography.Text style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{user?.username}
|
|
</Typography.Text>
|
|
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>
|
|
{isAdminUser ? '管理员' : '普通用户'}
|
|
</Typography.Text>
|
|
</div>
|
|
</div>
|
|
</Dropdown>
|
|
</div>
|
|
|
|
</Sider>
|
|
|
|
<Layout style={{ marginLeft: 272, marginTop: 16, marginRight: 16, marginBottom: 16, background: 'transparent' }}>
|
|
{/* Header */}
|
|
<div style={{
|
|
height: 72,
|
|
background: '#ffffff',
|
|
borderRadius: '16px 16px 0 0',
|
|
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '0 32px',
|
|
fontFamily: 'var(--font-sans)',
|
|
border: '1px solid rgba(0, 0, 0, 0.06)',
|
|
borderBottom: 'none',
|
|
}}>
|
|
<Typography.Text strong style={{
|
|
fontSize: 18,
|
|
color: '#1e293b',
|
|
fontWeight: 600,
|
|
}}>
|
|
{antMenuItems.find(m => m.key === activeKey)?.label
|
|
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|
|
|| '管理后台'}
|
|
</Typography.Text>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<Content style={{
|
|
padding: 32,
|
|
background: '#ffffff',
|
|
borderRadius: '0 0 16px 16px',
|
|
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.06)',
|
|
overflow: 'auto',
|
|
border: '1px solid rgba(0, 0, 0, 0.06)',
|
|
borderTop: 'none',
|
|
}}>
|
|
{antMenuItems.length === 0 && !isAdminUser ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
|
|
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
|
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
|
|
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
|
|
</div>
|
|
) : (
|
|
<Outlet />
|
|
)}
|
|
</Content>
|
|
</Layout>
|
|
|
|
<Modal
|
|
title={<Space><LockOutlined />修改密码</Space>}
|
|
open={pwdModal}
|
|
onOk={async () => {
|
|
try {
|
|
const values = await pwdForm.validateFields();
|
|
if (values.newPassword !== values.confirmPassword) {
|
|
message.error('两次输入的密码不一致');
|
|
return;
|
|
}
|
|
await adminChangePassword(values.oldPassword, values.newPassword);
|
|
message.success('密码修改成功');
|
|
setPwdModal(false);
|
|
pwdForm.resetFields();
|
|
} catch (e: any) {
|
|
if (e?.errorFields) return;
|
|
message.error(e?.message || '修改失败');
|
|
}
|
|
}}
|
|
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
|
|
okText="确认修改" cancelText="取消" width={420}
|
|
>
|
|
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
|
|
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
|
<Input.Password placeholder="请输入原密码" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
|
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
|
|
</Form.Item>
|
|
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
|
|
<Input.Password placeholder="请再次输入新密码" size="large" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default AdminLayout;
|