菜单可正确被加载

This commit is contained in:
dengqichen 2024-12-02 16:37:23 +08:00
parent 5282716028
commit 4742437fe7
29 changed files with 443 additions and 597 deletions

View File

@ -12,6 +12,7 @@
- 如果你不知道答案,就说出来,而不是猜测。 - 如果你不知道答案,就说出来,而不是猜测。
- 可以提出合理化的建议,但是需要等待是否可以。 - 可以提出合理化的建议,但是需要等待是否可以。
- 对于新设计的实体类、字段、方法都要写注释,对于实际的逻辑要有逻辑注释。 - 对于新设计的实体类、字段、方法都要写注释,对于实际的逻辑要有逻辑注释。
- 不要随意修改现在的接口调用路径,如果不知道接口是什么,可以问。
#代码实现指南 #代码实现指南
在编写代码时遵循以下规则: 在编写代码时遵循以下规则:
- 尽可能使用早期返回,使代码更具可读性。 - 尽可能使用早期返回,使代码更具可读性。
@ -21,61 +22,50 @@
- 在元素上实现可访问性特性。例如一个标签应该有tabindex= " 0 "、aria-label、on:click和on:keydown以及类似的属性。 - 在元素上实现可访问性特性。例如一个标签应该有tabindex= " 0 "、aria-label、on:click和on:keydown以及类似的属性。
— 使用const代替函数例如const toggle =() =>。另外,如果可能的话,定义一个类型。 — 使用const代替函数例如const toggle =() =>。另外,如果可能的话,定义一个类型。
## Deploy Ease Platform 前端开发规范 # Deploy Ease Platform 前端开发规范
## 1. 项目结构规范 ## 1. 项目结构规范
1. 目录结构: ```
``` src/
src/ ├── components/ # 公共组件
├── components/ # 可复用的公共组件 ├── pages/ # 页面组件
├── pages/ # 页面组件 │ └── System/ # 系统管理模块
│ └── System/ # 系统管理模块 ├── layouts/ # 布局组件
│ ├── User/ # 用户管理模块 ├── router/ # 路由配置
│ └── Role/ # 角色管理模块 ├── store/ # 状态管理
├── layouts/ # 布局组件 ├── services/ # API 服务
├── router/ # 路由配置 ├── utils/ # 工具函数
├── store/ # 状态管理 ├── hooks/ # 自定义 Hooks
├── services/ # API 服务 └── types/ # TS 类型定义
├── utils/ # 工具函数
├── hooks/ # 自定义 Hooks
└── types/ # TypeScript 类型定义
```
2. 模块结构: ModuleName/ # 模块结构
``` ├── components/ # 模块私有组件
ModuleName/ # 使用 PascalCase 命名 ├── types/ # 类型定义
├── components/ # 模块私有组件 ├── service.ts # API 服务
├── types/ # 模块内部类型定义 └── index.tsx # 模块入口
├── types.ts # 模块对外暴露的类型定义 ```
├── service.ts # API 服务封装
└── index.tsx # 模块主入口
```
## 2. 命名规范 ## 2. 命名与类型规范
1. 文件命名: 1. 文件命名:
- 组件文件使用 PascalCase`UserProfile.tsx` - 组件PascalCase`UserProfile.tsx`
- 工具函数文件使用 camelCase`formatDate.ts` - 工具函数camelCase`formatDate.ts`
- 样式文件组件同名,使用 `.module.css``UserProfile.module.css` - 样式:组件同名(`UserProfile.module.css`
- Redux Slice 文件使用 `xxxSlice.ts` 命名 - Redux`xxxSlice.ts`
- 类型定义文件使用 `types.ts` 命名 - 类型:`types.ts`
- 服务文件使用 `service.ts` 命名 - 服务:`service.ts`
2. 变量命名: 2. 变量命名:
- 使用有意义的英文名称 - 常量UPPER_SNAKE_CASE
- 常量使用 UPPER_SNAKE_CASE - 变量camelCase
- 变量使用 camelCase - 接口:以 `I` 开头PascalCase
- 接口名使用 PascalCase并以 `I` 开头 - 类型:以 `T` 开头PascalCase
- 类型名使用 PascalCase并以 `T` 开头 - 事件处理:`handle` 前缀
- 事件处理函数使用 `handle` 前缀(如 `handleClick` - 异步函数:动词开头(`fetchData`
- 异步函数使用动词开头(如 `fetchData`、`loadUser`
## 3. TypeScript 类型规范 3. 基础类型:
1. 基础类型定义:
```typescript ```typescript
// 基础响应类型
interface BaseResponse { interface BaseResponse {
id: number; id: number;
createTime: string; createTime: string;
@ -86,7 +76,6 @@
version: number; version: number;
} }
// 基础查询类型
interface BaseQuery { interface BaseQuery {
keyword?: string; keyword?: string;
enabled?: boolean; enabled?: boolean;
@ -94,191 +83,206 @@
endTime?: string; endTime?: string;
} }
// 分页参数类型 interface Response<T = any> {
interface PageParams { code: number;
pageNum: number; message: string;
pageSize: number; data: T;
sortField?: string; success: boolean;
sortOrder?: string;
}
// 分页响应类型
interface Page<T> {
content: T[];
number: number;
size: number;
totalElements: number;
} }
``` ```
2. 类型使用规范: ## 3. 服务层规范
- 优先使用 `type` 而不是 `interface`
- 必须明确声明函数参数和返回值类型
- 避免使用 `any`,优先使用 `unknown`
- 必须启用 TypeScript 严格模式
- 所有实体响应类型必须继承 BaseResponse
- 所有查询类型必须继承 BaseQuery
- 时间字段统一使用 ISO 字符串格式
- 版本号用于乐观锁控制
## 4. HTTP 请求规范 1. 方法定义:
```typescript
// 推荐写法 - 使用 http 工具
export const resetPassword = (id: number, password: string) =>
http.post<void>(`/api/v1/users/${id}/reset-password`, { password });
1. 请求工具: // 标准 CRUD 接口
- 统一使用封装的 `request` 工具 export const getList = (params?: Query) =>
- 必须使用 `async/await` 语法,禁止使用 `.then()` http.get<Response[]>('/api/v1/xxx', { params });
- 必须使用 `try/catch` 进行错误处理
- Promise 对象的类型必须显式声明
2. API 规范: export const create = (data: Request) =>
- 所有后端接口必须使用统一前缀 `/api/v1/` http.post<Response>('/api/v1/xxx', data);
- API 版本控制统一使用 v1
- 按照 RESTful 规范组织 API export const update = (id: number, data: Request) =>
- 统一的响应格式: http.put<Response>(`/api/v1/xxx/${id}`, data);
```typescript
interface Response<T = any> { export const remove = (id: number) =>
code: number; http.delete(`/api/v1/xxx/${id}`);
message: string;
data: T; export const batchRemove = (ids: number[]) =>
success: boolean; 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<T> {
code: number;
message: string;
data: T;
success: boolean;
}
// http 工具类型定义
const http = {
get: <T = any>(url: string, config?: RequestOptions) =>
request.get<any, T>(url, config), // 返回 T 类型,而不是 Response<T>
post: <T = any>(url: string, data?: any, config?: RequestOptions) =>
request.post<any, T>(url, data, config) // 返回 T 类型
};
// 服务层方法 - 直接使用业务数据类型
export const login = (data: LoginRequest) =>
http.post<LoginResponse>('/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. 错误处理: 4. 类型处理规则:
- 统一的错误码定义: - http 工具的泛型参数 T 表示业务数据类型
- 401未授权重新登录 - 响应拦截器负责从 Response<T> 中提取 data
- 403拒绝访问 - 服务方法直接使用业务数据类型作为泛型参数
- 404资源未找到 - 组件中可以直接使用返回的业务数据
- 500服务器错误 - TypeScript 类型系统能正确推断类型
- 必须有完整的错误提示信息
- 必须有合理的错误降级策略
- 必须记录必要的错误日志
4. 请求配置: 5. 最佳实践:
- 超时时间30秒 ```typescript
- 重试次数3次 // 服务层 - 使用业务数据类型
- 重试延迟1秒 export const someService = () =>
- Token 使用 Bearer 方案 http.get<SomeType>('/api/v1/xxx'); // 返回 Promise<SomeType>
- Token 存储在 localStorage
- 文件上传下载使用专门的方法 // 组件层 - 直接使用业务数据
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<UploadResponse>('/api/v1/upload', file);
// 特殊处理 - 文件下载
export const downloadFile = (fileId: string) =>
http.download('/api/v1/download', `file-${fileId}.pdf`);
```
## 4. 错误处理规范
1. 统一处理:
```typescript
// 响应拦截器
const responseHandler = (response: AxiosResponse<Response<any>>) => {
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 开发规范 ## 5. React 开发规范
1. 组件开发: 1. 组件开发:
- 使用函数组件和箭头函数定义 - 使用函数组件和箭头函数
- Props 类型必须明确定义 - Props 类型必须定义
- 必须提供默认值 - 必须提供默认值
- 必须有完整的 JSDoc 注释 - 使用 memo 优化
- 必须使用 memo 优化,除非确定不需要 - 复杂组件需拆分
- 复杂组件需要拆分为多个子组件
- 必须处理组件的所有状态和异常情况
2. Hooks 使用: 2. Hooks 使用:
- 自定义 hooks 必须以 `use` 开头 - 自定义 hooks 以 `use` 开头
- 必须使用 TypeScript 泛型 - 使用 TypeScript 泛型
- 必须处理加载状态和错误情况 - 使用 useCallback 和 useMemo
- 必须使用 useCallback 和 useMemo 优化性能 - 使用 Promise.all 处理并行请求
- 避免嵌套 `await`,使用 `Promise.all` 处理并行请求
3. 状态管理: 3. 状态管理:
- 使用 Redux Toolkit - 使用 Redux Toolkit
- Slice 文件命名必须以 `Slice` 结尾 - 持久化数据存储在 localStorage
- Action 类型必须使用 `PayloadAction` - Token、用户信息、菜单统一管理
- 必须定义清晰的状态接口
- 本地存储同步必须在 reducer 中处理
- 状态持久化:
- Token 统一存储在 localStorage
- 用户信息统一存储在 localStorage
- 清理时必须调用统一的 logout action
## 6. 表格组件规范 ## 6. 样式与布局规范
1. 状态管理: 1. CSS 规范:
```typescript - 使用 CSS Modules
interface TableState<T> {
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
- 类名使用 kebab-case - 类名使用 kebab-case
- 避免内联样式,除非是动态计算的值 - 避免内联样式
- 必须响应式适配 - 响应式适配
- 必须考虑暗色主题 - 支持暗色主题
2. 布局规范: 2. 布局规范:
- 统一使用 BasicLayout - 使用 BasicLayout
- 必须实现面包屑导航 - 实现面包屑导航
- 必须处理响应式布局 - 菜单从后端获取
- 必须实现统一的错误页面 - 支持权限控制
- 导航菜单:
- 菜单数据必须从后端获取
- 必须支持多级菜单
- 必须支持菜单权限控制
- 必须支持菜单图标
## 8. 项目配置规范 ## 7. 项目配置规范
1. 依赖管理: 1. 依赖版本:
- 核心依赖版本: - React: ^18.2.0
- React: ^18.2.0 - TypeScript: ^5.3.3
- TypeScript: ^5.3.3 - Ant Design: ^5.22.2
- Ant Design: ^5.22.2 - Redux Toolkit: ^2.0.1
- Redux Toolkit: ^2.0.1
- 开发依赖必须锁定版本
- 生产依赖必须指定大版本
2. 开发规范: 2. 开发规范:
- 必须启用 ESLint - 启用 ESLint 和 Prettier
- 必须使用 Prettier 格式化 - 配置 Git Hooks
- 必须配置 Git Hooks - 使用 .env 管理环境变量
- commit message 遵循 Angular 规范 - 使用 Vite 构建
- 提交前必须进行 lint 和 type check - 配置路由级代码分割
3. 环境配置:
- 使用 .env 文件管理环境变量
- 必须区分开发和生产环境
- 必须配置代理规则
- 必须处理跨域问题
4. 构建优化:
- 使用 Vite 作为构建工具
- 必须配置路径别名
- 必须优化构建性能
- 必须分离开发和生产配置
- 路由级别的代码分割
- 大型第三方库异步加载
5. 安全规范:
- 环境变量必须通过 .env 文件管理
- 禁止在代码中硬编码敏感信息
- 使用 ESLint security 插件
- 小心使用 dangerouslySetInnerHTML
6. 测试规范:
- 单元测试:
- 使用 Jest 和 React Testing Library
- 重要组件必须包含测试用例
- 测试文件以 .test.tsx 结尾
- E2E 测试:
- 使用 Cypress 进行端到端测试
- 覆盖关键业务流程

View File

@ -15,8 +15,7 @@ const modulePath = path.join(__dirname, '../src/pages/System', moduleName);
const directories = [ const directories = [
'', '',
'components', 'components',
'hooks', 'types'
'styles'
]; ];
// 创建目录 // 创建目录
@ -30,7 +29,7 @@ directories.forEach(dir => {
// 创建基础文件 // 创建基础文件
const files = { 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'; import type { BaseQuery } from '@/types/base/query';
// 查询参数 // 查询参数
@ -52,59 +51,111 @@ export interface ${moduleName}Response extends BaseResponse {
// API 服务 // API 服务
'service.ts': `import request from '@/utils/request'; 'service.ts': `import request from '@/utils/request';
import type { ${moduleName}Response, ${moduleName}Request, ${moduleName}Query } from './types'; 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 getList = (params?: ${moduleName}Query) =>
// 获取列表(分页) request.get<${moduleName}Response[]>('/api/v1/${moduleName.toLowerCase()}s', { params });
export const get${moduleName}s = async (params?: ${moduleName}Query) =>
request.get<Page<${moduleName}Response>>(\`\${BASE_URL}/page\`, { params });
// 创建 // 创建
export const create${moduleName} = async (data: ${moduleName}Request) => export const create = (data: ${moduleName}Request) =>
request.post<${moduleName}Response>(BASE_URL, data); request.post<${moduleName}Response>('/api/v1/${moduleName.toLowerCase()}s', data);
// 更新 // 更新
export const update${moduleName} = async (id: number, data: ${moduleName}Request) => export const update = (id: number, data: ${moduleName}Request) =>
request.put<${moduleName}Response>(\`\${BASE_URL}/\${id}\`, data); request.put<${moduleName}Response>(\`/api/v1/${moduleName.toLowerCase()}s/\${id}\`, data);
// 删除 // 删除
export const delete${moduleName} = async (id: number) => export const remove = (id: number) =>
request.delete(\`\${BASE_URL}/\${id}\`); 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'; 'index.tsx': `import React, { useState } from 'react';
import { Card } from 'antd'; import { Card, message } from 'antd';
import type { ${moduleName}Response } from './types'; 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}Table from './components/${moduleName}Table';
import ${moduleName}Form from './components/${moduleName}Form'; import ${moduleName}Form from './components/${moduleName}Form';
import styles from './styles/index.module.css';
const ${moduleName}: React.FC = () => { const ${moduleName}: React.FC = () => {
const { const [loading, setLoading] = useState(false);
list, const [list, setList] = useState<${moduleName}Response[]>([]);
pagination, const [selectedRows, setSelectedRows] = useState<${moduleName}Response[]>([]);
loading,
selectedRows, const loadData = async () => {
handleCreate, setLoading(true);
handleUpdate, try {
handleDelete, const data = await service.getList();
handleSelectionChange setList(data);
} = use${moduleName}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 ( return (
<Card title="${moduleName} 管理" className={styles.container}> <Card title="${moduleName} 管理">
<${moduleName}Form onSubmit={handleCreate} /> <${moduleName}Form onSubmit={handleCreate} />
<${moduleName}Table <${moduleName}Table
loading={loading} loading={loading}
dataSource={list} dataSource={list}
pagination={pagination}
selectedRows={selectedRows} selectedRows={selectedRows}
onDelete={handleDelete} onDelete={handleDelete}
onBatchDelete={handleBatchDelete}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onSelectionChange={handleSelectionChange} onSelectionChange={setSelectedRows}
/> />
</Card> </Card>
); );
@ -117,28 +168,23 @@ export default ${moduleName};
'components/${moduleName}Table.tsx': `import React from 'react'; 'components/${moduleName}Table.tsx': `import React from 'react';
import { Table, Space, Button, Popconfirm } from 'antd'; import { Table, Space, Button, Popconfirm } from 'antd';
import type { ${moduleName}Response } from '../types'; import type { ${moduleName}Response } from '../types';
import styles from '../styles/table.module.css';
interface ${moduleName}TableProps { interface ${moduleName}TableProps {
loading: boolean; loading: boolean;
dataSource: ${moduleName}Response[]; dataSource: ${moduleName}Response[];
pagination: {
current: number;
pageSize: number;
total: number;
};
selectedRows: ${moduleName}Response[]; selectedRows: ${moduleName}Response[];
onDelete: (id: number) => Promise<void>; onDelete: (id: number) => Promise<void>;
onUpdate: (id: number, data: Partial<${moduleName}Response>) => Promise<void>; onBatchDelete: () => Promise<void>;
onSelectionChange: (selectedRows: ${moduleName}Response[]) => void; onUpdate: (id: number, data: ${moduleName}Response) => Promise<void>;
onSelectionChange: (rows: ${moduleName}Response[]) => void;
} }
const ${moduleName}Table: React.FC<${moduleName}TableProps> = ({ const ${moduleName}Table: React.FC<${moduleName}TableProps> = ({
loading, loading,
dataSource, dataSource,
pagination,
selectedRows, selectedRows,
onDelete, onDelete,
onBatchDelete,
onUpdate, onUpdate,
onSelectionChange onSelectionChange
}) => { }) => {
@ -166,18 +212,30 @@ const ${moduleName}Table: React.FC<${moduleName}TableProps> = ({
]; ];
return ( return (
<Table <>
className={styles.table} {selectedRows.length > 0 && (
rowKey="id" <div style={{ marginBottom: 16 }}>
loading={loading} <Popconfirm
dataSource={dataSource} title="确定要删除选中的项目吗?"
columns={columns} onConfirm={onBatchDelete}
pagination={pagination} >
rowSelection={{ <Button type="primary" danger>
selectedRowKeys: selectedRows.map(row => row.id), 批量删除
onChange: (_, rows) => onSelectionChange(rows), </Button>
}} </Popconfirm>
/> </div>
)}
<Table
rowKey="id"
loading={loading}
dataSource={dataSource}
columns={columns}
rowSelection={{
selectedRowKeys: selectedRows.map(row => row.id),
onChange: (_, rows) => onSelectionChange(rows),
}}
/>
</>
); );
}; };
@ -188,7 +246,6 @@ export default ${moduleName}Table;
'components/${moduleName}Form.tsx': `import React from 'react'; 'components/${moduleName}Form.tsx': `import React from 'react';
import { Form, Input, Button } from 'antd'; import { Form, Input, Button } from 'antd';
import type { ${moduleName}Request } from '../types'; import type { ${moduleName}Request } from '../types';
import styles from '../styles/form.module.css';
interface ${moduleName}FormProps { interface ${moduleName}FormProps {
initialValues?: Partial<${moduleName}Request>; initialValues?: Partial<${moduleName}Request>;
@ -202,9 +259,13 @@ const ${moduleName}Form: React.FC<${moduleName}FormProps> = ({
const [form] = Form.useForm(); const [form] = Form.useForm();
const handleSubmit = async () => { const handleSubmit = async () => {
const values = await form.validateFields(); try {
await onSubmit(values); const values = await form.validateFields();
form.resetFields(); await onSubmit(values);
form.resetFields();
} catch (error) {
console.error('表单提交失败:', error);
}
}; };
return ( return (
@ -212,7 +273,7 @@ const ${moduleName}Form: React.FC<${moduleName}FormProps> = ({
form={form} form={form}
layout="inline" layout="inline"
initialValues={initialValues} initialValues={initialValues}
className={styles.form} style={{ marginBottom: 16 }}
> >
{/* TODO: 添加表单项 */} {/* TODO: 添加表单项 */}
<Form.Item> <Form.Item>
@ -225,62 +286,6 @@ const ${moduleName}Form: React.FC<${moduleName}FormProps> = ({
}; };
export default ${moduleName}Form; 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;
}
` `
}; };

View File

@ -15,11 +15,10 @@ import {logout, setMenus} from '../store/userSlice';
import type {MenuProps} from 'antd'; import type {MenuProps} from 'antd';
import {getCurrentUserMenus} from '@/pages/System/Menu/service'; import {getCurrentUserMenus} from '@/pages/System/Menu/service';
import {getWeather} from '../services/weather'; 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 type {RootState} from '../store';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import {MenuResponse, MenuTypeEnum} from "@/pages/System/Menu/types";
const {Header, Content, Sider} = Layout; const {Header, Content, Sider} = Layout;
const {confirm} = Modal; const {confirm} = Modal;
@ -27,28 +26,6 @@ const {confirm} = Modal;
// 设置中文语言 // 设置中文语言
dayjs.locale('zh-cn'); 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 BasicLayout: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -59,6 +36,7 @@ const BasicLayout: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(dayjs()); const [currentTime, setCurrentTime] = useState(dayjs());
const [weather, setWeather] = useState({temp: '--', weather: '未知', city: '未知'}); const [weather, setWeather] = useState({temp: '--', weather: '未知', city: '未知'});
const [openKeys, setOpenKeys] = useState<string[]>([]);
// 将天气获取逻辑提取为useCallback // 将天气获取逻辑提取为useCallback
const fetchWeather = useCallback(async () => { const fetchWeather = useCallback(async () => {
@ -76,7 +54,7 @@ const BasicLayout: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
const menuData = await getCurrentUserMenus(); const menuData = await getCurrentUserMenus();
dispatch(setMenus(cleanEmptyChildren(menuData))); dispatch(setMenus(menuData));
} catch (error) { } catch (error) {
message.error('获取菜单数据失败'); message.error('获取菜单数据失败');
console.error('获取菜单数据失败:', error); console.error('获取菜单数据失败:', error);
@ -158,15 +136,24 @@ const BasicLayout: React.FC = () => {
// 将菜单数据转换为antd Menu需要的格式 // 将菜单数据转换为antd Menu需要的格式
const getMenuItems = (menuList: MenuResponse[]): MenuProps['items'] => { const getMenuItems = (menuList: MenuResponse[]): MenuProps['items'] => {
return menuList return menuList
?.filter(menu => menu.type !== MenuTypeEnum.BUTTON) // 过滤掉按钮类型的菜单 ?.map(menu => {
?.map(menu => ({ // 确保path存在否则使用id
key: menu.path || menu.id.toString(), const key = menu.path || `menu-${menu.id}`;
icon: getIcon(menu.icon), return {
label: menu.name, key,
children: menu.children && menu.children.length > 0 icon: getIcon(menu.icon),
? getMenuItems(menu.children) // 递归处理子菜单 label: menu.name,
: undefined 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) { if (loading) {
@ -202,8 +189,13 @@ const BasicLayout: React.FC = () => {
theme="dark" theme="dark"
mode="inline" mode="inline"
selectedKeys={[location.pathname]} selectedKeys={[location.pathname]}
openKeys={openKeys}
onOpenChange={handleOpenChange}
items={getMenuItems(menus)} items={getMenuItems(menus)}
onClick={({key}) => navigate(key)} onClick={({key}) => {
console.log('Menu click:', key);
navigate(key);
}}
/> />
</Sider> </Sider>
<Layout> <Layout>
@ -239,22 +231,26 @@ const BasicLayout: React.FC = () => {
</Tooltip> </Tooltip>
</Space> </Space>
<Dropdown menu={{items: userMenuItems}} trigger={['hover']}> <Dropdown menu={{items: userMenuItems}} trigger={['hover']}>
<span style={{ <span style={{
cursor: 'pointer', cursor: 'pointer',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
height: '100%', height: '100%',
transition: 'all 0.3s', transition: 'all 0.3s',
padding: '0 4px', padding: '0 4px',
borderRadius: 4, borderRadius: 4
'&:hover': { }}
backgroundColor: 'rgba(0,0,0,0.025)' onMouseEnter={(e) => {
} e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.025)';
}}> }}
<UserOutlined style={{fontSize: 16, marginRight: 8}}/> onMouseLeave={(e) => {
<span>{userInfo?.nickname || userInfo?.username}</span> e.currentTarget.style.backgroundColor = 'transparent';
<DownOutlined style={{fontSize: 12, marginLeft: 6}}/> }}
</span> >
<UserOutlined style={{fontSize: 16, marginRight: 8}}/>
<span>{userInfo?.nickname || userInfo?.username}</span>
<DownOutlined style={{fontSize: 12, marginLeft: 6}}/>
</span>
</Dropdown> </Dropdown>
</Space> </Space>
</Header> </Header>

View File

@ -2,11 +2,12 @@ import React, {useEffect, useState} from 'react';
import {Form, Input, Button, message, Select} from 'antd'; import {Form, Input, Button, message, Select} from 'antd';
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
import {useDispatch} from 'react-redux'; 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 {setToken, setUserInfo, setMenus} from '../../store/userSlice';
import {getCurrentUserMenus} from '@/pages/System/Menu/service'; import {getCurrentUserMenus} from '@/pages/System/Menu/service';
import type { MenuResponse } from '@/pages/System/Menu/types'; 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'; import styles from './index.module.css';
interface LoginForm { interface LoginForm {
@ -25,7 +26,7 @@ const Login: React.FC = () => {
useEffect(() => { useEffect(() => {
const fetchTenants = async () => { const fetchTenants = async () => {
try { try {
const data = await getTenantList(); const data = await getEnabledTenants();
setTenants(data); setTenants(data);
} catch (error) { } catch (error) {
console.error('获取租户列表失败:', error); console.error('获取租户列表失败:', error);
@ -35,44 +36,39 @@ const Login: React.FC = () => {
fetchTenants(); fetchTenants();
}, []); }, []);
// 清理空children的函数同时过滤掉按钮类型菜单 // 加载菜单数据
const cleanEmptyChildren = (menus: MenuResponse[]): MenuResponse[] => { const loadMenuData = async () => {
return menus try {
.filter(menu => menu.type !== 2) // 过滤掉按钮类型的菜单 const menuData = await getCurrentUserMenus();
.map(menu => { if (menuData && menuData.length > 0) {
const cleanedMenu = { ...menu }; dispatch(setMenus(menuData));
}
if (cleanedMenu.children && cleanedMenu.children.length > 0) { } catch (error) {
cleanedMenu.children = cleanEmptyChildren(cleanedMenu.children); console.error('获取菜单数据失败:', error);
if (cleanedMenu.children.length === 0) { }
delete cleanedMenu.children;
}
} else {
delete cleanedMenu.children;
}
return cleanedMenu;
});
}; };
const onFinish = async (values: LoginForm) => { const handleFinish = async (values: LoginForm) => {
setLoading(true);
try { try {
setLoading(true);
// 1. 登录获取 token 和用户信息 // 1. 登录获取 token 和用户信息
const result = await login(values); const loginData = await login(values);
dispatch(setToken(result.token)); dispatch(setToken(loginData.token));
dispatch(setUserInfo(result)); dispatch(setUserInfo({
id: loginData.id,
username: loginData.username,
nickname: loginData.nickname,
email: loginData.email,
phone: loginData.phone
}));
// 2. 获取菜单数据 // 2. 获取菜单数据
const menuData = await getCurrentUserMenus(); await loadMenuData();
dispatch(setMenus(cleanEmptyChildren(menuData)));
message.success('登录成功'); message.success('登录成功');
// 3. 所有数据都准备好后再跳转 navigate('/');
navigate('/dashboard', {replace: true});
} catch (error) { } catch (error) {
console.error('登录失败:', error); console.error('登录失败:', error);
message.error('登录失败,请重试');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -87,7 +83,7 @@ const Login: React.FC = () => {
<Form<LoginForm> <Form<LoginForm>
form={form} form={form}
name="login" name="login"
onFinish={onFinish} onFinish={handleFinish}
autoComplete="off" autoComplete="off"
size="large" size="large"
> >

View File

@ -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 { LoginRequest, LoginResponse } from './types';
import type { TenantResponse } from '../System/Tenant/types'; import type { MenuResponse } from '@/pages/System/Menu/types';
import type { MenuResponse } from '../System/Menu/types'; import type { TenantResponse } from '@/pages/System/Tenant/types';
export const login = async (data: LoginRequest): Promise<LoginResponse> => { // 登录
return request.post('/api/v1/user/login', data, { export const login = (data: LoginRequest) =>
errorMessage: '登录失败,请检查用户名和密码' http.post<LoginResponse>('/api/v1/user/login', data);
});
};
export const logout = async (): Promise<void> => { // 退出登录
return request.post('/api/v1/user/logout', null, { export const logout = () =>
errorMessage: '退出登录失败,请稍后重试' http.post('/api/v1/user/logout');
});
};
export const getCurrentUser = async (): Promise<LoginResponse> => { // 获取当前用户菜单
return request.get('/api/v1/user/current'); export const getCurrentUserMenus = () =>
}; http.get<MenuResponse[]>('/api/v1/menu/current');
export const getUserMenus = async (): Promise<MenuResponse[]> => { // 获取租户列表
return request.get('/api/v1/user/menus', { export const getTenantList = () =>
errorMessage: '获取菜单失败,请刷新页面重试' http.get<TenantResponse[]>('/api/v1/tenant/list');
});
};
export const getTenantList = async (): Promise<TenantResponse[]> => {
return request.get('/api/v1/tenant/list', {
errorMessage: '获取租户列表失败,请刷新重试'
});
};

View File

@ -1,2 +0,0 @@
export * from './request';
export * from './response';

View File

@ -1,5 +0,0 @@
export interface LoginRequest {
username: string;
password: string;
tenantId?: string;
}

View File

@ -1,8 +0,0 @@
export interface LoginResponse {
id: number;
username: string;
nickname?: string;
email?: string;
phone?: string;
token: string;
}

View File

@ -1,7 +0,0 @@
import { BaseQuery } from '@/types/base/query';
export interface DepartmentQuery extends BaseQuery {
name?: string;
code?: string;
enabled?: boolean;
}

View File

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

View File

@ -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[];
}

View File

@ -13,9 +13,9 @@ export const getMenuTree = () =>
request.get<MenuResponse[]>(`${BASE_URL}/tree`); request.get<MenuResponse[]>(`${BASE_URL}/tree`);
// 获取当前用户菜单 // 获取当前用户菜单
export const getCurrentUserMenus = () => { export const getCurrentUserMenus = () =>
return request.get<MenuResponse[]>(`${BASE_URL}/current`); request.get<MenuResponse[]>(`${BASE_URL}/current`);
}
// 创建菜单 // 创建菜单
export const createMenu = (data: MenuRequest) => export const createMenu = (data: MenuRequest) =>

View File

@ -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 { export enum MenuTypeEnum {
DIRECTORY = 1, // 目录 DIRECTORY = 1, // 目录
@ -6,23 +8,12 @@ export enum MenuTypeEnum {
BUTTON = 3 // 按钮 BUTTON = 3 // 按钮
} }
export const MenuTypeNames = { export interface MenuQuery extends BaseQuery {
[MenuTypeEnum.DIRECTORY]: '目录',
[MenuTypeEnum.MENU]: '菜单',
[MenuTypeEnum.BUTTON]: '按钮'
};
export interface MenuQuery {
pageNum?: number;
pageSize?: number;
sortField?: string;
sortOrder?: 'ascend' | 'descend';
name?: string; name?: string;
type?: MenuTypeEnum; type?: MenuTypeEnum;
enabled?: boolean;
} }
export interface MenuRequest { export interface MenuRequest extends BaseRequest {
name: string; name: string;
type: MenuTypeEnum; type: MenuTypeEnum;
parentId?: number; parentId?: number;
@ -45,4 +36,4 @@ export interface MenuResponse extends BaseResponse {
sort: number; sort: number;
hidden: boolean; hidden: boolean;
children?: MenuResponse[]; children?: MenuResponse[];
} }

View File

@ -1,3 +0,0 @@
export * from './query';
export * from './request';
export * from './response';

View File

@ -1,7 +0,0 @@
import { BaseQuery } from '@/types/base/query';
export interface MenuQuery extends BaseQuery {
name?: string;
path?: string;
enabled?: boolean;
}

View File

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

View File

@ -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[];
}

View File

@ -3,31 +3,22 @@ import type { TenantResponse, TenantRequest, TenantQuery } from './types';
export const getTenants = async (params?: TenantQuery) => { export const getTenants = async (params?: TenantQuery) => {
return request.get('/api/v1/tenant', { return request.get('/api/v1/tenant', {
params, params
errorMessage: '获取租户列表失败,请刷新重试'
}); });
}; };
export const createTenant = async (data: TenantRequest) => { export const createTenant = async (data: TenantRequest) => {
return request.post('/api/v1/tenant', data, { return request.post('/api/v1/tenant', data);
errorMessage: '创建租户失败,请稍后重试'
});
}; };
export const updateTenant = async (id: number, data: TenantRequest) => { export const updateTenant = async (id: number, data: TenantRequest) => {
return request.put(`/api/v1/tenant/${id}`, data, { return request.put(`/api/v1/tenant/${id}`, data);
errorMessage: '更新租户失败,请稍后重试'
});
}; };
export const deleteTenant = async (id: number) => { export const deleteTenant = async (id: number) => {
return request.delete(`/api/v1/tenant/${id}`, { return request.delete(`/api/v1/tenant/${id}`);
errorMessage: '删除租户失败,请稍后重试'
});
}; };
export const getEnabledTenants = async () => { export const getEnabledTenants = async () => {
return request.get('/api/v1/tenant/enabled', { return request.get('/api/v1/tenant/list');
errorMessage: '获取可用租户列表失败,请刷新重试'
});
}; };

View File

@ -1,3 +0,0 @@
export * from './query';
export * from './request';
export * from './response';

View File

@ -1,7 +0,0 @@
import { BaseQuery } from '@/types/base/query';
export interface TenantQuery extends BaseQuery {
name?: string;
code?: string;
enabled?: boolean;
}

View File

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

View File

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

View File

@ -1,3 +0,0 @@
export * from './query';
export * from './request';
export * from './response';

View File

@ -1,8 +0,0 @@
import { BaseQuery } from '@/types/base/query';
export interface UserQuery extends BaseQuery {
username?: string;
nickname?: string;
email?: string;
enabled?: boolean;
}

View File

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

View File

@ -1,8 +0,0 @@
import { BaseResponse } from '@/types/base/response';
export interface UserResponse extends BaseResponse {
username: string;
nickname?: string;
email?: string;
phone?: string;
}

View File

@ -18,7 +18,7 @@ interface UserState {
const initialState: UserState = { const initialState: UserState = {
token: localStorage.getItem('token'), token: localStorage.getItem('token'),
userInfo: localStorage.getItem('userInfo') ? JSON.parse(localStorage.getItem('userInfo')!) : null, userInfo: localStorage.getItem('userInfo') ? JSON.parse(localStorage.getItem('userInfo')!) : null,
menus: [] menus: localStorage.getItem('menus') ? JSON.parse(localStorage.getItem('menus')!) : []
}; };
const userSlice = createSlice({ const userSlice = createSlice({
@ -35,6 +35,9 @@ const userSlice = createSlice({
}, },
setMenus: (state, action: PayloadAction<MenuResponse[]>) => { setMenus: (state, action: PayloadAction<MenuResponse[]>) => {
state.menus = action.payload; state.menus = action.payload;
console.log(action.payload)
// console.log(JSON.stringify(action.payload))
// localStorage.setItem('menus', action.payload);
}, },
logout: (state) => { logout: (state) => {
state.token = null; state.token = null;
@ -43,6 +46,7 @@ const userSlice = createSlice({
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('tenantId'); localStorage.removeItem('tenantId');
localStorage.removeItem('userInfo'); localStorage.removeItem('userInfo');
localStorage.removeItem('menus');
} }
} }
}); });

View File

@ -1,3 +1,3 @@
export interface BaseRequest { export interface BaseRequest {
enabled?: boolean; enabled?: boolean;
} }

View File

@ -45,7 +45,7 @@ const responseHandler = (response: AxiosResponse<Response<any>>) => {
if (result.success && result.code === 200) { if (result.success && result.code === 200) {
return result.data; return result.data;
} else { } else {
if (result.message !== undefined) { if (result.message != undefined) {
message.error(result.message || defaultErrorMessage); message.error(result.message || defaultErrorMessage);
return Promise.reject(response); return Promise.reject(response);
} }
@ -104,18 +104,18 @@ const http = {
request.get<any, T>(url, createRequestConfig(config)), request.get<any, T>(url, createRequestConfig(config)),
post: <T = any>(url: string, data?: any, config?: RequestOptions) => post: <T = any>(url: string, data?: any, config?: RequestOptions) =>
request.post<any, Response<T>>(url, data, createRequestConfig(config)), request.post<any, T>(url, data, createRequestConfig(config)),
put: <T = any>(url: string, data?: any, config?: RequestOptions) => put: <T = any>(url: string, data?: any, config?: RequestOptions) =>
request.put<any, Response<T>>(url, data, createRequestConfig(config)), request.put<any, T>(url, data, createRequestConfig(config)),
delete: <T = any>(url: string, config?: RequestOptions) => delete: <T = any>(url: string, config?: RequestOptions) =>
request.delete<any, Response<T>>(url, createRequestConfig(config)), request.delete<any, T>(url, createRequestConfig(config)),
upload: <T = any>(url: string, file: File, config?: RequestOptions) => { upload: <T = any>(url: string, file: File, config?: RequestOptions) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return request.post<any, Response<T>>(url, formData, createRequestConfig({ return request.post<any, T>(url, formData, createRequestConfig({
headers: {'Content-Type': 'multipart/form-data'}, headers: {'Content-Type': 'multipart/form-data'},
...config ...config
})); }));