refactor: 系统权限模块重构,迁移至PostgreSQL并对齐新设计

- 切换数据库为PostgreSQL,更新依赖与配置
- 重构权限体系,使用sys_user/sys_role/sys_menu等新实体替代旧模型
- 移除旧的用户角色/权限/系统设置相关代码与实体
- 新增字典、菜单管理模块与基础CRUD接口
- 优化鉴权逻辑,基于菜单权限标识进行接口校验
- 前端同步更新API、路由与权限控制逻辑
- 删除冗余的v-permission指令与相关代码
This commit is contained in:
andyesfly committed 2026-08-19 14:11:34 +08:00
1 parent 98c22f6afb
commit 0a8103f33d
67 files changed
+3221 -1882

No files matched your search

@@ -0,0 +1,47 @@
# Checklist - 系统管理与认证授权模块对齐新设计
## 数据库与配置检查
- [x] server 已安装 pg / pg-hstore 依赖
- [x] config.default.ts 使用 PostgreSQL(dialect=postgres、port 5432、database=xintong)
- [x] database/db_init.sql 在 PostgreSQL 中执行成功
- [x] 初始数据齐全:4 角色、11 条菜单(6 顶级 + 5 系统子菜单)、20 条字典、admin 账户、角色菜单绑定
- [x] admin 账户密码为 admin123456(bcrypt hash 正确)
## 后端实体与接口检查
- [x] 7 个系统域实体(sysUser/sysRole/sysUserRole/sysMenu/sysRoleMenu/sysDict/sysOperationLog)创建成功且表名正确
- [x] 旧实体(user/role/userRole/permission/rolePermission/operationLog)已删除
- [x] 登录接口基于 sys_user 校验,返回 userInfo/roles/menus
- [x] 刷新 Token 与当前用户接口适配新模型
- [x] RbacMiddleware 基于菜单 permission 标识鉴权,ADMIN 放行
- [x] 用户管理接口字段为 realName/phone/email,返回不包含密码哈希
- [x] 角色管理接口字段为 roleCode/roleName/sortOrder
- [x] 角色菜单配置接口(/roles/:id/menus)读写 sys_role_menu 正确
- [x] 菜单管理接口:菜单树查询与 CRUD 正常
- [x] 数据字典接口:列表/类型筛选/CRUD 正常(修复 types() 的 PostgreSQL GROUP BY/ORDER BY SQL 问题)
- [x] 操作日志接口与中间件使用新字段(user_id/module/action/method/params/ip/created_at)
- [x] system.controller.ts / system.service.ts 已删除,无残留引用
## 前端检查
- [x] api/auth.ts 返回类型含 realName/roles/menus
- [x] api/user.ts、api/role.ts 字段更新,权限接口改为菜单接口
- [x] api/menu.ts、api/dict.ts 新增,api/system.ts 已删除
- [x] stores/permission.ts 管理用户信息与菜单树(login 完整写入 roles/menus)
- [x] 侧边栏菜单由用户菜单树动态渲染(含图标、两级结构)
- [x] 路由改为静态全量注册,守卫按菜单权限标识控制;未开发模块显示占位页(替代原动态路由注册方案,修复深层刷新跳登录问题)
- [x] 设置页路由与菜单已移除
- [x] 用户管理页字段为姓名/手机/邮箱
- [x] 角色管理页权限配置为菜单树勾选
- [x] 菜单管理页(树形表格 CRUD)可用
- [x] 数据字典页(列表+类型筛选 CRUD)可用
- [x] 操作日志页字段与筛选更新
- [x] v-permission 指令及引用已移除
- [x] views/settings 目录已删除
## 联调与边界检查
- [x] admin 登录 → 仪表盘 → 系统管理各子页面全流程走通(含深层链接整页刷新)
- [x] 业务员角色仅显示授权菜单,访问未授权接口返回 403(SALESMAN/WORKER 实测 403)
- [x] 写操作自动记录操作日志且可查询
- [x] 无菜单角色登录不报错,侧边栏仅显示仪表盘(WORKER 实测 menus=0)
- [x] 删除被占用角色被拦截
- [x] 重复用户名/角色编码校验生效
- [x] yarn build(server/client)无 TypeScript 编译错误
+125
View File
@@ -0,0 +1,125 @@
# 系统管理与认证授权模块对齐新设计 Spec
## Why
系统设计已完成调整(鑫潼智电系统):数据库从 MySQL 6 表升级为 PostgreSQL 37 表;系统管理域改为 7 张 `sys_` 前缀表;RBAC 由"权限码"模型改为"菜单级"模型(`sys_menu`/`sys_role_menu`);新增数据字典(`sys_dict`);原"系统设置"模块在新设计中无对应表。现有系统管理与认证授权代码仍基于旧模型,需要整体对齐新设计。
## What Changes
- **数据库引擎切换**:MySQL → PostgreSQL 15+(连接配置、安装 `pg`/`pg-hstore` 驱动;`database/db_init.sql` 已更新为 PostgreSQL 脚本)
- **系统域数据模型重构**(7 张表,替代原 6 张表):
- `sys_user`:username / password_hash / real_name / phone / email / status / last_login_at(移除 nickname、avatar、last_login_ip)
- `sys_role`:role_code / role_name / description / sort_order(移除 status)
- `sys_user_role`:user_id / role_id(不变)
- `sys_menu`:菜单级 RBAC,menu_type 1目录/2菜单(本次不启用 3按钮),含 menu_path / component / icon / sort_order / permission / visible(替代 permissions 表)
- `sys_role_menu`:role_id / menu_id(替代 role_permissions 表)
- `sys_dict`:**新增**数据字典表
- `sys_operation_log`:字段精简为 user_id / module / action / method / params / ip / created_at(移除 username、url、user_agent、request_params、response_data、status_code、duration、error_message)
- **认证逻辑适配**:登录基于 `sys_user` + `password_hash` 比对,更新 `last_login_at`;登录返回 userInfo(含 realName)、roles(role_code)、menus(角色菜单树)
- **RBAC 重构**:菜单级权限。前端由菜单树驱动侧边栏与路由;后端接口鉴权按"接口路径 → 菜单 permission 标识"校验,ADMIN 角色全量放行;**按钮级权限暂不保留**(v-permission 指令移除)
- **系统设置模块移除**:删除前后端系统设置代码(settings 页面、system.controller/system.service、api/system.ts)
- **新增菜单管理模块**:sys_menu 树形 CRUD(目录/菜单两级)
- **新增数据字典模块**:sys_dict CRUD 与类型分组查询
- **操作日志对齐**:实体、中间件、接口、前端页面字段更新
- **初始数据补齐**:4 个角色(ADMIN/SALESMAN/WORKER/CUSTOMER)、6 个顶级业务菜单 + 5 个系统管理子菜单、19 条字典数据、管理员账户 admin/admin123456(ADMIN 角色)及角色-菜单绑定
## Impact
- Affected specs: smart-grid-iot-admin(已完成项目,本次为增量调整)
- Affected code:
- server:config/config.default.ts、package.json、entity 全部系统域实体、controller(auth/user/role/log/menu/dict)、service(auth/user/role/log/menu/dict)、middleware(auth/rbac/log)、dto
- client:api(auth/user/role/log/menu/dict)、stores/permission.ts、router/index.ts、layouts/index.vue、views(system/user、system/role、log/operation、新增 system/menu、system/dict)、删除 views/settings
- database/db_init.sql:补充初始数据(管理员账户、角色菜单绑定、系统管理子菜单)
## ADDED Requirements
### Requirement: PostgreSQL 数据库连接
系统 SHALL 使用 PostgreSQL 15+ 作为数据库:
- server 依赖安装 `pg`、`pg-hstore`
- `config.default.ts` 的 sequelize 数据源 dialect 为 `postgres`,连接 xintong 库
- 系统域 7 张表通过 `database/db_init.sql` 创建(Sequelize `sync: false`)
#### Scenario: 服务连接 PostgreSQL 成功
- **WHEN** 启动后端服务且本机已按 db_init.sql 建好库表
- **THEN** 服务正常启动,登录、查询等接口可正常读写 sys_ 系列表
### Requirement: 菜单级 RBAC(sys_menu)
系统 SHALL 以菜单为权限载体:
- `sys_menu` 支持两级树:目录(menu_type=1)、菜单(menu_type=2);字段含 menu_path、component、icon、sort_order、permission、visible
- 用户登录返回其角色(经 `sys_role_menu`)关联的菜单树;前端据此渲染侧边栏与动态路由
- 后端接口鉴权:RbacMiddleware 将请求路径映射为权限标识,校验用户角色关联菜单的 permission 集合;ADMIN 角色(role_code=ADMIN)直接放行
- 本次不做按钮级权限(menu_type=3、v-permission 指令移除)
#### Scenario: 登录用户仅看到被授权菜单
- **WHEN** 业务员角色登录
- **THEN** 侧边栏只渲染其角色已绑定的菜单,访问未授权接口返回 403
#### Scenario: ADMIN 全量访问
- **WHEN** 运营管理员(ADMIN 角色)登录
- **THEN** 可见全部菜单,所有受保护接口放行
### Requirement: 菜单管理模块
系统 SHALL 提供菜单管理能力:
- 菜单树查询(全部菜单,供角色配置使用)
- 目录/菜单的创建、编辑、删除、排序、可见性切换
- 权限标识(permission)维护
### Requirement: 数据字典模块
系统 SHALL 提供数据字典管理能力:
- sys_dict 的创建、编辑、删除
- 按 dict_type 分组查询、状态筛选
### Requirement: 初始数据
系统 SHALL 通过 db_init.sql 初始化:
- 4 个角色:ADMIN(运营管理员)/ SALESMAN(业务员)/ WORKER(共享电工)/ CUSTOMER(客户)
- 6 个顶级业务菜单(客户管理/项目管理/运营派单/设备监控/统计报表/系统管理)与系统管理下 5 个子菜单(用户/角色/菜单/数据字典/操作日志)
- 管理员账户 admin/admin123456(bcrypt hash,角色 ADMIN)
- 角色-菜单绑定:ADMIN 绑定全部菜单;SALESMAN 绑定客户/项目/统计报表等业务菜单;WORKER/CUSTOMER 无后台菜单
- 19 条字典数据(customer_status/equip_type/device_type/dispatch_status/pay_cycle/dispatch_type)
## MODIFIED Requirements
### Requirement: 用户管理模块
The system SHALL 管理 sys_user:
- 列表支持分页、关键词搜索(username/realName/phone)、状态筛选、角色筛选
- 新增/编辑字段:username、password、realName、phone、email、status、角色分配(roleIds)
- 删除、批量删除、状态切换(原能力保留)
- 返回数据不包含 password_hash
#### Scenario: 管理员创建用户
- **WHEN** 管理员填写 username/realName/phone/email/角色并提交
- **THEN** 系统校验唯一性与必填项,bcrypt 加密密码写入 sys_user,分配 sys_user_role,列表刷新
### Requirement: 角色管理模块
The system SHALL 管理 sys_role:
- 列表支持分页、关键词搜索(roleCode/roleName)、关联用户数统计
- 新增/编辑字段:roleCode、roleName、description、sortOrder
- 权限配置由"权限码树"改为"菜单树勾选"(sys_role_menu)
- 删除时若关联用户则禁止删除
### Requirement: 操作日志模块
The system SHALL 记录操作日志:
- 字段:user_id、module、action、method、params、ip、created_at
- 日志中间件按新字段写入,仅记录写操作(POST/PUT/DELETE)
- 查询接口支持按操作人/模块/时间范围筛选
- 前端日志页字段与接口对齐
### Requirement: 认证授权服务
The system SHALL 适配新用户模型:
- 登录:username + password 校验(sys_user.password_hash)、禁用校验、更新 last_login_at
- 登录返回:accessToken、refreshToken、userInfo(id/username/realName)、roles(role_code 数组)、menus(角色菜单树)
- 刷新 Token、当前用户信息接口同步适配
### Requirement: 前端动态菜单与路由
The system SHALL 由菜单树驱动前端:
- 侧边栏菜单由用户 menus 渲染(目录→子菜单两级,含图标)
- 动态路由按 sys_menu.menu_path/component 注册;component 缺失(未开发业务模块)时渲染"功能开发中"占位页
- 登录跳转仪表盘(仪表盘保持固定路由,不纳入菜单表)
- 移除设置页相关路由与菜单
## REMOVED Requirements
### Requirement: 系统设置模块(站点名称/Logo/会话超时/密码策略/IP白名单)
**Reason**: 新设计无对应表,模块整体移除。
**Migration**: 删除 server 端 system.controller.ts/system.service.ts、client 端 views/settings 页面与 api/system.ts,以及相关路由、菜单、日志映射与中间件引用。
### Requirement: 权限码 RBAC 与按钮级权限(permissions/role_permissions + v-permission)
**Reason**: 新设计采用菜单级 RBAC,按钮级权限暂不保留。
**Migration**: 删除 permissions/role_permissions 相关实体、服务与 DTO,由 sys_menu/sys_role_menu 替代;移除前端 v-permission 指令及按钮级权限判断;路由 meta.permission 改为基于菜单 permission 标识。
@@ -0,0 +1,107 @@
# Tasks - 系统管理与认证授权模块对齐新设计
## 阶段一:数据库与基础配置
- [x] Task 1: 切换数据库引擎为 PostgreSQL
- [x] 在 server/package.json 安装 `pg`、`pg-hstore` 依赖
- [x] 修改 server/src/config/config.default.ts:sequelize dialect 改为 `postgres`,host/port(5432)/database(xintong)/username/password 环境变量(默认 postgres)
- [x] 检查 config.local.ts 的 sync 配置合理性(表由 SQL 脚本创建,保持 sync: false 或按需调整)
- [x] 验证:yarn build 通过,服务可连接 PostgreSQL
- [x] Task 2: 补充 database/db_init.sql 初始数据
- [x] 补充 sys_menu 系统管理目录下的 5 个子菜单(用户管理/角色管理/菜单管理/数据字典/操作日志,含 menu_path/component/permission/sort_order)
- [x] 补充 sys_user:admin/admin123456(用 Node bcryptjs 生成 hash 填入 password_hash,real_name=运营管理员)
- [x] 补充 sys_user_role:admin → ADMIN 角色
- [x] 补充 sys_role_menu:ADMIN 绑定全部菜单;SALESMAN 绑定客户/项目/统计报表等业务菜单
- [x] 验证:在 PostgreSQL 中执行脚本无报错,各表初始数据条数正确
## 阶段二:后端重构
- [x] Task 3: 重构系统域实体层(7 个实体)
- [x] 新建 sysUser.entity.ts(sys_user:username/password_hash/real_name/phone/email/status/last_login_at)
- [x] 新建 sysRole.entity.ts(sys_role:role_code/role_name/description/sort_order)
- [x] 新建 sysUserRole.entity.ts(sys_user_role)
- [x] 新建 sysMenu.entity.ts(sys_menu:parent_id/menu_name/menu_path/component/icon/sort_order/menu_type/permission/visible)
- [x] 新建 sysRoleMenu.entity.ts(sys_role_menu)
- [x] 新建 sysDict.entity.ts(sys_dict:dict_type/dict_label/dict_value/sort_order/status)
- [x] 新建 sysOperationLog.entity.ts(sys_operation_log:user_id/module/action/method/params/ip/created_at)
- [x] 删除旧实体 user/role/userRole/permission/rolePermission/operationLog 的 .entity.ts 文件
- [x] 验证:yarn build 通过
- [x] Task 4: 重构认证与 RBAC
- [x] auth.service.ts:登录校验 sys_user.password_hash,更新 last_login_at;返回 accessToken/refreshToken/userInfo(id/username/realName)/roles(role_code 数组)/menus(菜单树)
- [x] auth.controller.ts:登录/刷新/登出/当前用户接口适配
- [x] 新增菜单查询能力:permission.service(或新 menu.service)提供 getUserMenus(userId)(角色经 sys_role_menu 关联 sys_menu 的树)与 checkUserPermission(userId, code)
- [x] rbac.middleware.ts:路径→权限标识映射改为基于 sys_menu.permission;ADMIN 角色放行;未授权返回 403
- [x] 验证:admin 登录返回 menus;无权限角色访问受保护接口返回 403
- [x] Task 5: 重构用户管理
- [x] user.service.ts:字段适配 real_name/phone/email(删除 nickname/avatar/last_login_ip),搜索条件更新,返回数据剔除 password_hash
- [x] user.controller.ts:接口保持不变,入参字段对齐
- [x] dto/user.dto.ts:字段更新(realName/phone/email/roleIds)
- [x] 验证:用户 CRUD 与角色分配联调通过
- [x] Task 6: 重构角色管理
- [x] role.service.ts:字段适配 role_code/role_name/sort_order(删除 status);权限接口改为菜单(getRoleMenus 返回角色已勾选菜单树、updateRoleMenus 批量写 sys_role_menu)
- [x] role.controller.ts:权限相关接口改为 `/roles/:id/menus`
- [x] dto/role.dto.ts:字段更新
- [x] 验证:角色列表、菜单勾选保存联调通过
- [x] Task 7: 新增菜单管理模块
- [x] 新建 menu.controller.ts + menu.service.ts:菜单树查询(含全部菜单供角色配置)、创建/编辑/删除目录或菜单、排序与可见性维护
- [x] 新建 dto/menu.dto.ts
- [x] 验证:菜单树 CRUD 联调通过
- [x] Task 8: 新增数据字典模块
- [x] 新建 dict.controller.ts + dict.service.ts:分页列表、按 dict_type 分组查询、创建/编辑/删除
- [x] 新建 dto/dict.dto.ts
- [x] 验证:字典 CRUD 与类型筛选联调通过
- [x] Task 9: 重构操作日志模块
- [x] log.service.ts / log.controller.ts:字段对齐(user_id/module/action/method/params/ip),查询支持按 user/module/时间范围筛选
- [x] log.middleware.ts:按新字段写入(params 存请求体),移除 url/user_agent/status_code/duration/error_message
- [x] 验证:写操作自动记录日志,查询接口正常
- [x] Task 10: 移除系统设置模块
- [x] 删除 system.controller.ts / system.service.ts
- [x] 清理 rbac/log 中间件及配置中 settings 相关映射
- [x] 验证:yarn build 通过,无残留引用
## 阶段三:前端重构
- [x] Task 11: 前端 API 层适配
- [x] api/auth.ts:登录/当前用户返回类型更新(userInfo.realName、roles、menus)
- [x] api/user.ts:字段更新(realName/phone/email)
- [x] api/role.ts:角色字段更新;权限接口改为菜单接口(getRoleMenus/updateRoleMenus)
- [x] 新增 api/menu.ts、api/dict.ts
- [x] 删除 api/system.ts
- [x] api/log.ts:字段与筛选参数更新
- [x] Task 12: 前端权限 Store 与动态路由
- [x] stores/permission.ts:UserInfo 更新(id/username/realName/roles/menus),提供 menus 状态
- [x] router/index.ts:移除 settings 路由;基于用户 menus 注册动态路由(menu_path→component 映射,缺失组件渲染"功能开发中"占位页);登录跳转仪表盘
- [x] layouts/index.vue:侧边栏菜单由 menus 动态渲染(目录→子菜单两级 + 图标),移除静态菜单与设置分组;用户名显示 realName
- [x] 移除 v-permission 指令(directives/permission.ts)及引用
- [x] 验证:admin 登录后侧边栏显示全部菜单,业务占位页可访问
- [x] Task 13: 前端页面适配
- [x] views/system/user/index.vue:字段改为 realName/phone/email
- [x] views/system/role/index.vue:权限配置弹窗改为菜单树勾选
- [x] 新增 views/system/menu/index.vue(树形表格 CRUD)
- [x] 新增 views/system/dict/index.vue(列表 + 类型筛选 CRUD)
- [x] views/log/operation.vue:字段与筛选更新
- [x] 删除 views/settings/basic.vue、views/settings/security.vue
- [x] 验证:各页面构建通过、交互正常
## 阶段四:联调与验证
- [ ] Task 14: 全流程联调
- [ ] 前端 yarn dev + 后端服务 + PostgreSQL 环境启动
- [ ] admin 登录 → 仪表盘 → 系统管理(用户/角色/菜单/字典/日志)全流程走通
- [ ] 业务员角色仅见授权菜单,未授权接口 403
- [ ] 日志自动记录写操作并可查询
- [ ] 边界检查:无菜单角色登录、删除被占用角色、重复用户名/角色编码
## Task Dependencies
- [Task 2] depends on [Task 1](PG 环境就绪后可执行 SQL)
- [Task 3] 先行;[Task 4-10] depends on [Task 3](实体)
- [Task 11] depends on [Task 5-9](接口就绪)
- [Task 12] depends on [Task 11](API 类型)
- [Task 13] depends on [Task 11]
- [Task 14] depends on [Task 4-13]
+38 -3
View File
@@ -1,15 +1,50 @@
import request from '@/utils/request'; import request from '@/utils/request';
// 菜单节点(内联定义,避免与 api/menu.ts 产生依赖)
interface MenuNode {
id: number;
parentId: number | null;
menuName: string;
menuPath?: string | null;
component?: string | null;
icon?: string | null;
sortOrder: number;
menuType: number; // 1目录 2菜单
permission?: string | null;
visible: number;
children?: MenuNode[];
}
export interface LoginResult {
accessToken: string;
refreshToken: string;
userInfo: {
id: number;
username: string;
realName: string;
};
roles: string[];
menus: MenuNode[];
}
export interface CurrentUserInfo {
id: number;
username: string;
realName: string;
roles: string[];
menus: MenuNode[];
}
export const authApi = { export const authApi = {
login: (data: { username: string; password: string }) => login: (data: { username: string; password: string }) =>
request.post('/auth/login', data), request.post<LoginResult>('/auth/login', data),
logout: () => logout: () =>
request.post('/auth/logout'), request.post('/auth/logout'),
refreshToken: (refreshToken: string) => refreshToken: (refreshToken: string) =>
request.post('/auth/refresh', { refreshToken }), request.post<{ accessToken: string }>('/auth/refresh', { refreshToken }),
getCurrentUser: () => getCurrentUser: () =>
request.get('/auth/currentUser'), request.get<CurrentUserInfo>('/auth/currentUser'),
}; };
+51
View File
@@ -0,0 +1,51 @@
import request from '@/utils/request';
export interface DictItem {
id: number;
dictType: string;
dictLabel: string;
dictValue: string;
sortOrder: number;
status: number;
created_at: string;
}
export interface DictQuery {
page?: number;
pageSize?: number;
dictType?: string;
keyword?: string;
status?: number;
}
export interface DictListResult {
list: DictItem[];
total: number;
page: number;
pageSize: number;
}
export interface DictForm {
dictType: string;
dictLabel: string;
dictValue: string;
sortOrder?: number;
status?: number;
}
export const dictApi = {
getDictList: (params?: DictQuery) =>
request.get<DictListResult>('/dicts', { params }),
getDictTypes: () =>
request.get<string[]>('/dicts/types'),
createDict: (data: DictForm) =>
request.post('/dicts', data),
updateDict: (id: number, data: Partial<DictForm>) =>
request.put(`/dicts/${id}`, data),
deleteDict: (id: number) =>
request.delete(`/dicts/${id}`),
};
+28 -21
View File
@@ -1,36 +1,43 @@
import request from '@/utils/request' import request from '@/utils/request';
export interface LogItem { export interface LogItem {
id: number id: number;
userId?: number userId?: number;
username?: string module?: string;
action: string action: string;
module?: string method: string;
method: string params?: string;
url: string ip: string;
ipAddress: string created_at: string;
statusCode: number
duration: number
createdAt: string
} }
export interface LogQuery { export interface LogQuery {
page?: number page?: number;
pageSize?: number pageSize?: number;
operator?: string userId?: number;
actionType?: string module?: string;
moduleName?: string action?: string;
startDate?: string startDate?: string;
endDate?: string endDate?: string;
}
export interface LogListResult {
list: LogItem[];
total: number;
page: number;
pageSize: number;
} }
export const logApi = { export const logApi = {
getLogList: (params: LogQuery) => getLogList: (params: LogQuery) =>
request.get('/logs', { params }), request.get<LogListResult>('/logs', { params }),
getLogDetail: (id: number) =>
request.get<LogItem>(`/logs/${id}`),
exportLogs: (params: Omit<LogQuery, 'page' | 'pageSize'>) => exportLogs: (params: Omit<LogQuery, 'page' | 'pageSize'>) =>
request.get('/logs/export', { request.get('/logs/export', {
params, params,
responseType: 'blob', responseType: 'blob',
}), }),
} };
+42
View File
@@ -0,0 +1,42 @@
import request from '@/utils/request';
// 菜单节点
export interface MenuNode {
id: number;
parentId: number | null;
menuName: string;
menuPath?: string | null;
component?: string | null;
icon?: string | null;
sortOrder: number;
menuType: number; // 1目录 2菜单
permission?: string | null;
visible: number;
children?: MenuNode[];
}
export interface MenuForm {
parentId?: number;
menuName: string;
menuPath?: string;
component?: string;
icon?: string;
sortOrder?: number;
menuType: number;
permission?: string;
visible?: number;
}
export const menuApi = {
getMenuTree: () =>
request.get<MenuNode[]>('/menus/tree'),
createMenu: (data: MenuForm) =>
request.post('/menus', data),
updateMenu: (id: number, data: Partial<MenuForm>) =>
request.put(`/menus/${id}`, data),
deleteMenu: (id: number) =>
request.delete(`/menus/${id}`),
};
+20 -24
View File
@@ -1,31 +1,22 @@
import request from '@/utils/request'; import request from '@/utils/request';
import type { MenuNode } from './menu';
export interface RoleInfo { export interface RoleInfo {
id: number; id: number;
name: string; roleCode: string;
code: string; roleName: string;
description?: string; description?: string;
status: number;
sortOrder: number; sortOrder: number;
userCount: number; userCount: number;
permissions?: any[];
created_at: string; created_at: string;
updated_at: string;
} }
export interface PermissionTreeNode { export interface SysRole {
id: number; id: number;
name: string; roleCode: string;
code: string; roleName: string;
type: string; description?: string;
parent_id?: number | null; sortOrder?: number;
sortOrder: number;
icon?: string;
route?: string;
component?: string;
status: number;
children?: PermissionTreeNode[];
checked?: boolean;
} }
export interface RoleListResult { export interface RoleListResult {
@@ -35,28 +26,33 @@ export interface RoleListResult {
pageSize: number; pageSize: number;
} }
export interface RoleMenusResult {
menuTree: MenuNode[];
menuIds: number[];
}
export const roleApi = { export const roleApi = {
getRoleList: (params?: { page?: number; pageSize?: number; keyword?: string }) => getRoleList: (params?: { page?: number; pageSize?: number; keyword?: string }) =>
request.get<RoleListResult>('/roles', { params }), request.get<RoleListResult>('/roles', { params }),
getAllRoles: () => getAllRoles: () =>
request.get<RoleInfo[]>('/roles/all'), request.get<SysRole[]>('/roles/all'),
getRoleDetail: (id: number) => getRoleDetail: (id: number) =>
request.get<RoleInfo>(`/roles/${id}`), request.get<RoleInfo>(`/roles/${id}`),
createRole: (data: { name: string; code: string; description?: string; status?: number; sortOrder?: number }) => createRole: (data: { roleCode: string; roleName: string; description?: string; sortOrder?: number }) =>
request.post('/roles', data), request.post('/roles', data),
updateRole: (id: number, data: { name?: string; description?: string; status?: number; sortOrder?: number }) => updateRole: (id: number, data: { roleCode?: string; roleName?: string; description?: string; sortOrder?: number }) =>
request.put(`/roles/${id}`, data), request.put(`/roles/${id}`, data),
deleteRole: (id: number) => deleteRole: (id: number) =>
request.delete(`/roles/${id}`), request.delete(`/roles/${id}`),
getRolePermissions: (id: number) => getRoleMenus: (id: number) =>
request.get<PermissionTreeNode[]>(`/roles/${id}/permissions`), request.get<RoleMenusResult>(`/roles/${id}/menus`),
updateRolePermissions: (id: number, permissionIds: number[]) => updateRoleMenus: (id: number, menuIds: number[]) =>
request.put(`/roles/${id}/permissions`, { permissionIds }), request.put(`/roles/${id}/menus`, { menuIds }),
}; };
-39
View File
@@ -1,39 +0,0 @@
import request from '@/utils/request';
export interface BasicSettings {
siteName: string;
logo: string;
sessionTimeout: number;
}
export interface SecuritySettings {
passwordMinLength: number;
maxLoginAttempts: number;
lockoutDuration: number;
ipWhitelist: string[];
}
export interface NotificationSettings {
enableEmail: boolean;
emailSmtpConfig: any;
}
export interface SystemSettings {
basic: BasicSettings;
security: SecuritySettings;
notification: NotificationSettings;
}
export const systemApi = {
getSettings: () =>
request.get<SystemSettings>('/settings'),
updateBasicSettings: (data: Partial<BasicSettings>) =>
request.put('/settings/basic', data),
updateSecuritySettings: (data: Partial<SecuritySettings>) =>
request.put('/settings/security', data),
updateNotificationSettings: (data: Partial<NotificationSettings>) =>
request.put('/settings/notification', data),
};
+29 -21
View File
@@ -3,30 +3,24 @@ import request from '@/utils/request';
export interface UserInfo { export interface UserInfo {
id: number; id: number;
username: string; username: string;
nickname: string; realName: string;
email?: string; email?: string;
phone?: string; phone?: string;
avatar?: string;
status: number; status: number;
lastLoginAt: string | null;
userRoles?: UserRoleInfo[]; userRoles?: UserRoleInfo[];
created_at: string; created_at: string;
updated_at: string;
} }
export interface UserRoleInfo { export interface UserRoleInfo {
id: number;
userId: number;
roleId: number; roleId: number;
role?: RoleInfo; role?: RoleInfo;
} }
export interface RoleInfo { export interface RoleInfo {
id: number; id: number;
name: string; roleCode: string;
code: string; roleName: string;
description?: string;
status: number;
sortOrder: number;
} }
export interface UserQuery { export interface UserQuery {
@@ -37,10 +31,17 @@ export interface UserQuery {
roleId?: number; roleId?: number;
} }
export interface UserListResult {
list: UserInfo[];
total: number;
page: number;
pageSize: number;
}
export interface CreateUserParams { export interface CreateUserParams {
username: string; username: string;
password: string; password: string;
nickname: string; realName: string;
email?: string; email?: string;
phone?: string; phone?: string;
roleIds: number[]; roleIds: number[];
@@ -48,29 +49,36 @@ export interface CreateUserParams {
} }
export interface UpdateUserParams { export interface UpdateUserParams {
nickname?: string; realName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
avatar?: string;
roleIds?: number[]; roleIds?: number[];
status?: number; status?: number;
password?: string; password?: string;
} }
export const userApi = { export const userApi = {
getUserList: (params: UserQuery) => request.get('/users', { params }), getUserList: (params: UserQuery) =>
request.get<UserListResult>('/users', { params }),
getRoleList: () => request.get<RoleInfo[]>('/users/roles'), getRoleList: () =>
request.get<RoleInfo[]>('/users/roles'),
getUserDetail: (id: number) => request.get<UserInfo>(`/users/${id}`), getUserDetail: (id: number) =>
request.get<UserInfo>(`/users/${id}`),
createUser: (data: CreateUserParams) => request.post('/users', data), createUser: (data: CreateUserParams) =>
request.post('/users', data),
updateUser: (id: number, data: UpdateUserParams) => request.put(`/users/${id}`, data), updateUser: (id: number, data: UpdateUserParams) =>
request.put(`/users/${id}`, data),
deleteUser: (id: number) => request.delete(`/users/${id}`), deleteUser: (id: number) =>
request.delete(`/users/${id}`),
batchDeleteUsers: (ids: number[]) => request.post('/users/batch-delete', { ids }), batchDeleteUsers: (ids: number[]) =>
request.post('/users/batch-delete', { ids }),
updateStatus: (id: number, status: number) => request.put(`/users/${id}/status`, { status }), updateStatus: (id: number, status: number) =>
request.put(`/users/${id}/status`, { status }),
}; };
-29
View File
@@ -1,29 +0,0 @@
import type { App, Directive, DirectiveBinding } from 'vue';
const permissionDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
const { value } = binding;
if (!value) {
console.warn('[v-permission] 需要提供权限码');
return;
}
const stores = import('@/stores/permission');
stores.then(({ usePermissionStore }) => {
const store = usePermissionStore();
if (store.userInfo?.roles.includes('super_admin')) {
return;
}
if (!store.permissions.includes(value)) {
el.parentNode?.removeChild(el);
}
});
},
};
export default function setupPermissionDirective(app: App) {
app.directive('permission', permissionDirective);
}
+83 -56
View File
@@ -91,24 +91,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, h } from 'vue' import { ref, computed, watch, h, type Component, type VNode } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { import {
DashboardOutlined, DashboardOutlined,
SettingOutlined, SettingOutlined,
UserOutlined, UserOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
ToolOutlined, MenuOutlined,
InfoCircleOutlined, TeamOutlined,
LockOutlined, ProjectOutlined,
ScheduleOutlined,
MonitorOutlined,
BarChartOutlined,
BookOutlined,
FileTextOutlined, FileTextOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
HomeOutlined, HomeOutlined,
DownOutlined, DownOutlined,
LogoutOutlined, LogoutOutlined,
LockOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { usePermissionStore } from '@/stores/permission' import { usePermissionStore, type MenuNode } from '@/stores/permission'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -119,57 +124,79 @@ const collapsed = ref(false)
const selectedKeys = ref<string[]>([]) const selectedKeys = ref<string[]>([])
const openKeys = ref<string[]>([]) const openKeys = ref<string[]>([])
// 菜单配置 // 菜单图标映射:菜单 icon 字符串 → 图标组件,未知图标显示默认图标
const menuItems = computed(() => [ const iconMap: Record<string, Component> = {
TeamOutlined,
ProjectOutlined,
ScheduleOutlined,
MonitorOutlined,
BarChartOutlined,
SettingOutlined,
UserOutlined,
SafetyCertificateOutlined,
MenuOutlined,
BookOutlined,
FileTextOutlined,
DashboardOutlined,
}
function resolveIcon(icon?: string | null) {
const iconComponent = icon ? iconMap[icon] : undefined
return () => h(iconComponent || MenuOutlined)
}
// 侧边栏菜单项结构(与 a-menu items 兼容)
interface MenuItem {
key: string
icon: () => VNode
label: string
children?: MenuItem[]
}
// 由后端菜单树构建侧边栏菜单:menuType=1 目录 → 子菜单组,menuType=2 菜单 → 菜单项
function buildMenuItems(nodes: MenuNode[]): MenuItem[] {
const items: MenuItem[] = []
for (const node of nodes) {
if (node.menuType === 1) {
const children = (node.children || [])
.filter(child => child.menuType === 2 && child.menuPath)
.map(child => ({
key: child.menuPath as string,
icon: resolveIcon(child.icon),
label: child.menuName,
}))
if (children.length) {
items.push({
key: node.menuPath || `dir-${node.id}`,
icon: resolveIcon(node.icon),
label: node.menuName,
children,
})
}
} else if (node.menuType === 2 && node.menuPath) {
items.push({
key: node.menuPath,
icon: resolveIcon(node.icon),
label: node.menuName,
})
}
}
return items
}
// 菜单配置:仪表盘固定展示在最前 + 后端菜单树动态渲染
const menuItems = computed<MenuItem[]>(() => [
{ {
key: '/dashboard', key: '/dashboard',
icon: () => h(DashboardOutlined), icon: () => h(DashboardOutlined),
label: '仪表盘', label: '仪表盘',
}, },
{ ...buildMenuItems(permissionStore.menus),
key: '/system',
icon: () => h(SettingOutlined),
label: '系统管理',
children: [
{
key: '/system/user',
icon: () => h(UserOutlined),
label: '用户管理',
},
{
key: '/system/role',
icon: () => h(SafetyCertificateOutlined),
label: '角色权限',
},
],
},
{
key: '/settings',
icon: () => h(ToolOutlined),
label: '系统设置',
children: [
{
key: '/settings/basic',
icon: () => h(InfoCircleOutlined),
label: '基本设置',
},
{
key: '/settings/security',
icon: () => h(LockOutlined),
label: '安全设置',
},
],
},
{
key: '/log/operation',
icon: () => h(FileTextOutlined),
label: '操作日志',
},
]) ])
// 计算属性 // 计算属性
const userInfo = computed(() => permissionStore.userInfo) const userInfo = computed(() => permissionStore.userInfo)
const displayName = computed(() => userInfo.value?.nickname || userInfo.value?.username || '管理员') const displayName = computed(() => userInfo.value?.realName || userInfo.value?.username || '管理员')
const userInitial = computed(() => (displayName.value?.charAt(0) || 'A').toUpperCase()) const userInitial = computed(() => (displayName.value?.charAt(0) || 'A').toUpperCase())
// 面包屑列表 // 面包屑列表
@@ -381,15 +408,15 @@ function handleUserMenuClick({ key }: { key: string }) {
} }
.content { .content {
margin: 16px; margin: 0;
min-height: calc(100vh - 64px - 32px); min-height: calc(100vh - 64px);
.view-wrapper { .view-wrapper {
background: #fff; background: #fff;
padding: 24px; padding: 0;
border-radius: 8px; border-radius: 0;
min-height: calc(100vh - 64px - 32px - 48px); // 减去header、margin和padding min-height: calc(100vh - 64px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); box-shadow: none;
} }
} }
} }
@@ -435,11 +462,11 @@ function handleUserMenuClick({ key }: { key: string }) {
} }
.content { .content {
margin: 12px; margin: 0;
.view-wrapper { .view-wrapper {
padding: 16px; padding: 0;
border-radius: 4px; border-radius: 0;
} }
} }
} }
-3
View File
@@ -5,7 +5,6 @@ import "@/assets/styles/global.less";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import pinia from "./stores"; import pinia from "./stores";
import setupPermissionDirective from "@/directives/permission";
const app = createApp(App); const app = createApp(App);
@@ -13,6 +12,4 @@ app.use(Antd);
app.use(router); app.use(router);
app.use(pinia); app.use(pinia);
setupPermissionDirective(app);
app.mount("#app"); app.mount("#app");
+49 -37
View File
@@ -17,6 +17,7 @@ const routes: RouteRecordRaw[] = [
}, },
{ {
path: "/", path: "/",
name: "Root",
component: () => import("@/layouts/index.vue"), component: () => import("@/layouts/index.vue"),
redirect: "/dashboard", redirect: "/dashboard",
children: [ children: [
@@ -26,65 +27,76 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/dashboard/index.vue"), component: () => import("@/views/dashboard/index.vue"),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
title: "仪表盘", title: "仪表盘"
permission: "dashboard"
} }
}, },
// 业务模块(未开发,先使用占位页;菜单 component 落地后替换为真实组件)
{
path: "customer",
name: "Customer",
component: () => import("@/views/Placeholder.vue"),
meta: { requiresAuth: true, title: "客户管理", permission: "customer:list" }
},
{
path: "project",
name: "Project",
component: () => import("@/views/Placeholder.vue"),
meta: { requiresAuth: true, title: "项目管理", permission: "project:list" }
},
{
path: "dispatch",
name: "Dispatch",
component: () => import("@/views/Placeholder.vue"),
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
},
{
path: "device",
name: "Device",
component: () => import("@/views/Placeholder.vue"),
meta: { requiresAuth: true, title: "设备监控", permission: "device:list" }
},
{
path: "report",
name: "Report",
component: () => import("@/views/Placeholder.vue"),
meta: { requiresAuth: true, title: "统计报表", permission: "report:list" }
},
// 系统管理
{ {
path: "system/user", path: "system/user",
name: "UserList", name: "SystemUser",
component: () => import("@/views/system/user/index.vue"), component: () => import("@/views/system/user/index.vue"),
meta: { meta: { requiresAuth: true, title: "用户管理", permission: "system:user" }
requiresAuth: true,
title: "用户管理",
permission: "user:list"
}
}, },
{ {
path: "system/role", path: "system/role",
name: "RoleList", name: "SystemRole",
component: () => import("@/views/system/role/index.vue"), component: () => import("@/views/system/role/index.vue"),
meta: { meta: { requiresAuth: true, title: "角色管理", permission: "system:role" }
requiresAuth: true,
title: "角色管理",
permission: "role:list"
}
}, },
{ {
path: "settings/basic", path: "system/menu",
name: "BasicSettings", name: "SystemMenu",
component: () => import("@/views/settings/basic.vue"), component: () => import("@/views/system/menu/index.vue"),
meta: { meta: { requiresAuth: true, title: "菜单管理", permission: "system:menu" }
requiresAuth: true,
title: "基本设置",
permission: "system:basicSetting"
}
}, },
{ {
path: "settings/security", path: "system/dict",
name: "SecuritySettings", name: "SystemDict",
component: () => import("@/views/settings/security.vue"), component: () => import("@/views/system/dict/index.vue"),
meta: { meta: { requiresAuth: true, title: "数据字典", permission: "system:dict" }
requiresAuth: true,
title: "安全设置",
permission: "system:securitySetting"
}
}, },
{ {
path: "log/operation", path: "log/operation",
name: "OperationLog", name: "OperationLog",
component: () => import("@/views/log/operation.vue"), component: () => import("@/views/log/operation/index.vue"),
meta: { meta: { requiresAuth: true, title: "操作日志", permission: "system:log" }
requiresAuth: true,
title: "操作日志",
permission: "log:operation"
}
} }
] ]
}, },
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
redirect: "/login" redirect: "/403"
} }
]; ];
+45 -9
View File
@@ -3,18 +3,34 @@ import { ref, computed } from 'vue';
import { authApi } from '@/api/auth'; import { authApi } from '@/api/auth';
import router from '@/router'; import router from '@/router';
// 后端菜单树节点结构(sys_menu)
export interface MenuNode {
id: number;
parentId: number | null;
menuName: string;
menuPath?: string | null;
component?: string | null;
icon?: string | null;
sortOrder: number;
menuType: number; // 1目录(父级) 2菜单(叶子)
permission?: string | null;
visible: number;
children?: MenuNode[];
}
interface UserInfo { interface UserInfo {
id: number; id: number;
username: string; username: string;
nickname: string; realName: string;
avatar?: string;
roles: string[]; roles: string[];
permissions: string[]; menus: MenuNode[];
} }
export const usePermissionStore = defineStore('permission', () => { export const usePermissionStore = defineStore('permission', () => {
const userInfo = ref<UserInfo | null>(null); const userInfo = ref<UserInfo | null>(null);
const token = ref<string>(localStorage.getItem('access_token') || ''); const token = ref<string>(localStorage.getItem('access_token') || '');
const menus = ref<MenuNode[]>([]);
// 兼容旧调用方(如登录页 setPermissions),权限判断已改为基于菜单树
const permissions = ref<string[]>([]); const permissions = ref<string[]>([]);
const isLoggedIn = computed(() => !!token.value); const isLoggedIn = computed(() => !!token.value);
@@ -23,23 +39,40 @@ export const usePermissionStore = defineStore('permission', () => {
userInfo.value?.roles.includes(role) || false userInfo.value?.roles.includes(role) || false
); );
// 从菜单树展平所有节点的 permission 标识集合
const permissionSet = computed(() => {
const set = new Set<string>();
const walk = (nodes: MenuNode[]) => {
for (const node of nodes) {
if (node.permission) {
set.add(node.permission);
}
if (node.children?.length) {
walk(node.children);
}
}
};
walk(menus.value);
return set;
});
const hasPermission = computed(() => (permission: string) => const hasPermission = computed(() => (permission: string) =>
permissions.value.includes(permission) || permissionSet.value.has(permission) || hasRole.value('ADMIN') || false
userInfo.value?.roles.includes('super_admin') || false
); );
async function login(username: string, password: string) { async function login(username: string, password: string) {
try { try {
const res = await authApi.login({ username, password }); const res = await authApi.login({ username, password });
if (res.code === 200) { if (res.code === 200) {
const { accessToken, refreshToken, userInfo: info } = res.data; const { accessToken, refreshToken, userInfo: info, roles, menus: menuTree } = res.data;
token.value = accessToken; token.value = accessToken;
localStorage.setItem('access_token', accessToken); localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken); localStorage.setItem('refresh_token', refreshToken);
userInfo.value = info; // 后端将 roles/menus 作为 userInfo 的同级字段返回
permissions.value = info.permissions || []; userInfo.value = { ...info, roles: roles || [], menus: menuTree || [] };
menus.value = menuTree || [];
return true; return true;
} }
@@ -52,6 +85,7 @@ export const usePermissionStore = defineStore('permission', () => {
function logout() { function logout() {
token.value = ''; token.value = '';
userInfo.value = null; userInfo.value = null;
menus.value = [];
permissions.value = []; permissions.value = [];
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token'); localStorage.removeItem('refresh_token');
@@ -63,7 +97,7 @@ export const usePermissionStore = defineStore('permission', () => {
const res = await authApi.getCurrentUser(); const res = await authApi.getCurrentUser();
if (res.code === 200) { if (res.code === 200) {
userInfo.value = res.data; userInfo.value = res.data;
permissions.value = res.data.permissions || []; menus.value = res.data.menus || [];
return true; return true;
} }
return false; return false;
@@ -80,6 +114,7 @@ export const usePermissionStore = defineStore('permission', () => {
function setUserInfo(info: UserInfo | null) { function setUserInfo(info: UserInfo | null) {
userInfo.value = info; userInfo.value = info;
menus.value = info?.menus || [];
} }
function setPermissions(perms: string[]) { function setPermissions(perms: string[]) {
@@ -89,6 +124,7 @@ export const usePermissionStore = defineStore('permission', () => {
return { return {
userInfo, userInfo,
token, token,
menus,
permissions, permissions,
isLoggedIn, isLoggedIn,
hasRole, hasRole,
+20 -14
View File
@@ -86,20 +86,26 @@ service.interceptors.response.use(
} }
); );
export default service; // 响应拦截器已解包 response.data,统一返回 { code, message, data } 结构
export interface ApiResult<T = any> {
export function get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> { code: number;
return service.get(url, config); message: string;
data: T;
} }
export function post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> { const request = {
return service.post(url, data, config); get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResult<T>> {
} return service.get(url, config) as unknown as Promise<ApiResult<T>>;
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResult<T>> {
return service.post(url, data, config) as unknown as Promise<ApiResult<T>>;
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResult<T>> {
return service.put(url, data, config) as unknown as Promise<ApiResult<T>>;
},
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResult<T>> {
return service.delete(url, config) as unknown as Promise<ApiResult<T>>;
}
};
export function put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> { export default request;
return service.put(url, data, config);
}
export function del<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config);
}
+77
View File
@@ -0,0 +1,77 @@
<template>
<div class="placeholder-container">
<div class="placeholder-content">
<div class="placeholder-icon">
<ToolOutlined />
</div>
<h2 class="placeholder-title">功能开发中</h2>
<p class="placeholder-desc">该模块正在开发中,敬请期待</p>
<a-button type="primary" @click="goBack">
<ArrowLeftOutlined />
返回
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ToolOutlined, ArrowLeftOutlined } from '@ant-design/icons-vue'
const router = useRouter()
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/dashboard')
}
}
</script>
<style scoped lang="less">
.placeholder-container {
min-height: calc(100vh - 200px);
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.placeholder-content {
text-align: center;
padding: 60px 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.placeholder-icon {
width: 120px;
height: 120px;
margin: 0 auto 24px;
background: #e6f7ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
:deep(.anticon) {
font-size: 64px;
color: #1890ff;
}
}
.placeholder-title {
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
margin: 0 0 12px 0;
}
.placeholder-desc {
font-size: 14px;
color: #999;
margin: 0 0 32px 0;
}
</style>
@@ -4,19 +4,28 @@
<a-form layout="inline" :model="queryParams"> <a-form layout="inline" :model="queryParams">
<a-form-item label="操作人"> <a-form-item label="操作人">
<a-input <a-input
v-model:value="queryParams.operator" v-model:value="queryParams.userId"
placeholder="请输入操作人" placeholder="请输入用户ID"
allow-clear
style="width: 140px"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="操作模块">
<a-input
v-model:value="queryParams.module"
placeholder="请输入操作模块"
allow-clear allow-clear
style="width: 160px" style="width: 160px"
@pressEnter="handleSearch" @pressEnter="handleSearch"
/> />
</a-form-item> </a-form-item>
<a-form-item label="操作类型"> <a-form-item label="操作动作">
<a-select <a-select
v-model:value="queryParams.actionType" v-model:value="queryParams.action"
placeholder="请选择操作类型" placeholder="请选择操作动作"
allow-clear allow-clear
style="width: 160px" style="width: 140px"
> >
<a-select-option value="登录">登录</a-select-option> <a-select-option value="登录">登录</a-select-option>
<a-select-option value="创建">创建</a-select-option> <a-select-option value="创建">创建</a-select-option>
@@ -63,24 +72,14 @@
@change="handleTableChange" @change="handleTableChange"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'statusCode'"> <template v-if="column.key === 'params'">
<a-tag :color="getStatusCodeColor(record.statusCode)"> <a-tooltip :title="record.params || ''">
{{ record.statusCode }} <span class="params-text">{{ record.params || '-' }}</span>
</a-tag>
</template>
<template v-else-if="column.key === 'duration'">
<span :class="{ 'duration-warning': record.duration > 1000 }">
{{ record.duration }}ms
</span>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ formatTime(record.createdAt) }}
</template>
<template v-else-if="column.key === 'action'">
<a-tooltip :title="record.action">
<span class="action-text">{{ record.action }}</span>
</a-tooltip> </a-tooltip>
</template> </template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
</template> </template>
</a-table> </a-table>
</a-card> </a-card>
@@ -92,19 +91,20 @@ import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined, DownloadOutlined } from '@ant-design/icons-vue' import { SearchOutlined, ReloadOutlined, DownloadOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { logApi, type LogItem } from '@/api/log' import { logApi } from '@/api/log'
const tableLoading = ref(false) const tableLoading = ref(false)
const exportLoading = ref(false) const exportLoading = ref(false)
const logList = ref<LogItem[]>([]) const logList = ref<any[]>([])
const total = ref(0) const total = ref(0)
const timeRange = ref<any[]>([]) const timeRange = ref<any[]>([])
const queryParams = reactive({ const queryParams = reactive({
page: 1, page: 1,
pageSize: 10, pageSize: 10,
operator: '', userId: '' as string | number,
actionType: undefined as string | undefined, module: '',
action: undefined as string | undefined,
startDate: '', startDate: '',
endDate: '', endDate: '',
}) })
@@ -118,23 +118,23 @@ const columns = [
}, },
{ {
title: '操作人', title: '操作人',
dataIndex: 'username', dataIndex: 'userId',
key: 'username', key: 'userId',
width: 120, width: 100,
ellipsis: true,
},
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 140,
ellipsis: true, ellipsis: true,
}, },
{ {
title: '操作模块', title: '操作模块',
dataIndex: 'module', dataIndex: 'module',
key: 'module', key: 'module',
width: 110, width: 130,
ellipsis: true,
},
{
title: '操作动作',
dataIndex: 'action',
key: 'action',
width: 120,
ellipsis: true, ellipsis: true,
}, },
{ {
@@ -145,29 +145,22 @@ const columns = [
}, },
{ {
title: 'IP地址', title: 'IP地址',
dataIndex: 'ipAddress', dataIndex: 'ip',
key: 'ipAddress', key: 'ip',
width: 140, width: 140,
ellipsis: true, ellipsis: true,
}, },
{ {
title: '状态码', title: '请求参数',
dataIndex: 'statusCode', dataIndex: 'params',
key: 'statusCode', key: 'params',
width: 90, width: 180,
align: 'center' as const, ellipsis: true,
},
{
title: '耗时(ms)',
dataIndex: 'duration',
key: 'duration',
width: 100,
align: 'center' as const,
}, },
{ {
title: '操作时间', title: '操作时间',
dataIndex: 'createdAt', dataIndex: 'created_at',
key: 'createdAt', key: 'created_at',
width: 180, width: 180,
}, },
] ]
@@ -182,13 +175,6 @@ const paginationConfig = reactive({
showTotal: (t: number) => `共 ${t} 条记录`, showTotal: (t: number) => `共 ${t} 条记录`,
}) })
function getStatusCodeColor(code: number): string {
if (code >= 200 && code < 300) return 'success'
if (code >= 400 && code < 500) return 'warning'
if (code >= 500) return 'error'
return 'default'
}
function formatTime(time: string): string { function formatTime(time: string): string {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss') return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
} }
@@ -204,7 +190,14 @@ async function fetchLogList() {
queryParams.endDate = '' queryParams.endDate = ''
} }
const res: any = await logApi.getLogList(queryParams) const requestParams: any = { ...queryParams }
if (requestParams.userId !== '') {
requestParams.userId = Number(requestParams.userId)
} else {
delete requestParams.userId
}
const res: any = await logApi.getLogList(requestParams)
if (res.code === 200) { if (res.code === 200) {
logList.value = res.data.list logList.value = res.data.list
total.value = res.data.total total.value = res.data.total
@@ -224,8 +217,9 @@ function handleSearch() {
} }
function handleReset() { function handleReset() {
queryParams.operator = '' queryParams.userId = ''
queryParams.actionType = undefined queryParams.module = ''
queryParams.action = undefined
queryParams.startDate = '' queryParams.startDate = ''
queryParams.endDate = '' queryParams.endDate = ''
timeRange.value = [] timeRange.value = []
@@ -244,8 +238,9 @@ async function handleExport() {
exportLoading.value = true exportLoading.value = true
try { try {
const params: any = {} const params: any = {}
if (queryParams.operator) params.operator = queryParams.operator if (queryParams.userId !== '') params.userId = Number(queryParams.userId)
if (queryParams.actionType) params.actionType = queryParams.actionType if (queryParams.module) params.module = queryParams.module
if (queryParams.action) params.action = queryParams.action
if (timeRange.value && timeRange.value.length === 2) { if (timeRange.value && timeRange.value.length === 2) {
params.startDate = timeRange.value[0].format('YYYY-MM-DD HH:mm:ss') params.startDate = timeRange.value[0].format('YYYY-MM-DD HH:mm:ss')
params.endDate = timeRange.value[1].format('YYYY-MM-DD HH:mm:ss') params.endDate = timeRange.value[1].format('YYYY-MM-DD HH:mm:ss')
@@ -292,12 +287,7 @@ onMounted(() => {
} }
} }
.duration-warning { .params-text {
color: #cf1322;
font-weight: 500;
}
.action-text {
max-width: 150px; max-width: 150px;
display: inline-block; display: inline-block;
overflow: hidden; overflow: hidden;
+1 -20
View File
@@ -68,7 +68,6 @@
import { reactive, ref, onMounted, nextTick } from 'vue' import { reactive, ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import axios from 'axios'
import { import {
UserOutlined, UserOutlined,
LockOutlined, LockOutlined,
@@ -121,22 +120,7 @@ async function handleLogin() {
try { try {
loading.value = true loading.value = true
const response = await axios.post('/api/auth/login', { await permissionStore.login(formState.username, formState.password)
username: formState.username,
password: formState.password
})
const res = response.data
if (res.code === 200) {
const { accessToken, refreshToken, userInfo } = res.data
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
permissionStore.setToken(accessToken)
permissionStore.setUserInfo(userInfo)
permissionStore.setPermissions(userInfo.permissions || [])
if (formState.rememberMe) { if (formState.rememberMe) {
localStorage.setItem('remembered_username', formState.username) localStorage.setItem('remembered_username', formState.username)
@@ -148,9 +132,6 @@ async function handleLogin() {
const redirect = (route.query.redirect as string) || '/' const redirect = (route.query.redirect as string) || '/'
router.push(redirect) router.push(redirect)
} else {
throw new Error(res.message || '登录失败')
}
} catch (error: any) { } catch (error: any) {
console.error('登录失败:', error) console.error('登录失败:', error)
message.error(error.message || '登录失败,请检查用户名和密码') message.error(error.message || '登录失败,请检查用户名和密码')
-175
View File
@@ -1,175 +0,0 @@
<template>
<div class="basic-settings">
<a-card title="基本设置" :loading="loading">
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
>
<a-form-item label="站点名称" name="siteName">
<a-input
v-model:value="formState.siteName"
placeholder="请输入站点名称"
:maxlength="50"
show-count
/>
</a-form-item>
<a-form-item label="Logo" name="logo">
<a-upload
name="file"
list-type="picture-card"
class="logo-uploader"
:show-upload-list="false"
:before-upload="beforeUpload"
:custom-request="handleUpload"
>
<img v-if="formState.logo" :src="formState.logo" alt="logo" style="width: 100%" />
<div v-else>
<plus-outlined />
<div class="ant-upload-text">上传</div>
</div>
</a-upload>
<div class="form-tip">支持 JPG、PNG 格式,建议尺寸 200x200px</div>
</a-form-item>
<a-form-item label="会话超时时间" name="sessionTimeout">
<a-input-number
v-model:value="formState.sessionTimeout"
:min="5"
:max="1440"
style="width: 200px"
/>
<span class="form-tip">单位:分钟,范围 5-1440</span>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
<a-button type="primary" @click="handleSave" :loading="saving">
保存设置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { systemApi, type BasicSettings } from '@/api/system';
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const formState = reactive<BasicSettings>({
siteName: '',
logo: '',
sessionTimeout: 120,
});
const rules = {
siteName: [
{ required: true, message: '请输入站点名称', trigger: 'blur' },
{ max: 50, message: '站点名称不能超过50个字符', trigger: 'blur' },
],
sessionTimeout: [
{ required: true, message: '请输入会话超时时间', trigger: 'blur' },
{ type: 'number', min: 5, max: 1440, message: '超时时间范围为5-1440分钟', trigger: 'blur' },
],
};
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
loading.value = true;
try {
const res: any = await systemApi.getSettings();
if (res.code === 200 && res.data?.basic) {
Object.assign(formState, res.data.basic);
}
} catch (error) {
console.error('加载设置失败:', error);
message.error('加载设置失败');
} finally {
loading.value = false;
}
}
function beforeUpload(file: File) {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
if (!isJpgOrPng) {
message.error('只能上传 JPG/PNG 格式的图片!');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('图片大小不能超过 2MB!');
return false;
}
return true;
}
function handleUpload(options: any) {
const reader = new FileReader();
reader.onload = () => {
formState.logo = reader.result as string;
};
reader.readAsDataURL(options.file);
options.onSuccess();
}
async function handleSave() {
try {
await formRef.value.validateFields();
} catch {
return;
}
saving.value = true;
try {
const res: any = await systemApi.updateBasicSettings({
siteName: formState.siteName,
logo: formState.logo,
sessionTimeout: formState.sessionTimeout,
});
if (res.code === 200) {
message.success('基本设置保存成功');
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="less">
.basic-settings {
padding: 20px;
.form-tip {
margin-left: 10px;
color: #999;
font-size: 12px;
}
.logo-uploader {
:deep(.ant-upload) {
width: 128px;
height: 128px;
}
}
.ant-upload-text {
margin-top: 8px;
color: #999;
}
}
</style>
-151
View File
@@ -1,151 +0,0 @@
<template>
<div class="security-settings">
<a-card title="安全设置" :loading="loading">
<a-form
:model="formState"
:rules="rules"
ref="formRef"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 14 }"
>
<a-form-item label="密码最小长度" name="passwordMinLength">
<a-input-number
v-model:value="formState.passwordMinLength"
:min="6"
:max="20"
style="width: 200px"
/>
<span class="form-tip">范围 6-20 位</span>
</a-form-item>
<a-form-item label="最大登录尝试次数" name="maxLoginAttempts">
<a-input-number
v-model:value="formState.maxLoginAttempts"
:min="3"
:max="10"
style="width: 200px"
/>
<span class="form-tip">超过此次数将锁定账户</span>
</a-form-item>
<a-form-item label="账户锁定时长" name="lockoutDuration">
<a-input-number
v-model:value="formState.lockoutDuration"
:min="5"
:max="60"
style="width: 200px"
/>
<span class="form-tip">单位:分钟,范围 5-60</span>
</a-form-item>
<a-form-item label="IP白名单" name="ipWhitelist">
<a-select
v-model:value="formState.ipWhitelist"
mode="tags"
placeholder="输入IP地址后按回车添加"
style="width: 100%"
:token-separators="[',']"
/>
<div class="form-tip">
留空表示不限制IP访问,多个IP用回车或逗号分隔(如:192.168.1.1)
</div>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
<a-button type="primary" @click="handleSave" :saving="saving">
保存设置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { systemApi, type SecuritySettings } from '@/api/system';
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const formState = reactive<SecuritySettings>({
passwordMinLength: 6,
maxLoginAttempts: 5,
lockoutDuration: 30,
ipWhitelist: [],
});
const rules = {
passwordMinLength: [
{ required: true, message: '请输入密码最小长度', trigger: 'blur' },
{ type: 'number', min: 6, max: 20, message: '长度范围为6-20', trigger: 'blur' },
],
maxLoginAttempts: [
{ required: true, message: '请输入最大登录尝试次数', trigger: 'blur' },
{ type: 'number', min: 3, max: 10, message: '范围为3-10次', trigger: 'blur' },
],
lockoutDuration: [
{ required: true, message: '请输入账户锁定时长', trigger: 'blur' },
{ type: 'number', min: 5, max: 60, message: '范围为5-60分钟', trigger: 'blur' },
],
};
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
loading.value = true;
try {
const res: any = await systemApi.getSettings();
if (res.code === 200 && res.data?.security) {
Object.assign(formState, res.data.security);
}
} catch (error) {
console.error('加载设置失败:', error);
message.error('加载设置失败');
} finally {
loading.value = false;
}
}
async function handleSave() {
try {
await formRef.value.validateFields();
} catch {
return;
}
saving.value = true;
try {
const res: any = await systemApi.updateSecuritySettings({
passwordMinLength: formState.passwordMinLength,
maxLoginAttempts: formState.maxLoginAttempts,
lockoutDuration: formState.lockoutDuration,
ipWhitelist: formState.ipWhitelist,
});
if (res.code === 200) {
message.success('安全设置保存成功');
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="less">
.security-settings {
padding: 20px;
.form-tip {
margin-left: 10px;
color: #999;
font-size: 12px;
}
}
</style>
+368
View File
@@ -0,0 +1,368 @@
<template>
<div class="dict-management">
<a-card :bordered="false">
<a-form layout="inline" :model="queryParams" class="search-form">
<a-form-item label="字典类型">
<a-select
v-model:value="queryParams.dictType"
placeholder="请选择字典类型"
allow-clear
style="width: 200px"
>
<a-select-option v-for="type in dictTypeOptions" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="关键词">
<a-input v-model:value="queryParams.keyword" placeholder="字典标签/值" allow-clear style="width: 200px" @pressEnter="handleSearch" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="queryParams.status" placeholder="请选择状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<div class="table-operations">
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增字典
</a-button>
</div>
<a-card :bordered="false">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'success' : 'default'">
{{ record.status === 1 ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<EditOutlined />编辑
</a-button>
<a-popconfirm
title="确定删除该字典?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="560px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="字典类型" name="dictType">
<a-input v-model:value="formData.dictType" placeholder="如 sys_user_status" :disabled="isEdit" />
</a-form-item>
<a-form-item label="字典标签" name="dictLabel">
<a-input v-model:value="formData.dictLabel" placeholder="请输入字典标签" />
</a-form-item>
<a-form-item label="字典值" name="dictValue">
<a-input v-model:value="formData.dictValue" placeholder="请输入字典值" />
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import { dictApi } from '@/api/dict';
import dayjs from 'dayjs';
interface DictItem {
id: number;
dictType: string;
dictLabel: string;
dictValue: string;
sortOrder: number;
status: number;
created_at: string;
[key: string]: any;
}
const loading = ref(false);
const dataSource = ref<DictItem[]>([]);
const total = ref(0);
const dictTypeOptions = ref<string[]>([]);
const queryParams = reactive({
page: 1,
pageSize: 10,
dictType: undefined as string | undefined,
keyword: '',
status: undefined as number | undefined,
});
const columns = [
{ title: '字典类型', dataIndex: 'dictType', key: 'dictType', width: 160 },
{ title: '字典标签', dataIndex: 'dictLabel', key: 'dictLabel', width: 160 },
{ title: '字典值', dataIndex: 'dictValue', key: 'dictValue', width: 160 },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80, align: 'center' as const },
{ title: '状态', key: 'status', width: 90, align: 'center' as const },
{ title: '创建时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: queryParams.page,
pageSize: queryParams.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchDictList();
fetchDictTypes();
});
async function fetchDictTypes() {
try {
const res: any = await dictApi.getDictTypes();
if (res.code === 200) {
const data = res.data || [];
dictTypeOptions.value = data.map((item: any) =>
typeof item === 'string' ? item : item.dictType || item.type || item.label
);
}
} catch (error) {
console.error('获取字典类型失败:', error);
}
}
async function fetchDictList() {
loading.value = true;
try {
const res: any = await dictApi.getDictList(queryParams);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取字典列表失败');
} finally {
loading.value = false;
}
}
function handleSearch() {
queryParams.page = 1;
fetchDictList();
}
function handleReset() {
queryParams.dictType = undefined;
queryParams.keyword = '';
queryParams.status = undefined;
queryParams.page = 1;
fetchDictList();
}
function handleTableChange(pag: any) {
queryParams.page = pag.current;
queryParams.pageSize = pag.pageSize;
fetchDictList();
}
function formatTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalTitle = ref('');
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const formData = reactive({
dictType: '',
dictLabel: '',
dictValue: '',
sortOrder: 0,
status: 1,
});
const formRules = {
dictType: [{ required: true, message: '请输入字典类型', trigger: 'blur' }],
dictLabel: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
dictValue: [{ required: true, message: '请输入字典值', trigger: 'blur' }],
};
function handleAdd() {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增字典';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: DictItem) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑字典';
formData.dictType = record.dictType;
formData.dictLabel = record.dictLabel;
formData.dictValue = record.dictValue;
formData.sortOrder = record.sortOrder ?? 0;
formData.status = record.status;
modalVisible.value = true;
}
function resetForm() {
formData.dictType = '';
formData.dictLabel = '';
formData.dictValue = '';
formData.sortOrder = 0;
formData.status = 1;
formRef.value?.resetFields();
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const payload: any = {
dictType: formData.dictType,
dictLabel: formData.dictLabel,
dictValue: formData.dictValue,
sortOrder: formData.sortOrder,
status: formData.status,
};
let res: any;
if (isEdit.value && editId.value) {
res = await dictApi.updateDict(editId.value, payload);
} else {
res = await dictApi.createDict(payload);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchDictList();
fetchDictTypes();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(record: DictItem) {
try {
const res: any = await dictApi.deleteDict(record.id);
if (res.code === 200) {
message.success('删除成功');
fetchDictList();
fetchDictTypes();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
</script>
<style scoped lang="less">
.dict-management {
padding: 24px;
.search-form {
margin-bottom: 16px;
}
.table-operations {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
}
</style>
+335
View File
@@ -0,0 +1,335 @@
<template>
<div class="menu-management">
<a-card title="菜单管理">
<template #extra>
<a-button type="primary" @click="handleAdd()">
<template #icon><PlusOutlined /></template>
新增菜单
</a-button>
</template>
<a-table
:columns="columns"
:data-source="menuList"
:loading="loading"
:pagination="false"
:default-expand-all-rows="true"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'menuType'">
<a-tag :color="record.menuType === 1 ? 'blue' : 'green'">
{{ record.menuType === 1 ? '目录' : '菜单' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'visible'">
<a-tag :color="record.visible === 1 ? 'success' : 'default'">
{{ record.visible === 1 ? '显示' : '隐藏' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleAdd(record)">
<PlusOutlined />新增子菜单
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<EditOutlined />编辑
</a-button>
<a-popconfirm
title="确定删除该菜单?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="640px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="上级菜单" name="parentId">
<a-tree-select
v-model:value="formData.parentId"
:tree-data="parentOptions"
:field-names="{ label: 'menuName', value: 'id', children: 'children' }"
allow-clear
tree-default-expand-all
placeholder="请选择上级菜单"
/>
</a-form-item>
<a-form-item label="菜单类型" name="menuType">
<a-radio-group v-model:value="formData.menuType">
<a-radio :value="1">目录</a-radio>
<a-radio :value="2">菜单</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="菜单名称" name="menuName">
<a-input v-model:value="formData.menuName" placeholder="请输入菜单名称" />
</a-form-item>
<a-form-item label="路由路径" name="menuPath">
<a-input v-model:value="formData.menuPath" placeholder="如 /system/user" />
</a-form-item>
<a-form-item label="组件" name="component">
<a-input v-model:value="formData.component" placeholder="如 system/user/index" />
</a-form-item>
<a-form-item label="图标" name="icon">
<a-input v-model:value="formData.icon" placeholder="如 UserOutlined" />
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item>
<a-form-item label="权限标识" name="permission">
<a-input v-model:value="formData.permission" placeholder="如 user:list" />
</a-form-item>
<a-form-item label="可见性" name="visible">
<a-switch
:checked="formData.visible === 1"
checked-children="显示"
un-checked-children="隐藏"
@change="(checked: boolean) => (formData.visible = checked ? 1 : 0)"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { menuApi } from '@/api/menu';
interface MenuItem {
id: number;
parentId?: number | null;
menuName: string;
menuPath?: string;
component?: string;
icon?: string;
menuType: number;
sortOrder: number;
permission?: string;
visible: number;
children?: MenuItem[];
[key: string]: any;
}
const loading = ref(false);
const menuList = ref<MenuItem[]>([]);
const columns = [
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName' },
{ title: '路由路径', dataIndex: 'menuPath', key: 'menuPath', width: 160 },
{ title: '组件', dataIndex: 'component', key: 'component', width: 180 },
{ title: '图标', dataIndex: 'icon', key: 'icon', width: 120 },
{ title: '类型', dataIndex: 'menuType', key: 'menuType', width: 80, align: 'center' as const },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80, align: 'center' as const },
{ title: '权限标识', dataIndex: 'permission', key: 'permission', width: 150 },
{ title: '可见性', dataIndex: 'visible', key: 'visible', width: 90, align: 'center' as const },
{ title: '操作', key: 'action', width: 240, fixed: 'right' as const },
];
// 上级菜单树(编辑时排除自身及后代节点,防止循环引用)
const parentOptions = computed(() => {
const excludeId = isEdit.value ? editId.value : null;
const filtered = excludeId ? filterTree(menuList.value, excludeId) : menuList.value;
return [{ id: 0, menuName: '根目录', children: filtered }];
});
function filterTree(nodes: MenuItem[], excludeId: number): MenuItem[] {
const result: MenuItem[] = [];
for (const node of nodes) {
if (node.id === excludeId) continue;
const children = node.children?.length ? filterTree(node.children, excludeId) : undefined;
if (children) {
result.push({ ...node, children });
} else {
result.push(node);
}
}
return result;
}
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalTitle = ref('');
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const formData = reactive({
parentId: 0,
menuType: 2,
menuName: '',
menuPath: '',
component: '',
icon: '',
sortOrder: 0,
permission: '',
visible: 1,
});
const formRules = {
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
};
onMounted(() => {
fetchMenuTree();
});
async function fetchMenuTree() {
loading.value = true;
try {
const res: any = await menuApi.getMenuTree();
if (res.code === 200) {
menuList.value = res.data || [];
}
} catch (error) {
message.error('获取菜单列表失败');
} finally {
loading.value = false;
}
}
function handleAdd(parent?: MenuItem) {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增菜单';
resetForm();
if (parent) {
formData.parentId = parent.id;
formData.menuType = 2;
}
modalVisible.value = true;
}
function handleEdit(record: MenuItem) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑菜单';
formData.parentId = record.parentId || 0;
formData.menuType = record.menuType;
formData.menuName = record.menuName;
formData.menuPath = record.menuPath || '';
formData.component = record.component || '';
formData.icon = record.icon || '';
formData.sortOrder = record.sortOrder ?? 0;
formData.permission = record.permission || '';
formData.visible = record.visible ? 1 : 0;
modalVisible.value = true;
}
function resetForm() {
formData.parentId = 0;
formData.menuType = 2;
formData.menuName = '';
formData.menuPath = '';
formData.component = '';
formData.icon = '';
formData.sortOrder = 0;
formData.permission = '';
formData.visible = 1;
formRef.value?.resetFields();
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const payload: any = {
parentId: formData.parentId || null,
menuType: formData.menuType,
menuName: formData.menuName,
menuPath: formData.menuPath,
component: formData.component,
icon: formData.icon,
sortOrder: formData.sortOrder,
permission: formData.permission,
visible: formData.visible,
};
let res: any;
if (isEdit.value && editId.value) {
res = await menuApi.updateMenu(editId.value, payload);
} else {
res = await menuApi.createMenu(payload);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchMenuTree();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(record: MenuItem) {
try {
const res: any = await menuApi.deleteMenu(record.id);
if (res.code === 200) {
message.success('删除成功');
fetchMenuTree();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
</script>
<style scoped lang="less">
.menu-management {
padding: 20px;
:deep(.ant-btn-link) {
padding: 0 4px;
}
}
</style>
+46 -81
View File
@@ -30,16 +30,12 @@
@change="handleTableChange" @change="handleTableChange"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'name'"> <template v-if="column.dataIndex === 'roleName'">
<span>{{ record.name }}</span> <span>{{ record.roleName }}</span>
</template> </template>
<template v-if="column.dataIndex === 'code'"> <template v-if="column.dataIndex === 'roleCode'">
<a-tag color="blue">{{ record.code }}</a-tag> <a-tag color="blue">{{ record.roleCode }}</a-tag>
</template>
<template v-if="column.dataIndex === 'status'">
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '启用' : '禁用'" />
</template> </template>
<template v-if="column.dataIndex === 'userCount'"> <template v-if="column.dataIndex === 'userCount'">
@@ -88,12 +84,12 @@
:label-col="{ span: 5 }" :label-col="{ span: 5 }"
:wrapper-col="{ span: 17 }" :wrapper-col="{ span: 17 }"
> >
<a-form-item label="角色名称" name="name"> <a-form-item label="角色名称" name="roleName">
<a-input v-model:value="formData.name" placeholder="请输入角色名称" /> <a-input v-model:value="formData.roleName" placeholder="请输入角色名称" />
</a-form-item> </a-form-item>
<a-form-item label="角色编码" name="code"> <a-form-item label="角色编码" name="roleCode">
<a-input <a-input
v-model:value="formData.code" v-model:value="formData.roleCode"
placeholder="请输入角色编码" placeholder="请输入角色编码"
:disabled="isEdit" :disabled="isEdit"
/> />
@@ -104,12 +100,6 @@
<a-form-item label="排序" name="sortOrder"> <a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" /> <a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item> </a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
@@ -124,17 +114,17 @@
<a-tree <a-tree
v-if="!permissionTreeLoading" v-if="!permissionTreeLoading"
v-model:checkedKeys="checkedKeys" v-model:checkedKeys="checkedKeys"
:tree-data="permissionTree" :tree-data="menuTree"
checkable checkable
:field-names="{ title: 'name', key: 'id', children: 'children' }" :field-names="{ title: 'menuName', key: 'id', children: 'children' }"
:check-strictly="false"
:default-expand-all="true" :default-expand-all="true"
style="max-height: 500px; overflow-y: auto;" style="max-height: 500px; overflow-y: auto;"
> >
<template #title="{ name, icon }"> <template #title="{ menuName, menuType }">
<span> <span>
<component v-if="icon" :is="icon" style="margin-right: 8px" /> <a-tag v-if="menuType === 1" color="blue">目录</a-tag>
{{ name }} <a-tag v-else color="green">菜单</a-tag>
{{ menuName }}
</span> </span>
</template> </template>
</a-tree> </a-tree>
@@ -153,9 +143,17 @@ import {
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { roleApi, type RoleInfo, type PermissionTreeNode } from '@/api/role'; import { roleApi, type RoleInfo } from '@/api/role';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
interface MenuTreeNode {
id: number;
menuName: string;
menuType?: number;
children?: MenuTreeNode[];
[key: string]: any;
}
const loading = ref(false); const loading = ref(false);
const roleList = ref<RoleInfo[]>([]); const roleList = ref<RoleInfo[]>([]);
const keyword = ref(''); const keyword = ref('');
@@ -171,13 +169,13 @@ const pagination = reactive({
const columns = [ const columns = [
{ {
title: '角色名称', title: '角色名称',
dataIndex: 'name', dataIndex: 'roleName',
key: 'name', key: 'roleName',
}, },
{ {
title: '角色编码', title: '角色编码',
dataIndex: 'code', dataIndex: 'roleCode',
key: 'code', key: 'roleCode',
width: 150, width: 150,
}, },
{ {
@@ -186,13 +184,6 @@ const columns = [
key: 'description', key: 'description',
ellipsis: true, ellipsis: true,
}, },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center' as const,
},
{ {
title: '关联用户数', title: '关联用户数',
dataIndex: 'userCount', dataIndex: 'userCount',
@@ -259,16 +250,15 @@ const currentRoleId = ref<number | null>(null);
const formModalTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色')); const formModalTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色'));
const formData = reactive({ const formData = reactive({
name: '', roleName: '',
code: '', roleCode: '',
description: '', description: '',
sortOrder: 0, sortOrder: 0,
status: 1,
}); });
const formRules = { const formRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }], roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }], roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
}; };
function handleAdd() { function handleAdd() {
@@ -281,20 +271,18 @@ function handleAdd() {
function handleEdit(record: RoleInfo) { function handleEdit(record: RoleInfo) {
isEdit.value = true; isEdit.value = true;
currentRoleId.value = record.id; currentRoleId.value = record.id;
formData.name = record.name; formData.roleName = record.roleName;
formData.code = record.code; formData.roleCode = record.roleCode;
formData.description = record.description || ''; formData.description = record.description || '';
formData.sortOrder = record.sortOrder; formData.sortOrder = record.sortOrder;
formData.status = record.status;
formModalVisible.value = true; formModalVisible.value = true;
} }
function resetForm() { function resetForm() {
formData.name = ''; formData.roleName = '';
formData.code = ''; formData.roleCode = '';
formData.description = ''; formData.description = '';
formData.sortOrder = 0; formData.sortOrder = 0;
formData.status = 1;
formRef.value?.resetFields(); formRef.value?.resetFields();
} }
@@ -310,18 +298,16 @@ async function handleSubmitForm() {
let res: any; let res: any;
if (isEdit.value && currentRoleId.value) { if (isEdit.value && currentRoleId.value) {
res = await roleApi.updateRole(currentRoleId.value, { res = await roleApi.updateRole(currentRoleId.value, {
name: formData.name, roleName: formData.roleName,
description: formData.description, description: formData.description,
sortOrder: formData.sortOrder, sortOrder: formData.sortOrder,
status: formData.status,
}); });
} else { } else {
res = await roleApi.createRole({ res = await roleApi.createRole({
name: formData.name, roleName: formData.roleName,
code: formData.code, roleCode: formData.roleCode,
description: formData.description, description: formData.description,
sortOrder: formData.sortOrder, sortOrder: formData.sortOrder,
status: formData.status,
}); });
} }
@@ -360,60 +346,39 @@ function handleViewUsers(_record: RoleInfo) {
const permissionModalVisible = ref(false); const permissionModalVisible = ref(false);
const permissionLoading = ref(false); const permissionLoading = ref(false);
const permissionTreeLoading = ref(false); const permissionTreeLoading = ref(false);
const permissionTree = ref<PermissionTreeNode[]>([]); const menuTree = ref<MenuTreeNode[]>([]);
const checkedKeys = ref<number[]>([]); const checkedKeys = ref<number[]>([]);
async function handlePermission(record: RoleInfo) { async function handlePermission(record: RoleInfo) {
currentRoleId.value = record.id; currentRoleId.value = record.id;
permissionModalVisible.value = true; permissionModalVisible.value = true;
checkedKeys.value = []; checkedKeys.value = [];
await loadPermissionTree(); await loadMenuTree();
} }
async function loadPermissionTree() { async function loadMenuTree() {
permissionTreeLoading.value = true; permissionTreeLoading.value = true;
try { try {
if (!currentRoleId.value) return; if (!currentRoleId.value) return;
const res: any = await roleApi.getRolePermissions(currentRoleId.value); const res: any = await roleApi.getRoleMenus(currentRoleId.value);
if (res.code === 200) { if (res.code === 200) {
permissionTree.value = res.data; menuTree.value = res.data.menuTree || [];
extractCheckedKeys(res.data); checkedKeys.value = res.data.menuIds || [];
} }
} catch (error) { } catch (error) {
message.error('获取权限数据失败'); message.error('获取菜单权限失败');
} finally { } finally {
permissionTreeLoading.value = false; permissionTreeLoading.value = false;
} }
} }
function extractCheckedKeys(tree: PermissionTreeNode[]) {
const keys: number[] = [];
function traverse(nodes: PermissionTreeNode[]) {
for (const node of nodes) {
if (node.checked) {
keys.push(node.id);
}
if (node.children?.length) {
traverse(node.children);
}
}
}
traverse(tree);
checkedKeys.value = keys;
}
async function handleSavePermissions() { async function handleSavePermissions() {
if (!currentRoleId.value) return; if (!currentRoleId.value) return;
permissionLoading.value = true; permissionLoading.value = true;
try { try {
const res: any = await roleApi.updateRolePermissions( const res: any = await roleApi.updateRoleMenus(currentRoleId.value, checkedKeys.value);
currentRoleId.value,
checkedKeys.value as unknown as number[]
);
if (res.code === 200) { if (res.code === 200) {
message.success('权限保存成功'); message.success('权限保存成功');
+66 -80
View File
@@ -1,64 +1,53 @@
<template> <template>
<div class="user-management"> <div class="user-management">
<a-card :bordered="false"> <a-card title="用户管理">
<a-form layout="inline" :model="searchForm" class="search-form"> <template #extra>
<a-form-item label="关键词">
<a-input v-model:value="searchForm.keyword" placeholder="用户名/昵称/手机号" allow-clear style="width: 220px" @pressEnter="handleSearch" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="searchForm.status" placeholder="请选择状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space> <a-space>
<a-button type="primary" @click="handleSearch"> <a-button type="primary" @click="handleAdd">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<div class="table-operations">
<a-space>
<a-button type="primary" v-permission="'user:create'" @click="handleAdd">
<template #icon><PlusOutlined /></template> <template #icon><PlusOutlined /></template>
新增用户 新增用户
</a-button> </a-button>
<a-button danger v-permission="'user:delete'" :disabled="!selectedRowKeys.length" @click="handleBatchDelete"> <a-button danger :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
<template #icon><DeleteOutlined /></template> <template #icon><DeleteOutlined /></template>
批量删除 ({{ selectedRowKeys.length }}) 批量删除 ({{ selectedRowKeys.length }})
</a-button> </a-button>
</a-space> </a-space>
</template>
<div class="search-bar">
<a-input
v-model:value="searchForm.keyword"
placeholder="用户名/姓名/手机号"
allow-clear
style="width: 220px"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 120px">
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div> </div>
<a-card :bordered="false">
<a-table <a-table
:columns="columns" :columns="columns"
:data-source="dataSource" :data-source="dataSource"
:row-selection="rowSelection" :row-selection="rowSelection"
:loading="loading" :loading="loading"
:pagination="pagination" :pagination="pagination"
:scroll="{ x: 1200 }"
row-key="id" row-key="id"
@change="handleTableChange" @change="handleTableChange"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :size="40">{{ record.nickname?.charAt(0) }}</a-avatar>
</template>
<template v-if="column.key === 'roles'"> <template v-if="column.key === 'roles'">
<a-space> <a-space>
<a-tag v-for="role in record.userRoles" :key="role.roleId" :color="getRoleColor(role.role?.code)"> <a-tag v-for="role in record.userRoles" :key="role.roleId" :color="getRoleColor(role.role?.roleCode)">
{{ role.role?.name }} {{ role.role?.roleName }}
</a-tag> </a-tag>
</a-space> </a-space>
</template> </template>
@@ -69,18 +58,21 @@
checked-children="启用" checked-children="启用"
un-checked-children="禁用" un-checked-children="禁用"
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)" @change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
v-permission="'user:update'"
/> />
</template> </template>
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-if="column.key === 'action'"> <template v-if="column.key === 'action'">
<a-space> <a-space>
<a-button type="link" size="small" v-permission="'user:update'" @click="handleEdit(record)"> <a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template> <template #icon><EditOutlined /></template>
编辑 编辑
</a-button> </a-button>
<a-popconfirm title="确定要删除该用户吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)"> <a-popconfirm title="确定要删除该用户吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger v-permission="'user:delete'"> <a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template> <template #icon><DeleteOutlined /></template>
删除 删除
</a-button> </a-button>
@@ -126,8 +118,8 @@
<a-button type="link" size="small" @click="showPassword = true">修改密码</a-button> <a-button type="link" size="small" @click="showPassword = true">修改密码</a-button>
</a-form-item> </a-form-item>
<a-form-item label="昵称" name="nickname"> <a-form-item label="姓名" name="realName">
<a-input v-model:value="formData.nickname" placeholder="请输入昵称" /> <a-input v-model:value="formData.realName" placeholder="请输入姓名" />
</a-form-item> </a-form-item>
<a-form-item label="手机号" name="phone"> <a-form-item label="手机号" name="phone">
@@ -169,6 +161,7 @@ import {
EditOutlined, EditOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { userApi, type UserInfo, type UserQuery, type RoleInfo } from '@/api/user'; import { userApi, type UserInfo, type UserQuery, type RoleInfo } from '@/api/user';
import dayjs from 'dayjs';
const loading = ref(false); const loading = ref(false);
const dataSource = ref<UserInfo[]>([]); const dataSource = ref<UserInfo[]>([]);
@@ -193,7 +186,7 @@ const formData = reactive({
username: '', username: '',
password: '', password: '',
confirmPassword: '', confirmPassword: '',
nickname: '', realName: '',
phone: '', phone: '',
email: '', email: '',
roleIds: [] as number[], roleIds: [] as number[],
@@ -214,25 +207,25 @@ const formRules = {
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' }, { min: 6, message: '密码长度不能小于6位', trigger: 'blur' },
], ],
confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }], confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }],
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }], realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }], phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
email: [{ type: 'email' as const, message: '邮箱格式不正确', trigger: 'blur' }], email: [{ type: 'email' as const, message: '邮箱格式不正确', trigger: 'blur' }],
roleIds: [{ required: true, message: '请选择角色', trigger: 'change', type: 'array' as const }], roleIds: [{ required: true, message: '请选择角色', trigger: 'change', type: 'array' as const }],
}; };
const columns = [ const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 }, { title: '用户名', dataIndex: 'username', key: 'username', ellipsis: true },
{ title: '昵称', dataIndex: 'nickname', key: 'nickname', width: 120 }, { title: '姓名', dataIndex: 'realName', key: 'realName', ellipsis: true },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 }, { title: '手机号', dataIndex: 'phone', key: 'phone', ellipsis: true },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180 }, { title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
{ title: '角色', key: 'roles', width: 200 }, { title: '角色', key: 'roles' },
{ {
title: '状态', title: '状态',
key: 'status', key: 'status',
width: 100,
align: 'center' as const, align: 'center' as const,
width: 80,
}, },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', ellipsis: true },
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const }, { title: '操作', key: 'action', width: 160, fixed: 'right' as const },
]; ];
@@ -277,7 +270,7 @@ async function fetchRoleList() {
const res: any = await userApi.getRoleList(); const res: any = await userApi.getRoleList();
if (res.code === 200) { if (res.code === 200) {
roleOptions.value = res.data.map((item: RoleInfo) => ({ roleOptions.value = res.data.map((item: RoleInfo) => ({
label: item.name, label: item.roleName,
value: item.id, value: item.id,
})); }));
} }
@@ -321,7 +314,7 @@ function handleEdit(record: UserInfo) {
resetForm(); resetForm();
formData.username = record.username; formData.username = record.username;
formData.nickname = record.nickname; formData.realName = record.realName;
formData.phone = record.phone || ''; formData.phone = record.phone || '';
formData.email = record.email || ''; formData.email = record.email || '';
formData.status = record.status; formData.status = record.status;
@@ -334,7 +327,7 @@ function resetForm() {
formData.username = ''; formData.username = '';
formData.password = ''; formData.password = '';
formData.confirmPassword = ''; formData.confirmPassword = '';
formData.nickname = ''; formData.realName = '';
formData.phone = ''; formData.phone = '';
formData.email = ''; formData.email = '';
formData.roleIds = []; formData.roleIds = [];
@@ -357,7 +350,7 @@ async function handleSubmit() {
let res: any; let res: any;
if (isEdit.value && editId.value) { if (isEdit.value && editId.value) {
const updateData: any = { const updateData: any = {
nickname: formData.nickname, realName: formData.realName,
phone: formData.phone, phone: formData.phone,
email: formData.email, email: formData.email,
roleIds: formData.roleIds, roleIds: formData.roleIds,
@@ -373,7 +366,7 @@ async function handleSubmit() {
res = await userApi.createUser({ res = await userApi.createUser({
username: formData.username, username: formData.username,
password: formData.password, password: formData.password,
nickname: formData.nickname, realName: formData.realName,
phone: formData.phone, phone: formData.phone,
email: formData.email, email: formData.email,
roleIds: formData.roleIds, roleIds: formData.roleIds,
@@ -463,37 +456,30 @@ function getRoleColor(code?: string): string {
}; };
return colorMap[code || ''] || 'default'; return colorMap[code || ''] || 'default';
} }
function formatDateTime(dateStr: string) {
if (!dateStr) return '-';
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
}
</script> </script>
<style scoped lang="less"> <style scoped>
.user-management { .user-management {
padding: 24px; padding: 20px;
}
.search-form { .search-bar {
margin-bottom: 16px;
}
.table-operations {
margin-bottom: 16px; margin-bottom: 16px;
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center; gap: 8px;
} }
:deep(.ant-table) { :deep(.ant-table) {
.ant-table-thead > tr > th { font-size: 14px;
background-color: #fafafa; }
font-weight: 600;
}
.ant-table-tbody > tr:hover > td { :deep(.ant-btn-link) {
background-color: #e6f7ff; padding: 0 4px;
}
}
:deep(.ant-avatar) {
background-color: #1890ff;
color: #fff;
}
} }
</style> </style>
+3 -40
View File
@@ -1307,51 +1307,14 @@ CREATE INDEX idx_file_business ON file_record (business_type, business_id);
-- ============================================================ -- ============================================================
-- 十三、初始数据 -- 十三、初始数据
-- 已迁移至独立脚本 db_seed.sql(幂等执行,便于单独重置种子数据)
-- 执行顺序:先 db_init.sql 建表结构 → 再 db_seed.sql 写入种子数据
-- ============================================================ -- ============================================================
-- 初始角色
INSERT INTO sys_role (role_code, role_name, description, sort_order) VALUES
('ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
('SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2),
('WORKER', '共享电工', 'APP 接单处理,无后台菜单', 3),
('CUSTOMER', '客户', '小程序查看用电/报警/报修', 4);
-- 初始菜单
INSERT INTO sys_menu (menu_name, menu_path, menu_type, sort_order, permission) VALUES
('客户管理', '/customer', 2, 1, 'customer:list'),
('项目管理', '/project', 2, 2, 'project:list'),
('运营派单', '/dispatch', 2, 3, 'dispatch:list'),
('设备监控', '/device', 2, 4, 'device:list'),
('统计报表', '/report', 2, 5, 'report:list'),
('系统管理', '/system', 2, 6, 'system:list');
-- 初始字典
INSERT INTO sys_dict (dict_type, dict_label, dict_value, sort_order) VALUES
('customer_status', '意向', '1', 1),
('customer_status', '签约', '2', 2),
('customer_status', '运维中', '3', 3),
('customer_status', '终止', '4', 4),
('equip_type', '配电间', '配电间', 1),
('equip_type', '配电井', '配电井', 2),
('equip_type', '配电箱', '配电箱', 3),
('device_type', '网关', '网关', 1),
('device_type', '探测器', '探测器', 2),
('device_type', '采集器', '采集器', 3),
('dispatch_status', '已派发', '0', 1),
('dispatch_status', '已接单', '1', 2),
('dispatch_status', '已完成', '2', 3),
('pay_cycle', '按月支付', '按月', 1),
('pay_cycle', '按年支付', '按年', 2),
('dispatch_type', '勘察派单', 'survey', 1),
('dispatch_type', '安装派单', 'install', 2),
('dispatch_type', '报警派单', 'alarm', 3),
('dispatch_type', '维修派单', 'repair', 4),
('dispatch_type', '维保派单', 'maintenance', 5);
COMMIT; COMMIT;
-- ============================================================ -- ============================================================
-- 完 · 37 张表 · 50+ 索引 · 3 个触发器 · 3 个分区管理函数 · 1 个物化视图 · 2 个 pg_cron 定时任务 · 初始数据 -- 完 · 37 张表 · 50+ 索引 · 3 个触发器 · 3 个分区管理函数 · 1 个物化视图 · 2 个 pg_cron 定时任务(种子数据见 db_seed.sql)
-- ============================================================ -- ============================================================
-- ============================================================ -- ============================================================
+150
View File
@@ -0,0 +1,150 @@
-- ============================================================
-- 鑫潼智电系统 PostgreSQL 种子数据脚本 V1.0
--
-- 依赖: db_init.sql(须先执行,确保表结构已创建)
--
-- 用法:
-- psql -U postgres -d xintong -f db_seed.sql
--
-- 特性:
-- - 独立事务(BEGIN/COMMIT)
-- - 幂等执行:
-- sys_role / sys_menu / sys_user / sys_user_role / sys_role_menu
-- 使用 ON CONFLICT (id / 联合主键) DO UPDATE | DO NOTHING
-- sys_dict 无业务唯一键,使用 TRUNCATE RESTART IDENTITY 清空重建
-- sys_user 可能被业务表外键引用,禁用 TRUNCATE CASCADE
-- - 显式重置序列,确保 id 不漂移
--
-- 内容:
-- 1. 初始角色(ADMIN / SALESMAN / WORKER / CUSTOMER)
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 5)
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
-- 4. 管理员账户(admin / admin123456)
-- 5. 用户-角色绑定 / 角色-菜单绑定
-- ============================================================
BEGIN;
-- ------------------------------------------------------------
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN 3=WORKER 4=CUSTOMER)
-- ------------------------------------------------------------
INSERT INTO sys_role (id, role_code, role_name, description, sort_order) VALUES
(1, 'ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2),
(3, 'WORKER', '共享电工', 'APP 接单处理,无后台菜单', 3),
(4, 'CUSTOMER', '客户', '小程序查看用电/报警/报修', 4)
ON CONFLICT (id) DO UPDATE SET
role_code = EXCLUDED.role_code,
role_name = EXCLUDED.role_name,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order;
-- 重置 sys_role 自增序列
SELECT setval('sys_role_id_seq', 4, true);
-- ------------------------------------------------------------
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理为目录类型 menu_type=1,其余为 menu_type=2)
-- ------------------------------------------------------------
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(1, NULL, '客户管理', '/customer', NULL, 'TeamOutlined', 1, 2, 'customer:list', 1),
(2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 2, 'project:list', 1),
(3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 2, 'dispatch:list', 1),
(4, NULL, '设备监控', '/device', NULL, 'MonitorOutlined', 4, 2, 'device:list', 1),
(5, NULL, '统计报表', '/report', NULL, 'BarChartOutlined', 5, 2, 'report:list', 1),
(6, NULL, '系统管理', '/system', NULL, 'SettingOutlined', 6, 1, 'system:list', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 初始菜单(系统管理子菜单,parent_id=6,显式指定 id:7-11)
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(7, 6, '用户管理', '/system/user', 'system/user/index', 'UserOutlined', 1, 2, 'system:user', 1),
(8, 6, '角色管理', '/system/role', 'system/role/index', 'SafetyCertificateOutlined', 2, 2, 'system:role', 1),
(9, 6, '菜单管理', '/system/menu', 'system/menu/index', 'MenuOutlined', 3, 2, 'system:menu', 1),
(10, 6, '数据字典', '/system/dict', 'system/dict/index', 'BookOutlined', 4, 2, 'system:dict', 1),
(11, 6, '操作日志', '/log/operation', 'log/operation/index', 'FileTextOutlined', 5, 2, 'system:log', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 11, true);
-- ------------------------------------------------------------
-- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行)
-- ------------------------------------------------------------
TRUNCATE sys_dict RESTART IDENTITY;
INSERT INTO sys_dict (dict_type, dict_label, dict_value, sort_order) VALUES
('customer_status', '意向', '1', 1),
('customer_status', '签约', '2', 2),
('customer_status', '运维中', '3', 3),
('customer_status', '终止', '4', 4),
('equip_type', '配电间', '配电间', 1),
('equip_type', '配电井', '配电井', 2),
('equip_type', '配电箱', '配电箱', 3),
('device_type', '网关', '网关', 1),
('device_type', '探测器', '探测器', 2),
('device_type', '采集器', '采集器', 3),
('dispatch_status', '已派发', '0', 1),
('dispatch_status', '已接单', '1', 2),
('dispatch_status', '已完成', '2', 3),
('pay_cycle', '按月支付', '按月', 1),
('pay_cycle', '按年支付', '按年', 2),
('dispatch_type', '勘察派单', 'survey', 1),
('dispatch_type', '安装派单', 'install', 2),
('dispatch_type', '报警派单', 'alarm', 3),
('dispatch_type', '维修派单', 'repair', 4),
('dispatch_type', '维保派单', 'maintenance', 5);
-- ------------------------------------------------------------
-- 4. 管理员账户(显式指定 id=1,密码哈希为 bcrypt('admin123456'))
-- sys_user 可能被业务表外键引用,使用 ON CONFLICT 而非 TRUNCATE,避免业务数据被级联清空
-- ------------------------------------------------------------
INSERT INTO sys_user (id, username, password_hash, real_name, phone, email, status) VALUES
(1, 'admin', '$2a$10$GC/0CaPf9Ti/yeNVSZ8L3OKvCS1MrAY.aohg9UerB6heyp78Fo17.', '运营管理员', NULL, NULL, 1)
ON CONFLICT (id) DO UPDATE SET
username = EXCLUDED.username,
password_hash = EXCLUDED.password_hash,
real_name = EXCLUDED.real_name,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
status = EXCLUDED.status;
-- 重置 sys_user 自增序列
SELECT setval('sys_user_id_seq', 1, true);
-- ------------------------------------------------------------
-- 5. 用户-角色绑定:admin → ADMIN
-- ------------------------------------------------------------
INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
ON CONFLICT (user_id, role_id) DO NOTHING;
-- ------------------------------------------------------------
-- 6. 角色-菜单绑定:ADMIN 拥有全部 11 条菜单,SALESMAN 拥有业务菜单(客户/项目/统计报表)
-- WORKER、CUSTOMER 不绑定任何菜单
-- ------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
(2, 1), (2, 2), (2, 5)
ON CONFLICT (role_id, menu_id) DO NOTHING;
COMMIT;
-- ============================================================
-- 完 · 7 张系统表种子数据(4 角色 + 11 菜单 + 20 字典 + 1 管理员 + 绑定关系)
-- ============================================================
+3 -2
View File
@@ -19,13 +19,14 @@
"@midwayjs/validate": "^3.19.0", "@midwayjs/validate": "^3.19.0",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cache-manager-ioredis": "^2.1.5", "cache-manager-ioredis": "^2.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mysql2": "^3.12.0", "pg": "^8.11.0",
"pg-hstore": "^2.3.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"sequelize-typescript": "^2.1.6", "sequelize-typescript": "^2.1.6",
+6 -23
View File
@@ -11,12 +11,12 @@ export default {
sequelize: { sequelize: {
dataSource: { dataSource: {
default: { default: {
dialect: "mysql", dialect: "postgres",
host: process.env.MYSQL_HOST || "localhost", host: process.env.PG_HOST || "localhost",
port: Number(process.env.MYSQL_PORT) || 3306, port: Number(process.env.PG_PORT) || 5432,
database: process.env.MYSQL_DATABASE || "smart_grid_iot", database: process.env.PG_DATABASE || "xintong",
username: process.env.MYSQL_USERNAME || "root", username: process.env.PG_USERNAME || "postgres",
password: process.env.MYSQL_PASSWORD || "123", password: process.env.PG_PASSWORD || "postgres",
timezone: "+08:00", timezone: "+08:00",
models: [join(__dirname, "../entity")], models: [join(__dirname, "../entity")],
define: { define: {
@@ -62,22 +62,5 @@ export default {
csrf: { csrf: {
enable: false enable: false
} }
},
middleware: ["authMiddleware", "rbacMiddleware", "logMiddleware"],
middlewareConfig: {
authMiddleware: {
match: "/api/**",
ignore: ["/api/auth/**", "/api/health"]
},
rbacMiddleware: {
match: "/api/**",
ignore: ["/api/auth/**", "/api/health", "/api/dashboard/**"]
},
logMiddleware: {
match: "/api/**",
ignore: ["/api/logs/**", "/api/health", "/api/auth/login"]
}
} }
} as MidwayConfig; } as MidwayConfig;
+1 -1
View File
@@ -1,5 +1,5 @@
export default { export default {
sequelize: { sequelize: {
sync: true, sync: false,
}, },
}; };
+9 -1
View File
@@ -1,15 +1,23 @@
import { Configuration, ILifeCycle } from "@midwayjs/core"; import { Configuration, App, ILifeCycle } from "@midwayjs/core";
import * as Koa from "@midwayjs/koa"; import * as Koa from "@midwayjs/koa";
import * as Sequelize from "@midwayjs/sequelize"; import * as Sequelize from "@midwayjs/sequelize";
import * as CacheManager from "@midwayjs/cache-manager"; import * as CacheManager from "@midwayjs/cache-manager";
import { join } from "path"; import { join } from "path";
import { AuthMiddleware } from "./middleware/auth.middleware";
import { RbacMiddleware } from "./middleware/rbac.middleware";
import { LogMiddleware } from "./middleware/log.middleware";
@Configuration({ @Configuration({
imports: [Koa, Sequelize, CacheManager], imports: [Koa, Sequelize, CacheManager],
importConfigs: [join(__dirname, "./config")] importConfigs: [join(__dirname, "./config")]
}) })
export class ContainerConfigLifeCycle implements ILifeCycle { export class ContainerConfigLifeCycle implements ILifeCycle {
@App()
app!: Koa.Application;
async onReady() { async onReady() {
// 注册全局中间件(顺序:鉴权 → 权限 → 日志)
this.app.useMiddleware([AuthMiddleware, RbacMiddleware, LogMiddleware]);
console.log("智能物联网设备后台管理系统启动成功"); console.log("智能物联网设备后台管理系统启动成功");
} }
+19 -22
View File
@@ -4,9 +4,9 @@ import { AuthService } from "../service/auth.service";
import { TokenUtil } from "../util/token"; import { TokenUtil } from "../util/token";
import { PermissionService } from "../service/permission.service"; import { PermissionService } from "../service/permission.service";
import { InjectRepository } from "@midwayjs/sequelize"; import { InjectRepository } from "@midwayjs/sequelize";
import User from "../entity/user.entity"; import SysUser from "../entity/sysUser.entity";
import UserRole from "../entity/userRole.entity"; import SysUserRole from "../entity/sysUserRole.entity";
import Role from "../entity/role.entity"; import SysRole from "../entity/sysRole.entity";
@Controller("/api/auth") @Controller("/api/auth")
export class AuthController { export class AuthController {
@@ -22,14 +22,14 @@ export class AuthController {
@Inject() @Inject()
ctx!: Context; ctx!: Context;
@InjectRepository(User) @InjectRepository(SysUser)
userModel!: typeof User; userModel!: typeof SysUser;
@InjectRepository(UserRole) @InjectRepository(SysUserRole)
userRoleModel!: typeof UserRole; userRoleModel!: typeof SysUserRole;
@InjectRepository(Role) @InjectRepository(SysRole)
roleModel!: typeof Role; roleModel!: typeof SysRole;
@Post("/login") @Post("/login")
async login(@Body() body: { username: string; password: string }) { async login(@Body() body: { username: string; password: string }) {
@@ -110,14 +110,7 @@ export class AuthController {
const decoded = this.tokenUtil.verifyToken(token); const decoded = this.tokenUtil.verifyToken(token);
const userId = decoded.userId; const userId = decoded.userId;
const user = await this.userModel.findByPk(userId, { const user = await this.userModel.findByPk(userId);
include: [
{
model: this.userRoleModel,
include: [this.roleModel]
}
]
});
if (!user) { if (!user) {
return { return {
code: 404, code: 404,
@@ -125,8 +118,13 @@ export class AuthController {
}; };
} }
const permissions = await this.permissionService.getUserPermissions(userId); const userRoles = await this.userRoleModel.findAll({
const roles = (user as any).userRoles?.map((ur: any) => ur.role?.code).filter(Boolean) || []; where: { userId },
include: [this.roleModel]
});
const roles = userRoles.map(ur => ur.role?.roleCode).filter(Boolean) || [];
const menus = await this.permissionService.getUserMenus(userId);
return { return {
code: 200, code: 200,
@@ -134,10 +132,9 @@ export class AuthController {
data: { data: {
id: user.id, id: user.id,
username: user.username, username: user.username,
nickname: user.nickname || user.username, realName: user.realName,
avatar: user.avatar,
roles, roles,
permissions menus
} }
}; };
} catch (error: any) { } catch (error: any) {
+42
View File
@@ -0,0 +1,42 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { DictService } from "../service/dict.service";
@Controller("/api/dicts")
export class DictController {
@Inject()
ctx!: Context;
@Inject()
dictService!: DictService;
@Get("/")
async list(@Query() query: { page?: number; pageSize?: number; dictType?: string; keyword?: string; status?: number }) {
const result = await this.dictService.list(query);
return result;
}
@Get("/types")
async types() {
const result = await this.dictService.types();
return result;
}
@Post("/")
async create(@Body() body: any) {
const result = await this.dictService.create(body);
return result;
}
@Put("/:id")
async update(@Param("id") id: string, @Body() body: any) {
const result = await this.dictService.update(id, body);
return result;
}
@Del("/:id")
async remove(@Param("id") id: string) {
const result = await this.dictService.remove(id);
return result;
}
}
+3 -9
View File
@@ -1,13 +1,7 @@
import { Controller, Get, Inject, Query, Param, All } from "@midwayjs/core"; import { Controller, Get, Del, Inject, Query, Param, All } from "@midwayjs/core";
import { Context } from "@midwayjs/koa"; import { Context } from "@midwayjs/koa";
import { LogService } from "../service/log.service"; import { LogService } from "../service/log.service";
const Delete = (path?: string) => {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
return descriptor;
};
};
@Controller("/api/logs") @Controller("/api/logs")
export class LogController { export class LogController {
@Inject() @Inject()
@@ -17,7 +11,7 @@ export class LogController {
logService!: LogService; logService!: LogService;
@Get("/") @Get("/")
async list(@Query() query: { page?: number; pageSize?: number; moduleName?: string; actionType?: string; startDate?: string; endDate?: string; operator?: string }) { async list(@Query() query: { page?: number; pageSize?: number; userId?: string; module?: string; action?: string; startDate?: string; endDate?: string }) {
const result = await this.logService.list(query); const result = await this.logService.list(query);
return result; return result;
} }
@@ -46,7 +40,7 @@ export class LogController {
} }
} }
@Delete("/clear") @Del("/clear")
async clear() { async clear() {
const result = await this.logService.cleanExpiredLogs(0); const result = await this.logService.cleanExpiredLogs(0);
return { return {
+36
View File
@@ -0,0 +1,36 @@
import { Controller, Get, Post, Put, Del, Inject, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { MenuService } from "../service/menu.service";
@Controller("/api/menus")
export class MenuController {
@Inject()
ctx!: Context;
@Inject()
menuService!: MenuService;
@Get("/tree")
async getMenuTree() {
const result = await this.menuService.getMenuTree();
return result;
}
@Post("/")
async create(@Body() body: any) {
const result = await this.menuService.create(body);
return result;
}
@Put("/:id")
async update(@Param("id") id: string, @Body() body: any) {
const result = await this.menuService.update(id, body);
return result;
}
@Del("/:id")
async remove(@Param("id") id: string) {
const result = await this.menuService.remove(id);
return result;
}
}
+8 -14
View File
@@ -1,13 +1,7 @@
import { Controller, Get, Post, Put, Inject, Query, Body, Param } from "@midwayjs/core"; import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa"; import { Context } from "@midwayjs/koa";
import { RoleService } from "../service/role.service"; import { RoleService } from "../service/role.service";
const Delete = (path?: string) => {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
return descriptor;
};
};
@Controller("/api/roles") @Controller("/api/roles")
export class RoleController { export class RoleController {
@Inject() @Inject()
@@ -46,21 +40,21 @@ export class RoleController {
return result; return result;
} }
@Delete("/:id") @Del("/:id")
async delete(@Param("id") id: string) { async delete(@Param("id") id: string) {
const result = await this.roleService.delete(id); const result = await this.roleService.delete(id);
return result; return result;
} }
@Get("/:id/permissions") @Get("/:id/menus")
async getPermissions(@Param("id") id: string) { async getMenus(@Param("id") id: string) {
const result = await this.roleService.getPermissions(Number(id)); const result = await this.roleService.getMenus(Number(id));
return result; return result;
} }
@Put("/:id/permissions") @Put("/:id/menus")
async updatePermissions(@Param("id") id: string, @Body() body: { permissionIds: number[] }) { async updateMenus(@Param("id") id: string, @Body() body: { menuIds: number[] }) {
const result = await this.roleService.updatePermissions(Number(id), body.permissionIds); const result = await this.roleService.updateMenus(Number(id), body.menuIds);
return result; return result;
} }
} }
@@ -1,32 +0,0 @@
import { Controller, Get, Put, Inject, Body } from "@midwayjs/core";
import { SystemService } from "../service/system.service";
@Controller("/api/settings")
export class SystemController {
@Inject()
systemService!: SystemService;
@Get("/")
async getSettings() {
const result = await this.systemService.getSettings();
return result;
}
@Put("/basic")
async updateBasicSettings(@Body() body: any) {
const result = await this.systemService.updateBasicSettings(body);
return result;
}
@Put("/security")
async updateSecuritySettings(@Body() body: any) {
const result = await this.systemService.updateSecuritySettings(body);
return result;
}
@Put("/notification")
async updateNotificationSettings(@Body() body: any) {
const result = await this.systemService.updateNotificationSettings(body);
return result;
}
}
+6 -10
View File
@@ -1,13 +1,7 @@
import { Controller, Get, Post, Put, Inject, Query, Body, Param } from "@midwayjs/core"; import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa"; import { Context } from "@midwayjs/koa";
import { UserService } from "../service/user.service"; import { UserService } from "../service/user.service";
const Delete = (path?: string) => {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
return descriptor;
};
};
@Controller("/api/users") @Controller("/api/users")
export class UserController { export class UserController {
@Inject() @Inject()
@@ -46,15 +40,17 @@ export class UserController {
return result; return result;
} }
@Delete("/:id") @Del("/:id")
async delete(@Param("id") id: string) { async delete(@Param("id") id: string) {
const result = await this.userService.delete(id); const currentUserId = this.ctx.state.user?.userId;
const result = await this.userService.delete(id, currentUserId);
return result; return result;
} }
@Post("/batch-delete") @Post("/batch-delete")
async batchDelete(@Body() body: { ids: number[] }) { async batchDelete(@Body() body: { ids: number[] }) {
const result = await this.userService.batchDelete(body.ids); const currentUserId = this.ctx.state.user?.userId;
const result = await this.userService.batchDelete(body.ids, currentUserId);
return result; return result;
} }
+67
View File
@@ -0,0 +1,67 @@
import { IsNotEmpty, IsString, IsOptional, IsInt, IsIn } from "class-validator";
export class CreateDictDTO {
@IsNotEmpty({ message: "字典类型不能为空" })
@IsString()
dictType!: string;
@IsNotEmpty({ message: "字典标签不能为空" })
@IsString()
dictLabel!: string;
@IsNotEmpty({ message: "字典键值不能为空" })
@IsString()
dictValue!: string;
@IsOptional()
@IsInt()
sortOrder?: number = 0;
@IsOptional()
@IsIn([0, 1], { message: "状态只能为 0 或 1" })
status?: number = 1;
}
export class UpdateDictDTO {
@IsOptional()
@IsString()
dictType?: string;
@IsOptional()
@IsString()
dictLabel?: string;
@IsOptional()
@IsString()
dictValue?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
@IsOptional()
@IsIn([0, 1], { message: "状态只能为 0 或 1" })
status?: number;
}
export class QueryDictDTO {
@IsOptional()
@IsInt()
page?: number;
@IsOptional()
@IsInt()
pageSize?: number;
@IsOptional()
@IsString()
dictType?: string;
@IsOptional()
@IsString()
keyword?: string;
@IsOptional()
@IsIn([0, 1])
status?: number;
}
+77
View File
@@ -0,0 +1,77 @@
import { IsNotEmpty, IsString, IsOptional, IsInt, IsIn } from "class-validator";
export class CreateMenuDTO {
@IsOptional()
@IsInt()
parentId?: number;
@IsNotEmpty({ message: "菜单名称不能为空" })
@IsString()
menuName!: string;
@IsOptional()
@IsString()
menuPath?: string;
@IsOptional()
@IsString()
component?: string;
@IsOptional()
@IsString()
icon?: string;
@IsOptional()
@IsInt()
sortOrder?: number = 0;
@IsOptional()
@IsIn([1, 2])
menuType?: number = 2;
@IsOptional()
@IsString()
permission?: string;
@IsOptional()
@IsIn([0, 1])
visible?: number = 1;
}
export class UpdateMenuDTO {
@IsOptional()
@IsInt()
parentId?: number;
@IsOptional()
@IsString()
menuName?: string;
@IsOptional()
@IsString()
menuPath?: string;
@IsOptional()
@IsString()
component?: string;
@IsOptional()
@IsString()
icon?: string;
@IsOptional()
@IsInt()
sortOrder?: number;
@IsOptional()
@IsIn([1, 2])
menuType?: number;
@IsOptional()
@IsString()
permission?: string;
@IsOptional()
@IsIn([0, 1])
visible?: number;
}
+12 -20
View File
@@ -1,24 +1,18 @@
import { IsNotEmpty, IsString, IsOptional, IsInt, Min, Max, IsArray } from "class-validator"; import { IsNotEmpty, IsString, IsOptional, IsInt, IsArray } from "class-validator";
export class CreateRoleDTO { export class CreateRoleDTO {
@IsNotEmpty({ message: "角色标识不能为空" }) @IsNotEmpty({ message: "角色编码不能为空" })
@IsString() @IsString()
code!: string; roleCode!: string;
@IsNotEmpty({ message: "角色名称不能为空" }) @IsNotEmpty({ message: "角色名称不能为空" })
@IsString() @IsString()
name!: string; roleName!: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(1)
status?: number = 1;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
sortOrder?: number = 0; sortOrder?: number = 0;
@@ -27,25 +21,23 @@ export class CreateRoleDTO {
export class UpdateRoleDTO { export class UpdateRoleDTO {
@IsOptional() @IsOptional()
@IsString() @IsString()
name?: string; roleCode?: string;
@IsOptional()
@IsString()
roleName?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(1)
status?: number;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
sortOrder?: number; sortOrder?: number;
} }
export class UpdatePermissionDTO { export class UpdateMenusDTO {
@IsNotEmpty({ message: "权限ID列表不能为空" }) @IsNotEmpty({ message: "菜单ID列表不能为空" })
@IsArray() @IsArray()
permissionIds!: number[]; menuIds!: number[];
} }
+7 -9
View File
@@ -13,10 +13,11 @@ export class CreateUserDTO {
@MaxLength(20, { message: "密码长度不能超过20位" }) @MaxLength(20, { message: "密码长度不能超过20位" })
password!: string; password!: string;
@IsNotEmpty({ message: "昵称不能为空" }) @IsNotEmpty({ message: "真实姓名不能为空" })
@IsString() @IsString()
@MinLength(2, { message: "昵称长度不能小于2位" }) @MinLength(2, { message: "真实姓名长度不能小于2位" })
nickname!: string; @MaxLength(64, { message: "真实姓名长度不能超过64位" })
realName!: string;
@IsOptional() @IsOptional()
@IsEmail({}, { message: "邮箱格式不正确" }) @IsEmail({}, { message: "邮箱格式不正确" })
@@ -39,8 +40,9 @@ export class CreateUserDTO {
export class UpdateUserDTO { export class UpdateUserDTO {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(2, { message: "昵称长度不能小于2位" }) @MinLength(2, { message: "真实姓名长度不能小于2位" })
nickname?: string; @MaxLength(64, { message: "真实姓名长度不能超过64位" })
realName?: string;
@IsOptional() @IsOptional()
@IsEmail({}, { message: "邮箱格式不正确" }) @IsEmail({}, { message: "邮箱格式不正确" })
@@ -50,10 +52,6 @@ export class UpdateUserDTO {
@Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" }) @Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" })
phone?: string; phone?: string;
@IsOptional()
@IsString()
avatar?: string;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
roleIds?: number[]; roleIds?: number[];
-112
View File
@@ -1,112 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "operation_logs",
comment: "操作日志表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class OperationLog extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@Column({
type: DataType.BIGINT,
field: "user_id",
comment: "操作用户ID"
})
userId!: number;
@Column({
type: DataType.STRING(50),
field: "username",
comment: "操作人用户名"
})
username!: string;
@Column({
type: DataType.STRING(255),
allowNull: false,
field: "action",
comment: "操作描述"
})
action!: string;
@Column({
type: DataType.STRING(50),
field: "module",
comment: "操作模块"
})
module!: string;
@Column({
type: DataType.STRING(10),
field: "method",
comment: "请求方法(GET/POST/PUT/DELETE等)"
})
method!: string;
@Column({
type: DataType.STRING(500),
field: "url",
comment: "请求URL"
})
url!: string;
@Column({
type: DataType.STRING(45),
field: "ip_address",
comment: "IP地址(支持IPv6)"
})
ipAddress!: string;
@Column({
type: DataType.STRING(500),
field: "user_agent",
comment: "浏览器UA"
})
userAgent!: string;
@Column({
type: DataType.TEXT,
field: "request_params",
comment: "请求参数(JSON格式)"
})
requestParams!: string;
@Column({
type: DataType.TEXT,
field: "response_data",
comment: "响应数据(JSON格式)"
})
responseData!: string;
@Column({
type: DataType.INTEGER,
field: "status_code",
comment: "HTTP响应状态码"
})
statusCode!: number;
@Column({
type: DataType.INTEGER,
field: "duration",
comment: "请求耗时(毫秒)"
})
duration!: number;
@Column({
type: DataType.TEXT,
field: "error_message",
comment: "错误信息(仅失败时记录)"
})
errorMessage!: string;
@CreatedAt
created_at!: Date;
}
-95
View File
@@ -1,95 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, HasMany } from "sequelize-typescript";
import RolePermission from "./rolePermission.entity";
@Table({
tableName: "permissions",
comment: "权限表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at"
})
export default class Permission extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: "权限名称"
})
name!: string;
@Column({
type: DataType.STRING(100),
allowNull: false,
unique: true,
comment: "权限编码(唯一标识)"
})
code!: string;
@Column({
type: DataType.ENUM("menu", "button", "api"),
allowNull: false,
defaultValue: "menu",
comment: "权限类型: menu-菜单 button-按钮 api-接口"
})
type!: string;
@Column({
type: DataType.BIGINT,
field: "parent_id",
comment: "父权限ID(NULL表示一级菜单)"
})
parentId!: number;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
field: "sort_order",
comment: "排序(数值越小越靠前)"
})
sortOrder!: number;
@Column({
type: DataType.STRING(50),
field: "icon",
comment: "图标(菜单类型使用)"
})
icon!: string;
@Column({
type: DataType.STRING(255),
field: "route",
comment: "路由路径(菜单类型使用)"
})
route!: string;
@Column({
type: DataType.STRING(255),
field: "component",
comment: "组件路径(菜单类型使用)"
})
component!: string;
@Column({
type: DataType.TINYINT,
allowNull: false,
defaultValue: 1,
comment: "状态: 0-禁用 1-启用"
})
status!: number;
@HasMany(() => RolePermission)
rolePermissions!: RolePermission[];
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
-70
View File
@@ -1,70 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, HasMany } from "sequelize-typescript";
import UserRole from "./userRole.entity";
import RolePermission from "./rolePermission.entity";
@Table({
tableName: "roles",
comment: "角色表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at"
})
export default class Role extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@Column({
type: DataType.STRING(50),
allowNull: false,
comment: "角色名称"
})
name!: string;
@Column({
type: DataType.STRING(50),
allowNull: false,
unique: true,
comment: "角色编码(唯一标识)"
})
code!: string;
@Column({
type: DataType.STRING(255),
field: "description",
comment: "描述"
})
description!: string;
@Column({
type: DataType.TINYINT,
allowNull: false,
defaultValue: 1,
comment: "状态: 0-禁用 1-启用"
})
status!: number;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
field: "sort_order",
comment: "排序(数值越小越靠前)"
})
sortOrder!: number;
@HasMany(() => UserRole)
userRoles!: UserRole[];
@HasMany(() => RolePermission)
rolePermissions!: RolePermission[];
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
@@ -1,46 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Role from "./role.entity";
import Permission from "./permission.entity";
@Table({
tableName: "role_permissions",
comment: "角色权限关联表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class RolePermission extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@ForeignKey(() => Role)
@Column({
type: DataType.BIGINT,
allowNull: false,
field: "role_id",
comment: "角色ID"
})
roleId!: number;
@ForeignKey(() => Permission)
@Column({
type: DataType.BIGINT,
allowNull: false,
field: "permission_id",
comment: "权限ID"
})
permissionId!: number;
@BelongsTo(() => Role)
role!: Role;
@BelongsTo(() => Permission)
permission!: Permission;
@CreatedAt
created_at!: Date;
}
+61
View File
@@ -0,0 +1,61 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "sys_dict",
comment: "系统字典表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class SysDict extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@Column({
type: DataType.STRING(64),
allowNull: false,
field: "dict_type",
comment: "字典类型"
})
dictType!: string;
@Column({
type: DataType.STRING(128),
allowNull: false,
field: "dict_label",
comment: "字典标签"
})
dictLabel!: string;
@Column({
type: DataType.STRING(128),
allowNull: false,
field: "dict_value",
comment: "字典键值"
})
dictValue!: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
field: "sort_order",
comment: "排序(数值越小越靠前)"
})
sortOrder!: number;
@Column({
type: DataType.SMALLINT,
allowNull: false,
defaultValue: 1,
comment: "状态: 0-禁用 1-启用"
})
status!: number;
@CreatedAt
created_at!: Date;
}
+93
View File
@@ -0,0 +1,93 @@
import { Column, Model, Table, DataType, CreatedAt, HasMany } from "sequelize-typescript";
import SysRoleMenu from "./sysRoleMenu.entity";
@Table({
tableName: "sys_menu",
comment: "系统菜单表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class SysMenu extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@Column({
type: DataType.BIGINT,
field: "parent_id",
comment: "父菜单ID(NULL表示一级菜单)"
})
parentId!: number;
@Column({
type: DataType.STRING(64),
allowNull: false,
field: "menu_name",
comment: "菜单名称"
})
menuName!: string;
@Column({
type: DataType.STRING(256),
field: "menu_path",
comment: "菜单路由路径"
})
menuPath!: string;
@Column({
type: DataType.STRING(256),
field: "component",
comment: "组件路径"
})
component!: string;
@Column({
type: DataType.STRING(64),
field: "icon",
comment: "菜单图标"
})
icon!: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
field: "sort_order",
comment: "排序(数值越小越靠前)"
})
sortOrder!: number;
@Column({
type: DataType.SMALLINT,
allowNull: false,
defaultValue: 1,
field: "menu_type",
comment: "菜单类型: 1-目录 2-菜单 3-按钮"
})
menuType!: number;
@Column({
type: DataType.STRING(128),
field: "permission",
comment: "权限标识(按钮类型使用)"
})
permission!: string;
@Column({
type: DataType.SMALLINT,
allowNull: false,
defaultValue: 1,
comment: "是否可见: 0-隐藏 1-显示"
})
visible!: number;
@HasMany(() => SysRoleMenu)
sysRoleMenus!: SysRoleMenu[];
@CreatedAt
created_at!: Date;
}
@@ -0,0 +1,62 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "sys_operation_log",
comment: "系统操作日志表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class SysOperationLog extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@Column({
type: DataType.BIGINT,
field: "user_id",
comment: "操作用户ID"
})
userId!: number;
@Column({
type: DataType.STRING(64),
field: "module",
comment: "操作模块"
})
module!: string;
@Column({
type: DataType.STRING(64),
field: "action",
comment: "操作动作"
})
action!: string;
@Column({
type: DataType.STRING(256),
field: "method",
comment: "请求方法(GET/POST/PUT/DELETE等)"
})
method!: string;
@Column({
type: DataType.TEXT,
field: "params",
comment: "请求参数(JSON格式)"
})
params!: string;
@Column({
type: DataType.STRING(64),
field: "ip",
comment: "操作IP(支持IPv6)"
})
ip!: string;
@CreatedAt
created_at!: Date;
}
+61
View File
@@ -0,0 +1,61 @@
import { Column, Model, Table, DataType, CreatedAt, HasMany } from "sequelize-typescript";
import SysUserRole from "./sysUserRole.entity";
import SysRoleMenu from "./sysRoleMenu.entity";
@Table({
tableName: "sys_role",
comment: "系统角色表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class SysRole extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@Column({
type: DataType.STRING(32),
allowNull: false,
unique: true,
field: "role_code",
comment: "角色编码(唯一标识)"
})
roleCode!: string;
@Column({
type: DataType.STRING(64),
allowNull: false,
field: "role_name",
comment: "角色名称"
})
roleName!: string;
@Column({
type: DataType.TEXT,
field: "description",
comment: "角色描述"
})
description!: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
field: "sort_order",
comment: "排序(数值越小越靠前)"
})
sortOrder!: number;
@HasMany(() => SysUserRole)
sysUserRoles!: SysUserRole[];
@HasMany(() => SysRoleMenu)
sysRoleMenus!: SysRoleMenu[];
@CreatedAt
created_at!: Date;
}
+36
View File
@@ -0,0 +1,36 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from "sequelize-typescript";
import SysRole from "./sysRole.entity";
import SysMenu from "./sysMenu.entity";
@Table({
tableName: "sys_role_menu",
comment: "系统角色菜单关联表",
timestamps: false
})
export default class SysRoleMenu extends Model {
@ForeignKey(() => SysRole)
@Column({
type: DataType.BIGINT,
allowNull: false,
primaryKey: true,
field: "role_id",
comment: "角色ID"
})
roleId!: number;
@ForeignKey(() => SysMenu)
@Column({
type: DataType.BIGINT,
allowNull: false,
primaryKey: true,
field: "menu_id",
comment: "菜单ID"
})
menuId!: number;
@BelongsTo(() => SysRole)
role!: SysRole;
@BelongsTo(() => SysMenu)
menu!: SysMenu;
}
@@ -1,14 +1,14 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, HasMany } from "sequelize-typescript"; import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, HasMany } from "sequelize-typescript";
import UserRole from "./userRole.entity"; import SysUserRole from "./sysUserRole.entity";
@Table({ @Table({
tableName: "users", tableName: "sys_user",
comment: "用户表", comment: "系统用户表",
timestamps: true, timestamps: true,
createdAt: "created_at", createdAt: "created_at",
updatedAt: "updated_at" updatedAt: "updated_at"
}) })
export default class User extends Model { export default class SysUser extends Model {
@Column({ @Column({
type: DataType.BIGINT, type: DataType.BIGINT,
autoIncrement: true, autoIncrement: true,
@@ -17,7 +17,7 @@ export default class User extends Model {
id!: number; id!: number;
@Column({ @Column({
type: DataType.STRING(50), type: DataType.STRING(64),
allowNull: false, allowNull: false,
unique: true, unique: true,
comment: "用户名" comment: "用户名"
@@ -25,25 +25,20 @@ export default class User extends Model {
username!: string; username!: string;
@Column({ @Column({
type: DataType.STRING(255), type: DataType.STRING(256),
allowNull: false, allowNull: false,
field: "password_hash",
comment: "加密密码(bcrypt)" comment: "加密密码(bcrypt)"
}) })
password!: string; passwordHash!: string;
@Column({ @Column({
type: DataType.STRING(50), type: DataType.STRING(64),
field: "nickname", allowNull: false,
comment: "昵称" field: "real_name",
comment: "真实姓名"
}) })
nickname!: string; realName!: string;
@Column({
type: DataType.STRING(100),
field: "email",
comment: "邮箱"
})
email!: string;
@Column({ @Column({
type: DataType.STRING(20), type: DataType.STRING(20),
@@ -53,14 +48,14 @@ export default class User extends Model {
phone!: string; phone!: string;
@Column({ @Column({
type: DataType.STRING(255), type: DataType.STRING(128),
field: "avatar", field: "email",
comment: "头像URL" comment: "邮箱"
}) })
avatar!: string; email!: string;
@Column({ @Column({
type: DataType.TINYINT, type: DataType.SMALLINT,
allowNull: false, allowNull: false,
defaultValue: 1, defaultValue: 1,
comment: "状态: 0-禁用 1-启用" comment: "状态: 0-禁用 1-启用"
@@ -74,15 +69,8 @@ export default class User extends Model {
}) })
lastLoginAt!: Date; lastLoginAt!: Date;
@Column({ @HasMany(() => SysUserRole)
type: DataType.STRING(45), sysUserRoles!: SysUserRole[];
field: "last_login_ip",
comment: "最后登录IP(支持IPv6)"
})
lastLoginIp!: string;
@HasMany(() => UserRole)
userRoles!: UserRole[];
@CreatedAt @CreatedAt
created_at!: Date; created_at!: Date;
+36
View File
@@ -0,0 +1,36 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from "sequelize-typescript";
import SysUser from "./sysUser.entity";
import SysRole from "./sysRole.entity";
@Table({
tableName: "sys_user_role",
comment: "系统用户角色关联表",
timestamps: false
})
export default class SysUserRole extends Model {
@ForeignKey(() => SysUser)
@Column({
type: DataType.BIGINT,
allowNull: false,
primaryKey: true,
field: "user_id",
comment: "用户ID"
})
userId!: number;
@ForeignKey(() => SysRole)
@Column({
type: DataType.BIGINT,
allowNull: false,
primaryKey: true,
field: "role_id",
comment: "角色ID"
})
roleId!: number;
@BelongsTo(() => SysUser)
user!: SysUser;
@BelongsTo(() => SysRole)
role!: SysRole;
}
-46
View File
@@ -1,46 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import User from "./user.entity";
import Role from "./role.entity";
@Table({
tableName: "user_roles",
comment: "用户角色关联表",
timestamps: true,
createdAt: "created_at",
updatedAt: false
})
export default class UserRole extends Model {
@Column({
type: DataType.BIGINT,
autoIncrement: true,
primaryKey: true
})
id!: number;
@ForeignKey(() => User)
@Column({
type: DataType.BIGINT,
allowNull: false,
field: "user_id",
comment: "用户ID"
})
userId!: number;
@ForeignKey(() => Role)
@Column({
type: DataType.BIGINT,
allowNull: false,
field: "role_id",
comment: "角色ID"
})
roleId!: number;
@BelongsTo(() => User)
user!: User;
@BelongsTo(() => Role)
role!: Role;
@CreatedAt
created_at!: Date;
}
+3
View File
@@ -5,6 +5,9 @@ import { TokenUtil } from '../util/token';
@Provide() @Provide()
export class AuthMiddleware implements IMiddleware<Context, NextFunction> { export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,但跳过公开接口
ignore = ['/api/auth/*', '/api/health'];
@Inject() @Inject()
tokenUtil!: TokenUtil; tokenUtil!: TokenUtil;
+36 -44
View File
@@ -2,56 +2,41 @@ import { Middleware, IMiddleware } from '@midwayjs/core';
import { Context, NextFunction } from '@midwayjs/koa'; import { Context, NextFunction } from '@midwayjs/koa';
import { Provide, Inject } from '@midwayjs/core'; import { Provide, Inject } from '@midwayjs/core';
import { InjectRepository } from '@midwayjs/sequelize'; import { InjectRepository } from '@midwayjs/sequelize';
import OperationLog from '../entity/operationLog.entity'; import SysOperationLog from '../entity/sysOperationLog.entity';
@Provide() @Provide()
export class LogMiddleware implements IMiddleware<Context, NextFunction> { export class LogMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,跳过日志查询、健康检查与登录(登录由内部记录)
ignore = ['/api/logs/*', '/api/health', '/api/auth/login'];
@InjectRepository(OperationLog) @InjectRepository(SysOperationLog)
logModel!: typeof OperationLog; logModel!: typeof SysOperationLog;
resolve() { resolve() {
return async (ctx: Context, next: NextFunction) => { return async (ctx: Context, next: NextFunction) => {
const startTime = Date.now();
let logData: any = {};
try {
logData = {
method: ctx.method,
url: ctx.url,
ipAddress: ctx.ip,
userAgent: ctx.header['user-agent'],
requestParams: JSON.stringify(ctx.request.body || {}),
};
if (ctx.state.user) {
logData.userId = ctx.state.user.userId;
logData.username = ctx.state.user.username;
}
await next(); await next();
logData.statusCode = ctx.status; // 仅记录写操作,且要求登录用户存在
logData.duration = Date.now() - startTime; if (!['POST', 'PUT', 'DELETE'].includes(ctx.method)) {
return;
}
if (['POST', 'PUT', 'DELETE'].includes(ctx.method)) { if (!ctx.state.user) {
logData.action = this.getActionDescription(ctx); return;
logData.module = this.getModuleFromUrl(ctx.url); }
const logData = {
userId: ctx.state.user.userId,
module: this.getModuleFromUrl(ctx.url),
action: this.getActionDescription(ctx),
method: ctx.method,
params: JSON.stringify(ctx.request.body || {}),
ip: ctx.ip,
};
this.saveLog(logData).catch(err => { this.saveLog(logData).catch(err => {
console.error('保存操作日志失败:', err); console.error('保存操作日志失败:', err);
}); });
}
} catch (error: any) {
logData.statusCode = 500;
logData.duration = Date.now() - startTime;
logData.errorMessage = error.message;
this.saveLog(logData).catch(() => {});
throw error;
}
}; };
} }
@@ -59,18 +44,25 @@ export class LogMiddleware implements IMiddleware<Context, NextFunction> {
const actionMap: Record<string, Record<string, string>> = { const actionMap: Record<string, Record<string, string>> = {
'/api/auth/login': { POST: '用户登录' }, '/api/auth/login': { POST: '用户登录' },
'/api/auth/logout': { POST: '用户登出' }, '/api/auth/logout': { POST: '用户登出' },
'/api/user': { '/api/users': {
POST: '创建用户', POST: '创建用户',
PUT: '更新用户', PUT: '更新用户',
DELETE: '删除用户', DELETE: '删除用户',
}, },
'/api/role': { '/api/roles': {
POST: '创建角色', POST: '创建角色',
PUT: '更新角色', PUT: '更新角色',
DELETE: '删除角色', DELETE: '删除角色',
}, },
'/api/settings': { '/api/menus': {
PUT: '修改系统设置', POST: '创建菜单',
PUT: '更新菜单',
DELETE: '删除菜单',
},
'/api/dicts': {
POST: '创建字典',
PUT: '更新字典',
DELETE: '删除字典',
}, },
}; };
@@ -92,10 +84,10 @@ export class LogMiddleware implements IMiddleware<Context, NextFunction> {
private getModuleFromUrl(url: string): string { private getModuleFromUrl(url: string): string {
const moduleMap: Record<string, string> = { const moduleMap: Record<string, string> = {
'/api/auth': '认证', '/api/auth': '认证',
'/api/user': '用户管理', '/api/users': '用户管理',
'/api/role': '角色管理', '/api/roles': '角色管理',
'/api/settings': '系统设置', '/api/menus': '菜单管理',
'/api/log': '日志管理', '/api/dicts': '数据字典',
'/api/logs': '日志管理', '/api/logs': '日志管理',
'/api/dashboard': '仪表盘', '/api/dashboard': '仪表盘',
}; };
+48 -24
View File
@@ -1,14 +1,37 @@
import { Middleware, IMiddleware } from '@midwayjs/core'; import { Middleware, IMiddleware } from '@midwayjs/core';
import { Context, NextFunction } from '@midwayjs/koa'; import { Context, NextFunction } from '@midwayjs/koa';
import { Provide, Inject } from '@midwayjs/core'; import { Provide, Inject } from '@midwayjs/core';
import { InjectRepository } from '@midwayjs/sequelize';
import { PermissionService } from '../service/permission.service'; import { PermissionService } from '../service/permission.service';
import SysUserRole from '../entity/sysUserRole.entity';
import SysRole from '../entity/sysRole.entity';
// 请求路径前缀 → 菜单权限标识映射
const PERMISSION_MAP: Record<string, string> = {
'/api/users': 'system:user',
'/api/roles': 'system:role',
'/api/menus': 'system:menu',
'/api/dicts': 'system:dict',
'/api/logs': 'system:log',
};
// 超级管理员角色编码,拥有全部权限
const ADMIN_ROLE_CODE = 'ADMIN';
@Provide() @Provide()
export class RbacMiddleware implements IMiddleware<Context, NextFunction> { export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,跳过公开接口与仪表盘
ignore = ['/api/auth/*', '/api/health', '/api/dashboard/*'];
@Inject() @Inject()
permissionService!: PermissionService; permissionService!: PermissionService;
@InjectRepository(SysUserRole)
userRoleModel!: typeof SysUserRole;
@InjectRepository(SysRole)
roleModel!: typeof SysRole;
resolve() { resolve() {
return async (ctx: Context, next: NextFunction) => { return async (ctx: Context, next: NextFunction) => {
const user = ctx.state.user; const user = ctx.state.user;
@@ -22,17 +45,21 @@ export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
return; return;
} }
// 获取当前请求路径和方法,转换为权限码 // 路径前缀匹配,未匹配到任何权限映射的请求直接放行
// 例如:GET /api/user/list -> user:list const permissionCode = this.matchPermissionCode(ctx.path);
const permissionCode = this.extractPermissionCode(ctx); if (!permissionCode) {
// 超级管理员拥有所有权限
if (user.roles && user.roles.includes('super_admin')) {
await next(); await next();
return; return;
} }
// 检查用户是否拥有该权限 // 查询用户角色编码,超级管理员直接放行
const roleCodes = await this.getUserRoleCodes(user.userId);
if (roleCodes.includes(ADMIN_ROLE_CODE)) {
await next();
return;
}
// 检查用户菜单中是否包含该权限标识
const hasPermission = await this.permissionService.checkUserPermission( const hasPermission = await this.permissionService.checkUserPermission(
user.userId, user.userId,
permissionCode permissionCode
@@ -51,24 +78,21 @@ export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
}; };
} }
private extractPermissionCode(ctx: Context): string { private matchPermissionCode(path: string): string {
const url = ctx.path.replace('/api/', '').replace(/^\//, ''); for (const [prefix, code] of Object.entries(PERMISSION_MAP)) {
const method = ctx.method.toLowerCase(); if (path === prefix || path.startsWith(prefix + '/')) {
return code;
const methodMap: Record<string, string> = { }
post: 'create', }
put: 'update', return '';
delete: 'delete',
get: 'list',
};
const action = methodMap[method] || method;
const parts = url.split('/');
if (parts.length >= 2) {
return `${parts[0]}:${action}`;
} }
return `${url}:${action}`; private async getUserRoleCodes(userId: number): Promise<string[]> {
const userRoles = await this.userRoleModel.findAll({
where: { userId },
include: [this.roleModel],
});
return userRoles.map(ur => ur.role?.roleCode).filter(Boolean) as string[];
} }
} }
+27 -47
View File
@@ -1,43 +1,33 @@
import { Provide, Inject } from "@midwayjs/core"; import { Provide, Inject } from "@midwayjs/core";
import { Context } from "@midwayjs/koa"; import { Context } from "@midwayjs/koa";
import { InjectRepository } from "@midwayjs/sequelize"; import { InjectRepository } from "@midwayjs/sequelize";
import User from "../entity/user.entity"; import SysUser from "../entity/sysUser.entity";
import UserRole from "../entity/userRole.entity"; import SysUserRole from "../entity/sysUserRole.entity";
import Role from "../entity/role.entity"; import SysRole from "../entity/sysRole.entity";
import RolePermission from "../entity/rolePermission.entity";
import Permission from "../entity/permission.entity";
import { TokenUtil } from "../util/token"; import { TokenUtil } from "../util/token";
import { comparePassword } from "../util/crypto"; import { comparePassword } from "../util/crypto";
import { PermissionService } from "./permission.service";
@Provide() @Provide()
export class AuthService { export class AuthService {
@Inject() @Inject()
tokenUtil!: TokenUtil; tokenUtil!: TokenUtil;
@InjectRepository(User) @Inject()
userModel!: typeof User; permissionService!: PermissionService;
@InjectRepository(UserRole) @InjectRepository(SysUser)
userRoleModel!: typeof UserRole; userModel!: typeof SysUser;
@InjectRepository(Role) @InjectRepository(SysUserRole)
roleModel!: typeof Role; userRoleModel!: typeof SysUserRole;
@InjectRepository(RolePermission) @InjectRepository(SysRole)
rolePermissionModel!: typeof RolePermission; roleModel!: typeof SysRole;
@InjectRepository(Permission)
permissionModel!: typeof Permission;
async login(username: string, password: string, ctx: Context): Promise<any> { async login(username: string, password: string, ctx: Context): Promise<any> {
const user = await this.userModel.findOne({ const user = await this.userModel.findOne({
where: { username }, where: { username }
include: [
{
model: this.userRoleModel,
include: [this.roleModel]
}
]
}); });
if (!user) { if (!user) {
@@ -48,15 +38,20 @@ export class AuthService {
throw new Error("账户已被禁用"); throw new Error("账户已被禁用");
} }
const isPasswordValid = await comparePassword(password, user.password); const isPasswordValid = await comparePassword(password, user.passwordHash);
if (!isPasswordValid) { if (!isPasswordValid) {
throw new Error("密码错误"); throw new Error("密码错误");
} }
const roles = (user as any).userRoles?.map((ur: any) => ur.role) || []; // 获取用户角色编码
const roleIds = roles.map((r: Role) => r.id); const userRoles = await this.userRoleModel.findAll({
where: { userId: user.id },
include: [this.roleModel]
});
const roleCodes = userRoles.map(ur => ur.role?.roleCode).filter(Boolean) as string[];
const permissions = await this.getUserPermissions(roleIds); // 获取用户菜单树
const menus = await this.permissionService.getUserMenus(user.id);
const accessToken = this.tokenUtil.generateAccessToken({ const accessToken = this.tokenUtil.generateAccessToken({
userId: user.id, userId: user.id,
@@ -68,8 +63,7 @@ export class AuthService {
}); });
await user.update({ await user.update({
lastLoginAt: new Date(), lastLoginAt: new Date()
lastLoginIp: ctx.ip
}); });
return { return {
@@ -78,11 +72,10 @@ export class AuthService {
userInfo: { userInfo: {
id: user.id, id: user.id,
username: user.username, username: user.username,
nickname: user.nickname, realName: user.realName
avatar: user.avatar, },
roles: roles.map((r: Role) => r.code), roles: roleCodes,
permissions: permissions.map((p: Permission) => p.code) menus
}
}; };
} }
@@ -111,17 +104,4 @@ export class AuthService {
throw new Error("Refresh Token无效或已过期"); throw new Error("Refresh Token无效或已过期");
} }
} }
private async getUserPermissions(roleIds: number[]): Promise<Permission[]> {
if (!roleIds || roleIds.length === 0) {
return [];
}
const rolePermissions = await this.rolePermissionModel.findAll({
where: { roleId: roleIds },
include: [this.permissionModel]
});
return [...new Map(rolePermissions.map((rp: any) => [rp.permission.id, rp.permission])).values()];
}
} }
+7 -10
View File
@@ -1,17 +1,14 @@
import { Provide, Inject } from "@midwayjs/core"; import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize"; import { InjectRepository } from "@midwayjs/sequelize";
import User from "../entity/user.entity"; import SysUser from "../entity/sysUser.entity";
import OperationLog from "../entity/operationLog.entity"; import SysOperationLog from "../entity/sysOperationLog.entity";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Op } from "sequelize"; import { Op } from "sequelize";
@Provide() @Provide()
export class DashboardService { export class DashboardService {
@InjectRepository(User) @InjectRepository(SysOperationLog)
userModel!: typeof User; logModel!: typeof SysOperationLog;
@InjectRepository(OperationLog)
logModel!: typeof OperationLog;
async getDashboardStats(): Promise<any> { async getDashboardStats(): Promise<any> {
const mockDeviceTotal = 1256; const mockDeviceTotal = 1256;
@@ -103,15 +100,15 @@ export class DashboardService {
const logs = await this.logModel.findAll({ const logs = await this.logModel.findAll({
order: [["created_at", "DESC"]], order: [["created_at", "DESC"]],
limit, limit,
attributes: ["id", "username", "action", "module", "ip_address", "created_at"] attributes: ["id", "action", "module", "ip", "created_at"]
}); });
return logs.map((log) => ({ return logs.map((log) => ({
id: log.id, id: log.id,
user: log.username || "系统", user: String(log.userId || "系统"),
action: log.action, action: log.action,
module: log.module, module: log.module,
ip: log.ipAddress, ip: log.ip,
time: dayjs(log.created_at).format("MM-DD HH:mm") time: dayjs(log.created_at).format("MM-DD HH:mm")
})); }));
} catch (error) { } catch (error) {
+145
View File
@@ -0,0 +1,145 @@
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import SysDict from "../entity/sysDict.entity";
import { Op } from "sequelize";
@Provide()
export class DictService {
@InjectRepository(SysDict)
dictModel!: typeof SysDict;
async list(query: {
page?: number;
pageSize?: number;
dictType?: string;
keyword?: string;
status?: number;
}): Promise<any> {
const page = query.page || 1;
const pageSize = query.pageSize || 10;
const where: any = {};
if (query.dictType) {
where.dictType = query.dictType;
}
if (query.keyword) {
where[Op.or] = [
{ dictLabel: { [Op.like]: `%${query.keyword}%` } },
{ dictValue: { [Op.like]: `%${query.keyword}%` } },
];
}
if (query.status !== undefined && query.status !== null) {
where.status = query.status;
}
const { count, rows } = await this.dictModel.findAndCountAll({
where,
order: [["sort_order", "ASC"], ["id", "ASC"]],
limit: Number(pageSize),
offset: (Number(page) - 1) * Number(pageSize),
});
return {
code: 200,
message: "获取字典列表成功",
data: {
list: rows,
total: count,
page: Number(page),
pageSize: Number(pageSize),
},
};
}
async types(): Promise<any> {
const rows = await this.dictModel.findAll({
attributes: ["dictType"],
group: ["dictType"],
order: [["dictType", "ASC"]],
});
const types = rows.map(row => row.dictType);
return {
code: 200,
message: "获取字典类型成功",
data: types,
};
}
async create(data: any): Promise<any> {
const { dictType, dictLabel, dictValue, sortOrder = 0, status = 1 } = data;
if (!dictType || !dictLabel || !dictValue) {
return {
code: 400,
message: "字典类型、字典标签、字典键值不能为空",
data: null,
};
}
const dict = await this.dictModel.create({
dict_type: dictType,
dict_label: dictLabel,
dict_value: dictValue,
sort_order: sortOrder,
status,
});
return {
code: 200,
message: "创建字典成功",
data: dict,
};
}
async update(id: string, data: any): Promise<any> {
const dict = await this.dictModel.findByPk(Number(id));
if (!dict) {
return {
code: 404,
message: "字典不存在",
data: null,
};
}
const updateData: any = {};
if (data.dictType !== undefined) updateData.dict_type = data.dictType;
if (data.dictLabel !== undefined) updateData.dict_label = data.dictLabel;
if (data.dictValue !== undefined) updateData.dict_value = data.dictValue;
if (data.sortOrder !== undefined) updateData.sort_order = data.sortOrder;
if (data.status !== undefined) updateData.status = data.status;
await dict.update(updateData);
return {
code: 200,
message: "更新字典成功",
data: dict,
};
}
async remove(id: string): Promise<any> {
const dict = await this.dictModel.findByPk(Number(id));
if (!dict) {
return {
code: 404,
message: "字典不存在",
data: null,
};
}
await dict.destroy();
return {
code: 200,
message: "删除字典成功",
data: null,
};
}
}
+25 -22
View File
@@ -1,22 +1,22 @@
import { Provide, Inject } from "@midwayjs/core"; import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize"; import { InjectRepository } from "@midwayjs/sequelize";
import OperationLog from "../entity/operationLog.entity"; import SysOperationLog from "../entity/sysOperationLog.entity";
import { Op } from "sequelize"; import { Op } from "sequelize";
import dayjs from "dayjs"; import dayjs from "dayjs";
@Provide() @Provide()
export class LogService { export class LogService {
@InjectRepository(OperationLog) @InjectRepository(SysOperationLog)
logModel!: typeof OperationLog; logModel!: typeof SysOperationLog;
async list(query: { async list(query: {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
moduleName?: string; userId?: string | number;
actionType?: string; module?: string;
action?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
operator?: string;
}): Promise<any> { }): Promise<any> {
const page = query.page || 1; const page = query.page || 1;
const pageSize = query.pageSize || 10; const pageSize = query.pageSize || 10;
@@ -67,19 +67,22 @@ export class LogService {
limit: 10000, limit: 10000,
}); });
const headers = ["序号", "操作人", "操作类型", "操作模块", "请求方法", "请求URL", "IP地址", "状态码", "耗时(ms)", "操作时间"]; const headers = ["序号", "操作人ID", "操作模块", "操作动作", "请求方法", "请求参数", "IP地址", "操作时间"];
const rows = logs.map((log: any, index: number) => [ const rows = logs.map((log: any, index: number) => {
const paramsText = (log.params || "").replace(/,/g, ",");
const paramsCell = paramsText.length > 200 ? paramsText.substring(0, 200) + "..." : paramsText;
return [
index + 1, index + 1,
log.username || "-", log.userId || "-",
log.action || "-",
log.module || "-", log.module || "-",
log.action || "-",
log.method || "-", log.method || "-",
(log.url || "").replace(/,/g, ","), paramsCell,
log.ipAddress || "-", log.ip || "-",
log.statusCode || "-",
log.duration || 0,
dayjs(log.created_at).format("YYYY-MM-DD HH:mm:ss"), dayjs(log.created_at).format("YYYY-MM-DD HH:mm:ss"),
]); ];
});
const csvContent = [headers.join(","), ...rows.map(row => row.join(","))].join("\n"); const csvContent = [headers.join(","), ...rows.map(row => row.join(","))].join("\n");
@@ -100,18 +103,18 @@ export class LogService {
private buildWhereClause(query: any): any { private buildWhereClause(query: any): any {
const where: any = {}; const where: any = {};
const { operator, actionType, moduleName, startDate, endDate } = query; const { userId, module, action, startDate, endDate } = query;
if (operator) { if (userId) {
where.username = { [Op.like]: `%${operator}%` }; where.userId = userId;
} }
if (actionType) { if (module) {
where.action = { [Op.like]: `%${actionType}%` }; where.module = { [Op.like]: `%${module}%` };
} }
if (moduleName) { if (action) {
where.module = { [Op.like]: `%${moduleName}%` }; where.action = { [Op.like]: `%${action}%` };
} }
if (startDate || endDate) { if (startDate || endDate) {
+158
View File
@@ -0,0 +1,158 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import SysMenu from "../entity/sysMenu.entity";
@Provide()
export class MenuService {
@InjectRepository(SysMenu)
menuModel!: typeof SysMenu;
async getMenuTree(): Promise<any> {
const menus = await this.menuModel.findAll({
order: [["sort_order", "ASC"], ["id", "ASC"]],
});
const tree = this.buildMenuTree(menus.map(menu => menu.toJSON()));
return {
code: 200,
message: "获取菜单树成功",
data: tree,
};
}
async create(data: any): Promise<any> {
const {
parentId,
menuName,
menuPath,
component,
icon,
sortOrder = 0,
menuType = 2,
permission,
visible = 1,
} = data;
if (!menuName) {
return {
code: 400,
message: "菜单名称不能为空",
data: null,
};
}
const menu = await this.menuModel.create({
parent_id: parentId,
menu_name: menuName,
menu_path: menuPath,
component,
icon,
sort_order: sortOrder,
menu_type: menuType,
permission,
visible,
});
return {
code: 200,
message: "创建菜单成功",
data: menu,
};
}
async update(id: string, data: any): Promise<any> {
const menu = await this.menuModel.findByPk(Number(id));
if (!menu) {
return {
code: 404,
message: "菜单不存在",
data: null,
};
}
const updateData: any = {};
if (data.parentId !== undefined) updateData.parent_id = data.parentId;
if (data.menuName !== undefined) updateData.menu_name = data.menuName;
if (data.menuPath !== undefined) updateData.menu_path = data.menuPath;
if (data.component !== undefined) updateData.component = data.component;
if (data.icon !== undefined) updateData.icon = data.icon;
if (data.sortOrder !== undefined) updateData.sort_order = data.sortOrder;
if (data.menuType !== undefined) updateData.menu_type = data.menuType;
if (data.permission !== undefined) updateData.permission = data.permission;
if (data.visible !== undefined) updateData.visible = data.visible;
await menu.update(updateData);
return {
code: 200,
message: "更新菜单成功",
data: menu,
};
}
async remove(id: string): Promise<any> {
const menu = await this.menuModel.findByPk(Number(id));
if (!menu) {
return {
code: 404,
message: "菜单不存在",
data: null,
};
}
const childCount = await this.menuModel.count({
where: { parentId: Number(id) },
});
if (childCount > 0) {
return {
code: 400,
message: "存在子菜单,无法删除",
data: null,
};
}
await menu.destroy();
return {
code: 200,
message: "删除菜单成功",
data: null,
};
}
private buildMenuTree(menus: any[]): any[] {
const map = new Map<number, any>();
const tree: any[] = [];
for (const menu of menus) {
map.set(menu.id, {
id: menu.id,
parentId: menu.parentId,
menuName: menu.menuName,
menuPath: menu.menuPath,
component: menu.component,
icon: menu.icon,
sortOrder: menu.sortOrder,
menuType: menu.menuType,
permission: menu.permission,
visible: menu.visible,
children: [],
});
}
for (const menu of menus) {
const node = map.get(menu.id)!;
if (menu.parentId && map.has(menu.parentId)) {
map.get(menu.parentId)!.children.push(node);
} else {
tree.push(node);
}
}
return tree;
}
}
+78 -90
View File
@@ -1,110 +1,111 @@
import { Provide, Inject } from "@midwayjs/core"; import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize"; import { InjectRepository } from "@midwayjs/sequelize";
import User from "../entity/user.entity"; import SysUserRole from "../entity/sysUserRole.entity";
import UserRole from "../entity/userRole.entity"; import SysRoleMenu from "../entity/sysRoleMenu.entity";
import Role from "../entity/role.entity"; import SysMenu from "../entity/sysMenu.entity";
import RolePermission from "../entity/rolePermission.entity";
import Permission from "../entity/permission.entity";
@Provide() @Provide()
export class PermissionService { export class PermissionService {
@InjectRepository(User) @InjectRepository(SysUserRole)
userModel!: typeof User; userRoleModel!: typeof SysUserRole;
@InjectRepository(UserRole) @InjectRepository(SysRoleMenu)
userRoleModel!: typeof UserRole; roleMenuModel!: typeof SysRoleMenu;
@InjectRepository(Role) @InjectRepository(SysMenu)
roleModel!: typeof Role; menuModel!: typeof SysMenu;
@InjectRepository(RolePermission) /**
rolePermissionModel!: typeof RolePermission; * 获取用户可见菜单树(用户角色 → 角色菜单 → 可见菜单,按 sort_order/id 升序去重)
*/
@InjectRepository(Permission) async getUserMenus(userId: number): Promise<any[]> {
permissionModel!: typeof Permission;
async checkUserPermission(userId: number, permissionCode: string): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ const userRoles = await this.userRoleModel.findAll({
where: { userId }, where: { userId }
include: [this.roleModel],
}); });
const roleIds = userRoles.map(ur => ur.roleId); const roleIds = userRoles.map(ur => ur.roleId);
if (!roleIds.length) {
return [];
}
const roleMenus = await this.roleMenuModel.findAll({
where: { roleId: roleIds },
include: [
{
model: this.menuModel,
where: { visible: 1 }
}
]
});
const menuMap = new Map<number, any>();
for (const rm of roleMenus) {
const menu = rm.menu;
if (menu && !menuMap.has(menu.id)) {
menuMap.set(menu.id, this.toMenuNode(menu));
}
}
const menus = Array.from(menuMap.values());
menus.sort((a, b) => a.sortOrder - b.sortOrder || a.id - b.id);
return this.buildMenuTree(menus);
}
/**
* 检查用户角色关联的菜单中是否存在指定权限标识的菜单
*/
async checkUserPermission(userId: number, permissionCode: string): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({
where: { userId }
});
const roleIds = userRoles.map(ur => ur.roleId);
if (!roleIds.length) { if (!roleIds.length) {
return false; return false;
} }
const rolePermissions = await this.rolePermissionModel.findAll({ const roleMenus = await this.roleMenuModel.findAll({
where: { roleId: roleIds }, where: { roleId: roleIds },
include: [ include: [
{ {
model: this.permissionModel, model: this.menuModel,
where: { code: permissionCode }, where: { permission: permissionCode }
}, }
], ]
}); });
return rolePermissions.some(rp => rp.permission?.code === permissionCode); return roleMenus.some(rm => rm.menu?.permission === permissionCode);
} }
async getUserPermissions(userId: number): Promise<string[]> { private toMenuNode(menu: SysMenu): any {
const permissions = await this.getUserPermissionTree(userId); return {
return this.flattenPermissions(permissions); id: menu.id,
parentId: menu.parentId,
menuName: menu.menuName,
menuPath: menu.menuPath,
component: menu.component,
icon: menu.icon,
sortOrder: menu.sortOrder,
menuType: menu.menuType,
permission: menu.permission,
visible: menu.visible,
children: []
};
} }
async getUserPermissionTree(userId: number): Promise<any[]> { private buildMenuTree(menus: any[]): any[] {
const userRoles = await this.userRoleModel.findAll({
where: { userId },
include: [
{
model: this.roleModel,
include: [
{
model: this.rolePermissionModel,
include: [this.permissionModel],
},
],
},
],
});
const allPermissions = new Map<number, any>();
for (const ur of userRoles) {
const role: any = ur.role;
if (role && role.rolePermissions) {
for (const rp of role.rolePermissions) {
if (rp.permission && !allPermissions.has(rp.permission.id)) {
allPermissions.set(rp.permission.id, rp.permission.toJSON());
}
}
}
}
return this.buildPermissionTree(Array.from(allPermissions.values()));
}
async getAllPermissions(): Promise<any[]> {
const permissions = await this.permissionModel.findAll({
order: [["sort_order", "ASC"]],
});
return this.buildPermissionTree(permissions.map(p => p.toJSON()));
}
private buildPermissionTree(permissions: any[]): any[] {
const map = new Map<number, any>(); const map = new Map<number, any>();
const tree: any[] = []; const tree: any[] = [];
for (const perm of permissions) { for (const menu of menus) {
map.set(perm.id, { ...perm, children: [] }); map.set(menu.id, { ...menu, children: [] });
} }
for (const perm of permissions) { for (const menu of menus) {
const node = map.get(perm.id)!; const node = map.get(menu.id)!;
if (perm.parentId && map.has(perm.parentId)) { if (menu.parentId && map.has(menu.parentId)) {
map.get(perm.parentId)!.children.push(node); map.get(menu.parentId)!.children.push(node);
} else { } else {
tree.push(node); tree.push(node);
} }
@@ -112,17 +113,4 @@ export class PermissionService {
return tree; return tree;
} }
private flattenPermissions(tree: any[]): string[] {
const codes: string[] = [];
for (const node of tree) {
codes.push(node.code);
if (node.children?.length) {
codes.push(...this.flattenPermissions(node.children));
}
}
return codes;
}
} }
+65 -60
View File
@@ -1,24 +1,24 @@
import { Provide, Inject } from "@midwayjs/core"; import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize"; import { InjectRepository } from "@midwayjs/sequelize";
import Role from "../entity/role.entity"; import SysRole from "../entity/sysRole.entity";
import UserRole from "../entity/userRole.entity"; import SysUserRole from "../entity/sysUserRole.entity";
import RolePermission from "../entity/rolePermission.entity"; import SysRoleMenu from "../entity/sysRoleMenu.entity";
import Permission from "../entity/permission.entity"; import SysMenu from "../entity/sysMenu.entity";
import { Op } from "sequelize"; import { Op } from "sequelize";
@Provide() @Provide()
export class RoleService { export class RoleService {
@InjectRepository(Role) @InjectRepository(SysRole)
roleModel!: typeof Role; roleModel!: typeof SysRole;
@InjectRepository(UserRole) @InjectRepository(SysUserRole)
userRoleModel!: typeof UserRole; userRoleModel!: typeof SysUserRole;
@InjectRepository(RolePermission) @InjectRepository(SysRoleMenu)
rolePermissionModel!: typeof RolePermission; roleMenuModel!: typeof SysRoleMenu;
@InjectRepository(Permission) @InjectRepository(SysMenu)
permissionModel!: typeof Permission; menuModel!: typeof SysMenu;
async list(query: { async list(query: {
page?: number; page?: number;
@@ -32,20 +32,17 @@ export class RoleService {
const whereClause: any = {}; const whereClause: any = {};
if (query.keyword) { if (query.keyword) {
whereClause[Op.or] = [ whereClause[Op.or] = [
{ name: { [Op.like]: `%${query.keyword}%` } }, { roleCode: { [Op.like]: `%${query.keyword}%` } },
{ code: { [Op.like]: `%${query.keyword}%` } }, { roleName: { [Op.like]: `%${query.keyword}%` } },
]; ];
} }
const { count, rows } = await this.roleModel.findAndCountAll({ const { count, rows } = await this.roleModel.findAndCountAll({
where: whereClause, where: whereClause,
attributes: ["id", "roleCode", "roleName", "description", "sortOrder"],
offset, offset,
limit: pageSize, limit: pageSize,
order: [["sort_order", "ASC"], ["created_at", "DESC"]], order: [["sort_order", "ASC"], ["created_at", "DESC"]],
include: [{
model: this.rolePermissionModel,
include: [this.permissionModel],
}],
}); });
for (const role of rows) { for (const role of rows) {
@@ -80,12 +77,7 @@ export class RoleService {
} }
async detail(id: string): Promise<any> { async detail(id: string): Promise<any> {
const role = await this.roleModel.findByPk(Number(id), { const role = await this.roleModel.findByPk(Number(id));
include: [{
model: this.rolePermissionModel,
include: [this.permissionModel],
}],
});
if (!role) { if (!role) {
return { return {
@@ -108,10 +100,10 @@ export class RoleService {
} }
async create(data: any): Promise<any> { async create(data: any): Promise<any> {
const { name, code, description, status = 1, sortOrder = 0 } = data; const { roleCode, roleName, description, sortOrder = 0 } = data;
const existingRole = await this.roleModel.findOne({ const existingRole = await this.roleModel.findOne({
where: { code }, where: { roleCode },
}); });
if (existingRole) { if (existingRole) {
@@ -123,10 +115,9 @@ export class RoleService {
} }
const role = await this.roleModel.create({ const role = await this.roleModel.create({
name, roleCode,
code, roleName,
description, description,
status,
sort_order: sortOrder, sort_order: sortOrder,
}); });
@@ -148,9 +139,9 @@ export class RoleService {
}; };
} }
if (data.code && data.code !== role.code) { if (data.roleCode && data.roleCode !== role.roleCode) {
const existingRole = await this.roleModel.findOne({ const existingRole = await this.roleModel.findOne({
where: { code: data.code }, where: { roleCode: data.roleCode },
}); });
if (existingRole) { if (existingRole) {
@@ -163,9 +154,9 @@ export class RoleService {
} }
const updateData: any = {}; const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name; if (data.roleCode !== undefined) updateData.roleCode = data.roleCode;
if (data.roleName !== undefined) updateData.roleName = data.roleName;
if (data.description !== undefined) updateData.description = data.description; if (data.description !== undefined) updateData.description = data.description;
if (data.status !== undefined) updateData.status = data.status;
if (data.sortOrder !== undefined) updateData.sort_order = data.sortOrder; if (data.sortOrder !== undefined) updateData.sort_order = data.sortOrder;
await role.update(updateData); await role.update(updateData);
@@ -200,7 +191,7 @@ export class RoleService {
}; };
} }
await this.rolePermissionModel.destroy({ await this.roleMenuModel.destroy({
where: { roleId: Number(id) }, where: { roleId: Number(id) },
}); });
@@ -213,7 +204,7 @@ export class RoleService {
}; };
} }
async getPermissions(roleId: number): Promise<any> { async getMenus(roleId: number): Promise<any> {
const role = await this.roleModel.findByPk(roleId); const role = await this.roleModel.findByPk(roleId);
if (!role) { if (!role) {
@@ -224,28 +215,32 @@ export class RoleService {
}; };
} }
const rolePermissions = await this.rolePermissionModel.findAll({ const roleMenus = await this.roleMenuModel.findAll({
where: { roleId }, where: { roleId },
}); });
const assignedPermissionIds = rolePermissions.map(rp => rp.permissionId); const assignedMenuIds = roleMenus.map(rm => rm.menuId);
const allPermissions = await this.permissionModel.findAll({ const menus = await this.menuModel.findAll({
where: { menuType: { [Op.in]: [1, 2] } },
order: [["sort_order", "ASC"], ["id", "ASC"]], order: [["sort_order", "ASC"], ["id", "ASC"]],
}); });
const permissionTree = this.buildPermissionTree( const menuTree = this.buildMenuTree(
allPermissions.map(p => p.toJSON()), menus.map(m => m.toJSON()),
assignedPermissionIds assignedMenuIds
); );
return { return {
code: 200, code: 200,
message: "获取角色权限成功", message: "获取角色菜单成功",
data: permissionTree, data: {
menuTree,
menuIds: assignedMenuIds,
},
}; };
} }
async updatePermissions(roleId: number, permissionIds: number[]): Promise<any> { async updateMenus(roleId: number, menuIds: number[]): Promise<any> {
const role = await this.roleModel.findByPk(roleId); const role = await this.roleModel.findByPk(roleId);
if (!role) { if (!role) {
@@ -256,47 +251,57 @@ export class RoleService {
}; };
} }
await this.rolePermissionModel.destroy({ await this.roleMenuModel.destroy({
where: { roleId }, where: { roleId },
}); });
if (permissionIds && permissionIds.length > 0) { if (menuIds && menuIds.length > 0) {
const rolePermissions = permissionIds.map(permissionId => ({ const roleMenus = menuIds.map(menuId => ({
roleId, roleId,
permissionId, menuId,
})); }));
await this.rolePermissionModel.bulkCreate(rolePermissions); await this.roleMenuModel.bulkCreate(roleMenus);
} }
return { return {
code: 200, code: 200,
message: "权限更新成功", message: "菜单更新成功",
data: null, data: null,
}; };
} }
private buildPermissionTree(permissions: any[], checkedIds: number[]): any[] { private buildMenuTree(menus: any[], checkedIds: number[]): any[] {
const map = new Map<number, any>(); const nodeMap = new Map<number, any>();
const menuIdSet = new Set(menus.map(m => m.id));
const tree: any[] = []; const tree: any[] = [];
for (const perm of permissions) { for (const menu of menus) {
map.set(perm.id, { nodeMap.set(menu.id, {
...perm, ...menu,
children: [], children: [],
checked: checkedIds.includes(perm.id), checked: checkedIds.includes(menu.id),
}); });
} }
for (const perm of permissions) { for (const menu of menus) {
const node = map.get(perm.id)!; const node = nodeMap.get(menu.id)!;
if (perm.parent_id && map.has(perm.parent_id)) { const parent = menu.parentId ? nodeMap.get(menu.parentId) : undefined;
map.get(perm.parent_id)!.children.push(node);
// 仅当父节点为一级节点时挂为其子节点,保证输出两级树;
// 父节点不在列表或父节点本身是二级节点时,提升为一级节点
if (parent && !menuIdSet.has(parent.parentId)) {
parent.children.push(node);
} else { } else {
tree.push(node); tree.push(node);
} }
} }
// 一级节点已在查询时按 sort_order 排序,此处对子节点排序
for (const node of tree) {
node.children.sort((a: any, b: any) => a.sortOrder - b.sortOrder);
}
return tree; return tree;
} }
} }
-69
View File
@@ -1,69 +0,0 @@
import { Provide } from '@midwayjs/core';
@Provide()
export class SystemService {
private settingsCache: Map<string, any> = new Map();
constructor() {
this.initDefaultSettings();
}
private initDefaultSettings() {
this.settingsCache.set('basic', {
siteName: '智能物联网设备管理系统',
logo: '',
sessionTimeout: 120,
});
this.settingsCache.set('security', {
passwordMinLength: 6,
maxLoginAttempts: 5,
lockoutDuration: 30,
ipWhitelist: [] as string[],
});
this.settingsCache.set('notification', {
enableEmail: false,
emailSmtpConfig: null as any,
});
}
async getSettings(): Promise<any> {
return {
code: 200,
message: '获取系统设置成功',
data: {
basic: this.settingsCache.get('basic'),
security: this.settingsCache.get('security'),
notification: this.settingsCache.get('notification'),
},
};
}
async updateBasicSettings(data: any): Promise<any> {
const current = this.settingsCache.get('basic') || {};
this.settingsCache.set('basic', { ...current, ...data });
return {
code: 200,
message: '基本设置保存成功',
};
}
async updateSecuritySettings(data: any): Promise<any> {
const current = this.settingsCache.get('security') || {};
this.settingsCache.set('security', { ...current, ...data });
return {
code: 200,
message: '安全设置保存成功',
};
}
async updateNotificationSettings(data: any): Promise<any> {
const current = this.settingsCache.get('notification') || {};
this.settingsCache.set('notification', { ...current, ...data });
return {
code: 200,
message: '通知设置保存成功',
};
}
}
+70 -35
View File
@@ -1,22 +1,22 @@
import { Provide, Inject } from "@midwayjs/core"; import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize"; import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript"; import { Repository } from "sequelize-typescript";
import User from "../entity/user.entity"; import SysUser from "../entity/sysUser.entity";
import UserRole from "../entity/userRole.entity"; import SysUserRole from "../entity/sysUserRole.entity";
import Role from "../entity/role.entity"; import SysRole from "../entity/sysRole.entity";
import { Op } from "sequelize"; import { Op } from "sequelize";
import { hashPassword } from "../util/crypto"; import { hashPassword } from "../util/crypto";
@Provide() @Provide()
export class UserService { export class UserService {
@InjectRepository(User) @InjectRepository(SysUser)
userModel!: Repository<User>; userModel!: Repository<SysUser>;
@InjectRepository(UserRole) @InjectRepository(SysUserRole)
userRoleModel!: Repository<UserRole>; userRoleModel!: Repository<SysUserRole>;
@InjectRepository(Role) @InjectRepository(SysRole)
roleModel!: Repository<Role>; roleModel!: Repository<SysRole>;
async list(query: { page?: any; pageSize?: any; keyword?: string; status?: number; roleId?: number }): Promise<any> { async list(query: { page?: any; pageSize?: any; keyword?: string; status?: number; roleId?: number }): Promise<any> {
const page = Number(query.page) || 1; const page = Number(query.page) || 1;
@@ -24,7 +24,7 @@ export class UserService {
const where: any = {}; const where: any = {};
if (query.keyword) { if (query.keyword) {
where[Op.or] = [{ username: { [Op.like]: `%${query.keyword}%` } }, { nickname: { [Op.like]: `%${query.keyword}%` } }, { phone: { [Op.like]: `%${query.keyword}%` } }]; where[Op.or] = [{ username: { [Op.like]: `%${query.keyword}%` } }, { realName: { [Op.like]: `%${query.keyword}%` } }, { phone: { [Op.like]: `%${query.keyword}%` } }];
} }
if (query.status !== undefined) { if (query.status !== undefined) {
@@ -33,11 +33,11 @@ export class UserService {
const includeOptions: any = [ const includeOptions: any = [
{ {
model: UserRole, model: SysUserRole,
required: !!query.roleId, required: !!query.roleId,
include: [ include: [
{ {
model: Role, model: SysRole,
where: query.roleId ? { id: query.roleId } : {}, where: query.roleId ? { id: query.roleId } : {},
required: !!query.roleId required: !!query.roleId
} }
@@ -57,7 +57,8 @@ export class UserService {
const list = rows.map((user) => { const list = rows.map((user) => {
const u = user.toJSON(); const u = user.toJSON();
delete u.password; delete u.passwordHash;
delete u.password_hash;
return u; return u;
}); });
@@ -77,8 +78,8 @@ export class UserService {
const user = await this.userModel.findByPk(id, { const user = await this.userModel.findByPk(id, {
include: [ include: [
{ {
model: UserRole, model: SysUserRole,
include: [Role] include: [SysRole]
} }
] ]
}); });
@@ -92,7 +93,8 @@ export class UserService {
} }
const userData = user.toJSON(); const userData = user.toJSON();
delete userData.password; delete userData.passwordHash;
delete userData.password_hash;
return { return {
code: 200, code: 200,
@@ -102,7 +104,7 @@ export class UserService {
} }
async create(data: any): Promise<any> { async create(data: any): Promise<any> {
const { username, password, nickname, email, phone, roleIds, status = 1 } = data; const { username, password, realName, email, phone, roleIds, status = 1 } = data;
const existingUser = await this.userModel.findOne({ const existingUser = await this.userModel.findOne({
where: { username } where: { username }
@@ -120,8 +122,8 @@ export class UserService {
const user = await this.userModel.create({ const user = await this.userModel.create({
username, username,
password: hashedPassword, passwordHash: hashedPassword,
nickname, realName,
email, email,
phone, phone,
status status
@@ -134,14 +136,15 @@ export class UserService {
const createdUser = await this.userModel.findByPk(user.id, { const createdUser = await this.userModel.findByPk(user.id, {
include: [ include: [
{ {
model: UserRole, model: SysUserRole,
include: [Role] include: [SysRole]
} }
] ]
}); });
const userData = createdUser!.toJSON(); const userData = createdUser!.toJSON();
delete userData.password; delete userData.passwordHash;
delete userData.password_hash;
return { return {
code: 200, code: 200,
@@ -163,14 +166,13 @@ export class UserService {
const updateData: any = {}; const updateData: any = {};
if (data.nickname !== undefined) updateData.nickname = data.nickname; if (data.realName !== undefined) updateData.realName = data.realName;
if (data.email !== undefined) updateData.email = data.email; if (data.email !== undefined) updateData.email = data.email;
if (data.phone !== undefined) updateData.phone = data.phone; if (data.phone !== undefined) updateData.phone = data.phone;
if (data.avatar !== undefined) updateData.avatar = data.avatar;
if (data.status !== undefined) updateData.status = data.status; if (data.status !== undefined) updateData.status = data.status;
if (data.password) { if (data.password) {
updateData.password = await hashPassword(data.password); updateData.passwordHash = await hashPassword(data.password);
} }
await user.update(updateData); await user.update(updateData);
@@ -182,14 +184,15 @@ export class UserService {
const updatedUser = await this.userModel.findByPk(id, { const updatedUser = await this.userModel.findByPk(id, {
include: [ include: [
{ {
model: UserRole, model: SysUserRole,
include: [Role] include: [SysRole]
} }
] ]
}); });
const userData = updatedUser!.toJSON(); const userData = updatedUser!.toJSON();
delete userData.password; delete userData.passwordHash;
delete userData.password_hash;
return { return {
code: 200, code: 200,
@@ -198,7 +201,7 @@ export class UserService {
}; };
} }
async delete(id: string): Promise<any> { async delete(id: string, currentUserId?: number): Promise<any> {
const user = await this.userModel.findByPk(id); const user = await this.userModel.findByPk(id);
if (!user) { if (!user) {
@@ -209,6 +212,22 @@ export class UserService {
}; };
} }
if (Number(id) === 1) {
return {
code: 400,
message: "内置管理员账号不允许删除",
data: null
};
}
if (currentUserId && Number(id) === Number(currentUserId)) {
return {
code: 400,
message: "不能删除当前登录账号",
data: null
};
}
await this.userRoleModel.destroy({ await this.userRoleModel.destroy({
where: { userId: id } where: { userId: id }
}); });
@@ -222,7 +241,7 @@ export class UserService {
}; };
} }
async batchDelete(ids: number[]): Promise<any> { async batchDelete(ids: number[], currentUserId?: number): Promise<any> {
if (!ids || ids.length === 0) { if (!ids || ids.length === 0) {
return { return {
code: 400, code: 400,
@@ -231,6 +250,22 @@ export class UserService {
}; };
} }
if (ids.includes(1)) {
return {
code: 400,
message: "内置管理员账号不允许删除",
data: null
};
}
if (currentUserId && ids.includes(Number(currentUserId))) {
return {
code: 400,
message: "不能删除当前登录账号",
data: null
};
}
await this.userRoleModel.destroy({ await this.userRoleModel.destroy({
where: { userId: ids } where: { userId: ids }
}); });
@@ -260,7 +295,8 @@ export class UserService {
await user.update({ status }); await user.update({ status });
const userData = user.toJSON(); const userData = user.toJSON();
delete userData.password; delete userData.passwordHash;
delete userData.password_hash;
return { return {
code: 200, code: 200,
@@ -271,18 +307,17 @@ export class UserService {
async getRoleList(): Promise<any> { async getRoleList(): Promise<any> {
const roles = await this.roleModel.findAll({ const roles = await this.roleModel.findAll({
where: { status: 1 }, attributes: ["id", "roleCode", "roleName"],
order: [ order: [
["sort_order", "ASC"], ["sort_order", "ASC"],
["created_at", "DESC"] ["created_at", "DESC"]
], ]
raw: true
}); });
return { return {
code: 200, code: 200,
message: "获取角色列表成功", message: "获取角色列表成功",
data: roles data: roles.map((role) => role.toJSON())
}; };
} }
+103 -63
View File
@@ -1316,11 +1316,6 @@ asynckit@^0.4.0:
resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
aws-ssl-profiles@^1.1.2:
version "1.1.2"
resolved "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641"
integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==
babel-jest@^29.7.0: babel-jest@^29.7.0:
version "29.7.0" version "29.7.0"
resolved "https://registry.npmmirror.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" resolved "https://registry.npmmirror.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5"
@@ -1486,7 +1481,7 @@ cache-content-type@^1.0.0:
mime-types "^2.1.18" mime-types "^2.1.18"
ylru "^1.2.0" ylru "^1.2.0"
cache-manager-ioredis@^2.1.5: cache-manager-ioredis@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmmirror.com/cache-manager-ioredis/-/cache-manager-ioredis-2.1.0.tgz#69c9ab4cf72531f9015adf44d3822f9e2dcadc01" resolved "https://registry.npmmirror.com/cache-manager-ioredis/-/cache-manager-ioredis-2.1.0.tgz#69c9ab4cf72531f9015adf44d3822f9e2dcadc01"
integrity sha512-TCxbp9ceuFveTKWuNaCX8QjoC41rAlHen4s63u9Yd+iXlw3efYmimc/u935PKPxSdhkXpnMes4mxtK3/yb0L4g== integrity sha512-TCxbp9ceuFveTKWuNaCX8QjoC41rAlHen4s63u9Yd+iXlw3efYmimc/u935PKPxSdhkXpnMes4mxtK3/yb0L4g==
@@ -1764,7 +1759,7 @@ delegates@^1.0.0:
resolved "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" resolved "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
denque@2.1.0, denque@^2.1.0: denque@2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" resolved "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
@@ -2085,13 +2080,6 @@ function-bind@^1.1.2:
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
generate-function@^2.3.1:
version "2.3.1"
resolved "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f"
integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==
dependencies:
is-property "^1.0.2"
generator-function@^2.0.0: generator-function@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" resolved "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2"
@@ -2290,13 +2278,6 @@ iconv-lite@0.4.24, iconv-lite@~0.4.24:
dependencies: dependencies:
safer-buffer ">= 2.1.2 < 3" safer-buffer ">= 2.1.2 < 3"
iconv-lite@^0.7.2:
version "0.7.2"
resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz#d0bdeac3f12b4835b7359c2ad89c422a4d1cc72e"
integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
ignore@^5.1.1: ignore@^5.1.1:
version "5.3.2" version "5.3.2"
resolved "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" resolved "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
@@ -2430,11 +2411,6 @@ is-number@^7.0.0:
resolved "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" resolved "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
is-property@^1.0.2:
version "1.0.2"
resolved "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84"
integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==
is-regex@^1.2.1: is-regex@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" resolved "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22"
@@ -3130,11 +3106,6 @@ lodash@^4.17.21:
resolved "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" resolved "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
long@^5.3.2:
version "5.3.2"
resolved "https://registry.npmmirror.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83"
integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==
lru-cache@7.18.3: lru-cache@7.18.3:
version "7.18.3" version "7.18.3"
resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
@@ -3147,11 +3118,6 @@ lru-cache@^5.1.1:
dependencies: dependencies:
yallist "^3.0.2" yallist "^3.0.2"
lru.min@^1.1.0, lru.min@^1.1.4:
version "1.1.4"
resolved "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b"
integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==
make-dir@^4.0.0: make-dir@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" resolved "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e"
@@ -3298,27 +3264,6 @@ ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
mysql2@^3.12.0:
version "3.22.4"
resolved "https://registry.npmmirror.com/mysql2/-/mysql2-3.22.4.tgz#2cfe5f6125d285755b90cbad9d7d35ceb526c417"
integrity sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==
dependencies:
aws-ssl-profiles "^1.1.2"
denque "^2.1.0"
generate-function "^2.3.1"
iconv-lite "^0.7.2"
long "^5.3.2"
lru.min "^1.1.4"
named-placeholders "^1.1.6"
sql-escaper "^1.3.3"
named-placeholders@^1.1.6:
version "1.1.6"
resolved "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.6.tgz#c50c6920b43f258f59c16add1e56654f5cc02bb5"
integrity sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==
dependencies:
lru.min "^1.1.0"
natural-compare@^1.4.0: natural-compare@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@@ -3470,11 +3415,74 @@ path-type@^4.0.0:
resolved "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" resolved "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
pg-cloudflare@^1.4.0:
version "1.4.0"
resolved "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497"
integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==
pg-connection-string@^2.14.0:
version "2.14.0"
resolved "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9"
integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==
pg-connection-string@^2.6.1: pg-connection-string@^2.6.1:
version "2.13.0" version "2.13.0"
resolved "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.13.0.tgz#8678113465a5af3cc977dcb51eadc847b27aa2de" resolved "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.13.0.tgz#8678113465a5af3cc977dcb51eadc847b27aa2de"
integrity sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig== integrity sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==
pg-hstore@^2.3.4:
version "2.3.4"
resolved "https://registry.npmmirror.com/pg-hstore/-/pg-hstore-2.3.4.tgz#4425e3e2a3e15d2a334c35581186c27cf2e9b8dd"
integrity sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==
dependencies:
underscore "^1.13.1"
pg-int8@1.0.1:
version "1.0.1"
resolved "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"
integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==
pg-pool@^3.14.0:
version "3.14.0"
resolved "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90"
integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==
pg-protocol@^1.16.0:
version "1.16.0"
resolved "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.16.0.tgz#cffb008826561ee9770a8a15dc21f269d731b305"
integrity sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==
pg-types@2.2.0:
version "2.2.0"
resolved "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3"
integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==
dependencies:
pg-int8 "1.0.1"
postgres-array "~2.0.0"
postgres-bytea "~1.0.0"
postgres-date "~1.0.4"
postgres-interval "^1.1.0"
pg@^8.11.0:
version "8.23.0"
resolved "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz#5c2026d32bd0cb4fbd9196bac1ecf5ae66607180"
integrity sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==
dependencies:
pg-connection-string "^2.14.0"
pg-pool "^3.14.0"
pg-protocol "^1.16.0"
pg-types "2.2.0"
pgpass "1.0.5"
optionalDependencies:
pg-cloudflare "^1.4.0"
pgpass@1.0.5:
version "1.0.5"
resolved "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d"
integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==
dependencies:
split2 "^4.1.0"
picocolors@^1.1.1: picocolors@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@@ -3502,6 +3510,28 @@ pkg-dir@^4.2.0:
dependencies: dependencies:
find-up "^4.0.0" find-up "^4.0.0"
postgres-array@~2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e"
integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==
postgres-bytea@~1.0.0:
version "1.0.1"
resolved "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz#c40b3da0222c500ff1e51c5d7014b60b79697c7a"
integrity sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==
postgres-date@~1.0.4:
version "1.0.7"
resolved "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8"
integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==
postgres-interval@^1.1.0:
version "1.2.0"
resolved "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695"
integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==
dependencies:
xtend "^4.0.0"
pretty-format@^29.0.0, pretty-format@^29.7.0: pretty-format@^29.0.0, pretty-format@^29.7.0:
version "29.7.0" version "29.7.0"
resolved "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" resolved "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812"
@@ -3667,7 +3697,7 @@ safe-stable-stringify@^2.5.0:
resolved "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" resolved "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd"
integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==
"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": "safer-buffer@>= 2.1.2 < 3":
version "2.1.2" version "2.1.2"
resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
@@ -3819,16 +3849,16 @@ source-map@^0.6.0, source-map@^0.6.1:
resolved "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" resolved "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
split2@^4.1.0:
version "4.2.0"
resolved "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4"
integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
sprintf-js@~1.0.2: sprintf-js@~1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
sql-escaper@^1.3.3:
version "1.3.3"
resolved "https://registry.npmmirror.com/sql-escaper/-/sql-escaper-1.3.3.tgz#65faf89f048d26bb9a75566b82b5990ddf8a5b7f"
integrity sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==
stack-utils@^2.0.3: stack-utils@^2.0.3:
version "2.0.6" version "2.0.6"
resolved "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" resolved "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f"
@@ -4066,6 +4096,11 @@ uglify-js@^3.1.4:
resolved "https://registry.npmmirror.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" resolved "https://registry.npmmirror.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f"
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
underscore@^1.13.1:
version "1.13.8"
resolved "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz#a93a21186c049dbf0e847496dba72b7bd8c1e92b"
integrity sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==
"undici-types@>=7.24.0 <7.24.7": "undici-types@>=7.24.0 <7.24.7":
version "7.24.6" version "7.24.6"
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91" resolved "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91"
@@ -4194,6 +4229,11 @@ ws@^7.2.3:
resolved "https://registry.npmmirror.com/ws/-/ws-7.5.11.tgz#9460daf1812bb81a423c5b9eac746941a86310fa" resolved "https://registry.npmmirror.com/ws/-/ws-7.5.11.tgz#9460daf1812bb81a423c5b9eac746941a86310fa"
integrity sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA== integrity sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y18n@^5.0.5: y18n@^5.0.5:
version "5.0.8" version "5.0.8"
resolved "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" resolved "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"