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

View File

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

View File

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

View File

@ -23,4 +23,7 @@ public class UpdateRoleDTO {
@Schema(description = "权限 ID 列表") @Schema(description = "权限 ID 列表")
private java.util.List<Long> permissionIds; 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 查询角色详情 * 根据 ID 查询角色详情
* *
* @param id 角色 ID * @param id 角色 ID
* @param tenantId 租户 ID
* @return 角色详情 VO * @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 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 org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -33,6 +37,8 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
private final UserRoleMapper userRoleMapper; private final UserRoleMapper userRoleMapper;
private final RolePermissionMapper rolePermissionMapper; private final RolePermissionMapper rolePermissionMapper;
private final PermissionMapper permissionMapper; private final PermissionMapper permissionMapper;
private final RoleMenuMapper roleMenuMapper;
private final RoleMapper roleMapper;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@ -93,30 +99,31 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
.map(UserRole::getRoleId) .map(UserRole::getRoleId)
.collect(Collectors.toList()); .collect(Collectors.toList());
// 3. 查询角色关联的权限 // 3. 判断是否超级管理员角色 code=super_admin 或权限 code=super_admin 兜底
List<RolePermission> rolePermissions = rolePermissionMapper.selectList( boolean hasSuperAdmin = false;
new LambdaQueryWrapper<RolePermission>() List<Role> roles = roleMapper.selectBatchIds(roleIds);
.in(RolePermission::getRoleId, roleIds) if (roles != null) {
); hasSuperAdmin = roles.stream().anyMatch(r -> "super_admin".equals(r.getCode()));
if (rolePermissions.isEmpty()) {
// 角色没有关联权限返回空菜单
log.warn("用户角色没有关联任何权限,用户 ID: {}", userId);
return new ArrayList<>();
} }
// 4. 获取权限 ID 列表 if (!hasSuperAdmin) {
List<Long> permissionIds = rolePermissions.stream() // 兜底如果权限中包含 super_admin也视为超管
.map(RolePermission::getPermissionId) List<RolePermission> rolePermissionsForSuper = rolePermissionMapper.selectList(
.distinct() new LambdaQueryWrapper<RolePermission>()
.collect(Collectors.toList()); .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 权限 // 4. 查询菜单超管返回所有非超管按角色-菜单关联取并集 + 补齐祖先节点
List<Permission> permissions = permissionMapper.selectBatchIds(permissionIds);
boolean hasSuperAdmin = permissions.stream()
.anyMatch(p -> "super_admin".equals(p.getCode()));
// 6. 查询菜单
LambdaQueryWrapper<Menu> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<Menu> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Menu::getValidState, 1) wrapper.eq(Menu::getValidState, 1)
.orderByAsc(Menu::getSort, Menu::getId); .orderByAsc(Menu::getSort, Menu::getId);
@ -127,24 +134,50 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
log.info("用户是超级管理员,返回所有菜单"); log.info("用户是超级管理员,返回所有菜单");
allMenus = menuMapper.selectList(wrapper); allMenus = menuMapper.selectList(wrapper);
} else { } else {
// 非超管只查询有权限的菜单 // 非超管按角色-菜单关联表控制可见性
// 获取有权限的菜单编码 List<RoleMenu> roleMenus = roleMenuMapper.selectList(
List<String> permissionCodes = permissions.stream() new LambdaQueryWrapper<RoleMenu>()
.map(Permission::getCode) .in(RoleMenu::getRoleId, roleIds)
.collect(Collectors.toList());
// 筛选条件菜单权限为空所有人都可见 菜单权限在用户权限列表中
wrapper.and(w ->
w.isNull(Menu::getPermission)
.or()
.in(Menu::getPermission, permissionCodes)
); );
if (roleMenus.isEmpty()) {
log.warn("用户角色未配置任何菜单可见性,用户 ID: {}", userId);
return new ArrayList<>();
}
log.info("非超管用户,根据权限过滤菜单,权限编码数量:{}", permissionCodes.size()); Set<Long> visibleMenuIds = roleMenus.stream()
allMenus = menuMapper.selectList(wrapper); .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); 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.common.exception.BusinessException;
import com.lesingle.creation.dto.role.CreateRoleDTO; import com.lesingle.creation.dto.role.CreateRoleDTO;
import com.lesingle.creation.dto.role.UpdateRoleDTO; import com.lesingle.creation.dto.role.UpdateRoleDTO;
import com.lesingle.creation.entity.Permission;
import com.lesingle.creation.entity.Role; import com.lesingle.creation.entity.Role;
import com.lesingle.creation.entity.RoleMenu;
import com.lesingle.creation.entity.RolePermission; 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.RoleMapper;
import com.lesingle.creation.mapper.RolePermissionMapper; import com.lesingle.creation.mapper.RolePermissionMapper;
import com.lesingle.creation.service.RoleService; 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 RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper; private final RolePermissionMapper rolePermissionMapper;
private final PermissionMapper permissionMapper;
private final RoleMenuMapper roleMenuMapper;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@ -71,6 +77,22 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
log.info("角色权限关联创建成功,权限数量:{}", dto.getPermissionIds().size()); 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); return convertToDetailVO(role);
} }
@ -98,13 +120,16 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
} }
@Override @Override
public RoleDetailVO detail(Long id) { public RoleDetailVO detail(Long id, Long tenantId) {
log.info("查询角色详情,角色 ID: {}", id); log.info("查询角色详情,角色 ID: {}", id);
Role role = roleMapper.selectById(id); Role role = roleMapper.selectById(id);
if (role == null) { if (role == null) {
throw new BusinessException("角色不存在"); throw new BusinessException("角色不存在");
} }
if (tenantId != null && role.getTenantId() != null && !role.getTenantId().equals(tenantId)) {
throw new BusinessException("角色不存在或不属于当前租户");
}
return convertToDetailVO(role); return convertToDetailVO(role);
} }
@ -118,6 +143,9 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
if (existingRole == null) { if (existingRole == null) {
throw new BusinessException("角色不存在"); throw new BusinessException("角色不存在");
} }
if (existingRole.getTenantId() != null && tenantId != null && !existingRole.getTenantId().equals(tenantId)) {
throw new BusinessException("角色不存在或不属于当前租户");
}
// 如果更新了 code检查是否冲突 // 如果更新了 code检查是否冲突
if (dto.getCode() != null && !dto.getCode().isEmpty()) { 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)); return convertToDetailVO(roleMapper.selectById(id));
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void delete(Long id) { public void delete(Long id, Long tenantId) {
log.info("删除角色,角色 ID: {}", id); log.info("删除角色,角色 ID: {}", id);
Role role = roleMapper.selectById(id); Role role = roleMapper.selectById(id);
if (role == null) { if (role == null) {
throw new BusinessException("角色不存在"); throw new BusinessException("角色不存在");
} }
if (role.getTenantId() != null && tenantId != null && !role.getTenantId().equals(tenantId)) {
throw new BusinessException("角色不存在或不属于当前租户");
}
// 删除角色关联 // 删除角色关联
LambdaQueryWrapper<RolePermission> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<RolePermission> wrapper = new LambdaQueryWrapper<>();
@ -215,9 +267,41 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
vo.setUpdateBy(role.getUpdateBy()); vo.setUpdateBy(role.getUpdateBy());
vo.setUpdateTime(role.getUpdateTime()); vo.setUpdateTime(role.getUpdateTime());
// TODO: 获取角色权限 // 获取角色权限用于授权回显
vo.setPermissionIds(new ArrayList<>()); LambdaQueryWrapper<RolePermission> rpWrapper = new LambdaQueryWrapper<>();
vo.setPermissionNames(new ArrayList<>()); 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; return vo;
} }

