feat(auth): 实现多租户切换功能
- 在前端AppLayout中添加租户选择下拉框组件 - 实现switchTenant API接口用于租户间切换 - 更新登录流程以支持租户信息存储和解析 - 修改后端认证服务以支持租户上下文管理 - 添加租户权限验证和访问控制逻辑 - 重构权限查询以基于当前租户进行过滤 - 更新数据访问层以正确处理租户隔离 - 添加租户切换相关的状态管理和UI显示master
parent
26c2b977d6
commit
86009e2602
|
|
@ -69,17 +69,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
// 1. Validate User Status (Ignore Tenant isolation here)
|
// 1. Validate User Status (Ignore Tenant isolation here)
|
||||||
SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId);
|
SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId);
|
||||||
if (user == null || user.getStatus() != 1 || user.getIsDeleted() ) {
|
if (user == null || user.getStatus() != 1 || user.getIsDeleted() != 0) {
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User account is disabled or deleted");
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"code\":\"401\",\"msg\":\"User account is disabled or deleted\"}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Validate Tenant Status & Grace Period
|
// 2. Validate Tenant Status & Grace Period
|
||||||
// Skip validation for system platform tenant (ID=0)
|
// Skip validation for system platform tenant (ID=0)
|
||||||
if (tenantId != null && !Long.valueOf(0).equals(tenantId)) {
|
Long activeTenantId = tenantId;
|
||||||
SysTenant tenant = sysTenantMapper.selectByIdIgnoreTenant(tenantId);
|
if (activeTenantId != null && !Long.valueOf(0).equals(activeTenantId)) {
|
||||||
|
SysTenant tenant = sysTenantMapper.selectByIdIgnoreTenant(activeTenantId);
|
||||||
if (tenant == null || tenant.getStatus() != 1) {
|
if (tenant == null || tenant.getStatus() != 1) {
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Tenant is disabled");
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Tenant is disabled\"}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,27 +94,29 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
String graceDaysStr = sysParamService.getParamValue("sys.tenant.grace_period_days", "0");
|
String graceDaysStr = sysParamService.getParamValue("sys.tenant.grace_period_days", "0");
|
||||||
int graceDays = Integer.parseInt(graceDaysStr);
|
int graceDays = Integer.parseInt(graceDaysStr);
|
||||||
if (now.isAfter(tenant.getExpireTime().plusDays(graceDays))) {
|
if (now.isAfter(tenant.getExpireTime().plusDays(graceDays))) {
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Tenant subscription expired");
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Tenant subscription expired\"}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Get Permissions (With Redis Cache)
|
// 3. Get Permissions (With Redis Cache, Key must include tenantId)
|
||||||
String permKey = "sys:auth:perm:" + userId;
|
String permKey = "sys:auth:perm:" + userId + ":" + activeTenantId;
|
||||||
Set<String> permissions;
|
Set<String> permissions;
|
||||||
String cachedPerms = redisTemplate.opsForValue().get(permKey);
|
String cachedPerms = redisTemplate.opsForValue().get(permKey);
|
||||||
if (cachedPerms != null) {
|
if (cachedPerms != null) {
|
||||||
permissions = Set.of(cachedPerms.split(","));
|
permissions = Set.of(cachedPerms.split(","));
|
||||||
} else {
|
} else {
|
||||||
permissions = sysPermissionService.listPermissionCodesByUserId(userId);
|
permissions = sysPermissionService.listPermissionCodesByUserId(userId, activeTenantId);
|
||||||
if (permissions != null && !permissions.isEmpty()) {
|
if (permissions != null && !permissions.isEmpty()) {
|
||||||
redisTemplate.opsForValue().set(permKey, String.join(",", permissions), java.time.Duration.ofHours(2));
|
redisTemplate.opsForValue().set(permKey, String.join(",", permissions), java.time.Duration.ofHours(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LoginUser loginUser = new LoginUser(userId, tenantId, username, user.getIsPlatformAdmin(), permissions);
|
LoginUser loginUser = new LoginUser(userId, activeTenantId, username, user.getIsPlatformAdmin(), permissions);
|
||||||
|
|
||||||
UsernamePasswordAuthenticationToken authentication =
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
|
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
package com.imeeting.auth.dto;
|
package com.imeeting.auth.dto;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@AllArgsConstructor
|
@Builder
|
||||||
public class TokenResponse {
|
public class TokenResponse {
|
||||||
private String accessToken;
|
private String accessToken;
|
||||||
private String refreshToken;
|
private String refreshToken;
|
||||||
private long accessExpiresInMinutes;
|
private long accessExpiresInMinutes;
|
||||||
private long refreshExpiresInDays;
|
private long refreshExpiresInDays;
|
||||||
|
private List<TenantInfo> availableTenants;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class TenantInfo {
|
||||||
|
private Long tenantId;
|
||||||
|
private String tenantCode;
|
||||||
|
private String tenantName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ public class GlobalExceptionHandler {
|
||||||
return ApiResponse.error(ex.getMessage());
|
return ApiResponse.error(ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
|
||||||
|
public ApiResponse<Void> handleAccessDenied(org.springframework.security.access.AccessDeniedException ex) {
|
||||||
|
log.warn("Access denied: {}", ex.getMessage());
|
||||||
|
return ApiResponse.error("无权限操作");
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ApiResponse<Void> handleGeneric(Exception ex) {
|
public ApiResponse<Void> handleGeneric(Exception ex) {
|
||||||
log.error("Unhandled exception", ex);
|
log.error("Unhandled exception", ex);
|
||||||
|
|
|
||||||
|
|
@ -43,17 +43,18 @@ public class MybatisPlusConfig {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean ignoreTable(String tableName) {
|
public boolean ignoreTable(String tableName) {
|
||||||
// System level tables that should not be filtered by tenant_id
|
|
||||||
// and check if current user is platform admin to skip filtering
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
|
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
|
||||||
LoginUser user = (LoginUser) auth.getPrincipal();
|
LoginUser user = (LoginUser) auth.getPrincipal();
|
||||||
|
// 只有当平台管理员处于系统租户(0)时,才忽略所有过滤。
|
||||||
|
// 如果他切换到了具体租户(>0),则必须接受过滤,确保只能看到当前租户数据。
|
||||||
if (Boolean.TRUE.equals(user.getIsPlatformAdmin()) && Long.valueOf(0).equals(user.getTenantId())) {
|
if (Boolean.TRUE.equals(user.getIsPlatformAdmin()) && Long.valueOf(0).equals(user.getTenantId())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return List.of("sys_tenant", "sys_permission", "sys_role_permission", "sys_user_role", "sys_dict_type", "sys_dict_item", "sys_param").contains(tableName.toLowerCase());
|
// 公共表始终忽略过滤
|
||||||
|
return List.of("sys_tenant", "sys_user", "sys_tenant_user", "sys_permission", "sys_role_permission", "sys_user_role", "sys_dict_type", "sys_dict_item", "sys_param").contains(tableName.toLowerCase());
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
return interceptor;
|
return interceptor;
|
||||||
|
|
@ -67,7 +68,7 @@ public class MybatisPlusConfig {
|
||||||
strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class);
|
strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class);
|
||||||
strictInsertFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
|
strictInsertFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
|
||||||
strictInsertFill(metaObject, "status", () -> 1, Integer.class);
|
strictInsertFill(metaObject, "status", () -> 1, Integer.class);
|
||||||
strictInsertFill(metaObject, "isDeleted", () -> Boolean.FALSE, Boolean.class);
|
strictInsertFill(metaObject, "isDeleted", () -> 0, Integer.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,15 @@ public class AuthController {
|
||||||
return ApiResponse.ok(authService.refresh(request.getRefreshToken()));
|
return ApiResponse.ok(authService.refresh(request.getRefreshToken()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/switch-tenant")
|
||||||
|
public ApiResponse<TokenResponse> switchTenant(@RequestParam Long tenantId, @RequestHeader("Authorization") String authorization) {
|
||||||
|
String token = authorization.replace("Bearer ", "");
|
||||||
|
var claims = jwtTokenProvider.parseToken(token);
|
||||||
|
Long userId = claims.get("userId", Long.class);
|
||||||
|
String deviceCode = claims.get("deviceCode", String.class);
|
||||||
|
return ApiResponse.ok(authService.switchTenant(userId, tenantId, deviceCode));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ApiResponse<Void> logout(@RequestHeader("Authorization") String authorization) {
|
public ApiResponse<Void> logout(@RequestHeader("Authorization") String authorization) {
|
||||||
String token = authorization.replace("Bearer ", "");
|
String token = authorization.replace("Bearer ", "");
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,7 @@ public class PermissionController {
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
public ApiResponse<List<SysPermission>> myPermissions() {
|
public ApiResponse<List<SysPermission>> myPermissions() {
|
||||||
// Implementation can use SecurityContext to get current userId
|
return ApiResponse.ok(sysPermissionService.listByUserId(getCurrentUserId(), getCurrentTenantId()));
|
||||||
return ApiResponse.ok(sysPermissionService.listByUserId(getCurrentUserId()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/tree")
|
@GetMapping("/tree")
|
||||||
|
|
@ -46,7 +45,7 @@ public class PermissionController {
|
||||||
|
|
||||||
@GetMapping("/tree/me")
|
@GetMapping("/tree/me")
|
||||||
public ApiResponse<List<PermissionNode>> myTree() {
|
public ApiResponse<List<PermissionNode>> myTree() {
|
||||||
return ApiResponse.ok(buildTree(sysPermissionService.listByUserId(getCurrentUserId())));
|
return ApiResponse.ok(buildTree(sysPermissionService.listByUserId(getCurrentUserId(), getCurrentTenantId())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
|
@ -97,6 +96,14 @@ public class PermissionController {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Long getCurrentTenantId() {
|
||||||
|
org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication != null && authentication.getPrincipal() instanceof com.imeeting.security.LoginUser) {
|
||||||
|
return ((com.imeeting.security.LoginUser) authentication.getPrincipal()).getTenantId();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private String validateParent(SysPermission perm) {
|
private String validateParent(SysPermission perm) {
|
||||||
if (perm.getLevel() == null) {
|
if (perm.getLevel() == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -28,22 +28,35 @@ public class UserController {
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
private final SysUserRoleMapper sysUserRoleMapper;
|
private final SysUserRoleMapper sysUserRoleMapper;
|
||||||
|
private final com.imeeting.service.SysTenantUserService sysTenantUserService;
|
||||||
|
|
||||||
public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, SysUserRoleMapper sysUserRoleMapper) {
|
public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, SysUserRoleMapper sysUserRoleMapper, com.imeeting.service.SysTenantUserService sysTenantUserService) {
|
||||||
this.sysUserService = sysUserService;
|
this.sysUserService = sysUserService;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.jwtTokenProvider = jwtTokenProvider;
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
this.sysUserRoleMapper = sysUserRoleMapper;
|
this.sysUserRoleMapper = sysUserRoleMapper;
|
||||||
|
this.sysTenantUserService = sysTenantUserService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("@ss.hasPermi('sys_user:list')")
|
@PreAuthorize("@ss.hasPermi('sys_user:list')")
|
||||||
public ApiResponse<List<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) {
|
public ApiResponse<List<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) {
|
||||||
LambdaQueryWrapper<SysUser> query = new LambdaQueryWrapper<>();
|
Long currentTenantId = getCurrentTenantId();
|
||||||
if (orgId != null) {
|
if (Long.valueOf(0).equals(currentTenantId) && tenantId == null) {
|
||||||
query.eq(SysUser::getOrgId, orgId);
|
List<SysUser> allUsers = sysUserService.list();
|
||||||
|
// 为每个用户加载其租户关系
|
||||||
|
if (allUsers != null && !allUsers.isEmpty()) {
|
||||||
|
for (SysUser user : allUsers) {
|
||||||
|
user.setMemberships(sysTenantUserService.listByUserId(user.getUserId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(allUsers);
|
||||||
}
|
}
|
||||||
return ApiResponse.ok(sysUserService.list(query));
|
Long targetTenantId = tenantId != null ? tenantId : currentTenantId;
|
||||||
|
if (targetTenantId == null) {
|
||||||
|
return ApiResponse.error("Tenant ID required");
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(sysUserService.listUsersByTenant(targetTenantId, orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
|
|
@ -55,7 +68,7 @@ public class UserController {
|
||||||
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
|
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
|
||||||
Long userId = loginUser.getUserId();
|
Long userId = loginUser.getUserId();
|
||||||
|
|
||||||
SysUser user = sysUserService.getById(userId);
|
SysUser user = sysUserService.getByIdIgnoreTenant(userId);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return ApiResponse.error("User not found");
|
return ApiResponse.error("User not found");
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +87,19 @@ public class UserController {
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@PreAuthorize("@ss.hasPermi('sys_user:query')")
|
@PreAuthorize("@ss.hasPermi('sys_user:query')")
|
||||||
public ApiResponse<SysUser> get(@PathVariable Long id) {
|
public ApiResponse<SysUser> get(@PathVariable Long id) {
|
||||||
return ApiResponse.ok(sysUserService.getById(id));
|
SysUser user = sysUserService.getByIdIgnoreTenant(id);
|
||||||
|
if (user != null) {
|
||||||
|
user.setMemberships(sysTenantUserService.listByUserId(id));
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getCurrentTenantId() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
|
||||||
|
return ((LoginUser) auth.getPrincipal()).getTenantId();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -84,7 +109,11 @@ public class UserController {
|
||||||
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
||||||
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
||||||
}
|
}
|
||||||
return ApiResponse.ok(sysUserService.save(user));
|
boolean saved = sysUserService.save(user);
|
||||||
|
if (saved) {
|
||||||
|
sysTenantUserService.syncMemberships(user.getUserId(), user.getMemberships());
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
|
|
@ -95,7 +124,11 @@ public class UserController {
|
||||||
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
|
||||||
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
|
||||||
}
|
}
|
||||||
return ApiResponse.ok(sysUserService.updateById(user));
|
boolean updated = sysUserService.updateById(user);
|
||||||
|
if (updated) {
|
||||||
|
sysTenantUserService.syncMemberships(id, user.getMemberships());
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ public class BaseEntity {
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@TableLogic
|
@TableLogic(value = "0", delval = "1")
|
||||||
private Boolean isDeleted;
|
private Integer isDeleted;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,5 @@ public class SysDictItem extends BaseEntity {
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
|
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private Boolean isDeleted;
|
private Integer isDeleted;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,5 @@ public class SysDictType extends BaseEntity {
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
|
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private Boolean isDeleted;
|
private Integer isDeleted;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ public class SysUser extends BaseEntity {
|
||||||
private String phone;
|
private String phone;
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
|
|
||||||
private Long orgId;
|
|
||||||
private Boolean isPlatformAdmin;
|
private Boolean isPlatformAdmin;
|
||||||
|
|
||||||
|
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||||
|
private Long tenantId;
|
||||||
|
|
||||||
|
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||||
|
private Long orgId;
|
||||||
|
|
||||||
|
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||||
|
private java.util.List<SysTenantUser> memberships;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface SysPermissionMapper extends BaseMapper<SysPermission> {
|
public interface SysPermissionMapper extends BaseMapper<SysPermission> {
|
||||||
|
@com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true")
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT DISTINCT p.*
|
SELECT DISTINCT p.*
|
||||||
FROM sys_permission p
|
FROM sys_permission p
|
||||||
JOIN sys_role_permission rp ON rp.perm_id = p.perm_id
|
JOIN sys_role_permission rp ON rp.perm_id = p.perm_id
|
||||||
JOIN sys_user_role ur ON ur.role_id = rp.role_id
|
JOIN sys_role r ON r.role_id = rp.role_id
|
||||||
WHERE ur.user_id = #{userId}
|
JOIN sys_user_role ur ON ur.role_id = r.role_id
|
||||||
|
WHERE ur.user_id = #{userId} AND r.tenant_id = #{tenantId}
|
||||||
""")
|
""")
|
||||||
List<SysPermission> selectByUserId(@Param("userId") Long userId);
|
List<SysPermission> selectByUserId(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,25 @@ public interface SysUserMapper extends BaseMapper<SysUser> {
|
||||||
@Select("SELECT * FROM sys_user WHERE user_id = #{userId} AND is_deleted = 0")
|
@Select("SELECT * FROM sys_user WHERE user_id = #{userId} AND is_deleted = 0")
|
||||||
SysUser selectByIdIgnoreTenant(@Param("userId") Long userId);
|
SysUser selectByIdIgnoreTenant(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("<script>" +
|
||||||
|
"SELECT u.*, tu.org_id as orgId, tu.tenant_id as tenantId " +
|
||||||
|
"FROM sys_user u " +
|
||||||
|
"JOIN sys_tenant_user tu ON u.user_id = tu.user_id " +
|
||||||
|
"WHERE tu.tenant_id = #{tenantId} " +
|
||||||
|
"<if test='orgId != null'> AND tu.org_id = #{orgId} </if> " +
|
||||||
|
"AND u.is_deleted = 0 AND tu.is_deleted = 0" +
|
||||||
|
"</script>")
|
||||||
|
List<SysUser> selectUsersByTenant(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId);
|
||||||
|
|
||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT u.*
|
SELECT t.id as tenantId, t.tenant_code as tenantCode, t.tenant_name as tenantName
|
||||||
FROM sys_user u
|
FROM sys_tenant t
|
||||||
JOIN sys_tenant t ON u.tenant_id = t.id
|
JOIN sys_tenant_user tu ON t.id = tu.tenant_id
|
||||||
WHERE u.username = #{username}
|
JOIN sys_user u ON u.user_id = tu.user_id
|
||||||
AND t.tenant_code = #{tenantCode}
|
WHERE u.username = #{username} AND u.is_deleted = 0 AND t.is_deleted = 0
|
||||||
AND u.is_deleted = 0
|
ORDER BY t.id ASC
|
||||||
""")
|
""")
|
||||||
SysUser selectByUsernameAndTenantCode(@Param("username") String username, @Param("tenantCode") String tenantCode);
|
List<com.imeeting.auth.dto.TokenResponse.TenantInfo> selectTenantsByUsername(@Param("username") String username);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,5 @@ public interface AuthService {
|
||||||
TokenResponse refresh(String refreshToken);
|
TokenResponse refresh(String refreshToken);
|
||||||
void logout(Long userId, String deviceCode);
|
void logout(Long userId, String deviceCode);
|
||||||
String createDeviceCode(LoginRequest request, String deviceName);
|
String createDeviceCode(LoginRequest request, String deviceName);
|
||||||
|
TokenResponse switchTenant(Long userId, Long targetTenantId, String deviceCode);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface SysPermissionService extends IService<SysPermission> {
|
public interface SysPermissionService extends IService<SysPermission> {
|
||||||
List<SysPermission> listByUserId(Long userId);
|
List<SysPermission> listByUserId(Long userId, Long tenantId);
|
||||||
|
|
||||||
Set<String> listPermissionCodesByUserId(Long userId);
|
Set<String> listPermissionCodesByUserId(Long userId, Long tenantId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,16 @@ import java.util.List;
|
||||||
|
|
||||||
public interface SysUserService extends IService<SysUser> {
|
public interface SysUserService extends IService<SysUser> {
|
||||||
|
|
||||||
List<SysUser> listUsersByRoleId(Long roleId);
|
List<SysUser> listUsersByRoleId(Long roleId);
|
||||||
|
|
||||||
|
SysUser getByIdIgnoreTenant(Long userId);
|
||||||
|
|
||||||
|
List<SysUser> listUsersByTenant(Long tenantId, Long orgId);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,20 +71,51 @@ public class AuthServiceImpl implements AuthService {
|
||||||
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
SysUser user;
|
SysUser user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername());
|
||||||
if (request.getTenantCode() == null || request.getTenantCode().trim().isEmpty()) {
|
|
||||||
// 平台管理登录逻辑 (全局搜索)
|
|
||||||
user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername());
|
|
||||||
if (user != null && !Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
|
||||||
throw new IllegalArgumentException("非平台管理账号请提供租户编码");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 租户用户登录逻辑 (按租户搜索)
|
|
||||||
user = sysUserMapper.selectByUsernameAndTenantCode(request.getUsername(), request.getTenantCode().trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user == null || user.getStatus() != 1 || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
if (user == null || user.getStatus() != 1 || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
throw new IllegalArgumentException("用户名、密码或租户编码错误");
|
throw new IllegalArgumentException("用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该用户关联的所有租户
|
||||||
|
java.util.List<TokenResponse.TenantInfo> availableTenants = sysUserMapper.selectTenantsByUsername(user.getUsername());
|
||||||
|
|
||||||
|
// 如果是平台管理员,且没有在租户列表中,手动添加系统租户(ID=0)
|
||||||
|
if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
||||||
|
boolean hasSystemTenant = availableTenants.stream().anyMatch(t -> t.getTenantId() == 0L);
|
||||||
|
if (!hasSystemTenant) {
|
||||||
|
availableTenants.add(0, TokenResponse.TenantInfo.builder()
|
||||||
|
.tenantId(0L).tenantCode("SYSTEM").tenantName("系统平台").build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableTenants.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("该账号未关联任何租户");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定当前租户:
|
||||||
|
// 1. 如果请求指定了租户,且用户属于该租户,则使用该租户
|
||||||
|
// 2. 否则,如果用户是平台管理员,默认进入系统租户(0)
|
||||||
|
// 3. 否则,使用第一个非0的业务租户
|
||||||
|
Long activeTenantId = null;
|
||||||
|
if (request.getTenantCode() != null && !request.getTenantCode().trim().isEmpty()) {
|
||||||
|
String tc = request.getTenantCode().trim();
|
||||||
|
activeTenantId = availableTenants.stream()
|
||||||
|
.filter(t -> t.getTenantCode().equals(tc))
|
||||||
|
.map(TokenResponse.TenantInfo::getTenantId)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("您不属于指定的租户: " + tc));
|
||||||
|
} else {
|
||||||
|
if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
||||||
|
activeTenantId = 0L;
|
||||||
|
} else {
|
||||||
|
// 优先选择非0的租户
|
||||||
|
activeTenantId = availableTenants.stream()
|
||||||
|
.map(TokenResponse.TenantInfo::getTenantId)
|
||||||
|
.filter(id -> id != 0L)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(availableTenants.get(0).getTenantId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String deviceCode = request.getDeviceCode();
|
String deviceCode = request.getDeviceCode();
|
||||||
|
|
@ -107,10 +138,11 @@ public class AuthServiceImpl implements AuthService {
|
||||||
if (deviceCode == null || deviceCode.isEmpty()) {
|
if (deviceCode == null || deviceCode.isEmpty()) {
|
||||||
deviceCode = "default";
|
deviceCode = "default";
|
||||||
}
|
}
|
||||||
TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays);
|
TokenResponse tokens = issueTokens(user, activeTenantId, deviceCode, accessMinutes, refreshDays);
|
||||||
|
tokens.setAvailableTenants(availableTenants);
|
||||||
cacheRefreshToken(user.getUserId(), deviceCode, tokens.getRefreshToken(), refreshDays);
|
cacheRefreshToken(user.getUserId(), deviceCode, tokens.getRefreshToken(), refreshDays);
|
||||||
|
|
||||||
recordLoginLog(user.getUserId(), user.getTenantId(), user.getUsername(), 1, "登录成功", System.currentTimeMillis() - start);
|
recordLoginLog(user.getUserId(), activeTenantId, user.getUsername(), 1, "登录成功", System.currentTimeMillis() - start);
|
||||||
return tokens;
|
return tokens;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
recordLoginLog(null, null, request.getUsername(), 0, e.getMessage(), System.currentTimeMillis() - start);
|
recordLoginLog(null, null, request.getUsername(), 0, e.getMessage(), System.currentTimeMillis() - start);
|
||||||
|
|
@ -141,6 +173,7 @@ public class AuthServiceImpl implements AuthService {
|
||||||
throw new IllegalArgumentException("无效的刷新令牌");
|
throw new IllegalArgumentException("无效的刷新令牌");
|
||||||
}
|
}
|
||||||
Long userId = claims.get("userId", Long.class);
|
Long userId = claims.get("userId", Long.class);
|
||||||
|
Long tenantId = claims.get("tenantId", Long.class);
|
||||||
String deviceCode = claims.get("deviceCode", String.class);
|
String deviceCode = claims.get("deviceCode", String.class);
|
||||||
String cached = stringRedisTemplate.opsForValue().get(RedisKeys.refreshTokenKey(userId, deviceCode));
|
String cached = stringRedisTemplate.opsForValue().get(RedisKeys.refreshTokenKey(userId, deviceCode));
|
||||||
if (cached == null || !cached.equals(refreshToken)) {
|
if (cached == null || !cached.equals(refreshToken)) {
|
||||||
|
|
@ -152,12 +185,54 @@ public class AuthServiceImpl implements AuthService {
|
||||||
long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days",
|
long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days",
|
||||||
String.valueOf(refreshDefaultDays)), refreshDefaultDays);
|
String.valueOf(refreshDefaultDays)), refreshDefaultDays);
|
||||||
|
|
||||||
SysUser user = sysUserService.getById(userId);
|
SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId);
|
||||||
TokenResponse tokens = issueTokens(user, deviceCode, accessMinutes, refreshDays);
|
TokenResponse tokens = issueTokens(user, tenantId, deviceCode, accessMinutes, refreshDays);
|
||||||
cacheRefreshToken(userId, deviceCode, tokens.getRefreshToken(), refreshDays);
|
cacheRefreshToken(userId, deviceCode, tokens.getRefreshToken(), refreshDays);
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TokenResponse switchTenant(Long userId, Long targetTenantId, String deviceCode) {
|
||||||
|
SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new IllegalArgumentException("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验权限:平台管理员可以直接进入租户0,或者用户确实关联了目标租户
|
||||||
|
boolean hasAccess = false;
|
||||||
|
if (targetTenantId == 0L && Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
||||||
|
hasAccess = true;
|
||||||
|
} else {
|
||||||
|
java.util.List<TokenResponse.TenantInfo> tenants = sysUserMapper.selectTenantsByUsername(user.getUsername());
|
||||||
|
hasAccess = tenants.stream().anyMatch(t -> t.getTenantId().equals(targetTenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
throw new IllegalArgumentException("您不属于目标租户");
|
||||||
|
}
|
||||||
|
|
||||||
|
long accessMinutes = parseLong(sysParamService.getParamValue("security.token.access_ttl_minutes",
|
||||||
|
String.valueOf(accessDefaultMinutes)), accessDefaultMinutes);
|
||||||
|
long refreshDays = parseLong(sysParamService.getParamValue("security.token.refresh_ttl_days",
|
||||||
|
String.valueOf(refreshDefaultDays)), refreshDefaultDays);
|
||||||
|
|
||||||
|
TokenResponse tokens = issueTokens(user, targetTenantId, deviceCode, accessMinutes, refreshDays);
|
||||||
|
cacheRefreshToken(userId, deviceCode, tokens.getRefreshToken(), refreshDays);
|
||||||
|
|
||||||
|
// 重新获取该用户关联的所有租户信息返回
|
||||||
|
java.util.List<TokenResponse.TenantInfo> availableTenants = sysUserMapper.selectTenantsByUsername(user.getUsername());
|
||||||
|
if (Boolean.TRUE.equals(user.getIsPlatformAdmin())) {
|
||||||
|
boolean hasSystemTenant = availableTenants.stream().anyMatch(t -> t.getTenantId() == 0L);
|
||||||
|
if (!hasSystemTenant) {
|
||||||
|
availableTenants.add(0, TokenResponse.TenantInfo.builder()
|
||||||
|
.tenantId(0L).tenantCode("SYSTEM").tenantName("系统平台").build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.setAvailableTenants(availableTenants);
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void logout(Long userId, String deviceCode) {
|
public void logout(Long userId, String deviceCode) {
|
||||||
stringRedisTemplate.delete(RedisKeys.refreshTokenKey(userId, deviceCode));
|
stringRedisTemplate.delete(RedisKeys.refreshTokenKey(userId, deviceCode));
|
||||||
|
|
@ -169,15 +244,10 @@ public class AuthServiceImpl implements AuthService {
|
||||||
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
validateCaptcha(request.getCaptchaId(), request.getCaptchaCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
SysUser user;
|
SysUser user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername());
|
||||||
if (request.getTenantCode() == null || request.getTenantCode().trim().isEmpty()) {
|
|
||||||
user = sysUserMapper.selectByUsernameIgnoreTenant(request.getUsername());
|
|
||||||
} else {
|
|
||||||
user = sysUserMapper.selectByUsernameAndTenantCode(request.getUsername(), request.getTenantCode().trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user == null || user.getStatus() != 1 || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
if (user == null || user.getStatus() != 1 || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
throw new IllegalArgumentException("用户名、密码或租户编码错误");
|
throw new IllegalArgumentException("用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
String deviceCode = UUID.randomUUID().toString().replace("-", "");
|
String deviceCode = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
|
@ -227,23 +297,28 @@ public class AuthServiceImpl implements AuthService {
|
||||||
return Boolean.parseBoolean(value);
|
return Boolean.parseBoolean(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TokenResponse issueTokens(SysUser user, String deviceCode, long accessMinutes, long refreshDays) {
|
private TokenResponse issueTokens(SysUser user, Long tenantId, String deviceCode, long accessMinutes, long refreshDays) {
|
||||||
Map<String, Object> accessClaims = new HashMap<>();
|
Map<String, Object> accessClaims = new HashMap<>();
|
||||||
accessClaims.put("tokenType", "access");
|
accessClaims.put("tokenType", "access");
|
||||||
accessClaims.put("userId", user.getUserId());
|
accessClaims.put("userId", user.getUserId());
|
||||||
accessClaims.put("tenantId", user.getTenantId());
|
accessClaims.put("tenantId", tenantId);
|
||||||
accessClaims.put("username", user.getUsername());
|
accessClaims.put("username", user.getUsername());
|
||||||
accessClaims.put("deviceCode", deviceCode);
|
accessClaims.put("deviceCode", deviceCode);
|
||||||
|
|
||||||
Map<String, Object> refreshClaims = new HashMap<>();
|
Map<String, Object> refreshClaims = new HashMap<>();
|
||||||
refreshClaims.put("tokenType", "refresh");
|
refreshClaims.put("tokenType", "refresh");
|
||||||
refreshClaims.put("userId", user.getUserId());
|
refreshClaims.put("userId", user.getUserId());
|
||||||
refreshClaims.put("tenantId", user.getTenantId());
|
refreshClaims.put("tenantId", tenantId);
|
||||||
refreshClaims.put("deviceCode", deviceCode);
|
refreshClaims.put("deviceCode", deviceCode);
|
||||||
|
|
||||||
String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis());
|
String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis());
|
||||||
String refresh = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis());
|
String refresh = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis());
|
||||||
return new TokenResponse(access, refresh, accessMinutes, refreshDays);
|
return TokenResponse.builder()
|
||||||
|
.accessToken(access)
|
||||||
|
.refreshToken(refresh)
|
||||||
|
.accessExpiresInMinutes(accessMinutes)
|
||||||
|
.refreshExpiresInDays(refreshDays)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cacheRefreshToken(Long userId, String deviceCode, String refreshToken, long refreshDays) {
|
private void cacheRefreshToken(Long userId, String deviceCode, String refreshToken, long refreshDays) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ public class SysOrgServiceImpl extends ServiceImpl<SysOrgMapper, SysOrg> impleme
|
||||||
@Override
|
@Override
|
||||||
public List<SysOrg> listTree(Long tenantId) {
|
public List<SysOrg> listTree(Long tenantId) {
|
||||||
LambdaQueryWrapper<SysOrg> query = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<SysOrg> query = new LambdaQueryWrapper<>();
|
||||||
|
if (tenantId != null) {
|
||||||
|
query.eq(SysOrg::getTenantId, tenantId);
|
||||||
|
}
|
||||||
query.orderByAsc(SysOrg::getSortOrder);
|
query.orderByAsc(SysOrg::getSortOrder);
|
||||||
return list(query);
|
return list(query);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,32 +23,25 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<SysPermission> listByUserId(Long userId) {
|
public List<SysPermission> listByUserId(Long userId, Long tenantId) {
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current login user from Security Context
|
SysUser user = sysUserService.getByIdIgnoreTenant(userId);
|
||||||
org.springframework.security.core.Authentication auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
if (user != null && Boolean.TRUE.equals(user.getIsPlatformAdmin()) && Long.valueOf(0).equals(tenantId)) {
|
||||||
if (auth != null && auth.getPrincipal() instanceof com.imeeting.security.LoginUser) {
|
|
||||||
com.imeeting.security.LoginUser loginUser = (com.imeeting.security.LoginUser) auth.getPrincipal();
|
|
||||||
// If current user is Platform Admin (tenantId=0 & isPlatformAdmin=true), return all permissions
|
|
||||||
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0).equals(loginUser.getTenantId())) {
|
|
||||||
return list();
|
return list();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback or specific user logic (for tenant users or internal calls)
|
// 如果没有指定租户,或者租户为0但用户不是平台管理员,则返回空或按默认逻辑(通常需要指定租户)
|
||||||
if (userId == 1L) {
|
if (tenantId == null) return List.of();
|
||||||
return list();
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseMapper.selectByUserId(userId);
|
return baseMapper.selectByUserId(userId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Set<String> listPermissionCodesByUserId(Long userId) {
|
public Set<String> listPermissionCodesByUserId(Long userId, Long tenantId) {
|
||||||
List<SysPermission> perms = listByUserId(userId);
|
List<SysPermission> perms = listByUserId(userId, tenantId);
|
||||||
return perms.stream()
|
return perms.stream()
|
||||||
.map(SysPermission::getCode)
|
.map(SysPermission::getCode)
|
||||||
.filter(code -> code != null && !code.isEmpty())
|
.filter(code -> code != null && !code.isEmpty())
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||||
return baseMapper.selectUsersByRoleId(roleId);
|
return baseMapper.selectUsersByRoleId(roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SysUser getByIdIgnoreTenant(Long userId) {
|
||||||
|
return baseMapper.selectByIdIgnoreTenant(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SysUser> listUsersByTenant(Long tenantId, Long orgId) {
|
||||||
|
return baseMapper.selectUsersByTenant(tenantId, orgId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean save(SysUser entity) {
|
public boolean save(SysUser entity) {
|
||||||
validateUniqueUsername(entity);
|
validateUniqueUsername(entity);
|
||||||
|
|
@ -40,7 +50,7 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count(query) > 0) {
|
if (count(query) > 0) {
|
||||||
throw new IllegalArgumentException("该租户下已存在名为 [" + user.getUsername() + "] 的用户");
|
throw new IllegalArgumentException("用户名 [" + user.getUsername() + "] 已被占用");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,18 @@ export interface CaptchaResponse {
|
||||||
imageBase64: string;
|
imageBase64: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TenantInfo {
|
||||||
|
tenantId: number;
|
||||||
|
tenantCode: string;
|
||||||
|
tenantName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
accessExpiresInMinutes: number;
|
accessExpiresInMinutes: number;
|
||||||
refreshExpiresInDays: number;
|
refreshExpiresInDays: number;
|
||||||
|
availableTenants?: TenantInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
|
|
@ -48,3 +55,8 @@ export async function refreshToken(refreshToken: string) {
|
||||||
const resp = await http.post("/auth/refresh", { refreshToken });
|
const resp = await http.post("/auth/refresh", { refreshToken });
|
||||||
return resp.data.data as TokenResponse;
|
return resp.data.data as TokenResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function switchTenant(tenantId: number) {
|
||||||
|
const resp = await http.post(`/auth/switch-tenant?tenantId=${tenantId}`);
|
||||||
|
return resp.data.data as TokenResponse;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ http.interceptors.response.use(
|
||||||
(resp) => {
|
(resp) => {
|
||||||
const body = resp.data;
|
const body = resp.data;
|
||||||
if (body && body.code !== "0") {
|
if (body && body.code !== "0") {
|
||||||
return Promise.reject(new Error(body.msg || "请求失败"));
|
const err = new Error(body.msg || "请求失败");
|
||||||
|
(err as any).code = body.code;
|
||||||
|
(err as any).msg = body.msg;
|
||||||
|
return Promise.reject(err);
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
},
|
},
|
||||||
|
|
@ -31,6 +34,16 @@ http.interceptors.response.use(
|
||||||
// Force redirect to login with timeout flag
|
// Force redirect to login with timeout flag
|
||||||
window.location.href = "/login?timeout=1";
|
window.location.href = "/login?timeout=1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process backend error message if available in response body even for non-200 status
|
||||||
|
const body = error.response?.data;
|
||||||
|
if (body && body.msg) {
|
||||||
|
const err = new Error(body.msg);
|
||||||
|
(err as any).code = body.code;
|
||||||
|
(err as any).msg = body.msg;
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ export async function deleteUser(id: number) {
|
||||||
return resp.data.data as boolean;
|
return resp.data.data as boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserDetail(id: number) {
|
||||||
|
const resp = await http.get(`/api/users/${id}`);
|
||||||
|
return resp.data.data as SysUser;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listRoles() {
|
export async function listRoles() {
|
||||||
const resp = await http.get("/api/roles");
|
const resp = await http.get("/api/roles");
|
||||||
return resp.data.data as SysRole[];
|
return resp.data.data as SysRole[];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps } from "antd";
|
import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps, Select } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,11 +14,13 @@ import {
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
GlobalOutlined
|
GlobalOutlined,
|
||||||
|
ShopOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { usePermission } from "../hooks/usePermission";
|
import { usePermission } from "../hooks/usePermission";
|
||||||
import { listMyPermissions } from "../api";
|
import { listMyPermissions, getCurrentUser } from "../api";
|
||||||
|
import { switchTenant, type TenantInfo } from "../api/auth";
|
||||||
import { SysPermission } from "../types";
|
import { SysPermission } from "../types";
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
@ -36,13 +38,34 @@ export default function AppLayout() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [menus, setMenus] = useState<SysPermission[]>([]);
|
const [menus, setMenus] = useState<SysPermission[]>([]);
|
||||||
|
const [availableTenants, setAvailableTenants] = useState<TenantInfo[]>([]);
|
||||||
|
const [currentTenantId, setCurrentTenantId] = useState<number | null>(null);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { load: loadPermissions } = usePermission();
|
const { load: loadPermissions } = usePermission();
|
||||||
|
|
||||||
const fetchMenus = async () => {
|
const fetchInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Load tenants from localStorage
|
||||||
|
const storedTenants = localStorage.getItem("availableTenants");
|
||||||
|
if (storedTenants) {
|
||||||
|
const tenants = JSON.parse(storedTenants) as TenantInfo[];
|
||||||
|
setAvailableTenants(tenants);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current profile to know current tenant
|
||||||
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
|
if (profileStr) {
|
||||||
|
const profile = JSON.parse(profileStr);
|
||||||
|
// We need to know which tenant is active. The token has it,
|
||||||
|
// but for UI we can infer from profile if we update profile on switch.
|
||||||
|
// For now, let's assume we store activeTenantId in localStorage on login/switch
|
||||||
|
const activeId = localStorage.getItem("activeTenantId");
|
||||||
|
if (activeId) setCurrentTenantId(Number(activeId));
|
||||||
|
}
|
||||||
|
|
||||||
const data = await listMyPermissions();
|
const data = await listMyPermissions();
|
||||||
// Load permissions into localStorage as well
|
// Load permissions into localStorage as well
|
||||||
await loadPermissions();
|
await loadPermissions();
|
||||||
|
|
@ -58,9 +81,31 @@ export default function AppLayout() {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMenus();
|
fetchInitialData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSwitchTenant = async (tenantId: number) => {
|
||||||
|
try {
|
||||||
|
const data = await switchTenant(tenantId);
|
||||||
|
localStorage.setItem("accessToken", data.accessToken);
|
||||||
|
localStorage.setItem("refreshToken", data.refreshToken);
|
||||||
|
localStorage.setItem("activeTenantId", String(tenantId));
|
||||||
|
if (data.availableTenants) {
|
||||||
|
localStorage.setItem("availableTenants", JSON.stringify(data.availableTenants));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh profile
|
||||||
|
const profile = await getCurrentUser();
|
||||||
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||||
|
|
||||||
|
message.success(t('common.success'));
|
||||||
|
// Reload to refresh all states and permissions
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('common.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
|
|
@ -179,6 +224,22 @@ export default function AppLayout() {
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', paddingLeft: 12 }}>
|
||||||
|
{availableTenants.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={currentTenantId}
|
||||||
|
onChange={handleSwitchTenant}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
placeholder="切换租户"
|
||||||
|
variant="borderless"
|
||||||
|
suffixIcon={<ShopOutlined />}
|
||||||
|
options={availableTenants.map(t => ({
|
||||||
|
label: t.tenantName,
|
||||||
|
value: t.tenantId
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Space size={20}>
|
<Space size={20}>
|
||||||
<Dropdown menu={{ items: langMenuItems }} placement="bottomRight">
|
<Dropdown menu={{ items: langMenuItems }} placement="bottomRight">
|
||||||
<GlobalOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} />
|
<GlobalOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} />
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,15 @@ export default function Login() {
|
||||||
localStorage.setItem("accessToken", data.accessToken);
|
localStorage.setItem("accessToken", data.accessToken);
|
||||||
localStorage.setItem("refreshToken", data.refreshToken);
|
localStorage.setItem("refreshToken", data.refreshToken);
|
||||||
localStorage.setItem("username", values.username);
|
localStorage.setItem("username", values.username);
|
||||||
|
if (data.availableTenants) {
|
||||||
|
localStorage.setItem("availableTenants", JSON.stringify(data.availableTenants));
|
||||||
|
// We should infer activeTenantId from token or just use the first/default logic
|
||||||
|
// For simplicity, we can parse the JWT to get tenantId, or backend can return it.
|
||||||
|
// Let's assume for now we use the first one if not platform admin, or the backend logic.
|
||||||
|
// Actually, if we use a helper to parse JWT:
|
||||||
|
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
|
||||||
|
localStorage.setItem("activeTenantId", String(payload.tenantId));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const profile = await getCurrentUser();
|
const profile = await getCurrentUser();
|
||||||
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||||
|
|
@ -125,18 +134,6 @@ export default function Login() {
|
||||||
requiredMark={false}
|
requiredMark={false}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
>
|
>
|
||||||
<Form.Item
|
|
||||||
name="tenantCode"
|
|
||||||
rules={[{ required: false }]}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
size="large"
|
|
||||||
prefix={<ShopOutlined className="text-gray-400" aria-hidden="true" />}
|
|
||||||
placeholder={t('login.tenantCodePlaceholder')}
|
|
||||||
aria-label={t('login.tenantCode')}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="username"
|
name="username"
|
||||||
rules={[{ required: true, message: t('login.username') }]}
|
rules={[{ required: true, message: t('login.username') }]}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,18 @@ export default function Orgs() {
|
||||||
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
||||||
const [selectedTenantId, setSelectedTenantId] = useState<number | undefined>(undefined);
|
const [selectedTenantId, setSelectedTenantId] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// Platform admin check
|
||||||
|
const isPlatformMode = useMemo(() => {
|
||||||
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
|
if (profileStr) {
|
||||||
|
const profile = JSON.parse(profileStr);
|
||||||
|
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<SysOrg | null>(null);
|
const [editing, setEditing] = useState<SysOrg | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
@ -74,7 +86,10 @@ export default function Orgs() {
|
||||||
const resp = await listTenants({ current: 1, size: 100 });
|
const resp = await listTenants({ current: 1, size: 100 });
|
||||||
const list = resp.records || [];
|
const list = resp.records || [];
|
||||||
setTenants(list);
|
setTenants(list);
|
||||||
if (list.length > 0 && selectedTenantId === undefined) {
|
|
||||||
|
if (!isPlatformMode) {
|
||||||
|
setSelectedTenantId(activeTenantId);
|
||||||
|
} else if (list.length > 0 && selectedTenantId === undefined) {
|
||||||
setSelectedTenantId(list[0].id);
|
setSelectedTenantId(list[0].id);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -219,20 +234,22 @@ export default function Orgs() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-sm mb-4">
|
{isPlatformMode && (
|
||||||
<Space>
|
<Card className="shadow-sm mb-4">
|
||||||
<Text strong>{t('users.tenant')}:</Text>
|
<Space>
|
||||||
<Select
|
<Text strong>{t('users.tenant')}:</Text>
|
||||||
style={{ width: 220 }}
|
<Select
|
||||||
placeholder={t('orgs.selectTenant')}
|
style={{ width: 220 }}
|
||||||
value={selectedTenantId}
|
placeholder={t('orgs.selectTenant')}
|
||||||
onChange={setSelectedTenantId}
|
value={selectedTenantId}
|
||||||
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
onChange={setSelectedTenantId}
|
||||||
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
||||||
/>
|
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
||||||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>{t('common.refresh')}</Button>
|
/>
|
||||||
</Space>
|
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>{t('common.refresh')}</Button>
|
||||||
</Card>
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
|
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
|
||||||
{selectedTenantId !== undefined ? (
|
{selectedTenantId !== undefined ? (
|
||||||
|
|
@ -271,9 +288,19 @@ export default function Orgs() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item label={t('users.tenant')} name="tenantId" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
|
label={t('users.tenant')}
|
||||||
|
name="tenantId"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
hidden={!isPlatformMode}
|
||||||
|
>
|
||||||
<Select disabled options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} />
|
<Select disabled options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{!isPlatformMode && (
|
||||||
|
<Form.Item label={t('users.tenant')}>
|
||||||
|
<Input value={tenants.find(t => t.id === activeTenantId)?.tenantName || "当前租户"} disabled />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item label={t('orgs.parentOrg')} name="parentId">
|
<Form.Item label={t('orgs.parentOrg')} name="parentId">
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export default function Permissions() {
|
||||||
|
|
||||||
const parentOptions = useMemo(() => {
|
const parentOptions = useMemo(() => {
|
||||||
return data
|
return data
|
||||||
.filter((p) => p.permType === "menu" && (p.level === 1 || !p.parentId))
|
.filter((p) => p.permType === "menu")
|
||||||
.map((p) => ({ value: p.permId, label: p.name }));
|
.map((p) => ({ value: p.permId, label: p.name }));
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
|
@ -119,6 +119,20 @@ export default function Permissions() {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAddChild = (record: SysPermission) => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({
|
||||||
|
parentId: record.permId,
|
||||||
|
level: Math.min((record.level || 1) + 1, 3),
|
||||||
|
permType: record.level === 1 ? "menu" : "button",
|
||||||
|
status: 1,
|
||||||
|
isVisible: 1,
|
||||||
|
sortOrder: 0
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
@ -229,10 +243,20 @@ export default function Permissions() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('common.action'),
|
title: t('common.action'),
|
||||||
width: 120,
|
width: 150,
|
||||||
fixed: "right" as const,
|
fixed: "right" as const,
|
||||||
render: (_: any, record: SysPermission) => (
|
render: (_: any, record: SysPermission) => (
|
||||||
<Space>
|
<Space>
|
||||||
|
{can("sys_permission:create") && record.permType === 'menu' && (
|
||||||
|
<Tooltip title="添加子项">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined aria-hidden="true" />}
|
||||||
|
onClick={() => openAddChild(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{can("sys_permission:update") && (
|
{can("sys_permission:update") && (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -363,6 +387,9 @@ export default function Permissions() {
|
||||||
if (changed.level === 1) {
|
if (changed.level === 1) {
|
||||||
form.setFieldsValue({ parentId: undefined });
|
form.setFieldsValue({ parentId: undefined });
|
||||||
}
|
}
|
||||||
|
if (changed.level === 3) {
|
||||||
|
form.setFieldsValue({ permType: "button" });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
|
|
@ -371,6 +398,7 @@ export default function Permissions() {
|
||||||
<Select aria-label={t('permissions.level')}>
|
<Select aria-label={t('permissions.level')}>
|
||||||
<Select.Option value={1}>一级入口</Select.Option>
|
<Select.Option value={1}>一级入口</Select.Option>
|
||||||
<Select.Option value={2}>二级子项</Select.Option>
|
<Select.Option value={2}>二级子项</Select.Option>
|
||||||
|
<Select.Option value={3}>三级按钮</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -387,8 +415,8 @@ export default function Permissions() {
|
||||||
dependencies={["level"]}
|
dependencies={["level"]}
|
||||||
rules={[
|
rules={[
|
||||||
({ getFieldValue }) => ({
|
({ getFieldValue }) => ({
|
||||||
required: getFieldValue("level") === 2,
|
required: getFieldValue("level") > 1,
|
||||||
message: "二级子项必须选择父级菜单"
|
message: "非一级入口必须选择父级"
|
||||||
})
|
})
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|
@ -397,7 +425,7 @@ export default function Permissions() {
|
||||||
showSearch
|
showSearch
|
||||||
placeholder={level === 1 ? "一级入口无须父级" : "请选择父级菜单…"}
|
placeholder={level === 1 ? "一级入口无须父级" : "请选择父级菜单…"}
|
||||||
options={parentOptions}
|
options={parentOptions}
|
||||||
disabled={level !== 2}
|
disabled={level === 1}
|
||||||
aria-label={t('permissions.parentId')}
|
aria-label={t('permissions.parentId')}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,18 @@ export default function Roles() {
|
||||||
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
const [permissions, setPermissions] = useState<SysPermission[]>([]);
|
||||||
const [selectedRole, setSelectedRole] = useState<SysRole | null>(null);
|
const [selectedRole, setSelectedRole] = useState<SysRole | null>(null);
|
||||||
|
|
||||||
|
// Platform admin check
|
||||||
|
const isPlatformMode = useMemo(() => {
|
||||||
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
|
if (profileStr) {
|
||||||
|
const profile = JSON.parse(profileStr);
|
||||||
|
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
||||||
|
|
||||||
// Right side states
|
// Right side states
|
||||||
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
||||||
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
||||||
|
|
@ -120,14 +132,28 @@ export default function Roles() {
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
// Drawer (Only for Add/Edit basic info)
|
// Drawer (Only for Add/Edit basic info)
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<SysRole | null>(null);
|
const [editing, setEditing] = useState<SysRole | null>(null);
|
||||||
|
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
|
|
||||||
|
const loadTenants = async () => {
|
||||||
|
if (!isPlatformMode) return;
|
||||||
|
try {
|
||||||
|
const resp = await (await import("../api")).listTenants({ current: 1, size: 100 });
|
||||||
|
setTenants(resp.records || []);
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTenants();
|
||||||
|
}, [isPlatformMode]);
|
||||||
|
|
||||||
const permissionTreeData = useMemo(
|
const permissionTreeData = useMemo(
|
||||||
() => toTreeData(buildPermissionTree(permissions), t),
|
() => toTreeData(buildPermissionTree(permissions), t),
|
||||||
[permissions, t]
|
[permissions, t]
|
||||||
|
|
@ -193,7 +219,14 @@ export default function Roles() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const list = await listRoles();
|
const list = await listRoles();
|
||||||
const roles = list || [];
|
let roles = list || [];
|
||||||
|
|
||||||
|
if (isPlatformMode && filterTenantId !== undefined) {
|
||||||
|
roles = roles.filter(r => r.tenantId === filterTenantId);
|
||||||
|
} else if (!isPlatformMode) {
|
||||||
|
roles = roles.filter(r => r.tenantId === activeTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
setData(roles);
|
setData(roles);
|
||||||
if (roles.length > 0 && !selectedRole) {
|
if (roles.length > 0 && !selectedRole) {
|
||||||
selectRole(roles[0]);
|
selectRole(roles[0]);
|
||||||
|
|
@ -260,7 +293,10 @@ export default function Roles() {
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ status: 1 });
|
form.setFieldsValue({
|
||||||
|
status: 1,
|
||||||
|
tenantId: isPlatformMode ? undefined : activeTenantId
|
||||||
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -291,7 +327,8 @@ export default function Roles() {
|
||||||
roleCode: editing?.roleCode || values.roleCode || generateRoleCode(),
|
roleCode: editing?.roleCode || values.roleCode || generateRoleCode(),
|
||||||
roleName: values.roleName,
|
roleName: values.roleName,
|
||||||
remark: values.remark,
|
remark: values.remark,
|
||||||
status: values.status ?? DEFAULT_STATUS
|
status: values.status ?? DEFAULT_STATUS,
|
||||||
|
tenantId: values.tenantId
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
|
|
@ -344,7 +381,17 @@ export default function Roles() {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex gap-2">
|
||||||
|
{isPlatformMode && (
|
||||||
|
<Select
|
||||||
|
placeholder={t('users.tenantFilter')}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
allowClear
|
||||||
|
value={filterTenantId}
|
||||||
|
onChange={setFilterTenantId}
|
||||||
|
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('roles.searchPlaceholder')}
|
placeholder={t('roles.searchPlaceholder')}
|
||||||
prefix={<SearchOutlined aria-hidden="true" />}
|
prefix={<SearchOutlined aria-hidden="true" />}
|
||||||
|
|
@ -578,6 +625,22 @@ export default function Roles() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
label={t('users.tenant')}
|
||||||
|
name="tenantId"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
hidden={!isPlatformMode}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
||||||
|
disabled={!!editing}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{!isPlatformMode && (
|
||||||
|
<Form.Item label={t('users.tenant')}>
|
||||||
|
<Input value={tenants.find(t => t.id === activeTenantId)?.tenantName || "当前租户"} disabled />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
<Form.Item label={t('roles.roleName')} name="roleName" rules={[{ required: true }]}>
|
<Form.Item label={t('roles.roleName')} name="roleName" rules={[{ required: true }]}>
|
||||||
<Input placeholder={t('roles.roleName')} />
|
<Input placeholder={t('roles.roleName')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
ShopOutlined,
|
ShopOutlined,
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
ReloadOutlined
|
ReloadOutlined,
|
||||||
|
MinusCircleOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import type { SysRole, SysUser, SysTenant, SysOrg } from "../types";
|
import type { SysRole, SysUser, SysTenant, SysOrg } from "../types";
|
||||||
import "./Users.css";
|
import "./Users.css";
|
||||||
|
|
@ -65,6 +66,39 @@ function buildOrgTree(list: SysOrg[]): any[] {
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MembershipOrgSelect = ({ fieldProps, name, tenantId }: { fieldProps: any, name: number, tenantId?: number }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [orgs, setOrgs] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tenantId) {
|
||||||
|
setLoading(true);
|
||||||
|
listOrgs(tenantId).then(data => {
|
||||||
|
setOrgs(buildOrgTree(data || []));
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setOrgs([]);
|
||||||
|
}
|
||||||
|
}, [tenantId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
{...fieldProps}
|
||||||
|
label={t('users.orgNode')}
|
||||||
|
name={[name, 'orgId']}
|
||||||
|
>
|
||||||
|
<TreeSelect
|
||||||
|
placeholder="选择部门"
|
||||||
|
allowClear
|
||||||
|
treeData={orgs}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!tenantId}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Users() {
|
export default function Users() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
|
|
@ -75,6 +109,18 @@ export default function Users() {
|
||||||
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
||||||
const [orgs, setOrgs] = useState<SysOrg[]>([]);
|
const [orgs, setOrgs] = useState<SysOrg[]>([]);
|
||||||
|
|
||||||
|
// Platform admin check
|
||||||
|
const isPlatformMode = useMemo(() => {
|
||||||
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
|
if (profileStr) {
|
||||||
|
const profile = JSON.parse(profileStr);
|
||||||
|
return profile.isPlatformAdmin && localStorage.getItem("activeTenantId") === "0";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
|
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
|
||||||
|
|
@ -151,23 +197,31 @@ export default function Users() {
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({ status: 1, roleIds: [], isPlatformAdmin: false });
|
form.setFieldsValue({
|
||||||
|
status: 1,
|
||||||
|
roleIds: [],
|
||||||
|
isPlatformAdmin: false,
|
||||||
|
tenantId: isPlatformMode ? undefined : activeTenantId,
|
||||||
|
memberships: isPlatformMode ? [] : [{ tenantId: activeTenantId }]
|
||||||
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = async (record: SysUser) => {
|
const openEdit = async (record: SysUser) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
try {
|
try {
|
||||||
|
// Fetch full detail including memberships
|
||||||
|
const detail = await (await import("../api")).getUserDetail(record.userId);
|
||||||
const roleIds = await listUserRoles(record.userId);
|
const roleIds = await listUserRoles(record.userId);
|
||||||
// Ensure orgs for the tenant are loaded
|
|
||||||
if (record.tenantId) {
|
|
||||||
const orgList = await listOrgs(record.tenantId);
|
|
||||||
setOrgs(orgList || []);
|
|
||||||
}
|
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...record,
|
...detail,
|
||||||
roleIds: roleIds || [],
|
roleIds: roleIds || [],
|
||||||
password: ""
|
password: "",
|
||||||
|
// For compatibility with single tenant view if needed
|
||||||
|
tenantId: detail.tenantId || (detail.memberships?.[0]?.tenantId),
|
||||||
|
orgId: detail.orgId || (detail.memberships?.[0]?.orgId),
|
||||||
|
memberships: detail.memberships || []
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -196,11 +250,15 @@ export default function Users() {
|
||||||
email: values.email,
|
email: values.email,
|
||||||
phone: values.phone,
|
phone: values.phone,
|
||||||
status: values.status,
|
status: values.status,
|
||||||
tenantId: values.tenantId,
|
isPlatformAdmin: values.isPlatformAdmin,
|
||||||
orgId: values.orgId,
|
memberships: values.memberships || []
|
||||||
isPlatformAdmin: values.isPlatformAdmin
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If single tenant view, ensure current active tenant is included
|
||||||
|
if (!isPlatformMode && userPayload.memberships!.length === 0) {
|
||||||
|
userPayload.memberships = [{ tenantId: activeTenantId, orgId: values.orgId } as any];
|
||||||
|
}
|
||||||
|
|
||||||
if (values.password) {
|
if (values.password) {
|
||||||
userPayload.passwordHash = values.password;
|
userPayload.passwordHash = values.password;
|
||||||
}
|
}
|
||||||
|
|
@ -251,20 +309,40 @@ export default function Users() {
|
||||||
{
|
{
|
||||||
title: t('users.org'),
|
title: t('users.org'),
|
||||||
key: "org",
|
key: "org",
|
||||||
render: (_: any, record: SysUser) => (
|
render: (_: any, record: SysUser) => {
|
||||||
<div className="flex flex-col gap-1">
|
// Platform mode: show all tenants as tags
|
||||||
<Space size={4} style={{ fontSize: 13 }}>
|
if (isPlatformMode && record.memberships && record.memberships.length > 0) {
|
||||||
<ShopOutlined style={{ color: '#8c8c8c' }} />
|
return (
|
||||||
<span>{tenantMap[record.tenantId] || "未知租户"}</span>
|
<div className="flex flex-col gap-1">
|
||||||
</Space>
|
{record.memberships.slice(0, 3).map(m => (
|
||||||
{record.orgId && (
|
<Tag key={m.tenantId} color="blue" style={{ margin: 0, padding: '0 4px', fontSize: 11 }}>
|
||||||
<Space size={4} style={{ fontSize: 12, color: '#8c8c8c' }}>
|
{tenantMap[m.tenantId] || `租户${m.tenantId}`}
|
||||||
<ApartmentOutlined />
|
</Tag>
|
||||||
<span>{orgs.find(o => o.id === record.orgId)?.orgName || "组织节点"}</span>
|
))}
|
||||||
|
{record.memberships.length > 3 && <Text type="secondary" style={{ fontSize: 11 }}>等 {record.memberships.length} 个</Text>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single tenant mode or fallback: show specific tenant and org
|
||||||
|
const tid = record.tenantId || record.memberships?.[0]?.tenantId;
|
||||||
|
const oid = record.orgId || record.memberships?.[0]?.orgId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Space size={4} style={{ fontSize: 13 }}>
|
||||||
|
<ShopOutlined style={{ color: '#8c8c8c' }} />
|
||||||
|
<span>{tenantMap[tid || 0] || "未知租户"}</span>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
{oid && (
|
||||||
</div>
|
<Space size={4} style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||||
)
|
<ApartmentOutlined />
|
||||||
|
<span>关联组织已设置</span>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('common.status'),
|
title: t('common.status'),
|
||||||
|
|
@ -318,15 +396,17 @@ export default function Users() {
|
||||||
<Card className="users-table-card shadow-sm">
|
<Card className="users-table-card shadow-sm">
|
||||||
<div className="users-table-toolbar mb-4">
|
<div className="users-table-toolbar mb-4">
|
||||||
<Space size="middle" wrap>
|
<Space size="middle" wrap>
|
||||||
<Select
|
{isPlatformMode && (
|
||||||
placeholder={t('users.tenantFilter')}
|
<Select
|
||||||
style={{ width: 200 }}
|
placeholder={t('users.tenantFilter')}
|
||||||
allowClear
|
style={{ width: 200 }}
|
||||||
value={filterTenantId}
|
allowClear
|
||||||
onChange={setFilterTenantId}
|
value={filterTenantId}
|
||||||
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
onChange={setFilterTenantId}
|
||||||
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
||||||
/>
|
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('users.searchPlaceholder')}
|
placeholder={t('users.searchPlaceholder')}
|
||||||
prefix={<SearchOutlined aria-hidden="true" />}
|
prefix={<SearchOutlined aria-hidden="true" />}
|
||||||
|
|
@ -375,29 +455,7 @@ export default function Users() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" className="user-form">
|
<Form form={form} layout="vertical" className="user-form">
|
||||||
<Row gutter={16}>
|
<Title level={5} style={{ marginBottom: 16 }}>基本信息</Title>
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item label={t('users.tenant')} name="tenantId" rules={[{ required: true, message: t('users.tenant') }]}>
|
|
||||||
<Select
|
|
||||||
placeholder={t('users.tenant')}
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item label={t('users.orgNode')} name="orgId">
|
|
||||||
<TreeSelect
|
|
||||||
placeholder={t('users.orgNode')}
|
|
||||||
allowClear
|
|
||||||
treeData={orgTreeData}
|
|
||||||
disabled={!selectedTenantId}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item label={t('users.username')} name="username" rules={[{ required: true, message: t('users.username') }]}>
|
<Form.Item label={t('users.username')} name="username" rules={[{ required: true, message: t('users.username') }]}>
|
||||||
|
|
@ -452,6 +510,56 @@ export default function Users() {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Title level={5} style={{ marginTop: 24, marginBottom: 16 }}>租户成员身份</Title>
|
||||||
|
|
||||||
|
<Form.List name="memberships">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map(({ key, name, ...restField }) => (
|
||||||
|
<Card
|
||||||
|
key={key}
|
||||||
|
size="small"
|
||||||
|
className="mb-3"
|
||||||
|
styles={{ body: { padding: '12px' } }}
|
||||||
|
title={isPlatformMode ? `身份 #${name + 1}` : undefined}
|
||||||
|
extra={isPlatformMode && fields.length > 1 && (
|
||||||
|
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => remove(name)} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
{...restField}
|
||||||
|
label={t('users.tenant')}
|
||||||
|
name={[name, 'tenantId']}
|
||||||
|
rules={[{ required: true, message: '必填' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
||||||
|
disabled={!isPlatformMode}
|
||||||
|
placeholder="选择租户"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<MembershipOrgSelect
|
||||||
|
fieldProps={{...restField}}
|
||||||
|
name={name}
|
||||||
|
tenantId={form.getFieldValue(['memberships', name, 'tenantId'])}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{isPlatformMode && (
|
||||||
|
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||||
|
分配到新租户
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue