This commit is contained in:
2025-08-28 00:55:35 +08:00
commit 410f54a65e
93 changed files with 9863 additions and 0 deletions

View 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>
)
}

View File

@ -0,0 +1 @@
.logo{height:48px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px}