diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index 343904b..8cabb1a 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -19,7 +19,7 @@ CREATE TABLE sys_tenant ( updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, is_deleted SMALLINT DEFAULT 0 ); -CREATE UNIQUE INDEX uk_tenant_code ON sys_tenant (tenant_code) WHERE is_deleted = FALSE; +CREATE UNIQUE INDEX uk_tenant_code ON sys_tenant (tenant_code) WHERE is_deleted = 0; -- 组织架构表 DROP TABLE IF EXISTS sys_org CASCADE; @@ -65,7 +65,7 @@ CREATE TABLE sys_user ( updated_at TIMESTAMP(6) NOT NULL DEFAULT now(), is_platform_admin BOOLEAN DEFAULT false ); -CREATE UNIQUE INDEX uk_user_username ON sys_user (username) WHERE is_deleted = FALSE; +CREATE INDEX uk_user_username ON sys_user (username) WHERE is_deleted = 0; -- 角色表 DROP TABLE IF EXISTS sys_role CASCADE; @@ -83,7 +83,7 @@ CREATE TABLE sys_role ( ); CREATE INDEX idx_sys_role_tenant ON sys_role (tenant_id); -CREATE UNIQUE INDEX uk_role_code ON sys_role (tenant_id, role_code) WHERE is_deleted = FALSE; +CREATE UNIQUE INDEX uk_role_code ON sys_role (tenant_id, role_code) WHERE is_deleted = 0; -- 用户-角色关联表 (按 tenant_id 强约束,避免跨租户角色污染) DROP TABLE IF EXISTS sys_user_role CASCADE; diff --git a/backend/src/main/java/com/imeeting/controller/RoleController.java b/backend/src/main/java/com/imeeting/controller/RoleController.java index 2b4791e..5a80bbc 100644 --- a/backend/src/main/java/com/imeeting/controller/RoleController.java +++ b/backend/src/main/java/com/imeeting/controller/RoleController.java @@ -54,8 +54,19 @@ public class RoleController { @GetMapping @PreAuthorize("@ss.hasPermi('sys:role:list')") - public ApiResponse> list() { - return ApiResponse.ok(sysRoleService.list()); + public ApiResponse> list(@RequestParam(required = false) Long tenantId) { + QueryWrapper wrapper = new QueryWrapper<>(); + + if (authScopeService.isCurrentPlatformAdmin()) { + if (tenantId != null) { + wrapper.eq("tenant_id", tenantId); + } + } else { + Long currentTenantId = getCurrentTenantId(); + wrapper.eq("tenant_id", currentTenantId); + } + + return ApiResponse.ok(sysRoleService.list(wrapper)); } @GetMapping("/{id}/users") @@ -241,19 +252,21 @@ public class RoleController { if (userId == null) { continue; } - QueryWrapper qw = new QueryWrapper<>(); - qw.eq("role_id", id).eq("user_id", userId).eq("tenant_id", role.getTenantId()); - if (sysUserRoleMapper.selectCount(qw) == 0) { - boolean hasMembership = sysTenantUserService.count( - new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() - .eq(com.imeeting.entity.SysTenantUser::getUserId, userId) - .eq(com.imeeting.entity.SysTenantUser::getTenantId, role.getTenantId()) - ) > 0; - if (!hasMembership) { - return ApiResponse.error("用户不属于角色所在租户:" + role.getTenantId()); - } - toInsertUserIds.add(userId); + + // 修复:处理逻辑删除导致的唯一键冲突 + // 执行物理删除,彻底清除旧记录(包括已逻辑删除的) + sysUserRoleMapper.physicalDelete(id, userId, role.getTenantId()); + + // 确保该用户属于该租户 + boolean hasMembership = sysTenantUserService.count( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(com.imeeting.entity.SysTenantUser::getUserId, userId) + .eq(com.imeeting.entity.SysTenantUser::getTenantId, role.getTenantId()) + ) > 0; + if (!hasMembership) { + return ApiResponse.error("用户不属于角色所在租户:" + role.getTenantId()); } + toInsertUserIds.add(userId); } for (Long userId : toInsertUserIds) { @@ -279,9 +292,7 @@ public class RoleController { if (!canAccessTenant(role.getTenantId())) { return ApiResponse.error("禁止跨租户解绑用户"); } - QueryWrapper qw = new QueryWrapper<>(); - qw.eq("role_id", id).eq("user_id", userId).eq("tenant_id", role.getTenantId()); - sysUserRoleMapper.delete(qw); + sysUserRoleMapper.physicalDelete(id, userId, role.getTenantId()); authVersionService.invalidateUserTenantAuth(userId, role.getTenantId()); return ApiResponse.ok(true); } diff --git a/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java b/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java index e6766d3..2ecc03a 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java @@ -4,12 +4,19 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.imeeting.entity.SysUserRole; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Mapper; import java.util.List; @Mapper public interface SysUserRoleMapper extends BaseMapper { + @Delete(""" + DELETE FROM sys_user_role + WHERE role_id = #{roleId} AND user_id = #{userId} AND tenant_id = #{tenantId} + """) + int physicalDelete(@Param("roleId") Long roleId, @Param("userId") Long userId, @Param("tenantId") Long tenantId); + @Select(""" SELECT COUNT(1) FROM sys_user_role ur diff --git a/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java index 13ca541..3fd3462 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java @@ -54,19 +54,29 @@ public class SysTenantServiceImpl extends ServiceImpl tenantUsers = sysTenantUserService.list( new LambdaQueryWrapper().eq(SysTenantUser::getTenantId, tenantId) ); List userIds = tenantUsers.stream().map(SysTenantUser::getUserId).collect(Collectors.toList()); - // 2. 逻辑删除租户下的角色 + List roles = sysRoleService.list( + new LambdaQueryWrapper().eq(SysRole::getTenantId, tenantId) + ); + List roleIds = roles.stream().map(SysRole::getRoleId).collect(Collectors.toList()); + + // 2. 逻辑删除角色权限关联 + if (roleIds != null && !roleIds.isEmpty()) { + sysRolePermissionMapper.delete(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().in("role_id", roleIds)); + } + + // 3. 逻辑删除租户下的角色 sysRoleService.lambdaUpdate() .set(SysRole::getIsDeleted, 1) .eq(SysRole::getTenantId, tenantId) .update(); - // 3. 逻辑删除租户下的组织 + // 4. 逻辑删除租户下的组织 sysOrgService.lambdaUpdate() .set(SysOrg::getIsDeleted, 1) .eq(SysOrg::getTenantId, tenantId) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 59e9f92..b5b2646 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -50,8 +50,8 @@ export async function getUserDetail(id: number) { return resp.data.data as SysUser; } -export async function listRoles() { - const resp = await http.get("/api/roles"); +export async function listRoles(tenantId?: number) { + const resp = await http.get("/api/roles", { params: { tenantId } }); return resp.data.data as SysRole[]; } diff --git a/frontend/src/pages/Roles.css b/frontend/src/pages/Roles.css index 4dc6a8a..3d8071d 100644 --- a/frontend/src/pages/Roles.css +++ b/frontend/src/pages/Roles.css @@ -1,80 +1,109 @@ .roles-page-v2 { - padding: 24px; + background-color: #f0f2f5; } -.full-height-card { - height: 100%; - display: flex; - flex-direction: column; +.shadow-sm { + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02); } -.full-height-card .ant-card-body { - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; +/* Role List Styling */ +.role-list-container-v3 { + scrollbar-width: thin; + scrollbar-color: #e8e8e8 transparent; } -.role-list-container { - flex: 1; - overflow-y: auto; - border: 1px solid #f0f0f0; - border-radius: 4px; +.role-list-container-v3::-webkit-scrollbar { + width: 6px; } -.role-row { - transition: all 0.3s; +.role-list-container-v3::-webkit-scrollbar-thumb { + background-color: #e8e8e8; + border-radius: 3px; } -.role-row:hover { - background-color: #f5f5f5; -} - -.role-row-selected { - background-color: #e6f7ff !important; - border-right: 3px solid #1890ff; -} - -.role-item-content { +.role-item-card-v3 { + padding: 12px 16px; + margin-bottom: 8px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); display: flex; justify-content: space-between; align-items: center; - padding: 4px 0; -} - -.role-item-name { - font-weight: 600; - color: #262626; -} - -.role-item-code { - font-size: 12px; - color: #8c8c8c; -} - -.role-tabs { - flex: 1; - display: flex; - flex-direction: column; -} - -.role-tabs .ant-tabs-content { - flex: 1; - overflow-y: auto; - padding: 8px 0; -} - -.role-permission-tree-v2 { - border: 1px solid #f0f0f0; - border-radius: 4px; - padding: 16px; + border: 1px solid transparent; background: #fafafa; } -.flex-center { +.role-item-card-v3:hover { + background: #f0f7ff; + border-color: #e6f4ff; +} + +.role-item-card-v3.active { + background: #e6f4ff; + border-color: #1890ff; +} + +.role-item-card-v3.active .role-name { + color: #1890ff; +} + +.role-item-main { + flex: 1; + min-width: 0; +} + +.role-item-name-row { display: flex; align-items: center; - justify-content: center; + gap: 8px; + margin-bottom: 2px; +} + +.role-name { + font-size: 14px; + color: #262626; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.role-code { + font-size: 12px; + color: #8c8c8c; + display: block; +} + +.role-item-actions { + opacity: 0; + transition: opacity 0.2s; + flex-shrink: 0; + margin-left: 8px; +} + +.role-item-card-v3:hover .role-item-actions { + opacity: 1; +} + +/* Tabs Styling */ +.role-detail-tabs .ant-tabs-nav { + margin-bottom: 0 !important; + padding: 0 24px; + background: #fff; +} + +.role-detail-tabs .ant-tabs-content-holder { + background: #fff; + padding-top: 24px; +} + +/* Tree Styling */ +.permission-tree-wrapper { + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 8px; + padding: 20px; } .role-permission-node { @@ -82,6 +111,24 @@ align-items: center; } -.mb-4 { - margin-bottom: 16px; +.ant-tree-treenode { + padding: 4px 0 !important; } + +.ant-tree-node-content-wrapper { + transition: background-color 0.2s; +} + +.ant-tree-node-content-wrapper:hover { + background-color: #e6f4ff !important; +} + +/* Table Styling */ +.ant-table-small { + background: transparent; +.full-height-card { + height: 100%; +} + +.shadow-sm { + diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx index 178db75..d93ee63 100644 --- a/frontend/src/pages/Roles.tsx +++ b/frontend/src/pages/Roles.tsx @@ -16,10 +16,16 @@ import { Tabs, Empty, Select, - Modal + Modal, + Tooltip, + Divider, + Switch, + Badge, + Avatar, + List } from "antd"; import type { DataNode } from "antd/es/tree"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { createRole, @@ -46,7 +52,10 @@ import { KeyOutlined, UserOutlined, SaveOutlined, - UserAddOutlined + UserAddOutlined, + TeamOutlined, + FilterOutlined, + ApartmentOutlined } from "@ant-design/icons"; import PageHeader from "../components/shared/PageHeader"; import "./Roles.css"; @@ -91,8 +100,12 @@ const toTreeData = (nodes: PermissionNode[], t: any): DataNode[] => key: node.permId, title: ( - {node.name} - {node.permType === "button" && {t('permissions.permType') === '按钮' ? '按钮' : 'Button'}} + {node.name} + {node.permType === "button" && ( + + {t('permissions.permType') === '按钮' ? '按钮' : 'BTN'} + + )} ), children: node.children && node.children.length > 0 ? toTreeData(node.children, t) : undefined @@ -108,10 +121,8 @@ export default function Roles() { const [permissions, setPermissions] = useState([]); const [selectedRole, setSelectedRole] = useState(null); - // Dictionaries const { items: statusDict } = useDict("sys_common_status"); - // Platform admin check const isPlatformMode = useMemo(() => { const profileStr = sessionStorage.getItem("userProfile"); if (profileStr) { @@ -123,23 +134,19 @@ export default function Roles() { const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []); - // Right side states const [selectedPermIds, setSelectedPermIds] = useState([]); const [halfCheckedIds, setHalfCheckedIds] = useState([]); const [roleUsers, setRoleUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(false); - // User selection states const [allUsers, setAllUsers] = useState([]); const [userModalOpen, setUserModalOpen] = useState(false); const [selectedUserKeys, setSelectedUserKeys] = useState([]); const [userSearchText, setUserSearchText] = useState(""); - // Search const [searchText, setSearchText] = useState(""); const [filterTenantId, setFilterTenantId] = useState(undefined); - // Drawer (Only for Add/Edit basic info) const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [tenants, setTenants] = useState([]); @@ -186,20 +193,23 @@ export default function Roles() { message.success(t('common.success')); setUserModalOpen(false); selectRole(selectedRole); - } catch (e) { - // Handled by interceptor - } + } catch (e) {} }; const handleUnbindUser = async (userId: number) => { if (!selectedRole) return; + + // 安全校验:租户管理员角色至少保留一个关联用户 + if (selectedRole.roleCode === 'TENANT_ADMIN' && roleUsers.length <= 1) { + message.warning('租户管理员角色必须至少保留一个关联用户,以防止租户孤立'); + return; + } + try { await unbindUserFromRole(selectedRole.roleId, userId); message.success(t('common.success')); selectRole(selectedRole); - } catch (e) { - // Handled by interceptor - } + } catch (e) {} }; const filteredModalUsers = useMemo(() => { @@ -223,15 +233,8 @@ export default function Roles() { const loadRoles = async () => { setLoading(true); try { - const list = await listRoles(); + const list = await listRoles(isPlatformMode ? filterTenantId : activeTenantId); 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); if (roles.length > 0 && !selectedRole) { selectRole(roles[0]); @@ -248,33 +251,27 @@ export default function Roles() { const selectRole = async (role: SysRole) => { setSelectedRole(role); try { - // Load permissions for this role const ids = await listRolePermissions(role.roleId); const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)); - // Filter out parents for Tree回显 const leafIds = normalized.filter(id => { return !permissions.some(p => p.parentId === id); }); setSelectedPermIds(leafIds); setHalfCheckedIds([]); - // Load users for this role setLoadingUsers(true); const users = await fetchUsersByRoleId(role.roleId); setRoleUsers(users || []); - } catch (e) { - // Handled by interceptor - } finally { + } catch (e) {} finally { setLoadingUsers(false); } }; useEffect(() => { loadRoles(); - }, []); + }, [filterTenantId]); - // Reload role detail if permissions list loaded later useEffect(() => { if (selectedRole && permissions.length > 0) { const leafIds = selectedPermIds.filter(id => { @@ -319,9 +316,7 @@ export default function Roles() { message.success(t('common.success')); if (selectedRole?.roleId === id) setSelectedRole(null); loadRoles(); - } catch (e) { - // Handled by interceptor - } + } catch (e) {} }; const submitBasic = async () => { @@ -346,9 +341,7 @@ export default function Roles() { setDrawerOpen(false); loadRoles(); - } catch (e) { - // Handled by interceptor - } finally { + } catch (e) {} finally { setSaving(false); } }; @@ -360,249 +353,240 @@ export default function Roles() { const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds])); await saveRolePermissions(selectedRole.roleId, allPermIds); message.success(t('common.success')); - } catch (e) { - // Handled by interceptor - } finally { + } catch (e) {} finally { setSaving(false); } }; return ( -
+