This commit is contained in:
gpu 2026-01-05 00:05:04 +08:00
parent e4505e0370
commit e0a4bc4ba2
16 changed files with 877 additions and 15 deletions

View File

@ -0,0 +1,188 @@
# Design Document: Menu CRUD
## Overview
完善菜单管理页面的 CRUD 功能。后端 API 已完整实现,本设计主要关注前端实现:补充 API 接口定义、实现表单对话框、完善操作逻辑。
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Menu Page (index.vue) │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Menu Table │ │ Menu Dialog │ │ Delete Confirm │ │
│ │ (Tree View) │ │ (Form) │ │ Dialog │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Menu API (menu.ts) │
│ getMenuTree | createMenu | updateMenu | deleteMenu │
├─────────────────────────────────────────────────────────┤
│ Backend API (已实现) │
│ GET /menus | POST /menus | PUT /menus/{id} | DELETE │
└─────────────────────────────────────────────────────────┘
```
## Components and Interfaces
### Menu API 接口补充
```typescript
// 创建菜单请求
export interface CreateMenuRequest {
parentId: number
name: string
path?: string
component?: string
icon?: string
menuType: number // 1=目录, 2=菜单, 3=按钮
permission?: string
sortOrder: number
status: number // 1=显示, 0=隐藏
isExternal: boolean
isCache: boolean
}
// 更新菜单请求
export interface UpdateMenuRequest {
parentId: number
name: string
path?: string
component?: string
icon?: string
menuType: number
permission?: string
sortOrder: number
status: number
isExternal: boolean
isCache: boolean
}
// API 函数
export function createMenu(data: CreateMenuRequest): Promise<ApiResponse<number>>
export function updateMenu(id: number, data: UpdateMenuRequest): Promise<ApiResponse<void>>
export function deleteMenu(id: number): Promise<ApiResponse<void>>
export function getMenuById(id: number): Promise<ApiResponse<MenuTree>>
```
### Menu Form 表单字段
| 字段 | 类型 | 组件 | 验证规则 |
|------|------|------|----------|
| parentId | number | el-tree-select | 可选默认0表示顶级 |
| name | string | el-input | 必填 |
| menuType | number | el-radio-group | 必填1/2/3 |
| icon | string | el-input | 可选,目录/菜单时显示 |
| path | string | el-input | 菜单类型时必填 |
| component | string | el-input | 菜单类型时必填 |
| permission | string | el-input | 按钮类型时必填 |
| sortOrder | number | el-input-number | 必填,>=0 |
| status | number | el-radio-group | 必填0/1 |
| isExternal | boolean | el-switch | 可选 |
| isCache | boolean | el-switch | 可选 |
### 表单验证逻辑
```typescript
const formRules = {
name: [{ required: true, message: '请输入菜单名称' }],
menuType: [{ required: true, message: '请选择菜单类型' }],
path: [{
required: true,
validator: (rule, value, callback) => {
if (formData.menuType === 2 && !value) {
callback(new Error('菜单类型必须填写路由路径'))
} else {
callback()
}
}
}],
component: [{
required: true,
validator: (rule, value, callback) => {
if (formData.menuType === 2 && !value) {
callback(new Error('菜单类型必须填写组件路径'))
} else {
callback()
}
}
}],
permission: [{
validator: (rule, value, callback) => {
if (formData.menuType === 3 && !value) {
callback(new Error('按钮类型必须填写权限标识'))
} else {
callback()
}
}
}]
}
```
## Data Models
### 表单数据模型
```typescript
interface MenuFormData {
id: number
parentId: number
name: string
path: string
component: string
icon: string
menuType: number
permission: string
sortOrder: number
status: number
isExternal: boolean
isCache: boolean
}
// 默认值
const defaultFormData: MenuFormData = {
id: 0,
parentId: 0,
name: '',
path: '',
component: '',
icon: '',
menuType: 2,
permission: '',
sortOrder: 0,
status: 1,
isExternal: false,
isCache: true
}
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system.*
本功能主要是 UI 交互,大部分验证在后端完成。以下是可测试的属性:
**Property 1: 表单提交数据完整性**
*For any* valid form submission, the request payload SHALL contain all required fields based on menuType.
**Validates: Requirements 6.1, 6.2, 6.3**
**Property 2: 父菜单选择排除自身**
*For any* menu being edited, the parent menu selector SHALL NOT include the menu itself or its descendants.
**Validates: Requirements 4.3**
## Error Handling
| 场景 | 处理方式 |
|------|----------|
| API 请求失败 | 显示 ElMessage.error 提示错误信息 |
| 表单验证失败 | 阻止提交,显示字段错误提示 |
| 删除有子菜单的菜单 | 后端返回错误,前端显示提示 |
| 网络超时 | 显示网络错误提示 |
## Testing Strategy
由于本功能主要是 UI 交互,测试策略以手动测试为主:
1. **功能测试**:验证新增、编辑、删除操作正常工作
2. **表单验证测试**:验证必填字段和条件必填逻辑
3. **边界测试**:测试空数据、特殊字符等边界情况

