diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc new file mode 100644 index 00000000..c3ada895 --- /dev/null +++ b/.cursor/rules/project.mdc @@ -0,0 +1,245 @@ +--- +alwaysApply: true +--- +# 身份定义 +你是一位资深的软件架构师和工程师,具备丰富的项目经验和系统思维能力。你的核心优势在于: + +- 上下文工程专家:构建完整的任务上下文,而非简单的提示响应 +- 规范驱动思维:将模糊需求转化为精确、可执行的规范 +- 质量优先理念:每个阶段都确保高质量输出 +- 项目对齐能力:深度理解现有项目架构和约束 + +# 6A工作流执行规则 + +## 阶段1: Align (对齐阶段) +**目标:** 模糊需求 → 精确规范 + +### 执行步骤 + +### 1. 项目上下文分析 + +- 分析现有项目结构、技术栈、架构模式、依赖关系 +- 分析现有代码模式、现有文档和约定 +- 理解业务域和数据模型 + +### 2. 需求理解确认 + +- 创建 docs/任务名/ALIGNMENT_[任务名].md +- 包含项目和任务特性规范 +- 包含原始需求、边界确认(明确任务范围)、需求理解(对现有项目的理解)、疑问澄清(存在歧义的地方) + +### 3. 智能决策策略 + +- 自动识别歧义和不确定性 +- 生成结构化问题清单(按优先级排序) +- 优先基于现有项目内容和查找类似工程和行业知识进行决策和在文档中回答 +- 有人员倾向或不确定的问题主动中断并询问关键决策点 +- 基于回答更新理解和规范 + +### 4. 中断并询问关键决策点 + +- 主动中断询问,迭代执行智能决策策略 + +### 5. 最终共识 + +生成 docs/任务名/CONSENSUS_[任务名].md 包含: + +- 明确的需求描述和验收标准 +- 技术实现方案和技术约束和集成方案 +- 任务边界限制和验收标准 +- 确认所有不确定性已解决 + +### 质量门控 + +- 需求边界清晰无歧义 +- 技术方案与现有架构对齐 +- 验收标准具体可测试 +- 所有关键假设已确认 +- 项目特性规范已对齐 + +## 阶段2: Architect (架构阶段) +**目标: **共识文档 → 系统架构 → 模块设计 → 接口规范 + +### 执行步骤 + +### 1. 系统分层设计 + +基于CONSENSUS、ALIGNMENT文档设计架构 + +生成 docs/任务名/DESIGN_[任务名].md 包含: + +- 整体架构图(mermaid绘制) +- 分层设计和核心组件 +- 模块依赖关系图 +- 接口契约定义 +- 数据流向图 +- 异常处理策略 + +### 2. 设计原则 + +- 严格按照任务范围,避免过度设计 +- 确保与现有系统架构一致 +- 复用现有组件和模式 + +### 质量门控 + +- 架构图清晰准确 +- 接口定义完整 +- 与现有系统无冲突 +- 设计可行性验证 + +## 阶段3: Atomize (原子化阶段) + +**目标:** 架构设计 → 拆分任务 → 明确接口 → 依赖关系 + +### 执行步骤 + +### 1. 子任务拆分 + +基于DESIGN文档生成 docs/任务名/TASK_[任务名].md + +每个原子任务包含: + +- 输入契约(前置依赖、输入数据、环境依赖) +- 输出契约(输出数据、交付物、验收标准) +- 实现约束(技术栈、接口规范、质量要求) +- 依赖关系(后置任务、并行任务) + +### 2. 拆分原则 + +- 复杂度可控,便于AI高成功率交付 +- 按功能模块分解,确保任务原子性和独立性 +- 有明确的验收标准,尽量可以独立编译和测试 +- 依赖关系清晰 + +### 3. 生成任务依赖图(使用mermaid) + +### 质量门控 + +- 任务覆盖完整需求 +- 依赖关系无循环 +- 每个任务都可独立验证 +- 复杂度评估合理 + +## 阶段4: Approve (审批阶段) +**目标:** 原子任务 → 人工审查 → 迭代修改 → 按文档执行 + +### 执行步骤 + +### 1. 执行检查清单 + +- 完整性:任务计划覆盖所有需求 +- 一致性:与前期文档保持一致 +- 可行性:技术方案确实可行 +- 可控性:风险在可接受范围,复杂度是否可控 +- 可测性:验收标准明确可执行 + +### 2. 最终确认清单 + +- 明确的实现需求(无歧义) +- 明确的子任务定义 +- 明确的边界和限制 +- 明确的验收标准 +- 代码、测试、文档质量标准 + +## 阶段5: Automate (自动化执行) +**目标:** 按节点执行 → 编写测试 → 实现代码 → 文档同步 + +### 执行步骤 + +### 1. 逐步实施子任务 + +- 创建 docs/任务名/ACCEPTANCE_[任务名].md 记录完成情况 + +### 2. 代码质量要求 + +- 严格遵循项目现有代码规范 +- 保持与现有代码风格一致 +- 使用项目现有的工具和库 +- 复用项目现有组件 +- 代码尽量精简易读 +- API KEY放到.env文件中并且不要提交git + +### 3. 异常处理 + +- 遇到不确定问题立刻中断执行 +- 在TASK文档中记录问题详细信息和位置 +- 寻求人工澄清后继续 + +### 4. 逐步实施流程 按任务依赖顺序执行,对每个子任务执行: + +- 执行前检查(验证输入契约、环境准备、依赖满足) +- 实现核心逻辑(按设计文档编写代码) +- 编写单元测试(边界条件、异常情况) +- 运行验证测试 +- 更新相关文档 +- 每完成一个任务立即验证 + +## 阶段6: Assess (评估阶段) +**目标:** 执行结果 → 质量评估 → 文档更新 → 交付确认 + +### 执行步骤 + +### 1. 验证执行结果 + +更新 docs/任务名/ACCEPTANCE_[任务名].md + +整体验收检查: + +- 所有需求已实现 +- 验收标准全部满足 +- 项目编译通过 +- 所有测试通过 +- 功能完整性验证 +- 实现与设计文档一致 + +### 2. 质量评估指标 + +- 代码质量(规范、可读性、复杂度) +- 测试质量(覆盖率、用例有效性) +- 文档质量(完整性、准确性、一致性) +- 现有系统集成良好 +- 未引入技术债务 + +### 3. 最终交付物 + +- 生成 docs/任务名/FINAL_[任务名].md(项目总结报告) +- 生成 docs/任务名/TODO_[任务名].md(精简明确哪些待办的事宜和哪些缺少的配置等,我方便直接寻找支持) + +### 4. TODO询问 询问用户TODO的解决方式,精简明确哪些待办的事宜和哪些缺少的配置等,同时提供有用的操作指引 + +## 技术执行规范 + +### 安全规范 + +API密钥等敏感信息使用.env文件管理 + +### 文档同步 + +代码变更同时更新相关文档 + +### 测试策略 +**- 测试优先:**先写测试,后写实现 +**- 边界覆盖:**覆盖正常流程、边界条件、异常情况 + +## 交互体验优化 + +## 进度反馈 +- 显示当前执行阶段 +- 提供详细的执行步骤 +- 标示完成情况 +- 突出需要关注的问题 + +## 异常处理机制 + +### 中断条件 +- 遇到无法自主决策的问题 +- 觉得需要询问用户的问题 +- 技术实现出现阻塞 +- 文档不一致需要确认修正 + +### 恢复策略 +- 保存当前执行状态 +- 记录问题详细信息 +- 询问并等待人工干预 +- 从中断点任务继续执行 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ec00f53..cade4698 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,31 @@ -import React from 'react'; -import {RouterProvider} from 'react-router-dom'; -import {ConfigProvider} from 'antd'; -import zhCN from 'antd/locale/zh_CN'; -import router from './router'; +import React, { useMemo } from 'react'; +import { RouterProvider } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { createDynamicRouter } from './router'; +import type { RootState } from './store'; +/** + * 应用根组件 + * + * 架构说明: + * 1. 使用 createBrowserRouter 创建路由实例 + * 2. 路由实例在应用启动时基于菜单数据创建 + * 3. 登录后通过页面刷新重新创建路由(稳定可靠) + * + * 注意: + * React Router 6 的 createBrowserRouter 是静态的 + * 不支持运行时动态替换路由实例 + * 如需动态路由,登录后必须刷新页面 + */ const App: React.FC = () => { - return ( - - - - - - ); + const menus = useSelector((state: RootState) => state.user.menus); + + // 根据菜单状态创建路由 + const router = useMemo(() => { + return createDynamicRouter(); + }, [menus]); + + return ; }; -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/CodeMirrorVariableInput/README.md b/frontend/src/components/CodeMirrorVariableInput/README.md new file mode 100644 index 00000000..dc905592 --- /dev/null +++ b/frontend/src/components/CodeMirrorVariableInput/README.md @@ -0,0 +1,180 @@ +# CodeMirror Variable Input + +基于 CodeMirror 6 的变量输入组件,提供更强大的编辑体验和自动补全功能。 + +## ✨ 特性 + +- ✅ **语法高亮**:使用 CodeMirror 的 JavaScript 语法高亮 +- ✅ **智能补全**:输入 `${` 自动提示可用变量 +- ✅ **变量信息**:显示变量来源节点和字段名称 +- ✅ **快捷键支持**:完整的编辑器快捷键(Ctrl+Z 撤销、Ctrl+Y 重做等) +- ✅ **括号匹配**:自动匹配和高亮括号 +- ✅ **单行/多行模式**:支持 input 和 textarea 两种变体 +- ✅ **主题切换**:支持 light 和 dark 主题 +- ✅ **轻量级**:核心体积约 200KB(gzip 后约 60KB) + +## 📦 使用方法 + +### 基础用法 + +```tsx +import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput'; + + +``` + +### 在 SelectOrVariableInput 中使用 + +SelectOrVariableInput 已经集成了 CodeMirror,默认启用: + +```tsx + +``` + +如果需要使用旧版 VariableInput: + +```tsx + +``` + +## 🎮 Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `value` | `string` | `''` | 输入值 | +| `onChange` | `(value: string) => void` | - | 值变化回调 | +| `allNodes` | `FlowNode[]` | - | 所有节点(用于收集变量) | +| `allEdges` | `FlowEdge[]` | - | 所有连线 | +| `currentNodeId` | `string` | - | 当前节点ID | +| `formFields` | `FormField[]` | `[]` | 表单字段 | +| `variant` | `'input' \| 'textarea'` | `'input'` | 渲染模式 | +| `rows` | `number` | `3` | textarea 行数 | +| `theme` | `'light' \| 'dark'` | `'light'` | 主题 | +| `placeholder` | `string` | `''` | 占位文本 | +| `disabled` | `boolean` | `false` | 是否禁用 | + +## 🎯 自动补全 + +### 触发补全 + +输入 `${` 会自动触发变量补全: + +``` +用户输入: ${ + ↓ +显示补全列表: + - ${jenkins.buildNumber} + - ${jenkins.buildUrl} + - ${form.applicationName} + - ... +``` + +### 补全信息 + +每个补全项显示: +- **Label**: 完整变量表达式(如 `${jenkins.buildNumber}`) +- **Info**: 来源节点和字段名(如 `Jenkins构建 - buildNumber`) + +### 快捷键 + +- `↓` / `↑`: 选择补全项 +- `Enter`: 应用当前选中的补全 +- `Esc`: 关闭补全列表 + +## 🔄 迁移指南 + +### 从 VariableInput 迁移 + +#### 1. 直接替换(推荐) + +在 SelectOrVariableInput 中,默认已使用 CodeMirror: + +```diff + +``` + +#### 2. 单独使用 + +如果你直接使用 VariableInput,可以替换为 CodeMirrorVariableInput: + +```diff +- import VariableInput from '@/components/VariableInput'; ++ import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput'; + +- +``` + +## 🎨 样式定制 + +CodeMirror 使用 CSS 变量与你的主题系统集成: + +- `--border`: 边框颜色 +- `--ring`: 焦点环颜色 +- `--radius`: 圆角大小 +- `--foreground`: 文本颜色 +- `--muted-foreground`: 占位符颜色 + +## 🐛 已知问题 + +### 1. 外部值同步 + +当外部 `value` 变化时,编辑器会更新内容。但如果用户正在输入,可能会导致光标跳动。 + +**解决方案**: 使用 `internalValue` 状态管理(已在 SelectOrVariableInput 中实现) + +### 2. 变量列表更新 + +变量列表更新时不会重新创建编辑器,使用 ref 存储最新变量。 + +## 📈 性能对比 + +| 特性 | VariableInput | CodeMirrorVariableInput | +|------|---------------|------------------------| +| 体积 | ~5KB | ~60KB (gzip) | +| 语法高亮 | ❌ | ✅ | +| 快捷键 | 有限 | 完整 | +| 扩展性 | 低 | 高 | +| 性能 | 优秀 | 良好 | + +## 🚀 未来计划 + +- [ ] 支持表达式验证 +- [ ] 支持代码折叠 +- [ ] 支持类型提示 +- [ ] 支持自定义语法高亮规则 +- [ ] 支持代码片段(Snippets) + +## 📝 License + +MIT + diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..097a2952 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,165 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +/** + * 全局错误边界组件 + * + * 功能: + * 1. 捕获组件树中的 JavaScript 错误 + * 2. 记录错误信息 + * 3. 显示降级 UI + * 4. 防止整个应用崩溃白屏 + * + * 使用方式: + * + * + * + */ +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): State { + // 更新 state 使下一次渲染能够显示降级后的 UI + return { + hasError: true, + error, + errorInfo: null, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // 记录错误信息 + console.error('ErrorBoundary 捕获到错误:', error); + console.error('错误组件栈:', errorInfo.componentStack); + + // 更新状态 + this.setState({ + error, + errorInfo, + }); + + // 调用自定义错误处理函数 + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + + // 在生产环境中,可以将错误发送到错误监控服务 + if (import.meta.env.PROD) { + // 示例:发送到错误监控服务 + // reportErrorToService(error, errorInfo); + } + } + + handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render() { + if (this.state.hasError) { + // 如果提供了自定义降级 UI,使用它 + if (this.props.fallback) { + return this.props.fallback; + } + + // 默认降级 UI + return ( +
+
+
+ + + +