View File

@ -48,4 +48,7 @@ public class RoleDetailVO {
@Schema(description = "权限名称列表") @Schema(description = "权限名称列表")
private List<String> permissionNames; 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> <template>
<a-config-provider :theme="themeConfig"> <a-config-provider :theme="themeConfig" :locale="zhCN">
<router-view /> <router-view />
</a-config-provider> </a-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ConfigProviderProps } from "ant-design-vue" 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> => { 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; return response as unknown as User;
}, },

View File

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

View File

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

View File

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

View File

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

View File

@ -1,146 +1,277 @@
import { defineStore } from "pinia" import { defineStore } from "pinia";
import { ref, computed } from "vue" import { ref, computed } from "vue";
import type { User, LoginForm } from "@/types/auth" import type { User, LoginForm } from "@/types/auth";
import { authApi } from "@/api/auth" import { authApi } from "@/api/auth";
import { menusApi, type Menu } from "@/api/menus" import { menusApi, type Menu } from "@/api/menus";
import { getToken, setToken, removeToken, getTenantCode } from "@/utils/auth" import {
getToken,
setToken,
removeToken,
getTenantCode,
setTenantId,
removeTenantId,
} from "@/utils/auth";
export const useAuthStore = defineStore("auth", () => { export const useAuthStore = defineStore("auth", () => {
const user = ref<User | null>(null) const user = ref<User | null>(null);
const token = ref<string>(getToken() || "") const token = ref<string>(getToken() || "");
const loading = ref<boolean>(false) const loading = ref<boolean>(false);
const menus = ref<Menu[]>([]) const menus = ref<Menu[]>([]);
const isAuthenticated = computed(() => !!token.value) const isAuthenticated = computed(() => !!token.value);
// 获取当前用户的租户编码(优先从用户信息获取,其次从 URL 获取) // 获取当前用户的租户编码(优先从用户信息获取,其次从 URL 获取)
const tenantCode = computed(() => { const tenantCode = computed(() => {
return user.value?.tenantCode || getTenantCode() return user.value?.tenantCode || getTenantCode();
}) });
// 检查是否有指定角色 // 检查是否有指定角色
const hasRole = (role: string): boolean => { const hasRole = (role: string): boolean => {
return user.value?.roles?.includes(role) ?? false return user.value?.roles?.includes(role) ?? false;
} };
// 检查是否为超级管理员 // 检查是否为超级管理员
const isSuperAdmin = (): boolean => { const isSuperAdmin = (): boolean => {
return user.value?.roles?.includes('super_admin') ?? false return user.value?.roles?.includes("super_admin") ?? false;
} };
// 检查是否有指定权限 // 检查是否有指定权限
const hasPermission = (permission: string): boolean => { const hasPermission = (permission: string): boolean => {
// 超级管理员拥有所有权限 // 超级管理员拥有所有权限
if (isSuperAdmin()) return true if (isSuperAdmin()) return true;
return user.value?.permissions?.includes(permission) ?? false return user.value?.permissions?.includes(permission) ?? false;
} };
// 检查是否有任一角色 // 检查是否有任一角色
const hasAnyRole = (roles: string[]): boolean => { const hasAnyRole = (roles: string[]): boolean => {
if (!roles || roles.length === 0) return true if (!roles || roles.length === 0) return true;
return roles.some((role) => hasRole(role)) return roles.some((role) => hasRole(role));
} };
// 检查是否有任一权限 // 检查是否有任一权限
const hasAnyPermission = (permissions: string[]): boolean => { const hasAnyPermission = (permissions: string[]): boolean => {
if (!permissions || permissions.length === 0) return true if (!permissions || permissions.length === 0) return true;
// 超级管理员拥有所有权限 // 超级管理员拥有所有权限
if (isSuperAdmin()) return true if (isSuperAdmin()) return true;
return permissions.some((perm) => hasPermission(perm)) return permissions.some((perm) => hasPermission(perm));
} };
const login = async (form: LoginForm) => { const login = async (form: LoginForm) => {
const response = await authApi.login(form) const response = await authApi.login(form);
token.value = response.token token.value = response.token;
user.value = response.user user.value = response.user;
// 使用租户编码作为 cookie path不再存储到 localStorage // 使用租户编码作为 cookie path不再存储到 localStorage
if (response.user.tenantCode) { if (response.user.tenantCode) {
setToken(response.token, response.user.tenantCode) setToken(response.token, response.user.tenantCode);
} else { } else {
setToken(response.token) setToken(response.token);
}
if (response.user.tenantId) {
setTenantId(response.user.tenantId);
} }
// 登录后获取用户菜单 // 登录后获取用户菜单
await fetchUserMenus() await fetchUserMenus();
return response return response;
} };
const logout = async () => { const logout = async () => {
try { try {
await authApi.logout() await authApi.logout();
} finally { } finally {
token.value = "" token.value = "";
// 删除 token cookie使用当前用户的租户编码或 URL 中的租户编码 // 删除 token cookie使用当前用户的租户编码或 URL 中的租户编码
const tenantCode = user.value?.tenantCode || getTenantCode() const tenantCode = user.value?.tenantCode || getTenantCode();
removeToken(tenantCode || undefined) removeToken(tenantCode || undefined);
user.value = null removeTenantId();
menus.value = [] user.value = null;
menus.value = [];
// 重置动态路由标记 // 重置动态路由标记
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const { resetDynamicRoutes } = await import("@/router") const { resetDynamicRoutes } = await import("@/router");
resetDynamicRoutes() resetDynamicRoutes();
} }
} }
} };
const fetchUserInfo = async () => { const fetchUserInfo = async () => {
if (!token.value) { if (!token.value) {
throw new Error("未登录") throw new Error("未登录");
} }
try { try {
loading.value = true loading.value = true;
const response = await authApi.getUserInfo() const response = await authApi.getUserInfo();
user.value = response user.value = response;
// 获取用户菜单 // 获取用户菜单
await fetchUserMenus() await fetchUserMenus();
return response return response;
} catch (error) { } catch (error) {
// 如果获取用户信息失败,清除 token // 如果获取用户信息失败,清除 token
token.value = "" token.value = "";
user.value = null user.value = null;
menus.value = [] menus.value = [];
removeToken() removeToken();
throw error throw error;
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
const fetchUserMenus = async () => { const fetchUserMenus = async () => {
if (!token.value) { if (!token.value) {
return return;
} }
try { try {
const userMenus = await menusApi.getUserMenus() const userMenus = await menusApi.getUserMenus();
menus.value = userMenus // 前端兜底:当数据库菜单缺失时,补齐关键入口,避免页面不可达
return userMenus // 场景:系统管理下的「角色管理」「权限管理」在数据库未配置或被误删
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) { } catch (error) {
console.error("获取用户菜单失败:", error) console.error("获取用户菜单失败:", error);
menus.value = [] menus.value = [];
throw error throw error;
} }
} };
const updateToken = (newToken: string) => { const updateToken = (newToken: string) => {
token.value = newToken token.value = newToken;
// 使用当前用户的租户编码更新 token cookie // 使用当前用户的租户编码更新 token cookie
const tenantCode = user.value?.tenantCode || getTenantCode() const tenantCode = user.value?.tenantCode || getTenantCode();
setToken(newToken, tenantCode || undefined) setToken(newToken, tenantCode || undefined);
} };
// 初始化:如果有 token 但没有用户信息,自动获取 // 初始化:如果有 token 但没有用户信息,自动获取
const initAuth = async () => { const initAuth = async () => {
if (token.value && !user.value) { if (token.value && !user.value) {
try { try {
await fetchUserInfo() await fetchUserInfo();
} catch (error) { } catch (error) {
console.error("自动获取用户信息失败:", error) console.error("自动获取用户信息失败:", error);
} }
} }
} };
return { return {
user, user,
@ -159,5 +290,5 @@ export const useAuthStore = defineStore("auth", () => {
fetchUserMenus, fetchUserMenus,
updateToken, updateToken,
initAuth, initAuth,
} };
}) });

View File

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

View File

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

View File

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

View File

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