View File

@ -0,0 +1,84 @@
# Requirements Document
## Introduction
完善 HoneyBox.Admin 后台管理系统的菜单管理页面功能。当前菜单管理页面只有展示功能,需要实现完整的 CRUD 操作,包括新增菜单、编辑菜单、添加子菜单和删除菜单。
## Glossary
- **Menu_Page**: 菜单管理页面组件
- **Menu_API**: 前端菜单 API 模块
- **Menu_Form**: 菜单表单对话框组件
- **Menu_Tree**: 菜单树形表格
## Requirements
### Requirement 1: 前端 API 补充
**User Story:** As a developer, I want to have complete menu API functions, so that the frontend can call backend CRUD operations.
#### Acceptance Criteria
1. THE Menu_API SHALL export createMenu function to call POST /api/admin/menus
2. THE Menu_API SHALL export updateMenu function to call PUT /api/admin/menus/{id}
3. THE Menu_API SHALL export deleteMenu function to call DELETE /api/admin/menus/{id}
4. THE Menu_API SHALL export getMenuById function to call GET /api/admin/menus/{id}
5. THE Menu_API SHALL define CreateMenuRequest and UpdateMenuRequest TypeScript interfaces
### Requirement 2: 新增菜单功能
**User Story:** As an administrator, I want to add new menus, so that I can expand the system navigation structure.
#### Acceptance Criteria
1. WHEN the user clicks "新增菜单" button, THE Menu_Page SHALL display a form dialog
2. THE Menu_Form SHALL include fields: parentId (tree select), name, path, component, icon, menuType, permission, sortOrder, status, isExternal, isCache
3. WHEN the user submits a valid form, THE Menu_Page SHALL call createMenu API and refresh the menu tree
4. IF the API returns an error, THEN THE Menu_Page SHALL display the error message
5. WHEN the form is submitted successfully, THE Menu_Page SHALL close the dialog and show success message
### Requirement 3: 添加子菜单功能
**User Story:** As an administrator, I want to add child menus under existing menus, so that I can create hierarchical navigation.
#### Acceptance Criteria
1. WHEN the user clicks "添加子菜单" button on a menu row, THE Menu_Page SHALL display the form dialog with parentId pre-filled
2. THE Menu_Form SHALL show the parent menu name in the parentId field
3. WHEN the user submits the form, THE Menu_Page SHALL create the menu as a child of the selected parent
### Requirement 4: 编辑菜单功能
**User Story:** As an administrator, I want to edit existing menus, so that I can update menu information.
#### Acceptance Criteria
1. WHEN the user clicks "编辑" button on a menu row, THE Menu_Page SHALL display the form dialog with existing data
2. THE Menu_Form SHALL populate all fields with the current menu data
3. THE Menu_Form SHALL allow changing the parent menu (with validation to prevent circular reference)
4. WHEN the user submits a valid form, THE Menu_Page SHALL call updateMenu API and refresh the menu tree
5. IF the API returns an error, THEN THE Menu_Page SHALL display the error message
### Requirement 5: 删除菜单功能
**User Story:** As an administrator, I want to delete menus, so that I can remove unused navigation items.
#### Acceptance Criteria
1. WHEN the user clicks "删除" button on a menu row, THE Menu_Page SHALL display a confirmation dialog
2. THE confirmation dialog SHALL warn about deleting child menus if the menu has children
3. WHEN the user confirms deletion, THE Menu_Page SHALL call deleteMenu API and refresh the menu tree
4. IF the API returns an error, THEN THE Menu_Page SHALL display the error message
5. WHEN deletion is successful, THE Menu_Page SHALL show success message
### Requirement 6: 表单验证
**User Story:** As an administrator, I want form validation, so that I can ensure data integrity.
#### Acceptance Criteria
1. THE Menu_Form SHALL require name field (non-empty)
2. WHEN menuType is "菜单" (type 2), THE Menu_Form SHALL require path and component fields
3. WHEN menuType is "按钮" (type 3), THE Menu_Form SHALL require permission field
4. THE Menu_Form SHALL validate path format (must start with / for internal routes)
5. THE Menu_Form SHALL validate sortOrder as a non-negative integer