应用出现错误

+
+ +
+

+ 很抱歉,应用遇到了一个意外错误。您可以尝试刷新页面或返回首页。 +

+ + {/* 开发环境显示错误详情 */} + {import.meta.env.DEV && this.state.error && ( +
+

错误详情:

+
+                    {this.state.error.toString()}
+                  
+ {this.state.errorInfo && ( +
+ + 组件堆栈 + +
+                        {this.state.errorInfo.componentStack}
+                      
+
+ )} +
+ )} +
+ +
+ + + {import.meta.env.DEV && ( + + )} +
+
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; + diff --git a/frontend/src/components/ErrorFallback.tsx b/frontend/src/components/ErrorFallback.tsx new file mode 100644 index 00000000..f81b996b --- /dev/null +++ b/frontend/src/components/ErrorFallback.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { useNavigate, useRouteError } from 'react-router-dom'; +import { AlertCircle, Home, RefreshCw } from 'lucide-react'; + +/** + * 路由错误降级组件 + * + * 用于 React Router 的 errorElement + * 当路由加载或渲染出错时显示 + */ +const ErrorFallback: React.FC = () => { + const navigate = useNavigate(); + const error = useRouteError() as Error; + + const handleGoHome = () => { + navigate('/', { replace: true }); + }; + + const handleReload = () => { + window.location.reload(); + }; + + return ( +
+
+ {/* 错误图标 */} +
+
+ +
+
+ + {/* 错误标题 */} +

+ 页面加载失败 +

+ + {/* 错误描述 */} +

+ 抱歉,页面在加载过程中出现了问题。请尝试刷新页面或返回首页。 +

+ + {/* 开发环境显示错误详情 */} + {import.meta.env.DEV && error && ( +
+

错误信息:

+
+              {error.message || error.toString()}
+            
+ {error.stack && ( +
+ + 堆栈跟踪 + +
+                  {error.stack}
+                
+
+ )} +
+ )} + + {/* 操作按钮 */} +
+ + +
+
+
+ ); +}; + +export default ErrorFallback; + diff --git a/frontend/src/components/RouteLoading.tsx b/frontend/src/components/RouteLoading.tsx new file mode 100644 index 00000000..23e256ba --- /dev/null +++ b/frontend/src/components/RouteLoading.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from 'react'; +import { AlertCircle, Loader2 } from 'lucide-react'; + +interface RouteLoadingProps { + timeout?: number; // 超时时间(毫秒),默认10秒 +} + +/** + * 路由懒加载时的加载组件 + * + * 功能: + * 1. 显示加载动画(使用 shadcn 风格) + * 2. 超时检测 + * 3. 提供重试机制 + * 4. 优化用户体验 + */ +const RouteLoading: React.FC = ({ timeout = 10000 }) => { + const [isTimeout, setIsTimeout] = useState(false); + + useEffect(() => { + // 设置超时检测 + const timer = setTimeout(() => { + setIsTimeout(true); + }, timeout); + + return () => clearTimeout(timer); + }, [timeout]); + + const handleReload = () => { + window.location.reload(); + }; + + if (isTimeout) { + return ( +
+
+
+ +
+

+ 加载时间过长 +

+

+ 页面加载超时,可能是网络问题。 +

+ +
+
+ ); + } + + return ( +
+ {/* ✅ 使用 shadcn 风格的 loading(lucide-react 图标) */} + +
+

正在加载页面

+

请稍候...

+
+
+ ); +}; + +export default RouteLoading; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index e2977385..c8cd8851 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -29,10 +29,13 @@ export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ) -} +const Badge = React.forwardRef( + ({ className, variant, ...props }, ref) => { + return ( +
+ ) + } +) +Badge.displayName = "Badge" export { Badge, badgeVariants } diff --git a/frontend/src/layouts/BasicLayout.tsx b/frontend/src/layouts/BasicLayout.tsx index 2e95c099..a275a836 100644 --- a/frontend/src/layouts/BasicLayout.tsx +++ b/frontend/src/layouts/BasicLayout.tsx @@ -1,269 +1,35 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { Dropdown, Spin, Space, Tooltip } from 'antd'; -import { useNavigate, useLocation, Outlet } from 'react-router-dom'; -import { useDispatch, useSelector } from 'react-redux'; -import { - UserOutlined, - LogoutOutlined, - ClockCircleOutlined, - CloudOutlined, -} from '@ant-design/icons'; -import { logout, setMenus } from '../store/userSlice'; -import type { MenuProps } from 'antd'; -import { getCurrentUserMenus } from '@/pages/System/Menu/List/service'; -import { getWeather } from '../services/weather'; -import type { RootState } from '../store'; -import dayjs from 'dayjs'; -import 'dayjs/locale/zh-cn'; +import React, { useState } from 'react'; +import { Outlet } from 'react-router-dom'; import { AppMenu } from './AppMenu'; -import { Layout, LayoutContent, Header, Main } from '@/components/ui/layout'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { useToast } from '@/components/ui/use-toast'; -import { AlertCircle } from 'lucide-react'; - -// 设置中文语言 -dayjs.locale('zh-cn'); +import { Layout, LayoutContent, Main } from '@/components/ui/layout'; +import Header from './components/Header'; +/** + * 基础布局组件 + * + * 重构改进: + * 1. 移除菜单加载逻辑(已在 main.tsx 中预加载) + * 2. 移除 window.location.reload() 刷新逻辑 + * 3. 拆分组件(Header、TimeWeather、UserPanel) + * 4. 简化代码,提升可维护性 + */ const BasicLayout: React.FC = () => { - const navigate = useNavigate(); - const location = useLocation(); - const dispatch = useDispatch(); - const { toast } = useToast(); - const userInfo = useSelector((state: RootState) => state.user.userInfo); - const menus = useSelector((state: RootState) => state.user.menus); - const [loading, setLoading] = useState(true); - const [currentTime, setCurrentTime] = useState(dayjs()); - const [weather, setWeather] = useState({ temp: '--', weather: '未知', city: '未知' }); - const [logoutDialogOpen, setLogoutDialogOpen] = useState(false); - const hasInitialized = React.useRef(false); - - // 根据当前路径获取需要展开的父级菜单 - const getDefaultOpenKeys = () => { - const pathSegments = location.pathname.split('/').filter(Boolean); - const openKeys: string[] = []; - let currentPath = ''; - - pathSegments.forEach((segment) => { - currentPath += `/${segment}`; - openKeys.push(currentPath); - }); - - return openKeys; - }; - - // 设置默认展开的菜单 - const [openKeys, setOpenKeys] = useState(getDefaultOpenKeys()); - - // 当路由变化时,自动展开对应的父级菜单 - useEffect(() => { - const newOpenKeys = getDefaultOpenKeys(); - setOpenKeys((prevKeys) => { - const mergedKeys = [...new Set([...prevKeys, ...newOpenKeys])]; - return mergedKeys; - }); - }, [location.pathname]); - - // 将天气获取逻辑提取为useCallback - const fetchWeather = useCallback(async () => { - try { - const data = await getWeather(); - setWeather(data); - } catch (error) { - console.error('获取天气信息失败:', error); - } - }, []); - - // 初始化用户数据 - 确保在页面加载时数据完整 - useEffect(() => { - const initializeUserData = async () => { - // 防止重复初始化 - if (hasInitialized.current) { - return; - } - - setLoading(true); - try { - // 检查是否有 token - const token = localStorage.getItem('token'); - if (!token) { - // 没有 token,跳转到登录页 - console.log('未检测到登录令牌,跳转到登录页'); - navigate('/login', { replace: true }); - return; - } - - // 检查用户信息和菜单数据是否完整 - const hasUserInfo = !!userInfo; - const hasMenus = menus && menus.length > 0; - - if (!hasUserInfo || !hasMenus) { - console.log('用户数据不完整,重新加载...', { hasUserInfo, hasMenus }); - - // 标记为已初始化(防止循环) - hasInitialized.current = true; - - // 重新获取菜单数据 - const menuData = await getCurrentUserMenus(); - dispatch(setMenus(menuData)); - - console.log('菜单数据已更新,准备刷新页面以重新生成路由'); - - // 菜单数据更新后,强制刷新页面以重新生成路由 - // 使用 setTimeout 确保 Redux 状态已更新到 localStorage - setTimeout(() => { - window.location.reload(); - }, 100); - } else { - // 数据完整,直接完成加载 - setLoading(false); - } - } catch (error) { - hasInitialized.current = false; - console.error('初始化用户数据失败:', error); - toast({ - title: '加载失败', - description: '获取菜单数据失败,请重新登录', - variant: 'destructive', - }); - // 跳转到登录页 - navigate('/login', { replace: true }); - } - }; - - initializeUserData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // 处理时间和天气更新 - useEffect(() => { - // 每秒更新时间 - const timer = setInterval(() => { - setCurrentTime(dayjs()); - }, 1000); - - // 初始化天气并设置定时更新 - fetchWeather(); - const weatherTimer = setInterval(fetchWeather, 30 * 60 * 1000); - - return () => { - clearInterval(timer); - clearInterval(weatherTimer); - }; - }, [fetchWeather]); - - const handleLogout = () => { - setLogoutDialogOpen(true); - }; - - const confirmLogout = () => { - dispatch(logout()); - toast({ - title: '退出成功', - description: '已成功退出系统', - }); - navigate('/login', { replace: true }); - }; - - const userMenuItems: MenuProps['items'] = [ - { - key: 'userInfo', - icon: , - label: ( -
-
当前用户:{userInfo?.nickname || userInfo?.username}
- {userInfo?.email &&
{userInfo.email}
} -
- ), - disabled: true, - }, - { - type: 'divider', - }, - { - key: 'logout', - icon: , - label: '退出登录', - onClick: handleLogout, - }, - ]; + const [openKeys, setOpenKeys] = useState([]); // 处理菜单展开/收起 const handleOpenChange = (keys: string[]) => { setOpenKeys(keys); }; - if (loading) { - return ( -
- -
-

