feat: 角色菜单授权与权限同步

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-31 18:10:20 +08:00
parent dba6def3c5
commit b20c00bea3
25 changed files with 957 additions and 164 deletions

View File

@ -49,7 +49,7 @@ public class MenuController {
@GetMapping("/user-menus")
@Operation(summary = "当前用户菜单")
@PreAuthorize("hasAuthority('menu:read')")
@PreAuthorize("isAuthenticated()")
public Result<List<MenuTreeVO>> userMenus(
@AuthenticationPrincipal UserPrincipal userPrincipal) {
Long userId = userPrincipal.getUserId();

View File

@ -66,8 +66,11 @@ public class RoleController {
@GetMapping("/{id}")
@Operation(summary = "角色详情")
@PreAuthorize("hasAuthority('role:read')")
public Result<RoleDetailVO> detail(@PathVariable Long id) {
RoleDetailVO result = roleService.detail(id);
public Result<RoleDetailVO> detail(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@PathVariable Long id) {
Long tenantId = userPrincipal.getTenantId();
RoleDetailVO result = roleService.detail(id, tenantId);
return Result.success(result);
}
@ -87,8 +90,10 @@ public class RoleController {
@Operation(summary = "删除角色")
@PreAuthorize("hasAuthority('role:delete')")
public Result<Void> delete(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@PathVariable Long id) {
roleService.delete(id);
Long tenantId = userPrincipal.getTenantId();
roleService.delete(id, tenantId);
return Result.success();
}
}

View File

@ -26,4 +26,7 @@ public class CreateRoleDTO {
@Schema(description = "权限 ID 列表")
private List<Long> permissionIds;
@Schema(description = "菜单 ID 列表(菜单可见性授权)")
private List<Long> menuIds;
}

View File

@ -23,4 +23,7 @@ public class UpdateRoleDTO {
@Schema(description = "权限 ID 列表")
private java.util.List<Long> permissionIds;
@Schema(description = "菜单 ID 列表(菜单可见性授权)")
private java.util.List<Long> menuIds;
}

View File

@ -0,0 +1,39 @@
package com.lesingle.creation.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
/**
* 角色-菜单关联表实体类
* 中间表不使用逻辑删除和审计字段
*/
@Data
@TableName("t_auth_role_menu")
public class RoleMenu implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键 ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 角色 ID
*/
@TableField("role_id")
private Long roleId;
/**
* 菜单 ID
*/
@TableField("menu_id")
private Long menuId;
}

View File

@ -0,0 +1,13 @@
package com.lesingle.creation.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.lesingle.creation.entity.RoleMenu;
import org.apache.ibatis.annotations.Mapper;
/**
* 角色-菜单关联 Mapper 接口
*/
@Mapper
public interface RoleMenuMapper extends BaseMapper<RoleMenu> {
}

View File

@ -39,9 +39,10 @@ public interface RoleService extends IService<Role> {
* 根据 ID 查询角色详情
*
* @param id 角色 ID
* @param tenantId 租户 ID
* @return 角色详情 VO
*/
RoleDetailVO detail(Long id);
RoleDetailVO detail(Long id, Long tenantId);
/**
* 更新角色
@ -57,8 +58,9 @@ public interface RoleService extends IService<Role> {
* 删除角色
*
* @param id 角色 ID
* @param tenantId 租户 ID
*/
void delete(Long id);
void delete(Long id, Long tenantId);
/**
* 获取所有角色列表

View File

@ -17,8 +17,12 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -33,6 +37,8 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
private final UserRoleMapper userRoleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final PermissionMapper permissionMapper;
private final RoleMenuMapper roleMenuMapper;
private final RoleMapper roleMapper;
@Override
@Transactional(rollbackFor = Exception.class)
@ -93,30 +99,31 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
.map(UserRole::getRoleId)
.collect(Collectors.toList());
// 3. 查询角色关联的权限
List<RolePermission> rolePermissions = rolePermissionMapper.selectList(
new LambdaQueryWrapper<RolePermission>()
.in(RolePermission::getRoleId, roleIds)
);
if (rolePermissions.isEmpty()) {
// 角色没有关联权限返回空菜单
log.warn("用户角色没有关联任何权限,用户 ID: {}", userId);
return new ArrayList<>();
// 3. 判断是否超级管理员角色 code=super_admin 或权限 code=super_admin 兜底
boolean hasSuperAdmin = false;
List<Role> roles = roleMapper.selectBatchIds(roleIds);
if (roles != null) {
hasSuperAdmin = roles.stream().anyMatch(r -> "super_admin".equals(r.getCode()));
}
// 4. 获取权限 ID 列表
List<Long> permissionIds = rolePermissions.stream()
.map(RolePermission::getPermissionId)
.distinct()
.collect(Collectors.toList());
if (!hasSuperAdmin) {
// 兜底如果权限中包含 super_admin也视为超管
List<RolePermission> rolePermissionsForSuper = rolePermissionMapper.selectList(
new LambdaQueryWrapper<RolePermission>()
.in(RolePermission::getRoleId, roleIds)
);
if (!rolePermissionsForSuper.isEmpty()) {
List<Long> permissionIds = rolePermissionsForSuper.stream()
.map(RolePermission::getPermissionId)
.distinct()
.collect(Collectors.toList());
List<Permission> permissions = permissionMapper.selectBatchIds(permissionIds);
hasSuperAdmin = permissions != null && permissions.stream()
.anyMatch(p -> "super_admin".equals(p.getCode()));
}
}
// 5. 查询权限详情检查是否有 super_admin 权限
List<Permission> permissions = permissionMapper.selectBatchIds(permissionIds);
boolean hasSuperAdmin = permissions.stream()
.anyMatch(p -> "super_admin".equals(p.getCode()));
// 6. 查询菜单
// 4. 查询菜单超管返回所有非超管按角色-菜单关联取并集 + 补齐祖先节点
LambdaQueryWrapper<Menu> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Menu::getValidState, 1)
.orderByAsc(Menu::getSort, Menu::getId);
@ -127,24 +134,50 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
log.info("用户是超级管理员,返回所有菜单");
allMenus = menuMapper.selectList(wrapper);
} else {
// 非超管只查询有权限的菜单
// 获取有权限的菜单编码
List<String> permissionCodes = permissions.stream()
.map(Permission::getCode)
.collect(Collectors.toList());
// 筛选条件菜单权限为空所有人都可见 菜单权限在用户权限列表中
wrapper.and(w ->
w.isNull(Menu::getPermission)
.or()
.in(Menu::getPermission, permissionCodes)
// 非超管按角色-菜单关联表控制可见性
List<RoleMenu> roleMenus = roleMenuMapper.selectList(
new LambdaQueryWrapper<RoleMenu>()
.in(RoleMenu::getRoleId, roleIds)
);
if (roleMenus.isEmpty()) {
log.warn("用户角色未配置任何菜单可见性,用户 ID: {}", userId);
return new ArrayList<>();
}
log.info("非超管用户,根据权限过滤菜单,权限编码数量:{}", permissionCodes.size());
allMenus = menuMapper.selectList(wrapper);
Set<Long> visibleMenuIds = roleMenus.stream()
.map(RoleMenu::getMenuId)
.collect(Collectors.toSet());
// 拉取全量菜单用于补齐祖先节点然后再裁剪
List<Menu> all = menuMapper.selectList(wrapper);
Map<Long, Menu> menuMap = new HashMap<>();
for (Menu m : all) {
menuMap.put(m.getId(), m);
}
Set<Long> withAncestors = new HashSet<>(visibleMenuIds);
for (Long mid : visibleMenuIds) {
Menu cur = menuMap.get(mid);
while (cur != null) {
Long pid = cur.getParentId();
if (pid == null || pid == 0L) {
break;
}
if (withAncestors.add(pid)) {
cur = menuMap.get(pid);
} else {
// 已存在继续向上也可能已处理过
cur = menuMap.get(pid);
}
}
}
allMenus = all.stream()
.filter(m -> withAncestors.contains(m.getId()))
.collect(Collectors.toList());
}
// 7. 构建树形结构
// 5. 构建树形结构
return buildTree(allMenus, 0L);
}

View File

@ -6,8 +6,12 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.lesingle.creation.common.exception.BusinessException;
import com.lesingle.creation.dto.role.CreateRoleDTO;
import com.lesingle.creation.dto.role.UpdateRoleDTO;
import com.lesingle.creation.entity.Permission;
import com.lesingle.creation.entity.Role;
import com.lesingle.creation.entity.RoleMenu;
import com.lesingle.creation.entity.RolePermission;
import com.lesingle.creation.mapper.PermissionMapper;
import com.lesingle.creation.mapper.RoleMenuMapper;
import com.lesingle.creation.mapper.RoleMapper;
import com.lesingle.creation.mapper.RolePermissionMapper;
import com.lesingle.creation.service.RoleService;
@ -33,6 +37,8 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final PermissionMapper permissionMapper;
private final RoleMenuMapper roleMenuMapper;
@Override
@Transactional(rollbackFor = Exception.class)
@ -71,6 +77,22 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
log.info("角色权限关联创建成功,权限数量:{}", dto.getPermissionIds().size());
}
// 如果提供了菜单 ID创建菜单关联用于菜单可见性授权
if (!CollectionUtils.isEmpty(dto.getMenuIds())) {
List<RoleMenu> roleMenus = dto.getMenuIds().stream()
.distinct()
.map(menuId -> {
RoleMenu rm = new RoleMenu();
rm.setRoleId(role.getId());
rm.setMenuId(menuId);
return rm;
})
.collect(Collectors.toList());
// 批量插入使用 MyBatis-Plus insert 循环即可
roleMenus.forEach(roleMenuMapper::insert);
log.info("角色菜单关联创建成功,菜单数量:{}", roleMenus.size());
}
return convertToDetailVO(role);
}
@ -98,13 +120,16 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
}
@Override
public RoleDetailVO detail(Long id) {
public RoleDetailVO detail(Long id, Long tenantId) {
log.info("查询角色详情,角色 ID: {}", id);
Role role = roleMapper.selectById(id);
if (role == null) {
throw new BusinessException("角色不存在");
}
if (tenantId != null && role.getTenantId() != null && !role.getTenantId().equals(tenantId)) {
throw new BusinessException("角色不存在或不属于当前租户");
}
return convertToDetailVO(role);
}
@ -118,6 +143,9 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
if (existingRole == null) {
throw new BusinessException("角色不存在");
}
if (existingRole.getTenantId() != null && tenantId != null && !existingRole.getTenantId().equals(tenantId)) {
throw new BusinessException("角色不存在或不属于当前租户");
}
// 如果更新了 code检查是否冲突
if (dto.getCode() != null && !dto.getCode().isEmpty()) {
@ -160,18 +188,42 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
}
}
// 如果提供了 menuIds更新菜单关联菜单可见性授权
if (dto.getMenuIds() != null) {
LambdaQueryWrapper<RoleMenu> rmWrapper = new LambdaQueryWrapper<>();
rmWrapper.eq(RoleMenu::getRoleId, id);
roleMenuMapper.delete(rmWrapper);
if (!dto.getMenuIds().isEmpty()) {
List<RoleMenu> roleMenus = dto.getMenuIds().stream()
.distinct()
.map(menuId -> {
RoleMenu rm = new RoleMenu();
rm.setRoleId(id);
rm.setMenuId(menuId);
return rm;
})
.collect(Collectors.toList());
// 批量插入使用 MyBatis-Plus 默认 insert 循环也可
roleMenus.forEach(roleMenuMapper::insert);
}
}
return convertToDetailVO(roleMapper.selectById(id));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
public void delete(Long id, Long tenantId) {
log.info("删除角色,角色 ID: {}", id);
Role role = roleMapper.selectById(id);
if (role == null) {
throw new BusinessException("角色不存在");
}
if (role.getTenantId() != null && tenantId != null && !role.getTenantId().equals(tenantId)) {
throw new BusinessException("角色不存在或不属于当前租户");
}
// 删除角色关联
LambdaQueryWrapper<RolePermission> wrapper = new LambdaQueryWrapper<>();
@ -215,9 +267,41 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
vo.setUpdateBy(role.getUpdateBy());
vo.setUpdateTime(role.getUpdateTime());
// TODO: 获取角色权限
vo.setPermissionIds(new ArrayList<>());
vo.setPermissionNames(new ArrayList<>());
// 获取角色权限用于授权回显
LambdaQueryWrapper<RolePermission> rpWrapper = new LambdaQueryWrapper<>();
rpWrapper.eq(RolePermission::getRoleId, role.getId());
List<RolePermission> rolePermissions = rolePermissionMapper.selectList(rpWrapper);
List<Long> permissionIds = rolePermissions == null
? new ArrayList<>()
: rolePermissions.stream()
.map(RolePermission::getPermissionId)
.distinct()
.collect(Collectors.toList());
vo.setPermissionIds(permissionIds);
if (permissionIds.isEmpty()) {
vo.setPermissionNames(new ArrayList<>());
} else {
List<Permission> permissions = permissionMapper.selectBatchIds(permissionIds);
List<String> names = permissions == null
? new ArrayList<>()
: permissions.stream()
.map(Permission::getName)
.collect(Collectors.toList());
vo.setPermissionNames(names);
}
// 获取角色菜单用于菜单授权回显
LambdaQueryWrapper<RoleMenu> rmWrapper = new LambdaQueryWrapper<>();
rmWrapper.eq(RoleMenu::getRoleId, role.getId());
List<RoleMenu> roleMenus = roleMenuMapper.selectList(rmWrapper);
List<Long> menuIds = roleMenus == null
? new ArrayList<>()
: roleMenus.stream()
.map(RoleMenu::getMenuId)
.distinct()
.collect(Collectors.toList());
vo.setMenuIds(menuIds);
return vo;
}

View File

@ -48,4 +48,7 @@ public class RoleDetailVO {
@Schema(description = "权限名称列表")
private List<String> permissionNames;
@Schema(description = "菜单 ID 列表(用于菜单授权回显)")
private List<Long> menuIds;
}

View File

@ -0,0 +1,25 @@
-- ============================================
-- 角色 - 菜单 关联表
-- 用于“菜单可见性”授权(与按钮/接口权限分离)
-- ============================================
CREATE TABLE IF NOT EXISTS `t_auth_role_menu` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`role_id` BIGINT NOT NULL COMMENT '角色 ID',
`menu_id` BIGINT NOT NULL COMMENT '菜单 ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`),
KEY `idx_role_id` (`role_id`),
KEY `idx_menu_id` (`menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色-菜单关联表';
-- 初始化:为 tenant_id=1 的 super_admin 角色关联所有菜单(增量补齐)
INSERT INTO `t_auth_role_menu` (`role_id`, `menu_id`)
SELECT r.id, m.id
FROM `t_auth_role` r
JOIN `t_auth_menu` m ON m.deleted = 0
WHERE r.tenant_id = 1
AND r.code = 'super_admin'
AND r.deleted = 0
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);

View File

@ -0,0 +1,26 @@
-- ============================================
-- 角色 - 菜单 关联表(补偿迁移)
-- 说明:部分环境数据库 schema_version 已大于/等于 19但实际未创建 t_auth_role_menu。
-- 通过更高版本迁移确保表存在。
-- ============================================
CREATE TABLE IF NOT EXISTS `t_auth_role_menu` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`role_id` BIGINT NOT NULL COMMENT '角色 ID',
`menu_id` BIGINT NOT NULL COMMENT '菜单 ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`),
KEY `idx_role_id` (`role_id`),
KEY `idx_menu_id` (`menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色-菜单关联表';
-- 初始化:为 tenant_id=1 的 super_admin 角色关联所有菜单(增量补齐)
INSERT INTO `t_auth_role_menu` (`role_id`, `menu_id`)
SELECT r.id, m.id
FROM `t_auth_role` r
JOIN `t_auth_menu` m ON m.deleted = 0
WHERE r.tenant_id = 1
AND r.code = 'super_admin'
AND r.deleted = 0
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);

View File

@ -0,0 +1,148 @@
-- ============================================
-- 补齐权限码 & 绑定超管全量权限(幂等)
-- 目标:
-- 1) 确保所有 Controller 上使用的 @PreAuthorize 权限码在 t_auth_permission 中存在
-- 2) 确保 tenant_id=1 的 super_admin 角色拥有全部有效权限
-- ============================================
-- 1. 补齐权限码(以 Controller 实际使用为准)
INSERT INTO `t_auth_permission`
(`tenant_id`, `name`, `code`, `resource`, `action`, `description`, `valid_state`)
VALUES
-- 菜单
(1, '菜单创建', 'menu:create', 'menu', 'create', '创建菜单权限', 1),
(1, '菜单查询', 'menu:read', 'menu', 'read', '查询菜单权限', 1),
(1, '菜单更新', 'menu:update', 'menu', 'update', '更新菜单权限', 1),
(1, '菜单删除', 'menu:delete', 'menu', 'delete', '删除菜单权限', 1),
-- 权限管理
(1, '权限查询', 'permission:read', 'permission', 'read', '查询权限权限', 1),
-- 配置
(1, '配置创建', 'config:create', 'config', 'create', '创建配置权限', 1),
(1, '配置查询', 'config:read', 'config', 'read', '查询配置权限', 1),
(1, '配置更新', 'config:update', 'config', 'update', '更新配置权限', 1),
(1, '配置删除', 'config:delete', 'config', 'delete', '删除配置权限', 1),
-- 字典
(1, '字典创建', 'dict:create', 'dict', 'create', '创建字典权限', 1),
(1, '字典查询', 'dict:read', 'dict', 'read', '查询字典权限', 1),
(1, '字典更新', 'dict:update', 'dict', 'update', '更新字典权限', 1),
(1, '字典删除', 'dict:delete', 'dict', 'delete', '删除字典权限', 1),
-- 日志
(1, '日志查询', 'log:read', 'log', 'read', '查询日志权限', 1),
(1, '日志删除', 'log:delete', 'log', 'delete', '删除日志权限', 1),
-- 学校/组织/人员
(1, '学校创建', 'school:create', 'school', 'create', '创建学校权限', 1),
(1, '学校查询', 'school:read', 'school', 'read', '查询学校权限', 1),
(1, '学校更新', 'school:update', 'school', 'update', '更新学校权限', 1),
(1, '学校删除', 'school:delete', 'school', 'delete', '删除学校权限', 1),
(1, '部门创建', 'department:create', 'department', 'create', '创建部门权限', 1),
(1, '部门查询', 'department:read', 'department', 'read', '查询部门权限', 1),
(1, '部门更新', 'department:update', 'department', 'update', '更新部门权限', 1),
(1, '部门删除', 'department:delete', 'department', 'delete', '删除部门权限', 1),
(1, '年级创建', 'grade:create', 'grade', 'create', '创建年级权限', 1),
(1, '年级查询', 'grade:read', 'grade', 'read', '查询年级权限', 1),
(1, '年级更新', 'grade:update', 'grade', 'update', '更新年级权限', 1),
(1, '年级删除', 'grade:delete', 'grade', 'delete', '删除年级权限', 1),
(1, '班级创建', 'class:create', 'class', 'create', '创建班级权限', 1),
(1, '班级查询', 'class:read', 'class', 'read', '查询班级权限', 1),
(1, '班级更新', 'class:update', 'class', 'update', '更新班级权限', 1),
(1, '班级删除', 'class:delete', 'class', 'delete', '删除班级权限', 1),
(1, '教师创建', 'teacher:create', 'teacher', 'create', '创建教师权限', 1),
(1, '教师查询', 'teacher:read', 'teacher', 'read', '查询教师权限', 1),
(1, '教师更新', 'teacher:update', 'teacher', 'update', '更新教师权限', 1),
(1, '教师删除', 'teacher:delete', 'teacher', 'delete', '删除教师权限', 1),
(1, '学生创建', 'student:create', 'student', 'create', '创建学生权限', 1),
(1, '学生查询', 'student:read', 'student', 'read', '查询学生权限', 1),
(1, '学生更新', 'student:update', 'student', 'update', '更新学生权限', 1),
(1, '学生删除', 'student:delete', 'student', 'delete', '删除学生权限', 1),
-- 活动
(1, '活动创建', 'contest:create', 'contest', 'create', '创建活动权限', 1),
(1, '活动查询', 'contest:read', 'contest', 'read', '查询活动权限', 1),
(1, '活动更新', 'contest:update', 'contest', 'update', '更新活动权限', 1),
(1, '活动发布', 'contest:publish', 'contest', 'publish', '发布活动权限', 1),
(1, '活动删除', 'contest:delete', 'contest', 'delete', '删除活动权限', 1),
(1, '活动报名', 'contest:register', 'contest', 'register', '活动报名权限', 1),
-- 活动公告
(1, '公告创建', 'contest:notice:create', 'contest', 'notice:create', '创建活动公告权限', 1),
(1, '公告更新', 'contest:notice:update', 'contest', 'notice:update', '更新活动公告权限', 1),
(1, '公告删除', 'contest:notice:delete', 'contest', 'notice:delete', '删除活动公告权限', 1),
(1, '公告发布', 'contest:notice:publish', 'contest', 'notice:publish', '发布活动公告权限', 1),
-- 活动评委
(1, '评委创建', 'contest:judge:create', 'contest', 'judge:create', '创建活动评委权限', 1),
(1, '评委更新', 'contest:judge:update', 'contest', 'judge:update', '更新活动评委权限', 1),
(1, '评委删除', 'contest:judge:delete', 'contest', 'judge:delete', '删除活动评委权限', 1),
-- 活动评审规则
(1, '评审规则创建', 'contest:review-rule:create', 'contest', 'review-rule:create', '创建评审规则权限', 1),
(1, '评审规则更新', 'contest:review-rule:update', 'contest', 'review-rule:update', '更新评审规则权限', 1),
(1, '评审规则删除', 'contest:review-rule:delete', 'contest', 'review-rule:delete', '删除评审规则权限', 1),
-- 活动团队
(1, '团队创建', 'contest:team:create', 'contest', 'team:create', '创建活动团队权限', 1),
(1, '团队更新', 'contest:team:update', 'contest', 'team:update', '更新活动团队权限', 1),
(1, '团队删除', 'contest:team:delete', 'contest', 'team:delete', '删除活动团队权限', 1),
-- 活动作业/作品
(1, '作品提交', 'contest:work:submit', 'contest', 'work:submit', '提交作品权限', 1),
(1, '作品更新', 'contest:work:update', 'contest', 'work:update', '更新作品权限', 1),
(1, '作品删除', 'contest:work:delete', 'contest', 'work:delete', '删除作品权限', 1),
-- 活动评审
(1, '评审分配', 'contest:review:assign', 'contest', 'review:assign', '分配评审权限', 1),
(1, '评审评分', 'contest:review:score', 'contest', 'review:score', '评审评分权限', 1),
-- 活动评审结果
(1, '结果创建', 'contest:result:create', 'contest', 'result:create', '创建评审结果权限', 1),
(1, '结果更新', 'contest:result:update', 'contest', 'result:update', '更新评审结果权限', 1),
(1, '结果发布', 'contest:result:publish', 'contest', 'result:publish', '发布评审结果权限', 1),
(1, '结果删除', 'contest:result:delete', 'contest', 'result:delete', '删除评审结果权限', 1),
-- 预设评语
(1, '预设评语创建', 'contest:preset-comment:create', 'contest', 'preset-comment:create', '创建预设评语权限', 1),
(1, '预设评语删除', 'contest:preset-comment:delete', 'contest', 'preset-comment:delete', '删除预设评语权限', 1),
-- 作业(独立模块)
(1, '作业创建', 'homework:create', 'homework', 'create', '创建作业权限', 1),
(1, '作业更新', 'homework:update', 'homework', 'update', '更新作业权限', 1),
(1, '作业发布', 'homework:publish', 'homework', 'publish', '发布作业权限', 1),
(1, '作业删除', 'homework:delete', 'homework', 'delete', '删除作业权限', 1),
(1, '作业评审', 'homework:review', 'homework', 'review', '作业评审权限', 1),
(1, '作业评审规则创建', 'homework:review-rule:create', 'homework', 'review-rule:create', '创建作业评审规则权限', 1),
(1, '作业评审规则更新', 'homework:review-rule:update', 'homework', 'review-rule:update', '更新作业评审规则权限', 1),
(1, '作业评审规则删除', 'homework:review-rule:delete', 'homework', 'review-rule:delete', '删除作业评审规则权限', 1),
-- AI 3D
(1, 'AI 3D 任务删除', 'ai-3d:delete', 'ai-3d', 'delete', '删除 AI 3D 任务权限', 1)
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`resource` = VALUES(`resource`),
`action` = VALUES(`action`),
`description` = VALUES(`description`),
`valid_state` = 1,
`deleted` = 0;
-- 2. 为 tenant_id=1 的 super_admin 角色补齐“全部有效权限”(增量、幂等)
INSERT INTO `t_auth_role_permission` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `t_auth_role` r
JOIN `t_auth_permission` p ON p.tenant_id = r.tenant_id
WHERE r.tenant_id = 1
AND r.code = 'super_admin'
AND r.deleted = 0
AND p.deleted = 0
AND p.valid_state = 1
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);

View File

@ -0,0 +1,169 @@
-- ============================================
-- 根据 Controller 上的 @PreAuthorize 权限码补齐权限表(幂等)
-- 目标:
-- 1) 确保 Controller 实际使用的 hasAuthority('xxx') 在 t_auth_permission 中存在
-- 2) 确保 tenant_id=1 的 super_admin 角色拥有全部有效权限
-- 说明:
-- - 使用 ON DUPLICATE KEY UPDATE 适配 uk_tenant_code / uk_tenant_resource_action
-- - 避免修改已发布的历史迁移,通过更高版本脚本补偿
-- ============================================
INSERT INTO `t_auth_permission`
(`tenant_id`, `name`, `code`, `resource`, `action`, `description`, `valid_state`)
VALUES
-- 菜单
(1, '菜单创建', 'menu:create', 'menu', 'create', '创建菜单权限', 1),
(1, '菜单查询', 'menu:read', 'menu', 'read', '查询菜单权限', 1),
(1, '菜单更新', 'menu:update', 'menu', 'update', '更新菜单权限', 1),
(1, '菜单删除', 'menu:delete', 'menu', 'delete', '删除菜单权限', 1),
-- 角色
(1, '角色创建', 'role:create', 'role', 'create', '创建角色权限', 1),
(1, '角色查询', 'role:read', 'role', 'read', '查询角色权限', 1),
(1, '角色更新', 'role:update', 'role', 'update', '更新角色权限', 1),
(1, '角色删除', 'role:delete', 'role', 'delete', '删除角色权限', 1),
-- 租户
(1, '租户创建', 'tenant:create', 'tenant', 'create', '创建租户权限', 1),
(1, '租户查询', 'tenant:read', 'tenant', 'read', '查询租户权限', 1),
(1, '租户更新', 'tenant:update', 'tenant', 'update', '更新租户权限', 1),
(1, '租户删除', 'tenant:delete', 'tenant', 'delete', '删除租户权限', 1),
-- 用户
(1, '用户创建', 'user:create', 'user', 'create', '创建用户权限', 1),
(1, '用户查询', 'user:read', 'user', 'read', '查询用户权限', 1),
(1, '用户更新', 'user:update', 'user', 'update', '更新用户权限', 1),
(1, '用户删除', 'user:delete', 'user', 'delete', '删除用户权限', 1),
(1, '用户管理', 'user:manage', 'user', 'manage', '用户管理权限', 1),
-- 权限管理Controller 中目前仅使用 permission:read写操作由 super_admin 控制)
(1, '权限查询', 'permission:read', 'permission', 'read', '查询权限权限', 1),
-- 配置
(1, '配置创建', 'config:create', 'config', 'create', '创建配置权限', 1),
(1, '配置查询', 'config:read', 'config', 'read', '查询配置权限', 1),
(1, '配置更新', 'config:update', 'config', 'update', '更新配置权限', 1),
(1, '配置删除', 'config:delete', 'config', 'delete', '删除配置权限', 1),
-- 字典
(1, '字典创建', 'dict:create', 'dict', 'create', '创建字典权限', 1),
(1, '字典查询', 'dict:read', 'dict', 'read', '查询字典权限', 1),
(1, '字典更新', 'dict:update', 'dict', 'update', '更新字典权限', 1),
(1, '字典删除', 'dict:delete', 'dict', 'delete', '删除字典权限', 1),
-- 日志
(1, '日志查询', 'log:read', 'log', 'read', '查询日志权限', 1),
(1, '日志删除', 'log:delete', 'log', 'delete', '删除日志权限', 1),
-- 学校/组织/人员
(1, '学校创建', 'school:create', 'school', 'create', '创建学校权限', 1),
(1, '学校查询', 'school:read', 'school', 'read', '查询学校权限', 1),
(1, '学校更新', 'school:update', 'school', 'update', '更新学校权限', 1),
(1, '学校删除', 'school:delete', 'school', 'delete', '删除学校权限', 1),
(1, '部门创建', 'department:create', 'department', 'create', '创建部门权限', 1),
(1, '部门查询', 'department:read', 'department', 'read', '查询部门权限', 1),
(1, '部门更新', 'department:update', 'department', 'update', '更新部门权限', 1),
(1, '部门删除', 'department:delete', 'department', 'delete', '删除部门权限', 1),
(1, '年级创建', 'grade:create', 'grade', 'create', '创建年级权限', 1),
(1, '年级查询', 'grade:read', 'grade', 'read', '查询年级权限', 1),
(1, '年级更新', 'grade:update', 'grade', 'update', '更新年级权限', 1),
(1, '年级删除', 'grade:delete', 'grade', 'delete', '删除年级权限', 1),
(1, '班级创建', 'class:create', 'class', 'create', '创建班级权限', 1),
(1, '班级查询', 'class:read', 'class', 'read', '查询班级权限', 1),
(1, '班级更新', 'class:update', 'class', 'update', '更新班级权限', 1),
(1, '班级删除', 'class:delete', 'class', 'delete', '删除班级权限', 1),
(1, '教师创建', 'teacher:create', 'teacher', 'create', '创建教师权限', 1),
(1, '教师查询', 'teacher:read', 'teacher', 'read', '查询教师权限', 1),
(1, '教师更新', 'teacher:update', 'teacher', 'update', '更新教师权限', 1),
(1, '教师删除', 'teacher:delete', 'teacher', 'delete', '删除教师权限', 1),
(1, '学生创建', 'student:create', 'student', 'create', '创建学生权限', 1),
(1, '学生查询', 'student:read', 'student', 'read', '查询学生权限', 1),
(1, '学生更新', 'student:update', 'student', 'update', '更新学生权限', 1),
(1, '学生删除', 'student:delete', 'student', 'delete', '删除学生权限', 1),
-- 活动
(1, '活动创建', 'contest:create', 'contest', 'create', '创建活动权限', 1),
(1, '活动查询', 'contest:read', 'contest', 'read', '查询活动权限', 1),
(1, '活动更新', 'contest:update', 'contest', 'update', '更新活动权限', 1),
(1, '活动发布', 'contest:publish', 'contest', 'publish', '发布活动权限', 1),
(1, '活动删除', 'contest:delete', 'contest', 'delete', '删除活动权限', 1),
(1, '活动报名', 'contest:register', 'contest', 'register', '活动报名权限', 1),
-- 活动公告
(1, '公告创建', 'contest:notice:create', 'contest', 'notice:create', '创建活动公告权限', 1),
(1, '公告更新', 'contest:notice:update', 'contest', 'notice:update', '更新活动公告权限', 1),
(1, '公告删除', 'contest:notice:delete', 'contest', 'notice:delete', '删除活动公告权限', 1),
(1, '公告发布', 'contest:notice:publish', 'contest', 'notice:publish', '发布活动公告权限', 1),
-- 活动评委
(1, '评委创建', 'contest:judge:create', 'contest', 'judge:create', '创建活动评委权限', 1),
(1, '评委更新', 'contest:judge:update', 'contest', 'judge:update', '更新活动评委权限', 1),
(1, '评委删除', 'contest:judge:delete', 'contest', 'judge:delete', '删除活动评委权限', 1),
-- 活动评审规则
(1, '评审规则创建', 'contest:review-rule:create', 'contest', 'review-rule:create', '创建评审规则权限', 1),
(1, '评审规则更新', 'contest:review-rule:update', 'contest', 'review-rule:update', '更新评审规则权限', 1),
(1, '评审规则删除', 'contest:review-rule:delete', 'contest', 'review-rule:delete', '删除评审规则权限', 1),
-- 活动团队
(1, '团队创建', 'contest:team:create', 'contest', 'team:create', '创建活动团队权限', 1),
(1, '团队更新', 'contest:team:update', 'contest', 'team:update', '更新活动团队权限', 1),
(1, '团队删除', 'contest:team:delete', 'contest', 'team:delete', '删除活动团队权限', 1),
-- 活动作业/作品
(1, '作品提交', 'contest:work:submit', 'contest', 'work:submit', '提交作品权限', 1),
(1, '作品更新', 'contest:work:update', 'contest', 'work:update', '更新作品权限', 1),
(1, '作品删除', 'contest:work:delete', 'contest', 'work:delete', '删除作品权限', 1),
-- 活动评审
(1, '评审分配', 'contest:review:assign', 'contest', 'review:assign', '分配评审权限', 1),
(1, '评审评分', 'contest:review:score', 'contest', 'review:score', '评审评分权限', 1),
-- 活动评审结果
(1, '结果创建', 'contest:result:create', 'contest', 'result:create', '创建评审结果权限', 1),
(1, '结果更新', 'contest:result:update', 'contest', 'result:update', '更新评审结果权限', 1),
(1, '结果发布', 'contest:result:publish', 'contest', 'result:publish', '发布评审结果权限', 1),
(1, '结果删除', 'contest:result:delete', 'contest', 'result:delete', '删除评审结果权限', 1),
-- 预设评语
(1, '预设评语创建', 'contest:preset-comment:create', 'contest', 'preset-comment:create', '创建预设评语权限', 1),
(1, '预设评语删除', 'contest:preset-comment:delete', 'contest', 'preset-comment:delete', '删除预设评语权限', 1),
-- 作业(独立模块)
(1, '作业创建', 'homework:create', 'homework', 'create', '创建作业权限', 1),
(1, '作业更新', 'homework:update', 'homework', 'update', '更新作业权限', 1),
(1, '作业发布', 'homework:publish', 'homework', 'publish', '发布作业权限', 1),
(1, '作业删除', 'homework:delete', 'homework', 'delete', '删除作业权限', 1),
(1, '作业评审', 'homework:review', 'homework', 'review', '作业评审权限', 1),
(1, '作业评审规则创建', 'homework:review-rule:create', 'homework', 'review-rule:create', '创建作业评审规则权限', 1),
(1, '作业评审规则更新', 'homework:review-rule:update', 'homework', 'review-rule:update', '更新作业评审规则权限', 1),
(1, '作业评审规则删除', 'homework:review-rule:delete', 'homework', 'review-rule:delete', '删除作业评审规则权限', 1),
-- AI 3D
(1, 'AI 3D 任务删除', 'ai-3d:delete', 'ai-3d', 'delete', '删除 AI 3D 任务权限', 1)
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`code` = VALUES(`code`),
`resource` = VALUES(`resource`),
`action` = VALUES(`action`),
`description` = VALUES(`description`),
`valid_state` = 1,
`deleted` = 0;
-- 为 tenant_id=1 的 super_admin 角色补齐“全部有效权限”(增量、幂等)
INSERT INTO `t_auth_role_permission` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `t_auth_role` r
JOIN `t_auth_permission` p ON p.tenant_id = r.tenant_id
WHERE r.tenant_id = 1
AND r.code = 'super_admin'
AND r.deleted = 0
AND p.deleted = 0
AND p.valid_state = 1
ON DUPLICATE KEY UPDATE `role_id` = VALUES(`role_id`);

View File

@ -1,11 +1,12 @@
<template>
<a-config-provider :theme="themeConfig">
<a-config-provider :theme="themeConfig" :locale="zhCN">
<router-view />
</a-config-provider>
</template>
<script setup lang="ts">
import { ConfigProviderProps } from "ant-design-vue"
import zhCN from "ant-design-vue/es/locale/zh_CN"
//
//

View File

@ -12,7 +12,7 @@ export const authApi = {
},
getUserInfo: async (): Promise<User> => {
const response = await request.get("/api/auth/user-info");
const response = await request.get("/api/auth/me");
return response as unknown as User;
},

View File

@ -11,16 +11,10 @@ export interface Role {
updateBy?: number;
createTime?: string;
updateTime?: string;
permissions?: Array<{
id: number;
permission: {
id: number;
name: string;
code: string;
resource: string;
action: string;
};
}>;
/** 后端返回:权限 ID 列表(用于回显/编辑) */
permissionIds?: number[];
/** 后端返回:菜单 ID 列表(用于回显/编辑) */
menuIds?: number[];
}
export interface CreateRoleForm {
@ -28,6 +22,7 @@ export interface CreateRoleForm {
code: string;
description?: string;
permissionIds?: number[];
menuIds?: number[];
}
export interface UpdateRoleForm {
@ -35,15 +30,19 @@ export interface UpdateRoleForm {
code?: string;
description?: string;
permissionIds?: number[];
menuIds?: number[];
}
// 获取角色列表
export async function getRolesList(
params: PaginationParams
params: PaginationParams,
): Promise<PaginationResponse<Role>> {
const response = await request.get<any, PaginationResponse<Role>>("/api/roles", {
params,
});
const response = await request.get<any, PaginationResponse<Role>>(
"/api/roles",
{
params,
},
);
return response;
}
@ -68,7 +67,7 @@ export async function createRole(data: CreateRoleForm): Promise<Role> {
// 更新角色
export async function updateRole(
id: number,
data: UpdateRoleForm
data: UpdateRoleForm,
): Promise<Role> {
const response = await request.put<any, Role>(`/api/roles/${id}`, data);
return response;

View File

@ -58,7 +58,8 @@ export function useListRequest<
} as PaginationParams & P;
const response = await requestFn(params);
dataSource.value = response.list;
const list = (response.list ?? response.records ?? []) as T[];
dataSource.value = list;
pagination.total = response.total;
} catch (error) {
message.error(errorMessage);

View File

@ -7,7 +7,7 @@ import { useAuthStore } from "@/stores/auth";
interface PermissionDirectiveValue {
// 权限码或权限码数组
permission: string | string[];
// 是否隐藏元素(默认 false即禁用
// 是否隐藏元素(默认 true无权限直接隐藏
hide?: boolean;
// 是否需要所有权限(默认 false即任一权限即可
all?: boolean;
@ -73,13 +73,17 @@ function handlePermission(
if (typeof value === "string" || Array.isArray(value)) {
config = {
permission: value,
hide: modifiers.hide || false,
// 默认隐藏;显式 .disable 才走“禁用”逻辑
hide: modifiers.disable ? false : true,
all: needAll,
};
} else {
config = {
permission: value.permission,
hide: value.hide ?? modifiers.hide ?? false,
// 默认隐藏;若显式传入 hide/disable 则尊重调用方
hide:
value.hide ??
(modifiers.disable ? false : modifiers.hide ? true : true),
all: value.all ?? needAll,
};
}

View File

@ -1,6 +1,8 @@
import { createApp } from "vue"
import { createPinia } from "pinia"
import Antd from "ant-design-vue"
import dayjs from "dayjs"
import "dayjs/locale/zh-cn"
import "ant-design-vue/dist/reset.css"
import "./styles/global.scss"
import "./styles/theme.scss"
@ -9,6 +11,9 @@ import router from "./router"
import { useAuthStore } from "./stores/auth"
import { setupPermissionDirective } from "./directives/permission"
// dayjs 全局中文(简体)
dayjs.locale("zh-cn")
const app = createApp(App)
const pinia = createPinia()

View File

@ -1,146 +1,277 @@
import { defineStore } from "pinia"
import { ref, computed } from "vue"
import type { User, LoginForm } from "@/types/auth"
import { authApi } from "@/api/auth"
import { menusApi, type Menu } from "@/api/menus"
import { getToken, setToken, removeToken, getTenantCode } from "@/utils/auth"
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { User, LoginForm } from "@/types/auth";
import { authApi } from "@/api/auth";
import { menusApi, type Menu } from "@/api/menus";
import {
getToken,
setToken,
removeToken,
getTenantCode,
setTenantId,
removeTenantId,
} from "@/utils/auth";
export const useAuthStore = defineStore("auth", () => {
const user = ref<User | null>(null)
const token = ref<string>(getToken() || "")
const loading = ref<boolean>(false)
const menus = ref<Menu[]>([])
const user = ref<User | null>(null);
const token = ref<string>(getToken() || "");
const loading = ref<boolean>(false);
const menus = ref<Menu[]>([]);
const isAuthenticated = computed(() => !!token.value)
const isAuthenticated = computed(() => !!token.value);
// 获取当前用户的租户编码(优先从用户信息获取,其次从 URL 获取)
const tenantCode = computed(() => {
return user.value?.tenantCode || getTenantCode()
})
return user.value?.tenantCode || getTenantCode();
});
// 检查是否有指定角色
const hasRole = (role: string): boolean => {
return user.value?.roles?.includes(role) ?? false
}
return user.value?.roles?.includes(role) ?? false;
};
// 检查是否为超级管理员
const isSuperAdmin = (): boolean => {
return user.value?.roles?.includes('super_admin') ?? false
}
return user.value?.roles?.includes("super_admin") ?? false;
};
// 检查是否有指定权限
const hasPermission = (permission: string): boolean => {
// 超级管理员拥有所有权限
if (isSuperAdmin()) return true
return user.value?.permissions?.includes(permission) ?? false
}
if (isSuperAdmin()) return true;
return user.value?.permissions?.includes(permission) ?? false;
};
// 检查是否有任一角色
const hasAnyRole = (roles: string[]): boolean => {
if (!roles || roles.length === 0) return true
return roles.some((role) => hasRole(role))
}
if (!roles || roles.length === 0) return true;
return roles.some((role) => hasRole(role));
};
// 检查是否有任一权限
const hasAnyPermission = (permissions: string[]): boolean => {
if (!permissions || permissions.length === 0) return true
if (!permissions || permissions.length === 0) return true;
// 超级管理员拥有所有权限
if (isSuperAdmin()) return true
return permissions.some((perm) => hasPermission(perm))
}
if (isSuperAdmin()) return true;
return permissions.some((perm) => hasPermission(perm));
};
const login = async (form: LoginForm) => {
const response = await authApi.login(form)
token.value = response.token
user.value = response.user
const response = await authApi.login(form);
token.value = response.token;
user.value = response.user;
// 使用租户编码作为 cookie path不再存储到 localStorage
if (response.user.tenantCode) {
setToken(response.token, response.user.tenantCode)
setToken(response.token, response.user.tenantCode);
} else {
setToken(response.token)
setToken(response.token);
}
if (response.user.tenantId) {
setTenantId(response.user.tenantId);
}
// 登录后获取用户菜单
await fetchUserMenus()
return response
}
await fetchUserMenus();
return response;
};
const logout = async () => {
try {
await authApi.logout()
await authApi.logout();
} finally {
token.value = ""
token.value = "";
// 删除 token cookie使用当前用户的租户编码或 URL 中的租户编码
const tenantCode = user.value?.tenantCode || getTenantCode()
removeToken(tenantCode || undefined)
user.value = null
menus.value = []
const tenantCode = user.value?.tenantCode || getTenantCode();
removeToken(tenantCode || undefined);
removeTenantId();
user.value = null;
menus.value = [];
// 重置动态路由标记
if (typeof window !== "undefined") {
const { resetDynamicRoutes } = await import("@/router")
resetDynamicRoutes()
const { resetDynamicRoutes } = await import("@/router");
resetDynamicRoutes();
}
}
}
};
const fetchUserInfo = async () => {
if (!token.value) {
throw new Error("未登录")
throw new Error("未登录");
}
try {
loading.value = true
const response = await authApi.getUserInfo()
user.value = response
loading.value = true;
const response = await authApi.getUserInfo();
user.value = response;
// 获取用户菜单
await fetchUserMenus()
return response
await fetchUserMenus();
return response;
} catch (error) {
// 如果获取用户信息失败,清除 token
token.value = ""
user.value = null
menus.value = []
removeToken()
throw error
token.value = "";
user.value = null;
menus.value = [];
removeToken();
throw error;
} finally {
loading.value = false
loading.value = false;
}
}
};
const fetchUserMenus = async () => {
if (!token.value) {
return
return;
}
try {
const userMenus = await menusApi.getUserMenus()
menus.value = userMenus
return userMenus
const userMenus = await menusApi.getUserMenus();
// 前端兜底:当数据库菜单缺失时,补齐关键入口,避免页面不可达
// 场景:系统管理下的「角色管理」「权限管理」在数据库未配置或被误删
const normalizedMenus = (userMenus || []) as Menu[];
const ensureSystemRolePermissionMenus = (list: Menu[]): Menu[] => {
// 没有用户信息时无法判断权限,直接返回原菜单
if (!user.value) return list;
const has = (
nodes: Menu[],
predicate: (m: Menu) => boolean,
): boolean => {
for (const n of nodes) {
if (predicate(n)) return true;
if (n.children && n.children.length && has(n.children, predicate))
return true;
}
return false;
};
const findSystem = (nodes: Menu[]): Menu | null => {
for (const n of nodes) {
// 优先按 path 匹配 system其次按名称兜底
if (
n.path === "system" ||
n.path === "/system" ||
n.name === "系统管理"
)
return n;
}
return null;
};
const systemMenu = findSystem(list);
// 只有具备读取权限时才补齐入口避免给无权限用户制造“点了进403”的假入口
const canReadRole = hasPermission("role:read");
const canReadPermission = hasPermission("permission:read");
const canReadMenu = hasPermission("menu:read");
// 如果用户完全没有相关权限,就不补
if (!canReadRole && !canReadPermission && !canReadMenu) return list;
// 构造需要补齐的两个菜单使用负数ID避免与数据库ID冲突key 生成依赖 id
const roleMenu: Menu = {
id: -91001,
name: "角色管理",
path: "system/roles",
icon: "Team",
component: "system/roles/Index",
parentId: systemMenu?.id ?? -91000,
permission: "role:read",
sort: 2,
};
const permMenu: Menu = {
id: -91002,
name: "权限管理",
path: "system/permissions",
icon: "Lock",
component: "system/permissions/Index",
parentId: systemMenu?.id ?? -91000,
permission: "permission:read",
sort: 3,
};
const menuMgmtMenu: Menu = {
id: -91003,
name: "菜单管理",
path: "system/menus",
icon: "Menu",
component: "system/menus/Index",
parentId: systemMenu?.id ?? -91000,
permission: "menu:read",
sort: 4,
};
// 若系统管理本身缺失,则创建一个父级系统管理菜单承载
const ensuredList = [...list];
const ensuredSystem: Menu =
systemMenu ??
({
id: -91000,
name: "系统管理",
path: "system",
icon: "Setting",
component: undefined,
parentId: 0,
permission: "super_admin",
sort: 999,
children: [],
} as Menu);
if (!systemMenu) {
ensuredList.push(ensuredSystem);
} else if (!ensuredSystem.children) {
ensuredSystem.children = [];
}
const children = ensuredSystem.children || [];
// // 仅当缺失对应 path 时才补齐
// if (canReadRole && !has(children, (m) => m.path === roleMenu.path)) {
// children.push(roleMenu);
// }
// if (canReadPermission && !has(children, (m) => m.path === permMenu.path)) {
// children.push(permMenu);
// }
// if (canReadMenu && !has(children, (m) => m.path === menuMgmtMenu.path)) {
// children.push(menuMgmtMenu);
// }
// 按 sort 排序,保持菜单稳定
ensuredSystem.children = children.sort(
(a, b) => (a.sort ?? 0) - (b.sort ?? 0),
);
return ensuredList;
};
menus.value = ensureSystemRolePermissionMenus(normalizedMenus);
return userMenus;
} catch (error) {
console.error("获取用户菜单失败:", error)
menus.value = []
throw error
console.error("获取用户菜单失败:", error);
menus.value = [];
throw error;
}
}
};
const updateToken = (newToken: string) => {
token.value = newToken
token.value = newToken;
// 使用当前用户的租户编码更新 token cookie
const tenantCode = user.value?.tenantCode || getTenantCode()
setToken(newToken, tenantCode || undefined)
}
const tenantCode = user.value?.tenantCode || getTenantCode();
setToken(newToken, tenantCode || undefined);
};
// 初始化:如果有 token 但没有用户信息,自动获取
const initAuth = async () => {
if (token.value && !user.value) {
try {
await fetchUserInfo()
await fetchUserInfo();
} catch (error) {
console.error("自动获取用户信息失败:", error)
console.error("自动获取用户信息失败:", error);
}
}
}
};
return {
user,
@ -159,5 +290,5 @@ export const useAuthStore = defineStore("auth", () => {
fetchUserMenus,
updateToken,
initAuth,
}
})
};
});

