feat(react19-migration): 阶段4 - 布局与通用组件

- T4.1: DefaultLayout (ProLayout + Suspense + Outlet)
- T4.2: SideMenu (AntD Menu + 递归菜单转换 + 图标映射)
- T4.3: HeaderRight (Dropdown + Avatar + 退出登录)
- T4.4: AuthGuard (认证守卫 → Navigate /login)
- T4.5: PermissionGuard (权限守卫 → permission/role 检查)
- T4.6: ChartContainer (AntV 图表容器)
- T4.7: useAntV Hook (图表生命周期管理)
- T4.8: usePermission Hook (权限检查封装)
- 安装 @ant-design/pro-components @ant-design/icons

验证: npx tsc --noEmit 通过
This commit is contained in:
张翔
2026-05-03 15:48:30 +08:00
parent 8a03923dd7
commit c5547cff06
10 changed files with 314 additions and 11 deletions
@@ -0,0 +1,16 @@
import { Navigate } from 'react-router'
import { useAuthStore } from '@/stores/useAuthStore'
interface AuthGuardProps {
children: React.ReactNode
}
export default function AuthGuard({ children }: AuthGuardProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
@@ -0,0 +1,23 @@
import { useRef, useEffect } from 'react'
interface ChartContainerProps {
onInit: (container: HTMLDivElement) => void
onDestroy?: () => void
style?: React.CSSProperties
}
export default function ChartContainer({ onInit, onDestroy, style }: ChartContainerProps) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (containerRef.current) {
onInit(containerRef.current)
}
return () => {
onDestroy?.()
}
}, [onInit, onDestroy])
return <div ref={containerRef} style={{ width: '100%', height: '100%', ...style }} />
}
@@ -0,0 +1,30 @@
import { usePermissionStore } from '@/stores/usePermissionStore'
interface PermissionGuardProps {
permission?: string
role?: string
type?: 'permission' | 'role'
children: React.ReactNode
fallback?: React.ReactNode
}
export default function PermissionGuard({
permission,
role,
type = 'permission',
children,
fallback = null,
}: PermissionGuardProps) {
const hasPermission = usePermissionStore((s) => s.hasPermission)
const hasRole = usePermissionStore((s) => s.hasRole)
if (type === 'role' && role) {
return hasRole(role) ? <>{children}</> : <>{fallback}</>
}
if (permission) {
return hasPermission(permission) ? <>{children}</> : <>{fallback}</>
}
return <>{children}</>
}
+38
View File
@@ -0,0 +1,38 @@
import { useRef, useEffect, useCallback } from 'react'
interface UseAntVOptions {
autoDestroy?: boolean
}
export function useAntV<T>(ChartClass: new (container: HTMLElement, options?: any) => T, options?: any, antvOptions?: UseAntVOptions) {
const chartRef = useRef<T | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const initChart = useCallback(
(container: HTMLDivElement) => {
if (chartRef.current) {
;(chartRef.current as any).destroy?.()
}
containerRef.current = container
chartRef.current = new ChartClass(container, options)
},
[ChartClass, options]
)
const updateData = useCallback((data: any[]) => {
if (chartRef.current && typeof (chartRef.current as any).changeData === 'function') {
;(chartRef.current as any).changeData(data)
}
}, [])
useEffect(() => {
return () => {
if (antvOptions?.autoDestroy !== false && chartRef.current) {
;(chartRef.current as any).destroy?.()
chartRef.current = null
}
}
}, [antvOptions?.autoDestroy])
return { chartRef, containerRef, initChart, updateData }
}
@@ -0,0 +1,10 @@
import { usePermissionStore } from '@/stores/usePermissionStore'
export function usePermission() {
const hasPermission = usePermissionStore((s) => s.hasPermission)
const hasRole = usePermissionStore((s) => s.hasRole)
const permissions = usePermissionStore((s) => s.permissions)
const roles = usePermissionStore((s) => s.roles)
return { hasPermission, hasRole, permissions, roles }
}
@@ -1,10 +0,0 @@
import { Outlet } from 'react-router'
export default function DefaultLayout() {
return (
<div style={{ padding: 24 }}>
<h1>DefaultLayout (TODO)</h1>
<Outlet />
</div>
)
}
@@ -0,0 +1,53 @@
import { useNavigate } from 'react-router'
import { Dropdown, Avatar, Space } from 'antd'
import { UserOutlined, LogoutOutlined, KeyOutlined } from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { useAuthStore } from '@/stores/useAuthStore'
export default function HeaderRight() {
const navigate = useNavigate()
const username = useAuthStore((s) => s.username)
const logout = useAuthStore((s) => s.logout)
const menuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人中心',
},
{
key: 'password',
icon: <KeyOutlined />,
label: '修改密码',
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
},
]
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'profile':
break
case 'password':
break
case 'logout':
logout()
navigate('/login')
break
}
}
return (
<Dropdown menu={{ items: menuItems, onClick: handleMenuClick }} trigger={['click']}>
<Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} />
<span>{username || '用户'}</span>
</Space>
</Dropdown>
)
}
@@ -0,0 +1,104 @@
import { useNavigate, useLocation } from 'react-router'
import { Menu } from 'antd'
import type { MenuProps } from 'antd'
import {
DashboardOutlined,
UserOutlined,
TeamOutlined,
MenuOutlined,
SettingOutlined,
BookOutlined,
FileOutlined,
NotificationOutlined,
AuditOutlined,
FileSearchOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { usePermissionStore } from '@/stores/usePermissionStore'
import type { MenuItem } from '@/api/menu'
type AntMenuItem = Required<MenuProps>['items'][number]
const iconMap: Record<string, React.ReactNode> = {
dashboard: <DashboardOutlined />,
user: <UserOutlined />,
users: <UserOutlined />,
role: <TeamOutlined />,
roles: <TeamOutlined />,
menu: <MenuOutlined />,
menus: <MenuOutlined />,
config: <SettingOutlined />,
dict: <BookOutlined />,
file: <FileOutlined />,
files: <FileOutlined />,
notice: <NotificationOutlined />,
loginlog: <AuditOutlined />,
oplog: <FileSearchOutlined />,
exceptionlog: <WarningOutlined />,
}
const pathMap: Record<string, string> = {
dashboard: '/dashboard',
users: '/users',
roles: '/roles',
menus: '/menus',
'sys/config': '/sys/config',
dict: '/dict',
files: '/files',
notice: '/notice',
loginlog: '/loginlog',
oplog: '/oplog',
exceptionlog: '/exceptionlog',
}
function convertMenus(items: MenuItem[]): AntMenuItem[] {
return items
.filter((item) => item.type !== 'button' && item.visible !== false)
.map((item) => {
const path = item.path || pathMap[item.permission] || `/custom/${item.id}`
const icon = iconMap[item.icon] || iconMap[item.permission]
if (item.children?.length) {
return {
key: path,
icon,
label: item.name,
children: convertMenus(item.children),
}
}
return {
key: path,
icon,
label: item.name,
}
})
}
export default function SideMenu() {
const navigate = useNavigate()
const location = useLocation()
const menus = usePermissionStore((s) => s.menus)
const menuItems = convertMenus(menus)
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key)
}
return (
<Menu
mode="inline"
selectedKeys={[location.pathname]}
defaultOpenKeys={[getOpenKey(location.pathname)]}
items={menuItems}
onClick={handleMenuClick}
style={{ height: '100%', borderRight: 0 }}
/>
)
}
function getOpenKey(pathname: string): string {
const segments = pathname.split('/').filter(Boolean)
return segments.length > 1 ? `/${segments[0]}` : pathname
}
@@ -0,0 +1,39 @@
import { Suspense } from 'react'
import { Outlet } from 'react-router'
import { ProLayout } from '@ant-design/pro-components'
import { Spin } from 'antd'
import { useAppStore } from '@/stores/useAppStore'
import { useAuthStore } from '@/stores/useAuthStore'
import HeaderRight from './HeaderRight'
export default function DefaultLayout() {
const collapsed = useAppStore((s) => s.collapsed)
const toggleCollapsed = useAppStore((s) => s.toggleCollapsed)
const username = useAuthStore((s) => s.username)
return (
<ProLayout
title="Novalon 管理系统"
logo={null}
layout="mix"
collapsed={collapsed}
onCollapse={toggleCollapsed}
fixSiderbar
fixedHeader
menuItemRender={(item, dom) => <a onClick={() => item.onClick?.()}>{dom}</a>}
headerTitleRender={(logo, title) => (
<a onClick={toggleCollapsed} style={{ cursor: 'pointer' }}>
{logo}{title}
</a>
)}
avatarProps={{
title: username || '用户',
render: () => <HeaderRight />,
}}
>
<Suspense fallback={<Spin size="large" style={{ display: 'block', margin: '100px auto' }} />}>
<Outlet />
</Suspense>
</ProLayout>
)
}
+1 -1
View File
@@ -13,7 +13,7 @@ const LoginLog = lazy(() => import('@/pages/audit/login-log'))
const OperationLog = lazy(() => import('@/pages/audit/operation-log'))
const ExceptionLog = lazy(() => import('@/pages/audit/exception-log'))
const Forbidden = lazy(() => import('@/pages/403'))
const DefaultLayout = lazy(() => import('@/layouts/DefaultLayout'))
const DefaultLayout = lazy(() => import('@/layouts/DefaultLayout/index'))
export { authLoader } from './guards'
export { DefaultLayout, Login, Dashboard, UserManagement, RoleManagement, MenuManagement, ConfigManagement, DictManagement, FileManagement, NoticeManagement, LoginLog, OperationLog, ExceptionLog, Forbidden }