View File

@ -0,0 +1,84 @@
# Implementation Plan: Menu CRUD
## Overview
完善菜单管理页面的 CRUD 功能。后端 API 已完整实现,本计划专注于前端实现。
## Tasks
- [x] 1. 补充前端 Menu API 接口
- [x] 1.1 添加 TypeScript 接口定义
- 添加 CreateMenuRequest 接口
- 添加 UpdateMenuRequest 接口
- _Requirements: 1.5_
- [x] 1.2 添加 API 函数
- 添加 createMenu 函数 (POST /admin/menus)
- 添加 updateMenu 函数 (PUT /admin/menus/{id})
- 添加 deleteMenu 函数 (DELETE /admin/menus/{id})
- 添加 getMenuById 函数 (GET /admin/menus/{id})
- _Requirements: 1.1, 1.2, 1.3, 1.4_
- [x] 2. 实现菜单表单对话框
- [x] 2.1 添加表单状态和数据
- 添加 dialogVisible, isEdit, formRef, submitLoading 状态
- 添加 formData reactive 对象
- 添加 menuTreeForSelect 计算属性(用于父菜单选择)
- _Requirements: 2.1, 2.2_
- [x] 2.2 添加表单验证规则
- 实现 name 必填验证
- 实现 menuType 条件验证(菜单类型需要 path/component按钮类型需要 permission
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
- [x] 2.3 添加表单对话框模板
- 添加 el-dialog 组件
- 添加 el-form 包含所有字段
- 添加 el-tree-select 用于父菜单选择
- 根据 menuType 动态显示/隐藏字段
- _Requirements: 2.2, 3.2_
- [x] 3. 实现新增菜单功能
- [x] 3.1 实现 handleAdd 方法
- 重置表单数据为默认值
- 设置 isEdit = false
- 打开对话框
- _Requirements: 2.1_
- [x] 3.2 实现 handleAddChild 方法
- 重置表单数据
- 设置 parentId 为当前行的 id
- 打开对话框
- _Requirements: 3.1, 3.2, 3.3_
- [x] 4. 实现编辑菜单功能
- [x] 4.1 实现 handleEdit 方法
- 设置 isEdit = true
- 填充表单数据
- 打开对话框
- _Requirements: 4.1, 4.2_
- [x] 5. 实现表单提交功能
- [x] 5.1 实现 handleSubmit 方法
- 验证表单
- 根据 isEdit 调用 createMenu 或 updateMenu API
- 成功后关闭对话框并刷新数据
- 失败时显示错误信息
- _Requirements: 2.3, 2.4, 2.5, 4.4, 4.5_
- [x] 6. 实现删除菜单功能
- [x] 6.1 实现 handleDelete 方法
- 显示确认对话框
- 调用 deleteMenu API
- 成功后刷新数据
- 失败时显示错误信息
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
- [x] 7. Checkpoint - 功能验证
- 验证新增菜单功能正常
- 验证添加子菜单功能正常
- 验证编辑菜单功能正常
- 验证删除菜单功能正常
- 验证表单验证逻辑正确
## Notes
- 后端 API 已完整实现,无需修改后端代码
- 参考角色管理页面 (role/index.vue) 和部门管理页面 (department/index.vue) 的实现模式
- 表单字段根据 menuType 动态显示:目录(1)显示图标,菜单(2)显示路径/组件/图标,按钮(3)显示权限标识

View File

@ -1,4 +1,5 @@
using System.Security.Claims;
using HoneyBox.Admin.Filters;
using HoneyBox.Admin.Models.AdminUser;
using HoneyBox.Admin.Models.Common;
using HoneyBox.Admin.Services;
@ -54,6 +55,7 @@ public class AdminUserController : ControllerBase
/// <param name="request">创建请求</param>
/// <returns>新管理员ID</returns>
[HttpPost]
[OperationLog("管理员管理", "创建管理员")]
public async Task<ApiResponse<long>> Create([FromBody] CreateAdminUserRequest request)
{
var createdBy = GetCurrentUserId();
@ -67,6 +69,7 @@ public class AdminUserController : ControllerBase
/// <param name="id">管理员ID</param>
/// <param name="request">更新请求</param>
[HttpPut("{id:long}")]
[OperationLog("管理员管理", "更新管理员")]
public async Task<ApiResponse> Update(long id, [FromBody] UpdateAdminUserRequest request)
{
await _adminUserService.UpdateAsync(id, request);
@ -78,6 +81,7 @@ public class AdminUserController : ControllerBase
/// </summary>
/// <param name="id">管理员ID</param>
[HttpDelete("{id:long}")]
[OperationLog("管理员管理", "删除管理员")]
public async Task<ApiResponse> Delete(long id)
{
await _adminUserService.DeleteAsync(id);
@ -102,6 +106,7 @@ public class AdminUserController : ControllerBase
/// <param name="id">管理员ID</param>
/// <param name="request">分配请求</param>
[HttpPut("{id:long}/roles")]
[OperationLog("管理员管理", "分配角色")]
public async Task<ApiResponse> AssignRoles(long id, [FromBody] AssignRolesRequest request)
{
await _adminUserService.AssignRolesAsync(id, request.RoleIds);
@ -126,6 +131,7 @@ public class AdminUserController : ControllerBase
/// <param name="id">管理员ID</param>
/// <param name="request">分配请求</param>
[HttpPut("{id:long}/menus")]
[OperationLog("管理员管理", "分配专属菜单")]
public async Task<ApiResponse> AssignMenus(long id, [FromBody] AssignUserMenusRequest request)
{
await _adminUserService.AssignMenusAsync(id, request.MenuIds);
@ -138,6 +144,7 @@ public class AdminUserController : ControllerBase
/// <param name="id">管理员ID</param>
/// <param name="request">分配请求</param>
[HttpPut("{id:long}/department")]
[OperationLog("管理员管理", "分配部门")]
public async Task<ApiResponse> AssignDepartment(long id, [FromBody] AssignDepartmentRequest request)
{
await _adminUserService.AssignDepartmentAsync(id, request.DepartmentId);
@ -150,6 +157,7 @@ public class AdminUserController : ControllerBase
/// <param name="id">管理员ID</param>
/// <param name="request">状态请求</param>
[HttpPut("{id:long}/status")]
[OperationLog("管理员管理", "设置状态")]
public async Task<ApiResponse> SetStatus(long id, [FromBody] SetStatusRequest request)
{
await _adminUserService.SetStatusAsync(id, request.Status == 1);
@ -162,6 +170,7 @@ public class AdminUserController : ControllerBase
/// <param name="id">管理员ID</param>
/// <param name="request">重置密码请求</param>
[HttpPut("{id:long}/reset-password")]
[OperationLog("管理员管理", "重置密码")]
public async Task<ApiResponse> ResetPassword(long id, [FromBody] ResetPasswordRequest request)
{
await _adminUserService.ResetPasswordAsync(id, request.NewPassword);

View File

@ -1,4 +1,5 @@
using System.Security.Claims;
using HoneyBox.Admin.Filters;
using HoneyBox.Admin.Models.Auth;
using HoneyBox.Admin.Models.Common;
using HoneyBox.Admin.Services;
@ -30,6 +31,7 @@ public class AuthController : ControllerBase
/// <returns>登录响应</returns>
[HttpPost("login")]
[AllowAnonymous]
[OperationLog("认证", "登录")]
public async Task<ApiResponse<LoginResponse>> Login([FromBody] LoginRequest request)
{
var ipAddress = GetClientIpAddress();
@ -56,6 +58,7 @@ public class AuthController : ControllerBase
/// <param name="request">修改密码请求</param>
[HttpPut("password")]
[Authorize]
[OperationLog("认证", "修改密码")]
public async Task<ApiResponse> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userId = GetCurrentUserId();
@ -68,6 +71,7 @@ public class AuthController : ControllerBase
/// </summary>
[HttpPost("logout")]
[Authorize]
[OperationLog("认证", "退出登录")]
public async Task<ApiResponse> Logout()
{
var userId = GetCurrentUserId();

View File

@ -1,3 +1,4 @@
using HoneyBox.Admin.Filters;
using HoneyBox.Admin.Models.AdminUser;
using HoneyBox.Admin.Models.Common;
using HoneyBox.Admin.Models.Department;
@ -53,6 +54,7 @@ public class DepartmentController : ControllerBase
/// <param name="request">创建请求</param>
/// <returns>新部门ID</returns>
[HttpPost]
[OperationLog("部门管理", "创建部门")]
public async Task<ApiResponse<long>> Create([FromBody] CreateDepartmentRequest request)
{
var id = await _departmentService.CreateAsync(request);
@ -65,6 +67,7 @@ public class DepartmentController : ControllerBase
/// <param name="id">部门ID</param>
/// <param name="request">更新请求</param>
[HttpPut("{id:long}")]
[OperationLog("部门管理", "更新部门")]
public async Task<ApiResponse> Update(long id, [FromBody] UpdateDepartmentRequest request)
{
await _departmentService.UpdateAsync(id, request);
@ -76,6 +79,7 @@ public class DepartmentController : ControllerBase
/// </summary>
/// <param name="id">部门ID</param>
[HttpDelete("{id:long}")]
[OperationLog("部门管理", "删除部门")]
public async Task<ApiResponse> Delete(long id)
{
await _departmentService.DeleteAsync(id);
@ -88,6 +92,7 @@ public class DepartmentController : ControllerBase
/// <param name="id">部门ID</param>
/// <param name="request">分配请求</param>
[HttpPut("{id:long}/menus")]
[OperationLog("部门管理", "分配菜单")]
public async Task<ApiResponse> AssignMenus(long id, [FromBody] AssignDepartmentMenusRequest request)
{
await _departmentService.AssignMenusAsync(id, request.MenuIds);

View File

@ -54,6 +54,7 @@ public class MenuController : ControllerBase
/// <param name="request">创建请求</param>
/// <returns>新菜单ID</returns>
[HttpPost]
[OperationLog("菜单管理", "创建菜单")]
public async Task<ApiResponse<long>> Create([FromBody] CreateMenuRequest request)
{
var id = await _menuService.CreateAsync(request);
@ -66,6 +67,7 @@ public class MenuController : ControllerBase
/// <param name="id">菜单ID</param>
/// <param name="request">更新请求</param>
[HttpPut("{id:long}")]
[OperationLog("菜单管理", "更新菜单")]
public async Task<ApiResponse> Update(long id, [FromBody] UpdateMenuRequest request)
{
await _menuService.UpdateAsync(id, request);
@ -77,6 +79,7 @@ public class MenuController : ControllerBase
/// </summary>
/// <param name="id">菜单ID</param>
[HttpDelete("{id:long}")]
[OperationLog("菜单管理", "删除菜单")]
public async Task<ApiResponse> Delete(long id)
{
await _menuService.DeleteAsync(id);

View File

@ -1,3 +1,4 @@
using HoneyBox.Admin.Filters;
using HoneyBox.Admin.Models.Common;
using HoneyBox.Admin.Models.Role;
using HoneyBox.Admin.Services;
@ -64,6 +65,7 @@ public class RoleController : ControllerBase
/// <param name="request">创建请求</param>
/// <returns>新角色ID</returns>
[HttpPost]
[OperationLog("角色管理", "创建角色")]
public async Task<ApiResponse<long>> Create([FromBody] CreateRoleRequest request)
{
var id = await _roleService.CreateAsync(request);
@ -76,6 +78,7 @@ public class RoleController : ControllerBase
/// <param name="id">角色ID</param>
/// <param name="request">更新请求</param>
[HttpPut("{id:long}")]
[OperationLog("角色管理", "更新角色")]
public async Task<ApiResponse> Update(long id, [FromBody] UpdateRoleRequest request)
{
await _roleService.UpdateAsync(id, request);
@ -87,6 +90,7 @@ public class RoleController : ControllerBase
/// </summary>
/// <param name="id">角色ID</param>
[HttpDelete("{id:long}")]
[OperationLog("角色管理", "删除角色")]
public async Task<ApiResponse> Delete(long id)
{
await _roleService.DeleteAsync(id);
@ -111,6 +115,7 @@ public class RoleController : ControllerBase
/// <param name="id">角色ID</param>
/// <param name="request">分配请求</param>
[HttpPut("{id:long}/menus")]
[OperationLog("角色管理", "分配菜单")]
public async Task<ApiResponse> AssignMenus(long id, [FromBody] AssignMenusRequest request)
{
await _roleService.AssignMenusAsync(id, request.MenuIds);
@ -135,6 +140,7 @@ public class RoleController : ControllerBase
/// <param name="id">角色ID</param>
/// <param name="request">分配请求</param>
[HttpPut("{id:long}/permissions")]
[OperationLog("角色管理", "分配权限")]
public async Task<ApiResponse> AssignPermissions(long id, [FromBody] AssignPermissionsRequest request)
{
await _roleService.AssignPermissionsAsync(id, request.PermissionCodes);

View File

@ -74,9 +74,8 @@ public static class ServiceCollectionExtensions
// 启用静态文件服务
app.UseStaticFiles();
// 启用认证和授权
// 启用认证 (Authorization 需要在 UseRouting 之后调用,所以不在这里调用)
app.UseAuthentication();
app.UseAuthorization();
return app;
}

View File

@ -40,11 +40,14 @@ if (app.Environment.IsDevelopment())
app.MapScalarApiReference();
}
// Use HoneyBox Admin middleware (Static files, Authentication, Authorization)
// Use HoneyBox Admin middleware (Static files, Authentication)
app.UseHoneyBoxAdmin();
app.UseRouting();
// Authorization must be between UseRouting and MapControllers
app.UseAuthorization();
app.MapControllers();
// SPA fallback - serve index.html for non-API routes

View File

@ -17,6 +17,36 @@ export interface MenuTree {
children: MenuTree[]
}
// 创建菜单请求
export interface CreateMenuRequest {
parentId: number
name: string
path?: string
component?: string
icon?: string
menuType: number // 1=目录, 2=菜单, 3=按钮
permission?: string
sortOrder: number
status: number // 1=显示, 0=隐藏
isExternal: boolean
isCache: boolean
}
// 更新菜单请求
export interface UpdateMenuRequest {
parentId: number
name: string
path?: string
component?: string
icon?: string
menuType: number // 1=目录, 2=菜单, 3=按钮
permission?: string
sortOrder: number
status: number // 1=显示, 0=隐藏
isExternal: boolean
isCache: boolean
}
// 获取用户菜单
export function getUserMenus(): Promise<ApiResponse<MenuTree[]>> {
return request({
@ -32,3 +62,37 @@ export function getMenuTree(): Promise<ApiResponse<MenuTree[]>> {
method: 'get'
})
}
// 获取菜单详情
export function getMenuById(id: number): Promise<ApiResponse<MenuTree>> {
return request({
url: `/admin/menus/${id}`,
method: 'get'
})
}
// 创建菜单
export function createMenu(data: CreateMenuRequest): Promise<ApiResponse<number>> {
return request({
url: '/admin/menus',
method: 'post',
data
})
}
// 更新菜单
export function updateMenu(id: number, data: UpdateMenuRequest): Promise<ApiResponse<null>> {
return request({
url: `/admin/menus/${id}`,
method: 'put',
data
})
}
// 删除菜单
export function deleteMenu(id: number): Promise<ApiResponse<null>> {
return request({
url: `/admin/menus/${id}`,
method: 'delete'
})
}

View File

@ -16,6 +16,32 @@ const constantRoutes: RouteRecordRaw[] = [
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { title: '404' }
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/layout/index.vue'),
meta: { title: '个人中心' },
children: [
{
path: '',
component: () => import('@/views/profile/index.vue'),
meta: { title: '个人中心' }
}
]
},
{
path: '/password',
name: 'Password',
component: () => import('@/layout/index.vue'),
meta: { title: '修改密码' },
children: [
{
path: '',
component: () => import('@/views/password/index.vue'),
meta: { title: '修改密码' }
}
]
}
]

View File

@ -0,0 +1,114 @@
<template>
<div class="page-container">
<el-card>
<template #header>
<span>修改密码</span>
</template>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
style="max-width: 400px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="formData.oldPassword"
type="password"
placeholder="请输入原密码"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="formData.newPassword"
type="password"
placeholder="请输入新密码"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="formData.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit" :loading="loading">
确认修改
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { changePassword } from '@/api/auth'
const formRef = ref<FormInstance>()
const loading = ref(false)
const formData = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const formRules: FormRules = {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value !== formData.newPassword) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
loading.value = true
try {
await changePassword({
oldPassword: formData.oldPassword,
newPassword: formData.newPassword
})
ElMessage.success('密码修改成功')
//
formData.oldPassword = ''
formData.newPassword = ''
formData.confirmPassword = ''
} catch (error: any) {
ElMessage.error(error.message || '修改失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.page-container {
padding: 0;
}
</style>

View File

@ -0,0 +1,36 @@
<template>
<div class="page-container">
<el-card>
<template #header>
<span>个人中心</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="用户名">{{ userInfo?.username }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ userInfo?.realName || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ userInfo?.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ userInfo?.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="部门">{{ userInfo?.departmentName || '-' }}</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag v-for="role in userInfo?.roles" :key="role" size="small" style="margin-right: 4px">
{{ role }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
</script>
<style scoped>
.page-container {
padding: 0;
}
</style>

View File

@ -49,14 +49,77 @@
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px" @close="resetForm">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="上级菜单" prop="parentId">
<el-tree-select
v-model="formData.parentId"
:data="menuTreeForSelect"
:props="{ label: 'name', value: 'id', children: 'children' }"
check-strictly
:render-after-expand="false"
placeholder="请选择上级菜单"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单类型" prop="menuType">
<el-radio-group v-model="formData.menuType">
<el-radio :value="1">目录</el-radio>
<el-radio :value="2">菜单</el-radio>
<el-radio :value="3">按钮</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="菜单名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入菜单名称" />
</el-form-item>
<!-- 目录和菜单显示图标 -->
<el-form-item v-if="formData.menuType !== 3" label="图标" prop="icon">
<el-input v-model="formData.icon" placeholder="请输入图标名称,如 Setting" />
</el-form-item>
<!-- 菜单类型显示路由路径和组件路径 -->
<el-form-item v-if="formData.menuType === 2" label="路由路径" prop="path">
<el-input v-model="formData.path" placeholder="请输入路由路径,如 /system/menu" />
</el-form-item>
<el-form-item v-if="formData.menuType === 2" label="组件路径" prop="component">
<el-input v-model="formData.component" placeholder="请输入组件路径,如 system/menu/index" />
</el-form-item>
<!-- 按钮类型显示权限标识 -->
<el-form-item v-if="formData.menuType === 3" label="权限标识" prop="permission">
<el-input v-model="formData.permission" placeholder="请输入权限标识,如 menu:create" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="formData.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">显示</el-radio>
<el-radio :value="0">隐藏</el-radio>
</el-radio-group>
</el-form-item>
<!-- 菜单类型显示外链和缓存选项 -->
<el-form-item v-if="formData.menuType === 2" label="是否外链" prop="isExternal">
<el-switch v-model="formData.isExternal" />
</el-form-item>
<el-form-item v-if="formData.menuType === 2" label="是否缓存" prop="isCache">
<el-switch v-model="formData.isCache" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMenuTree, type MenuTree } from '@/api/menu'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getMenuTree, createMenu, updateMenu, deleteMenu, type MenuTree, type CreateMenuRequest, type UpdateMenuRequest } from '@/api/menu'
const loading = ref(false)
const menuTree = ref<MenuTree[]>([])
@ -67,6 +130,141 @@ const menuTypeMap: Record<number, { label: string; type: string }> = {
3: { label: '按钮', type: 'warning' }
}
//
const dialogVisible = ref(false)
const isEdit = ref(false)
const dialogTitle = computed(() => isEdit.value ? '编辑菜单' : '新增菜单')
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
//
const formData = reactive({
id: 0,
parentId: 0,
name: '',
path: '',
component: '',
icon: '',
menuType: 2,
permission: '',
sortOrder: 0,
status: 1,
isExternal: false,
isCache: true
})
//
const menuTreeForSelect = computed(() => {
if (!isEdit.value) {
return [{ id: 0, name: '顶级菜单', children: menuTree.value }]
}
//
const filterTree = (nodes: MenuTree[]): MenuTree[] => {
return nodes
.filter(n => n.id !== formData.id)
.map(n => ({ ...n, children: filterTree(n.children || []) }))
}
return [{ id: 0, name: '顶级菜单', children: filterTree(menuTree.value) }]
})
//
const formRules: FormRules = {
name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
menuType: [{ required: true, message: '请选择菜单类型', trigger: 'change' }],
path: [{
validator: (_rule, value, callback) => {
if (formData.menuType === 2 && !value) {
callback(new Error('菜单类型必须填写路由路径'))
} else if (value && !formData.isExternal && !value.startsWith('/')) {
callback(new Error('内部路由路径必须以 / 开头'))
} else {
callback()
}
},
trigger: 'blur'
}],
component: [{
validator: (_rule, value, callback) => {
if (formData.menuType === 2 && !value) {
callback(new Error('菜单类型必须填写组件路径'))
} else {
callback()
}
},
trigger: 'blur'
}],
permission: [{
validator: (_rule, value, callback) => {
if (formData.menuType === 3 && !value) {
callback(new Error('按钮类型必须填写权限标识'))
} else {
callback()
}
},
trigger: 'blur'
}],
sortOrder: [{
type: 'number',
min: 0,
message: '排序值必须为非负整数',
trigger: 'blur'
}]
}
//
const resetForm = () => {
formData.id = 0
formData.parentId = 0
formData.name = ''
formData.path = ''
formData.component = ''
formData.icon = ''
formData.menuType = 2
formData.permission = ''
formData.sortOrder = 0
formData.status = 1
formData.isExternal = false
formData.isCache = true
formRef.value?.resetFields()
}
//
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
submitLoading.value = true
try {
const requestData: CreateMenuRequest | UpdateMenuRequest = {
parentId: formData.parentId,
name: formData.name,
path: formData.path || undefined,
component: formData.component || undefined,
icon: formData.icon || undefined,
menuType: formData.menuType,
permission: formData.permission || undefined,
sortOrder: formData.sortOrder,
status: formData.status,
isExternal: formData.isExternal,
isCache: formData.isCache
}
if (isEdit.value) {
await updateMenu(formData.id, requestData)
ElMessage.success('更新成功')
} else {
await createMenu(requestData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error: any) {
ElMessage.error(error.message || '操作失败')
} finally {
submitLoading.value = false
}
}
const fetchData = async () => {
loading.value = true
try {
@ -77,26 +275,65 @@ const fetchData = async () => {
}
}
//
const handleAdd = () => {
ElMessage.info('新增菜单功能待实现')
resetForm()
isEdit.value = false
dialogVisible.value = true
}
//
const handleAddChild = (row: MenuTree) => {
ElMessage.info(`添加 ${row.name} 的子菜单功能待实现`)
resetForm()
isEdit.value = false
formData.parentId = row.id
dialogVisible.value = true
}
//
const handleEdit = (row: MenuTree) => {
ElMessage.info(`编辑 ${row.name} 功能待实现`)
resetForm()
isEdit.value = true
//
formData.id = row.id
formData.parentId = row.parentId
formData.name = row.name
formData.path = row.path || ''
formData.component = row.component || ''
formData.icon = row.icon || ''
formData.menuType = row.menuType
formData.permission = row.permission || ''
formData.sortOrder = row.sortOrder
formData.status = row.status
formData.isExternal = row.isExternal
formData.isCache = row.isCache
dialogVisible.value = true
}
const handleDelete = async (row: MenuTree) => {
//
const hasChildren = row.children && row.children.length > 0
const confirmMessage = hasChildren
? `菜单 "${row.name}" 下有 ${row.children.length} 个子菜单,删除后子菜单也将被删除。确定要删除吗?`
: `确定要删除菜单 "${row.name}" 吗?`
try {
await ElMessageBox.confirm(`确定要删除菜单 "${row.name}" 吗?`, '提示', {
type: 'warning'
await ElMessageBox.confirm(confirmMessage, '删除确认', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消'
})
ElMessage.info('删除功能待实现')
} catch {
//
// API
await deleteMenu(row.id)
ElMessage.success('删除成功')
//
fetchData()
} catch (error: any) {
// error 'cancel'
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}

View File

@ -23,7 +23,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:5238',
target: 'http://localhost:5000',
changeOrigin: true
}
}