重构前端逻辑
This commit is contained in:
parent
6eb7983c99
commit
c6558ee00d
245
.cursor/rules/project.mdc
Normal file
245
.cursor/rules/project.mdc
Normal 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文件管理
|
||||||
|
|
||||||
|
### 文档同步
|
||||||
|
|
||||||
|
代码变更同时更新相关文档
|
||||||
|
|
||||||
|
### 测试策略
|
||||||
|
**- 测试优先:**先写测试,后写实现
|
||||||
|
**- 边界覆盖:**覆盖正常流程、边界条件、异常情况
|
||||||
|
|
||||||
|
## 交互体验优化
|
||||||
|
|
||||||
|
## 进度反馈
|
||||||
|
- 显示当前执行阶段
|
||||||
|
- 提供详细的执行步骤
|
||||||
|
- 标示完成情况
|
||||||
|
- 突出需要关注的问题
|
||||||
|
|
||||||
|
## 异常处理机制
|
||||||
|
|
||||||
|
### 中断条件
|
||||||
|
- 遇到无法自主决策的问题
|
||||||
|
- 觉得需要询问用户的问题
|
||||||
|
- 技术实现出现阻塞
|
||||||
|
- 文档不一致需要确认修正
|
||||||
|
|
||||||
|
### 恢复策略
|
||||||
|
- 保存当前执行状态
|
||||||
|
- 记录问题详细信息
|
||||||
|
- 询问并等待人工干预
|
||||||
|
- 从中断点任务继续执行
|
||||||
@ -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;
|
||||||
180
frontend/src/components/CodeMirrorVariableInput/README.md
Normal file
180
frontend/src/components/CodeMirrorVariableInput/README.md
Normal file
@ -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';
|
||||||
|
|
||||||
|
<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
|
||||||
|
|
||||||
165
frontend/src/components/ErrorBoundary.tsx
Normal file
165
frontend/src/components/ErrorBoundary.tsx
Normal 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;
|
||||||
|
|
||||||
86
frontend/src/components/ErrorFallback.tsx
Normal file
86
frontend/src/components/ErrorFallback.tsx
Normal 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;
|
||||||
|
|
||||||
69
frontend/src/components/RouteLoading.tsx
Normal file
69
frontend/src/components/RouteLoading.tsx
Normal 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 风格的 loading(lucide-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;
|
||||||
@ -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 }
|
||||||
|
|||||||
@ -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. 拆分组件(Header、TimeWeather、UserPanel)
|
||||||
|
* 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
47
frontend/src/layouts/components/AppSkeleton.tsx
Normal file
47
frontend/src/layouts/components/AppSkeleton.tsx
Normal 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;
|
||||||
|
|
||||||
29
frontend/src/layouts/components/Header.tsx
Normal file
29
frontend/src/layouts/components/Header.tsx
Normal 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;
|
||||||
|
|
||||||
78
frontend/src/layouts/components/TimeWeather.tsx
Normal file
78
frontend/src/layouts/components/TimeWeather.tsx
Normal 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;
|
||||||
|
|
||||||
121
frontend/src/layouts/components/UserPanel.tsx
Normal file
121
frontend/src/layouts/components/UserPanel.tsx
Normal 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;
|
||||||
|
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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秒)
|
||||||
|
|||||||
68
frontend/src/pages/Error/403.tsx
Normal file
68
frontend/src/pages/Error/403.tsx
Normal 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;
|
||||||
|
|
||||||
68
frontend/src/pages/Error/404.tsx
Normal file
68
frontend/src/pages/Error/404.tsx
Normal 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;
|
||||||
|
|
||||||
@ -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({
|
||||||
|
|||||||
39
frontend/src/router/ProtectedRoute.tsx
Normal file
39
frontend/src/router/ProtectedRoute.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
104
frontend/src/utils/permission.ts
Normal file
104
frontend/src/utils/permission.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user