diff --git a/frontend/.cursorrules b/frontend/.cursorrules index 9cee8632..0da2b2c8 100644 --- a/frontend/.cursorrules +++ b/frontend/.cursorrules @@ -12,6 +12,7 @@ - 如果你不知道答案,就说出来,而不是猜测。 - 可以提出合理化的建议,但是需要等待是否可以。 - 对于新设计的实体类、字段、方法都要写注释,对于实际的逻辑要有逻辑注释。 +- 不要随意修改现在的接口调用路径,如果不知道接口是什么,可以问。 #代码实现指南 在编写代码时遵循以下规则: - 尽可能使用早期返回,使代码更具可读性。 @@ -21,61 +22,50 @@ - 在元素上实现可访问性特性。例如,一个标签应该有tabindex= " 0 "、aria-label、on:click和on:keydown以及类似的属性。 — 使用const代替函数,例如:const toggle =() =>。另外,如果可能的话,定义一个类型。 -## Deploy Ease Platform 前端开发规范 +# Deploy Ease Platform 前端开发规范 ## 1. 项目结构规范 -1. 目录结构: - ``` - src/ - ├── components/ # 可复用的公共组件 - ├── pages/ # 页面组件 - │ └── System/ # 系统管理模块 - │ ├── User/ # 用户管理模块 - │ └── Role/ # 角色管理模块 - ├── layouts/ # 布局组件 - ├── router/ # 路由配置 - ├── store/ # 状态管理 - ├── services/ # API 服务 - ├── utils/ # 工具函数 - ├── hooks/ # 自定义 Hooks - └── types/ # TypeScript 类型定义 - ``` +``` +src/ +├── components/ # 公共组件 +├── pages/ # 页面组件 +│ └── System/ # 系统管理模块 +├── layouts/ # 布局组件 +├── router/ # 路由配置 +├── store/ # 状态管理 +├── services/ # API 服务 +├── utils/ # 工具函数 +├── hooks/ # 自定义 Hooks +└── types/ # TS 类型定义 -2. 模块结构: - ``` - ModuleName/ # 使用 PascalCase 命名 - ├── components/ # 模块私有组件 - ├── types/ # 模块内部类型定义 - ├── types.ts # 模块对外暴露的类型定义 - ├── service.ts # API 服务封装 - └── index.tsx # 模块主入口 - ``` +ModuleName/ # 模块结构 +├── components/ # 模块私有组件 +├── types/ # 类型定义 +├── service.ts # API 服务 +└── index.tsx # 模块入口 +``` -## 2. 命名规范 +## 2. 命名与类型规范 1. 文件命名: - - 组件文件:使用 PascalCase(如 `UserProfile.tsx`) - - 工具函数文件:使用 camelCase(如 `formatDate.ts`) - - 样式文件:与组件同名,使用 `.module.css`(如 `UserProfile.module.css`) - - Redux Slice 文件:使用 `xxxSlice.ts` 命名 - - 类型定义文件:使用 `types.ts` 命名 - - 服务文件:使用 `service.ts` 命名 + - 组件:PascalCase(`UserProfile.tsx`) + - 工具函数:camelCase(`formatDate.ts`) + - 样式:组件同名(`UserProfile.module.css`) + - Redux:`xxxSlice.ts` + - 类型:`types.ts` + - 服务:`service.ts` 2. 变量命名: - - 使用有意义的英文名称 - - 常量使用 UPPER_SNAKE_CASE - - 变量使用 camelCase - - 接口名使用 PascalCase,并以 `I` 开头 - - 类型名使用 PascalCase,并以 `T` 开头 - - 事件处理函数使用 `handle` 前缀(如 `handleClick`) - - 异步函数使用动词开头(如 `fetchData`、`loadUser`) + - 常量:UPPER_SNAKE_CASE + - 变量:camelCase + - 接口:以 `I` 开头,PascalCase + - 类型:以 `T` 开头,PascalCase + - 事件处理:`handle` 前缀 + - 异步函数:动词开头(`fetchData`) -## 3. TypeScript 类型规范 - -1. 基础类型定义: +3. 基础类型: ```typescript - // 基础响应类型 interface BaseResponse { id: number; createTime: string; @@ -86,7 +76,6 @@ version: number; } - // 基础查询类型 interface BaseQuery { keyword?: string; enabled?: boolean; @@ -94,191 +83,206 @@ endTime?: string; } - // 分页参数类型 - interface PageParams { - pageNum: number; - pageSize: number; - sortField?: string; - sortOrder?: string; - } - - // 分页响应类型 - interface Page { - content: T[]; - number: number; - size: number; - totalElements: number; + interface Response { + code: number; + message: string; + data: T; + success: boolean; } ``` -2. 类型使用规范: - - 优先使用 `type` 而不是 `interface` - - 必须明确声明函数参数和返回值类型 - - 避免使用 `any`,优先使用 `unknown` - - 必须启用 TypeScript 严格模式 - - 所有实体响应类型必须继承 BaseResponse - - 所有查询类型必须继承 BaseQuery - - 时间字段统一使用 ISO 字符串格式 - - 版本号用于乐观锁控制 +## 3. 服务层规范 -## 4. HTTP 请求规范 +1. 方法定义: + ```typescript + // 推荐写法 - 使用 http 工具 + export const resetPassword = (id: number, password: string) => + http.post(`/api/v1/users/${id}/reset-password`, { password }); -1. 请求工具: - - 统一使用封装的 `request` 工具 - - 必须使用 `async/await` 语法,禁止使用 `.then()` - - 必须使用 `try/catch` 进行错误处理 - - Promise 对象的类型必须显式声明 + // 标准 CRUD 接口 + export const getList = (params?: Query) => + http.get('/api/v1/xxx', { params }); -2. API 规范: - - 所有后端接口必须使用统一前缀 `/api/v1/` - - API 版本控制统一使用 v1 - - 按照 RESTful 规范组织 API - - 统一的响应格式: - ```typescript - interface Response { - code: number; - message: string; - data: T; - success: boolean; + export const create = (data: Request) => + http.post('/api/v1/xxx', data); + + export const update = (id: number, data: Request) => + http.put(`/api/v1/xxx/${id}`, data); + + export const remove = (id: number) => + http.delete(`/api/v1/xxx/${id}`); + + export const batchRemove = (ids: number[]) => + http.post('/api/v1/xxx/batch-delete', { ids }); + + export const exportData = (params?: Query) => + http.download('/api/v1/xxx/export', undefined, { params }); + ``` + +2. 规范要点: + - 统一使用 `http` 工具(不要使用 request) + - 使用泛型指定响应数据类型 + - 错误处理在拦截器中统一处理 + - 使用模板字符串拼接路径 + - API 路径使用 `/api/v1/` 前缀 + - 资源使用复数形式(users, roles) + - 特殊操作使用动词(export, import) + +3. 响应数据处理: + ```typescript + // 响应数据结构 + interface Response { + code: number; + message: string; + data: T; + success: boolean; + } + + // http 工具类型定义 + const http = { + get: (url: string, config?: RequestOptions) => + request.get(url, config), // 返回 T 类型,而不是 Response + + post: (url: string, data?: any, config?: RequestOptions) => + request.post(url, data, config) // 返回 T 类型 + }; + + // 服务层方法 - 直接使用业务数据类型 + export const login = (data: LoginRequest) => + http.post('/api/v1/user/login', data); // 返回 LoginResponse + + // 组件中使用 - 直接获取业务数据 + const handleSubmit = async () => { + try { + const userData = await login(data); // userData 类型是 LoginResponse + console.log(userData.token); // 可以直接访问业务数据 + } catch (error) { + console.error('操作失败:', error); } - ``` + }; + ``` -3. 错误处理: - - 统一的错误码定义: - - 401:未授权,重新登录 - - 403:拒绝访问 - - 404:资源未找到 - - 500:服务器错误 - - 必须有完整的错误提示信息 - - 必须有合理的错误降级策略 - - 必须记录必要的错误日志 +4. 类型处理规则: + - http 工具的泛型参数 T 表示业务数据类型 + - 响应拦截器负责从 Response 中提取 data + - 服务方法直接使用业务数据类型作为泛型参数 + - 组件中可以直接使用返回的业务数据 + - TypeScript 类型系统能正确推断类型 -4. 请求配置: - - 超时时间:30秒 - - 重试次数:3次 - - 重试延迟:1秒 - - Token 使用 Bearer 方案 - - Token 存储在 localStorage - - 文件上传下载使用专门的方法 +5. 最佳实践: + ```typescript + // 服务层 - 使用业务数据类型 + export const someService = () => + http.get('/api/v1/xxx'); // 返回 Promise + + // 组件层 - 直接使用业务数据 + const handleOperation = async () => { + try { + const data = await someService(); // data 类型是 SomeType + console.log(data.someField); // TypeScript 能正确推断字段类型 + } catch (error) { + console.error('操作失败:', error); + } + }; + + // 特殊处理 - 文件上传 + export const uploadFile = (file: File) => + http.upload('/api/v1/upload', file); + + // 特殊处理 - 文件下载 + export const downloadFile = (fileId: string) => + http.download('/api/v1/download', `file-${fileId}.pdf`); + ``` + +## 4. 错误处理规范 + +1. 统一处理: + ```typescript + // 响应拦截器 + const responseHandler = (response: AxiosResponse>) => { + const { data: result } = response; + if (result.success && result.code === 200) { + return result.data; + } + message.error(result.message || '操作失败'); + return Promise.reject(response); + }; + + // 错误拦截器 + const errorHandler = (error: AxiosError) => { + const messages = { + 401: '未授权,请重新登录', + 403: '拒绝访问', + 404: '资源未找到', + 500: '服务器错误' + }; + message.error(messages[error.response?.status] || '网络错误'); + return Promise.reject(error); + }; + ``` + +2. 组件中使用: + ```typescript + const handleSubmit = async (values: FormData) => { + setLoading(true); + try { + const response = await submitData(values); + message.success('操作成功'); + } catch (error) { + console.error('操作失败:', error); + } finally { + setLoading(false); + } + }; + ``` ## 5. React 开发规范 1. 组件开发: - - 使用函数组件和箭头函数定义 - - Props 类型必须明确定义 + - 使用函数组件和箭头函数 + - Props 类型必须定义 - 必须提供默认值 - - 必须有完整的 JSDoc 注释 - - 必须使用 memo 优化,除非确定不需要 - - 复杂组件需要拆分为多个子组件 - - 必须处理组件的所有状态和异常情况 + - 使用 memo 优化 + - 复杂组件需拆分 2. Hooks 使用: - - 自定义 hooks 必须以 `use` 开头 - - 必须使用 TypeScript 泛型 - - 必须处理加载状态和错误情况 - - 必须使用 useCallback 和 useMemo 优化性能 - - 避免嵌套 `await`,使用 `Promise.all` 处理并行请求 + - 自定义 hooks 以 `use` 开头 + - 使用 TypeScript 泛型 + - 使用 useCallback 和 useMemo + - 使用 Promise.all 处理并行请求 3. 状态管理: - 使用 Redux Toolkit - - Slice 文件命名必须以 `Slice` 结尾 - - Action 类型必须使用 `PayloadAction` - - 必须定义清晰的状态接口 - - 本地存储同步必须在 reducer 中处理 - - 状态持久化: - - Token 统一存储在 localStorage - - 用户信息统一存储在 localStorage - - 清理时必须调用统一的 logout action + - 持久化数据存储在 localStorage + - Token、用户信息、菜单统一管理 -## 6. 表格组件规范 +## 6. 样式与布局规范 -1. 状态管理: - ```typescript - interface TableState { - list: T[]; - pagination: { - current: number; - pageSize: number; - total: number; - }; - loading: boolean; - selectedRows: T[]; - selectedRowKeys: React.Key[]; - } - ``` - -2. 功能实现: - - 必须实现标准的 CRUD 操作 - - 必须处理分页、排序、筛选 - - 必须处理选择行功能 - - 必须提供重置功能 - - 默认分页大小:10 - - 页码从 1 开始 - -## 7. 样式开发规范 - -1. CSS 使用规范: - - 必须使用 CSS Modules +1. CSS 规范: + - 使用 CSS Modules - 类名使用 kebab-case - - 避免内联样式,除非是动态计算的值 - - 必须响应式适配 - - 必须考虑暗色主题 + - 避免内联样式 + - 响应式适配 + - 支持暗色主题 2. 布局规范: - - 统一使用 BasicLayout - - 必须实现面包屑导航 - - 必须处理响应式布局 - - 必须实现统一的错误页面 - - 导航菜单: - - 菜单数据必须从后端获取 - - 必须支持多级菜单 - - 必须支持菜单权限控制 - - 必须支持菜单图标 + - 使用 BasicLayout + - 实现面包屑导航 + - 菜单从后端获取 + - 支持权限控制 -## 8. 项目配置规范 +## 7. 项目配置规范 -1. 依赖管理: - - 核心依赖版本: - - React: ^18.2.0 - - TypeScript: ^5.3.3 - - Ant Design: ^5.22.2 - - Redux Toolkit: ^2.0.1 - - 开发依赖必须锁定版本 - - 生产依赖必须指定大版本 +1. 依赖版本: + - React: ^18.2.0 + - TypeScript: ^5.3.3 + - Ant Design: ^5.22.2 + - Redux Toolkit: ^2.0.1 2. 开发规范: - - 必须启用 ESLint - - 必须使用 Prettier 格式化 - - 必须配置 Git Hooks - - commit message 遵循 Angular 规范 - - 提交前必须进行 lint 和 type check - -3. 环境配置: - - 使用 .env 文件管理环境变量 - - 必须区分开发和生产环境 - - 必须配置代理规则 - - 必须处理跨域问题 - -4. 构建优化: - - 使用 Vite 作为构建工具 - - 必须配置路径别名 - - 必须优化构建性能 - - 必须分离开发和生产配置 - - 路由级别的代码分割 - - 大型第三方库异步加载 - -5. 安全规范: - - 环境变量必须通过 .env 文件管理 - - 禁止在代码中硬编码敏感信息 - - 使用 ESLint security 插件 - - 小心使用 dangerouslySetInnerHTML - -6. 测试规范: - - 单元测试: - - 使用 Jest 和 React Testing Library - - 重要组件必须包含测试用例 - - 测试文件以 .test.tsx 结尾 - - E2E 测试: - - 使用 Cypress 进行端到端测试 - - 覆盖关键业务流程 + - 启用 ESLint 和 Prettier + - 配置 Git Hooks + - 使用 .env 管理环境变量 + - 使用 Vite 构建 + - 配置路由级代码分割 \ No newline at end of file diff --git a/frontend/scripts/create-module.js b/frontend/scripts/create-module.js index d47b0d96..c416ba3f 100644 --- a/frontend/scripts/create-module.js +++ b/frontend/scripts/create-module.js @@ -15,8 +15,7 @@ const modulePath = path.join(__dirname, '../src/pages/System', moduleName); const directories = [ '', 'components', - 'hooks', - 'styles' + 'types' ]; // 创建目录 @@ -30,7 +29,7 @@ directories.forEach(dir => { // 创建基础文件 const files = { // 类型定义 - 'types.ts': `import type { BaseResponse } from '@/types/base/response'; + 'types/index.ts': `import type { BaseResponse } from '@/types/base/response'; import type { BaseQuery } from '@/types/base/query'; // 查询参数 @@ -52,59 +51,111 @@ export interface ${moduleName}Response extends BaseResponse { // API 服务 'service.ts': `import request from '@/utils/request'; import type { ${moduleName}Response, ${moduleName}Request, ${moduleName}Query } from './types'; -import type { Page } from '@/types/base/page'; -const BASE_URL = '/api/v1/${moduleName.toLowerCase()}'; - -// 获取列表(分页) -export const get${moduleName}s = async (params?: ${moduleName}Query) => - request.get>(\`\${BASE_URL}/page\`, { params }); +// 获取列表 +export const getList = (params?: ${moduleName}Query) => + request.get<${moduleName}Response[]>('/api/v1/${moduleName.toLowerCase()}s', { params }); // 创建 -export const create${moduleName} = async (data: ${moduleName}Request) => - request.post<${moduleName}Response>(BASE_URL, data); +export const create = (data: ${moduleName}Request) => + request.post<${moduleName}Response>('/api/v1/${moduleName.toLowerCase()}s', data); // 更新 -export const update${moduleName} = async (id: number, data: ${moduleName}Request) => - request.put<${moduleName}Response>(\`\${BASE_URL}/\${id}\`, data); +export const update = (id: number, data: ${moduleName}Request) => + request.put<${moduleName}Response>(\`/api/v1/${moduleName.toLowerCase()}s/\${id}\`, data); // 删除 -export const delete${moduleName} = async (id: number) => - request.delete(\`\${BASE_URL}/\${id}\`); +export const remove = (id: number) => + request.delete(\`/api/v1/${moduleName.toLowerCase()}s/\${id}\`); + +// 批量删除 +export const batchRemove = (ids: number[]) => + request.post('/api/v1/${moduleName.toLowerCase()}s/batch-delete', { ids }); + +// 导出 +export const exportData = (params?: ${moduleName}Query) => + request.get('/api/v1/${moduleName.toLowerCase()}s/export', { + params, + responseType: 'blob' + }); `, // 主页面组件 - 'index.tsx': `import React from 'react'; -import { Card } from 'antd'; + 'index.tsx': `import React, { useState } from 'react'; +import { Card, message } from 'antd'; import type { ${moduleName}Response } from './types'; -import { use${moduleName}Data } from './hooks/use${moduleName}Data'; +import * as service from './service'; import ${moduleName}Table from './components/${moduleName}Table'; import ${moduleName}Form from './components/${moduleName}Form'; -import styles from './styles/index.module.css'; const ${moduleName}: React.FC = () => { - const { - list, - pagination, - loading, - selectedRows, - handleCreate, - handleUpdate, - handleDelete, - handleSelectionChange - } = use${moduleName}Data(); + const [loading, setLoading] = useState(false); + const [list, setList] = useState<${moduleName}Response[]>([]); + const [selectedRows, setSelectedRows] = useState<${moduleName}Response[]>([]); + + const loadData = async () => { + setLoading(true); + try { + const data = await service.getList(); + setList(data); + } catch (error) { + console.error('加载数据失败:', error); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (values: ${moduleName}Request) => { + try { + await service.create(values); + message.success('创建成功'); + loadData(); + } catch (error) { + console.error('创建失败:', error); + } + }; + + const handleUpdate = async (id: number, values: ${moduleName}Request) => { + try { + await service.update(id, values); + message.success('更新成功'); + loadData(); + } catch (error) { + console.error('更新失败:', error); + } + }; + + const handleDelete = async (id: number) => { + try { + await service.remove(id); + message.success('删除成功'); + loadData(); + } catch (error) { + console.error('删除失败:', error); + } + }; + + const handleBatchDelete = async () => { + try { + await service.batchRemove(selectedRows.map(row => row.id)); + message.success('批量删除成功'); + loadData(); + } catch (error) { + console.error('批量删除失败:', error); + } + }; return ( - + <${moduleName}Form onSubmit={handleCreate} /> <${moduleName}Table loading={loading} dataSource={list} - pagination={pagination} selectedRows={selectedRows} onDelete={handleDelete} + onBatchDelete={handleBatchDelete} onUpdate={handleUpdate} - onSelectionChange={handleSelectionChange} + onSelectionChange={setSelectedRows} /> ); @@ -117,28 +168,23 @@ export default ${moduleName}; 'components/${moduleName}Table.tsx': `import React from 'react'; import { Table, Space, Button, Popconfirm } from 'antd'; import type { ${moduleName}Response } from '../types'; -import styles from '../styles/table.module.css'; interface ${moduleName}TableProps { loading: boolean; dataSource: ${moduleName}Response[]; - pagination: { - current: number; - pageSize: number; - total: number; - }; selectedRows: ${moduleName}Response[]; onDelete: (id: number) => Promise; - onUpdate: (id: number, data: Partial<${moduleName}Response>) => Promise; - onSelectionChange: (selectedRows: ${moduleName}Response[]) => void; + onBatchDelete: () => Promise; + onUpdate: (id: number, data: ${moduleName}Response) => Promise; + onSelectionChange: (rows: ${moduleName}Response[]) => void; } const ${moduleName}Table: React.FC<${moduleName}TableProps> = ({ loading, dataSource, - pagination, selectedRows, onDelete, + onBatchDelete, onUpdate, onSelectionChange }) => { @@ -166,18 +212,30 @@ const ${moduleName}Table: React.FC<${moduleName}TableProps> = ({ ]; return ( - row.id), - onChange: (_, rows) => onSelectionChange(rows), - }} - /> + <> + {selectedRows.length > 0 && ( +
+ + + +
+ )} +
row.id), + onChange: (_, rows) => onSelectionChange(rows), + }} + /> + ); }; @@ -188,7 +246,6 @@ export default ${moduleName}Table; 'components/${moduleName}Form.tsx': `import React from 'react'; import { Form, Input, Button } from 'antd'; import type { ${moduleName}Request } from '../types'; -import styles from '../styles/form.module.css'; interface ${moduleName}FormProps { initialValues?: Partial<${moduleName}Request>; @@ -202,9 +259,13 @@ const ${moduleName}Form: React.FC<${moduleName}FormProps> = ({ const [form] = Form.useForm(); const handleSubmit = async () => { - const values = await form.validateFields(); - await onSubmit(values); - form.resetFields(); + try { + const values = await form.validateFields(); + await onSubmit(values); + form.resetFields(); + } catch (error) { + console.error('表单提交失败:', error); + } }; return ( @@ -212,7 +273,7 @@ const ${moduleName}Form: React.FC<${moduleName}FormProps> = ({ form={form} layout="inline" initialValues={initialValues} - className={styles.form} + style={{ marginBottom: 16 }} > {/* TODO: 添加表单项 */} @@ -225,62 +286,6 @@ const ${moduleName}Form: React.FC<${moduleName}FormProps> = ({ }; export default ${moduleName}Form; -`, - - // 自定义 Hook - 'hooks/use${moduleName}Data.ts': `import { useState, useCallback } from 'react'; -import type { ${moduleName}Response, ${moduleName}Request } from '../types'; -import { useTableData } from '@/hooks/useTableData'; -import * as service from '../service'; - -export const use${moduleName}Data = () => { - const [selectedRows, setSelectedRows] = useState<${moduleName}Response[]>([]); - - const { - list, - pagination, - loading, - loadData, - handleCreate, - handleUpdate, - handleDelete - } = useTableData<${moduleName}Response>({ - service: { - baseUrl: '/api/v1/${moduleName.toLowerCase()}' - } - }); - - const handleSelectionChange = useCallback((rows: ${moduleName}Response[]) => { - setSelectedRows(rows); - }, []); - - return { - list, - pagination, - loading, - selectedRows, - handleCreate, - handleUpdate, - handleDelete, - handleSelectionChange - }; -}; -`, - - // 样式文件 - 'styles/index.module.css': `.container { - padding: 24px; -} -`, - - 'styles/table.module.css': `.table { - margin-top: 16px; -} -`, - - 'styles/form.module.css': `.form { - margin-bottom: 16px; -} ` }; diff --git a/frontend/src/layouts/BasicLayout.tsx b/frontend/src/layouts/BasicLayout.tsx index 3aafe2da..5b32a14d 100644 --- a/frontend/src/layouts/BasicLayout.tsx +++ b/frontend/src/layouts/BasicLayout.tsx @@ -15,11 +15,10 @@ import {logout, setMenus} from '../store/userSlice'; import type {MenuProps} from 'antd'; import {getCurrentUserMenus} from '@/pages/System/Menu/service'; import {getWeather} from '../services/weather'; -import type {MenuResponse} from '@/pages/System/Menu/types'; -import {MenuTypeEnum} from '@/pages/System/Menu/types'; import type {RootState} from '../store'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; +import {MenuResponse, MenuTypeEnum} from "@/pages/System/Menu/types"; const {Header, Content, Sider} = Layout; const {confirm} = Modal; @@ -27,28 +26,6 @@ const {confirm} = Modal; // 设置中文语言 dayjs.locale('zh-cn'); -// 添加清理空children的函数,同时过滤掉按钮类型菜单 -const cleanEmptyChildren = (menus: MenuResponse[]): MenuResponse[] => { - // 先过滤掉按钮类型的菜单 - return menus - .filter(menu => menu.type !== MenuTypeEnum.BUTTON) - .map(menu => { - const cleanedMenu = { ...menu }; - - if (cleanedMenu.children && cleanedMenu.children.length > 0) { - // 递归处理子菜单,同样需要过滤按钮 - cleanedMenu.children = cleanEmptyChildren(cleanedMenu.children); - // 如果过滤后子菜单为空,则删除children属性 - if (cleanedMenu.children.length === 0) { - delete cleanedMenu.children; - } - } else { - delete cleanedMenu.children; - } - - return cleanedMenu; - }); -}; const BasicLayout: React.FC = () => { const navigate = useNavigate(); @@ -59,6 +36,7 @@ const BasicLayout: React.FC = () => { const [loading, setLoading] = useState(true); const [currentTime, setCurrentTime] = useState(dayjs()); const [weather, setWeather] = useState({temp: '--', weather: '未知', city: '未知'}); + const [openKeys, setOpenKeys] = useState([]); // 将天气获取逻辑提取为useCallback const fetchWeather = useCallback(async () => { @@ -76,7 +54,7 @@ const BasicLayout: React.FC = () => { setLoading(true); try { const menuData = await getCurrentUserMenus(); - dispatch(setMenus(cleanEmptyChildren(menuData))); + dispatch(setMenus(menuData)); } catch (error) { message.error('获取菜单数据失败'); console.error('获取菜单数据失败:', error); @@ -158,15 +136,24 @@ const BasicLayout: React.FC = () => { // 将菜单数据转换为antd Menu需要的格式 const getMenuItems = (menuList: MenuResponse[]): MenuProps['items'] => { return menuList - ?.filter(menu => menu.type !== MenuTypeEnum.BUTTON) // 过滤掉按钮类型的菜单 - ?.map(menu => ({ - key: menu.path || menu.id.toString(), - icon: getIcon(menu.icon), - label: menu.name, - children: menu.children && menu.children.length > 0 - ? getMenuItems(menu.children) // 递归处理子菜单 - : undefined - })); + ?.map(menu => { + // 确保path存在,否则使用id + const key = menu.path || `menu-${menu.id}`; + return { + key, + icon: getIcon(menu.icon), + label: menu.name, + children: menu.children && menu.children.length > 0 + ? getMenuItems(menu.children) + : undefined + }; + }); + }; + + // 处理菜单展开/收起 + const handleOpenChange = (keys: string[]) => { + console.log('Menu open change:', keys); + setOpenKeys(keys); }; if (loading) { @@ -202,8 +189,13 @@ const BasicLayout: React.FC = () => { theme="dark" mode="inline" selectedKeys={[location.pathname]} + openKeys={openKeys} + onOpenChange={handleOpenChange} items={getMenuItems(menus)} - onClick={({key}) => navigate(key)} + onClick={({key}) => { + console.log('Menu click:', key); + navigate(key); + }} /> @@ -239,22 +231,26 @@ const BasicLayout: React.FC = () => { - - - {userInfo?.nickname || userInfo?.username} - - + { + e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.025)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + {userInfo?.nickname || userInfo?.username} + + diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index d58db9d9..f9f3ad61 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -2,11 +2,12 @@ import React, {useEffect, useState} from 'react'; import {Form, Input, Button, message, Select} from 'antd'; import {useNavigate} from 'react-router-dom'; import {useDispatch} from 'react-redux'; -import {login, getTenantList} from './service'; +import {login} from './service'; +import {getEnabledTenants} from '@/pages/System/Tenant/service'; import {setToken, setUserInfo, setMenus} from '../../store/userSlice'; import {getCurrentUserMenus} from '@/pages/System/Menu/service'; import type { MenuResponse } from '@/pages/System/Menu/types'; -import type { TenantResponse } from './types'; +import type { TenantResponse } from '@/pages/System/Tenant/types'; import styles from './index.module.css'; interface LoginForm { @@ -25,7 +26,7 @@ const Login: React.FC = () => { useEffect(() => { const fetchTenants = async () => { try { - const data = await getTenantList(); + const data = await getEnabledTenants(); setTenants(data); } catch (error) { console.error('获取租户列表失败:', error); @@ -35,44 +36,39 @@ const Login: React.FC = () => { fetchTenants(); }, []); - // 清理空children的函数,同时过滤掉按钮类型菜单 - const cleanEmptyChildren = (menus: MenuResponse[]): MenuResponse[] => { - return menus - .filter(menu => menu.type !== 2) // 过滤掉按钮类型的菜单 - .map(menu => { - const cleanedMenu = { ...menu }; - - if (cleanedMenu.children && cleanedMenu.children.length > 0) { - cleanedMenu.children = cleanEmptyChildren(cleanedMenu.children); - if (cleanedMenu.children.length === 0) { - delete cleanedMenu.children; - } - } else { - delete cleanedMenu.children; - } - - return cleanedMenu; - }); + // 加载菜单数据 + const loadMenuData = async () => { + try { + const menuData = await getCurrentUserMenus(); + if (menuData && menuData.length > 0) { + dispatch(setMenus(menuData)); + } + } catch (error) { + console.error('获取菜单数据失败:', error); + } }; - const onFinish = async (values: LoginForm) => { + const handleFinish = async (values: LoginForm) => { + setLoading(true); try { - setLoading(true); // 1. 登录获取 token 和用户信息 - const result = await login(values); - dispatch(setToken(result.token)); - dispatch(setUserInfo(result)); + const loginData = await login(values); + dispatch(setToken(loginData.token)); + dispatch(setUserInfo({ + id: loginData.id, + username: loginData.username, + nickname: loginData.nickname, + email: loginData.email, + phone: loginData.phone + })); // 2. 获取菜单数据 - const menuData = await getCurrentUserMenus(); - dispatch(setMenus(cleanEmptyChildren(menuData))); + await loadMenuData(); message.success('登录成功'); - // 3. 所有数据都准备好后再跳转 - navigate('/dashboard', {replace: true}); + navigate('/'); } catch (error) { console.error('登录失败:', error); - message.error('登录失败,请重试'); } finally { setLoading(false); } @@ -87,7 +83,7 @@ const Login: React.FC = () => { form={form} name="login" - onFinish={onFinish} + onFinish={handleFinish} autoComplete="off" size="large" > diff --git a/frontend/src/pages/Login/service.ts b/frontend/src/pages/Login/service.ts index 61f8930f..a817e190 100644 --- a/frontend/src/pages/Login/service.ts +++ b/frontend/src/pages/Login/service.ts @@ -1,32 +1,21 @@ -import request from '@/utils/request'; +import http from '@/utils/request'; +import type { Response } from '@/utils/request'; import type { LoginRequest, LoginResponse } from './types'; -import type { TenantResponse } from '../System/Tenant/types'; -import type { MenuResponse } from '../System/Menu/types'; +import type { MenuResponse } from '@/pages/System/Menu/types'; +import type { TenantResponse } from '@/pages/System/Tenant/types'; -export const login = async (data: LoginRequest): Promise => { - return request.post('/api/v1/user/login', data, { - errorMessage: '登录失败,请检查用户名和密码' - }); -}; +// 登录 +export const login = (data: LoginRequest) => + http.post('/api/v1/user/login', data); -export const logout = async (): Promise => { - return request.post('/api/v1/user/logout', null, { - errorMessage: '退出登录失败,请稍后重试' - }); -}; +// 退出登录 +export const logout = () => + http.post('/api/v1/user/logout'); -export const getCurrentUser = async (): Promise => { - return request.get('/api/v1/user/current'); -}; +// 获取当前用户菜单 +export const getCurrentUserMenus = () => + http.get('/api/v1/menu/current'); -export const getUserMenus = async (): Promise => { - return request.get('/api/v1/user/menus', { - errorMessage: '获取菜单失败,请刷新页面重试' - }); -}; - -export const getTenantList = async (): Promise => { - return request.get('/api/v1/tenant/list', { - errorMessage: '获取租户列表失败,请刷新重试' - }); -}; \ No newline at end of file +// 获取租户列表 +export const getTenantList = () => + http.get('/api/v1/tenant/list'); \ No newline at end of file diff --git a/frontend/src/pages/Login/types/index.ts b/frontend/src/pages/Login/types/index.ts deleted file mode 100644 index 8611b878..00000000 --- a/frontend/src/pages/Login/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './request'; -export * from './response'; \ No newline at end of file diff --git a/frontend/src/pages/Login/types/request.ts b/frontend/src/pages/Login/types/request.ts deleted file mode 100644 index 782414cd..00000000 --- a/frontend/src/pages/Login/types/request.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface LoginRequest { - username: string; - password: string; - tenantId?: string; -} \ No newline at end of file diff --git a/frontend/src/pages/Login/types/response.ts b/frontend/src/pages/Login/types/response.ts deleted file mode 100644 index ab9d38cd..00000000 --- a/frontend/src/pages/Login/types/response.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface LoginResponse { - id: number; - username: string; - nickname?: string; - email?: string; - phone?: string; - token: string; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Department/types/query.ts b/frontend/src/pages/System/Department/types/query.ts deleted file mode 100644 index 77d4d2eb..00000000 --- a/frontend/src/pages/System/Department/types/query.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseQuery } from '@/types/base/query'; - -export interface DepartmentQuery extends BaseQuery { - name?: string; - code?: string; - enabled?: boolean; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Department/types/request.ts b/frontend/src/pages/System/Department/types/request.ts deleted file mode 100644 index 6503804f..00000000 --- a/frontend/src/pages/System/Department/types/request.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BaseRequest } from '@/types/base/request'; - -export interface DepartmentRequest extends BaseRequest { - name: string; - code: string; - parentId?: number; - sort?: number; - description?: string; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Department/types/response.ts b/frontend/src/pages/System/Department/types/response.ts deleted file mode 100644 index 981c836a..00000000 --- a/frontend/src/pages/System/Department/types/response.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BaseResponse } from '@/types/base/response'; - -export interface DepartmentResponse extends BaseResponse { - name: string; - code: string; - parentId?: number; - sort: number; - description?: string; - children?: DepartmentResponse[]; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Menu/service.ts b/frontend/src/pages/System/Menu/service.ts index 3036b945..5dea99cc 100644 --- a/frontend/src/pages/System/Menu/service.ts +++ b/frontend/src/pages/System/Menu/service.ts @@ -13,9 +13,9 @@ export const getMenuTree = () => request.get(`${BASE_URL}/tree`); // 获取当前用户菜单 -export const getCurrentUserMenus = () => { - return request.get(`${BASE_URL}/current`); -} +export const getCurrentUserMenus = () => + request.get(`${BASE_URL}/current`); + // 创建菜单 export const createMenu = (data: MenuRequest) => diff --git a/frontend/src/pages/System/Menu/types.ts b/frontend/src/pages/System/Menu/types.ts index ad8a5a73..7518fecf 100644 --- a/frontend/src/pages/System/Menu/types.ts +++ b/frontend/src/pages/System/Menu/types.ts @@ -1,4 +1,6 @@ -import type { BaseResponse } from '@/types/base/response'; +import {BaseQuery} from "@/types/base/query.ts"; +import {BaseRequest} from "@/types/base/request.ts"; +import {BaseResponse} from "@/types/base/response.ts"; export enum MenuTypeEnum { DIRECTORY = 1, // 目录 @@ -6,23 +8,12 @@ export enum MenuTypeEnum { BUTTON = 3 // 按钮 } -export const MenuTypeNames = { - [MenuTypeEnum.DIRECTORY]: '目录', - [MenuTypeEnum.MENU]: '菜单', - [MenuTypeEnum.BUTTON]: '按钮' -}; - -export interface MenuQuery { - pageNum?: number; - pageSize?: number; - sortField?: string; - sortOrder?: 'ascend' | 'descend'; +export interface MenuQuery extends BaseQuery { name?: string; type?: MenuTypeEnum; - enabled?: boolean; } -export interface MenuRequest { +export interface MenuRequest extends BaseRequest { name: string; type: MenuTypeEnum; parentId?: number; @@ -45,4 +36,4 @@ export interface MenuResponse extends BaseResponse { sort: number; hidden: boolean; children?: MenuResponse[]; -} \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/pages/System/Menu/types/index.ts b/frontend/src/pages/System/Menu/types/index.ts deleted file mode 100644 index 4741b3e5..00000000 --- a/frontend/src/pages/System/Menu/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './query'; -export * from './request'; -export * from './response'; \ No newline at end of file diff --git a/frontend/src/pages/System/Menu/types/query.ts b/frontend/src/pages/System/Menu/types/query.ts deleted file mode 100644 index 6759cf66..00000000 --- a/frontend/src/pages/System/Menu/types/query.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseQuery } from '@/types/base/query'; - -export interface MenuQuery extends BaseQuery { - name?: string; - path?: string; - enabled?: boolean; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Menu/types/request.ts b/frontend/src/pages/System/Menu/types/request.ts deleted file mode 100644 index 9ffe14e3..00000000 --- a/frontend/src/pages/System/Menu/types/request.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BaseRequest } from '@/types/base/request'; - -export interface MenuRequest extends BaseRequest { - name: string; - path?: string; - icon?: string; - parentId?: number; - sort?: number; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Menu/types/response.ts b/frontend/src/pages/System/Menu/types/response.ts deleted file mode 100644 index bd1d0e12..00000000 --- a/frontend/src/pages/System/Menu/types/response.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { BaseResponse } from '@/types/base/response'; - -export interface MenuResponse extends BaseResponse { - id: number; - name: string; - path?: string; - icon?: string; - parentId?: number; - sort: number; - enabled: boolean; - children?: MenuResponse[]; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Tenant/service.ts b/frontend/src/pages/System/Tenant/service.ts index 10e83bd2..b019a5a8 100644 --- a/frontend/src/pages/System/Tenant/service.ts +++ b/frontend/src/pages/System/Tenant/service.ts @@ -3,31 +3,22 @@ import type { TenantResponse, TenantRequest, TenantQuery } from './types'; export const getTenants = async (params?: TenantQuery) => { return request.get('/api/v1/tenant', { - params, - errorMessage: '获取租户列表失败,请刷新重试' + params }); }; export const createTenant = async (data: TenantRequest) => { - return request.post('/api/v1/tenant', data, { - errorMessage: '创建租户失败,请稍后重试' - }); + return request.post('/api/v1/tenant', data); }; export const updateTenant = async (id: number, data: TenantRequest) => { - return request.put(`/api/v1/tenant/${id}`, data, { - errorMessage: '更新租户失败,请稍后重试' - }); + return request.put(`/api/v1/tenant/${id}`, data); }; export const deleteTenant = async (id: number) => { - return request.delete(`/api/v1/tenant/${id}`, { - errorMessage: '删除租户失败,请稍后重试' - }); + return request.delete(`/api/v1/tenant/${id}`); }; export const getEnabledTenants = async () => { - return request.get('/api/v1/tenant/enabled', { - errorMessage: '获取可用租户列表失败,请刷新重试' - }); + return request.get('/api/v1/tenant/list'); }; \ No newline at end of file diff --git a/frontend/src/pages/System/Tenant/types/index.ts b/frontend/src/pages/System/Tenant/types/index.ts deleted file mode 100644 index 4741b3e5..00000000 --- a/frontend/src/pages/System/Tenant/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './query'; -export * from './request'; -export * from './response'; \ No newline at end of file diff --git a/frontend/src/pages/System/Tenant/types/query.ts b/frontend/src/pages/System/Tenant/types/query.ts deleted file mode 100644 index 771e8117..00000000 --- a/frontend/src/pages/System/Tenant/types/query.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseQuery } from '@/types/base/query'; - -export interface TenantQuery extends BaseQuery { - name?: string; - code?: string; - enabled?: boolean; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Tenant/types/request.ts b/frontend/src/pages/System/Tenant/types/request.ts deleted file mode 100644 index eeef7624..00000000 --- a/frontend/src/pages/System/Tenant/types/request.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BaseRequest } from '@/types/base/request'; - -export interface TenantRequest extends BaseRequest { - name: string; - code: string; - address?: string; - contactName?: string; - contactPhone?: string; - email?: string; -} \ No newline at end of file diff --git a/frontend/src/pages/System/Tenant/types/response.ts b/frontend/src/pages/System/Tenant/types/response.ts deleted file mode 100644 index 29b29419..00000000 --- a/frontend/src/pages/System/Tenant/types/response.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BaseResponse } from '@/types/base/response'; - -export interface TenantResponse extends BaseResponse { - name: string; - code: string; - address?: string; - contactName?: string; - contactPhone?: string; - email?: string; -} \ No newline at end of file diff --git a/frontend/src/pages/System/User/types/index.ts b/frontend/src/pages/System/User/types/index.ts deleted file mode 100644 index 4741b3e5..00000000 --- a/frontend/src/pages/System/User/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './query'; -export * from './request'; -export * from './response'; \ No newline at end of file diff --git a/frontend/src/pages/System/User/types/query.ts b/frontend/src/pages/System/User/types/query.ts deleted file mode 100644 index 0a185d42..00000000 --- a/frontend/src/pages/System/User/types/query.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseQuery } from '@/types/base/query'; - -export interface UserQuery extends BaseQuery { - username?: string; - nickname?: string; - email?: string; - enabled?: boolean; -} \ No newline at end of file diff --git a/frontend/src/pages/System/User/types/request.ts b/frontend/src/pages/System/User/types/request.ts deleted file mode 100644 index 3e7d3d0f..00000000 --- a/frontend/src/pages/System/User/types/request.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BaseRequest } from '@/types/base/request'; - -export interface UserRequest extends BaseRequest { - username: string; - nickname?: string; - email?: string; - phone?: string; - password?: string; -} \ No newline at end of file diff --git a/frontend/src/pages/System/User/types/response.ts b/frontend/src/pages/System/User/types/response.ts deleted file mode 100644 index 022fb844..00000000 --- a/frontend/src/pages/System/User/types/response.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { BaseResponse } from '@/types/base/response'; - -export interface UserResponse extends BaseResponse { - username: string; - nickname?: string; - email?: string; - phone?: string; -} \ No newline at end of file diff --git a/frontend/src/store/userSlice.ts b/frontend/src/store/userSlice.ts index 845127df..e477bbc1 100644 --- a/frontend/src/store/userSlice.ts +++ b/frontend/src/store/userSlice.ts @@ -18,7 +18,7 @@ interface UserState { const initialState: UserState = { token: localStorage.getItem('token'), userInfo: localStorage.getItem('userInfo') ? JSON.parse(localStorage.getItem('userInfo')!) : null, - menus: [] + menus: localStorage.getItem('menus') ? JSON.parse(localStorage.getItem('menus')!) : [] }; const userSlice = createSlice({ @@ -35,6 +35,9 @@ const userSlice = createSlice({ }, setMenus: (state, action: PayloadAction) => { state.menus = action.payload; + console.log(action.payload) + // console.log(JSON.stringify(action.payload)) + // localStorage.setItem('menus', action.payload); }, logout: (state) => { state.token = null; @@ -43,6 +46,7 @@ const userSlice = createSlice({ localStorage.removeItem('token'); localStorage.removeItem('tenantId'); localStorage.removeItem('userInfo'); + localStorage.removeItem('menus'); } } }); diff --git a/frontend/src/types/base/request.ts b/frontend/src/types/base/request.ts index 9e10c898..fe1a2c3d 100644 --- a/frontend/src/types/base/request.ts +++ b/frontend/src/types/base/request.ts @@ -1,3 +1,3 @@ export interface BaseRequest { - enabled?: boolean; + enabled?: boolean; } \ No newline at end of file diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index 525ddca0..463df0b8 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -45,7 +45,7 @@ const responseHandler = (response: AxiosResponse>) => { if (result.success && result.code === 200) { return result.data; } else { - if (result.message !== undefined) { + if (result.message != undefined) { message.error(result.message || defaultErrorMessage); return Promise.reject(response); } @@ -104,18 +104,18 @@ const http = { request.get(url, createRequestConfig(config)), post: (url: string, data?: any, config?: RequestOptions) => - request.post>(url, data, createRequestConfig(config)), + request.post(url, data, createRequestConfig(config)), put: (url: string, data?: any, config?: RequestOptions) => - request.put>(url, data, createRequestConfig(config)), + request.put(url, data, createRequestConfig(config)), delete: (url: string, config?: RequestOptions) => - request.delete>(url, createRequestConfig(config)), + request.delete(url, createRequestConfig(config)), upload: (url: string, file: File, config?: RequestOptions) => { const formData = new FormData(); formData.append('file', file); - return request.post>(url, formData, createRequestConfig({ + return request.post(url, formData, createRequestConfig({ headers: {'Content-Type': 'multipart/form-data'}, ...config }));