From c5547cff06db6f1117556675ea3adccb972ab4ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E7=BF=94?= Date: Sun, 3 May 2026 15:48:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(react19-migration):=20=E9=98=B6=E6=AE=B54?= =?UTF-8?q?=20-=20=E5=B8=83=E5=B1=80=E4=B8=8E=E9=80=9A=E7=94=A8=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 通过 --- .../src/components/AuthGuard.tsx | 16 +++ .../src/components/ChartContainer.tsx | 23 ++++ .../src/components/PermissionGuard.tsx | 30 +++++ novalon-manage-web/src/hooks/useAntV.ts | 38 +++++++ novalon-manage-web/src/hooks/usePermission.ts | 10 ++ .../src/layouts/DefaultLayout.tsx | 10 -- .../src/layouts/DefaultLayout/HeaderRight.tsx | 53 +++++++++ .../src/layouts/DefaultLayout/SideMenu.tsx | 104 ++++++++++++++++++ .../src/layouts/DefaultLayout/index.tsx | 39 +++++++ novalon-manage-web/src/router/routes.ts | 2 +- 10 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 novalon-manage-web/src/components/AuthGuard.tsx create mode 100644 novalon-manage-web/src/components/ChartContainer.tsx create mode 100644 novalon-manage-web/src/components/PermissionGuard.tsx create mode 100644 novalon-manage-web/src/hooks/useAntV.ts create mode 100644 novalon-manage-web/src/hooks/usePermission.ts delete mode 100644 novalon-manage-web/src/layouts/DefaultLayout.tsx create mode 100644 novalon-manage-web/src/layouts/DefaultLayout/HeaderRight.tsx create mode 100644 novalon-manage-web/src/layouts/DefaultLayout/SideMenu.tsx create mode 100644 novalon-manage-web/src/layouts/DefaultLayout/index.tsx diff --git a/novalon-manage-web/src/components/AuthGuard.tsx b/novalon-manage-web/src/components/AuthGuard.tsx new file mode 100644 index 0000000..f1a3262 --- /dev/null +++ b/novalon-manage-web/src/components/AuthGuard.tsx @@ -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 + } + + return <>{children} +} diff --git a/novalon-manage-web/src/components/ChartContainer.tsx b/novalon-manage-web/src/components/ChartContainer.tsx new file mode 100644 index 0000000..c806cbc --- /dev/null +++ b/novalon-manage-web/src/components/ChartContainer.tsx @@ -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(null) + + useEffect(() => { + if (containerRef.current) { + onInit(containerRef.current) + } + + return () => { + onDestroy?.() + } + }, [onInit, onDestroy]) + + return
+} diff --git a/novalon-manage-web/src/components/PermissionGuard.tsx b/novalon-manage-web/src/components/PermissionGuard.tsx new file mode 100644 index 0000000..bea4127 --- /dev/null +++ b/novalon-manage-web/src/components/PermissionGuard.tsx @@ -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} +} diff --git a/novalon-manage-web/src/hooks/useAntV.ts b/novalon-manage-web/src/hooks/useAntV.ts new file mode 100644 index 0000000..563737a --- /dev/null +++ b/novalon-manage-web/src/hooks/useAntV.ts @@ -0,0 +1,38 @@ +import { useRef, useEffect, useCallback } from 'react' + +interface UseAntVOptions { + autoDestroy?: boolean +} + +export function useAntV(ChartClass: new (container: HTMLElement, options?: any) => T, options?: any, antvOptions?: UseAntVOptions) { + const chartRef = useRef(null) + const containerRef = useRef(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 } +} diff --git a/novalon-manage-web/src/hooks/usePermission.ts b/novalon-manage-web/src/hooks/usePermission.ts new file mode 100644 index 0000000..d2d2794 --- /dev/null +++ b/novalon-manage-web/src/hooks/usePermission.ts @@ -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 } +} diff --git a/novalon-manage-web/src/layouts/DefaultLayout.tsx b/novalon-manage-web/src/layouts/DefaultLayout.tsx deleted file mode 100644 index a1adf1d..0000000 --- a/novalon-manage-web/src/layouts/DefaultLayout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Outlet } from 'react-router' - -export default function DefaultLayout() { - return ( -
-

DefaultLayout (TODO)

- -
- ) -} diff --git a/novalon-manage-web/src/layouts/DefaultLayout/HeaderRight.tsx b/novalon-manage-web/src/layouts/DefaultLayout/HeaderRight.tsx new file mode 100644 index 0000000..43470ad --- /dev/null +++ b/novalon-manage-web/src/layouts/DefaultLayout/HeaderRight.tsx @@ -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: , + label: '个人中心', + }, + { + key: 'password', + icon: , + label: '修改密码', + }, + { type: 'divider' }, + { + key: 'logout', + icon: , + label: '退出登录', + danger: true, + }, + ] + + const handleMenuClick: MenuProps['onClick'] = ({ key }) => { + switch (key) { + case 'profile': + break + case 'password': + break + case 'logout': + logout() + navigate('/login') + break + } + } + + return ( + + + } /> + {username || '用户'} + + + ) +} diff --git a/novalon-manage-web/src/layouts/DefaultLayout/SideMenu.tsx b/novalon-manage-web/src/layouts/DefaultLayout/SideMenu.tsx new file mode 100644 index 0000000..60b7d3e --- /dev/null +++ b/novalon-manage-web/src/layouts/DefaultLayout/SideMenu.tsx @@ -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['items'][number] + +const iconMap: Record = { + dashboard: , + user: , + users: , + role: , + roles: , + menu: , + menus: , + config: , + dict: , + file: , + files: , + notice: , + loginlog: , + oplog: , + exceptionlog: , +} + +const pathMap: Record = { + 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 ( + + ) +} + +function getOpenKey(pathname: string): string { + const segments = pathname.split('/').filter(Boolean) + return segments.length > 1 ? `/${segments[0]}` : pathname +} diff --git a/novalon-manage-web/src/layouts/DefaultLayout/index.tsx b/novalon-manage-web/src/layouts/DefaultLayout/index.tsx new file mode 100644 index 0000000..9289cc8 --- /dev/null +++ b/novalon-manage-web/src/layouts/DefaultLayout/index.tsx @@ -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 ( + item.onClick?.()}>{dom}} + headerTitleRender={(logo, title) => ( + + {logo}{title} + + )} + avatarProps={{ + title: username || '用户', + render: () => , + }} + > + }> + + + + ) +} diff --git a/novalon-manage-web/src/router/routes.ts b/novalon-manage-web/src/router/routes.ts index a1ca388..e345bbc 100644 --- a/novalon-manage-web/src/router/routes.ts +++ b/novalon-manage-web/src/router/routes.ts @@ -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 }