重构前端逻辑

This commit is contained in:
dengqichen 2025-11-06 10:56:17 +08:00
parent 6eb7983c99
commit c6558ee00d
21 changed files with 1583 additions and 364 deletions

245
.cursor/rules/project.mdc Normal file
View File

@ -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文件管理
### 文档同步
代码变更同时更新相关文档
### 测试策略
**- 测试优先:**先写测试,后写实现
**- 边界覆盖:**覆盖正常流程、边界条件、异常情况
## 交互体验优化
## 进度反馈
- 显示当前执行阶段
- 提供详细的执行步骤
- 标示完成情况
- 突出需要关注的问题
## 异常处理机制
### 中断条件
- 遇到无法自主决策的问题
- 觉得需要询问用户的问题
- 技术实现出现阻塞
- 文档不一致需要确认修正
### 恢复策略
- 保存当前执行状态
- 记录问题详细信息
- 询问并等待人工干预
- 从中断点任务继续执行

View File

@ -1,17 +1,31 @@
import React from 'react'; import React, { useMemo } from 'react';
import {RouterProvider} from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
import {ConfigProvider} from 'antd'; import { useSelector } from 'react-redux';
import zhCN from 'antd/locale/zh_CN'; import { createDynamicRouter } from './router';
import router from './router'; import type { RootState } from './store';
/**
*
*
*
* 1. 使 createBrowserRouter
* 2.
* 3.
*
*
* React Router 6 createBrowserRouter
*
*
*/
const App: React.FC = () => { const App: React.FC = () => {
return ( const menus = useSelector((state: RootState) => state.user.menus);
<React.Fragment>
<ConfigProvider locale={zhCN}> // 根据菜单状态创建路由
<RouterProvider router={router}/> const router = useMemo(() => {
</ConfigProvider> return createDynamicRouter();
</React.Fragment> }, [menus]);
);
return <RouterProvider router={router} />;
}; };
export default App; export default App;

View File

