-
-
加载中,请稍候...
+
+ {/* 标题骨架 */}
+
+
+ {/* 卡片骨架 */}
+
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => (
+
+ ))}
+
);
@@ -153,8 +165,18 @@ const Dashboard: React.FC = () => {
// 首次加载数据
useEffect(() => {
- loadData(true);
- loadPendingApprovalCount();
+ // ✅ 优化:异步加载数据,不阻塞页面渲染
+ // 先渲染骨架,然后后台加载数据
+ const initData = async () => {
+ await Promise.all([
+ loadData(false), // 不显示 loading,直接显示内容
+ loadPendingApprovalCount()
+ ]);
+ setLoading(false);
+ };
+
+ initData();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 定时刷新数据(每5秒)
diff --git a/frontend/src/pages/Error/403.tsx b/frontend/src/pages/Error/403.tsx
new file mode 100644
index 00000000..1c1ec640
--- /dev/null
+++ b/frontend/src/pages/Error/403.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ShieldAlert, Home, ArrowLeft } from 'lucide-react';
+
+/**
+ * 403 无权限页面
+ * 当用户尝试访问没有权限的页面时显示
+ */
+const Forbidden: React.FC = () => {
+ const navigate = useNavigate();
+
+ const handleGoHome = () => {
+ navigate('/dashboard', { replace: true });
+ };
+
+ const handleGoBack = () => {
+ navigate(-1);
+ };
+
+ return (
+
+
+ {/* 403 图标 */}
+
+
+ {/* 403 标题 */}
+
+
403
+
+ 访问被拒绝
+
+
+
+ {/* 描述 */}
+
+ 抱歉,您没有访问此页面的权限。
+
+ 如需访问,请联系管理员申请相应权限。
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+ );
+};
+
+export default Forbidden;
+
diff --git a/frontend/src/pages/Error/404.tsx b/frontend/src/pages/Error/404.tsx
new file mode 100644
index 00000000..52707533
--- /dev/null
+++ b/frontend/src/pages/Error/404.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { FileQuestion, Home, ArrowLeft } from 'lucide-react';
+
+/**
+ * 404 页面不存在
+ * 当用户访问不存在的路由时显示
+ */
+const NotFound: React.FC = () => {
+ const navigate = useNavigate();
+
+ const handleGoHome = () => {
+ navigate('/dashboard', { replace: true });
+ };
+
+ const handleGoBack = () => {
+ navigate(-1);
+ };
+
+ return (
+
+
+ {/* 404 图标 */}
+
+
+ {/* 404 标题 */}
+
+
404
+
+ 页面不存在
+
+
+
+ {/* 描述 */}
+
+ 抱歉,您访问的页面不存在或已被移除。
+
+ 请检查URL是否正确,或返回首页。
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+ );
+};
+
+export default NotFound;
+
diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx
index 7e2bc9a6..f3f84227 100644
--- a/frontend/src/pages/Login/index.tsx
+++ b/frontend/src/pages/Login/index.tsx
@@ -84,7 +84,7 @@ const Login: React.FC = () => {
setLoading(true);
try {
// 1. 登录获取 token 和用户信息
- const loginData = await login(values);
+ const loginData = await login(values as { tenantId: string; username: string; password: string });
dispatch(setToken(loginData.token));
dispatch(
setUserInfo({
@@ -99,11 +99,18 @@ const Login: React.FC = () => {
// 2. 获取菜单数据
await loadMenuData();
+ // 3. 显示成功提示
toast({
title: '登录成功',
- description: '欢迎回来!',
+ description: '正在进入系统...',
});
- navigate('/');
+
+ // 4. 短暂延迟后跳转(让用户看到成功提示)
+ setTimeout(() => {
+ // ✅ 最稳定方案:刷新页面重新初始化
+ // 优点:100% 可靠,使用缓存菜单启动快
+ window.location.href = '/';
+ }, 300);
} catch (error) {
console.error('登录失败:', error);
toast({
diff --git a/frontend/src/router/ProtectedRoute.tsx b/frontend/src/router/ProtectedRoute.tsx
new file mode 100644
index 00000000..7177921c
--- /dev/null
+++ b/frontend/src/router/ProtectedRoute.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Navigate, useLocation } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+import type { RootState } from '@/store';
+
+interface ProtectedRouteProps {
+ children: React.ReactNode;
+ requiresAuth?: boolean;
+}
+
+/**
+ * 路由权限守卫组件
+ *
+ * 功能:
+ * 1. 检查用户登录状态
+ * 2. 未登录跳转到登录页
+ *
+ * 注意:
+ * - 路由已经是根据用户菜单动态生成的,所以不需要再次检查权限
+ * - 只需要检查登录状态即可
+ */
+const ProtectedRoute: React.FC
= ({
+ children,
+ requiresAuth = true
+}) => {
+ const location = useLocation();
+ const token = useSelector((state: RootState) => state.user.token);
+
+ // 检查是否需要认证
+ if (requiresAuth && !token) {
+ // 未登录,跳转到登录页,并记录原始路径用于登录后跳转
+ return ;
+ }
+
+ // 已登录或不需要认证,允许访问
+ return <>{children}>;
+};
+
+export default ProtectedRoute;
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx
index aaff7961..d4a32656 100644
--- a/frontend/src/router/index.tsx
+++ b/frontend/src/router/index.tsx
@@ -1,108 +1,139 @@
import { createBrowserRouter, Navigate, RouteObject } from 'react-router-dom';
import { Suspense, lazy } from 'react';
-import { Spin } from 'antd';
import Login from '../pages/Login';
import BasicLayout from '../layouts/BasicLayout';
import { getRouteComponent } from './routeMap';
import type { MenuResponse } from '@/pages/System/Menu/List/types';
import store from '../store';
+import ProtectedRoute from './ProtectedRoute';
+import RouteLoading from '@/components/RouteLoading';
+import ErrorFallback from '@/components/ErrorFallback';
+
+// 错误页面
+const Forbidden = lazy(() => import('@/pages/Error/403'));
+const NotFound = lazy(() => import('@/pages/Error/404'));
// 表单设计器测试页面(写死的路由)
const FormDesignerTest = lazy(() => import('../pages/FormDesigner'));
-// 加载组件
-const LoadingComponent = () => (
-
-
-
-);
-
/**
* 根据菜单数据动态生成路由配置
* @param menus 菜单列表
* @returns 路由配置数组
*/
const generateRoutes = (menus: MenuResponse[]): RouteObject[] => {
- const routes: RouteObject[] = [];
+ const routes: RouteObject[] = [];
- menus.forEach((menu) => {
- // ⚠️ 注意:不要跳过 hidden 的菜单,因为隐藏菜单也需要生成路由
- // 隐藏菜单只是不在侧边栏显示,但路由必须存在才能通过 navigate() 访问
+ menus.forEach((menu) => {
+ // ⚠️ 注意:不要跳过 hidden 的菜单,因为隐藏菜单也需要生成路由
+ // 隐藏菜单只是不在侧边栏显示,但路由必须存在才能通过 navigate() 访问
- // 如果有 component 且有 path,创建路由
- if (menu.component && menu.path) {
- const Component = getRouteComponent(menu.component);
- if (Component) {
- // 移除开头的 /
- const path = menu.path.replace(/^\//, '');
-
- routes.push({
- path,
- element: (
- }>
-
-
- ),
- });
- }
- }
+ // 如果有 component 且有 path,创建路由
+ if (menu.component && menu.path) {
+ // 对于隐藏菜单,抑制组件未找到的警告
+ const Component = getRouteComponent(menu.component, menu.hidden);
+ if (Component) {
+ // 移除开头的 /
+ const path = menu.path.replace(/^\//, '');
- // 递归处理子菜单
- if (menu.children && menu.children.length > 0) {
- const childRoutes = generateRoutes(menu.children);
- routes.push(...childRoutes);
- }
- });
+ routes.push({
+ path,
+ element: (
+
+ }>
+
+
+
+ ),
+ });
+ }
+ // 警告已在 getRouteComponent 内部处理
+ }
- return routes;
+ // 递归处理子菜单
+ if (menu.children && menu.children.length > 0) {
+ const childRoutes = generateRoutes(menu.children);
+ routes.push(...childRoutes);
+ }
+ });
+
+ return routes;
};
/**
* 创建路由配置
- * 从 Redux store 中获取菜单数据,动态生成路由
+ * 从 Redux store 中获取菜单数据,动态生成路由
*/
-const createDynamicRouter = () => {
- const state = store.getState();
- const menus = state.user.menus || [];
-
- // 动态生成路由
- const dynamicRoutes = generateRoutes(menus);
+export const createDynamicRouter = () => {
+ const state = store.getState();
+ const menus = state.user.menus || [];
- return createBrowserRouter([
+ // 动态生成路由
+ const dynamicRoutes = generateRoutes(menus);
+
+ return createBrowserRouter([
+ {
+ path: '/login',
+ element: ,
+ },
+ {
+ path: '/',
+ element: ,
+ errorElement: ,
+ children: [
{
- path: '/login',
- element:
+ path: '',
+ element: ,
},
+ // 写死的测试路由:表单设计器测试页面
{
- path: '/',
- element: ,
- children: [
- {
- path: '',
- element:
- },
- // 写死的测试路由:表单设计器测试页面
- {
- path: 'workflow/form-designer',
- element: (
- }>
-
-
- )
- },
- // 动态生成的路由
- ...dynamicRoutes,
- // 404 路由
- {
- path: '*',
- element:
- }
- ]
- }
- ]);
+ path: 'workflow/form-designer',
+ element: (
+
+ }>
+
+
+
+ ),
+ },
+ // 动态生成的路由
+ ...dynamicRoutes,
+ // 403 无权限页面
+ {
+ path: '403',
+ element: (
+ }>
+
+
+ ),
+ },
+ // 404 页面不存在
+ {
+ path: '404',
+ element: (
+ }>
+
+
+ ),
+ },
+ // 其他未匹配路由重定向到 404
+ {
+ path: '*',
+ element: ,
+ },
+ ],
+ },
+ ]);
};
-// 导出路由实例
-const router = createDynamicRouter();
+// 导出路由实例(初始化时创建一次)
+let router = createDynamicRouter();
-export default router;
\ No newline at end of file
+// 导出重新创建路由的函数
+export const recreateRouter = () => {
+ console.log('🔄 重新创建路由...');
+ router = createDynamicRouter();
+ return router;
+};
+
+export default router;
diff --git a/frontend/src/router/routeMap.ts b/frontend/src/router/routeMap.ts
index ad04b1c2..310e71b5 100644
--- a/frontend/src/router/routeMap.ts
+++ b/frontend/src/router/routeMap.ts
@@ -9,9 +9,13 @@ const modules = import.meta.glob<{ default: ComponentType }>('/src/pages/**
/**
* 根据 component 路径动态加载对应的组件
* @param componentPath 组件路径 (例如: 'Dashboard', 'Deploy/Application/List')
+ * @param suppressWarning 是否抑制警告(用于隐藏菜单等场景)
* @returns 懒加载的组件或 null
*/
-export const getRouteComponent = (componentPath: string | null | undefined): React.LazyExoticComponent> | null => {
+export const getRouteComponent = (
+ componentPath: string | null | undefined,
+ suppressWarning: boolean = false
+): React.LazyExoticComponent> | null => {
if (!componentPath) return null;
// 移除开头和结尾的斜杠
@@ -22,8 +26,10 @@ export const getRouteComponent = (componentPath: string | null | undefined): Rea
// 检查模块是否存在
if (!modules[modulePath]) {
- console.warn(`Route component not found: ${modulePath}`);
- console.warn('Available modules:', Object.keys(modules));
+ // 只在开发环境且未抑制警告时输出
+ if (import.meta.env.DEV && !suppressWarning) {
+ console.warn(`⚠️ 路由组件未找到: ${modulePath}`);
+ }
return null;
}
diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts
new file mode 100644
index 00000000..f9358ae6
--- /dev/null
+++ b/frontend/src/utils/permission.ts
@@ -0,0 +1,104 @@
+import type { MenuResponse } from '@/pages/System/Menu/List/types';
+import { MenuTypeEnum } from '@/pages/System/Menu/List/types';
+
+/**
+ * 递归检查用户是否有访问指定路径的权限
+ * @param path 路由路径
+ * @param menus 用户菜单列表
+ * @returns 是否有权限
+ */
+export function checkPermission(path: string, menus: MenuResponse[]): boolean {
+ if (!menus || menus.length === 0) {
+ // 如果菜单数据还未加载,默认允许访问(避免白屏)
+ // 实际权限会在菜单加载后重新验证
+ return true;
+ }
+
+ // 规范化路径:移除开头的斜杠,便于比较
+ const normalizedPath = path.replace(/^\//, '');
+
+ for (const menu of menus) {
+ // 只检查菜单类型(不检查目录和按钮)
+ if (menu.type === MenuTypeEnum.MENU && menu.path) {
+ const menuPath = menu.path.replace(/^\//, '');
+
+ // 精确匹配或前缀匹配(支持子路由)
+ if (menuPath === normalizedPath || normalizedPath.startsWith(menuPath + '/')) {
+ return true;
+ }
+ }
+
+ // 递归检查子菜单
+ if (menu.children && menu.children.length > 0) {
+ if (checkPermission(path, menu.children)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * 检查用户是否拥有任意一个路径的权限
+ * @param paths 路由路径数组
+ * @param menus 用户菜单列表
+ * @returns 是否有权限
+ */
+export function hasAnyPermission(paths: string[], menus: MenuResponse[]): boolean {
+ return paths.some(path => checkPermission(path, menus));
+}
+
+/**
+ * 获取用户可访问的所有路径列表(扁平化)
+ * @param menus 用户菜单列表
+ * @returns 可访问的路径数组
+ */
+export function getAccessiblePaths(menus: MenuResponse[]): string[] {
+ const paths: string[] = [];
+
+ function traverse(menuList: MenuResponse[]) {
+ for (const menu of menuList) {
+ if (menu.type === MenuTypeEnum.MENU && menu.path) {
+ paths.push(menu.path);
+ }
+
+ if (menu.children && menu.children.length > 0) {
+ traverse(menu.children);
+ }
+ }
+ }
+
+ traverse(menus);
+ return paths;
+}
+
+/**
+ * 检查特定权限标识
+ * @param permission 权限标识(如 'system:user:add')
+ * @param menus 用户菜单列表
+ * @returns 是否有权限
+ */
+export function checkPermissionCode(permission: string, menus: MenuResponse[]): boolean {
+ if (!permission || !menus || menus.length === 0) {
+ return false;
+ }
+
+ function traverse(menuList: MenuResponse[]): boolean {
+ for (const menu of menuList) {
+ if (menu.permission === permission) {
+ return true;
+ }
+
+ if (menu.children && menu.children.length > 0) {
+ if (traverse(menu.children)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ return traverse(menus);
+}
+