init
This commit is contained in:
556
frontend/src/layouts/MainLayout.tsx
Normal file
556
frontend/src/layouts/MainLayout.tsx
Normal file
@ -0,0 +1,556 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Layout, Menu, theme, Avatar, Dropdown, Space, Modal, Form, Input, message, Breadcrumb, ConfigProvider, Tabs } from 'antd'
|
||||
import { useNavigate, useLocation, useOutlet } from 'react-router-dom'
|
||||
import { HomeOutlined, LogoutOutlined, TeamOutlined, SettingOutlined, AppstoreOutlined, KeyOutlined, UserOutlined, DashboardOutlined, FileOutlined, LockOutlined, MenuOutlined, PieChartOutlined, BarChartOutlined, TableOutlined, CalendarOutlined, FormOutlined, SearchOutlined, ToolOutlined, ShoppingCartOutlined, ShopOutlined, FolderOpenOutlined, FolderOutlined, CloudOutlined, DatabaseOutlined, ApiOutlined, CodeOutlined, BugOutlined, BellOutlined, PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, EyeOutlined, EyeInvisibleOutlined, StarOutlined, HeartOutlined, LikeOutlined, DislikeOutlined, SmileOutlined, FrownOutlined, PhoneOutlined, MailOutlined, EnvironmentOutlined, GlobalOutlined, AimOutlined, CompassOutlined, CameraOutlined, VideoCameraOutlined, SoundOutlined, WifiOutlined, RocketOutlined, ThunderboltOutlined, ExperimentOutlined, BulbOutlined, GiftOutlined, BankOutlined, WalletOutlined, MoneyCollectOutlined, BookOutlined, ReadOutlined, ProfileOutlined, CloudUploadOutlined, CloudDownloadOutlined, InboxOutlined, FolderAddOutlined, SlidersOutlined, FilterOutlined, AlertOutlined, ClockCircleOutlined, FieldTimeOutlined, HistoryOutlined, ContactsOutlined, SolutionOutlined, IdcardOutlined, QrcodeOutlined, ScanOutlined, SafetyOutlined, SecurityScanOutlined, UnlockOutlined, HddOutlined, CopyOutlined, ScissorOutlined, SnippetsOutlined, FileProtectOutlined, DesktopOutlined, LaptopOutlined, MobileOutlined, TabletOutlined, ClusterOutlined, AppstoreAddOutlined, PlusSquareOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { clearToken, getUser as getUserLocal, setUser as setUserLocal } from '../utils/token'
|
||||
import api from '../utils/axios'
|
||||
import './layout.css'
|
||||
import { PermissionProvider } from '../utils/permission'
|
||||
const { Header, Sider, Content } = Layout
|
||||
|
||||
interface MenuItemResp { id: number; parent_id?: number | null; name: string; path?: string | null; component?: string | null; type: number; icon?: string | null; order_no: number; visible: boolean; status: number; perms?: string | null; keep_alive?: boolean }
|
||||
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import enUS from 'antd/locale/en_US'
|
||||
|
||||
export default function MainLayout() {
|
||||
const navigate = useNavigate()
|
||||
const loc = useLocation()
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken()
|
||||
|
||||
// 简易语言切换(存储到 localStorage)
|
||||
const [lang, setLang] = useState<'zh' | 'en'>(() => (localStorage.getItem('lang') === 'en' ? 'en' : 'zh'))
|
||||
const localeObj = lang === 'zh' ? zhCN : enUS
|
||||
const [user, setUser] = useState(() => getUserLocal())
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const [rawMenus, setRawMenus] = useState([] as MenuItemResp[])
|
||||
const [menuItems, setMenuItems] = useState([] as any[])
|
||||
|
||||
// 从菜单提取权限编码集合(后端通过菜单返回 perms,包含页面/按钮权限)
|
||||
const permissionCodes = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
rawMenus.forEach((m: MenuItemResp) => {
|
||||
const p = m.perms
|
||||
if (p && typeof p === 'string') {
|
||||
p.split(',').map(s => s.trim()).filter(Boolean).forEach(code => set.add(code))
|
||||
}
|
||||
})
|
||||
return set
|
||||
}, [rawMenus])
|
||||
|
||||
const iconMap: Record<string, any> = useMemo(() => ({
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
TeamOutlined: <TeamOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
KeyOutlined: <KeyOutlined />,
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
FileOutlined: <FileOutlined />,
|
||||
LockOutlined: <LockOutlined />,
|
||||
MenuOutlined: <MenuOutlined />,
|
||||
PieChartOutlined: <PieChartOutlined />,
|
||||
BarChartOutlined: <BarChartOutlined />,
|
||||
TableOutlined: <TableOutlined />,
|
||||
CalendarOutlined: <CalendarOutlined />,
|
||||
FormOutlined: <FormOutlined />,
|
||||
SearchOutlined: <SearchOutlined />,
|
||||
ToolOutlined: <ToolOutlined />,
|
||||
ShoppingCartOutlined: <ShoppingCartOutlined />,
|
||||
ShopOutlined: <ShopOutlined />,
|
||||
FolderOpenOutlined: <FolderOpenOutlined />,
|
||||
FolderOutlined: <FolderOutlined />,
|
||||
CloudOutlined: <CloudOutlined />,
|
||||
DatabaseOutlined: <DatabaseOutlined />,
|
||||
ApiOutlined: <ApiOutlined />,
|
||||
CodeOutlined: <CodeOutlined />,
|
||||
BugOutlined: <BugOutlined />,
|
||||
BellOutlined: <BellOutlined />,
|
||||
// 新增常用图标映射
|
||||
PlusOutlined: <PlusOutlined />,
|
||||
EditOutlined: <EditOutlined />,
|
||||
DeleteOutlined: <DeleteOutlined />,
|
||||
UploadOutlined: <UploadOutlined />,
|
||||
DownloadOutlined: <DownloadOutlined />,
|
||||
EyeOutlined: <EyeOutlined />,
|
||||
EyeInvisibleOutlined: <EyeInvisibleOutlined />,
|
||||
StarOutlined: <StarOutlined />,
|
||||
HeartOutlined: <HeartOutlined />,
|
||||
LikeOutlined: <LikeOutlined />,
|
||||
DislikeOutlined: <DislikeOutlined />,
|
||||
SmileOutlined: <SmileOutlined />,
|
||||
FrownOutlined: <FrownOutlined />,
|
||||
PhoneOutlined: <PhoneOutlined />,
|
||||
MailOutlined: <MailOutlined />,
|
||||
EnvironmentOutlined: <EnvironmentOutlined />,
|
||||
GlobalOutlined: <GlobalOutlined />,
|
||||
AimOutlined: <AimOutlined />,
|
||||
CompassOutlined: <CompassOutlined />,
|
||||
CameraOutlined: <CameraOutlined />,
|
||||
VideoCameraOutlined: <VideoCameraOutlined />,
|
||||
SoundOutlined: <SoundOutlined />,
|
||||
WifiOutlined: <WifiOutlined />,
|
||||
RocketOutlined: <RocketOutlined />,
|
||||
ThunderboltOutlined: <ThunderboltOutlined />,
|
||||
ExperimentOutlined: <ExperimentOutlined />,
|
||||
BulbOutlined: <BulbOutlined />,
|
||||
GiftOutlined: <GiftOutlined />,
|
||||
BankOutlined: <BankOutlined />,
|
||||
WalletOutlined: <WalletOutlined />,
|
||||
MoneyCollectOutlined: <MoneyCollectOutlined />,
|
||||
BookOutlined: <BookOutlined />,
|
||||
ReadOutlined: <ReadOutlined />,
|
||||
ProfileOutlined: <ProfileOutlined />,
|
||||
CloudUploadOutlined: <CloudUploadOutlined />,
|
||||
CloudDownloadOutlined: <CloudDownloadOutlined />,
|
||||
InboxOutlined: <InboxOutlined />,
|
||||
FolderAddOutlined: <FolderAddOutlined />,
|
||||
SlidersOutlined: <SlidersOutlined />,
|
||||
FilterOutlined: <FilterOutlined />,
|
||||
AlertOutlined: <AlertOutlined />,
|
||||
ClockCircleOutlined: <ClockCircleOutlined />,
|
||||
FieldTimeOutlined: <FieldTimeOutlined />,
|
||||
HistoryOutlined: <HistoryOutlined />,
|
||||
ContactsOutlined: <ContactsOutlined />,
|
||||
SolutionOutlined: <SolutionOutlined />,
|
||||
IdcardOutlined: <IdcardOutlined />,
|
||||
QrcodeOutlined: <QrcodeOutlined />,
|
||||
ScanOutlined: <ScanOutlined />,
|
||||
SafetyOutlined: <SafetyOutlined />,
|
||||
SecurityScanOutlined: <SecurityScanOutlined />,
|
||||
UnlockOutlined: <UnlockOutlined />,
|
||||
HddOutlined: <HddOutlined />,
|
||||
CopyOutlined: <CopyOutlined />,
|
||||
ScissorOutlined: <ScissorOutlined />,
|
||||
SnippetsOutlined: <SnippetsOutlined />,
|
||||
FileProtectOutlined: <FileProtectOutlined />,
|
||||
DesktopOutlined: <DesktopOutlined />,
|
||||
LaptopOutlined: <LaptopOutlined />,
|
||||
MobileOutlined: <MobileOutlined />,
|
||||
TabletOutlined: <TabletOutlined />,
|
||||
ClusterOutlined: <ClusterOutlined />,
|
||||
AppstoreAddOutlined: <AppstoreAddOutlined />,
|
||||
PlusSquareOutlined: <PlusSquareOutlined />,
|
||||
SyncOutlined: <SyncOutlined />,
|
||||
ReloadOutlined: <ReloadOutlined />,
|
||||
}), [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMenus = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/auth/menus')
|
||||
if (data?.code === 0) {
|
||||
const list: MenuItemResp[] = data.data || []
|
||||
setRawMenus(list)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
fetchMenus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// 前端移除不再需要的“权限”相关菜单项
|
||||
const filtered = rawMenus.filter((m: MenuItemResp) => m.path !== '/permissions' && m.path !== '/demo/perms')
|
||||
|
||||
// build tree items from filtered
|
||||
const map = new Map<number, any>()
|
||||
const byId = new Map<number, MenuItemResp>()
|
||||
filtered.forEach((m: MenuItemResp) => byId.set(m.id, m))
|
||||
filtered
|
||||
.filter((m: MenuItemResp) => m.type !== 3) // skip buttons
|
||||
.forEach((m: MenuItemResp) => {
|
||||
const key = m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`
|
||||
const item: any = {
|
||||
key,
|
||||
label: m.name,
|
||||
icon: m.icon ? iconMap[m.icon] : undefined,
|
||||
// NOTE: 不要预先放 children: [],否则在 antd Menu 中会被当成可展开项
|
||||
}
|
||||
map.set(m.id, item)
|
||||
})
|
||||
const roots: any[] = []
|
||||
filtered
|
||||
.filter((m: MenuItemResp) => m.type !== 3)
|
||||
.forEach((m: MenuItemResp) => {
|
||||
const node = map.get(m.id)
|
||||
const pid = m.parent_id || undefined
|
||||
// 只有当父级不是按钮时,才允许挂载,避免将节点放到按钮下
|
||||
if (pid && map.has(pid) && byId.get(pid)?.type !== 3) {
|
||||
const parent = map.get(pid)
|
||||
if (!parent.children) parent.children = []
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
setMenuItems(roots)
|
||||
}, [rawMenus, iconMap])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} catch (_) {}
|
||||
clearToken()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const openProfile = () => {
|
||||
const u = getUserLocal()
|
||||
setUser(u)
|
||||
form.setFieldsValue({ username: u?.username, nickname: u?.nickname, avatar: (u as any)?.avatar || '' })
|
||||
setProfileOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try{
|
||||
const values = await form.validateFields()
|
||||
const uid = (user as any)?.id
|
||||
if (uid) {
|
||||
const { data } = await api.put(`/users/${uid}`, { nickname: values.nickname })
|
||||
if (data?.code !== 0) throw new Error(data?.message || '更新失败')
|
||||
}
|
||||
const merged = { ...(user as any), nickname: values.nickname, avatar: values.avatar }
|
||||
setUserLocal(merged)
|
||||
setUser(merged)
|
||||
message.success('资料已更新')
|
||||
setProfileOpen(false)
|
||||
}catch(e: any){ if(e?.errorFields) return; message.error(e?.message || '更新失败') }
|
||||
}
|
||||
|
||||
const onMenuClick = (info: any) => {
|
||||
const key = info?.key as string
|
||||
if (key && key.startsWith('/')) navigate(key)
|
||||
}
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
const k = loc.pathname
|
||||
return [k]
|
||||
}, [loc.pathname])
|
||||
|
||||
// 根据当前路径计算需要展开的父级菜单 keys(不包含叶子自身)
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(() => {
|
||||
try {
|
||||
const v = localStorage.getItem('layout:menuOpenKeys')
|
||||
const arr = v ? JSON.parse(v) : []
|
||||
return Array.isArray(arr) ? (arr as string[]) : []
|
||||
} catch { return [] }
|
||||
})
|
||||
// 持久化用户手动展开/收起的子菜单状态,刷新后保持
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('layout:menuOpenKeys', JSON.stringify(openKeys)) } catch {}
|
||||
}, [openKeys])
|
||||
// 确保当前路由对应的父级菜单也处于展开状态,但不覆盖用户手动展开的其他菜单(合并而非替换)
|
||||
const currentAncestors = useMemo(() => {
|
||||
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms')
|
||||
const idMap = new Map<number, MenuItemResp>()
|
||||
const pathToId = new Map<string, number>()
|
||||
list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) })
|
||||
|
||||
const currentPath = loc.pathname
|
||||
let foundId: number | undefined = pathToId.get(currentPath)
|
||||
if (!foundId) {
|
||||
const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path))
|
||||
if (candidates.length) {
|
||||
candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0))
|
||||
foundId = candidates[0].id
|
||||
}
|
||||
}
|
||||
if (!foundId) return [] as string[]
|
||||
|
||||
const chain: MenuItemResp[] = []
|
||||
let cur: MenuItemResp | undefined = idMap.get(foundId)
|
||||
let guard = 0
|
||||
while (cur && guard++ < 50) {
|
||||
chain.push(cur)
|
||||
const pid = cur.parent_id || undefined
|
||||
cur = pid ? idMap.get(pid) : undefined
|
||||
}
|
||||
chain.reverse()
|
||||
const ancestors = chain.slice(0, Math.max(0, chain.length - 1))
|
||||
return ancestors.map((m: MenuItemResp) => (m.path && m.path.startsWith('/') ? m.path : `m-${m.id}`))
|
||||
}, [rawMenus, loc.pathname])
|
||||
useEffect(() => {
|
||||
setOpenKeys(prev => {
|
||||
const s = new Set(prev)
|
||||
currentAncestors.forEach(k => s.add(k))
|
||||
return Array.from(s)
|
||||
})
|
||||
}, [currentAncestors])
|
||||
|
||||
const dropdownMenuItems = useMemo(() => ([
|
||||
{ key: 'profile', label: '个人信息' },
|
||||
{ type: 'divider' as any },
|
||||
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined /> },
|
||||
]), [])
|
||||
|
||||
// 基于菜单树与当前路径计算面包屑
|
||||
const breadcrumbItems = useMemo(() => {
|
||||
// 过滤掉不需要显示的菜单与按钮(type 3)
|
||||
const list = rawMenus.filter((m: MenuItemResp) => m.type !== 3 && m.path !== '/permissions' && m.path !== '/demo/perms')
|
||||
const idMap = new Map<number, MenuItemResp>()
|
||||
const pathToId = new Map<string, number>()
|
||||
list.forEach((m: MenuItemResp) => { idMap.set(m.id, m); if (m.path) pathToId.set(m.path, m.id) })
|
||||
|
||||
const currentPath = loc.pathname
|
||||
let foundId: number | undefined
|
||||
if (pathToId.has(currentPath)) {
|
||||
foundId = pathToId.get(currentPath)!
|
||||
} else {
|
||||
// 兜底:取路径前缀最长匹配(例如 /users/detail/123 匹配 /users)
|
||||
const candidates = list.filter((m: MenuItemResp) => m.path && currentPath.startsWith(m.path))
|
||||
if (candidates.length) {
|
||||
candidates.sort((a: MenuItemResp, b: MenuItemResp) => (b.path?.length || 0) - (a.path?.length || 0))
|
||||
foundId = candidates[0].id
|
||||
}
|
||||
}
|
||||
if (!foundId) return [] as any[]
|
||||
|
||||
const chain: MenuItemResp[] = []
|
||||
let cur: MenuItemResp | undefined = idMap.get(foundId)
|
||||
let guard = 0
|
||||
while (cur && guard++ < 50) {
|
||||
chain.push(cur)
|
||||
const pid = cur.parent_id || undefined
|
||||
cur = pid ? idMap.get(pid) : undefined
|
||||
}
|
||||
chain.reverse()
|
||||
|
||||
return chain.map((m: MenuItemResp, idx: number) => ({
|
||||
title: (m.path && idx !== chain.length - 1)
|
||||
? <a onClick={(e: any) => { e.preventDefault(); navigate(m.path!) }}>{m.name}</a>
|
||||
: m.name
|
||||
}))
|
||||
}, [rawMenus, loc.pathname, navigate])
|
||||
|
||||
// 路由页签与缓存(放在 return 之前的顶层)
|
||||
type TabItem = { key: string; title: string; keepAlive: boolean }
|
||||
const [tabs, setTabs] = useState<TabItem[]>([])
|
||||
const [cache, setCache] = useState<Record<string, React.ReactNode>>({})
|
||||
const outlet = useOutlet()
|
||||
// 右侧菜单折叠状态(默认收起)
|
||||
// 左侧菜单折叠状态:持久化到 localStorage,刷新不丢失
|
||||
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
const v = localStorage.getItem('layout:leftCollapsed')
|
||||
return v ? v === '1' : false
|
||||
} catch { return false }
|
||||
})
|
||||
// 断点状态:仅用于小屏时临时强制折叠,不写入持久化
|
||||
const [isBroken, setIsBroken] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('layout:leftCollapsed', leftCollapsed ? '1' : '0') } catch {}
|
||||
}, [leftCollapsed])
|
||||
|
||||
// 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态)
|
||||
// 取消自动展开:尊重用户的折叠偏好(刷新后保持折叠/展开状态)
|
||||
|
||||
// path -> menu 映射,便于取标题与 keep_alive
|
||||
const pathToMenu = useMemo(() => {
|
||||
const map = new Map<string, MenuItemResp>()
|
||||
rawMenus.forEach((m: MenuItemResp) => { if (m.path) map.set(m.path, m) })
|
||||
return map
|
||||
}, [rawMenus])
|
||||
|
||||
// 当前路由激活 key
|
||||
const activeKey = useMemo(() => loc.pathname, [loc.pathname])
|
||||
|
||||
// 根据菜单变更,修正已存在 tabs 的标题与 keepAlive(解决刷新后标题变为路径的问题)
|
||||
useEffect(() => {
|
||||
setTabs(prev => {
|
||||
let changed = false
|
||||
const next = prev.map(t => {
|
||||
// 特殊处理首页:无菜单映射时也应显示“首页”
|
||||
if (t.key === '/') {
|
||||
const nextTitle = '首页'
|
||||
const nextKeep = true
|
||||
if (t.title !== nextTitle || t.keepAlive !== nextKeep) {
|
||||
changed = true
|
||||
return { ...t, title: nextTitle, keepAlive: nextKeep }
|
||||
}
|
||||
return t
|
||||
}
|
||||
const m = pathToMenu.get(t.key)
|
||||
if (!m) return t
|
||||
const nextKeep = (typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true
|
||||
const nextTitle = m.name || t.title
|
||||
if (t.title !== nextTitle || t.keepAlive !== nextKeep) {
|
||||
changed = true
|
||||
return { ...t, title: nextTitle, keepAlive: nextKeep }
|
||||
}
|
||||
return t
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [pathToMenu])
|
||||
|
||||
// 根据当前路由补齐 tabs
|
||||
useEffect(() => {
|
||||
const curPath = loc.pathname
|
||||
if (!curPath.startsWith('/')) return
|
||||
const m = pathToMenu.get(curPath)
|
||||
const title = m?.name || (curPath === '/' ? '首页' : curPath)
|
||||
const keepAlive = (m && typeof m.keep_alive === 'boolean') ? !!m.keep_alive : true
|
||||
setTabs(prev => (prev.some(t => t.key === curPath) ? prev : [...prev, { key: curPath, title, keepAlive }]))
|
||||
}, [loc.pathname, pathToMenu])
|
||||
|
||||
// 确保缓存当前激活页(若 keepAlive 打开)
|
||||
useEffect(() => {
|
||||
const cur = tabs.find(t => t.key === activeKey)
|
||||
if (!cur) return
|
||||
if (cur.keepAlive && outlet && !cache[activeKey]) {
|
||||
setCache(prev => ({ ...prev, [activeKey]: outlet }))
|
||||
}
|
||||
}, [activeKey, outlet, tabs, cache])
|
||||
|
||||
const onTabChange = (key: string) => { if (key && key !== activeKey) navigate(key) }
|
||||
const onTabEdit = (targetKey: any, action: 'add' | 'remove') => {
|
||||
if (action === 'remove') {
|
||||
const tKey = String(targetKey)
|
||||
if (tKey === '/') return
|
||||
setTabs(prev => {
|
||||
const idx = prev.findIndex(t => t.key === tKey)
|
||||
if (idx === -1) return prev
|
||||
const next = prev.filter(t => t.key !== tKey)
|
||||
// 清理缓存
|
||||
setCache(c => { const n = { ...c }; delete n[tKey]; return n })
|
||||
// 如果关闭的是当前激活页,跳转到相邻页
|
||||
if (tKey === activeKey) {
|
||||
const fallback = next[idx - 1] || next[idx] || { key: '/' }
|
||||
if (fallback.key) navigate(fallback.key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 确保“首页”始终存在且固定(仅初始化时补齐一次)
|
||||
useEffect(() => {
|
||||
setTabs(prev => (prev.some(t => t.key === '/') ? prev : [{ key: '/', title: '首页', keepAlive: true }, ...prev]))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PermissionProvider codes={permissionCodes}>
|
||||
<ConfigProvider locale={localeObj}>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* 其余内容保持不变 */}
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={isBroken ? true : leftCollapsed}
|
||||
onCollapse={(collapsed, type) => {
|
||||
// 仅在用户点击触发器时更新用户偏好;响应式折叠不写入
|
||||
if (type === 'clickTrigger') {
|
||||
setLeftCollapsed(collapsed)
|
||||
}
|
||||
}}
|
||||
breakpoint="lg"
|
||||
onBreakpoint={(broken) => setIsBroken(broken)}
|
||||
trigger={null}
|
||||
collapsedWidth={56}
|
||||
width={220}
|
||||
style={{ background: colorBgContainer }}
|
||||
>
|
||||
<div
|
||||
style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => setLeftCollapsed(c => !c)}
|
||||
title={leftCollapsed ? '展开菜单' : '收起菜单'}
|
||||
>
|
||||
{leftCollapsed ? 'U' : 'Udmin'}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={(keys) => setOpenKeys(keys as string[])}
|
||||
inlineCollapsed={isBroken ? true : leftCollapsed}
|
||||
items={menuItems}
|
||||
onClick={onMenuClick}
|
||||
style={{ borderInlineEnd: 0 }}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ padding: '8px 16px', background: colorBgContainer }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{/* 用户要求:隐藏展开/收起图标,保留名称点击收起/展开 */}
|
||||
{tabs.length > 0 && (
|
||||
<Tabs
|
||||
className="top-tabs"
|
||||
hideAdd
|
||||
type="editable-card"
|
||||
items={tabs.map(t => ({ key: t.key, label: t.title, closable: t.key !== '/' }))}
|
||||
activeKey={activeKey}
|
||||
onChange={onTabChange}
|
||||
onEdit={onTabEdit as any}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Space size={16} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'zh', label: '中文' },
|
||||
{ key: 'en', label: 'English' },
|
||||
],
|
||||
onClick: ({ key }) => { const v = key as 'zh'|'en'; setLang(v); localStorage.setItem('lang', v) }
|
||||
}}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Space style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||
<GlobalOutlined />
|
||||
<span>{lang === 'zh' ? '中文' : 'English'}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
menu={{ items: dropdownMenuItems as any, onClick: (e: { key: string }) => { if (e.key === 'logout') handleLogout(); if (e.key === 'profile') openProfile() } }}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar size={32} icon={<UserOutlined />} />
|
||||
<span>{(user as any)?.nickname || (user as any)?.username || '用户'}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ margin: '16px', padding: 16, background: colorBgContainer, borderRadius: borderRadiusLG }}>
|
||||
{/* 缓存的标签页:非激活隐藏 */}
|
||||
{tabs.filter(t => t.keepAlive).map(t => (
|
||||
<div key={t.key} style={{ display: t.key === activeKey ? 'block' : 'none' }}>
|
||||
{cache[t.key]}
|
||||
</div>
|
||||
))}
|
||||
{/* 未开启缓存:只渲染当前路由 */}
|
||||
{(() => {
|
||||
const cur = tabs.find(t => t.key === activeKey)
|
||||
if (!cur) return null
|
||||
if (cur.keepAlive) return null
|
||||
return <div key={activeKey}>{outlet}</div>
|
||||
})()}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
<Modal title="个人信息" open={profileOpen} onOk={handleSaveProfile} onCancel={() => setProfileOpen(false)} okText="保存" width={840}>
|
||||
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
|
||||
<Form.Item name="username" label="用户名">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="nickname" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
|
||||
<Input placeholder="请输入昵称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="avatar" label="头像 URL">
|
||||
<Input placeholder="请输入头像图片地址(URL)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</ConfigProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
1
frontend/src/layouts/layout.css
Normal file
1
frontend/src/layouts/layout.css
Normal file
@ -0,0 +1 @@
|
||||
.logo{height:48px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px}
|
||||
Reference in New Issue
Block a user