View File

@ -10,8 +10,20 @@ export interface PaginationParams {
}
export interface PaginationResponse<T> {
list: T[];
/**
*
* - list: 常见返回
* - records: MyBatis-Plus /
*/
list?: T[];
records?: T[];
total: number;
page: number;
pageSize: number;
/** 当前页page 或 current */
page?: number;
current?: number;
/** 每页大小pageSize 或 size */
pageSize?: number;
size?: number;
/** 总页数(可选) */
pages?: number;
}

View File

@ -1,4 +1,5 @@
const TOKEN_KEY = "token";
const TENANT_ID_KEY = "tenantId";
/**
* URL
@ -22,7 +23,7 @@ function getTenantCodeFromUrl(): string | null {
function setCookie(
name: string,
value: string,
options: { path?: string; expires?: number; maxAge?: number } = {}
options: { path?: string; expires?: number; maxAge?: number } = {},
): void {
const { path = "/", expires, maxAge } = options;
let cookieString = `${name}=${encodeURIComponent(value)}; path=${path}`;
@ -77,7 +78,7 @@ export const getToken = (): string | null => {
return getAllTokenCookies();
};
export const setToken = (token: string, tenantCode?: string): void => {
export const setToken = (token: string, _tenantCode?: string): void => {
// 始终将 token 存储在根路径下,确保所有页面都能访问
const base = import.meta.env.BASE_URL || "/";
const basePath = base.endsWith("/") ? base.slice(0, -1) : base;
@ -120,31 +121,35 @@ export const removeTenantCode = (): void => {
};
/**
* ID localStorage
* null
* ID
*
*/
export const getTenantId = (): string | null => {
// 不再从 localStorage 获取租户ID 从用户信息中获取
return null;
return getCookie(TENANT_ID_KEY);
};
/**
* ID localStorage
*
* ID
*/
export const setTenantId = (_tenantId: number | string): void => {
// 不再存储到 localStorage租户ID 从用户信息中获取
const base = import.meta.env.BASE_URL || "/";
const basePath = base.endsWith("/") ? base.slice(0, -1) : base;
const path = basePath || "/";
const expires = 7 * 24 * 60 * 60; // 7 天(秒)
setCookie(TENANT_ID_KEY, String(_tenantId), { path, expires });
};
/**
* ID localStorage
*
* ID
*/
export const removeTenantId = (): void => {
// 不再从 localStorage 删除租户ID 从用户信息中获取
const base = import.meta.env.BASE_URL || "/";
const basePath = base.endsWith("/") ? base.slice(0, -1) : base;
removeCookie(TENANT_ID_KEY, basePath || "/");
};
export const clearAuth = (): void => {
// 只清除 token租户信息不再存储在 localStorage
removeToken();
removeTenantId();
};