@ -0,0 +1,180 @@
# CodeMirror Variable Input
基于 CodeMirror 6 的变量输入组件,提供更强大的编辑体验和自动补全功能。
## ✨ 特性
- ✅ **语法高亮**:使用 CodeMirror 的 JavaScript 语法高亮
- ✅ **智能补全**:输入 `${` 自动提示可用变量
- ✅ **变量信息**:显示变量来源节点和字段名称
- ✅ **快捷键支持**完整的编辑器快捷键Ctrl+Z 撤销、Ctrl+Y 重做等)
- ✅ **括号匹配**:自动匹配和高亮括号
- ✅ **单行/多行模式**:支持 input 和 textarea 两种变体
- ✅ **主题切换**:支持 light 和 dark 主题
- ✅ **轻量级**:核心体积约 200KBgzip 后约 60KB
## 📦 使用方法
### 基础用法
```tsx
import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput';
<CodeMirrorVariableInput
value={value}
onChange={setValue}
allNodes={allNodes}
allEdges={allEdges}
currentNodeId={currentNodeId}
placeholder="输入变量或表达式"
/>
```
### 在 SelectOrVariableInput 中使用
SelectOrVariableInput 已经集成了 CodeMirror默认启用
```tsx
<SelectOrVariableInput
value={value}
onChange={onChange}
options={options}
allNodes={allNodes}
allEdges={allEdges}
currentNodeId={currentNodeId}
editor="codemirror" // 使用 CodeMirror默认
/>
```
如果需要使用旧版 VariableInput
```tsx
<SelectOrVariableInput
editor="legacy" // 使用旧版 VariableInput
// ... 其他 props
/>
```
## 🎮 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
<SelectOrVariableInput
value={value}
onChange={onChange}
// ... 其他 props
+ // editor="codemirror" 是默认值,无需显式指定
/>
```
#### 2. 单独使用
如果你直接使用 VariableInput可以替换为 CodeMirrorVariableInput
```diff
- import VariableInput from '@/components/VariableInput';
+ import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput';
- <VariableInput
+ <CodeMirrorVariableInput
value={value}
onChange={onChange}
allNodes={allNodes}
allEdges={allEdges}
currentNodeId={currentNodeId}
/>
```
## 🎨 样式定制
CodeMirror 使用 CSS 变量与你的主题系统集成:
- `--border`: 边框颜色
- `--ring`: 焦点环颜色
- `--radius`: 圆角大小
- `--foreground`: 文本颜色
- `--muted-foreground`: 占位符颜色
## 🐛 已知问题
### 1. 外部值同步
当外部 `value` 变化时,编辑器会更新内容。但如果用户正在输入,可能会导致光标跳动。
**解决方案**: 使用 `internalValue` 状态管理(已在 SelectOrVariableInput 中实现)
### 2. 变量列表更新
变量列表更新时不会重新创建编辑器,使用 ref 存储最新变量。
## 📈 性能对比
| 特性 | VariableInput | CodeMirrorVariableInput |
|------|---------------|------------------------|
| 体积 | ~5KB | ~60KB (gzip) |
| 语法高亮 | ❌ | ✅ |
| 快捷键 | 有限 | 完整 |
| 扩展性 | 低 | 高 |
| 性能 | 优秀 | 良好 |
## 🚀 未来计划
- [ ] 支持表达式验证
- [ ] 支持代码折叠
- [ ] 支持类型提示
- [ ] 支持自定义语法高亮规则
- [ ] 支持代码片段Snippets
## 📝 License
MIT

View File

@ -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.
*
* 使
* <ErrorBoundary>
* <App />
* </ErrorBoundary>
*/
class ErrorBoundary extends Component<Props, State> {
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 (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-slate-50 p-8">
<div className="max-w-2xl rounded-lg bg-white p-8 shadow-lg">
<div className="mb-6 flex items-center gap-3">
<svg
className="h-8 w-8 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h1 className="text-2xl font-bold text-slate-800"></h1>
</div>
<div className="mb-6">
<p className="mb-4 text-slate-600">
</p>
{/* 开发环境显示错误详情 */}
{import.meta.env.DEV && this.state.error && (
<div className="mt-4 rounded-md bg-red-50 p-4">
<p className="mb-2 font-semibold text-red-800"></p>
<pre className="overflow-auto text-sm text-red-700">
{this.state.error.toString()}
</pre>
{this.state.errorInfo && (
<details className="mt-3">
<summary className="cursor-pointer font-semibold text-red-800">
</summary>
<pre className="mt-2 overflow-auto text-xs text-red-600">
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
)}
</div>
<div className="flex gap-3">
<button
onClick={() => window.location.href = '/'}
className="rounded-md bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700"
>
</button>
<button
onClick={() => window.location.reload()}
className="rounded-md bg-slate-600 px-4 py-2 text-white transition-colors hover:bg-slate-700"
>
</button>
{import.meta.env.DEV && (
<button
onClick={this.handleReset}
className="rounded-md border border-slate-300 px-4 py-2 text-slate-700 transition-colors hover:bg-slate-100"
>
</button>
)}
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -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 (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-slate-50 p-8">
<div className="max-w-md text-center">
{/* 错误图标 */}
<div className="mb-6 flex justify-center">
<div className="rounded-full bg-red-100 p-6">
<AlertCircle className="h-16 w-16 text-red-500" />
</div>
</div>
{/* 错误标题 */}
<h1 className="mb-3 text-3xl font-bold text-slate-800">
</h1>
{/* 错误描述 */}
<p className="mb-8 text-slate-600">
</p>
{/* 开发环境显示错误详情 */}
{import.meta.env.DEV && error && (
<div className="mb-6 rounded-lg bg-red-50 p-4 text-left">
<p className="mb-2 text-sm font-semibold text-red-800"></p>
<pre className="overflow-auto text-xs text-red-700">
{error.message || error.toString()}
</pre>
{error.stack && (
<details className="mt-2">
<summary className="cursor-pointer text-sm font-semibold text-red-800">
</summary>
<pre className="mt-2 overflow-auto text-xs text-red-600">
{error.stack}
</pre>
</details>
)}
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-center gap-4">
<button
onClick={handleGoHome}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-white transition-colors hover:bg-blue-700"
>
<Home className="h-5 w-5" />
</button>
<button
onClick={handleReload}
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-6 py-3 text-slate-700 transition-colors hover:bg-slate-50"
>
<RefreshCw className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
};
export default ErrorFallback;

View File

@ -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<RouteLoadingProps> = ({ 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 (
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
<div className="text-center">
<div className="mb-4 flex justify-center">
<AlertCircle className="h-12 w-12 text-amber-500" />
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-800">
</h3>
<p className="mb-6 text-slate-600">
</p>
<button
onClick={handleReload}
className="rounded-md bg-blue-600 px-6 py-2 text-white transition-colors hover:bg-blue-700"
>
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-[400px] flex-col items-center justify-center">
{/* ✅ 使用 shadcn 风格的 loadinglucide-react 图标) */}
<Loader2 className="h-12 w-12 animate-spin text-blue-600" />
<div className="mt-6 text-center text-slate-500">
<p className="text-base"></p>
<p className="mt-2 text-sm">...</p>
</div>
</div>
);
};
export default RouteLoading;

View File

@ -29,10 +29,13 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
) )
} }
)
Badge.displayName = "Badge"
export { Badge, badgeVariants } export { Badge, badgeVariants }

View File

@ -1,269 +1,35 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useState } from 'react';
import { Dropdown, Spin, Space, Tooltip } from 'antd'; import { Outlet } from 'react-router-dom';
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 { AppMenu } from './AppMenu'; import { AppMenu } from './AppMenu';
import { Layout, LayoutContent, Header, Main } from '@/components/ui/layout'; import { Layout, LayoutContent, Main } from '@/components/ui/layout';
import { import Header from './components/Header';
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');
/**
*
*
*
* 1. main.tsx
* 2. window.location.reload()
* 3. HeaderTimeWeatherUserPanel
* 4.
*/
const BasicLayout: React.FC = () => { const BasicLayout: React.FC = () => {
const navigate = useNavigate(); const [openKeys, setOpenKeys] = useState<string[]>([]);
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<string[]>(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: <UserOutlined />,
label: (
<div style={{ padding: '4px 0' }}>
<div>{userInfo?.nickname || userInfo?.username}</div>
{userInfo?.email && <div style={{ fontSize: '12px', color: '#999' }}>{userInfo.email}</div>}
</div>
),
disabled: true,
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
];
// 处理菜单展开/收起 // 处理菜单展开/收起
const handleOpenChange = (keys: string[]) => { const handleOpenChange = (keys: string[]) => {
setOpenKeys(keys); setOpenKeys(keys);
}; };
if (loading) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-slate-50">
<Spin size="large" />
<div className="mt-6 text-center text-slate-500">
<p className="text-base"></p>
<p className="text-sm">...</p>
</div>
</div>
);
}
return ( return (
<Layout> <Layout>
<AppMenu openKeys={openKeys} onOpenChange={handleOpenChange} /> <AppMenu openKeys={openKeys} onOpenChange={handleOpenChange} />
<LayoutContent> <LayoutContent>
<Header className="justify-end"> <Header />
<Space size={24}>
<Space size={16}>
<span className="inline-flex items-center text-sm text-slate-600">
<ClockCircleOutlined className="mr-2" />
{currentTime.format('YYYY年MM月DD日 HH:mm:ss')} {currentTime.format('dd')}
</span>
<Tooltip title={`${weather.city}`}>
<span className="inline-flex items-center text-sm text-slate-600">
<CloudOutlined className="mr-2" />
{weather.weather} {weather.temp}
</span>
</Tooltip>
</Space>
<Dropdown menu={{ items: userMenuItems }} trigger={['hover']}>
<Space className="cursor-pointer">
<UserOutlined className="text-sm" />
<span className="text-sm">{userInfo?.nickname || userInfo?.username}</span>
</Space>
</Dropdown>
</Space>
</Header>
<Main className="bg-white"> <Main className="bg-white">
<Outlet /> <Outlet />
</Main> </Main>
</LayoutContent> </LayoutContent>
{/* 退出登录确认对话框 */}
<AlertDialog open={logoutDialogOpen} onOpenChange={setLogoutDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-500" />
退
</AlertDialogTitle>
<AlertDialogDescription>
退
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setLogoutDialogOpen(false)}>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmLogout}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout> </Layout>
); );
}; };

View File

@ -0,0 +1,47 @@
import React from 'react';
/**
*
*
*/
const AppSkeleton: React.FC = () => {
return (
<div className="flex h-screen bg-slate-50">
{/* 侧边栏骨架 */}
<div className="w-64 border-r border-slate-200 bg-white">
{/* Logo 区域 */}
<div className="h-16 border-b border-slate-200 px-4 flex items-center">
<div className="h-8 w-32 bg-slate-200 rounded animate-pulse"></div>
</div>
{/* 菜单骨架 */}
<div className="p-4 space-y-2">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<div key={i} className="h-10 bg-slate-200 rounded animate-pulse"></div>
))}
</div>
</div>
{/* 主内容区骨架 */}
<div className="flex-1 flex flex-col">
{/* 头部骨架 */}
<div className="h-16 border-b border-slate-200 bg-white px-6 flex items-center justify-end">
<div className="flex items-center gap-4">
<div className="h-8 w-48 bg-slate-200 rounded animate-pulse"></div>
<div className="h-8 w-24 bg-slate-200 rounded animate-pulse"></div>
</div>
</div>
{/* 内容区骨架 */}
<div className="flex-1 p-6 space-y-4">
<div className="h-12 w-64 bg-slate-200 rounded animate-pulse"></div>
<div className="h-64 bg-white rounded shadow animate-pulse"></div>
<div className="h-64 bg-white rounded shadow animate-pulse"></div>
</div>
</div>
</div>
);
};
export default AppSkeleton;

View File

@ -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 (
<LayoutHeader className="justify-end">
<Space size={24}>
<TimeWeather />
<UserPanel />
</Space>
</LayoutHeader>
);
});
Header.displayName = 'Header';
export default Header;

View File

@ -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 (
<Space size={16}>
{/* 时间显示 */}
<span className="inline-flex items-center text-sm text-slate-600">
<ClockCircleOutlined className="mr-2" />
{currentTime.format('YYYY年MM月DD日 HH:mm:ss')} {currentTime.format('dd')}
</span>
{/* 天气显示 */}
<Tooltip title={`${weather.city}`}>
<span className="inline-flex items-center text-sm text-slate-600">
<CloudOutlined className="mr-2" />
{weather.weather} {weather.temp}
</span>
</Tooltip>
</Space>
);
});
TimeWeather.displayName = 'TimeWeather';
export default TimeWeather;

View File

@ -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: <UserOutlined />,
label: (
<div style={{ padding: '4px 0' }}>
<div>{userInfo?.nickname || userInfo?.username}</div>
{userInfo?.email && (
<div style={{ fontSize: '12px', color: '#999' }}>
{userInfo.email}
</div>
)}
</div>
),
disabled: true,
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
];
return (
<>
<Dropdown menu={{ items: userMenuItems }} trigger={['hover']}>
<Space className="cursor-pointer">
<UserOutlined className="text-sm" />
<span className="text-sm">
{userInfo?.nickname || userInfo?.username}
</span>
</Space>
</Dropdown>
{/* 退出登录确认对话框 */}
<AlertDialog open={logoutDialogOpen} onOpenChange={setLogoutDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-500" />
退
</AlertDialogTitle>
<AlertDialogDescription>
退
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setLogoutDialogOpen(false)}>
</AlertDialogCancel>
<AlertDialogAction onClick={confirmLogout}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
});
UserPanel.displayName = 'UserPanel';
export default UserPanel;

View File

@ -1,15 +1,86 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import {RouterProvider} from 'react-router-dom'; import { Provider } from 'react-redux';
import {Provider} from 'react-redux';
import router from './router';
import store from './store'; import store from './store';
import './index.css'; 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( /**
* -
*
*
* 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(
<ErrorBoundary>
<Provider store={store}> <Provider store={store}>
<RouterProvider router={router}/> <App />
<Toaster/> <Toaster />
</Provider> </Provider>
); </ErrorBoundary>
);
} catch (error) {
// 启动失败,清除 token 并渲染应用(会自动跳转到登录页)
localStorage.removeItem('token');
localStorage.removeItem('menus');
root.render(
<ErrorBoundary>
<Provider store={store}>
<App />
<Toaster />
</Provider>
</ErrorBoundary>
);
}
}
// 显示骨架屏(更好的视觉体验)
root.render(<AppSkeleton />);
// 启动应用
bootstrap();

View File

@ -20,11 +20,23 @@ import { ApplicationCard } from './components/ApplicationCard';
import { PendingApprovalModal } from './components/PendingApprovalModal'; import { PendingApprovalModal } from './components/PendingApprovalModal';
import type { DeployTeam, ApplicationConfig } from './types'; import type { DeployTeam, ApplicationConfig } from './types';
// ✅ 优化:使用骨架屏替代 loading体验更好
const LoadingState = () => ( const LoadingState = () => (
<div className="flex-1 p-8"> <div className="flex-1 p-8 space-y-6">
<div className="flex flex-col items-center justify-center min-h-[400px]"> {/* 标题骨架 */}
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4"/> <div className="space-y-2">
<p className="text-sm text-muted-foreground">...</p> <div className="h-9 w-48 bg-slate-200 rounded animate-pulse" />
<div className="h-5 w-64 bg-slate-100 rounded animate-pulse" />
</div>
{/* 卡片骨架 */}
<div className="space-y-4">
<div className="h-12 w-full bg-slate-100 rounded animate-pulse" />
<div className="grid grid-cols-6 gap-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-48 bg-slate-100 rounded-lg animate-pulse" />
))}
</div>
</div> </div>
</div> </div>
); );
@ -153,8 +165,18 @@ const Dashboard: React.FC = () => {
// 首次加载数据 // 首次加载数据
useEffect(() => { 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秒 // 定时刷新数据每5秒

View File

@ -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 (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-slate-50 p-8">
<div className="max-w-md text-center">
{/* 403 图标 */}
<div className="mb-6 flex justify-center">
<div className="rounded-full bg-amber-100 p-6">
<ShieldAlert className="h-16 w-16 text-amber-600" />
</div>
</div>
{/* 403 标题 */}
<div className="mb-3">
<h1 className="text-6xl font-bold text-slate-800">403</h1>
<h2 className="mt-2 text-2xl font-semibold text-slate-700">
访
</h2>
</div>
{/* 描述 */}
<p className="mb-8 text-slate-600">
访
<br />
访
</p>
{/* 操作按钮 */}
<div className="flex justify-center gap-4">
<button
onClick={handleGoHome}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-white transition-colors hover:bg-blue-700"
>
<Home className="h-5 w-5" />
</button>
<button
onClick={handleGoBack}
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-6 py-3 text-slate-700 transition-colors hover:bg-slate-50"
>
<ArrowLeft className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
};
export default Forbidden;

View File

@ -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 (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-slate-50 p-8">
<div className="max-w-md text-center">
{/* 404 图标 */}
<div className="mb-6 flex justify-center">
<div className="rounded-full bg-blue-100 p-6">
<FileQuestion className="h-16 w-16 text-blue-600" />
</div>
</div>
{/* 404 标题 */}
<div className="mb-3">
<h1 className="text-6xl font-bold text-slate-800">404</h1>
<h2 className="mt-2 text-2xl font-semibold text-slate-700">
</h2>
</div>
{/* 描述 */}
<p className="mb-8 text-slate-600">
访
<br />
URL是否正确
</p>
{/* 操作按钮 */}
<div className="flex justify-center gap-4">
<button
onClick={handleGoHome}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-white transition-colors hover:bg-blue-700"
>
<Home className="h-5 w-5" />
</button>
<button
onClick={handleGoBack}
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-6 py-3 text-slate-700 transition-colors hover:bg-slate-50"
>
<ArrowLeft className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
};
export default NotFound;

View File

@ -84,7 +84,7 @@ const Login: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
// 1. 登录获取 token 和用户信息 // 1. 登录获取 token 和用户信息
const loginData = await login(values); const loginData = await login(values as { tenantId: string; username: string; password: string });
dispatch(setToken(loginData.token)); dispatch(setToken(loginData.token));
dispatch( dispatch(
setUserInfo({ setUserInfo({
@ -99,11 +99,18 @@ const Login: React.FC = () => {
// 2. 获取菜单数据 // 2. 获取菜单数据
await loadMenuData(); await loadMenuData();
// 3. 显示成功提示
toast({ toast({
title: '登录成功', title: '登录成功',
description: '欢迎回来!', description: '正在进入系统...',
}); });
navigate('/');
// 4. 短暂延迟后跳转(让用户看到成功提示)
setTimeout(() => {
// ✅ 最稳定方案:刷新页面重新初始化
// 优点100% 可靠,使用缓存菜单启动快
window.location.href = '/';
}, 300);
} catch (error) { } catch (error) {
console.error('登录失败:', error); console.error('登录失败:', error);
toast({ toast({

View File

@ -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<ProtectedRouteProps> = ({
children,
requiresAuth = true
}) => {
const location = useLocation();
const token = useSelector((state: RootState) => state.user.token);
// 检查是否需要认证
if (requiresAuth && !token) {
// 未登录,跳转到登录页,并记录原始路径用于登录后跳转
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
}
// 已登录或不需要认证,允许访问
return <>{children}</>;
};
export default ProtectedRoute;

View File

@ -1,22 +1,21 @@
import { createBrowserRouter, Navigate, RouteObject } from 'react-router-dom'; import { createBrowserRouter, Navigate, RouteObject } from 'react-router-dom';
import { Suspense, lazy } from 'react'; import { Suspense, lazy } from 'react';
import { Spin } from 'antd';
import Login from '../pages/Login'; import Login from '../pages/Login';
import BasicLayout from '../layouts/BasicLayout'; import BasicLayout from '../layouts/BasicLayout';
import { getRouteComponent } from './routeMap'; import { getRouteComponent } from './routeMap';
import type { MenuResponse } from '@/pages/System/Menu/List/types'; import type { MenuResponse } from '@/pages/System/Menu/List/types';
import store from '../store'; 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 FormDesignerTest = lazy(() => import('../pages/FormDesigner'));
// 加载组件
const LoadingComponent = () => (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin size="large" />
</div>
);
/** /**
* *
* @param menus * @param menus
@ -31,7 +30,8 @@ const generateRoutes = (menus: MenuResponse[]): RouteObject[] => {
// 如果有 component 且有 path创建路由 // 如果有 component 且有 path创建路由
if (menu.component && menu.path) { if (menu.component && menu.path) {
const Component = getRouteComponent(menu.component); // 对于隐藏菜单,抑制组件未找到的警告
const Component = getRouteComponent(menu.component, menu.hidden);
if (Component) { if (Component) {
// 移除开头的 / // 移除开头的 /
const path = menu.path.replace(/^\//, ''); const path = menu.path.replace(/^\//, '');
@ -39,12 +39,15 @@ const generateRoutes = (menus: MenuResponse[]): RouteObject[] => {
routes.push({ routes.push({
path, path,
element: ( element: (
<Suspense fallback={<LoadingComponent />}> <ProtectedRoute requiresAuth={true}>
<Suspense fallback={<RouteLoading />}>
<Component /> <Component />
</Suspense> </Suspense>
</ProtectedRoute>
), ),
}); });
} }
// 警告已在 getRouteComponent 内部处理
} }
// 递归处理子菜单 // 递归处理子菜单
@ -59,9 +62,9 @@ const generateRoutes = (menus: MenuResponse[]): RouteObject[] => {
/** /**
* *
* Redux store , * Redux store
*/ */
const createDynamicRouter = () => { export const createDynamicRouter = () => {
const state = store.getState(); const state = store.getState();
const menus = state.user.menus || []; const menus = state.user.menus || [];
@ -71,38 +74,66 @@ const createDynamicRouter = () => {
return createBrowserRouter([ return createBrowserRouter([
{ {
path: '/login', path: '/login',
element: <Login /> element: <Login />,
}, },
{ {
path: '/', path: '/',
element: <BasicLayout />, element: <BasicLayout />,
errorElement: <ErrorFallback />,
children: [ children: [
{ {
path: '', path: '',
element: <Navigate to="/dashboard" replace /> element: <Navigate to="/dashboard" replace />,
}, },
// 写死的测试路由:表单设计器测试页面 // 写死的测试路由:表单设计器测试页面
{ {
path: 'workflow/form-designer', path: 'workflow/form-designer',
element: ( element: (
<Suspense fallback={<LoadingComponent />}> <ProtectedRoute requiresAuth={true}>
<Suspense fallback={<RouteLoading />}>
<FormDesignerTest /> <FormDesignerTest />
</Suspense> </Suspense>
) </ProtectedRoute>
),
}, },
// 动态生成的路由 // 动态生成的路由
...dynamicRoutes, ...dynamicRoutes,
// 404 路由 // 403 无权限页面
{
path: '403',
element: (
<Suspense fallback={<RouteLoading />}>
<Forbidden />
</Suspense>
),
},
// 404 页面不存在
{
path: '404',
element: (
<Suspense fallback={<RouteLoading />}>
<NotFound />
</Suspense>
),
},
// 其他未匹配路由重定向到 404
{ {
path: '*', path: '*',
element: <Navigate to="/dashboard"/> element: <Navigate to="/404" replace />,
} },
] ],
} },
]); ]);
}; };
// 导出路由实例 // 导出路由实例(初始化时创建一次)
const router = createDynamicRouter(); let router = createDynamicRouter();
// 导出重新创建路由的函数
export const recreateRouter = () => {
console.log('🔄 重新创建路由...');
router = createDynamicRouter();
return router;
};
export default router; export default router;

View File

@ -9,9 +9,13 @@ const modules = import.meta.glob<{ default: ComponentType<any> }>('/src/pages/**
/** /**
* component * component
* @param componentPath (: 'Dashboard', 'Deploy/Application/List') * @param componentPath (: 'Dashboard', 'Deploy/Application/List')
* @param suppressWarning
* @returns null * @returns null
*/ */
export const getRouteComponent = (componentPath: string | null | undefined): React.LazyExoticComponent<ComponentType<any>> | null => { export const getRouteComponent = (
componentPath: string | null | undefined,
suppressWarning: boolean = false
): React.LazyExoticComponent<ComponentType<any>> | null => {
if (!componentPath) return null; if (!componentPath) return null;
// 移除开头和结尾的斜杠 // 移除开头和结尾的斜杠
@ -22,8 +26,10 @@ export const getRouteComponent = (componentPath: string | null | undefined): Rea
// 检查模块是否存在 // 检查模块是否存在
if (!modules[modulePath]) { 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; return null;
} }

View File

@ -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);
}