fix: tighten entry module rbac flows

This commit is contained in:
wren
2026-04-29 22:25:06 +08:00
parent b544b1a795
commit 55e2c6993f
5 changed files with 152 additions and 32 deletions
+1
View File
@@ -23,6 +23,7 @@ export interface EntryModule {
name: string;
description?: string | null;
path?: string | null; // logo图片路径
route_path?: string | null; // 前端跳转路径
areas?: AreaConfig[] | null; // 地区配置列表
created_at?: string;
updated_at?: string;
+5 -10
View File
@@ -23,6 +23,7 @@
*/
import { useRouteLoaderData, useLocation } from "@remix-run/react";
import { normalizeRoutePathForPermission } from "~/utils/route-alias";
interface RootLoaderData {
permissions?: string[];
@@ -74,7 +75,7 @@ export function usePermission() {
const userArea = rootData?.userArea || '';
// 🔑 根据当前路由获取权限列表
const currentPath = location.pathname;
const currentPath = normalizeRoutePathForPermission(location.pathname);
// console.log('currentPath', currentPath)
// 获取当前路由的权限:优先使用 permissionMap,否则使用交叉评查默认配置
@@ -118,13 +119,7 @@ export function usePermission() {
return legacyPermissions.includes(permissionKey);
}
// 降级方案:如果没有权限数据,使用userRole判断(兼容现有系统)
// 包含'provin'的角色拥有所有权限
if (userRole.toLowerCase().includes('provin')) {
return true;
}
// 默认只有查看权限
// 降级方案:没有权限映射时绝不默认放开写权限,只保留只读能力。
if (permissionKey.includes(':read')) {
return true;
}
@@ -174,8 +169,8 @@ export function usePermission() {
return legacyPermissions.some(p => p.startsWith(`${module}:`));
}
// 降级方案
return userRole.toLowerCase().includes('provin');
// 降级方案只保留只读模块识别,避免 0/6 权限时被角色名放大。
return hasPermission(`${module}:list:read`) || hasPermission(`${module}:detail:read`);
};
/**
+34 -13
View File
@@ -46,6 +46,7 @@ interface LoaderData {
pageSize: number;
currentPage: number;
error?: string;
accessDenied?: boolean;
}
function resolveModuleLogoUrl(path?: string | null): string | null {
@@ -92,16 +93,19 @@ export async function clientLoader({ request }: ClientLoaderFunctionArgs) {
modules: modulesResult,
total: totalCount,
pageSize,
currentPage: page
currentPage: page,
accessDenied: false,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "加载入口模块列表失败";
console.error("加载入口模块列表失败:", error);
return {
modules: [],
total: 0,
pageSize: 10,
currentPage: 1,
error: error instanceof Error ? error.message : "加载入口模块列表失败"
error: errorMessage,
accessDenied: errorMessage.includes('无权限') || errorMessage.includes('权限') || errorMessage.includes('403'),
};
}
}
@@ -127,7 +131,7 @@ export default function EntryModulesList() {
// 获取加载器数据
const loaderData = useLoaderData<LoaderData>();
const { modules, total, error } = loaderData;
const { modules, total, error, accessDenied } = loaderData;
// ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canView } = usePermission();
@@ -145,9 +149,13 @@ export default function EntryModulesList() {
// 处理loader加载数据的时候的错误
useEffect(() => {
if (error) {
if (accessDenied) {
toastService.warning('您当前只有入口模块页面可见权限,没有列表读取权限');
return;
}
toastService.error(error);
}
}, [error]);
}, [error, accessDenied]);
// 处理名称搜索
const handleNameSearch = (value: string) => {
@@ -413,17 +421,30 @@ export default function EntryModulesList() {
/>
</FilterPanel>
{/* 表格 */}
<Table
columns={columns}
dataSource={modules || []}
rowKey="id"
loading={false}
emptyText="暂无入口模块数据"
/>
{accessDenied ? (
<div className="empty-state" style={{ padding: '64px 24px', textAlign: 'center' }}>
<div style={{ fontSize: 40, color: '#faad14', marginBottom: 12 }}>
<i className="ri-lock-line"></i>
</div>
<div style={{ fontSize: 18, fontWeight: 600, color: '#1f2937', marginBottom: 8 }}>
</div>
<div style={{ fontSize: 14, color: '#6b7280' }}>
`entry_module:list:read` 访
</div>
</div>
) : (
<Table
columns={columns}
dataSource={modules || []}
rowKey="id"
loading={false}
emptyText="暂无入口模块数据"
/>
)}
{/* 分页 */}
{total > 0 && (
{!accessDenied && total > 0 && (
<Pagination
// pageSizeOptions={[10,20]}
currentPage={currentPage}
+2 -2
View File
@@ -218,8 +218,8 @@ export default function EntryModuleNew() {
const moduleData = {
name: name.trim(),
description: description.trim() || undefined,
// 创建时 path 为 null,编辑时保持原 path(图片上传接口会自动更新)
path: isEditMode ? module?.path : null,
// path 字段在后端表示 route_path,这里编辑时必须保留原 route_path,不能误传 logo path。
path: isEditMode ? module?.route_path : null,
areas: selectedAreas // 字符串数组,API会自动转换
};
+110 -7
View File
@@ -1372,6 +1372,64 @@ export default function RolePermissions() {
return permissions.filter(p => !isAllPermission(p));
};
const getPermissionShortLabel = (permission: ApiPermission): string => {
const [, resource = '', action = ''] = permission.permission_key.split(':');
const labelMap: Record<string, string> = {
'list:read': '列表读取',
'detail:read': '详情查看',
'create:write': '创建',
'update:write': '更新',
'delete:delete': '删除',
'image:write': '图标上传',
'roles:read': '角色列表',
'roles:update': '角色编辑',
'permissions:read': '权限点读取',
'role_permissions:write': '角色权限保存',
'role_routes:write': '角色菜单保存',
'users:read': '用户列表',
'user_roles:write': '用户角色分配',
};
return labelMap[`${resource}:${action}`] || permission.display_name;
};
const getPermissionHint = (route: RouteInfo, permission: ApiPermission): string => {
const key = permission.permission_key;
if (route.route_path === '/entry-modules') {
const hintMap: Record<string, string> = {
'entry_module:list:read': '决定能否读取入口模块列表并展示表格数据。',
'entry_module:detail:read': '决定能否进入编辑页查看模块详情。',
'entry_module:create:write': '决定能否新建入口模块。',
'entry_module:update:write': '决定能否保存入口模块修改。',
'entry_module:delete:delete': '决定能否删除入口模块。',
'entry_module:image:write': '决定能否上传或替换入口模块图标。',
};
return hintMap[key] || '';
}
if (route.route_path === '/role-permissions') {
const hintMap: Record<string, string> = {
'rbac:roles:read': '读取角色列表与角色详情。',
'rbac:roles:update': '创建、编辑、删除角色基础信息。',
'rbac:permissions:read': '读取页面右侧的接口权限定义。',
'rbac:role_permissions:write': '保存 API 权限勾选结果。',
'rbac:role_routes:write': '保存菜单/路由勾选结果。',
'rbac:users:read': '读取角色下的用户列表。',
'rbac:user_roles:write': '给用户分配或移除角色。',
};
return hintMap[key] || '';
}
return '';
};
const getRoutePermissionGuide = (route: RouteInfo, permissions: ApiPermission[]): string | null => {
if (route.route_path === '/entry-modules' && permissions.length > 0) {
return '这 6 项是完整闭环:先勾“列表读取/详情查看”,再按需勾“创建、更新、删除、图标上传”4 个写权限。';
}
if (route.route_path === '/role-permissions' && permissions.length > 0) {
return '角色权限页建议至少保留“角色列表、权限点读取、角色菜单保存、角色权限保存”,否则页面容易只看得到却保存不了。';
}
return null;
};
// v3.7: 切换单个API权限(支持通用权限同步)
const handleTogglePermission = (permission: ApiPermission, checked: boolean) => {
const permissionId = permission.id;
@@ -1638,10 +1696,29 @@ export default function RolePermissions() {
const renderPermissionsList = () => {
if (!hasPermissions || !isExpanded) return null;
const routeGuide = getRoutePermissionGuide(route, permissions);
return (
<div className="permissions-list">
{routeGuide && (
<div
style={{
marginBottom: '12px',
padding: '10px 12px',
borderRadius: '10px',
background: '#f6ffed',
border: '1px solid #b7eb8f',
color: '#237804',
fontSize: '12px',
lineHeight: 1.6,
}}
>
<strong></strong>{routeGuide}
</div>
)}
{permissions.map(permission => {
const isShared = isSharedPermission(permission);
const permissionHint = getPermissionHint(route, permission);
// 获取通用权限关联的路由名称(排除当前路由)
const relatedRouteNames = (() => {
@@ -1681,8 +1758,16 @@ export default function RolePermissions() {
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ color: isShared ? '#1890ff' : '#333', fontSize: '13px', fontWeight: 500 }}>
{permission.display_name}
{getPermissionShortLabel(permission)}
</span>
<div style={{ color: '#8c8c8c', fontSize: '12px', marginTop: '2px' }}>
{permission.display_name}
</div>
{permissionHint && (
<div style={{ color: '#595959', fontSize: '12px', marginTop: '4px' }}>
{permissionHint}
</div>
)}
{isShared && relatedRouteNames.length > 0 && (
<div className="related-routes">
<i className="ri-link"></i>
@@ -1693,9 +1778,10 @@ export default function RolePermissions() {
</div>
)}
</div>
<span style={{ color: '#999', fontSize: '11px', flexShrink: 0, fontFamily: 'Consolas, Monaco, monospace' }}>
{permission.api_path}
</span>
<div style={{ color: '#999', fontSize: '11px', flexShrink: 0, textAlign: 'right' }}>
<div style={{ fontFamily: 'Consolas, Monaco, monospace' }}>{permission.api_path}</div>
<div style={{ marginTop: '2px' }}>{permission.permission_key}</div>
</div>
</label>
);
})}
@@ -1872,15 +1958,16 @@ export default function RolePermissions() {
<i className="ri-shield-user-line"></i>
</h2>
{/* <div className="page-actions">
<div className="page-actions">
<Button
type="primary"
icon="ri-add-line"
onClick={() => setShowCreateModal(true)}
disabled={!isProvincialAdmin}
>
</Button>
</div> */}
</div>
</div>
<div className="permissions-container">
@@ -2012,7 +2099,7 @@ export default function RolePermissions() {
onClick={handleSavePermissions}
disabled={!isProvincialAdmin || savingPermissions}
>
{savingPermissions ? '保存中...' : '保存权限'}
{savingPermissions ? '保存中...' : '保存菜单与接口权限'}
</Button>
</div>
</div>
@@ -2025,6 +2112,22 @@ export default function RolePermissions() {
</div>
) : (
<>
<div
style={{
marginBottom: '16px',
padding: '12px 16px',
borderRadius: '12px',
background: '#fafafa',
border: '1px solid #f0f0f0',
color: '#595959',
fontSize: '13px',
lineHeight: 1.7,
}}
>
<strong>{selectedRole.role_name}</strong> <strong>{selectedRouteIds.length}</strong>
<strong> {selectedPermissionIds.length} </strong>
+
</div>
{/* v3.8: 路由树容器 - 可滚动区域 */}