View File

@ -4,7 +4,7 @@ import axios, {
type AxiosResponse,
} from "axios";
import { message } from "ant-design-vue";
import { getToken, removeToken, getTenantCode } from "./auth";
import { getToken, removeToken, getTenantCode, getTenantId } from "./auth";
import { useAuthStore } from "@/stores/auth";
import router from "@/router";
@ -87,7 +87,7 @@ service.interceptors.request.use(
// 租户编码从 URL 获取租户ID 从用户信息获取
const tenantCode = getTenantCode();
const authStore = useAuthStore();
const tenantId = authStore.user?.tenantId;
const tenantId = authStore.user?.tenantId ?? getTenantId();
if (config.headers) {
if (tenantCode) {

View File

@ -26,8 +26,15 @@
</a-table>
<!-- 新增/编辑角色弹窗 -->
<a-modal v-model:open="modalVisible" :title="modalTitle" :confirm-loading="submitLoading" @ok="handleSubmit"
@cancel="handleCancel" width="800px" :loading="detailLoading">
<a-modal
:open="modalVisible"
:title="modalTitle"
:confirm-loading="submitLoading"
width="800px"
@ok="handleSubmit"
@cancel="handleCancel"
@update:open="handleOpenChange"
>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="basic" tab="基本信息">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
@ -48,7 +55,7 @@
<div v-if="permissionsLoading" style="text-align: center; padding: 40px;">
<a-spin size="large" />
</div>
<div v-else>
<div v-else class="permission-scroll-wrapper">
<div v-for="(group, resource) in permissionGroups" :key="resource" class="permission-group">
<div class="permission-group-header">
<a-checkbox :checked="isResourceAllChecked(resource)" :indeterminate="isResourceIndeterminate(resource)"
@ -70,6 +77,25 @@
</div>
</div>
</a-tab-pane>
<a-tab-pane key="menus" tab="菜单授权">
<div v-if="menusLoading" style="text-align: center; padding: 40px;">
<a-spin size="large" />
</div>
<div v-else class="menu-tree-wrapper">
<a-tree
checkable
block-node
default-expand-all
v-model:checkedKeys="form.menuIds"
:tree-data="menuTreeData"
>
<template #title="{ title }">
<span>{{ title }}</span>
</template>
</a-tree>
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
</div>
@ -81,6 +107,7 @@ import { message, Modal } from 'ant-design-vue'
import type { TableColumnsType, FormInstance } from 'ant-design-vue'
import { rolesApi, type Role, type CreateRoleForm } from '@/api/roles'
import { permissionsApi, type Permission } from '@/api/permissions'
import { menusApi, type Menu } from '@/api/menus'
import { useListRequest } from '@/composables/useListRequest'
import { useAuthStore } from '@/stores/auth'
@ -89,6 +116,7 @@ const authStore = useAuthStore()
const submitLoading = ref(false)
const detailLoading = ref(false)
const permissionsLoading = ref(false)
const menusLoading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref('新增角色')
const formRef = ref<FormInstance>()
@ -99,7 +127,7 @@ const activeTab = ref('basic')
const allPermissions = ref<Permission[]>([])
const permissionGroups = computed(() => {
const groups: Record<string, Permission[]> = {}
allPermissions.value.forEach((permission) => {
;(allPermissions.value || []).forEach((permission) => {
if (!groups[permission.resource]) {
groups[permission.resource] = []
}
@ -125,6 +153,7 @@ const form = reactive<CreateRoleForm>({
code: '',
description: '',
permissionIds: [],
menuIds: [],
})
const rules = {
@ -160,7 +189,7 @@ const fetchAllPermissions = async () => {
permissionsLoading.value = true
try {
const response = await permissionsApi.getList({ page: 1, pageSize: 100 })
allPermissions.value = response.list
allPermissions.value = response.list ?? response.records ?? []
} catch (error) {
message.error('获取权限列表失败')
} finally {
@ -168,6 +197,30 @@ const fetchAllPermissions = async () => {
}
}
//
const allMenus = ref<Menu[]>([])
const menuTreeData = computed(() => {
const build = (menus: Menu[]): any[] =>
(menus || []).map((m) => ({
title: m.name,
key: m.id,
children: m.children && m.children.length ? build(m.children) : undefined,
}))
return build(allMenus.value)
})
const fetchAllMenus = async () => {
menusLoading.value = true
try {
const response = await menusApi.getList()
allMenus.value = response || []
} catch (error) {
message.error('获取菜单列表失败')
} finally {
menusLoading.value = false
}
}
//
const getResourceName = (resource: string): string => {
const resourceMap: Record<string, string> = {
@ -258,6 +311,7 @@ const handleAdd = () => {
form.code = ''
form.description = ''
form.permissionIds = []
form.menuIds = []
})
}
@ -275,7 +329,9 @@ const handleEdit = async (record: Role) => {
form.code = detail.code
form.description = detail.description || ''
//
form.permissionIds = detail.permissions?.map((rp) => rp.permission.id) || []
form.permissionIds = detail.permissionIds || []
//
form.menuIds = detail.menuIds || []
} catch (error) {
message.error('获取角色详情失败')
modalVisible.value = false
@ -335,6 +391,15 @@ const handleSubmit = async () => {
}
}
//
const handleOpenChange = (open: boolean) => {
modalVisible.value = open
if (!open) {
formRef.value?.resetFields()
activeTab.value = 'basic'
}
}
//
const handleCancel = () => {
modalVisible.value = false
@ -345,6 +410,7 @@ const handleCancel = () => {
//
onMounted(() => {
fetchAllPermissions()
fetchAllMenus()
})
</script>
@ -371,4 +437,20 @@ onMounted(() => {
margin-left: 4px;
}
}
.permission-scroll-wrapper {
padding: 8px 4px;
border: 1px solid #f0f0f0;
border-radius: 8px;
max-height: 520px;
overflow: auto;
}
.menu-tree-wrapper {
padding: 8px 4px;
border: 1px solid #f0f0f0;
border-radius: 8px;
max-height: 520px;
overflow: auto;
}
</style>