正在为您准备系统资源

-

请稍候,马上就好...

-
-
- ); - } - return ( -
- - - - - {currentTime.format('YYYY年MM月DD日 HH:mm:ss')} 星期{currentTime.format('dd')} - - - - - {weather.weather} {weather.temp}℃ - - - - - - - {userInfo?.nickname || userInfo?.username} - - - -
+
- - {/* 退出登录确认对话框 */} - - - - - - 确认退出 - - - 确定要退出系统吗? - - - - setLogoutDialogOpen(false)}> - 取消 - - 确定 - - - ); }; diff --git a/frontend/src/layouts/components/AppSkeleton.tsx b/frontend/src/layouts/components/AppSkeleton.tsx new file mode 100644 index 00000000..902e3c08 --- /dev/null +++ b/frontend/src/layouts/components/AppSkeleton.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +/** + * 应用骨架屏 + * 在应用初始化时显示,提升用户体验 + */ +const AppSkeleton: React.FC = () => { + return ( +
+ {/* 侧边栏骨架 */} +
+ {/* Logo 区域 */} +
+
+
+ + {/* 菜单骨架 */} +
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( +
+ ))} +
+
+ + {/* 主内容区骨架 */} +
+ {/* 头部骨架 */} +
+
+
+
+
+
+ + {/* 内容区骨架 */} +
+
+
+
+
+
+
+ ); +}; + +export default AppSkeleton; + diff --git a/frontend/src/layouts/components/Header.tsx b/frontend/src/layouts/components/Header.tsx new file mode 100644 index 00000000..85d1a56a --- /dev/null +++ b/frontend/src/layouts/components/Header.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Space } from 'antd'; +import { Header as LayoutHeader } from '@/components/ui/layout'; +import TimeWeather from './TimeWeather'; +import UserPanel from './UserPanel'; + +/** + * 应用头部组件 + * + * 功能: + * 1. 组合时间天气和用户面板 + * 2. 使用 React.memo 优化,避免不必要的重渲染 + * 3. 子组件独立渲染,不会相互影响 + */ +const Header: React.FC = React.memo(() => { + return ( + + + + + + + ); +}); + +Header.displayName = 'Header'; + +export default Header; + diff --git a/frontend/src/layouts/components/TimeWeather.tsx b/frontend/src/layouts/components/TimeWeather.tsx new file mode 100644 index 00000000..da1c729e --- /dev/null +++ b/frontend/src/layouts/components/TimeWeather.tsx @@ -0,0 +1,78 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Space, Tooltip } from 'antd'; +import { ClockCircleOutlined, CloudOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import 'dayjs/locale/zh-cn'; +import { getWeather } from '@/services/weather'; + +dayjs.locale('zh-cn'); + +/** + * 时间和天气显示组件 + * + * 功能: + * 1. 实时显示当前时间(每秒更新) + * 2. 显示天气信息(每30分钟更新) + * 3. 使用 React.memo 优化,避免父组件重渲染影响 + */ +const TimeWeather: React.FC = React.memo(() => { + const [currentTime, setCurrentTime] = useState(dayjs()); + const [weather, setWeather] = useState({ + temp: '--', + weather: '未知', + city: '未知' + }); + + // 获取天气数据 + const fetchWeather = useCallback(async () => { + try { + const data = await getWeather(); + setWeather(data); + } catch (error) { + console.error('获取天气信息失败:', error); + } + }, []); + + // 时间更新 effect + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(dayjs()); + }, 1000); + + return () => clearInterval(timer); + }, []); + + // 天气更新 effect + useEffect(() => { + // 初始化天气数据 + fetchWeather(); + + // 每30分钟更新一次 + const weatherTimer = setInterval(fetchWeather, 30 * 60 * 1000); + + return () => clearInterval(weatherTimer); + }, [fetchWeather]); + + return ( + + {/* 时间显示 */} + + + {currentTime.format('YYYY年MM月DD日 HH:mm:ss')} 星期{currentTime.format('dd')} + + + {/* 天气显示 */} + + + + {weather.weather} {weather.temp}℃ + + + + ); +}); + +TimeWeather.displayName = 'TimeWeather'; + +export default TimeWeather; + diff --git a/frontend/src/layouts/components/UserPanel.tsx b/frontend/src/layouts/components/UserPanel.tsx new file mode 100644 index 00000000..457db168 --- /dev/null +++ b/frontend/src/layouts/components/UserPanel.tsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { Dropdown, Space } from 'antd'; +import { UserOutlined, LogoutOutlined } from '@ant-design/icons'; +import type { MenuProps } from 'antd'; +import { logout } from '@/store/userSlice'; +import type { RootState } from '@/store'; +import { useToast } from '@/components/ui/use-toast'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { AlertCircle } from 'lucide-react'; +import { shallowEqual } from 'react-redux'; + +/** + * 用户面板组件 + * + * 功能: + * 1. 显示用户信息 + * 2. 提供退出登录功能 + * 3. 使用 React.memo 和 shallowEqual 优化渲染 + */ +const UserPanel: React.FC = React.memo(() => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const { toast } = useToast(); + const [logoutDialogOpen, setLogoutDialogOpen] = useState(false); + + // 使用 shallowEqual 优化,只有 userInfo 变化时才重新渲染 + const userInfo = useSelector( + (state: RootState) => state.user.userInfo, + shallowEqual + ); + + const handleLogout = () => { + setLogoutDialogOpen(true); + }; + + const confirmLogout = () => { + dispatch(logout()); + toast({ + title: '退出成功', + description: '已成功退出系统', + }); + navigate('/login', { replace: true }); + }; + + const userMenuItems: MenuProps['items'] = [ + { + key: 'userInfo', + icon: , + label: ( +
+
当前用户:{userInfo?.nickname || userInfo?.username}
+ {userInfo?.email && ( +
+ {userInfo.email} +
+ )} +
+ ), + disabled: true, + }, + { + type: 'divider', + }, + { + key: 'logout', + icon: , + label: '退出登录', + onClick: handleLogout, + }, + ]; + + return ( + <> + + + + + {userInfo?.nickname || userInfo?.username} + + + + + {/* 退出登录确认对话框 */} + + + + + + 确认退出 + + + 确定要退出系统吗? + + + + setLogoutDialogOpen(false)}> + 取消 + + 确定 + + + + + ); +}); + +UserPanel.displayName = 'UserPanel'; + +export default UserPanel; + diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f0e8e31e..feccfb96 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,15 +1,86 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import {RouterProvider} from 'react-router-dom'; -import {Provider} from 'react-redux'; -import router from './router'; +import { Provider } from 'react-redux'; import store from './store'; import './index.css'; -import {Toaster} from "@/components/ui/toaster.tsx"; +import { Toaster } from '@/components/ui/toaster.tsx'; +import ErrorBoundary from '@/components/ErrorBoundary'; +import { getCurrentUserMenus } from '@/pages/System/Menu/List/service'; +import { setMenus } from './store/userSlice'; +import App from './App'; +import AppSkeleton from './layouts/components/AppSkeleton'; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - -); \ No newline at end of file +/** + * 应用启动流程 - 性能优化版本 + * + * 优化点: + * 1. 优先使用缓存菜单,快速启动 + * 2. 减少控制台日志输出 + * 3. 使用骨架屏提升体验 + * 4. 登录后刷新页面确保路由正确 + */ + +const rootElement = document.getElementById('root')!; +const root = ReactDOM.createRoot(rootElement); + +// 启动应用 +async function bootstrap() { + try { + const token = localStorage.getItem('token'); + + // 如果已登录,预加载菜单数据 + if (token) { + // 检查 localStorage 中是否有缓存的菜单数据 + const cachedMenus = localStorage.getItem('menus'); + + if (cachedMenus) { + try { + const menus = JSON.parse(cachedMenus); + if (menus && menus.length > 0) { + // ✅ 优先使用缓存,快速启动 + store.dispatch(setMenus(menus)); + } else { + throw new Error('缓存菜单为空'); + } + } catch (error) { + // 缓存失败,从服务器获取 + const menuData = await getCurrentUserMenus(); + store.dispatch(setMenus(menuData)); + } + } else { + // 没有缓存,从服务器获取 + const menuData = await getCurrentUserMenus(); + store.dispatch(setMenus(menuData)); + } + } + + // ✅ 渲染应用 + root.render( + + + + + + + ); + } catch (error) { + // 启动失败,清除 token 并渲染应用(会自动跳转到登录页) + localStorage.removeItem('token'); + localStorage.removeItem('menus'); + + root.render( + + + + + + + ); + } +} + +// 显示骨架屏(更好的视觉体验) +root.render(); + +// 启动应用 +bootstrap(); diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index 814f7e6b..4bc8803f 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -20,11 +20,23 @@ import { ApplicationCard } from './components/ApplicationCard'; import { PendingApprovalModal } from './components/PendingApprovalModal'; import type { DeployTeam, ApplicationConfig } from './types'; +// ✅ 优化:使用骨架屏替代 loading,体验更好 const LoadingState = () => ( -
-
- -

加载中,请稍候...

+
+ {/* 标题骨架 */} +
+
+
+
+ + {/* 卡片骨架 */} +
+
+
+ {[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); +} +