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:
@@ -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}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user