zhouhui.jiang

添加 用户管理、角色管理、菜单管理、字典管理、日志管理

# Apple ERP 系统 API 接口文档
## 概述
本文档描述了 Apple ERP 系统的后端 API 接口,包括认证管理、用户管理、角色管理、菜单管理、字典管理、日志管理等模块的接口规范。
**基础信息:**
- 基础URL: `http://localhost:8083`
- 认证方式: JWT Bearer Token
- 响应格式: JSON
- 字符编码: UTF-8
## 通用响应格式
所有接口都遵循统一的响应格式:
```json
{
"code": 200,
"message": "操作成功",
"data": {}
}
```
**响应字段说明:**
- `code`: 响应状态码,200表示成功,其他表示失败
- `message`: 响应消息
- `data`: 响应数据,具体内容根据接口而定
## 1. 认证管理 (AuthController)
### 1.1 用户登录
**接口路径:** `POST /api/auth/login`
**功能描述:** 用户登录获取JWT令牌
**请求参数:**
```json
{
"username": "admin",
"password": "123456"
}
```
**响应示例:**
```json
{
"code": 200,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"username": "admin"
}
}
```
### 1.2 刷新令牌
**接口路径:** `POST /api/auth/refresh`
**功能描述:** 使用刷新令牌获取新的访问令牌
**请求参数:**
```json
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**响应示例:**
```json
{
"code": 200,
"message": "令牌刷新成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"username": "admin"
}
}
```
### 1.3 用户登出
**接口路径:** `POST /api/auth/logout`
**功能描述:** 用户登出清除认证信息和缓存
**请求头:** `Authorization: Bearer {token}`
**响应示例:**
```json
{
"code": 200,
"message": "登出成功",
"data": null
}
```
### 1.4 获取用户信息
**接口路径:** `GET /api/auth/userinfo`
**功能描述:** 获取当前登录用户的详细信息
**请求头:** `Authorization: Bearer {token}`
**响应示例:**
```json
{
"code": 200,
"message": "获取用户信息成功",
"data": {
"username": "admin",
"authorities": ["ROLE_ADMIN"]
}
}
```
## 2. 用户管理 (SysUserController)
### 2.1 获取用户列表
**接口路径:** `GET /api/system/user/list`
**功能描述:** 支持分页查询和条件筛选,包括用户名、真实姓名、手机号、邮箱、状态、创建时间范围等条件
**权限要求:** `sys:user:list`
**请求参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| pageNum | Integer | 否 | 页码,默认1 |
| pageSize | Integer | 否 | 每页大小,默认10 |
| username | String | 否 | 用户名,支持模糊查询 |
| realName | String | 否 | 真实姓名,支持模糊查询 |
| phone | String | 否 | 手机号,支持模糊查询 |
| email | String | 否 | 邮箱,支持模糊查询 |
| status | Integer | 否 | 用户状态,0-停用,1-启用 |
| startTime | String | 否 | 开始时间,创建时间范围查询的起始时间,格式:yyyy-MM-dd,自动转换为当天00:00:00 |
| endTime | String | 否 | 结束时间,创建时间范围查询的结束时间,格式:yyyy-MM-dd,自动转换为当天23:59:59 |
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": {
"records": [
{
"userId": 1,
"username": "admin",
"realName": "管理员",
"phone": "13800138000",
"email": "admin@example.com",
"status": 1,
"statusText": "正常",
"roles": [
{
"roleId": 1,
"roleName": "超级管理员"
}
],
"createTime": "2024-01-01T00:00:00",
"updateTime": "2024-01-01T00:00:00"
}
],
"total": 1,
"current": 1,
"size": 10
}
}
```
### 2.2 获取用户详情
**接口路径:** `GET /api/system/user/{userId}`
**功能描述:** 根据用户ID获取用户的详细信息,包括用户基本资料和分配的角色信息
**权限要求:** `sys:user:query`
**路径参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| userId | Long | 是 | 用户ID |
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": {
"userId": 1,
"username": "admin",
"realName": "管理员",
"phone": "13800138000",
"email": "admin@example.com",
"status": 1,
"statusText": "正常",
"roles": [
{
"roleId": 1,
"roleName": "超级管理员"
}
],
"createTime": "2024-01-01T00:00:00",
"updateTime": "2024-01-01T00:00:00"
}
}
```
### 2.3 新增用户
**接口路径:** `POST /api/system/user/add`
**功能描述:** 创建新用户,包括用户基本信息和角色分配
**权限要求:** `sys:user:add`
**请求参数:**
```json
{
"username": "testuser",
"password": "123456",
"realName": "测试用户",
"phone": "13800138001",
"email": "test@example.com",
"status": 1,
"roleIds": [2, 3]
}
```
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
### 2.4 修改用户
**接口路径:** `POST /api/system/user/edit`
**功能描述:** 更新用户基本信息,包括用户资料和角色分配
**权限要求:** `sys:user:edit`
**请求参数:**
```json
{
"userId": 2,
"username": "testuser",
"realName": "测试用户",
"phone": "13800138001",
"email": "test@example.com",
"status": 1,
"roleIds": [2, 3]
}
```
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
### 2.5 删除用户
**接口路径:** `DELETE /api/system/user/{userIds}`
**功能描述:** 批量删除用户,会同时清理用户角色关联关系
**权限要求:** `sys:user:remove`
**路径参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| userIds | Long[] | 是 | 需要删除的用户ID数组 |
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
### 2.6 重置密码
**接口路径:** `PUT /api/system/user/resetPwd`
**功能描述:** 重置指定用户的登录密码
**权限要求:** `sys:user:resetPwd`
**请求参数:**
```json
{
"userId": 2,
"newPassword": "newpassword123"
}
```
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
### 2.7 修改用户状态
**接口路径:** `POST /api/system/user/changeStatus`
**功能描述:** 启用或停用用户账户
**权限要求:** `sys:user:edit`
**请求参数:**
```json
{
"userId": 2,
"status": 0
}
```
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
## 3. 角色管理 (SysRoleController)
### 3.1 获取角色列表
**接口路径:** `GET /api/system/role/list`
**功能描述:** 支持分页查询和条件筛选,包括角色名称、角色编码、状态等条件
**权限要求:** `sys:role:list`
**请求参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| pageNum | Integer | 否 | 页码,默认1 |
| pageSize | Integer | 否 | 每页大小,默认10 |
| roleName | String | 否 | 角色名称,支持模糊查询 |
| roleCode | String | 否 | 角色编码,支持模糊查询 |
| status | Integer | 否 | 角色状态,0-停用,1-启用 |
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": {
"records": [
{
"roleId": 1,
"roleCode": "admin",
"roleName": "超级管理员",
"status": 1,
"statusText": "正常",
"remark": "系统超级管理员",
"createTime": "2024-01-01T00:00:00",
"updateTime": "2024-01-01T00:00:00"
}
],
"total": 1,
"current": 1,
"size": 10
}
}
```
### 3.2 获取角色详情
**接口路径:** `GET /api/system/role/{roleId}`
**功能描述:** 根据角色ID获取角色的详细信息,包括角色基本资料和分配的菜单权限
**权限要求:** `sys:role:query`
**路径参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| roleId | Long | 是 | 角色ID |
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": {
"roleId": 1,
"roleCode": "admin",
"roleName": "超级管理员",
"status": 1,
"statusText": "正常",
"menuIds": [1, 2, 3, 4, 5],
"remark": "系统超级管理员",
"createTime": "2024-01-01T00:00:00",
"updateTime": "2024-01-01T00:00:00"
}
}
```
### 3.3 新增角色
**接口路径:** `POST /api/system/role/add`
**功能描述:** 创建新角色,包括角色基本信息和菜单权限分配
**权限要求:** `sys:role:add`
**请求参数:**
```json
{
"roleCode": "testrole",
"roleName": "测试角色",
"status": 1,
"remark": "测试角色描述",
"menuIds": [1, 2, 3]
}
```
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
### 3.4 修改角色
**接口路径:** `POST /api/system/role/edit`
**功能描述:** 更新角色基本信息,包括角色资料和菜单权限分配
**权限要求:** `sys:role:edit`
**请求参数:**
```json
{
"roleId": 2,
"roleCode": "testrole",
"roleName": "测试角色",
"status": 1,
"remark": "测试角色描述",
"menuIds": [1, 2, 3]
}
```
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
### 3.5 删除角色
**接口路径:** `DELETE /api/system/role/{roleIds}`
**功能描述:** 批量删除角色,会同时清理角色与用户、菜单的关联关系
**权限要求:** `sys:role:remove`
**路径参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| roleIds | Long[] | 是 | 需要删除的角色ID数组 |
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
### 3.6 修改角色状态
**接口路径:** `POST /api/system/role/changeStatus`
**功能描述:** 启用或停用角色
**权限要求:** `sys:role:edit`
**请求参数:**
```json
{
"roleId": 2,
"status": 0
}
```
**响应示例:**
```json
{
"code": 200,
"message": "操作成功",
"data": null
}
```
## 4. 测试接口 (TestController)
### 4.1 公开接口测试
**接口路径:** `GET /api/test/public`
**功能描述:** 无需认证的公开接口
**响应示例:**
```json
{
"code": 200,
"message": "公开接口测试成功",
"data": {
"message": "这是一个公开接口",
"timestamp": 1640995200000
}
}
```
### 4.2 认证接口测试
**接口路径:** `GET /api/test/auth`
**功能描述:** 需要JWT令牌认证的接口
**请求头:** `Authorization: Bearer {token}`
**响应示例:**
```json
{
"code": 200,
"message": "认证接口测试成功",
"data": {
"message": "这是一个需要认证的接口",
"timestamp": 1640995200000
}
}
```
### 4.3 管理员接口测试
**接口路径:** `GET /api/test/admin`
**功能描述:** 需要ADMIN角色权限的接口
**请求头:** `Authorization: Bearer {token}`
**权限要求:** `ROLE_ADMIN`
**响应示例:**
```json
{
"code": 200,
"message": "管理员接口测试成功",
"data": {
"message": "这是一个需要管理员权限的接口",
"timestamp": 1640995200000
}
}
```
### 4.4 权限接口测试
**接口路径:** `GET /api/test/permission`
**功能描述:** 需要特定权限的接口
**请求头:** `Authorization: Bearer {token}`
**权限要求:** `sys:user:list`
**响应示例:**
```json
{
"code": 200,
"message": "权限接口测试成功",
"data": {
"message": "这是一个需要特定权限的接口",
"timestamp": 1640995200000
}
}
```
## 5. 错误码说明
| 错误码 | 说明 |
|--------|------|
| 200 | 操作成功 |
| 400 | 请求参数错误 |
| 401 | 未认证或认证失败 |
| 403 | 权限不足 |
| 404 | 资源不存在 |
| 409 | 数据冲突(如用户名已存在) |
| 500 | 服务器内部错误 |
## 6. 权限说明
### 6.1 用户管理权限
- `sys:user:list` - 查看用户列表
- `sys:user:query` - 查看用户详情
- `sys:user:add` - 新增用户
- `sys:user:edit` - 修改用户
- `sys:user:remove` - 删除用户
- `sys:user:resetPwd` - 重置密码
### 6.2 角色管理权限
- `sys:role:list` - 查看角色列表
- `sys:role:query` - 查看角色详情
- `sys:role:add` - 新增角色
- `sys:role:edit` - 修改角色
- `sys:role:remove` - 删除角色
### 6.3 字典管理权限
- `sys:dict:list` - 查看字典列表
- `sys:dict:query` - 查看字典详情
- `sys:dict:add` - 新增字典
- `sys:dict:edit` - 修改字典
- `sys:dict:remove` - 删除字典
## 7. 使用说明
### 7.1 认证流程
1. 调用登录接口获取JWT令牌
2. 在后续请求的Header中携带令牌:`Authorization: Bearer {token}`
3. 令牌过期时使用刷新令牌获取新令牌
4. 登出时调用登出接口清除认证信息
### 7.2 分页查询
所有列表接口都支持分页查询,使用以下参数:
- `pageNum`: 页码,从1开始
- `pageSize`: 每页大小,建议10-50之间
### 7.3 数据验证
- 所有必填字段都会进行验证
- 字符串长度、邮箱格式等都有相应验证规则
- 唯一性字段(如用户名、角色编码)会进行重复性检查
### 7.4 安全限制
- 超级管理员用户(ID=1)和角色(ID=1)不允许删除
- 超级管理员状态不允许修改
- 所有操作都会记录操作日志
---
**文档版本:** 1.0.0
**最后更新:** 2024-01-01
**维护人员:** Apple ERP Team
......@@ -5,8 +5,11 @@ import com.apple.erp.dto.request.RoleQueryReq;
import com.apple.erp.dto.request.RoleUpdateReq;
import com.apple.erp.dto.response.ApiRes;
import com.apple.erp.dto.response.RoleRes;
import com.apple.erp.dto.response.MenuRes;
import com.apple.erp.entity.SysRole;
import com.apple.erp.entity.SysMenu;
import com.apple.erp.service.SysRoleService;
import com.apple.erp.mapper.SysMenuMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
......@@ -21,7 +24,6 @@ import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
......@@ -40,6 +42,9 @@ public class SysRoleController {
@Autowired
private SysRoleService sysRoleService;
@Autowired
private SysMenuMapper sysMenuMapper;
/**
* 获取角色列表
......@@ -156,7 +161,7 @@ public class SysRoleController {
* @throws RuntimeException 当角色编码或名称已存在时
*/
@Operation(summary = "新增角色", description = "创建新角色,包括角色基本信息和菜单权限分配")
@PostMapping
@PostMapping("/add")
@PreAuthorize("hasAuthority('sys:role:add')")
public ApiRes<Void> add(
@Parameter(description = "角色新增请求,包含角色基本信息和菜单ID列表", required = true)
......@@ -222,7 +227,7 @@ public class SysRoleController {
* @throws RuntimeException 当角色不存在或角色编码/名称已存在时
*/
@Operation(summary = "修改角色", description = "更新角色基本信息,包括角色资料和菜单权限分配")
@PutMapping
@PostMapping("/edit")
@PreAuthorize("hasAuthority('sys:role:edit')")
public ApiRes<Void> edit(
@Parameter(description = "角色更新请求,包含角色ID、基本信息和菜单ID列表", required = true)
......@@ -329,7 +334,7 @@ public class SysRoleController {
* @throws RuntimeException 当尝试修改超级管理员角色状态时
*/
@Operation(summary = "修改角色状态", description = "启用或停用角色")
@PutMapping("/changeStatus")
@PostMapping("/changeStatus")
@PreAuthorize("hasAuthority('sys:role:edit')")
public ApiRes<Void> changeStatus(
@Parameter(description = "角色信息,主要使用角色ID和状态字段", required = true) @RequestBody SysRole role) {
......@@ -358,6 +363,53 @@ public class SysRoleController {
roleRes.setStatusText(role.getStatus() == 1 ? "正常" : "停用");
}
// 获取角色菜单权限
if (role.getRoleId() != null) {
List<SysMenu> menuList = sysMenuMapper.selectMenuTreeByRoleId(role.getRoleId());
if (menuList != null && !menuList.isEmpty()) {
List<MenuRes> menuResList = menuList.stream()
.map(this::convertToMenuRes)
.collect(Collectors.toList());
roleRes.setMenus(menuResList);
}
}
return roleRes;
}
/**
* 转换菜单实体为菜单响应DTO
*
* @param menu 菜单实体
* @return 菜单响应DTO
*/
private MenuRes convertToMenuRes(SysMenu menu) {
MenuRes menuRes = new MenuRes();
BeanUtils.copyProperties(menu, menuRes);
// 设置菜单类型描述
if (menu.getMenuType() != null) {
switch (menu.getMenuType()) {
case "0":
menuRes.setMenuTypeText("目录");
break;
case "1":
menuRes.setMenuTypeText("菜单");
break;
case "2":
menuRes.setMenuTypeText("按钮");
break;
default:
menuRes.setMenuTypeText("未知");
break;
}
}
// 设置状态描述
if (menu.getStatus() != null) {
menuRes.setStatusText(menu.getStatus().equals(1) ? "显示" : "隐藏");
}
return menuRes;
}
}
......
......@@ -8,6 +8,7 @@ import com.apple.erp.dto.response.ApiRes;
import com.apple.erp.dto.response.UserRes;
import com.apple.erp.entity.SysUser;
import com.apple.erp.service.SysUserService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
......@@ -17,6 +18,7 @@ import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
......@@ -53,7 +55,7 @@ public class SysUserController {
* @apiNote 接口路径: GET /api/system/user/list
* @apiNote 权限要求: sys:user:list
* @apiNote 支持分页参数: pageNum(页码), pageSize(每页大小)
* @apiNote 支持筛选条件: username(用户名), realName(真实姓名), phone(手机号), email(邮箱), status(状态)
* @apiNote 支持筛选条件: username(用户名), realName(真实姓名), phone(手机号), email(邮箱), status(状态), startTime(开始时间), endTime(结束时间)
*
* @param queryReq 用户查询条件,包含分页参数和筛选条件
* - pageNum: 页码,默认1
......@@ -63,6 +65,8 @@ public class SysUserController {
* - phone: 手机号,支持模糊查询
* - email: 邮箱,支持模糊查询
* - status: 用户状态,0-停用,1-启用
* - startTime: 开始时间,创建时间范围查询的起始时间
* - endTime: 结束时间,创建时间范围查询的结束时间
* @return 分页的用户列表数据
* - code: 响应码,200表示成功
* - message: 响应消息
......@@ -73,18 +77,17 @@ public class SysUserController {
* - size: 每页大小
* @throws SecurityException 当用户没有sys:user:list权限时
*/
@Operation(summary = "获取用户列表", description = "支持分页查询和条件筛选,包括用户名、真实姓名、手机号、邮箱、状态等条件")
@Operation(summary = "获取用户列表", description = "支持分页查询和条件筛选,包括用户名、真实姓名、手机号、邮箱、状态、创建时间范围等条件")
@GetMapping("/list")
@PreAuthorize("hasAuthority('sys:user:list')")
public ApiRes<IPage<UserRes>> list(
@Parameter(description = "用户查询条件,包含分页参数和筛选条件") UserQueryReq queryReq) {
Page<SysUser> page = new Page<>(queryReq.getPageNum(), queryReq.getPageSize());
// 构建查询条件
SysUser queryUser = new SysUser();
BeanUtils.copyProperties(queryReq, queryUser);
// 获取查询条件包装器(包含所有查询条件)
LambdaQueryWrapper<SysUser> wrapper = sysUserService.getQueryWrapper(queryReq);
IPage<SysUser> userPage = sysUserService.page(page, sysUserService.getQueryWrapper(queryUser));
IPage<SysUser> userPage = sysUserService.page(page, wrapper);
// 转换为响应DTO
List<UserRes> userResList = userPage.getRecords().stream()
......@@ -143,7 +146,7 @@ public class SysUserController {
* 新增用户
* 创建新用户,包括用户基本信息和角色分配
*
* @apiNote 接口路径: POST /api/system/user
* @apiNote 接口路径: POST /api/system/user/add
* @apiNote 权限要求: sys:user:add
* @apiNote 功能说明: 创建新用户账户,支持同时分配角色权限
* @apiNote 数据验证: 用户名唯一性检查,密码强度验证,邮箱格式验证
......@@ -165,8 +168,9 @@ public class SysUserController {
* @throws RuntimeException 当用户名已存在时
*/
@Operation(summary = "新增用户", description = "创建新用户,包括用户基本信息和角色分配")
@PostMapping
@PostMapping("/add")
@PreAuthorize("hasAuthority('sys:user:add')")
@Transactional(rollbackFor = Exception.class)
public ApiRes<Void> add(
@Parameter(description = "用户新增请求,包含用户基本信息和角色ID列表", required = true)
@Valid @RequestBody UserAddReq addReq) {
......@@ -198,7 +202,7 @@ public class SysUserController {
* 修改用户
* 更新用户基本信息,包括用户资料和角色分配
*
* @apiNote 接口路径: PUT /api/system/user
* @apiNote 接口路径: POST /api/system/user/edit
* @apiNote 权限要求: sys:user:edit
* @apiNote 功能说明: 更新现有用户的基本信息和角色分配
* @apiNote 数据验证: 用户存在性检查,邮箱格式验证,角色有效性验证
......@@ -219,8 +223,9 @@ public class SysUserController {
* @throws RuntimeException 当用户不存在时
*/
@Operation(summary = "修改用户", description = "更新用户基本信息,包括用户资料和角色分配")
@PutMapping
@PostMapping("/edit")
@PreAuthorize("hasAuthority('sys:user:edit')")
@Transactional(rollbackFor = Exception.class)
public ApiRes<Void> edit(
@Parameter(description = "用户更新请求,包含用户ID、基本信息和角色ID列表", required = true)
@Valid @RequestBody UserUpdateReq updateReq) {
......@@ -274,6 +279,7 @@ public class SysUserController {
@Operation(summary = "删除用户", description = "批量删除用户,会同时清理用户角色关联关系")
@DeleteMapping("/{userIds}")
@PreAuthorize("hasAuthority('sys:user:remove')")
@Transactional(rollbackFor = Exception.class)
public ApiRes<Void> remove(
@Parameter(description = "需要删除的用户ID数组", required = true) @PathVariable Long[] userIds) {
// 检查是否包含超级管理员
......@@ -341,7 +347,7 @@ public class SysUserController {
* 状态修改
* 启用或停用用户账户
*
* @apiNote 接口路径: PUT /api/system/user/changeStatus
* @apiNote 接口路径: POST /api/system/user/changeStatus
* @apiNote 权限要求: sys:user:edit
* @apiNote 功能说明: 修改用户账户状态,支持启用或停用用户
* @apiNote 安全限制: 不允许修改超级管理员用户状态
......@@ -358,7 +364,7 @@ public class SysUserController {
* @throws RuntimeException 当尝试修改超级管理员状态时
*/
@Operation(summary = "修改用户状态", description = "启用或停用用户账户")
@PutMapping("/changeStatus")
@PostMapping("/changeStatus")
@PreAuthorize("hasAuthority('sys:user:edit')")
public ApiRes<Void> changeStatus(
@Parameter(description = "用户信息,主要使用用户ID和状态字段", required = true) @RequestBody SysUser user) {
......@@ -392,4 +398,4 @@ public class SysUserController {
return userRes;
}
}
}
\ No newline at end of file
......
......@@ -2,6 +2,7 @@ package com.apple.erp.dto.request;
import lombok.Data;
/**
* 用户查询请求DTO
*
......@@ -38,6 +39,16 @@ public class UserQueryReq {
private Integer status;
/**
* 开始时间(创建时间范围查询)
*/
private String startTime;
/**
* 结束时间(创建时间范围查询)
*/
private String endTime;
/**
* 页码
*/
private Integer pageNum = 1;
......
......@@ -33,8 +33,9 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
/**
* 根据用户名查询用户基本信息
* 用于用户名唯一性检查,需要查询所有状态的用户(包括停用用户)
*/
@Select("SELECT * FROM t_sys_user WHERE username = #{username} AND del_flag = '0' AND status = 1")
@Select("SELECT * FROM t_sys_user WHERE username = #{username} AND del_flag = '0'")
SysUser findByUsername(@Param("username") String username);
/**
......@@ -47,4 +48,4 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
*/
@Update("UPDATE t_sys_user SET last_login_time = NOW(), last_login_ip = #{ip} WHERE user_id = #{userId}")
void updateLastLoginInfo(@Param("userId") Long userId, @Param("ip") String ip);
}
}
\ No newline at end of file
......
package com.apple.erp.service;
import com.apple.erp.dto.request.UserQueryReq;
import com.apple.erp.dto.response.RoleRes;
import com.apple.erp.entity.SysUser;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
......@@ -38,12 +39,12 @@ public interface SysUserService extends IService<SysUser> {
/**
* 获取用户查询条件包装器
* 根据用户信息构建MyBatis Plus查询条件,支持用户名、真实姓名、手机号、邮箱、状态的模糊查询
* 根据查询请求构建MyBatis Plus查询条件,支持用户名、真实姓名、手机号、邮箱、状态、创建时间范围等条件
*
* @param user 用户查询条件对象
* @param queryReq 用户查询请求对象
* @return MyBatis Plus查询条件包装器
*/
LambdaQueryWrapper<SysUser> getQueryWrapper(SysUser user);
LambdaQueryWrapper<SysUser> getQueryWrapper(UserQueryReq queryReq);
/**
* 获取用户角色列表
......
package com.apple.erp.service.impl;
import com.apple.erp.dto.request.UserQueryReq;
import com.apple.erp.dto.response.RoleRes;
import com.apple.erp.entity.SysRole;
import com.apple.erp.entity.SysUser;
......@@ -17,6 +18,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
......@@ -93,39 +96,55 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
/**
* 获取用户查询条件包装器
* 根据用户信息构建MyBatis Plus查询条件,支持用户名、真实姓名、手机号、邮箱、状态的模糊查询
* 根据查询请求构建MyBatis Plus查询条件,支持用户名、真实姓名、手机号、邮箱、状态、创建时间范围等条件
* 优化判空规则,兼容空字符串和null的情况
*
* @param user 用户查询条件对象
* @param queryReq 用户查询请求对象
* @return MyBatis Plus查询条件包装器
*/
@Override
public LambdaQueryWrapper<SysUser> getQueryWrapper(SysUser user) {
public LambdaQueryWrapper<SysUser> getQueryWrapper(UserQueryReq queryReq) {
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
// 用户名:支持模糊查询,过滤空字符串和null
if (StringUtils.isNotEmpty(user.getUsername())) {
wrapper.like(SysUser::getUsername, user.getUsername());
if (StringUtils.isNotEmpty(queryReq.getUsername())) {
wrapper.like(SysUser::getUsername, queryReq.getUsername());
}
// 真实姓名:支持模糊查询,过滤空字符串和null
if (StringUtils.isNotEmpty(user.getRealName())) {
wrapper.like(SysUser::getRealName, user.getRealName());
if (StringUtils.isNotEmpty(queryReq.getRealName())) {
wrapper.like(SysUser::getRealName, queryReq.getRealName());
}
// 手机号:支持模糊查询,过滤空字符串和null
if (StringUtils.isNotEmpty(user.getPhone())) {
wrapper.like(SysUser::getPhone, user.getPhone());
if (StringUtils.isNotEmpty(queryReq.getPhone())) {
wrapper.like(SysUser::getPhone, queryReq.getPhone());
}
// 邮箱:支持模糊查询,过滤空字符串和null
if (StringUtils.isNotEmpty(user.getEmail())) {
wrapper.like(SysUser::getEmail, user.getEmail());
if (StringUtils.isNotEmpty(queryReq.getEmail())) {
wrapper.like(SysUser::getEmail, queryReq.getEmail());
}
// 状态:精确查询
if (user.getStatus() != null) {
wrapper.eq(SysUser::getStatus, user.getStatus());
if (queryReq.getStatus() != null) {
wrapper.eq(SysUser::getStatus, queryReq.getStatus());
}
// 开始时间:创建时间 >= 开始时间
if (StringUtils.isNotEmpty(queryReq.getStartTime())) {
LocalDateTime startDateTime = parseDateTime(queryReq.getStartTime());
if (startDateTime != null) {
wrapper.ge(SysUser::getCreateTime, startDateTime);
}
}
// 结束时间:创建时间 <= 结束时间
if (StringUtils.isNotEmpty(queryReq.getEndTime())) {
LocalDateTime endDateTime = parseDateTime(queryReq.getEndTime());
if (endDateTime != null) {
wrapper.le(SysUser::getCreateTime, endDateTime);
}
}
// 固定条件:未删除
......@@ -138,6 +157,44 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
}
/**
* 解析时间字符串为LocalDateTime
* 支持格式:yyyy-MM-dd, yyyy-MM-dd HH:mm:ss
*
* @param dateTimeStr 时间字符串
* @return LocalDateTime对象,解析失败返回null
*/
private LocalDateTime parseDateTime(String dateTimeStr) {
if (StringUtils.isEmpty(dateTimeStr)) {
return null;
}
// 支持的时间格式
DateTimeFormatter[] formatters = {
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd")
};
// 尝试各种格式解析
for (DateTimeFormatter formatter : formatters) {
try {
if (formatter.toString().contains("HH:mm:ss")) {
// 完整时间格式
return LocalDateTime.parse(dateTimeStr, formatter);
} else {
// 仅日期格式,转换为当天的00:00:00
return LocalDateTime.parse(dateTimeStr + " 00:00:00", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
} catch (DateTimeParseException e) {
continue;
}
}
// 解析失败,记录日志
System.err.println("时间格式解析失败: " + dateTimeStr);
return null;
}
/**
* 获取用户角色列表
* 查询指定用户拥有的所有角色信息,包含角色基本信息和状态描述
*
......
......@@ -54,7 +54,7 @@
select distinct m.menu_id, m.parent_id, m.menu_name, m.path, m.menu_type, m.status, m.perms, m.icon, m.create_by, m.create_time, m.update_by, m.update_time, m.del_flag
from t_sys_menu m
left join t_sys_role_menu rm on m.menu_id = rm.menu_id
where rm.role_id = #{roleId} and m.menu_type in ('0','1') and m.status = 1 and m.del_flag = '0'
where rm.role_id = #{roleId} and m.status = 1 and m.del_flag = '0'
order by m.parent_id, m.sort
</select>
......
......@@ -34,7 +34,7 @@
<!-- 根据用户名查询用户基本信息 -->
<select id="findByUsername" resultType="com.apple.erp.entity.SysUser">
SELECT * FROM t_sys_user
WHERE username = #{username} AND del_flag = '0' AND status = 1
WHERE username = #{username}
</select>
<!-- 根据用户ID查询菜单权限标识列表 -->
......
......@@ -79,9 +79,3 @@ declare global {
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
......
......@@ -7,10 +7,21 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ActionBar: typeof import('./src/components/common/ActionBar.vue')['default']
DataTable: typeof import('./src/components/common/DataTable.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElLink: typeof import('element-plus/es')['ElLink']
Header: typeof import('./src/components/layout/Header.vue')['default']
Pagination: typeof import('./src/components/common/Pagination.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchBar: typeof import('./src/components/common/SearchBar.vue')['default']
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
SidebarItem: typeof import('./src/components/layout/SidebarItem.vue')['default']
TableToolbar: typeof import('./src/components/common/TableToolbar.vue')['default']
}
}
......
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Apple经销商ERP系统</title>
<meta name="description" content="Apple经销商ERP系统前端应用" />
<meta name="keywords" content="Apple,ERP,经销商,管理系统" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<template>
<div id="app">
<!-- <div id="app">
<h1>Vue应用已启动</h1>
<p>如果您能看到这个页面,说明Vue应用正常运行</p>
<p>当前时间: {{ currentTime }}</p>
</div>
</div> -->
<router-view/>
</template>
<script setup lang="ts">
......@@ -18,9 +19,23 @@ onMounted(() => {
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow-x: hidden;
}
#app {
height: 100vh;
font-family: Arial, sans-serif;
padding: 20px;
margin: 0;
padding: 0;
}
</style>
......
import { request } from '@/utils/request'
import type { DictType, DictItem, PageRequest, PageResponse } from '@/types'
// 字典类型相关API
// 获取字典类型列表
export function getDictTypeListApi(): Promise<DictType[]> {
return request.get('/api/sys/dict/type/list')
}
// 获取字典类型分页列表
export function getDictTypePageApi(params: PageRequest & {
dictName?: string
import axios from 'axios'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8083'
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
api.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
console.error('API请求错误:', error)
return Promise.reject(error)
}
)
export interface DictType {
dictTypeId: number
dictType: string
dictName: string
status: number
statusText: string
remark?: string
createBy?: string
createTime: string
updateBy?: string
updateTime: string
}
export interface DictItem {
dictItemId: number
dictTypeId: number
dictType: string
dictLabel: string
dictValue: string
sort: number
remark?: string
createBy?: string
createTime: string
updateBy?: string
updateTime: string
}
export interface DictTypeSearchParams {
dictType?: string
dictName?: string
status?: number
}): Promise<PageResponse<DictType>> {
return request.get('/api/sys/dict/type/page', params)
}
// 获取字典类型详情
export function getDictTypeDetailApi(dictId: number): Promise<DictType> {
return request.get(`/api/sys/dict/type/${dictId}`)
}
// 新增字典类型
export function addDictTypeApi(data: Partial<DictType>): Promise<void> {
return request.post('/api/sys/dict/type', data)
}
// 更新字典类型
export function updateDictTypeApi(data: Partial<DictType>): Promise<void> {
return request.put('/api/sys/dict/type', data)
}
// 删除字典类型
export function deleteDictTypeApi(dictId: number): Promise<void> {
return request.delete(`/api/sys/dict/type/${dictId}`)
}
// 批量删除字典类型
export function deleteBatchDictTypeApi(dictIds: number[]): Promise<void> {
return request.delete('/api/sys/dict/type/batch', { dictIds })
}
// 刷新字典缓存
export function refreshDictCacheApi(): Promise<void> {
return request.post('/api/sys/dict/type/refreshCache')
pageNum?: number
pageSize?: number
}
// 字典项相关API
// 获取字典项列表
export function getDictItemListApi(dictTypeId: number): Promise<DictItem[]> {
return request.get('/api/sys/dict/item/list', { dictTypeId })
}
// 获取字典项分页列表
export function getDictItemPageApi(params: PageRequest & {
export interface DictItemSearchParams {
dictTypeId?: number
dictLabel?: string
dictValue?: string
status?: number
}): Promise<PageResponse<DictItem>> {
return request.get('/api/sys/dict/item/page', params)
}
// 获取字典项详情
export function getDictItemDetailApi(dictItemId: number): Promise<DictItem> {
return request.get(`/api/sys/dict/item/${dictItemId}`)
}
// 新增字典项
export function addDictItemApi(data: Partial<DictItem>): Promise<void> {
return request.post('/api/sys/dict/item', data)
}
// 更新字典项
export function updateDictItemApi(data: Partial<DictItem>): Promise<void> {
return request.put('/api/sys/dict/item', data)
}
// 删除字典项
export function deleteDictItemApi(dictItemId: number): Promise<void> {
return request.delete(`/api/sys/dict/item/${dictItemId}`)
}
// 批量删除字典项
export function deleteBatchDictItemApi(dictItemIds: number[]): Promise<void> {
return request.delete('/api/sys/dict/item/batch', { dictItemIds })
}
// 根据字典类型获取字典项
export function getDictItemsByTypeApi(dictType: string): Promise<DictItem[]> {
return request.get(`/api/sys/dict/item/type/${dictType}`)
}
pageNum?: number
pageSize?: number
}
export interface DictTypeAddReq {
dictType: string
dictName: string
status: number
remark?: string
}
export interface DictTypeUpdateReq extends DictTypeAddReq {
dictTypeId: number
}
export interface DictItemAddReq {
dictTypeId: number
dictLabel: string
dictValue: string
sort?: number
remark?: string
}
export interface DictItemUpdateReq extends DictItemAddReq {
dictItemId: number
}
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
export interface PageResponse<T> {
records: T[]
total: number
size: number
current: number
orders: any[]
optimizeCountSql: boolean
searchCount: boolean
maxLimit: any
countId: any
pages: number
}
export const dictApi = {
// 字典类型管理
// 获取字典类型列表
getDictTypes: (params: DictTypeSearchParams): Promise<ApiResponse<PageResponse<DictType>>> => {
return api.get('/api/system/dict/type/list', { params })
},
// 获取字典类型详情
getDictTypeById: (dictTypeId: number): Promise<ApiResponse<DictType>> => {
return api.get(`/api/system/dict/type/${dictTypeId}`)
},
// 新增字典类型
createDictType: (dictTypeData: DictTypeAddReq): Promise<ApiResponse<any>> => {
return api.post('/api/system/dict/type', dictTypeData)
},
// 修改字典类型
updateDictType: (dictTypeData: DictTypeUpdateReq): Promise<ApiResponse<any>> => {
return api.put('/api/system/dict/type', dictTypeData)
},
// 删除字典类型
deleteDictTypes: (dictTypeIds: number[]): Promise<ApiResponse<any>> => {
return api.delete(`/api/system/dict/type/${dictTypeIds.join(',')}`)
},
// 获取字典类型选择框列表
getDictTypeOptions: (): Promise<ApiResponse<DictType[]>> => {
return api.get('/api/system/dict/type/optionselect')
},
// 刷新字典缓存
refreshDictCache: (): Promise<ApiResponse<any>> => {
return api.delete('/api/system/dict/type/refreshCache')
},
// 字典项管理
// 获取字典项列表
getDictItems: (params: DictItemSearchParams): Promise<ApiResponse<PageResponse<DictItem>>> => {
return api.get('/api/system/dict/item/list', { params })
},
// 根据字典类型获取字典项列表
getDictItemsByType: (dictType: string): Promise<ApiResponse<DictItem[]>> => {
return api.get(`/api/system/dict/item/type/${dictType}`)
},
// 获取字典项详情
getDictItemById: (dictItemId: number): Promise<ApiResponse<DictItem>> => {
return api.get(`/api/system/dict/item/${dictItemId}`)
},
// 新增字典项
createDictItem: (dictItemData: DictItemAddReq): Promise<ApiResponse<any>> => {
return api.post('/api/system/dict/item', dictItemData)
},
// 修改字典项
updateDictItem: (dictItemData: DictItemUpdateReq): Promise<ApiResponse<any>> => {
return api.put('/api/system/dict/item', dictItemData)
},
// 删除字典项
deleteDictItems: (dictItemIds: number[]): Promise<ApiResponse<any>> => {
return api.delete(`/api/system/dict/item/${dictItemIds.join(',')}`)
},
}
\ No newline at end of file
......
import axios from 'axios'
// 创建axios实例
const api = axios.create({
baseURL: 'http://localhost:8083/api/system/dict/item',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
console.error('API请求错误:', error)
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// 字典项接口类型定义
export interface DictItem {
dictItemId: number
dictTypeId: number
dictType: string
dictLabel: string
dictValue: string
sort: number
remark: string
createBy: string
createTime: string
updateBy: string
updateTime: string
}
export interface DictItemSearchParams {
pageNum: number
pageSize: number
dictTypeId?: number
dictLabel?: string
dictValue?: string
}
export interface DictItemAddReq {
dictTypeId: number
dictLabel: string
dictValue: string
sort?: number
remark?: string
}
export interface DictItemUpdateReq {
dictItemId: number
dictTypeId: number
dictLabel: string
dictValue: string
sort?: number
remark?: string
}
export interface ApiResponse<T> {
code: number
message: string
data: T
}
export interface PageData<T> {
records: T[]
total: number
current: number
size: number
pages: number
}
// API调用方法
export const dictItemApi = {
// 获取字典项列表
getDictItemList: (params: DictItemSearchParams): Promise<ApiResponse<PageData<DictItem>>> => {
return api.get('/list', { params })
},
// 根据字典类型获取字典项列表
getDictItemsByType: (dictType: string): Promise<ApiResponse<DictItem[]>> => {
return api.get(`/type/${dictType}`)
},
// 获取字典项详情
getDictItemById: (dictItemId: number): Promise<ApiResponse<DictItem>> => {
return api.get(`/${dictItemId}`)
},
// 新增字典项
createDictItem: (dictItemData: DictItemAddReq): Promise<ApiResponse<any>> => {
return api.post('/', dictItemData)
},
// 修改字典项
updateDictItem: (dictItemData: DictItemUpdateReq): Promise<ApiResponse<any>> => {
return api.put('/', dictItemData)
},
// 删除字典项
deleteDictItem: (dictItemIds: number[]): Promise<ApiResponse<any>> => {
const ids = dictItemIds.join(',')
return api.delete(`/${ids}`)
}
}
export default dictItemApi
import { request } from '@/utils/request'
import type { Log, PageRequest, PageResponse } from '@/types'
import axios from 'axios'
// 获取日志分页列表
export function getLogPageApi(params: PageRequest & {
title?: string
// 创建axios实例
const api = axios.create({
baseURL: 'http://localhost:8083/api/system/log',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
console.error('API请求错误:', error)
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// 日志接口类型定义
export interface Log {
logId: number
logType: string
logTypeText: string
userId: number
username: string
module: string
operation: string
ip: string
logTime: string
status: number
statusText: string
errorMsg: string
requestParam: string
createBy: string
createTime: string
updateBy: string
updateTime: string
}
export interface LogDetail {
logId: number
logType: string
logTypeText: string
userId: number
username: string
module: string
operation: string
ip: string
logTime: string
status: number
statusText: string
errorMsg: string
requestParam: string
createBy: string
createTime: string
updateBy: string
updateTime: string
}
export interface LogSearchParams {
pageNum: number
pageSize: number
logType?: string
userId?: number
username?: string
module?: string
operation?: string
ip?: string
status?: number
startTime?: string
endTime?: string
}): Promise<PageResponse<Log>> {
return request.get('/api/sys/log/page', params)
}
// 获取日志详情
export function getLogDetailApi(logId: number): Promise<Log> {
return request.get(`/api/sys/log/${logId}`)
export interface ApiResponse<T> {
code: number
message: string
data: T
}
// 删除日志
export function deleteLogApi(logId: number): Promise<void> {
return request.delete(`/api/sys/log/${logId}`)
export interface PageData<T> {
records: T[]
total: number
current: number
size: number
pages: number
}
// 批量删除日志
export function deleteBatchLogApi(logIds: number[]): Promise<void> {
return request.delete('/api/sys/log/batch', { logIds })
}
// API调用方法
export const logApi = {
// 获取日志列表
getLogList: (params: LogSearchParams): Promise<ApiResponse<PageData<Log>>> => {
return api.get('/page', { params })
},
// 清理过期日志
export function cleanExpiredLogsApi(days: number): Promise<void> {
return request.post('/api/sys/log/clean', { days })
}
// 获取日志详情
getLogDetail: (logId: number): Promise<ApiResponse<LogDetail>> => {
return api.get(`/${logId}`)
},
// 获取日志统计
export function getLogCountApi(params: {
logType?: string
startTime?: string
endTime?: string
}): Promise<{ total: number; success: number; error: number }> {
return request.get('/api/sys/log/count', params)
}
// 获取用户登录日志
getLoginLogsByUserId: (userId: number, startTime?: string, endTime?: string): Promise<ApiResponse<Log[]>> => {
return api.get(`/login/${userId}`, {
params: {
startTime,
endTime
}
})
},
// 获取用户登录日志
export function getUserLoginLogsApi(params: PageRequest & {
userId: number
startTime?: string
endTime?: string
}): Promise<PageResponse<Log>> {
return request.get('/api/sys/log/login', params)
// 清理过期日志
cleanExpiredLogs: (expireDays: number): Promise<ApiResponse<string>> => {
return api.delete('/clean', {
params: {
expireDays
}
})
},
// 统计日志数量
countLogs: (logType?: string, startTime?: string, endTime?: string): Promise<ApiResponse<number>> => {
return api.get('/count', {
params: {
logType,
startTime,
endTime
}
})
},
// 删除日志
deleteLog: (logId: number): Promise<ApiResponse<string>> => {
return api.delete(`/${logId}`)
},
// 批量删除日志
deleteLogs: (logIds: number[]): Promise<ApiResponse<string>> => {
return api.delete('/batch', { data: logIds })
}
}
export default logApi
\ No newline at end of file
......
import { request } from '@/utils/request'
import type { Menu, PageRequest, PageResponse } from '@/types'
import axios from 'axios'
// 获取菜单列表
export function getMenuListApi(): Promise<Menu[]> {
return request.get('/api/sys/menu/list')
}
// API基础配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8083'
// 获取菜单树
export function getMenuTreeApi(userId?: number): Promise<Menu[]> {
return request.get('/api/sys/menu/tree', { userId })
}
// 创建axios实例
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 获取菜单分页列表
export function getMenuPageApi(params: PageRequest & {
menuName?: string
console.log('菜单API实例创建,baseURL:', API_BASE_URL)
// 请求拦截器 - 添加token
api.interceptors.request.use(
(config) => {
console.log('菜单API请求:', {
url: config.url,
baseURL: config.baseURL,
method: config.method,
params: config.params,
fullURL: `${config.baseURL}${config.url}`
})
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
console.error('API请求错误:', error)
return Promise.reject(error)
}
)
// 菜单接口类型定义
export interface Menu {
menuId: number
parentId?: number
menuName: string
menuType: string
menuTypeText: string
path?: string
menuType?: string
icon?: string
sort: number
status: number
statusText: string
perms?: string
}): Promise<PageResponse<Menu>> {
return request.get('/api/sys/menu/page', params)
component?: string
isFrame?: number
remark?: string
level?: number
createTime: string
updateTime: string
children?: Menu[]
}
// 获取菜单详情
export function getMenuDetailApi(menuId: number): Promise<Menu> {
return request.get(`/api/sys/menu/${menuId}`)
export interface MenuSearchParams {
menuName?: string
menuType?: string
status?: number
pageNum?: number
pageSize?: number
}
// 新增菜单
export function addMenuApi(data: Partial<Menu>): Promise<void> {
return request.post('/api/sys/menu', data)
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 更新菜单
export function updateMenuApi(data: Partial<Menu>): Promise<void> {
return request.put('/api/sys/menu', data)
export interface PageResponse<T> {
records: T[]
total: number
size: number
current: number
orders: any[]
optimizeCountSql: boolean
searchCount: boolean
maxLimit: any
countId: any
pages: number
}
// 删除菜单
export function deleteMenuApi(menuId: number): Promise<void> {
return request.delete(`/api/sys/menu/${menuId}`)
}
// 菜单API接口
export const menuApi = {
// 获取菜单列表
getMenuList: (params: MenuSearchParams): Promise<ApiResponse<PageResponse<Menu>>> => {
return api.get('/api/system/menu/list', { params })
},
// 批量删除菜单
export function deleteBatchMenuApi(menuIds: number[]): Promise<void> {
return request.delete('/api/sys/menu/batch', { menuIds })
}
// 获取菜单树列表
getMenuTree: (userId?: number): Promise<ApiResponse<Menu[]>> => {
return api.get('/api/system/menu/tree', { params: { userId } })
},
// 获取角色菜单树
export function getRoleMenuTreeApi(roleId: number): Promise<Menu[]> {
return request.get(`/api/sys/menu/roleMenuTree/${roleId}`)
}
// 获取菜单详情
getMenuById: (menuId: number): Promise<ApiResponse<Menu>> => {
return api.get(`/api/system/menu/${menuId}`)
},
// 新增菜单
createMenu: (menuData: any): Promise<ApiResponse<any>> => {
return api.post('/api/system/menu', menuData)
},
// 获取菜单选择树
export function getMenuSelectTreeApi(): Promise<Menu[]> {
return request.get('/api/sys/menu/treeselect')
// 修改菜单
updateMenu: (menuData: any): Promise<ApiResponse<any>> => {
return api.put('/api/system/menu', menuData)
},
// 删除菜单
deleteMenu: (menuIds: number[]): Promise<ApiResponse<any>> => {
return api.delete(`/api/system/menu/${menuIds.join(',')}`)
},
// 获取菜单下拉树选择
getMenuTreeSelect: (): Promise<ApiResponse<Menu[]>> => {
return api.get('/api/system/menu/treeselect')
},
// 根据角色ID获取菜单下拉树选择
getMenuTreeSelectByRole: (roleId: number): Promise<ApiResponse<Menu[]>> => {
return api.get(`/api/system/menu/roleMenuTreeselect/${roleId}`)
}
}
export default menuApi
\ No newline at end of file
......
import { request } from '@/utils/request'
import type { Role, PageRequest, PageResponse } from '@/types'
import axios from 'axios'
// 获取角色列表
export function getRoleListApi(): Promise<Role[]> {
return request.get('/api/sys/role/list')
// API基础配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8083'
// 创建axios实例
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
console.log('角色API实例创建,baseURL:', API_BASE_URL)
// 请求拦截器 - 添加token
api.interceptors.request.use(
(config) => {
console.log('角色API请求:', {
url: config.url,
baseURL: config.baseURL,
method: config.method,
params: config.params,
fullURL: `${config.baseURL}${config.url}`
})
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
console.error('API请求错误:', error)
return Promise.reject(error)
}
)
// 菜单接口类型定义
export interface Menu {
menuId: number
parentId?: number
menuName: string
menuType: string
menuTypeText: string
path?: string
icon?: string
sort: number
status: number
statusText: string
perms?: string
createTime: string
updateTime: string
children?: Menu[]
}
// 获取角色分页列表
export function getRolePageApi(params: PageRequest & {
// 角色接口类型定义
export interface Role {
roleId: number
roleCode: string
roleName: string
status: number
statusText: string
remark: string
createTime: string
updateTime: string
menuIds?: number[]
menus?: Menu[]
}
export interface RoleSearchParams {
roleCode?: string
roleName?: string
roleKey?: string
status?: number
}): Promise<PageResponse<Role>> {
return request.get('/api/sys/role/page', params)
pageNum?: number
pageSize?: number
}
// 获取角色详情
export function getRoleDetailApi(roleId: number): Promise<Role> {
return request.get(`/api/sys/role/${roleId}`)
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 新增角色
export function addRoleApi(data: Partial<Role>): Promise<void> {
return request.post('/api/sys/role', data)
export interface PageResponse<T> {
records: T[]
total: number
size: number
current: number
orders: any[]
optimizeCountSql: boolean
searchCount: boolean
maxLimit: any
countId: any
pages: number
}
// 更新角色
export function updateRoleApi(data: Partial<Role>): Promise<void> {
return request.put('/api/sys/role', data)
}
// 角色API接口
export const roleApi = {
// 获取角色列表
getRoleList: (params?: RoleSearchParams): Promise<ApiResponse<PageResponse<Role>>> => {
return api.get('/api/system/role/list', { params })
},
// 删除角色
export function deleteRoleApi(roleId: number): Promise<void> {
return request.delete(`/api/sys/role/${roleId}`)
}
// 获取所有角色(不分页,用于下拉选择)
getAllRoles: (): Promise<ApiResponse<Role[]>> => {
return api.get('/api/system/role/list', {
params: { status: 1, pageSize: 9999 }
})
},
// 批量删除角色
export function deleteBatchRoleApi(roleIds: number[]): Promise<void> {
return request.delete('/api/sys/role/batch', { roleIds })
}
// 根据ID获取角色详情
getRoleById: (roleId: number): Promise<ApiResponse<Role>> => {
return api.get(`/api/system/role/${roleId}`)
},
// 修改角色状态
export function changeRoleStatusApi(data: {
roleId: number
status: number
}): Promise<void> {
return request.post('/api/sys/role/changeStatus', data)
}
// 创建角色
createRole: (roleData: any): Promise<ApiResponse<any>> => {
return api.post('/api/system/role/add', roleData)
},
// 分配权限
export function assignPermissionsApi(data: {
roleId: number
menuIds: number[]
}): Promise<void> {
return request.post('/api/sys/role/assignPermissions', data)
}
// 更新角色
updateRole: (roleData: any): Promise<ApiResponse<any>> => {
return api.post('/api/system/role/edit', roleData)
},
// 删除角色(单个或批量)
deleteRole: (roleIds: number[]): Promise<ApiResponse<any>> => {
return api.delete(`/api/system/role/${roleIds.join(',')}`)
},
// 更新角色状态
updateRoleStatus: (roleId: number, status: number): Promise<ApiResponse<any>> => {
return api.post('/api/system/role/changeStatus', {
roleId: roleId,
status: status
})
},
// 获取菜单树列表
getMenuTree: (): Promise<ApiResponse<Menu[]>> => {
return api.get('/api/system/menu/tree')
},
// 测试角色列表接口参数格式
testRoleListParams: (): RoleSearchParams => {
return {
roleCode: "",
roleName: "",
status: 1, // 默认获取有效角色
pageNum: 1,
pageSize: 9999 // 获取全部有效角色
}
}
}
\ No newline at end of file
......
import { request } from '@/utils/request'
import type { User, PageRequest, PageResponse } from '@/types'
import axios from 'axios'
// 获取用户列表
export function getUserListApi(): Promise<User[]> {
return request.get('/api/sys/user/list')
// API基础配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8083'
// 创建axios实例
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器 - 添加token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器 - 处理错误
api.interceptors.response.use(
(response) => {
return response
},
(error) => {
if (error.response?.status === 401) {
// token过期,跳转到登录页
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
window.location.href = '/'
}
return Promise.reject(error)
}
)
// 用户数据类型
export interface User {
userId: number
username: string
realName: string
phone: string
email: string
status: number
statusText: string
remark: string
createBy: string
createTime: string
updateBy: string
updateTime: string
lastLoginTime: string | null
lastLoginIp: string | null
roles: Role[]
}
// 获取用户分页列表
export function getUserPageApi(params: PageRequest & {
// 角色类型
export interface Role {
roleId: number
roleCode: string
roleName: string
status: number
statusText: string
remark: string
createBy: string
createTime: string
updateBy: string
updateTime: string
menus: any
}
// 搜索参数类型
export interface UserSearchParams {
username?: string
realName?: string
phone?: string
email?: string
status?: number
}): Promise<PageResponse<User>> {
return request.get('/api/sys/user/page', params)
startTime?: string
endTime?: string
page?: number
pageSize?: number
}
// 获取用户详情
export function getUserDetailApi(userId: number): Promise<User> {
return request.get(`/api/sys/user/${userId}`)
// 用户表单类型
export interface UserForm {
userId?: number
username: string
realName: string
phone: string
email: string
status: number
remark: string
roleIds: number[]
password?: string
}
// 新增用户
export function addUserApi(data: Partial<User>): Promise<void> {
return request.post('/api/sys/user', data)
// API响应类型
export interface ApiResponse<T> {
code: number
message: string
data: T
}
// 更新用户
export function updateUserApi(data: Partial<User>): Promise<void> {
return request.put('/api/sys/user', data)
// 分页响应类型
export interface PageResponse<T> {
records: T[]
total: number
size: number
current: number
orders: any[]
optimizeCountSql: boolean
searchCount: boolean
maxLimit: any
countId: any
pages: number
}
// 删除用户
export function deleteUserApi(userId: number): Promise<void> {
return request.delete(`/api/sys/user/${userId}`)
}
// 用户API接口
export const userApi = {
// 获取用户列表
getUserList: async (params: UserSearchParams = {}) => {
try {
const response = await api.get<ApiResponse<PageResponse<User>>>('/api/system/user/list', { params })
return response.data
} catch (error) {
console.error('获取用户列表失败:', error)
throw error
}
},
// 批量删除用户
export function deleteBatchUserApi(userIds: number[]): Promise<void> {
return request.delete('/api/sys/user/batch', { userIds })
}
// 获取用户详情
getUserById: async (id: number) => {
try {
const response = await api.get<ApiResponse<User>>(`/api/system/user/${id}`)
return response.data
} catch (error) {
console.error('获取用户详情失败:', error)
throw error
}
},
// 重置密码
export function resetPasswordApi(data: {
userId: number
newPassword: string
}): Promise<void> {
return request.post('/api/sys/user/resetPassword', data)
}
// 创建用户
createUser: async (userData: UserForm) => {
try {
const response = await api.post<ApiResponse<User>>('/api/system/user/add', userData)
return response.data
} catch (error) {
console.error('创建用户失败:', error)
throw error
}
},
// 修改用户状态
export function changeUserStatusApi(data: {
userId: number
status: number
}): Promise<void> {
return request.post('/api/sys/user/changeStatus', data)
}
// 更新用户
updateUser: async (id: number, userData: UserForm) => {
try {
const response = await api.post<ApiResponse<User>>('/api/system/user/edit', userData)
return response.data
} catch (error) {
console.error('更新用户失败:', error)
throw error
}
},
// 删除用户(单个或批量)
deleteUser: async (id: number) => {
try {
const response = await api.delete<ApiResponse<void>>(`/api/system/user/${id}`)
return response.data
} catch (error) {
console.error('删除用户失败:', error)
throw error
}
},
// 批量删除用户
batchDeleteUsers: async (ids: number[]) => {
try {
const response = await api.delete<ApiResponse<void>>(`/api/system/user/${ids.join(',')}`)
return response.data
} catch (error) {
console.error('批量删除用户失败:', error)
throw error
}
},
// 更新用户状态
updateUserStatus: async (id: number, status: number) => {
try {
const response = await api.post<ApiResponse<void>>('/api/system/user/changeStatus', {
userId: id,
status: status
})
return response.data
} catch (error) {
console.error('更新用户状态失败:', error)
throw error
}
},
// 分配角色
export function assignRolesApi(data: {
userId: number
roleIds: number[]
}): Promise<void> {
return request.post('/api/sys/user/assignRoles', data)
}
export default userApi
\ No newline at end of file
......
<template>
<div class="main-layout">
<!-- 左侧菜单栏 -->
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="sidebar-header">
<h2 class="logo">Apple ERP</h2>
<button
class="collapse-btn"
@click="toggleSidebar"
:title="sidebarCollapsed ? '展开菜单' : '收起菜单'"
>
{{ sidebarCollapsed ? '→' : '←' }}
</button>
</div>
<nav class="sidebar-nav">
<ul class="nav-list">
<li v-for="item in menuItems" :key="item.path || item.name" class="nav-item">
<!-- 一级菜单 -->
<div v-if="!item.children" class="nav-item-single">
<a
@click="handleMenuClick(item.path)"
class="nav-link"
:class="{ active: $route.path === item.path }"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-text" v-show="!sidebarCollapsed">{{ item.name }}</span>
</a>
</div>
<!-- 有子菜单的一级菜单 -->
<div v-else class="nav-item-group">
<div
class="nav-link nav-link-parent"
:class="{ active: isParentActive(item) }"
@click="toggleSubmenu(item)"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-text" v-show="!sidebarCollapsed">{{ item.name }}</span>
<span class="nav-arrow" v-show="!sidebarCollapsed">{{ item.expanded ? '▼' : '▶' }}</span>
</div>
<!-- 二级菜单 -->
<ul v-if="item.expanded && !sidebarCollapsed" class="submenu">
<li v-for="child in item.children" :key="child.path" class="submenu-item">
<a
@click="child.path ? handleMenuClick(child.path) : null"
class="nav-link nav-link-child"
:class="{ active: $route.path === child.path }"
>
<span class="nav-icon">{{ child.icon }}</span>
<span class="nav-text">{{ child.name }}</span>
</a>
</li>
</ul>
</div>
</li>
</ul>
</nav>
</aside>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 顶部导航栏 -->
<header class="top-header">
<div class="header-left">
<button class="menu-toggle" @click="toggleSidebar">
</button>
<h1 class="page-title">{{ currentPageTitle }}</h1>
</div>
<div class="header-right">
<div class="user-info">
<span class="welcome-text">欢迎,{{ userInfo?.username || '用户' }}</span>
<div class="user-actions">
<button class="logout-btn" @click="handleLogout">退出登录</button>
</div>
</div>
</div>
</header>
<!-- 标签页导航栏 -->
<div class="tab-navigation">
<div class="tab-scroll-left" @click="scrollTabsLeft">
<span class="scroll-arrow">‹‹</span>
</div>
<div class="tab-container" ref="tabContainer">
<div
v-for="tab in openTabs"
:key="tab.id"
class="tab-item"
:class="{ active: tab.active }"
@click="switchToTab(tab)"
>
<span class="tab-name">{{ tab.name }}</span>
<span
v-if="tab.closable"
@click.stop="closeTab(tab)"
class="tab-close"
>×</span>
</div>
</div>
<div class="tab-scroll-right" @click="scrollTabsRight">
<span class="scroll-arrow">››</span>
</div>
</div>
<!-- 页面内容 -->
<main class="page-content">
<router-view />
</main>
<!-- 底部栏 -->
<footer class="main-footer">
<div class="footer-content">
<p class="copyright">© 2025 Erry Copyright</p>
</div>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 侧边栏状态
const sidebarCollapsed = ref(false)
// 用户信息
const userInfo = ref<any>(null)
// 标签页容器引用
const tabContainer = ref<HTMLElement>()
// 菜单项配置
const menuItems = ref([
{ name: '首页', path: '/main/dashboard', icon: '🏠' },
{
name: '系统设置',
icon: '⚙️',
expanded: false,
children: [
{ name: '用户管理', path: '/main/users', icon: '👤' },
{ name: '角色管理', path: '/main/sys/role', icon: '🛡️' },
{ name: '菜单管理', path: '/main/sys/menu', icon: '📝' },
{
name: '字典管理',
path: '/main/sys/dict',
icon: '📖',
expanded: false,
children: [
{ name: '字典类型', path: '/main/sys/dict', icon: '📋' },
{ name: '字典项', path: '/main/sys/dict-item', icon: '📝' }
]
},
{ name: '日志管理', path: '/main/sys/log', icon: '📊' }
]
}
])
// 打开的标签页
const openTabs = ref([
{ id: 1, name: '首页', path: '/main/dashboard', active: true, closable: false }
])
// 当前页面标题
const currentPageTitle = computed(() => {
const activeTab = openTabs.value.find(tab => tab.active)
return activeTab ? activeTab.name : 'Apple经销商ERP系统'
})
// 处理菜单点击
const handleMenuClick = (path: string) => {
if (path && path !== route.path) {
console.log('菜单导航:', path)
// 检查是否已经存在该标签页
const existingTab = openTabs.value.find(tab => tab.path === path)
if (existingTab) {
// 切换到已存在的标签页
switchToTab(existingTab)
} else {
// 创建新标签页
const menuItem = findMenuItemByPath(path)
if (menuItem) {
addNewTab(menuItem.name, path)
}
}
router.push(path).catch(err => {
console.error('路由跳转失败:', err)
})
}
}
// 根据路径查找菜单项
const findMenuItemByPath = (path: string) => {
for (const item of menuItems.value) {
if (item.path === path) {
return item
}
if (item.children && Array.isArray(item.children)) {
const child = item.children.find((child: any) => child.path === path)
if (child) return child
}
}
return null
}
// 添加新标签页
const addNewTab = (name: string, path: string) => {
// 先取消所有标签页的激活状态
openTabs.value.forEach(tab => tab.active = false)
// 添加新标签页
const newTab = {
id: Date.now(),
name: name,
path: path,
active: true,
closable: true
}
openTabs.value.push(newTab)
}
// 切换到指定标签页
const switchToTab = (tab: any) => {
// 取消所有标签页的激活状态
openTabs.value.forEach(t => t.active = false)
// 激活当前标签页
tab.active = true
// 跳转到对应路由
if (tab.path !== route.path) {
router.push(tab.path)
}
}
// 关闭标签页
const closeTab = (tab: any) => {
if (!tab.closable) return
const tabIndex = openTabs.value.findIndex(t => t.id === tab.id)
if (tabIndex === -1) return
// 如果关闭的是当前激活的标签页
if (tab.active) {
// 找到下一个要激活的标签页
let nextActiveTab = null
// 优先选择右侧的标签页
if (tabIndex < openTabs.value.length - 1) {
nextActiveTab = openTabs.value[tabIndex + 1]
} else if (tabIndex > 0) {
nextActiveTab = openTabs.value[tabIndex - 1]
}
// 移除当前标签页
openTabs.value.splice(tabIndex, 1)
// 激活下一个标签页
if (nextActiveTab) {
nextActiveTab.active = true
router.push(nextActiveTab.path)
} else {
// 如果没有其他标签页,跳转到首页
router.push('/main/dashboard')
}
} else {
// 如果关闭的不是当前激活的标签页,直接移除
openTabs.value.splice(tabIndex, 1)
}
}
// 标签页滚动功能
const scrollTabsLeft = () => {
if (tabContainer.value) {
tabContainer.value.scrollBy({ left: -200, behavior: 'smooth' })
}
}
const scrollTabsRight = () => {
if (tabContainer.value) {
tabContainer.value.scrollBy({ left: 200, behavior: 'smooth' })
}
}
// 切换子菜单展开/收起
const toggleSubmenu = (item: any) => {
item.expanded = !item.expanded
// 如果父级菜单有路径,则同时导航到该路径
if (item.path && item.path !== route.path) {
handleMenuClick(item.path)
}
}
// 检查父菜单是否激活
const isParentActive = (item: any) => {
if (!item.children) return false
return item.children.some((child: any) => child.path === route.path)
}
// 切换侧边栏
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 退出登录
const handleLogout = () => {
if (confirm('确定要退出登录吗?')) {
// 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// 跳转到登录页
router.push('/')
}
}
// 监听路由变化,自动管理标签页
watch(() => route.path, (newPath) => {
// 检查是否已经存在该路径的标签页
const existingTab = openTabs.value.find(tab => tab.path === newPath)
if (existingTab) {
// 如果存在,激活该标签页
openTabs.value.forEach(tab => tab.active = false)
existingTab.active = true
} else {
// 如果不存在,创建新标签页
const menuItem = findMenuItemByPath(newPath)
if (menuItem) {
addNewTab(menuItem.name, newPath)
}
}
// 自动展开对应的父菜单
for (const item of menuItems.value) {
if (item.children && Array.isArray(item.children)) {
const hasActiveChild = item.children.some((child: any) => child.path === newPath)
if (hasActiveChild) {
item.expanded = true
break
}
}
}
}, { immediate: true })
// 组件挂载时获取用户信息
onMounted(() => {
const storedUserInfo = localStorage.getItem('userInfo')
if (storedUserInfo) {
userInfo.value = JSON.parse(storedUserInfo)
}
})
</script>
<style scoped>
.main-layout {
display: flex;
height: 100vh;
background-color: #f5f5f5;
}
/* 左侧菜单栏 */
.sidebar {
width: 200px;
background: linear-gradient(180deg, #2c3e50 0%, #34495e 100%);
color: white;
transition: width 0.3s ease;
overflow: hidden;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
z-index: 1000;
}
.sidebar.collapsed {
width: 50px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.logo {
margin: 0;
font-size: 16px;
font-weight: bold;
color: #ecf0f1;
}
.collapse-btn {
background: none;
border: none;
color: white;
font-size: 14px;
cursor: pointer;
padding: 4px;
border-radius: 3px;
transition: background-color 0.3s;
}
.collapse-btn:hover {
background-color: rgba(255,255,255,0.1);
}
/* 导航菜单 */
.sidebar-nav {
padding: 16px 0;
}
.nav-list {
list-style: none;
margin: 0;
padding: 0;
}
.nav-item {
margin-bottom: 5px;
}
.nav-link {
display: flex;
align-items: center;
padding: 8px 12px;
color: #bdc3c7;
text-decoration: none;
transition: all 0.3s ease;
border-left: 3px solid transparent;
cursor: pointer;
}
.nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-link.active {
background-color: rgba(52, 152, 219, 0.2);
color: #3498db;
border-left-color: #3498db;
}
.nav-icon {
font-size: 16px;
margin-right: 10px;
min-width: 18px;
}
.nav-text {
font-size: 13px;
white-space: nowrap;
}
.nav-arrow {
font-size: 10px;
margin-left: auto;
transition: transform 0.3s ease;
}
/* 二级菜单样式 */
.nav-item-group {
margin-bottom: 5px;
}
.nav-link-parent {
cursor: pointer;
user-select: none;
}
.nav-link-parent:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.submenu {
list-style: none;
margin: 0;
padding: 0;
background: rgba(0,0,0,0.1);
border-left: 2px solid #3498db;
}
.submenu-item {
margin: 0;
}
.nav-link-child {
padding: 6px 12px 6px 32px;
font-size: 12px;
color: #bdc3c7;
}
.nav-link-child:hover {
background-color: rgba(255,255,255,0.05);
color: white;
}
.nav-link-child.active {
background-color: rgba(52, 152, 219, 0.2);
color: #3498db;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* 顶部导航栏 */
.top-header {
background: white;
padding: 0 16px;
height: 50px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-bottom: 1px solid #e0e0e0;
position: relative;
z-index: 100;
width: 100%;
min-height: 50px;
}
.header-left {
display: flex;
align-items: center;
}
.menu-toggle {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
padding: 6px;
margin-right: 12px;
border-radius: 4px;
transition: background-color 0.3s;
}
.menu-toggle:hover {
background-color: #f0f0f0;
}
.page-title {
margin: 0;
font-size: 18px;
color: #333;
font-weight: 500;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.welcome-text {
color: #666;
font-size: 13px;
}
.logout-btn {
background: #e74c3c;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.3s;
}
.logout-btn:hover {
background: #c0392b;
}
/* 标签页导航栏 */
.tab-navigation {
background: white;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
height: 32px;
padding: 0 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.tab-scroll-left,
.tab-scroll-right {
background: #f5f5f5;
border: 1px solid #d9d9d9;
padding: 2px 6px;
cursor: pointer;
border-radius: 2px;
margin: 0 1px;
transition: all 0.2s;
}
.tab-scroll-left:hover,
.tab-scroll-right:hover {
background: #e6f7ff;
border-color: #91d5ff;
}
.scroll-arrow {
font-size: 10px;
color: #666;
font-weight: bold;
}
.tab-container {
flex: 1;
display: flex;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tab-container::-webkit-scrollbar {
display: none;
}
.tab-item {
display: flex;
align-items: center;
padding: 4px 8px;
margin: 0 1px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
min-width: 60px;
position: relative;
}
.tab-item:hover {
background: #f0f8ff;
border-color: #91d5ff;
}
.tab-item.active {
background: #1890ff;
border-color: #1890ff;
color: white;
}
.tab-name {
font-size: 11px;
margin-right: 4px;
}
.tab-close {
font-size: 12px;
cursor: pointer;
padding: 1px;
border-radius: 2px;
transition: all 0.2s;
}
.tab-item:not(.active) .tab-close {
color: #999;
}
.tab-item:not(.active) .tab-close:hover {
background: #f5f5f5;
color: #666;
}
.tab-item.active .tab-close {
color: white;
}
.tab-item.active .tab-close:hover {
background: rgba(255,255,255,0.2);
}
/* 页面内容 */
.page-content {
flex: 1;
padding: 0;
overflow-y: auto;
background-color: #f8f9fa;
position: relative;
}
/* 底部栏 */
.main-footer {
background: white;
color: #333;
padding: 8px 20px;
border-top: 1px solid #e0e0e0;
box-shadow: 0 -2px 4px rgba(0,0,0,0.1);
}
.footer-content {
display: flex;
justify-content: center;
align-items: center;
max-width: 1200px;
margin: 0 auto;
}
.copyright {
margin: 0;
font-size: 12px;
color: #666;
text-align: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -200px;
z-index: 1000;
height: 100vh;
}
.sidebar:not(.collapsed) {
left: 0;
}
.main-content {
margin-left: 0;
}
.tab-navigation {
height: 28px;
}
.tab-item {
min-width: 50px;
padding: 3px 6px;
}
.tab-name {
font-size: 10px;
}
}
</style>
\ No newline at end of file
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// 创建应用实例
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
// 挂载应用
app.mount('#app')
......
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import Login from '@/views/login/index.vue'
import MainLayout from '@/layouts/MainLayout.vue'
import Dashboard from '@/views/dashboard/index.vue'
import Users from '@/views/users/index.vue'
import Test from '@/views/Test.vue'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
......@@ -11,6 +15,115 @@ NProgress.configure({ showSpinner: false })
const staticRoutes: RouteRecordRaw[] = [
{
path: '/',
name: 'Login',
component: Login,
meta: {
title: '登录',
requiresAuth: false
}
},
{
path: '/main',
component: MainLayout,
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: Dashboard,
meta: {
title: '首页',
requiresAuth: true
}
},
{
path: 'users',
name: 'Users',
component: Users,
meta: {
title: '用户管理',
requiresAuth: true
}
},
{
path: 'sys/role',
name: 'Role',
component: () => import('@/views/sys/role/index.vue'),
meta: {
title: '角色管理',
requiresAuth: true
}
},
{
path: 'sys/menu',
name: 'Menu',
component: () => import('@/views/sys/menu/index.vue'),
meta: {
title: '菜单管理',
requiresAuth: true
}
},
{
path: 'sys/dict',
name: 'Dict',
component: () => import('@/views/sys/dict/index.vue'),
meta: {
title: '字典管理',
requiresAuth: true
}
},
{
path: 'sys/dict-item',
name: 'DictItem',
component: () => import('@/views/sys/dict-item/index.vue'),
meta: {
title: '字典项管理',
requiresAuth: true
}
},
{
path: 'sys/log',
name: 'Log',
component: () => import('@/views/sys/log/index.vue'),
meta: {
title: '日志管理',
requiresAuth: true
}
},
{
path: 'dicts',
name: 'Dicts',
component: () => import('@/views/dicts/index.vue'),
meta: {
title: '字典管理',
requiresAuth: true
}
},
{
path: 'logs',
name: 'Logs',
component: () => import('@/views/logs/index.vue'),
meta: {
title: '日志管理',
requiresAuth: true
}
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/index.vue'),
meta: {
title: '系统设置',
requiresAuth: true
}
}
]
},
{
path: '/dashboard',
redirect: '/main/dashboard'
},
{
path: '/test',
name: 'Test',
component: Test,
meta: {
......@@ -27,8 +140,13 @@ const router = createRouter({
scrollBehavior: () => ({ top: 0 })
})
// 路由守卫
router.beforeEach(async (to, from, next) => {
// 优化的路由守卫
router.beforeEach((to, from, next) => {
// 只在路由真正变化时记录日志
if (to.path !== from.path) {
console.log('路由变化:', { from: from.path, to: to.path })
}
// 开始进度条
NProgress.start()
......@@ -37,7 +155,24 @@ router.beforeEach(async (to, from, next) => {
document.title = `${to.meta.title} - Apple经销商ERP系统`
}
// 暂时跳过所有权限检查,直接放行
// 检查认证状态
if (to.meta.requiresAuth) {
const token = localStorage.getItem('token')
if (!token) {
console.log('未认证,跳转到登录页')
next('/')
return
}
}
// 如果已登录且访问登录页,跳转到首页
if (to.path === '/' && localStorage.getItem('token')) {
console.log('已登录,跳转到首页')
next('/main/dashboard')
return
}
// 继续导航
next()
})
......@@ -52,4 +187,4 @@ router.onError((error) => {
NProgress.done()
})
export default router
export default router
\ No newline at end of file
......
......@@ -12,9 +12,19 @@ const service: AxiosInstance = axios.create({
}
})
console.log('Axios实例创建,baseURL:', import.meta.env.VITE_API_BASE_URL)
// 请求拦截器
service.interceptors.request.use(
(config: any) => {
console.log('请求拦截器 - 请求配置:', {
url: config.url,
baseURL: config.baseURL,
method: config.method,
params: config.params,
headers: config.headers
})
const userStore = useUserStore()
// 添加token到请求头
......@@ -105,6 +115,7 @@ service.interceptors.response.use(
// 请求方法封装
export const request = {
get<T = any>(url: string, params?: any): Promise<T> {
console.log('Request GET:', url, params)
return service.get(url, { params })
},
......
<template>
<div class="dashboard">
<!-- 欢迎区域 -->
<div class="welcome-section">
<el-card class="welcome-card">
<div class="welcome-content">
<div class="welcome-text">
<h2>欢迎回来,{{ userStore.username }}!</h2>
<p>今天是 {{ currentDate }},祝您工作愉快!</p>
</div>
<div class="welcome-avatar">
<el-avatar :size="80" :src="userStore.avatar">
<el-icon><User /></el-icon>
</el-avatar>
</div>
</div>
</el-card>
<div class="dashboard-container">
<div class="dashboard-header">
<h2>系统概览</h2>
<p>欢迎回来,{{ userInfo?.username || '用户' }}!</p>
</div>
<!-- 统计卡片 -->
<div class="stats-section">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6" v-for="stat in statsData" :key="stat.title">
<el-card class="stat-card" :class="stat.type">
<div class="stat-content">
<div class="stat-icon">
<el-icon :size="32">
<component :is="stat.icon" />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 快捷操作 -->
<div class="quick-actions-section">
<el-card>
<template #header>
<div class="card-header">
<span class="title">快捷操作</span>
</div>
</template>
<div class="quick-actions">
<div
v-for="action in quickActions"
:key="action.title"
class="action-item"
@click="handleQuickAction(action)"
>
<div class="action-icon">
<el-icon :size="24">
<component :is="action.icon" />
</el-icon>
</div>
<div class="action-title">{{ action.title }}</div>
</div>
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-icon">👤</div>
<div class="stat-content">
<h3>用户总数</h3>
<p class="stat-number">1,234</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📈</div>
<div class="stat-content">
<h3>今日访问</h3>
<p class="stat-number">567</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">✅</div>
<div class="stat-content">
<h3>系统状态</h3>
<p class="stat-number">正常</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">⏱️</div>
<div class="stat-content">
<h3>运行时间</h3>
<p class="stat-number">{{ currentTime }}</p>
</div>
</el-card>
</div>
</div>
<!-- 系统信息 -->
<div class="system-info-section">
<el-row :gutter="20">
<el-col :xs="24" :lg="12">
<el-card>
<template #header>
<div class="card-header">
<span class="title">系统信息</span>
</div>
</template>
<div class="system-info">
<div class="dashboard-content">
<div class="dashboard-card">
<h3>系统信息</h3>
<div class="info-grid">
<div class="info-item">
<span class="label">系统版本:</span>
<span class="value">v1.0.0</span>
<label>用户名:</label>
<span>{{ userInfo?.username || '未知' }}</span>
</div>
<div class="info-item">
<span class="label">运行时间:</span>
<span class="value">{{ systemUptime }}</span>
<label>角色:</label>
<span>{{ userInfo?.roles?.[0]?.roleName || '普通用户' }}</span>
</div>
<div class="info-item">
<span class="label">在线用户:</span>
<span class="value">{{ onlineUsers }}</span>
<label>登录时间:</label>
<span>{{ currentTime }}</span>
</div>
<div class="info-item">
<span class="label">最后登录:</span>
<span class="value">{{ lastLoginTime }}</span>
<label>系统版本:</label>
<span>v1.0.0</span>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card>
<template #header>
<div class="card-header">
<span class="title">最近活动</span>
</div>
</template>
<div class="recent-activities">
<div
v-for="activity in recentActivities"
:key="activity.id"
class="activity-item"
>
<div class="activity-icon">
<el-icon>
<component :is="activity.icon" />
</el-icon>
</div>
<div class="activity-content">
<div class="activity-title">{{ activity.title }}</div>
<div class="activity-time">{{ activity.time }}</div>
</div>
</div>
<div class="dashboard-card">
<h3>快速操作</h3>
<div class="quick-actions">
<button class="action-btn">👤 用户管理</button>
<button class="action-btn">🛡️ 角色管理</button>
<button class="action-btn">⚙️ 系统设置</button>
<button class="action-btn">📊 查看日志</button>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { formatDate } from '@/utils'
const router = useRouter()
const userStore = useUserStore()
// 当前日期
const currentDate = computed(() => {
return formatDate(new Date(), 'YYYY年MM月DD日 dddd')
})
// 统计数据
const statsData = ref([
{
title: '总用户数',
value: '1,234',
icon: 'User',
type: 'primary'
},
{
title: '今日访问',
value: '5,678',
icon: 'View',
type: 'success'
},
{
title: '系统消息',
value: '12',
icon: 'Message',
type: 'warning'
},
{
title: '待处理',
value: '8',
icon: 'Clock',
type: 'danger'
}
])
const currentTime = ref('')
const userInfo = ref<any>(null)
// 快捷操作
const quickActions = ref([
{
title: '用户管理',
icon: 'User',
path: '/sys/user'
},
{
title: '角色管理',
icon: 'UserFilled',
path: '/sys/role'
},
{
title: '菜单管理',
icon: 'Menu',
path: '/sys/menu'
},
{
title: '字典管理',
icon: 'Document',
path: '/sys/dict'
},
{
title: '日志管理',
icon: 'DocumentCopy',
path: '/sys/log'
},
{
title: '系统设置',
icon: 'Setting',
path: '/sys/settings'
onMounted(() => {
// 获取当前时间
currentTime.value = new Date().toLocaleString()
// 获取用户信息
const storedUserInfo = localStorage.getItem('userInfo')
if (storedUserInfo) {
userInfo.value = JSON.parse(storedUserInfo)
}
])
// 系统信息
const systemUptime = ref('7天12小时30分钟')
const onlineUsers = ref('156')
const lastLoginTime = ref('2024-01-15 14:30:25')
// 最近活动
const recentActivities = ref([
{
id: 1,
title: '用户 admin 登录系统',
time: '2分钟前',
icon: 'User'
},
{
id: 2,
title: '新增用户:张三',
time: '15分钟前',
icon: 'UserFilled'
},
{
id: 3,
title: '修改角色权限:管理员',
time: '1小时前',
icon: 'Lock'
},
{
id: 4,
title: '系统备份完成',
time: '2小时前',
icon: 'Download'
// 检查是否已登录
const token = localStorage.getItem('token')
if (!token) {
router.push('/')
}
])
})
// 处理快捷操作
const handleQuickAction = (action: any) => {
router.push(action.path)
const logout = () => {
// 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// 跳转到登录页
router.push('/')
}
// 组件挂载时初始化
onMounted(() => {
// 这里可以加载一些初始化数据
console.log('仪表板初始化完成')
})
</script>
<style lang="scss" scoped>
.dashboard {
<style scoped>
.dashboard-container {
padding: 20px;
.welcome-section {
margin-bottom: 20px;
.welcome-card {
.welcome-content {
display: flex;
align-items: center;
justify-content: space-between;
.welcome-text {
h2 {
min-height: 100vh;
}
.dashboard-header {
margin-bottom: 30px;
}
.dashboard-header h2 {
font-size: 20px;
color: #333;
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
.dashboard-header p {
color: #666;
margin: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.welcome-avatar {
.el-avatar {
background-color: var(--el-color-primary);
color: white;
}
}
}
}
}
.stats-section {
margin-bottom: 20px;
}
.dashboard-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
.stat-content {
display: flex;
align-items: center;
.stat-icon {
margin-right: 16px;
padding: 12px;
border-radius: 8px;
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.stat-info {
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.stat-title {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
}
&.primary .stat-icon {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
&.success .stat-icon {
background-color: var(--el-color-success-light-9);
color: var(--el-color-success);
}
&.warning .stat-icon {
background-color: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
&.danger .stat-icon {
background-color: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
}
}
.quick-actions-section {
margin-bottom: 20px;
.quick-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
.action-item {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
gap: 15px;
transition: transform 0.3s ease;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-icon {
font-size: 20px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border-radius: 6px;
}
.stat-content h3 {
margin: 0 0 5px 0;
font-size: 12px;
color: #666;
font-weight: 500;
}
.stat-number {
margin: 0;
font-size: 18px;
font-weight: bold;
color: #333;
}
.dashboard-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.dashboard-card {
background: white;
padding: 24px;
border-radius: 8px;
background-color: var(--el-bg-color-page);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background-color: var(--el-color-primary-light-9);
transform: translateY(-2px);
}
.action-icon {
margin-bottom: 8px;
color: var(--el-color-primary);
}
.action-title {
font-size: 14px;
color: var(--el-text-color-primary);
text-align: center;
}
}
}
}
.system-info-section {
.system-info {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.dashboard-card h3 {
margin: 0 0 16px 0;
color: #333;
font-size: 16px;
font-weight: 600;
}
.info-grid {
display: grid;
gap: 12px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
&:last-child {
.info-item:last-child {
border-bottom: none;
}
.label {
font-size: 14px;
color: var(--el-text-color-secondary);
}
.value {
font-size: 14px;
color: var(--el-text-color-primary);
.info-item label {
font-weight: 500;
}
}
}
.recent-activities {
.activity-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.activity-icon {
margin-right: 12px;
padding: 8px;
border-radius: 50%;
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.activity-content {
flex: 1;
.activity-title {
font-size: 14px;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.activity-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
}
color: #666;
}
.info-item span {
color: #333;
}
.quick-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.action-btn {
background: #3498db;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.3s;
}
.action-btn:hover {
background: #2980b9;
}
// 响应式设计
@media (max-width: 768px) {
.dashboard {
padding: 16px;
.welcome-section {
.welcome-content {
flex-direction: column;
text-align: center;
.welcome-avatar {
margin-top: 16px;
}
}
.dashboard-stats {
grid-template-columns: 1fr;
}
.dashboard-content {
grid-template-columns: 1fr;
}
.quick-actions {
grid-template-columns: repeat(2, 1fr) !important;
}
grid-template-columns: 1fr;
}
}
</style>
</style>
\ No newline at end of file
......
......@@ -3,82 +3,62 @@
<div class="login-form-container">
<!-- Logo区域 -->
<div class="login-header">
<img src="/logo.png" alt="Logo" class="login-logo" />
<h1 class="login-title">Apple经销商ERP系统</h1>
<div class="logo-container">
<div class="logo-icon">🍎</div>
<h1 class="login-title">Apple经销商ERP系统</h1>
</div>
<p class="login-subtitle">欢迎登录</p>
</div>
<!-- 登录表单 -->
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
size="large"
prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item prop="captcha" v-if="showCaptcha">
<div class="captcha-container">
<el-input
v-model="loginForm.captcha"
placeholder="请输入验证码"
size="large"
prefix-icon="Picture"
clearable
/>
<img
:src="captchaImage"
alt="验证码"
class="captcha-image"
@click="refreshCaptcha"
<form class="login-form" @submit.prevent="handleLogin">
<div class="form-group">
<div class="input-wrapper">
<span class="input-icon">👤</span>
<input
v-model="loginForm.username"
type="text"
placeholder="请输入用户名"
class="form-input"
required
/>
</div>
</el-form-item>
</div>
<el-form-item>
<div class="login-options">
<el-checkbox v-model="loginForm.rememberMe">
记住我
</el-checkbox>
<el-link type="primary" class="forgot-password">
忘记密码?
</el-link>
<div class="form-group">
<div class="input-wrapper">
<span class="input-icon">🔒</span>
<input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
class="form-input"
required
/>
</div>
</el-form-item>
</div>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<div class="form-group">
<label class="checkbox-label">
<input
v-model="loginForm.rememberMe"
type="checkbox"
class="checkbox-input"
/>
记住我
</label>
<a href="#" class="forgot-password">忘记密码?</a>
</div>
<button
type="submit"
class="login-btn"
:disabled="loading"
>
<span class="btn-icon">{{ loading ? '⏳' : '🚀' }}</span>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
</div>
<!-- 背景装饰 -->
......@@ -91,174 +71,291 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useUserStore } from '@/stores/modules/user'
import type { LoginForm } from '@/types'
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 表单引用
const loginFormRef = ref<FormInstance>()
// 加载状态
const loading = ref(false)
// 验证码相关
const showCaptcha = ref(false)
const captchaImage = ref('')
// 登录表单数据
const loginForm = reactive<LoginForm>({
const loginForm = reactive({
username: '',
password: '',
captcha: '',
rememberMe: false
})
// 表单验证规则
const loginRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '用户名长度在 2 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' }
]
}
// 处理登录
const handleLogin = async () => {
if (!loginFormRef.value) return
if (!loginForm.username || !loginForm.password) {
alert('请输入用户名和密码')
return
}
loading.value = true
try {
const valid = await loginFormRef.value.validate()
if (!valid) return
// 调用后端登录API
const response = await axios.post('http://localhost:8083/api/auth/login', {
username: loginForm.username,
password: loginForm.password,
rememberMe: loginForm.rememberMe
})
loading.value = true
// 登录成功
console.log('登录成功:', response)
console.log('响应数据结构:', {
data: response.data,
status: response.status,
headers: response.headers
})
await userStore.login(loginForm)
// 检查响应数据结构并保存token
let token = null
let userInfo = null
// 尝试不同的数据结构
if (response.data && response.data.token) {
token = response.data.token
userInfo = response.data.userInfo
console.log('从response.data获取token:', token)
} else if (response.data && response.data.data && response.data.data.token) {
token = response.data.data.token
userInfo = response.data.data.userInfo
console.log('从response.data.data获取token:', token)
} else if (response.data && response.data.result && response.data.result.token) {
token = response.data.result.token
userInfo = response.data.result.userInfo
console.log('从response.data.result获取token:', token)
} else {
console.error('未找到token,响应结构:', response.data)
}
if (token) {
localStorage.setItem('token', token)
console.log('Token已保存到localStorage:', token)
if (userInfo) {
localStorage.setItem('userInfo', JSON.stringify(userInfo))
console.log('用户信息已保存:', userInfo)
}
// 验证保存是否成功
const savedToken = localStorage.getItem('token')
console.log('验证保存的token:', savedToken)
} else {
console.error('无法获取token,请检查后端响应格式')
alert('登录成功,但无法获取认证信息')
}
// 登录成功,跳转到目标页面或首页
const redirect = route.query.redirect as string
router.push(redirect || '/')
} catch (error) {
alert('登录成功!')
// 跳转到首页
console.log('准备跳转到首页...')
// 添加短暂延迟,确保localStorage保存完成
await new Promise(resolve => setTimeout(resolve, 100))
// 再次验证token是否存在
const finalToken = localStorage.getItem('token')
console.log('跳转前最终验证token:', finalToken)
if (finalToken) {
try {
await router.push('/main/dashboard')
console.log('跳转成功')
} catch (error) {
console.error('跳转失败:', error)
// 如果路由跳转失败,使用window.location
console.log('使用window.location跳转')
window.location.href = '/main/dashboard'
}
} else {
console.error('Token仍然不存在,无法跳转')
alert('认证信息保存失败,请重试')
}
} catch (error: any) {
console.error('登录失败:', error)
// 显示验证码
showCaptcha.value = true
await refreshCaptcha()
// 显示具体错误信息
const errorMessage = error.response?.data?.message || error.message || '登录失败,请重试'
alert(errorMessage)
} finally {
loading.value = false
}
}
// 刷新验证码
const refreshCaptcha = async () => {
try {
// 这里应该调用获取验证码的API
// const response = await getCaptchaApi()
// captchaImage.value = response.captchaImage
captchaImage.value = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
} catch (error) {
console.error('获取验证码失败:', error)
}
}
// 组件挂载时初始化
onMounted(() => {
// 如果已经登录,直接跳转到首页
if (userStore.isLoggedIn) {
router.push('/')
}
})
</script>
<style lang="scss" scoped>
<style scoped>
.login-container {
position: relative;
width: 100%;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin: 0;
padding: 0;
}
.login-form-container {
position: relative;
z-index: 2;
width: 400px;
width: 420px;
padding: 40px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.login-header {
text-align: center;
margin-bottom: 32px;
.login-logo {
width: 64px;
height: 64px;
margin-bottom: 16px;
}
.login-title {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.login-subtitle {
margin: 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.logo-container {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 16px;
}
.logo-icon {
font-size: 32px;
animation: bounce 2s infinite;
}
.login-title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #333;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-subtitle {
margin: 0;
font-size: 14px;
color: #666;
font-weight: 500;
}
.login-form {
.captcha-container {
display: flex;
align-items: center;
gap: 12px;
.captcha-image {
width: 100px;
height: 40px;
border-radius: 4px;
cursor: pointer;
border: 1px solid var(--el-border-color);
}
}
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.forgot-password {
font-size: 14px;
}
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 500;
}
width: 100%;
}
.form-group {
margin-bottom: 20px;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 12px;
font-size: 16px;
color: #667eea;
z-index: 1;
}
.form-input {
width: 100%;
height: 40px;
padding: 0 12px 0 40px;
border: 2px solid #e1e5e9;
border-radius: 10px;
font-size: 14px;
box-sizing: border-box;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.9);
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
background: white;
transform: translateY(-1px);
}
.checkbox-label {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
cursor: pointer;
}
.checkbox-input {
margin-right: 6px;
}
.forgot-password {
color: #667eea;
text-decoration: none;
font-size: 12px;
}
.login-options {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 20px;
}
.login-btn {
width: 100%;
height: 44px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.login-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.login-btn:active:not(:disabled) {
transform: translateY(0);
}
.login-btn:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-icon {
font-size: 18px;
}
.login-bg {
......@@ -268,37 +365,37 @@ onMounted(() => {
width: 100%;
height: 100%;
z-index: 1;
.bg-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
&.bg-circle-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
&.bg-circle-2 {
width: 150px;
height: 150px;
top: 60%;
right: 10%;
animation-delay: 2s;
}
&.bg-circle-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
}
}
.bg-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
}
.bg-circle-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
.bg-circle-2 {
width: 150px;
height: 150px;
top: 60%;
right: 10%;
animation-delay: 2s;
}
.bg-circle-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
@keyframes float {
......@@ -310,17 +407,39 @@ onMounted(() => {
}
}
// 响应式设计
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-form-container {
width: 90%;
padding: 24px;
}
.login-header {
.login-title {
font-size: 20px;
}
.login-title {
font-size: 20px;
}
}
</style>
</style>
\ No newline at end of file
......
<template>
<div class="dict-item-management">
<!-- 搜索区域 -->
<div class="search-section">
<div class="search-row">
<div class="search-item">
<label>字典类型:</label>
<select v-model="searchForm.dictTypeId" class="search-select">
<option value="">所有类型</option>
<option v-for="dictType in dictTypeOptions" :key="dictType.dictTypeId" :value="dictType.dictTypeId">
{{ dictType.dictName }}
</option>
</select>
</div>
<div class="search-item">
<label>字典标签:</label>
<input v-model="searchForm.dictLabel" type="text" placeholder="请输入字典标签" class="search-input">
</div>
<div class="search-item">
<label>字典值:</label>
<input v-model="searchForm.dictValue" type="text" placeholder="请输入字典值" class="search-input">
</div>
<div class="search-actions">
<button @click="handleSearch" class="search-btn">🔍 搜索</button>
<button @click="handleReset" class="reset-btn">🔄 重置</button>
</div>
</div>
</div>
<!-- 操作区域 -->
<div class="action-section">
<div class="action-buttons">
<button @click="handleAdd" class="action-btn primary">✨ 新增</button>
<button @click="handleBatchDelete" class="action-btn danger" :disabled="selectedItems.length === 0">🗑️ 删除</button>
</div>
</div>
<!-- 表格区域 -->
<div class="table-section">
<div class="table-header">
<div class="table-controls">
<button @click="handleTableSearch" class="control-btn" title="搜索">🔍</button>
<button @click="handleTableRefresh" class="control-btn" title="刷新">🔄</button>
<button @click="handleTableExport" class="control-btn" title="导出">📋</button>
<button @click="handleTableViewToggle" class="control-btn" title="视图切换">⊞</button>
</div>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th class="checkbox-col">
<input type="checkbox" v-model="selectAll" @change="handleSelectAll">
</th>
<th>字典项ID</th>
<th>字典类型</th>
<th>字典标签</th>
<th>字典值</th>
<th>排序</th>
<th>备注</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in dictItemList" :key="item.dictItemId" :class="{ selected: selectedItems.includes(item.dictItemId) }">
<td class="checkbox-col">
<input type="checkbox" v-model="selectedItems" :value="item.dictItemId" class="table-checkbox">
</td>
<td>{{ item.dictItemId }}</td>
<td>{{ item.dictType }}</td>
<td>{{ item.dictLabel }}</td>
<td>{{ item.dictValue }}</td>
<td>{{ item.sort }}</td>
<td>{{ item.remark || '-' }}</td>
<td>{{ formatTime(item.createTime) }}</td>
<td>
<button @click="handleEdit(item)" class="table-btn edit">✏️ 编辑</button>
<button @click="handleDelete(item)" class="table-btn delete">🗑️ 删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="table-footer">
<div class="pagination-left">
<div class="pagination-info">
共 {{ total }} 条记录,第 {{ currentPage }} / {{ totalPages }} 页
</div>
<div class="page-size-selector">
<label>每页显示:</label>
<select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<div class="pagination-container">
<button @click="goToPage(currentPage - 1)" :disabled="currentPage <= 1" class="pagination-btn">上一页</button>
<div class="pagination-pages">
<button
v-for="page in getPageNumbers()"
:key="page"
@click="goToPage(page)"
:class="['page-number', { active: page === currentPage }]"
>
{{ page }}
</button>
</div>
<button @click="goToPage(currentPage + 1)" :disabled="currentPage >= totalPages" class="pagination-btn">下一页</button>
</div>
</div>
</div>
<!-- 字典项对话框 -->
<div v-if="showDictItemDialog" class="dialog-overlay" @click="closeDictItemDialog">
<div class="dialog-content" @click.stop>
<div class="dialog-header">
<h3>{{ isEdit ? '编辑字典项' : '新增字典项' }}</h3>
<button @click="closeDictItemDialog" class="dialog-close">×</button>
</div>
<form @submit.prevent="handleSubmit" class="dialog-form">
<div class="form-row">
<div class="form-group">
<label class="required-label">字典类型 <span class="required-asterisk">*</span></label>
<select v-model="dictItemForm.dictTypeId" class="form-select" required>
<option value="">请选择字典类型</option>
<option v-for="dictType in dictTypeOptions" :key="dictType.dictTypeId" :value="dictType.dictTypeId">
{{ dictType.dictName }}
</option>
</select>
<div v-if="fieldErrors.dictTypeId" class="field-error">{{ fieldErrors.dictTypeId }}</div>
</div>
<div class="form-group">
<label class="required-label">字典标签 <span class="required-asterisk">*</span></label>
<input
v-model="dictItemForm.dictLabel"
type="text"
placeholder="请输入字典标签"
class="form-input"
required
>
<div v-if="fieldErrors.dictLabel" class="field-error">{{ fieldErrors.dictLabel }}</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="required-label">字典值 <span class="required-asterisk">*</span></label>
<input
v-model="dictItemForm.dictValue"
type="text"
placeholder="请输入字典值"
class="form-input"
required
>
<div v-if="fieldErrors.dictValue" class="field-error">{{ fieldErrors.dictValue }}</div>
</div>
<div class="form-group">
<label>排序</label>
<input
v-model.number="dictItemForm.sort"
type="number"
placeholder="请输入排序号"
class="form-input"
>
<div v-if="fieldErrors.sort" class="field-error">{{ fieldErrors.sort }}</div>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label>备注</label>
<input
v-model="dictItemForm.remark"
type="text"
placeholder="请输入备注"
class="form-input"
>
<div v-if="fieldErrors.remark" class="field-error">{{ fieldErrors.remark }}</div>
</div>
</div>
<div class="dialog-actions">
<button type="button" @click="closeDictItemDialog" class="btn-cancel">取消</button>
<button type="submit" class="btn-confirm" :disabled="formLoading">
{{ formLoading ? '保存中...' : '确定' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { dictItemApi, type DictItem, type DictItemSearchParams, type DictItemAddReq, type DictItemUpdateReq } from '../../../api/dictItem'
import { dictApi, type DictType } from '../../../api/dict'
// 路由
const route = useRoute()
// 响应式数据
const dictItemList = ref<DictItem[]>([])
const dictTypeOptions = ref<DictType[]>([])
const loading = ref(false)
const formLoading = ref(false)
const showDictItemDialog = ref(false)
const isEdit = ref(false)
// 分页数据
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 获取页码数组
const getPageNumbers = () => {
const pages = []
const maxVisible = 5 // 最多显示5个页码
const start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
const end = Math.min(totalPages.value, start + maxVisible - 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// 搜索表单
const searchForm = reactive<DictItemSearchParams>({
pageNum: 1,
pageSize: 10,
dictTypeId: undefined,
dictLabel: '',
dictValue: ''
})
// 字典项表单
const dictItemForm = reactive<DictItemAddReq & { dictItemId?: number }>({
dictTypeId: 0,
dictLabel: '',
dictValue: '',
sort: 0,
remark: ''
})
// 表单验证错误
const fieldErrors = reactive({
dictTypeId: '',
dictLabel: '',
dictValue: '',
sort: '',
remark: ''
})
// 选择相关
const selectedItems = ref<number[]>([])
const selectAll = ref(false)
// 可见页码
const visiblePages = computed(() => {
const pages = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, start + 4)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
// 获取字典项列表
const fetchDictItems = async () => {
try {
loading.value = true
const params = {
pageNum: currentPage.value,
pageSize: pageSize.value,
dictTypeId: searchForm.dictTypeId,
dictLabel: searchForm.dictLabel,
dictValue: searchForm.dictValue
}
const response = await dictItemApi.getDictItemList(params)
if (response.code === 200) {
dictItemList.value = response.data.records
total.value = response.data.total
} else {
showMessage(response.message || '获取字典项列表失败', 'error')
}
} catch (error: any) {
console.error('获取字典项列表失败:', error)
showMessage('获取字典项列表失败,请重试', 'error')
} finally {
loading.value = false
}
}
// 获取字典类型列表
const fetchDictTypes = async () => {
try {
const response = await dictApi.getDictTypes({
pageNum: 1,
pageSize: 9999,
dictType: '',
dictName: '',
status: 1
})
if (response.code === 200) {
dictTypeOptions.value = response.data.records
}
} catch (error: any) {
console.error('获取字典类型列表失败:', error)
}
}
// 搜索
const handleSearch = () => {
currentPage.value = 1
fetchDictItems()
}
// 重置
const handleReset = () => {
Object.assign(searchForm, {
pageNum: 1,
pageSize: 10,
dictTypeId: undefined,
dictLabel: '',
dictValue: ''
})
currentPage.value = 1
fetchDictItems()
}
// 新增
const handleAdd = () => {
isEdit.value = false
Object.assign(dictItemForm, {
dictTypeId: 0,
dictLabel: '',
dictValue: '',
sort: 0,
remark: ''
})
clearFieldErrors()
showDictItemDialog.value = true
}
// 编辑
const handleEdit = (item: DictItem) => {
isEdit.value = true
Object.assign(dictItemForm, {
dictItemId: item.dictItemId,
dictTypeId: item.dictTypeId,
dictLabel: item.dictLabel,
dictValue: item.dictValue,
sort: item.sort,
remark: item.remark || ''
})
clearFieldErrors()
showDictItemDialog.value = true
}
// 删除
const handleDelete = async (item: DictItem) => {
if (!confirm(`确定要删除字典项"${item.dictLabel}"吗?`)) return
try {
const response = await dictItemApi.deleteDictItem([item.dictItemId])
if (response.code === 200) {
showMessage('删除成功')
fetchDictItems()
} else {
showMessage(response.message || '删除失败', 'error')
}
} catch (error: any) {
console.error('删除字典项失败:', error)
showMessage('删除失败,请重试', 'error')
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedItems.value.length === 0) {
showMessage('请选择要删除的字典项', 'warning')
return
}
if (!confirm(`确定要删除选中的${selectedItems.value.length}个字典项吗?`)) return
try {
const response = await dictItemApi.deleteDictItem(selectedItems.value)
if (response.code === 200) {
showMessage('批量删除成功')
selectedItems.value = []
selectAll.value = false
fetchDictItems()
} else {
showMessage(response.message || '批量删除失败', 'error')
}
} catch (error: any) {
console.error('批量删除字典项失败:', error)
showMessage('批量删除失败,请重试', 'error')
}
}
// 表单提交
const handleSubmit = async () => {
clearFieldErrors()
let hasError = false
if (!dictItemForm.dictTypeId) {
fieldErrors.dictTypeId = '请选择字典类型'
hasError = true
}
if (!dictItemForm.dictLabel.trim()) {
fieldErrors.dictLabel = '字典标签不能为空'
hasError = true
} else if (dictItemForm.dictLabel.length > 100) {
fieldErrors.dictLabel = '字典标签长度不能超过100个字符'
hasError = true
}
if (!dictItemForm.dictValue.trim()) {
fieldErrors.dictValue = '字典值不能为空'
hasError = true
} else if (dictItemForm.dictValue.length > 50) {
fieldErrors.dictValue = '字典值长度不能超过50个字符'
hasError = true
}
if (dictItemForm.remark && dictItemForm.remark.length > 500) {
fieldErrors.remark = '备注长度不能超过500个字符'
hasError = true
}
if (hasError) return
try {
formLoading.value = true
if (isEdit.value) {
const updateData: DictItemUpdateReq = {
dictItemId: dictItemForm.dictItemId!,
dictTypeId: dictItemForm.dictTypeId,
dictLabel: dictItemForm.dictLabel,
dictValue: dictItemForm.dictValue,
sort: dictItemForm.sort || 0,
remark: dictItemForm.remark || ''
}
const response = await dictItemApi.updateDictItem(updateData)
if (response.code === 200) {
showMessage('修改字典项成功')
closeDictItemDialog()
fetchDictItems()
} else {
showMessage(response.message || '修改字典项失败', 'error')
}
} else {
const addData: DictItemAddReq = {
dictTypeId: dictItemForm.dictTypeId,
dictLabel: dictItemForm.dictLabel,
dictValue: dictItemForm.dictValue,
sort: dictItemForm.sort || 0,
remark: dictItemForm.remark || ''
}
const response = await dictItemApi.createDictItem(addData)
if (response.code === 200) {
showMessage('新增字典项成功')
closeDictItemDialog()
fetchDictItems()
} else {
showMessage(response.message || '新增字典项失败', 'error')
}
}
} catch (error: any) {
console.error('保存字典项失败:', error)
showMessage('保存字典项失败,请重试', 'error')
} finally {
formLoading.value = false
}
}
// 关闭对话框
const closeDictItemDialog = () => {
showDictItemDialog.value = false
clearFieldErrors()
}
// 清除字段错误
const clearFieldErrors = () => {
Object.assign(fieldErrors, {
dictTypeId: '',
dictLabel: '',
dictValue: '',
sort: '',
remark: ''
})
}
// 全选/取消全选
const handleSelectAll = () => {
if (selectAll.value) {
selectedItems.value = dictItemList.value.map(item => item.dictItemId)
} else {
selectedItems.value = []
}
}
// 分页相关
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
fetchDictItems()
}
}
const handlePageSizeChange = () => {
currentPage.value = 1
fetchDictItems()
}
// 表格工具栏功能
const handleTableSearch = () => {
// 滚动到搜索区域
document.querySelector('.search-section')?.scrollIntoView({ behavior: 'smooth' })
}
const handleTableRefresh = () => {
fetchDictItems()
}
const handleTableExport = () => {
showMessage('导出功能开发中...', 'info')
}
const handleTableViewToggle = () => {
showMessage('视图切换功能开发中...', 'info')
}
// 工具函数
const formatTime = (time: string) => {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 19)
}
const showMessage = (message: string, type: 'success' | 'error' | 'warning' | 'info' = 'success') => {
// 这里可以集成消息提示组件
alert(`${type.toUpperCase()}: ${message}`)
}
// 监听选择变化
const watchSelectedItems = () => {
selectAll.value = selectedItems.value.length === dictItemList.value.length && dictItemList.value.length > 0
}
// 监听selectedItems变化
import { watch } from 'vue'
watch(selectedItems, watchSelectedItems)
// 初始化
onMounted(() => {
fetchDictTypes()
// 检查URL参数,如果有dictTypeId则自动筛选
const dictTypeId = route.query.dictTypeId
if (dictTypeId) {
searchForm.dictTypeId = parseInt(dictTypeId as string)
}
fetchDictItems()
})
</script>
<style scoped lang="scss">
.dict-item-management {
background: #f5f5f5;
min-height: 100vh;
padding: 0;
}
/* 搜索区域样式 */
.search-section {
background: white;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.search-row {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
}
.search-item label {
font-size: 12px;
color: #333;
white-space: nowrap;
}
.search-input, .search-select {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
width: 120px;
}
.search-actions {
display: flex;
gap: 8px;
align-items: center;
}
.search-btn, .reset-btn {
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
border: none;
}
.search-btn {
background: #1890ff;
color: white;
}
.reset-btn {
background: #ff7875;
color: white;
}
/* 操作区域样式 */
.action-section {
background: white;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
height: 24px;
}
.action-btn.primary {
background: #1890ff;
color: white;
}
.action-btn.success {
background: #52c41a;
color: white;
}
.action-btn.danger {
background: #ff4d4f;
color: white;
}
.action-btn:disabled {
background: #d9d9d9;
color: #999;
cursor: not-allowed;
}
/* 表格区域样式 */
.table-section {
background: white;
margin: 16px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.table-header {
padding: 8px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
}
.table-controls {
display: flex;
gap: 4px;
}
.control-btn {
background: white;
border: 1px solid #d9d9d9;
border-radius: 3px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.control-btn:hover {
background: #f5f5f5;
border-color: #1890ff;
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
}
.data-table th {
background: #fafafa;
font-weight: 600;
color: #333;
}
.data-table tbody tr:hover {
background: #f5f5f5;
}
.data-table tr.selected {
background: #e6f7ff;
}
.checkbox-col {
width: 40px;
text-align: center;
}
.table-checkbox {
margin: 0;
cursor: pointer;
}
.table-btn {
padding: 2px 6px;
margin-right: 4px;
border: none;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
}
.table-btn.edit {
background: #1890ff;
color: white;
}
.table-btn.delete {
background: #ff4d4f;
color: white;
}
/* 分页样式 */
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #fafafa;
border-top: 1px solid #e0e0e0;
}
.pagination-left {
display: flex;
align-items: center;
gap: 16px;
}
.pagination-info {
font-size: 12px;
color: #666;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 4px;
}
.page-size-selector label {
font-size: 12px;
color: #666;
}
.page-size-select {
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
background: white;
cursor: pointer;
}
.page-size-select:hover,
.page-size-select:focus {
border-color: #1890ff;
outline: none;
}
.pagination-container {
display: flex;
align-items: center;
gap: 4px;
}
.pagination-btn {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
height: 20px;
}
.pagination-btn:hover:not(:disabled) {
background: #e9ecef;
color: #495057;
}
.pagination-btn:disabled {
background: #f8f9fa;
color: #adb5bd;
cursor: not-allowed;
}
.pagination-pages {
display: flex;
align-items: center;
}
.page-number {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
min-width: 28px;
height: 20px;
}
.page-number:hover {
background: #e9ecef;
color: #495057;
}
.page-number.active {
background: #6c757d;
color: white;
font-weight: 600;
}
.page-number.active:hover {
background: #5a6268;
}
/* 对话框样式 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.dialog-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
}
.dialog-form {
padding: 20px;
}
.form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group.full-width {
flex: 1 1 100%;
}
.form-group label {
font-size: 12px;
color: #333;
font-weight: 500;
}
.required-label {
color: #333;
font-weight: 500;
}
.required-asterisk {
color: #ff4d4f;
}
.form-input, .form-select {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
transition: border-color 0.2s;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.field-error {
color: #ff4d4f;
font-size: 11px;
margin-top: 4px;
display: block;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.btn-cancel, .btn-confirm {
padding: 6px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
border: 1px solid #d9d9d9;
}
.btn-confirm {
background: #1890ff;
color: white;
border: none;
}
.btn-confirm:disabled {
background: #d9d9d9;
color: #999;
cursor: not-allowed;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-row {
flex-direction: column;
gap: 12px;
}
.search-item {
min-width: auto;
width: 100%;
}
.search-input, .search-select {
width: 100%;
}
.form-row {
flex-direction: column;
gap: 8px;
}
.table-footer {
flex-direction: column;
gap: 12px;
}
.pagination-left {
flex-direction: column;
gap: 8px;
}
}
</style>
<template>
<div class="dict-management">
<!-- 搜索表单 -->
<el-card class="search-form">
<el-form :model="searchForm" inline>
<el-form-item label="字典名称">
<el-input v-model="searchForm.dictName" placeholder="请输入字典名称" clearable />
</el-form-item>
<el-form-item label="字典类型">
<el-input v-model="searchForm.dictType" placeholder="请输入字典类型" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="operation-buttons">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增字典
</el-button>
<el-button type="danger" :disabled="!multipleSelection.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除
</el-button>
<el-button type="success" @click="handleRefreshCache">
<el-icon><Refresh /></el-icon>
刷新缓存
</el-button>
</el-card>
<!-- 搜索筛选区域 -->
<div class="search-section">
<div class="search-row">
<div class="search-item">
<label>字典类型:</label>
<input
v-model="searchParams.dictType"
type="text"
class="search-input"
placeholder="请输入字典类型"
/>
</div>
<div class="search-item">
<label>字典名称:</label>
<input
v-model="searchParams.dictName"
type="text"
class="search-input"
placeholder="请输入字典名称"
/>
</div>
<div class="search-item">
<label>状态:</label>
<select v-model="searchParams.status" class="search-select">
<option value="">所有</option>
<option value="1">正常</option>
<option value="0">停用</option>
</select>
</div>
<div class="search-actions">
<button @click="handleSearch" class="search-btn">🔍 搜索</button>
<button @click="handleReset" class="reset-btn">🔄 重置</button>
</div>
</div>
</div>
<!-- 操作区域 -->
<div class="action-section">
<div class="action-buttons">
<button @click="handleAdd" class="action-btn primary">✨ 新增</button>
<button @click="handleBatchDelete" class="action-btn danger" :disabled="selectedDictTypes.length === 0">
🗑️ 删除
</button>
<button @click="handleRefreshCache" class="action-btn secondary">🔄 刷新缓存</button>
</div>
</div>
<!-- 数据表格 -->
<el-card class="table-container">
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="dictId" label="字典ID" width="80" />
<el-table-column prop="dictName" label="字典名称" width="200" />
<el-table-column prop="dictType" label="字典类型" width="200" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="success" size="small" @click="handleManageItems(row)">
字典项
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
<div class="table-section">
<div class="table-header">
<div class="table-controls">
<button @click="handleTableSearch" class="control-btn" title="搜索">
🔍
</button>
<button @click="handleTableRefresh" class="control-btn" title="刷新">
🔄
</button>
<button @click="handleTableExport" class="control-btn" title="导出">
📤
</button>
<button @click="handleTableViewToggle" class="control-btn" title="视图切换">
📋
</button>
</div>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>
<input
type="checkbox"
v-model="selectAll"
@change="handleSelectAll"
class="table-checkbox"
>
</th>
<th>字典类型</th>
<th>字典名称</th>
<th>状态</th>
<th>备注</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="dictType in dictTypeList" :key="dictType.dictTypeId" :class="{ selected: selectedDictTypes.includes(dictType.dictTypeId) }">
<td>
<input
type="checkbox"
:value="dictType.dictTypeId"
v-model="selectedDictTypes"
class="table-checkbox"
>
</td>
<td>
<span class="dict-type-tag">{{ dictType.dictType }}</span>
</td>
<td>{{ dictType.dictName }}</td>
<td>
<span class="status-tag" :class="dictType.status === 1 ? 'active' : 'inactive'">
{{ dictType.statusText }}
</span>
</td>
<td>{{ dictType.remark || '-' }}</td>
<td>{{ formatTime(dictType.createTime) }}</td>
<td>
<div class="action-buttons">
<button @click="handleEdit(dictType)" class="table-btn edit">✏️ 编辑</button>
<button @click="handleDelete(dictType)" class="table-btn delete">🗑️ 删除</button>
<button @click="handleViewItems(dictType)" class="table-btn view">📋 字典项</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页信息 -->
<div class="table-footer">
<div class="pagination-left">
<div class="pagination-info">
共 {{ total }} 条记录,第 {{ currentPage }} / {{ totalPages }} 页
</div>
<div class="page-size-selector">
<label>每页显示:</label>
<select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<option value="10">10条</option>
<option value="20">20条</option>
<option value="50">50条</option>
<option value="100">100条</option>
</select>
</div>
</div>
<div class="pagination-container">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="pagination-btn prev-btn"
>
上一页
</button>
<div class="pagination-pages">
<button
v-for="page in getPageNumbers()"
:key="page"
@click="handlePageChange(page)"
:class="['page-number', { active: page === currentPage }]"
>
{{ page }}
</button>
</div>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="pagination-btn next-btn"
>
下一页
</button>
</div>
</div>
</div>
<!-- 新增/编辑对话框 -->
<div v-if="showDictTypeDialog" class="dialog-overlay" @click="closeDictTypeDialog">
<div class="dialog-content" @click.stop>
<div class="dialog-header">
<h3>{{ dialogTitle }}</h3>
<button @click="closeDictTypeDialog" class="close-btn">×</button>
</div>
<form @submit.prevent="handleSubmit" class="dialog-form">
<div class="form-row">
<div class="form-group">
<label class="required">字典类型</label>
<input
v-model="dictTypeForm.dictType"
type="text"
placeholder="请输入字典类型(如:user_status)"
class="form-input"
required
>
<span v-if="fieldErrors.dictType" class="error-message">{{ fieldErrors.dictType }}</span>
</div>
<div class="form-group">
<label class="required">字典名称</label>
<input
v-model="dictTypeForm.dictName"
type="text"
placeholder="请输入字典名称"
class="form-input"
required
>
<span v-if="fieldErrors.dictName" class="error-message">{{ fieldErrors.dictName }}</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>状态</label>
<select v-model="dictTypeForm.status" class="form-select">
<option value="1">正常</option>
<option value="0">停用</option>
</select>
</div>
<div class="form-group">
<label>备注</label>
<input
v-model="dictTypeForm.remark"
type="text"
placeholder="请输入备注"
class="form-input"
>
</div>
</div>
<div class="form-actions">
<button type="button" @click="closeDictTypeDialog" class="cancel-btn">取消</button>
<button type="submit" :disabled="formLoading" class="submit-btn">
{{ formLoading ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { DictType } from '@/types'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { dictApi, type DictType, type DictTypeSearchParams } from '../../../api/dict'
// 搜索表单
const searchForm = reactive({
dictName: '',
const router = useRouter()
// 响应式数据
const dictTypeList = ref<DictType[]>([])
const selectedDictTypes = ref<number[]>([])
const selectAll = ref(false)
const loading = ref(false)
const formLoading = ref(false)
// 搜索参数
const searchParams = reactive<DictTypeSearchParams>({
dictType: '',
status: undefined
dictName: '',
status: undefined,
pageNum: 1,
pageSize: 10
})
// 表格数据
const tableData = ref<DictType[]>([])
const loading = ref(false)
const multipleSelection = ref<DictType[]>([])
// 分页数据
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 分页
const pagination = reactive({
current: 1,
size: 10,
total: 0
// 获取页码数组
const getPageNumbers = () => {
const pages = []
const maxVisible = 5 // 最多显示5个页码
const start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
const end = Math.min(totalPages.value, start + maxVisible - 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// 对话框相关
const showDictTypeDialog = ref(false)
const dialogTitle = ref('')
// 字典类型表单
const dictTypeForm = reactive({
dictTypeId: undefined as number | undefined,
dictType: '',
dictName: '',
status: 1,
remark: ''
})
const fieldErrors = reactive({
dictType: '',
dictName: ''
})
// 获取字典列表
const getDictList = async () => {
loading.value = true
// 消息提示
const showMessage = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
console.log(`${type.toUpperCase()}: ${message}`)
alert(message)
}
// 格式化时间
const formatTime = (time: string) => {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 19)
}
// 清除字段错误
const clearFieldErrors = () => {
Object.keys(fieldErrors).forEach(key => {
fieldErrors[key as keyof typeof fieldErrors] = ''
})
}
// 获取字典类型列表
const fetchDictTypes = async () => {
try {
// 模拟数据
tableData.value = [
{
dictId: 1,
dictName: '用户性别',
dictType: 'sys_user_sex',
status: 1,
remark: '用户性别列表',
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00'
},
{
dictId: 2,
dictName: '菜单状态',
dictType: 'sys_show_hide',
status: 1,
remark: '菜单状态列表',
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00'
},
{
dictId: 3,
dictName: '系统开关',
dictType: 'sys_normal_disable',
status: 1,
remark: '系统开关列表',
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00'
}
]
pagination.total = 3
loading.value = true
const params = {
...searchParams,
pageNum: currentPage.value,
pageSize: pageSize.value
}
const response = await dictApi.getDictTypes(params)
if (response.code === 200 && response.data) {
dictTypeList.value = response.data.records || []
total.value = response.data.total || 0
} else {
showMessage(response.message || '获取字典类型列表失败', 'error')
}
} catch (error) {
ElMessage.error('获取字典列表失败')
console.error('获取字典类型列表失败:', error)
showMessage('获取字典类型列表失败,请重试', 'error')
} finally {
loading.value = false
}
}
// 搜索
// 搜索功能
const handleSearch = () => {
pagination.current = 1
getDictList()
currentPage.value = 1
fetchDictTypes()
}
// 重置
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, {
dictName: '',
Object.assign(searchParams, {
dictType: '',
status: undefined
dictName: '',
status: undefined,
pageNum: 1,
pageSize: 10
})
handleSearch()
currentPage.value = 1
fetchDictTypes()
}
// 新增字典
const handleAdd = () => {
ElMessage.info('新增字典功能待实现')
// 分页处理
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
fetchDictTypes()
}
}
const handlePageSizeChange = () => {
currentPage.value = 1
fetchDictTypes()
}
// 编辑字典
const handleEdit = (row: DictType) => {
ElMessage.info(`编辑字典: ${row.dictName}`)
// 全选处理
const handleSelectAll = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.checked) {
selectedDictTypes.value = dictTypeList.value.map(dictType => dictType.dictTypeId)
} else {
selectedDictTypes.value = []
}
}
// 管理字典项
const handleManageItems = (row: DictType) => {
ElMessage.info(`管理字典项: ${row.dictName}`)
// 新增字典类型
const handleAdd = () => {
dialogTitle.value = '新增字典类型'
clearFieldErrors()
Object.assign(dictTypeForm, {
dictTypeId: undefined,
dictType: '',
dictName: '',
status: 1,
remark: ''
})
showDictTypeDialog.value = true
}
// 删除字典
const handleDelete = async (row: DictType) => {
// 编辑字典类型
const handleEdit = async (dictType: DictType) => {
dialogTitle.value = '编辑字典类型'
clearFieldErrors()
try {
await ElMessageBox.confirm(`确定要删除字典 "${row.dictName}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getDictList()
const response = await dictApi.getDictTypeById(dictType.dictTypeId)
if (response.code === 200 && response.data) {
const dictTypeDetail = response.data
Object.assign(dictTypeForm, {
dictTypeId: dictTypeDetail.dictTypeId,
dictType: dictTypeDetail.dictType,
dictName: dictTypeDetail.dictName,
status: dictTypeDetail.status,
remark: dictTypeDetail.remark || ''
})
showDictTypeDialog.value = true
} else {
showMessage(response.message || '获取字典类型详情失败', 'error')
}
} catch (error) {
// 用户取消删除
console.error('获取字典类型详情失败:', error)
showMessage('获取字典类型详情失败,请重试', 'error')
}
}
// 批量删除
// 删除字典类型
const handleDelete = async (dictType: DictType) => {
if (confirm(`确定要删除字典类型 "${dictType.dictName}" 吗?`)) {
try {
const response = await dictApi.deleteDictTypes([dictType.dictTypeId])
if (response.code === 200) {
showMessage('删除成功')
fetchDictTypes()
} else {
showMessage(response.message || '删除失败', 'error')
}
} catch (error) {
console.error('删除字典类型失败:', error)
showMessage('删除字典类型失败,请重试', 'error')
}
}
}
// 批量删除字典类型
const handleBatchDelete = async () => {
if (selectedDictTypes.value.length === 0) {
showMessage('请选择要删除的字典类型', 'warning')
return
}
if (confirm(`确定要删除选中的 ${selectedDictTypes.value.length} 个字典类型吗?`)) {
try {
const response = await dictApi.deleteDictTypes(selectedDictTypes.value)
if (response.code === 200) {
showMessage('批量删除成功')
selectedDictTypes.value = []
fetchDictTypes()
} else {
showMessage(response.message || '批量删除失败', 'error')
}
} catch (error) {
console.error('批量删除字典类型失败:', error)
showMessage('批量删除字典类型失败,请重试', 'error')
}
}
}
// 查看字典项
const handleViewItems = async (dictType: DictType) => {
// 跳转到字典项管理页面,并传递字典类型ID作为筛选条件
router.push({
path: '/main/sys/dict-item',
query: {
dictTypeId: dictType.dictTypeId.toString(),
dictType: dictType.dictType
}
})
}
// 字典类型表单提交
const handleSubmit = async () => {
clearFieldErrors()
let hasError = false
if (!dictTypeForm.dictType.trim()) {
fieldErrors.dictType = '字典类型不能为空'
hasError = true
} else {
// 验证字典类型格式:只能包含小写字母、数字和下划线,且必须以字母开头
const dictTypePattern = /^[a-z][a-z0-9_]*$/
if (!dictTypePattern.test(dictTypeForm.dictType)) {
fieldErrors.dictType = '字典类型只能包含小写字母、数字和下划线,且必须以字母开头'
hasError = true
} else if (dictTypeForm.dictType.length > 50) {
fieldErrors.dictType = '字典类型长度不能超过50个字符'
hasError = true
}
}
if (!dictTypeForm.dictName.trim()) {
fieldErrors.dictName = '字典名称不能为空'
hasError = true
} else if (dictTypeForm.dictName.length > 100) {
fieldErrors.dictName = '字典名称长度不能超过100个字符'
hasError = true
}
if (hasError) return
try {
await ElMessageBox.confirm(`确定要删除选中的 ${multipleSelection.value.length} 个字典吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('批量删除成功')
getDictList()
formLoading.value = true
let response
if (dictTypeForm.dictTypeId) {
response = await dictApi.updateDictType(dictTypeForm)
} else {
response = await dictApi.createDictType(dictTypeForm)
}
if (response.code === 200) {
showMessage(dictTypeForm.dictTypeId ? '修改成功' : '新增成功')
closeDictTypeDialog()
fetchDictTypes()
} else {
showMessage(response.message || '操作失败', 'error')
}
} catch (error) {
// 用户取消删除
console.error('保存字典类型失败:', error)
showMessage('保存字典类型失败,请重试', 'error')
} finally {
formLoading.value = false
}
}
// 刷新缓存
const handleRefreshCache = async () => {
try {
await ElMessageBox.confirm('确定要刷新字典缓存吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('缓存刷新成功')
const response = await dictApi.refreshDictCache()
if (response.code === 200) {
showMessage('缓存刷新成功')
} else {
showMessage(response.message || '缓存刷新失败', 'error')
}
} catch (error) {
// 用户取消操作
console.error('刷新缓存失败:', error)
showMessage('刷新缓存失败,请重试', 'error')
}
}
// 选择变化
const handleSelectionChange = (selection: DictType[]) => {
multipleSelection.value = selection
// 关闭对话框
const closeDictTypeDialog = () => {
showDictTypeDialog.value = false
clearFieldErrors()
}
// 表格工具栏功能
const handleTableSearch = () => {
document.querySelector('.search-section')?.scrollIntoView({ behavior: 'smooth' })
}
const handleTableRefresh = () => {
fetchDictTypes()
}
// 分页大小变化
const handleSizeChange = (size: number) => {
pagination.size = size
getDictList()
const handleTableExport = () => {
showMessage('导出功能开发中...', 'warning')
}
// 当前页变化
const handleCurrentChange = (current: number) => {
pagination.current = current
getDictList()
const handleTableViewToggle = () => {
showMessage('视图切换功能开发中...', 'warning')
}
// 组件挂载时获取数据
// 初始化
onMounted(() => {
getDictList()
fetchDictTypes()
})
</script>
<style lang="scss" scoped>
<style scoped lang="scss">
.dict-management {
background: #f5f5f5;
min-height: 100vh;
}
// 搜索区域
.search-section {
background: white;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.search-row {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
}
.search-item label {
font-size: 12px;
color: #333;
white-space: nowrap;
}
.search-input, .search-select {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
width: 120px;
}
.search-input:focus, .search-select:focus {
outline: none;
border-color: #1890ff;
}
.search-actions {
display: flex;
gap: 8px;
}
.search-btn {
background: #1890ff;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.search-btn:hover {
background: #40a9ff;
}
.reset-btn {
background: #ff7875;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.reset-btn:hover {
background: #ff9c99;
}
// 操作区域
.action-section {
background: white;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn.primary {
background: #1890ff;
color: white;
}
.action-btn.primary:hover {
background: #40a9ff;
}
.action-btn.danger {
background: #ff4d4f;
color: white;
}
.action-btn.danger:hover {
background: #ff7875;
}
.action-btn.secondary {
background: #52c41a;
color: white;
}
.action-btn.secondary:hover {
background: #73d13d;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
// 表格区域
.table-section {
background: white;
margin: 16px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.table-header {
padding: 8px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
}
.table-controls {
display: flex;
gap: 4px;
}
.control-btn {
background: white;
border: 1px solid #d9d9d9;
border-radius: 3px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.control-btn:hover {
background: #f0f8ff;
border-color: #1890ff;
color: #1890ff;
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
}
.data-table th {
background: #fafafa;
font-weight: 600;
color: #333;
}
.data-table tbody tr:hover {
background: #f5f5f5;
}
.data-table tr.selected {
background: #e6f7ff;
}
.table-checkbox {
margin: 0;
cursor: pointer;
}
.dict-type-tag {
background: #e6f7ff;
color: #1890ff;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-family: monospace;
}
.status-tag {
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
}
.status-tag.active {
background: #f6ffed;
color: #52c41a;
}
.status-tag.inactive {
background: #fff2e8;
color: #fa8c16;
}
.table-btn {
padding: 2px 6px;
margin-right: 4px;
border: none;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
}
.table-btn.edit {
background: #1890ff;
color: white;
}
.table-btn.delete {
background: #ff4d4f;
color: white;
}
.table-btn.view {
background: #52c41a;
color: white;
}
// 分页
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #fafafa;
border-top: 1px solid #e0e0e0;
}
.pagination-left {
display: flex;
align-items: center;
gap: 20px;
}
.pagination-info {
font-size: 12px;
color: #666;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
}
.page-size-selector label {
font-size: 12px;
color: #666;
}
.page-size-select {
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
background: white;
cursor: pointer;
transition: border-color 0.3s;
}
.page-size-select:hover {
border-color: #1890ff;
}
.page-size-select:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
/* 新的分页容器样式 - 模拟截图样式 */
.pagination-container {
display: flex;
align-items: center;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.pagination-btn {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
height: 20px;
}
.pagination-btn:hover:not(:disabled) {
background: #e9ecef;
color: #495057;
}
.pagination-btn:disabled {
background: #f8f9fa;
color: #adb5bd;
cursor: not-allowed;
}
.pagination-pages {
display: flex;
align-items: center;
}
.page-number {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
min-width: 28px;
text-align: center;
height: 20px;
}
.page-number:hover {
background: #e9ecef;
color: #495057;
}
.page-number.active {
background: #6c757d;
color: white;
font-weight: 600;
}
.page-number.active:hover {
background: #5a6268;
}
// 对话框
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.dialog-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.close-btn {
width: 24px;
height: 24px;
border: none;
background: none;
font-size: 18px;
cursor: pointer;
color: #999;
}
.close-btn:hover {
color: #333;
}
.dialog-form {
padding: 20px;
}
.form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-size: 12px;
color: #333;
font-weight: 500;
}
.form-group label.required::after {
content: ' *';
color: #ff4d4f;
}
.form-input, .form-select {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
transition: border-color 0.2s;
}
.form-input:focus, .form-select:focus {
border-color: #1890ff;
outline: none;
}
.error-message {
font-size: 10px;
color: #ff4d4f;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.cancel-btn, .submit-btn {
padding: 8px 16px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: white;
color: #333;
}
.cancel-btn:hover {
background: #f5f5f5;
}
.submit-btn {
background: #1890ff;
border-color: #1890ff;
color: white;
}
.submit-btn:hover:not(:disabled) {
background: #40a9ff;
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
// 响应式设计
@media (max-width: 768px) {
.dict-management {
padding: 10px;
}
.search-form {
margin-bottom: 16px;
flex-direction: column;
align-items: stretch;
}
.operation-buttons {
margin-bottom: 16px;
.form-group {
min-width: auto;
}
.table-container {
.el-pagination {
margin-top: 16px;
text-align: right;
}
.form-row {
flex-direction: column;
gap: 8px;
}
.table-footer {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.pagination-left {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.pagination-container {
justify-content: center;
flex-wrap: wrap;
}
.pagination-btn {
padding: 2px 4px;
font-size: 10px;
height: 20px;
}
.page-number {
padding: 2px 4px;
font-size: 10px;
min-width: 24px;
height: 20px;
}
}
</style>
</style>
\ No newline at end of file
......
<template>
<div class="menu-management">
<!-- 搜索表单 -->
<el-card class="search-form">
<el-form :model="searchForm" inline>
<el-form-item label="菜单名称">
<el-input v-model="searchForm.menuName" placeholder="请输入菜单名称" clearable />
</el-form-item>
<el-form-item label="菜单类型">
<el-select v-model="searchForm.menuType" placeholder="请选择菜单类型" clearable>
<el-option label="目录" value="0" />
<el-option label="菜单" value="1" />
<el-option label="按钮" value="2" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="operation-buttons">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增菜单
</el-button>
<el-button type="danger" :disabled="!multipleSelection.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</el-card>
<!-- 搜索筛选区域 -->
<div class="search-section">
<div class="search-row">
<div class="search-item">
<label>菜单名称:</label>
<input
v-model="searchParams.menuName"
type="text"
class="search-input"
placeholder="请输入菜单名称"
/>
</div>
<div class="search-item">
<label>菜单类型:</label>
<select v-model="searchParams.menuType" class="search-select">
<option value="">所有</option>
<option value="0">目录</option>
<option value="1">菜单</option>
<option value="2">按钮</option>
</select>
</div>
<div class="search-item">
<label>菜单状态:</label>
<select v-model="searchParams.status" class="search-select">
<option value="">所有</option>
<option value="1">正常</option>
<option value="0">停用</option>
</select>
</div>
<div class="search-actions">
<button @click="handleSearch" class="search-btn">🔍 搜索</button>
<button @click="handleReset" class="reset-btn">🔄 重置</button>
</div>
</div>
</div>
<!-- 操作区域 -->
<div class="action-section">
<div class="action-buttons">
<button @click="handleAdd" class="action-btn primary">✨ 新增</button>
<button @click="handleBatchDelete" class="action-btn danger" :disabled="selectedMenus.length === 0">
🗑️ 删除
</button>
</div>
</div>
<!-- 数据表格 -->
<el-card class="table-container">
<el-table
v-loading="loading"
:data="tableData"
row-key="menuId"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="menuName" label="菜单名称" width="200" />
<el-table-column prop="icon" label="图标" width="80" align="center">
<template #default="{ row }">
<el-icon v-if="row.icon">
<component :is="row.icon" />
</el-icon>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="path" label="路由地址" width="200" />
<el-table-column prop="component" label="组件路径" width="200" />
<el-table-column prop="perms" label="权限标识" width="200" />
<el-table-column prop="menuType" label="菜单类型" width="100">
<template #default="{ row }">
<el-tag :type="getMenuTypeTagType(row.menuType)">
{{ getMenuTypeText(row.menuType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="table-section">
<div class="table-header">
<div class="table-controls">
<button @click="handleTableSearch" class="control-btn" title="搜索">
🔍
</button>
<button @click="handleTableRefresh" class="control-btn" title="刷新">
🔄
</button>
<button @click="handleTableExport" class="control-btn" title="导出">
📤
</button>
<button @click="handleTableViewToggle" class="control-btn" title="视图切换">
📋
</button>
</div>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>
<input
type="checkbox"
v-model="selectAll"
@change="handleSelectAll"
class="table-checkbox"
>
</th>
<th>菜单名称</th>
<th>图标</th>
<th>排序</th>
<th>权限标识</th>
<th>路由地址</th>
<th>组件路径</th>
<th>菜单类型</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="menu in menuList" :key="menu.menuId" :class="{ selected: selectedMenus.includes(menu.menuId) }">
<td>
<input
type="checkbox"
:value="menu.menuId"
v-model="selectedMenus"
class="table-checkbox"
>
</td>
<td>
<span :style="{ paddingLeft: (menu.level || 0) * 20 + 'px' }">
{{ menu.menuName }}
</span>
</td>
<td>
<span v-if="menu.icon" class="menu-icon">{{ menu.icon }}</span>
</td>
<td>{{ menu.sort }}</td>
<td>
<span v-if="menu.perms" class="permission-tag">{{ menu.perms }}</span>
</td>
<td>{{ menu.path || '-' }}</td>
<td>{{ menu.component || '-' }}</td>
<td>
<span class="menu-type-tag" :class="`type-${menu.menuType}`">
{{ menu.menuTypeText }}
</span>
</td>
<td>
<span class="status-tag" :class="menu.status === 1 ? 'active' : 'inactive'">
{{ menu.statusText }}
</span>
</td>
<td>{{ formatTime(menu.createTime) }}</td>
<td>
<div class="action-buttons">
<button @click="handleEdit(menu)" class="table-btn edit">✏️ 编辑</button>
<button @click="handleDelete(menu)" class="table-btn delete">🗑️ 删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页信息 -->
<div class="table-footer">
<div class="pagination-left">
<div class="pagination-info">
共 {{ total }} 条记录,第 {{ currentPage }} / {{ totalPages }} 页
</div>
<div class="page-size-selector">
<label>每页显示:</label>
<select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<option value="10">10条</option>
<option value="20">20条</option>
<option value="50">50条</option>
<option value="100">100条</option>
</select>
</div>
</div>
<div class="pagination-container">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="pagination-btn prev-btn"
>
上一页
</button>
<div class="pagination-pages">
<button
v-for="page in getPageNumbers()"
:key="page"
@click="handlePageChange(page)"
:class="['page-number', { active: page === currentPage }]"
>
{{ page }}
</button>
</div>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="pagination-btn next-btn"
>
下一页
</button>
</div>
</div>
</div>
<!-- 新增/编辑对话框 -->
<div v-if="showMenuDialog" class="dialog-overlay" @click="closeMenuDialog">
<div class="dialog-content" @click.stop>
<div class="dialog-header">
<h3>{{ dialogTitle }}</h3>
<button @click="closeMenuDialog" class="close-btn">×</button>
</div>
<form @submit.prevent="handleSubmit" class="dialog-form">
<div class="form-row">
<div class="form-group">
<label class="required">菜单名称</label>
<input
v-model="menuForm.menuName"
type="text"
placeholder="请输入菜单名称"
class="form-input"
required
>
<span v-if="fieldErrors.menuName" class="error-message">{{ fieldErrors.menuName }}</span>
</div>
<div class="form-group">
<label>父菜单</label>
<select v-model="menuForm.parentId" class="form-select">
<option value="0">主目录</option>
<option v-for="menu in parentMenus" :key="menu.menuId" :value="menu.menuId">
{{ menu.menuName }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>菜单类型</label>
<select v-model="menuForm.menuType" class="form-select">
<option value="0">目录</option>
<option value="1">菜单</option>
<option value="2">按钮</option>
</select>
</div>
<div class="form-group">
<label>显示排序</label>
<input
v-model="menuForm.sort"
type="number"
placeholder="请输入显示排序"
class="form-input"
>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>菜单图标</label>
<input
v-model="menuForm.icon"
type="text"
placeholder="请输入菜单图标"
class="form-input"
>
</div>
<div class="form-group">
<label>路由地址</label>
<input
v-model="menuForm.path"
type="text"
placeholder="请输入路由地址"
class="form-input"
>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>组件路径</label>
<input
v-model="menuForm.component"
type="text"
placeholder="请输入组件路径"
class="form-input"
>
</div>
<div class="form-group">
<label>权限标识</label>
<input
v-model="menuForm.perms"
type="text"
placeholder="请输入权限标识"
class="form-input"
>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>菜单状态</label>
<select v-model="menuForm.status" class="form-select">
<option value="1">正常</option>
<option value="0">停用</option>
</select>
</div>
<div class="form-group">
<label>是否外链</label>
<select v-model="menuForm.isFrame" class="form-select">
<option value="0">否</option>
<option value="1">是</option>
</select>
</div>
</div>
<div class="form-group full-width">
<label>备注</label>
<textarea
v-model="menuForm.remark"
placeholder="请输入备注"
class="form-textarea"
></textarea>
</div>
<div class="form-actions">
<button type="button" @click="closeMenuDialog" class="cancel-btn">取消</button>
<button type="submit" :disabled="formLoading" class="submit-btn">
{{ formLoading ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Menu } from '@/types'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { menuApi, type Menu, type MenuSearchParams } from '../../../api/menu'
// 搜索表单
const searchForm = reactive({
const router = useRouter()
// 响应式数据
const menuList = ref<Menu[]>([])
const selectedMenus = ref<number[]>([])
const selectAll = ref(false)
const loading = ref(false)
const formLoading = ref(false)
// 搜索参数
const searchParams = reactive<MenuSearchParams>({
menuName: '',
menuType: '',
status: undefined
status: undefined,
pageNum: 1,
pageSize: 10
})
// 表格数据
const tableData = ref<Menu[]>([])
const loading = ref(false)
const multipleSelection = ref<Menu[]>([])
// 分页数据
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 获取页码数组
const getPageNumbers = () => {
const pages = []
const maxVisible = 5 // 最多显示5个页码
const start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
const end = Math.min(totalPages.value, start + maxVisible - 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// 对话框相关
const showMenuDialog = ref(false)
const dialogTitle = ref('')
const menuForm = reactive({
menuId: undefined as number | undefined,
menuName: '',
parentId: 0,
menuType: '0',
sort: 0,
icon: '',
path: '',
component: '',
perms: '',
status: 1,
isFrame: 0,
remark: ''
})
const fieldErrors = reactive({
menuName: ''
})
const parentMenus = ref<Menu[]>([])
// 消息提示
const showMessage = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
console.log(`${type.toUpperCase()}: ${message}`)
// 这里可以集成实际的消息提示组件
alert(message)
}
// 格式化时间
const formatTime = (time: string) => {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 19)
}
// 清除字段错误
const clearFieldErrors = () => {
Object.keys(fieldErrors).forEach(key => {
fieldErrors[key as keyof typeof fieldErrors] = ''
})
}
// 获取菜单列表
const getMenuList = async () => {
loading.value = true
const fetchMenus = async () => {
try {
// 模拟数据
tableData.value = [
{
menuId: 1,
menuName: '系统管理',
parentId: 0,
sort: 1,
path: '/system',
menuType: '0',
perms: '',
icon: 'Setting',
status: 1,
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00',
children: [
{
menuId: 2,
menuName: '用户管理',
parentId: 1,
sort: 1,
path: '/system/user',
component: '/sys/user/index',
menuType: '1',
perms: 'sys:user:list',
icon: 'User',
status: 1,
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00'
},
{
menuId: 3,
menuName: '角色管理',
parentId: 1,
sort: 2,
path: '/system/role',
component: '/sys/role/index',
menuType: '1',
perms: 'sys:role:list',
icon: 'UserFilled',
status: 1,
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00'
}
]
}
]
loading.value = true
const params = {
...searchParams,
pageNum: currentPage.value,
pageSize: pageSize.value
}
const response = await menuApi.getMenuList(params)
if (response.code === 200 && response.data) {
menuList.value = response.data.records || []
total.value = response.data.total || 0
} else {
showMessage(response.message || '获取菜单列表失败', 'error')
}
} catch (error) {
ElMessage.error('获取菜单列表失败')
console.error('获取菜单列表失败:', error)
showMessage('获取菜单列表失败,请重试', 'error')
} finally {
loading.value = false
}
}
// 获取菜单类型标签类型
const getMenuTypeTagType = (menuType: string) => {
switch (menuType) {
case '0':
return 'primary'
case '1':
return 'success'
case '2':
return 'warning'
default:
return 'info'
}
}
// 获取菜单类型文本
const getMenuTypeText = (menuType: string) => {
switch (menuType) {
case '0':
return '目录'
case '1':
return '菜单'
case '2':
return '按钮'
default:
return '未知'
// 获取父菜单列表
const fetchParentMenus = async () => {
try {
const response = await menuApi.getMenuTreeSelect()
if (response.code === 200 && response.data) {
parentMenus.value = response.data
}
} catch (error) {
console.error('获取父菜单列表失败:', error)
}
}
// 搜索
// 搜索功能
const handleSearch = () => {
getMenuList()
currentPage.value = 1
fetchMenus()
}
// 重置
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, {
Object.assign(searchParams, {
menuName: '',
menuType: '',
status: undefined
status: undefined,
pageNum: 1,
pageSize: 10
})
handleSearch()
currentPage.value = 1
fetchMenus()
}
// 分页处理
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
fetchMenus()
}
}
const handlePageSizeChange = () => {
currentPage.value = 1
fetchMenus()
}
// 全选处理
const handleSelectAll = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.checked) {
selectedMenus.value = menuList.value.map(menu => menu.menuId)
} else {
selectedMenus.value = []
}
}
// 新增菜单
const handleAdd = () => {
ElMessage.info('新增菜单功能待实现')
dialogTitle.value = '新增菜单'
clearFieldErrors()
// 重置表单
Object.assign(menuForm, {
menuId: undefined,
menuName: '',
parentId: 0,
menuType: '0',
sort: 0,
icon: '',
path: '',
component: '',
perms: '',
status: 1,
isFrame: 0,
remark: ''
})
showMenuDialog.value = true
}
// 编辑菜单
const handleEdit = (row: Menu) => {
ElMessage.info(`编辑菜单: ${row.menuName}`)
const handleEdit = async (menu: Menu) => {
dialogTitle.value = '编辑菜单'
clearFieldErrors()
try {
const response = await menuApi.getMenuById(menu.menuId)
if (response.code === 200 && response.data) {
const menuDetail = response.data
Object.assign(menuForm, {
menuId: menuDetail.menuId,
menuName: menuDetail.menuName,
parentId: menuDetail.parentId || 0,
menuType: menuDetail.menuType,
sort: menuDetail.sort,
icon: menuDetail.icon || '',
path: menuDetail.path || '',
component: (menuDetail as any).component || '',
perms: menuDetail.perms || '',
status: menuDetail.status,
isFrame: (menuDetail as any).isFrame || 0,
remark: (menuDetail as any).remark || ''
})
showMenuDialog.value = true
} else {
showMessage(response.message || '获取菜单详情失败', 'error')
}
} catch (error) {
console.error('获取菜单详情失败:', error)
showMessage('获取菜单详情失败,请重试', 'error')
}
}
// 删除菜单
const handleDelete = async (row: Menu) => {
try {
await ElMessageBox.confirm(`确定要删除菜单 "${row.menuName}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getMenuList()
} catch (error) {
// 用户取消删除
const handleDelete = async (menu: Menu) => {
if (confirm(`确定要删除菜单 "${menu.menuName}" 吗?`)) {
try {
const response = await menuApi.deleteMenu([menu.menuId])
if (response.code === 200) {
showMessage('删除成功')
fetchMenus()
} else {
showMessage(response.message || '删除失败', 'error')
}
} catch (error) {
console.error('删除菜单失败:', error)
showMessage('删除菜单失败,请重试', 'error')
}
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedMenus.value.length === 0) {
showMessage('请选择要删除的菜单', 'warning')
return
}
if (confirm(`确定要删除选中的 ${selectedMenus.value.length} 个菜单吗?`)) {
try {
const response = await menuApi.deleteMenu(selectedMenus.value)
if (response.code === 200) {
showMessage('批量删除成功')
selectedMenus.value = []
fetchMenus()
} else {
showMessage(response.message || '批量删除失败', 'error')
}
} catch (error) {
console.error('批量删除菜单失败:', error)
showMessage('批量删除菜单失败,请重试', 'error')
}
}
}
// 表单提交
const handleSubmit = async () => {
// 表单验证
clearFieldErrors()
let hasError = false
if (!menuForm.menuName.trim()) {
fieldErrors.menuName = '菜单名称不能为空'
hasError = true
}
if (hasError) return
try {
await ElMessageBox.confirm(`确定要删除选中的 ${multipleSelection.value.length} 个菜单吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('批量删除成功')
getMenuList()
formLoading.value = true
let response
if (menuForm.menuId) {
// 编辑
response = await menuApi.updateMenu(menuForm)
} else {
// 新增
response = await menuApi.createMenu(menuForm)
}
if (response.code === 200) {
showMessage(menuForm.menuId ? '修改成功' : '新增成功')
closeMenuDialog()
fetchMenus()
} else {
showMessage(response.message || '操作失败', 'error')
}
} catch (error) {
// 用户取消删除
console.error('保存菜单失败:', error)
showMessage('保存菜单失败,请重试', 'error')
} finally {
formLoading.value = false
}
}
// 选择变化
const handleSelectionChange = (selection: Menu[]) => {
multipleSelection.value = selection
// 关闭对话框
const closeMenuDialog = () => {
showMenuDialog.value = false
clearFieldErrors()
}
// 表格工具栏功能
const handleTableSearch = () => {
// 滚动到搜索区域
document.querySelector('.search-section')?.scrollIntoView({ behavior: 'smooth' })
}
const handleTableRefresh = () => {
fetchMenus()
}
const handleTableExport = () => {
showMessage('导出功能开发中...', 'warning')
}
const handleTableViewToggle = () => {
showMessage('视图切换功能开发中...', 'warning')
}
// 组件挂载时获取数据
// 初始化
onMounted(() => {
getMenuList()
fetchMenus()
fetchParentMenus()
})
</script>
<style lang="scss" scoped>
<style scoped lang="scss">
.menu-management {
background: #f5f5f5;
min-height: 100vh;
}
// 搜索区域
.search-section {
background: white;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.search-row {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
}
.search-item label {
font-size: 12px;
color: #333;
white-space: nowrap;
}
.search-input, .search-select {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
width: 120px;
}
.search-input:focus, .search-select:focus {
outline: none;
border-color: #1890ff;
}
.search-actions {
display: flex;
gap: 8px;
}
.search-btn {
background: #1890ff;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.search-btn:hover {
background: #40a9ff;
}
.reset-btn {
background: #ff7875;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.reset-btn:hover {
background: #ff9c99;
}
// 操作区域
.action-section {
background: white;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn.primary {
background: #1890ff;
color: white;
}
.action-btn.primary:hover {
background: #40a9ff;
}
.action-btn.danger {
background: #ff4d4f;
color: white;
}
.action-btn.danger:hover {
background: #ff7875;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
// 表格区域
.table-section {
background: white;
margin: 16px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.table-header {
padding: 8px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
}
.table-controls {
display: flex;
gap: 4px;
}
.control-btn {
background: white;
border: 1px solid #d9d9d9;
border-radius: 3px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.control-btn:hover {
background: #f0f8ff;
border-color: #1890ff;
color: #1890ff;
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
}
.data-table th {
background: #fafafa;
font-weight: 600;
color: #333;
}
.data-table tbody tr:hover {
background: #f5f5f5;
}
.data-table tr.selected {
background: #e6f7ff;
}
.table-checkbox {
margin: 0;
cursor: pointer;
}
.menu-icon {
font-size: 16px;
}
.permission-tag {
background: #f0f0f0;
color: #666;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-family: monospace;
}
.menu-type-tag {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
}
.menu-type-tag.type-0 {
background: #e6f7ff;
color: #1890ff;
}
.menu-type-tag.type-1 {
background: #f6ffed;
color: #52c41a;
}
.menu-type-tag.type-2 {
background: #fff2e8;
color: #fa8c16;
}
.status-tag {
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
}
.status-tag.active {
background: #f6ffed;
color: #52c41a;
}
.status-tag.inactive {
background: #fff2e8;
color: #fa8c16;
}
.table-btn {
padding: 2px 6px;
margin-right: 4px;
border: none;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
}
.table-btn.edit {
background: #1890ff;
color: white;
}
.table-btn.delete {
background: #ff4d4f;
color: white;
}
// 分页
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #fafafa;
border-top: 1px solid #e0e0e0;
}
.pagination-left {
display: flex;
align-items: center;
gap: 20px;
}
.pagination-info {
font-size: 12px;
color: #666;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
}
.page-size-selector label {
font-size: 12px;
color: #666;
}
.page-size-select {
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
background: white;
cursor: pointer;
transition: border-color 0.3s;
}
.page-size-select:hover {
border-color: #1890ff;
}
.page-size-select:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
/* 新的分页容器样式 - 模拟截图样式 */
.pagination-container {
display: flex;
align-items: center;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.pagination-btn {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
height: 20px;
}
.pagination-btn:hover:not(:disabled) {
background: #e9ecef;
color: #495057;
}
.pagination-btn:disabled {
background: #f8f9fa;
color: #adb5bd;
cursor: not-allowed;
}
.page-number {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
min-width: 28px;
text-align: center;
height: 20px;
}
.page-number:hover {
background: #e9ecef;
color: #495057;
}
.page-number.active {
background: #6c757d;
color: white;
font-weight: 600;
}
.page-number.active:hover {
background: #5a6268;
}
.pagination-pages {
display: flex;
align-items: center;
}
// 对话框
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.dialog-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.close-btn {
width: 24px;
height: 24px;
border: none;
background: none;
font-size: 18px;
cursor: pointer;
color: #999;
}
.close-btn:hover {
color: #333;
}
.dialog-form {
padding: 20px;
}
.form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group.full-width {
flex: 1 1 100%;
}
.form-group label {
font-size: 12px;
color: #333;
font-weight: 500;
}
.form-group label.required::after {
content: ' *';
color: #ff4d4f;
}
.form-input, .form-select, .form-textarea {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
transition: border-color 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: #1890ff;
outline: none;
}
.form-textarea {
resize: vertical;
min-height: 60px;
}
.error-message {
font-size: 10px;
color: #ff4d4f;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.cancel-btn, .submit-btn {
padding: 8px 16px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: white;
color: #333;
}
.cancel-btn:hover {
background: #f5f5f5;
}
.submit-btn {
background: #1890ff;
border-color: #1890ff;
color: white;
}
.submit-btn:hover:not(:disabled) {
background: #40a9ff;
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
// 响应式设计
@media (max-width: 768px) {
.menu-management {
padding: 10px;
}
.search-form {
margin-bottom: 16px;
flex-direction: column;
align-items: stretch;
}
.operation-buttons {
margin-bottom: 16px;
.form-group {
min-width: auto;
}
.table-container {
.el-pagination {
margin-top: 16px;
text-align: right;
}
.form-row {
flex-direction: column;
gap: 8px;
}
.table-footer {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.pagination-left {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.pagination-container {
justify-content: center;
flex-wrap: wrap;
}
.pagination-btn {
padding: 2px 4px;
font-size: 10px;
height: 20px;
}
.page-number {
padding: 2px 4px;
font-size: 10px;
min-width: 24px;
height: 20px;
}
}
</style>
</style>
\ No newline at end of file
......
<template>
<div class="role-management">
<!-- 搜索表单 -->
<el-card class="search-form">
<el-form :model="searchForm" inline>
<el-form-item label="角色名称">
<el-input v-model="searchForm.roleName" placeholder="请输入角色名称" clearable />
</el-form-item>
<el-form-item label="角色标识">
<el-input v-model="searchForm.roleKey" placeholder="请输入角色标识" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="operation-buttons">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增角色
</el-button>
<el-button type="danger" :disabled="!multipleSelection.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</el-card>
<!-- 搜索筛选区域 -->
<div class="search-section">
<div class="search-row">
<div class="search-item">
<label>角色名称:</label>
<input
v-model="searchForm.roleName"
type="text"
placeholder="请输入角色名称"
class="search-input"
/>
</div>
<div class="search-item">
<label>角色编码:</label>
<input
v-model="searchForm.roleCode"
type="text"
placeholder="请输入角色编码"
class="search-input"
/>
</div>
<div class="search-item">
<label>状态:</label>
<select v-model="searchForm.status" class="search-select">
<option value="">全部</option>
<option value="1">正常</option>
<option value="0">停用</option>
</select>
</div>
<div class="search-actions">
<button @click="handleSearch" class="search-btn">🔍 搜索</button>
<button @click="handleReset" class="reset-btn">🔄 重置</button>
</div>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="action-section">
<div class="action-buttons">
<button @click="handleAdd" class="action-btn primary">✨ 新增</button>
<button @click="handleBatchDelete" class="action-btn danger">🗑️ 删除</button>
</div>
</div>
<!-- 数据表格 -->
<el-card class="table-container">
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="roleId" label="角色ID" width="80" />
<el-table-column prop="roleName" label="角色名称" width="120" />
<el-table-column prop="roleKey" label="角色标识" width="120" />
<el-table-column prop="roleSort" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="success" size="small" @click="handleAssignPermissions(row)">
分配权限
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-section">
<div class="table-header">
<div class="table-controls">
<button @click="handleTableSearch" class="control-btn" title="搜索">🔍</button>
<button @click="handleTableRefresh" class="control-btn" title="刷新">🔄</button>
<button @click="handleTableExport" class="control-btn" title="导出">📋</button>
<button @click="handleTableViewToggle" class="control-btn" title="视图切换">⊞</button>
</div>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th class="checkbox-col">
<input
type="checkbox"
:checked="selectAll"
@change="handleSelectAll"
class="table-checkbox"
/>
</th>
<th>角色ID</th>
<th>角色名称</th>
<th>角色编码</th>
<th>状态</th>
<th>备注</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="role in roles" :key="role.roleId">
<td class="checkbox-col">
<input
type="checkbox"
:checked="selectedRoles.includes(role.roleId)"
@change="handleSelectRole(role.roleId)"
class="table-checkbox"
/>
</td>
<td>{{ role.roleId }}</td>
<td>{{ role.roleName }}</td>
<td>{{ role.roleCode }}</td>
<td>
<span
:class="['status-badge', role.status === 1 ? 'active' : 'inactive']"
@click="handleStatusToggle(role)"
>
{{ role.status === 1 ? '正常' : '停用' }}
</span>
</td>
<td>{{ role.remark || '-' }}</td>
<td>{{ formatTime(role.createTime) }}</td>
<td>
<button @click="handleEdit(role)" class="table-btn edit">✏️ 编辑</button>
<button @click="handleDelete(role)" class="table-btn delete">🗑️ 删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
<div class="table-footer">
<div class="pagination-left">
<div class="pagination-info">
共 {{ total }} 条记录,第 {{ currentPage }} / {{ totalPages }} 页
</div>
<div class="page-size-selector">
<label>每页显示:</label>
<select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<option value="10">10条</option>
<option value="20">20条</option>
<option value="50">50条</option>
<option value="100">100条</option>
</select>
</div>
</div>
<div class="pagination-container">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="pagination-btn prev-btn"
>
上一页
</button>
<div class="pagination-pages">
<button
v-for="page in getPageNumbers()"
:key="page"
@click="handlePageChange(page)"
:class="['page-number', { active: page === currentPage }]"
>
{{ page }}
</button>
</div>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="pagination-btn next-btn"
>
下一页
</button>
</div>
</div>
</div>
<!-- 角色编辑对话框 -->
<div v-if="showRoleDialog" class="dialog-overlay" @click="closeRoleDialog">
<div class="dialog-content" @click.stop>
<div class="dialog-header">
<h3>{{ dialogTitle }}</h3>
<button @click="closeRoleDialog" class="close-btn">×</button>
</div>
<div class="dialog-body">
<form @submit.prevent="handleSubmit" class="role-form">
<div class="form-row">
<div class="form-group">
<label class="required">角色名称:</label>
<input
v-model="roleForm.roleName"
type="text"
placeholder="请输入角色名称"
class="form-input"
required
/>
<div v-if="fieldErrors.roleName" class="error-message">{{ fieldErrors.roleName }}</div>
</div>
<div class="form-group">
<label class="required">角色编码:</label>
<input
v-model="roleForm.roleCode"
type="text"
placeholder="请输入角色编码"
class="form-input"
required
/>
<div v-if="fieldErrors.roleCode" class="error-message">{{ fieldErrors.roleCode }}</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>状态:</label>
<select v-model="roleForm.status" class="form-select">
<option value="1">正常</option>
<option value="0">停用</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label>备注:</label>
<textarea
v-model="roleForm.remark"
placeholder="请输入备注信息"
class="form-textarea"
rows="3"
></textarea>
</div>
</div>
<!-- 菜单权限选择 -->
<div class="form-row">
<div class="form-group full-width">
<label>菜单权限:</label>
<div class="menu-permissions">
<div class="permission-controls">
<button type="button" @click="toggleExpandAll" class="control-btn">
{{ allExpanded ? '折叠' : '展开' }}
</button>
<button type="button" @click="toggleSelectAll" class="control-btn">
{{ allSelected ? '全不选' : '全选' }}
</button>
<label class="linkage-label">
<input type="checkbox" v-model="parentChildLinkage" class="linkage-checkbox">
父子联动
</label>
</div>
<div class="menu-tree">
<div
v-for="menu in menuTree"
:key="menu.id"
class="menu-item"
:style="{ paddingLeft: (menu.level * 20 + 10) + 'px' }"
>
<div class="menu-row">
<span
v-if="menu.children && menu.children.length > 0"
@click="toggleMenu(menu)"
class="expand-icon"
>
{{ menu.expanded ? '−' : '+' }}
</span>
<span v-else class="expand-placeholder"></span>
<input
type="checkbox"
:id="'menu-' + menu.id"
:checked="selectedMenus.includes(menu.id)"
@change="handleMenuSelect(menu)"
class="menu-checkbox"
>
<span class="menu-icon">📁</span>
<span class="menu-name">{{ menu.name }}</span>
<span v-if="menu.permission" class="menu-permission">{{ menu.permission }}</span>
</div>
<!-- 递归渲染子菜单 -->
<div v-if="menu.expanded && menu.children" class="submenu">
<div
v-for="child in menu.children"
:key="child.id"
class="menu-item"
:style="{ paddingLeft: (child.level * 20 + 10) + 'px' }"
>
<div class="menu-row">
<span
v-if="child.children && child.children.length > 0"
@click="toggleMenu(child)"
class="expand-icon"
>
{{ child.expanded ? '−' : '+' }}
</span>
<span v-else class="expand-placeholder"></span>
<input
type="checkbox"
:id="'menu-' + child.id"
:checked="selectedMenus.includes(child.id)"
@change="handleMenuSelect(child)"
class="menu-checkbox"
>
<span class="menu-icon">📁</span>
<span class="menu-name">{{ child.name }}</span>
<span v-if="child.permission" class="menu-permission">{{ child.permission }}</span>
</div>
<!-- 第三级菜单 -->
<div v-if="child.expanded && child.children" class="submenu">
<div
v-for="grandChild in child.children"
:key="grandChild.id"
class="menu-item"
:style="{ paddingLeft: (grandChild.level * 20 + 10) + 'px' }"
>
<div class="menu-row">
<span
v-if="grandChild.children && grandChild.children.length > 0"
@click="toggleMenu(grandChild)"
class="expand-icon"
>
{{ grandChild.expanded ? '−' : '+' }}
</span>
<span v-else class="expand-placeholder"></span>
<input
type="checkbox"
:id="'menu-' + grandChild.id"
:checked="selectedMenus.includes(grandChild.id)"
@change="handleMenuSelect(grandChild)"
class="menu-checkbox"
>
<span class="menu-icon">📁</span>
<span class="menu-name">{{ grandChild.name }}</span>
<span v-if="grandChild.permission" class="menu-permission">{{ grandChild.permission }}</span>
</div>
<!-- 第四级菜单 -->
<div v-if="grandChild.expanded && grandChild.children" class="submenu">
<div
v-for="greatGrandChild in grandChild.children"
:key="greatGrandChild.id"
class="menu-item"
:style="{ paddingLeft: (greatGrandChild.level * 20 + 10) + 'px' }"
>
<div class="menu-row">
<span class="expand-placeholder"></span>
<input
type="checkbox"
:id="'menu-' + greatGrandChild.id"
:checked="selectedMenus.includes(greatGrandChild.id)"
@change="handleMenuSelect(greatGrandChild)"
class="menu-checkbox"
>
<span class="menu-icon">📁</span>
<span class="menu-name">{{ greatGrandChild.name }}</span>
<span v-if="greatGrandChild.permission" class="menu-permission">{{ greatGrandChild.permission }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" @click="closeRoleDialog" class="cancel-btn">取消</button>
<button type="submit" :disabled="formLoading" class="submit-btn">
{{ formLoading ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Role } from '@/types'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { roleApi, type Role, type RoleSearchParams } from '../../../api/role'
const router = useRouter()
// 导航栏相关
const sidebarCollapsed = ref(false)
const navTabs = ref([
{ id: 1, name: '角色管理', path: '/sys/role', active: true, closable: false }
])
// 搜索表单
const searchForm = reactive({
roleName: '',
roleKey: '',
status: undefined
roleCode: '',
status: ''
})
// 表格数据
const tableData = ref<Role[]>([])
const roles = ref<Role[]>([])
const selectedRoles = ref<number[]>([])
const selectAll = ref(false)
const loading = ref(false)
const multipleSelection = ref<Role[]>([])
// 分页
const pagination = reactive({
current: 1,
size: 10,
total: 0
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 获取页码数组
const getPageNumbers = () => {
const pages = []
const maxVisible = 5 // 最多显示5个页码
const start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
const end = Math.min(totalPages.value, start + maxVisible - 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// 对话框相关
const showRoleDialog = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const roleForm = reactive({
roleId: 0,
roleName: '',
roleCode: '',
status: 1,
remark: '',
menuIds: [] as number[]
})
// 字段错误信息
const fieldErrors = reactive({
roleName: '',
roleCode: ''
})
// 菜单权限相关
const menuTree = ref<any[]>([])
const selectedMenus = ref<number[]>([])
const allExpanded = ref(false)
const allSelected = ref(false)
const parentChildLinkage = ref(true)
// 导航栏功能
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
console.log('切换侧边栏:', sidebarCollapsed.value)
}
const switchTab = (tab: any) => {
navTabs.value.forEach(t => t.active = false)
tab.active = true
if (tab.path) {
router.push(tab.path)
}
}
const closeTab = (tab: any) => {
if (tab.closable) {
const index = navTabs.value.findIndex(t => t.id === tab.id)
if (index > -1) {
navTabs.value.splice(index, 1)
// 如果关闭的是当前激活的标签,激活相邻的标签
if (tab.active && navTabs.value.length > 0) {
const newActiveIndex = Math.min(index, navTabs.value.length - 1)
navTabs.value[newActiveIndex].active = true
if (navTabs.value[newActiveIndex].path) {
router.push(navTabs.value[newActiveIndex].path)
}
}
}
}
}
const handleRefresh = () => {
fetchRoles()
}
// 获取角色列表
const getRoleList = async () => {
const fetchRoles = async () => {
loading.value = true
try {
// 模拟数据
tableData.value = [
{
roleId: 1,
roleName: '超级管理员',
roleKey: 'ADMIN',
roleSort: 1,
status: 1,
remark: '超级管理员',
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00',
permissions: []
},
{
roleId: 2,
roleName: '普通用户',
roleKey: 'USER',
roleSort: 2,
status: 1,
remark: '普通用户',
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00',
permissions: []
}
]
pagination.total = 2
} catch (error) {
ElMessage.error('获取角色列表失败')
const params: RoleSearchParams = {
pageNum: currentPage.value,
pageSize: pageSize.value,
roleName: searchForm.roleName || undefined,
roleCode: searchForm.roleCode || undefined,
status: searchForm.status ? Number(searchForm.status) : undefined
}
console.log('获取角色列表参数:', params)
const response = await roleApi.getRoleList(params)
console.log('角色列表响应:', response)
if (response.code === 200) {
roles.value = response.data.records || []
total.value = response.data.total || 0
} else {
console.error('获取角色列表失败:', response.message)
showMessage(response.message || '获取角色列表失败', 'error')
}
} catch (error: any) {
console.error('获取角色列表失败:', error)
showMessage(error.response?.data?.message || '获取角色列表失败', 'error')
} finally {
loading.value = false
}
}
// 搜索
// 搜索功能
const handleSearch = () => {
pagination.current = 1
getRoleList()
currentPage.value = 1
fetchRoles()
}
// 重置
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, {
roleName: '',
roleKey: '',
status: undefined
roleCode: '',
status: ''
})
handleSearch()
}
// 新增角色
const handleAdd = () => {
ElMessage.info('新增角色功能待实现')
// 表格控制按钮功能
const handleTableSearch = () => {
const searchSection = document.querySelector('.search-section')
if (searchSection) {
searchSection.scrollIntoView({ behavior: 'smooth' })
}
}
// 编辑角色
const handleEdit = (row: Role) => {
ElMessage.info(`编辑角色: ${row.roleName}`)
const handleTableRefresh = () => {
fetchRoles()
}
// 分配权限
const handleAssignPermissions = (row: Role) => {
ElMessage.info(`分配权限: ${row.roleName}`)
const handleTableExport = () => {
console.log('导出角色数据')
showMessage('导出功能开发中...', 'info')
}
// 删除角色
const handleDelete = async (row: Role) => {
const handleTableViewToggle = () => {
console.log('切换视图模式')
showMessage('视图切换功能开发中...', 'info')
}
// 菜单权限相关方法
const toggleExpandAll = () => {
allExpanded.value = !allExpanded.value
const setExpanded = (menus: any[]) => {
menus.forEach(menu => {
menu.expanded = allExpanded.value
if (menu.children) {
setExpanded(menu.children)
}
})
}
setExpanded(menuTree.value)
}
const toggleSelectAll = () => {
allSelected.value = !allSelected.value
if (allSelected.value) {
const getAllMenuIds = (menus: any[]): number[] => {
let ids: number[] = []
menus.forEach(menu => {
ids.push(menu.id)
if (menu.children) {
ids = ids.concat(getAllMenuIds(menu.children))
}
})
return ids
}
selectedMenus.value = getAllMenuIds(menuTree.value)
} else {
selectedMenus.value = []
}
updateRoleFormMenuIds()
}
const toggleMenu = (menu: any) => {
menu.expanded = !menu.expanded
}
const handleMenuSelect = (menu: any) => {
const isSelected = selectedMenus.value.includes(menu.id)
if (isSelected) {
// 取消选择
selectedMenus.value = selectedMenus.value.filter(id => id !== menu.id)
if (parentChildLinkage.value && menu.children) {
// 取消选择所有子菜单
const removeChildIds = (children: any[]) => {
children.forEach(child => {
selectedMenus.value = selectedMenus.value.filter(id => id !== child.id)
if (child.children) {
removeChildIds(child.children)
}
})
}
removeChildIds(menu.children)
}
} else {
// 选择
selectedMenus.value.push(menu.id)
if (parentChildLinkage.value && menu.children) {
// 选择所有子菜单
const addChildIds = (children: any[]) => {
children.forEach(child => {
if (!selectedMenus.value.includes(child.id)) {
selectedMenus.value.push(child.id)
}
if (child.children) {
addChildIds(child.children)
}
})
}
addChildIds(menu.children)
}
}
// 更新父菜单选择状态
if (parentChildLinkage.value) {
updateParentSelection(menuTree.value)
}
updateRoleFormMenuIds()
}
const updateParentSelection = (menus: any[]) => {
menus.forEach(menu => {
if (menu.children && menu.children.length > 0) {
const allChildrenSelected = menu.children.every((child: any) =>
selectedMenus.value.includes(child.id)
)
const someChildrenSelected = menu.children.some((child: any) =>
selectedMenus.value.includes(child.id)
)
if (allChildrenSelected) {
if (!selectedMenus.value.includes(menu.id)) {
selectedMenus.value.push(menu.id)
}
} else if (!someChildrenSelected) {
selectedMenus.value = selectedMenus.value.filter(id => id !== menu.id)
}
updateParentSelection(menu.children)
}
})
}
const updateRoleFormMenuIds = () => {
roleForm.menuIds = [...selectedMenus.value]
}
// 获取菜单树数据
const fetchMenuTree = async () => {
try {
await ElMessageBox.confirm(`确定要删除角色 "${row.roleName}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
const response = await roleApi.getMenuTree()
if (response.code === 200 && response.data) {
// 转换菜单数据格式,添加前端需要的字段
menuTree.value = response.data.map(menu => convertMenuForTree(menu))
} else {
console.error('获取菜单数据失败:', response.message)
showMessage('获取菜单数据失败', 'error')
}
} catch (error) {
console.error('获取菜单数据失败:', error)
showMessage('获取菜单数据失败,请重试', 'error')
}
}
// 转换菜单数据为树形结构格式
const convertMenuForTree = (menu: any, level: number = 0): any => {
return {
id: menu.menuId,
name: menu.menuName,
permission: menu.perms || '',
level: level,
expanded: false,
children: menu.children ? menu.children.map((child: any) => convertMenuForTree(child, level + 1)) : []
}
}
// 更新菜单树选择状态
const updateMenuTreeSelection = (menus: any[]) => {
// 递归更新菜单树的选择状态
const updateTreeSelection = (treeMenus: any[]) => {
treeMenus.forEach(treeMenu => {
// 递归处理子菜单
if (treeMenu.children && treeMenu.children.length > 0) {
updateTreeSelection(treeMenu.children)
}
})
ElMessage.success('删除成功')
getRoleList()
}
// 更新整个菜单树
updateTreeSelection(menuTree.value)
// 更新表单中的菜单ID
updateRoleFormMenuIds()
}
// 选择功能
const handleSelectAll = (event: Event) => {
const target = event.target as HTMLInputElement
selectAll.value = target.checked
if (target.checked) {
selectedRoles.value = roles.value.map(role => role.roleId)
} else {
selectedRoles.value = []
}
}
const handleSelectRole = (roleId: number) => {
const index = selectedRoles.value.indexOf(roleId)
if (index > -1) {
selectedRoles.value.splice(index, 1)
} else {
selectedRoles.value.push(roleId)
}
selectAll.value = selectedRoles.value.length === roles.value.length
}
// 新增角色
const handleAdd = () => {
dialogTitle.value = '新增角色'
clearFieldErrors()
Object.assign(roleForm, {
roleId: 0,
roleName: '',
roleCode: '',
status: 1,
remark: '',
menuIds: []
})
selectedMenus.value = []
showRoleDialog.value = true
}
// 编辑角色
const handleEdit = async (role: Role) => {
dialogTitle.value = '编辑角色'
clearFieldErrors()
try {
// 确保菜单树数据已加载
if (menuTree.value.length === 0) {
console.log('菜单树数据未加载,先加载菜单树')
await fetchMenuTree()
}
// 获取角色详细信息,包括菜单权限
const response = await roleApi.getRoleById(role.roleId)
if (response.code === 200 && response.data) {
const roleDetail = response.data
// 初始化表单数据
Object.assign(roleForm, {
roleId: roleDetail.roleId,
roleName: roleDetail.roleName,
roleCode: roleDetail.roleCode,
status: roleDetail.status,
remark: roleDetail.remark || '',
menuIds: roleDetail.menuIds || []
})
// 初始化菜单权限选择
// 从 menus 字段提取菜单ID(后端返回的是菜单对象数组)
let menuIds: number[] = []
if (roleDetail.menus && Array.isArray(roleDetail.menus)) {
// 递归提取所有菜单ID(包括子菜单)
const extractMenuIds = (menus: any[]): number[] => {
const ids: number[] = []
menus.forEach(menu => {
if (menu.menuId) {
ids.push(menu.menuId)
}
if (menu.children && menu.children.length > 0) {
ids.push(...extractMenuIds(menu.children))
}
})
return ids
}
menuIds = extractMenuIds(roleDetail.menus)
} else if (roleDetail.menuIds && Array.isArray(roleDetail.menuIds)) {
menuIds = roleDetail.menuIds
}
selectedMenus.value = [...menuIds]
// 延迟更新菜单树选择状态,确保DOM已更新
setTimeout(() => {
updateMenuTreeSelection(roleDetail.menus || [])
}, 100)
showRoleDialog.value = true
} else {
showMessage(response.message || '获取角色详情失败', 'error')
}
} catch (error) {
// 用户取消删除
console.error('获取角色详情失败:', error)
showMessage('获取角色详情失败,请重试', 'error')
}
}
// 删除角色
const handleDelete = async (role: Role) => {
if (confirm(`确定要删除角色 ${role.roleName} 吗?`)) {
try {
const response = await roleApi.deleteRole([role.roleId])
if (response.code === 200) {
showMessage('删除成功!')
fetchRoles()
} else {
showMessage(response.message || '删除失败', 'error')
}
} catch (error: any) {
console.error('删除角色失败:', error)
showMessage(error.response?.data?.message || '删除失败', 'error')
}
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedRoles.value.length === 0) {
showMessage('请选择要删除的角色', 'warning')
return
}
if (confirm(`确定要删除选中的 ${selectedRoles.value.length} 个角色吗?`)) {
try {
const response = await roleApi.deleteRole(selectedRoles.value)
if (response.code === 200) {
showMessage('批量删除成功!')
selectedRoles.value = []
selectAll.value = false
fetchRoles()
} else {
showMessage(response.message || '批量删除失败', 'error')
}
} catch (error: any) {
console.error('批量删除角色失败:', error)
showMessage(error.response?.data?.message || '批量删除失败', 'error')
}
}
}
// 状态切换
const handleStatusToggle = async (role: Role) => {
const newStatus = role.status === 1 ? 0 : 1
const statusText = newStatus === 1 ? '启用' : '停用'
if (confirm(`确定要${statusText}角色 ${role.roleName} 吗?`)) {
try {
const response = await roleApi.updateRoleStatus(role.roleId, newStatus)
if (response.code === 200) {
showMessage(`${statusText}成功!`)
fetchRoles()
} else {
showMessage(response.message || `${statusText}失败`, 'error')
}
} catch (error: any) {
console.error(`${statusText}角色失败:`, error)
showMessage(error.response?.data?.message || `${statusText}失败`, 'error')
}
}
}
// 表单提交
const handleSubmit = async () => {
if (!validateForm()) {
return
}
formLoading.value = true
try {
await ElMessageBox.confirm(`确定要删除选中的 ${multipleSelection.value.length} 个角色吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('批量删除成功')
getRoleList()
} catch (error) {
// 用户取消删除
if (dialogTitle.value === '新增角色') {
const response = await roleApi.createRole(roleForm)
if (response.code === 200) {
showMessage('新增角色成功!')
showRoleDialog.value = false
fetchRoles()
} else {
if (response.code === 400 && response.data) {
setFieldErrors(response.data)
showMessage('请检查表单中的错误信息', 'error')
} else {
showMessage(response.message || '新增角色失败', 'error')
}
}
} else {
const response = await roleApi.updateRole(roleForm)
if (response.code === 200) {
showMessage('修改角色成功!')
showRoleDialog.value = false
fetchRoles()
} else {
if (response.code === 400 && response.data) {
setFieldErrors(response.data)
showMessage('请检查表单中的错误信息', 'error')
} else {
showMessage(response.message || '修改角色失败', 'error')
}
}
}
} catch (error: any) {
console.error('保存角色失败:', error)
showMessage(error.response?.data?.message || '保存失败', 'error')
} finally {
formLoading.value = false
}
}
// 表单验证
const validateForm = () => {
clearFieldErrors()
let isValid = true
if (!roleForm.roleName.trim()) {
fieldErrors.roleName = '角色名称不能为空'
isValid = false
}
if (!roleForm.roleCode.trim()) {
fieldErrors.roleCode = '角色编码不能为空'
isValid = false
}
return isValid
}
// 设置字段错误
const setFieldErrors = (errors: any) => {
clearFieldErrors()
if (errors.roleName) {
fieldErrors.roleName = errors.roleName
}
if (errors.roleCode) {
fieldErrors.roleCode = errors.roleCode
}
}
// 清除字段错误
const clearFieldErrors = () => {
fieldErrors.roleName = ''
fieldErrors.roleCode = ''
}
// 关闭对话框
const closeRoleDialog = () => {
showRoleDialog.value = false
clearFieldErrors()
}
// 分页功能
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
fetchRoles()
}
}
// 选择变化
const handleSelectionChange = (selection: Role[]) => {
multipleSelection.value = selection
const handlePageSizeChange = () => {
currentPage.value = 1
fetchRoles()
}
// 分页大小变
const handleSizeChange = (size: number) => {
pagination.size = size
getRoleList()
// 时间格式
const formatTime = (timeStr: string) => {
if (!timeStr) return '-'
return timeStr.replace('T', ' ').substring(0, 19)
}
// 当前页变化
const handleCurrentChange = (current: number) => {
pagination.current = current
getRoleList()
// 消息提示
const showMessage = (message: string, type: 'success' | 'error' | 'warning' | 'info' = 'success') => {
console.log(`${type.toUpperCase()}: ${message}`)
alert(message) // 简化版本,实际项目中应该使用更好的消息提示组件
}
// 组件挂载时获取数据
onMounted(() => {
getRoleList()
fetchRoles()
fetchMenuTree()
})
</script>
<style lang="scss" scoped>
.role-management {
.search-form {
margin-bottom: 16px;
background: #f5f5f5;
min-height: 100vh;
padding: 0;
}
/* 顶部导航栏 */
.top-nav {
background: white;
border-bottom: 1px solid #e0e0e0;
padding: 4px 12px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
min-height: 36px;
}
.nav-left {
display: flex;
align-items: center;
gap: 8px;
}
.nav-toggle {
background: none;
border: none;
font-size: 14px;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-toggle:hover {
background: #f0f0f0;
}
.nav-tabs {
display: flex;
gap: 2px;
}
.nav-tab {
padding: 4px 8px;
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 3px;
font-size: 11px;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
height: 24px;
}
.nav-tab.active {
background: #1890ff;
color: white;
border-color: #1890ff;
}
.close-icon {
font-size: 12px;
cursor: pointer;
opacity: 0.7;
margin-left: 4px;
padding: 1px 3px;
border-radius: 2px;
transition: all 0.2s;
}
.close-icon:hover {
opacity: 1;
color: #ff4d4f;
background: #fff2f0;
}
.refresh-btn {
background: #1890ff;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
}
/* 搜索筛选区域 */
.search-section {
background: white;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.search-row {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
}
.search-item label {
font-size: 12px;
color: #333;
white-space: nowrap;
}
.search-input, .search-select {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
width: 120px;
}
.search-input:focus, .search-select:focus {
outline: none;
border-color: #1890ff;
}
.search-actions {
display: flex;
gap: 6px;
}
.search-btn {
background: #1890ff;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
}
.reset-btn {
background: #ff7875;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
}
/* 操作按钮区域 */
.action-section {
background: white;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
height: 24px;
}
.action-btn.primary {
background: #1890ff;
color: white;
}
.action-btn.success {
background: #52c41a;
color: white;
}
.action-btn.danger {
background: #ff4d4f;
color: white;
}
.action-btn.info {
background: #13c2c2;
color: white;
}
.action-btn.warning {
background: #faad14;
color: white;
}
/* 表格区域 */
.table-section {
background: white;
margin: 16px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.table-header {
padding: 8px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
}
.table-controls {
display: flex;
gap: 4px;
}
.control-btn {
background: white;
border: 1px solid #d9d9d9;
border-radius: 3px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.control-btn:hover {
background: #f0f8ff;
border-color: #1890ff;
color: #1890ff;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
}
.control-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(24, 144, 255, 0.2);
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
}
.data-table th {
background: #fafafa;
font-weight: 600;
color: #333;
}
.data-table tbody tr:hover {
background: #f5f5f5;
}
.checkbox-col {
width: 40px;
text-align: center;
}
.table-checkbox {
width: 14px;
height: 14px;
cursor: pointer;
}
.status-badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
}
.status-badge.active {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.status-badge.inactive {
background: #fff2f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.status-badge:hover {
opacity: 0.8;
transform: scale(1.05);
}
.table-btn {
padding: 2px 6px;
margin-right: 4px;
border: none;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
}
.table-btn.edit {
background: #1890ff;
color: white;
}
.table-btn.delete {
background: #ff4d4f;
color: white;
}
/* 分页样式 */
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #f8f9fa;
border-top: 1px solid #e0e0e0;
}
.pagination-left {
display: flex;
align-items: center;
gap: 16px;
}
.pagination-info {
font-size: 12px;
color: #666;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
.page-size-select {
padding: 2px 4px;
border: 1px solid #d9d9d9;
border-radius: 3px;
font-size: 10px;
background: white;
}
.pagination-container {
display: flex;
align-items: center;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.pagination-btn {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
height: 20px;
}
.pagination-btn:hover:not(:disabled) {
background: #e9ecef;
color: #495057;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-number {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
min-width: 28px;
text-align: center;
height: 20px;
}
.page-number:hover {
background: #e9ecef;
color: #495057;
}
.page-number.active {
background: #1890ff;
color: white;
font-weight: 600;
}
.pagination-pages {
display: flex;
}
/* 对话框样式 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.dialog-header {
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #666;
}
.dialog-body {
padding: 20px;
}
.role-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
display: flex;
gap: 16px;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group.full-width {
flex: 1 1 100%;
}
.form-group label {
font-size: 12px;
color: #333;
font-weight: 500;
}
.form-group label.required::after {
content: ' *';
color: #ff4d4f;
}
.form-input, .form-select, .form-textarea {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
transition: border-color 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: #1890ff;
outline: none;
}
.form-textarea {
resize: vertical;
min-height: 60px;
}
.error-message {
font-size: 10px;
color: #ff4d4f;
margin-top: 2px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}
.cancel-btn, .submit-btn {
padding: 6px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: #f5f5f5;
color: #666;
border: 1px solid #d9d9d9;
}
.cancel-btn:hover {
background: #e6f7ff;
color: #1890ff;
border-color: #91d5ff;
}
.submit-btn {
background: #1890ff;
color: white;
border: 1px solid #1890ff;
}
.submit-btn:hover:not(:disabled) {
background: #40a9ff;
border-color: #40a9ff;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-row {
flex-direction: column;
align-items: stretch;
}
.operation-buttons {
margin-bottom: 16px;
.search-item {
min-width: auto;
}
.table-container {
.el-pagination {
margin-top: 16px;
text-align: right;
}
.form-row {
flex-direction: column;
}
.pagination-btn {
padding: 2px 4px;
font-size: 10px;
height: 20px;
}
.page-number {
padding: 2px 4px;
font-size: 10px;
min-width: 24px;
height: 20px;
}
.dialog-content {
width: 95%;
margin: 20px;
}
}
/* 菜单权限选择样式 */
.menu-permissions {
border: 1px solid #e0e0e0;
border-radius: 6px;
background: white;
max-height: 400px;
overflow-y: auto;
}
.permission-controls {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
display: flex;
align-items: center;
gap: 8px;
}
.menu-permissions .control-btn {
background: white;
border: 1px solid #d9d9d9;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.menu-permissions .control-btn:hover {
background: #f0f8ff;
border-color: #1890ff;
color: #1890ff;
}
.linkage-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
cursor: pointer;
}
.linkage-checkbox {
margin: 0;
}
.menu-tree {
padding: 4px 0;
}
.menu-item {
display: block;
margin: 0;
}
.menu-row {
display: flex;
align-items: center;
padding: 3px 12px;
transition: background-color 0.2s;
cursor: pointer;
}
.menu-row:hover {
background: #f5f5f5;
}
.expand-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
color: #666;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin-right: 8px;
background: white;
}
.expand-icon:hover {
background: #f0f0f0;
}
.expand-placeholder {
width: 16px;
margin-right: 8px;
}
.menu-checkbox {
margin: 0 8px 0 0;
cursor: pointer;
}
.menu-icon {
margin-right: 8px;
font-size: 14px;
}
.menu-name {
flex: 1;
font-size: 13px;
color: #333;
margin-right: 8px;
}
.menu-permission {
font-size: 11px;
color: #999;
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
flex-shrink: 0;
}
.submenu {
background: #fafafa;
margin: 0;
padding: 0;
}
</style>
......
<template>
<div class="user-management">
<!-- 搜索表单 -->
<el-card class="search-form">
<el-form :model="searchForm" inline>
<el-form-item label="用户名">
<el-input v-model="searchForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="searchForm.realName" placeholder="请输入真实姓名" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="operation-buttons">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增用户
</el-button>
<el-button type="danger" :disabled="!multipleSelection.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</el-card>
<!-- 数据表格 -->
<el-card class="table-container">
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="userId" label="用户ID" width="80" />
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="realName" label="真实姓名" width="120" />
<el-table-column prop="email" label="邮箱" width="200" />
<el-table-column prop="phone" label="手机号" width="120" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { User } from '@/types'
// 搜索表单
const searchForm = reactive({
username: '',
realName: '',
status: undefined
})
// 表格数据
const tableData = ref<User[]>([])
const loading = ref(false)
const multipleSelection = ref<User[]>([])
// 分页
const pagination = reactive({
current: 1,
size: 10,
total: 0
})
// 获取用户列表
const getUserList = async () => {
loading.value = true
try {
// 这里应该调用API
// const response = await getUserPageApi({ ...searchForm, ...pagination })
// tableData.value = response.records
// pagination.total = response.total
// 模拟数据
tableData.value = [
{
userId: 1,
username: 'admin',
realName: '管理员',
email: 'admin@example.com',
phone: '13800138000',
status: 1,
createTime: '2024-01-01 10:00:00',
updateTime: '2024-01-01 10:00:00',
roles: [],
permissions: []
}
]
pagination.total = 1
} catch (error) {
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
getUserList()
}
// 重置
const handleReset = () => {
Object.assign(searchForm, {
username: '',
realName: '',
status: undefined
})
handleSearch()
}
// 新增用户
const handleAdd = () => {
ElMessage.info('新增用户功能待实现')
}
// 编辑用户
const handleEdit = (row: User) => {
ElMessage.info(`编辑用户: ${row.username}`)
}
// 删除用户
const handleDelete = async (row: User) => {
try {
await ElMessageBox.confirm(`确定要删除用户 "${row.username}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getUserList()
} catch (error) {
// 用户取消删除
}
}
// 批量删除
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${multipleSelection.value.length} 个用户吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('批量删除成功')
getUserList()
} catch (error) {
// 用户取消删除
}
}
// 选择变化
const handleSelectionChange = (selection: User[]) => {
multipleSelection.value = selection
}
// 分页大小变化
const handleSizeChange = (size: number) => {
pagination.size = size
getUserList()
}
// 当前页变化
const handleCurrentChange = (current: number) => {
pagination.current = current
getUserList()
}
// 组件挂载时获取数据
onMounted(() => {
getUserList()
})
</script>
<style lang="scss" scoped>
.user-management {
.search-form {
margin-bottom: 16px;
}
.operation-buttons {
margin-bottom: 16px;
}
.table-container {
.el-pagination {
margin-top: 16px;
text-align: right;
}
}
}
</style>
<template>
<div class="users-container">
<!-- 搜索筛选区域 -->
<div class="search-section">
<div class="search-row">
<div class="search-item">
<label>登录名称:</label>
<input
v-model="searchForm.username"
type="text"
class="search-input"
placeholder="请输入登录名称"
/>
</div>
<div class="search-item">
<label>手机号码:</label>
<input
v-model="searchForm.phone"
type="text"
class="search-input"
placeholder="请输入手机号码"
/>
</div>
<div class="search-item">
<label>用户状态:</label>
<select v-model="searchForm.status" class="search-select">
<option value="">所有</option>
<option value="1">正常</option>
<option value="0">停用</option>
</select>
</div>
<div class="search-item">
<label>创建时间:</label>
<div class="date-range">
<div class="date-picker-container">
<input
v-model="searchForm.startTime"
type="text"
class="date-input"
placeholder="开始时间"
readonly
@click="toggleStartDatePicker"
@mouseenter="showStartDateOptions = true"
@mouseleave="hideStartDateOptions"
/>
<div v-if="showStartDateOptions" class="date-options" @mouseenter="showStartDateOptions = true" @mouseleave="hideStartDateOptions">
<div class="date-option" @click="selectStartDate('today')">今天</div>
<div class="date-option" @click="selectStartDate('yesterday')">昨天</div>
<div class="date-option" @click="selectStartDate('thisWeek')">本周</div>
<div class="date-option" @click="selectStartDate('lastWeek')">上周</div>
<div class="date-option" @click="selectStartDate('thisMonth')">本月</div>
<div class="date-option" @click="selectStartDate('lastMonth')">上月</div>
<div class="date-option" @click="selectStartDate('custom')">自定义</div>
</div>
<!-- 自定义日期选择器 -->
<div v-if="showStartDatePicker" class="custom-date-picker">
<input
type="date"
v-model="searchForm.startTime"
@change="showStartDatePicker = false"
class="date-input"
/>
</div>
</div>
<span class="date-separator">-</span>
<div class="date-picker-container">
<input
v-model="searchForm.endTime"
type="text"
class="date-input"
placeholder="结束时间"
readonly
@click="toggleEndDatePicker"
@mouseenter="showEndDateOptions = true"
@mouseleave="hideEndDateOptions"
/>
<div v-if="showEndDateOptions" class="date-options" @mouseenter="showEndDateOptions = true" @mouseleave="hideEndDateOptions">
<div class="date-option" @click="selectEndDate('today')">今天</div>
<div class="date-option" @click="selectEndDate('yesterday')">昨天</div>
<div class="date-option" @click="selectEndDate('thisWeek')">本周</div>
<div class="date-option" @click="selectEndDate('lastWeek')">上周</div>
<div class="date-option" @click="selectEndDate('thisMonth')">本月</div>
<div class="date-option" @click="selectEndDate('lastMonth')">上月</div>
<div class="date-option" @click="selectEndDate('custom')">自定义</div>
</div>
<!-- 自定义日期选择器 -->
<div v-if="showEndDatePicker" class="custom-date-picker">
<input
type="date"
v-model="searchForm.endTime"
@change="showEndDatePicker = false"
class="date-input"
/>
</div>
</div>
</div>
</div>
<div class="search-actions">
<button @click="handleSearch" class="search-btn">🔍 搜索</button>
<button @click="handleReset" class="reset-btn">🔄 重置</button>
</div>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="action-section">
<div class="action-buttons">
<button @click="handleAdd" class="action-btn primary">✨ 新增</button>
<button @click="handleBatchDelete" class="action-btn danger">🗑️ 删除</button>
</div>
</div>
<!-- 数据表格 -->
<div class="table-section">
<div class="table-header">
<div class="table-controls">
<button @click="handleTableSearch" class="control-btn" title="搜索">🔍</button>
<button @click="handleTableRefresh" class="control-btn" title="刷新">🔄</button>
<button @click="handleTableExport" class="control-btn" title="导出">📋</button>
<button @click="handleTableViewToggle" class="control-btn" title="视图切换">⊞</button>
</div>
</div>
<div class="table-container">
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">加载中...</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>
<input
type="checkbox"
class="select-all"
v-model="selectAll"
@change="handleSelectAll"
/>
</th>
<th>用户ID</th>
<th class="sortable">登录名称 ↕️</th>
<th>用户名称</th>
<th>角色</th>
<th>手机</th>
<th>用户状态</th>
<th class="sortable">创建时间 ↕️</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.userId">
<td>
<input
type="checkbox"
class="row-checkbox"
:checked="selectedUsers.includes(user.userId)"
@change="handleSelectUser(user.userId)"
/>
</td>
<td>{{ user.userId }}</td>
<td><a href="#" class="user-link">{{ user.username }}</a></td>
<td>{{ user.realName }}</td>
<td>{{ user.roles?.[0]?.roleName || '未分配' }}</td>
<td>{{ user.phone }}</td>
<td>
<label class="status-switch">
<input
type="checkbox"
:checked="user.status === 1"
@change="toggleStatus(user)"
/>
<span class="switch-slider"></span>
</label>
</td>
<td>{{ formatDateTime(user.createTime) }}</td>
<td>
<button @click="handleEdit(user)" class="table-btn edit">✏️ 编辑</button>
<button @click="handleDelete(user)" class="table-btn delete">🗑️ 删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页信息 -->
<div class="table-footer">
<div class="pagination-left">
<div class="pagination-info">
共 {{ total }} 条记录,第 {{ currentPage }} / {{ totalPages }} 页
</div>
<div class="page-size-selector">
<label>每页显示:</label>
<select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">
<option value="10">10条</option>
<option value="20">20条</option>
<option value="50">50条</option>
<option value="100">100条</option>
</select>
</div>
</div>
<div class="pagination-container">
<button
@click="handlePageChange(currentPage - 1)"
:disabled="currentPage <= 1"
class="pagination-btn prev-btn"
>
上一页
</button>
<div class="pagination-pages">
<button
v-for="page in getPageNumbers()"
:key="page"
@click="handlePageChange(page)"
:class="['page-number', { active: page === currentPage }]"
>
{{ page }}
</button>
</div>
<button
@click="handlePageChange(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="pagination-btn next-btn"
>
下一页
</button>
</div>
</div>
</div>
<!-- 用户表单对话框 -->
<div v-if="showUserDialog" class="dialog-overlay" @click="showUserDialog = false">
<div class="dialog-content" @click.stop>
<div class="dialog-header">
<h3>{{ dialogTitle }}</h3>
<button @click="showUserDialog = false" class="close-btn">×</button>
</div>
<div class="dialog-body">
<form @submit.prevent="handleSave">
<div class="form-row">
<div class="form-group">
<label class="required-label">用户名 <span class="required-asterisk">*</span></label>
<input
v-model="userForm.username"
type="text"
class="form-input"
:class="{ 'error': fieldErrors.username }"
required
/>
<div v-if="fieldErrors.username" class="field-error">{{ fieldErrors.username }}</div>
</div>
<div class="form-group">
<label class="required-label">真实姓名 <span class="required-asterisk">*</span></label>
<input
v-model="userForm.realName"
type="text"
class="form-input"
:class="{ 'error': fieldErrors.realName }"
required
/>
<div v-if="fieldErrors.realName" class="field-error">{{ fieldErrors.realName }}</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="required-label">手机号 <span class="required-asterisk">*</span></label>
<input
v-model="userForm.phone"
type="text"
class="form-input"
:class="{ 'error': fieldErrors.phone }"
required
/>
<div v-if="fieldErrors.phone" class="field-error">{{ fieldErrors.phone }}</div>
</div>
<div class="form-group">
<label>邮箱</label>
<input
v-model="userForm.email"
type="email"
class="form-input"
:class="{ 'error': fieldErrors.email }"
/>
<div v-if="fieldErrors.email" class="field-error">{{ fieldErrors.email }}</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>状态</label>
<select v-model="userForm.status" class="form-select">
<option :value="1">正常</option>
<option :value="0">停用</option>
</select>
</div>
<div class="form-group">
<label>备注</label>
<input
v-model="userForm.remark"
type="text"
class="form-input"
:class="{ 'error': fieldErrors.remark }"
placeholder="请输入备注信息"
/>
<div v-if="fieldErrors.remark" class="field-error">{{ fieldErrors.remark }}</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>角色</label>
<select v-model="userForm.roleIds" multiple class="form-select role-select" :disabled="rolesLoading">
<option v-if="rolesLoading" disabled>正在加载角色列表...</option>
<option v-else-if="roles.length === 0" disabled>暂无可用角色</option>
<option v-else v-for="role in roles" :key="role.roleId" :value="role.roleId">
{{ role.roleName }} ({{ role.roleCode }})
</option>
</select>
<small class="form-hint">
<span v-if="rolesLoading">正在加载有效角色列表...</span>
<span v-else>
按住Ctrl键可多选角色,已选择 {{ userForm.roleIds.length }} 个角色(仅显示有效角色)
<button @click="fetchRoles" class="refresh-roles-btn" type="button">刷新角色</button>
</span>
</small>
<!-- 显示已选择的角色 -->
<div v-if="userForm.roleIds.length > 0" class="selected-roles">
<div class="selected-roles-label">已选择的角色:</div>
<div class="role-tags">
<span v-for="roleId in userForm.roleIds" :key="roleId" class="role-tag">
{{ getRoleName(roleId) }} ({{ getRoleCode(roleId) }})
</span>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="dialog-footer">
<button @click="showUserDialog = false" class="btn-cancel">取消</button>
<button @click="handleSave" class="btn-save" :disabled="formLoading">
{{ formLoading ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { userApi, type User, type UserSearchParams, type UserForm } from '../../api/user'
import { roleApi, type Role } from '../../api/role'
const router = useRouter()
// 消息提示函数
const showMessage = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
// 这里可以使用更优雅的提示组件,暂时使用alert
alert(message)
}
// 角色列表
const roles = ref<Role[]>([])
const rolesLoading = ref(false)
// 获取角色列表
const fetchRoles = async () => {
try {
rolesLoading.value = true
console.log('开始获取有效角色列表...')
// 使用角色列表接口获取所有有效角色(status=1)
const params = {
roleCode: "",
roleName: "",
status: 1, // 只获取有效角色
pageNum: 1,
pageSize: 9999 // 获取全部有效角色
}
console.log('角色列表请求参数:', params)
console.log('API基础URL:', 'http://localhost:8083')
const response = await roleApi.getRoleList(params)
console.log('角色列表响应:', response)
// 根据后端响应格式,数据在response.data中
if (response && response.data && response.data.records) {
roles.value = response.data.records
console.log('有效角色列表加载成功:', roles.value)
console.log('加载的角色数量:', roles.value.length)
} else {
console.warn('角色列表响应格式异常:', response)
roles.value = []
}
} catch (error: any) {
console.error('获取角色列表失败:', error)
console.error('错误详情:', {
message: error.message,
response: error.response,
request: error.request,
config: error.config
})
showMessage('获取角色列表失败,使用默认角色', 'warning')
// 网络错误时使用默认角色
roles.value = [
{
roleId: 1,
roleCode: 'ADMIN',
roleName: '系统管理员',
status: 1,
statusText: '正常',
remark: '系统管理员角色',
createBy: 'system',
createTime: '2025-09-19T13:27:04',
updateBy: '',
updateTime: '2025-09-19T13:27:04',
menus: null
},
{
roleId: 2,
roleCode: 'OPERATOR',
roleName: '运营人员',
status: 1,
statusText: '正常',
remark: '运营人员角色',
createBy: 'system',
createTime: '2025-09-19T13:27:04',
updateBy: '',
updateTime: '2025-09-19T13:27:04',
menus: null
},
{
roleId: 3,
roleCode: 'AUDITOR',
roleName: '审核人员',
status: 1,
statusText: '正常',
remark: '审核人员角色',
createBy: 'system',
createTime: '2025-09-19T13:27:04',
updateBy: '',
updateTime: '2025-09-19T13:27:04',
menus: null
}
]
} finally {
rolesLoading.value = false
}
}
// 根据角色ID获取角色名称
const getRoleName = (roleId: number): string => {
const role = roles.value.find(r => r.roleId === roleId)
return role ? role.roleName : `未知角色(${roleId})`
}
// 根据角色ID获取角色代码
const getRoleCode = (roleId: number): string => {
const role = roles.value.find(r => r.roleId === roleId)
return role ? role.roleCode : `UNKNOWN`
}
// 搜索表单
const searchForm = reactive({
username: '',
phone: '',
status: '',
startTime: '',
endTime: ''
})
// 时间选择器状态
const showStartDateOptions = ref(false)
const showEndDateOptions = ref(false)
const showStartDatePicker = ref(false)
const showEndDatePicker = ref(false)
// 用户列表
const users = ref<User[]>([])
const loading = ref(false)
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 获取页码数组
const getPageNumbers = () => {
const pages = []
const maxVisible = 5 // 最多显示5个页码
const start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
const end = Math.min(totalPages.value, start + maxVisible - 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// 选中的用户
const selectedUsers = ref<number[]>([])
const selectAll = ref(false)
// 用户表单
const userForm = reactive<UserForm>({
userId: 0,
username: '',
realName: '',
phone: '',
email: '',
status: 1,
remark: '',
roleIds: [],
password: ''
})
// 表单字段错误信息
const fieldErrors = reactive<Record<string, string>>({})
// 对话框状态
const showUserDialog = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
// 格式化时间显示
const formatDateTime = (dateTime: string | undefined): string => {
if (!dateTime) return ''
// 将 ISO 格式的时间字符串转换为可读格式
// 例如: 2025-09-23T17:49:06 -> 2025-09-23 17:49:06
return dateTime.replace('T', ' ')
}
// 获取当前日期(YYYY-MM-DD格式)
const getCurrentDate = (): string => {
const now = new Date()
return now.toISOString().split('T')[0]
}
// 获取指定天数前的日期
const getDateBefore = (days: number): string => {
const date = new Date()
date.setDate(date.getDate() - days)
return date.toISOString().split('T')[0]
}
// 获取本周开始日期(周一)
const getWeekStart = (): string => {
const now = new Date()
const day = now.getDay()
const diff = now.getDate() - day + (day === 0 ? -6 : 1) // 调整到周一
const monday = new Date(now.setDate(diff))
return monday.toISOString().split('T')[0]
}
// 获取本月开始日期
const getMonthStart = (): string => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
}
// 获取上月开始日期
const getLastMonthStart = (): string => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0]
}
// 获取上月结束日期
const getLastMonthEnd = (): string => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0]
}
// 开始时间选择器控制
const toggleStartDatePicker = () => {
showStartDatePicker.value = !showStartDatePicker.value
showStartDateOptions.value = false
}
const hideStartDateOptions = () => {
setTimeout(() => {
showStartDateOptions.value = false
}, 200)
}
// 结束时间选择器控制
const toggleEndDatePicker = () => {
showEndDatePicker.value = !showEndDatePicker.value
showEndDateOptions.value = false
}
const hideEndDateOptions = () => {
setTimeout(() => {
showEndDateOptions.value = false
}, 200)
}
// 选择开始时间
const selectStartDate = (type: string) => {
switch (type) {
case 'today':
searchForm.startTime = getCurrentDate()
break
case 'yesterday':
searchForm.startTime = getDateBefore(1)
break
case 'thisWeek':
searchForm.startTime = getWeekStart()
break
case 'lastWeek':
searchForm.startTime = getDateBefore(14) // 两周前
break
case 'thisMonth':
searchForm.startTime = getMonthStart()
break
case 'lastMonth':
searchForm.startTime = getLastMonthStart()
break
case 'custom':
// 这里可以打开自定义日期选择器
showStartDatePicker.value = true
break
}
showStartDateOptions.value = false
}
// 选择结束时间
const selectEndDate = (type: string) => {
switch (type) {
case 'today':
searchForm.endTime = getCurrentDate()
break
case 'yesterday':
searchForm.endTime = getDateBefore(1)
break
case 'thisWeek':
searchForm.endTime = getCurrentDate()
break
case 'lastWeek':
searchForm.endTime = getDateBefore(7) // 一周前
break
case 'thisMonth':
searchForm.endTime = getCurrentDate()
break
case 'lastMonth':
searchForm.endTime = getLastMonthEnd()
break
case 'custom':
// 这里可以打开自定义日期选择器
showEndDatePicker.value = true
break
}
showEndDateOptions.value = false
}
// 获取用户列表
const fetchUsers = async () => {
try {
loading.value = true
const params: UserSearchParams = {
username: searchForm.username || undefined,
phone: searchForm.phone || undefined,
status: searchForm.status ? parseInt(searchForm.status) : undefined,
startTime: searchForm.startTime || undefined,
endTime: searchForm.endTime || undefined,
page: currentPage.value,
pageSize: pageSize.value
}
const response = await userApi.getUserList(params)
if (response.code === 200) {
users.value = response.data.records
total.value = response.data.total
} else {
showMessage(response.message || '获取用户列表失败', 'error')
}
} catch (error: any) {
console.error('获取用户列表失败:', error)
showMessage(error.response?.data?.message || '获取用户列表失败', 'error')
} finally {
loading.value = false
}
}
// 搜索功能
const handleSearch = () => {
currentPage.value = 1
fetchUsers()
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, {
username: '',
phone: '',
status: '',
startTime: '',
endTime: ''
})
currentPage.value = 1
fetchUsers()
}
// 清除字段错误
const clearFieldErrors = () => {
Object.keys(fieldErrors).forEach(key => {
delete fieldErrors[key]
})
}
// 新增用户
const handleAdd = async () => {
dialogTitle.value = '新增用户'
// 清除之前的错误信息
clearFieldErrors()
// 确保角色列表已加载
if (roles.value.length === 0) {
console.log('角色列表为空,重新加载角色...')
await fetchRoles()
}
Object.assign(userForm, {
userId: 0,
username: '',
realName: '',
phone: '',
email: '',
status: 1,
remark: '',
roleIds: [], // 新增用户时默认为空,用户可以选择多个角色
password: '123456' // 设置默认密码
})
showUserDialog.value = true
}
// 编辑用户
const handleEdit = async (user: User) => {
dialogTitle.value = '编辑用户'
// 清除之前的错误信息
clearFieldErrors()
// 确保角色列表已加载
if (roles.value.length === 0) {
console.log('角色列表为空,重新加载角色...')
await fetchRoles()
}
Object.assign(userForm, {
userId: user.userId,
username: user.username,
realName: user.realName,
phone: user.phone,
email: user.email,
status: user.status,
remark: user.remark || '',
roleIds: user.roles?.map(role => role.roleId) || [], // 编辑时加载用户当前的所有角色
password: '' // 编辑时不设置密码
})
showUserDialog.value = true
}
// 删除用户
const handleDelete = async (user: User) => {
if (confirm(`确定要删除用户 ${user.realName} 吗?`)) {
try {
const response = await userApi.deleteUser(user.userId)
if (response.code === 200) {
showMessage('删除成功!')
fetchUsers()
} else {
showMessage(response.message || '删除失败', 'error')
}
} catch (error: any) {
console.error('删除用户失败:', error)
showMessage(error.response?.data?.message || '删除失败', 'error')
}
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedUsers.value.length === 0) {
showMessage('请选择要删除的用户', 'warning')
return
}
if (confirm(`确定要删除选中的 ${selectedUsers.value.length} 个用户吗?`)) {
try {
const response = await userApi.batchDeleteUsers(selectedUsers.value)
if (response.code === 200) {
showMessage('批量删除成功!')
selectedUsers.value = []
selectAll.value = false
fetchUsers()
} else {
showMessage(response.message || '批量删除失败', 'error')
}
} catch (error: any) {
console.error('批量删除用户失败:', error)
showMessage(error.response?.data?.message || '批量删除失败', 'error')
}
}
}
// 状态切换
const toggleStatus = async (user: User) => {
const newStatus = user.status === 1 ? 0 : 1
try {
const response = await userApi.updateUserStatus(user.userId, newStatus)
if (response.code === 200) {
user.status = newStatus
user.statusText = newStatus === 1 ? '正常' : '停用'
showMessage(`用户状态已切换为: ${user.statusText}`)
} else {
showMessage(response.message || '状态切换失败', 'error')
}
} catch (error: any) {
console.error('状态切换失败:', error)
showMessage(error.response?.data?.message || '状态切换失败', 'error')
}
}
// 全选/取消全选
const handleSelectAll = () => {
if (selectAll.value) {
selectedUsers.value = users.value.map(user => user.userId)
} else {
selectedUsers.value = []
}
}
// 选择单个用户
const handleSelectUser = (userId: number) => {
const index = selectedUsers.value.indexOf(userId)
if (index > -1) {
selectedUsers.value.splice(index, 1)
} else {
selectedUsers.value.push(userId)
}
// 更新全选状态
selectAll.value = selectedUsers.value.length === users.value.length
}
// 解析字段错误信息
const parseFieldErrors = (errorData: any) => {
clearFieldErrors()
if (errorData && typeof errorData === 'object') {
// 遍历错误数据,设置对应的字段错误
Object.keys(errorData).forEach(field => {
fieldErrors[field] = errorData[field]
})
}
}
// 保存用户
const handleSave = async () => {
// 清除之前的错误信息
clearFieldErrors()
if (!userForm.username || !userForm.realName || !userForm.phone) {
showMessage('请填写必填字段(用户名、真实姓名、手机号)', 'warning')
return
}
try {
formLoading.value = true
if (dialogTitle.value === '新增用户') {
// 新增用户 - 确保包含默认密码
const createData = {
...userForm,
password: userForm.password || '123456' // 确保有默认密码
}
const response = await userApi.createUser(createData)
if (response.code === 200) {
showMessage('新增用户成功!默认密码为123456')
showUserDialog.value = false
fetchUsers()
} else {
// 检查是否有字段级别的错误信息
if (response.code === 400 && response.data) {
parseFieldErrors(response.data)
showMessage('请检查表单中的错误信息', 'error')
} else {
showMessage(response.message || '新增用户失败', 'error')
}
}
} else {
// 编辑用户 - 不传递密码字段
const updateData = { ...userForm }
delete updateData.password // 编辑时不传递密码
const response = await userApi.updateUser(userForm.userId!, updateData)
if (response.code === 200) {
showMessage('编辑用户成功!')
showUserDialog.value = false
fetchUsers()
} else {
// 检查是否有字段级别的错误信息
if (response.code === 400 && response.data) {
parseFieldErrors(response.data)
showMessage('请检查表单中的错误信息', 'error')
} else {
showMessage(response.message || '编辑用户失败', 'error')
}
}
}
} catch (error: any) {
console.error('保存用户失败:', error)
// 处理网络错误或服务器错误
if (error.response?.data) {
const errorData = error.response.data
if (errorData.code === 400 && errorData.data) {
parseFieldErrors(errorData.data)
showMessage('请检查表单中的错误信息', 'error')
} else {
showMessage(errorData.message || '保存用户失败', 'error')
}
} else {
showMessage('保存用户失败', 'error')
}
} finally {
formLoading.value = false
}
}
// 页面变化
const handlePageChange = (page: number) => {
currentPage.value = page
fetchUsers()
}
// 每页条数变化
const handlePageSizeChange = () => {
currentPage.value = 1 // 重置到第一页
fetchUsers()
}
// 导航栏功能
const sidebarCollapsed = ref(false)
// 侧边栏切换
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
// 通知父组件或使用事件总线
console.log('侧边栏状态:', sidebarCollapsed.value ? '收起' : '展开')
}
// 导航标签页
const navTabs = ref([
{ id: 1, name: '首页', path: '/main/dashboard', active: true, closable: false },
{ id: 2, name: '用户管理', path: '/main/users', active: true, closable: true },
{ id: 3, name: '个人中心', path: '/main/profile', active: false, closable: true },
{ id: 4, name: '角色管理', path: '/main/roles', active: false, closable: true },
{ id: 5, name: '菜单管理', path: '/main/menus', active: false, closable: true },
{ id: 6, name: '部门管理', path: '/main/departments', active: false, closable: true }
])
// 切换标签页
const switchTab = (tab: any) => {
// 取消所有标签页的激活状态
navTabs.value.forEach(t => t.active = false)
// 激活当前标签页
tab.active = true
// 如果标签页有路径,进行路由跳转
if (tab.path) {
router.push(tab.path)
}
}
// 关闭标签页
const closeTab = (tab: any) => {
if (!tab.closable) return
// 如果关闭的是当前激活的标签页,需要激活其他标签页
if (tab.active) {
const currentIndex = navTabs.value.findIndex(t => t.id === tab.id)
const nextTab = navTabs.value[currentIndex + 1] || navTabs.value[currentIndex - 1]
if (nextTab) {
nextTab.active = true
if (nextTab.path) {
router.push(nextTab.path)
}
}
}
// 移除标签页
const index = navTabs.value.findIndex(t => t.id === tab.id)
if (index > -1) {
navTabs.value.splice(index, 1)
}
}
// 刷新功能
const handleRefresh = () => {
// 刷新当前页面数据
fetchUsers()
console.log('页面已刷新')
}
// 表格控制按钮功能
const handleTableSearch = () => {
// 聚焦到搜索区域
const searchSection = document.querySelector('.search-section')
if (searchSection) {
searchSection.scrollIntoView({ behavior: 'smooth' })
}
}
const handleTableRefresh = () => {
// 刷新用户列表
fetchUsers()
}
const handleTableExport = () => {
// 导出用户数据
console.log('导出用户数据')
alert('导出功能开发中...')
}
const handleTableViewToggle = () => {
// 切换视图模式
console.log('切换视图模式')
alert('视图切换功能开发中...')
}
onMounted(() => {
console.log('用户管理页面已加载')
fetchRoles() // 先获取角色列表
fetchUsers()
})
</script>
<style scoped>
.users-container {
background: #f5f5f5;
min-height: 100vh;
padding: 0;
}
/* 顶部导航栏 */
.top-nav {
background: white;
border-bottom: 1px solid #e0e0e0;
padding: 4px 12px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
min-height: 36px;
}
.nav-left {
display: flex;
align-items: center;
gap: 8px;
}
.nav-toggle {
background: none;
border: none;
font-size: 14px;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-toggle:hover {
background: #f0f0f0;
}
.nav-tabs {
display: flex;
gap: 2px;
}
.nav-tab {
padding: 4px 8px;
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 3px;
font-size: 11px;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
height: 24px;
}
.nav-tab.active {
background: #1890ff;
color: white;
border-color: #1890ff;
}
.close-icon {
font-size: 12px;
cursor: pointer;
opacity: 0.7;
margin-left: 4px;
padding: 1px 3px;
border-radius: 2px;
transition: all 0.2s;
}
.close-icon:hover {
opacity: 1;
color: #ff4d4f;
background: #fff2f0;
}
.refresh-btn {
background: #1890ff;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
}
/* 搜索筛选区域 */
.search-section {
background: white;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.search-row {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
}
.search-item label {
font-size: 12px;
color: #333;
white-space: nowrap;
}
.search-input, .search-select, .date-input {
padding: 6px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
width: 120px;
}
.search-input:focus, .search-select:focus, .date-input:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.date-range {
display: flex;
align-items: center;
gap: 8px;
}
.date-separator {
color: #666;
font-size: 12px;
}
/* 时间选择器容器 */
.date-picker-container {
position: relative;
display: inline-block;
}
/* 时间选项下拉菜单 */
.date-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 120px;
}
.date-option {
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: #333;
transition: background-color 0.2s;
border-bottom: 1px solid #f0f0f0;
}
.date-option:last-child {
border-bottom: none;
}
.date-option:hover {
background-color: #f5f5f5;
color: #1890ff;
}
.date-option:active {
background-color: #e6f7ff;
}
/* 自定义日期选择器 */
.custom-date-picker {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
padding: 8px;
}
.custom-date-picker input {
width: 100%;
border: none;
outline: none;
font-size: 12px;
}
.search-actions {
display: flex;
gap: 6px;
}
.search-btn {
background: #1890ff;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
}
.reset-btn {
background: #ff7875;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
height: 24px;
display: flex;
align-items: center;
gap: 4px;
}
/* 操作按钮区域 */
.action-section {
background: white;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
height: 24px;
}
.action-btn.primary {
background: #1890ff;
color: white;
}
.action-btn.success {
background: #52c41a;
color: white;
}
.action-btn.danger {
background: #ff4d4f;
color: white;
}
.action-btn.info {
background: #13c2c2;
color: white;
}
.action-btn.warning {
background: #faad14;
color: white;
}
/* 表格区域 */
.table-section {
background: white;
margin: 16px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.table-header {
padding: 8px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
}
.table-controls {
display: flex;
gap: 4px;
}
.control-btn {
background: white;
border: 1px solid #d9d9d9;
border-radius: 3px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.control-btn:hover {
background: #f0f8ff;
border-color: #1890ff;
color: #1890ff;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
}
.control-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(24, 144, 255, 0.2);
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
}
.data-table th {
background: #fafafa;
font-weight: 600;
color: #333;
}
.data-table tbody tr:hover {
background: #f5f5f5;
}
.select-all, .row-checkbox {
margin: 0;
}
.sortable {
cursor: pointer;
position: relative;
}
.sortable:hover {
background: #f0f0f0;
}
.user-link {
color: #1890ff;
text-decoration: none;
}
.user-link:hover {
text-decoration: underline;
}
/* 状态开关 */
.status-switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.status-switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 20px;
}
.switch-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .switch-slider {
background-color: #13c2c2;
}
input:checked + .switch-slider:before {
transform: translateX(20px);
}
/* 表格操作按钮 */
.table-btn {
padding: 2px 6px;
margin-right: 4px;
border: none;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 2px;
}
.table-btn.edit {
background: #1890ff;
color: white;
}
.table-btn.delete {
background: #ff4d4f;
color: white;
}
/* 表格底部样式已移至分页控制样式 */
.pagination-info {
font-size: 12px;
color: #666;
}
/* 对话框样式 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: white;
border-radius: 8px;
width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
padding: 4px;
border-radius: 4px;
}
.close-btn:hover {
background: #f0f0f0;
}
.dialog-body {
padding: 20px;
}
.form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.form-group {
flex: 1;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #333;
font-weight: 500;
}
.required-label {
color: #333;
font-weight: 500;
}
.required-asterisk {
color: #ff4d4f;
font-weight: bold;
margin-left: 2px;
}
.form-hint {
display: block;
margin-top: 4px;
font-size: 11px;
color: #999;
font-style: italic;
}
.form-input, .form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
box-sizing: border-box;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
/* 错误状态样式 */
.form-input.error, .form-select.error {
border-color: #ff4d4f;
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2);
}
.field-error {
color: #ff4d4f;
font-size: 11px;
margin-top: 4px;
display: block;
line-height: 1.4;
}
/* 多角色选择样式 */
.role-select {
min-height: 80px;
max-height: 120px;
}
.role-select option {
padding: 4px 8px;
}
.role-select option:checked {
background-color: #e6f7ff;
color: #1890ff;
font-weight: 500;
}
/* 已选择角色显示样式 */
.selected-roles {
margin-top: 8px;
padding: 8px;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
}
.selected-roles-label {
font-size: 11px;
color: #666;
margin-bottom: 6px;
font-weight: 500;
}
.role-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.role-tag {
display: inline-block;
padding: 2px 8px;
background: #1890ff;
color: white;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
/* 刷新角色按钮样式 */
.refresh-roles-btn {
margin-left: 8px;
padding: 2px 6px;
background: #52c41a;
color: white;
border: none;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
transition: all 0.3s;
}
.refresh-roles-btn:hover {
background: #73d13d;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
}
.btn-cancel, .btn-save {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
border: 1px solid #d9d9d9;
}
.btn-cancel:hover {
background: #e6f7ff;
border-color: #91d5ff;
}
.btn-save {
background: #1890ff;
color: white;
}
.btn-save:hover:not(:disabled) {
background: #40a9ff;
}
.btn-save:disabled {
background: #ccc;
cursor: not-allowed;
}
/* 分页控制样式 */
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #fafafa;
border-top: 1px solid #e0e0e0;
}
.pagination-left {
display: flex;
align-items: center;
gap: 20px;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
}
.page-size-selector label {
font-size: 12px;
color: #666;
}
.page-size-select {
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
background: white;
cursor: pointer;
transition: border-color 0.3s;
}
.page-size-select:hover {
border-color: #1890ff;
}
.page-size-select:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
/* 新的分页容器样式 - 模拟截图样式 */
.pagination-container {
display: flex;
align-items: center;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.pagination-btn {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
height: 20px;
}
.pagination-btn:hover:not(:disabled) {
background: #e9ecef;
color: #495057;
}
.pagination-btn:disabled {
background: #f8f9fa;
color: #adb5bd;
cursor: not-allowed;
}
.pagination-pages {
display: flex;
align-items: center;
}
.page-number {
padding: 2px 6px;
border: none;
background: white;
color: #6c757d;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-right: 1px solid #e9ecef;
min-width: 28px;
text-align: center;
height: 20px;
}
.page-number:hover {
background: #e9ecef;
color: #495057;
}
.page-number.active {
background: #6c757d;
color: white;
font-weight: 600;
}
.page-number.active:hover {
background: #5a6268;
}
/* 加载状态样式 */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-spinner {
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 14px;
color: #666;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-row {
flex-direction: column;
align-items: stretch;
}
.search-item {
width: 100%;
}
.search-input, .search-select, .date-input {
width: 100%;
}
.action-buttons {
flex-wrap: wrap;
}
.nav-tabs {
overflow-x: auto;
}
.dialog-content {
width: 90%;
margin: 20px;
}
.form-row {
flex-direction: column;
gap: 0;
}
/* 分页组件响应式 */
.table-footer {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.pagination-left {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.pagination-container {
justify-content: center;
flex-wrap: wrap;
}
.pagination-btn {
padding: 2px 4px;
font-size: 10px;
height: 20px;
}
.page-number {
padding: 2px 4px;
font-size: 10px;
min-width: 24px;
height: 20px;
}
}
</style>
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
......
# 字段验证错误显示功能说明
## 功能概述
前端用户管理页面现在支持显示后端返回的详细字段验证错误信息,提供更好的用户体验。
## 功能特性
### 1. 字段级错误显示
- 当后端返回 `400` 状态码和详细的字段错误信息时,前端会自动解析并在对应字段下方显示错误信息
- 支持的错误格式:`{"code":400,"message":"参数验证失败","data":{"email":"邮箱格式不正确"}}`
### 2. 视觉反馈
- 有错误的字段会显示红色边框
- 错误信息以红色文字显示在字段下方
- 错误信息字体较小,不影响整体布局
### 3. 错误清除机制
- 打开新增/编辑对话框时自动清除之前的错误信息
- 重新提交表单时清除之前的错误信息
## 支持的字段
以下字段支持错误显示:
- `username` - 用户名
- `realName` - 真实姓名
- `phone` - 手机号
- `email` - 邮箱
- `remark` - 备注
## 后端错误格式要求
后端需要返回以下格式的错误信息:
```json
{
"code": 400,
"message": "参数验证失败",
"data": {
"username": "用户名已存在",
"email": "邮箱格式不正确",
"phone": "手机号格式不正确"
}
}
```
## 使用示例
### 1. 邮箱格式错误
当用户输入无效邮箱时:
- 邮箱输入框显示红色边框
- 输入框下方显示"邮箱格式不正确"
### 2. 用户名重复
当用户名已存在时:
- 用户名输入框显示红色边框
- 输入框下方显示"用户名已存在"
### 3. 多个字段错误
当多个字段都有错误时:
- 所有有错误的字段都会显示红色边框
- 每个字段下方显示对应的错误信息
## 技术实现
### 1. 响应式错误状态
```javascript
const fieldErrors = reactive<Record<string, string>>({})
```
### 2. 错误解析函数
```javascript
const parseFieldErrors = (errorData: any) => {
clearFieldErrors()
if (errorData && typeof errorData === 'object') {
Object.keys(errorData).forEach(field => {
fieldErrors[field] = errorData[field]
})
}
}
```
### 3. 模板绑定
```vue
<input
v-model="userForm.email"
type="email"
class="form-input"
:class="{ 'error': fieldErrors.email }"
/>
<div v-if="fieldErrors.email" class="field-error">{{ fieldErrors.email }}</div>
```
## 样式说明
### 错误状态样式
```css
.form-input.error, .form-select.error {
border-color: #ff4d4f;
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2);
}
.field-error {
color: #ff4d4f;
font-size: 11px;
margin-top: 4px;
display: block;
line-height: 1.4;
}
```
## 测试方法
1. 打开用户管理页面
2. 点击"新增用户"按钮
3. 输入无效的邮箱格式(如:`invalid-email`
4. 点击保存
5. 观察邮箱字段是否显示红色边框和错误信息
## 注意事项
1. 错误信息会在用户重新打开对话框时自动清除
2. 只有后端返回 `400` 状态码且包含 `data` 字段时才会显示字段级错误
3. 如果后端返回其他格式的错误,仍会显示通用错误信息
4. 错误信息支持中文,建议后端返回中文错误信息以提供更好的用户体验
# 用户管理时间格式修改说明
## 修改内容
将用户管理列表中的创建时间格式从 ISO 格式改为更易读的格式:
- **修改前**: `2025-09-23T17:49:06`
- **修改后**: `2025-09-23 17:49:06`
## 技术实现
### 1. 添加时间格式化函数
```javascript
// 格式化时间显示
const formatDateTime = (dateTime: string | undefined): string => {
if (!dateTime) return ''
// 将 ISO 格式的时间字符串转换为可读格式
// 例如: 2025-09-23T17:49:06 -> 2025-09-23 17:49:06
return dateTime.replace('T', ' ')
}
```
### 2. 修改模板显示
```vue
<!-- 修改前 -->
<td>{{ user.createTime }}</td>
<!-- 修改后 -->
<td>{{ formatDateTime(user.createTime) }}</td>
```
## 功能特点
1. **空值处理**: 如果时间字段为空或未定义,返回空字符串
2. **简单替换**: 使用 `replace('T', ' ')` 将 ISO 格式中的 `T` 替换为空格
3. **类型安全**: 使用 TypeScript 确保类型安全
4. **性能优化**: 简单的字符串替换,性能开销很小
## 影响范围
- **用户管理列表**: 创建时间列显示格式已修改
- **其他时间字段**: 目前只修改了 `createTime` 字段
- **搜索功能**: 时间搜索功能不受影响
## 测试方法
1. 打开用户管理页面
2. 查看用户列表中的"创建时间"列
3. 确认时间格式显示为 `2025-09-23 17:49:06` 而不是 `2025-09-23T17:49:06`
## 扩展说明
如果需要修改其他时间字段(如 `updateTime`),可以按照相同的方式:
```vue
<td>{{ formatDateTime(user.updateTime) }}</td>
```
## 注意事项
1. 这个修改只影响前端显示,不影响后端数据存储格式
2. 时间搜索功能仍然使用原始的 ISO 格式
3. 如果后端返回的时间格式发生变化,这个函数仍然可以正常工作
# 时间选择器功能说明
## 功能概述
用户管理页面的时间搜索组件已升级为具有悬停弹出选项功能的高级时间选择器,提供更便捷的时间范围选择体验。
## 功能特性
### 1. 悬停弹出选项
- 鼠标悬停在时间输入框上时,自动弹出时间选项菜单
- 支持快速选择常用的时间范围
- 提供直观的视觉反馈
### 2. 预设时间选项
- **今天**: 选择当前日期
- **昨天**: 选择昨天的日期
- **本周**: 选择本周开始日期(周一)
- **上周**: 选择上周的时间范围
- **本月**: 选择本月开始日期
- **上月**: 选择上月的时间范围
- **自定义**: 打开原生日期选择器
### 3. 智能时间范围
- 开始时间和结束时间都有独立的选项菜单
- 结束时间会自动选择合理的结束日期
- 支持时间范围的逻辑验证
## 技术实现
### 1. 组件结构
```vue
<div class="date-picker-container">
<input
v-model="searchForm.startTime"
type="text"
class="date-input"
readonly
@mouseenter="showStartDateOptions = true"
@mouseleave="hideStartDateOptions"
/>
<div v-if="showStartDateOptions" class="date-options">
<!-- 时间选项列表 -->
</div>
</div>
```
### 2. 状态管理
```javascript
// 时间选择器状态
const showStartDateOptions = ref(false)
const showEndDateOptions = ref(false)
const showStartDatePicker = ref(false)
const showEndDatePicker = ref(false)
```
### 3. 时间计算函数
- `getCurrentDate()`: 获取当前日期
- `getDateBefore(days)`: 获取指定天数前的日期
- `getWeekStart()`: 获取本周开始日期
- `getMonthStart()`: 获取本月开始日期
- `getLastMonthStart()`: 获取上月开始日期
- `getLastMonthEnd()`: 获取上月结束日期
## 使用方法
### 1. 快速选择时间
1. 将鼠标悬停在"开始时间"或"结束时间"输入框上
2. 在弹出的选项菜单中选择所需的时间范围
3. 系统自动填充对应的日期
### 2. 自定义时间选择
1. 悬停在时间输入框上
2. 选择"自定义"选项
3. 使用原生日期选择器选择具体日期
### 3. 时间范围搜索
1. 选择开始时间和结束时间
2. 点击"搜索"按钮执行查询
3. 系统会搜索指定时间范围内的用户
## 样式设计
### 1. 选项菜单样式
```css
.date-options {
position: absolute;
top: 100%;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
```
### 2. 选项项样式
```css
.date-option {
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.date-option:hover {
background-color: #f5f5f5;
color: #1890ff;
}
```
## 交互逻辑
### 1. 悬停显示
- 鼠标进入输入框时显示选项菜单
- 鼠标离开输入框时延迟隐藏菜单(200ms)
- 鼠标进入选项菜单时保持显示状态
### 2. 选择处理
- 点击选项后自动填充对应日期
- 选择"自定义"时显示原生日期选择器
- 选择完成后自动隐藏选项菜单
### 3. 时间范围逻辑
- 开始时间不能晚于结束时间
- 结束时间不能早于开始时间
- 支持空值选择(只选择开始时间或结束时间)
## 兼容性
- 支持现代浏览器的原生日期选择器
- 降级到文本输入框(readonly)
- 响应式设计,适配不同屏幕尺寸
## 扩展功能
### 1. 可添加的选项
- 最近7天
- 最近30天
- 最近3个月
- 最近一年
### 2. 可优化的功能
- 时间范围验证
- 快捷清除功能
- 时间格式显示优化
## 测试方法
1. 打开用户管理页面
2. 将鼠标悬停在"创建时间"的输入框上
3. 观察是否弹出选项菜单
4. 点击不同选项测试功能
5. 选择"自定义"测试原生日期选择器
6. 执行搜索验证时间范围功能
## 注意事项
1. 时间格式统一使用 YYYY-MM-DD 格式
2. 时区处理基于浏览器本地时间
3. 选项菜单的 z-index 设置为 1000,确保显示在最上层
4. 延迟隐藏机制避免鼠标快速移动时菜单闪烁