Files
video-gen/video-gen-admin/src/pages/AdminLayout.tsx
T
2026-06-16 11:25:05 +08:00

354 lines
14 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 [collapsed, setCollapsed] = useState(false);
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;
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' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
theme="light"
style={{
background: '#ffffff',
borderRight: '1px solid #e5e7eb',
}}
>
{/* Logo */}
<div style={{
height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
borderBottom: '1px solid #e5e7eb',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(99,102,241,0.3)',
overflow: 'hidden',
}}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 24, height: 24, objectFit: 'contain' }} />
) : (
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
)}
</div>
{!collapsed && (
<span style={{ color: '#1f2937', fontSize: 15, fontWeight: 600, letterSpacing: -0.02 }}>
{siteName}
</span>
)}
</div>
{/* Menu */}
<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"
/>
</Sider>
<Layout>
{/* Header */}
<div style={{
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px',
fontFamily: 'var(--font-sans)',
}}>
<Typography.Text strong style={{ fontSize: 16 }}>
{antMenuItems.find(m => m.key === activeKey)?.label
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|| '管理后台'}
</Typography.Text>
<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="bottomRight" arrow>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
<Avatar size={28} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
{user?.username}
</Typography.Text>
</div>
</Dropdown>
</div>
{/* Content */}
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
{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;