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; name: string;
description?: string | null; description?: string | null;
path?: string | null; // logo图片路径 path?: string | null; // logo图片路径
route_path?: string | null; // 前端跳转路径
areas?: AreaConfig[] | null; // 地区配置列表 areas?: AreaConfig[] | null; // 地区配置列表
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
+5 -10
View File
@@ -23,6 +23,7 @@
*/ */
import { useRouteLoaderData, useLocation } from "@remix-run/react"; import { useRouteLoaderData, useLocation } from "@remix-run/react";
import { normalizeRoutePathForPermission } from "~/utils/route-alias";
interface RootLoaderData { interface RootLoaderData {
permissions?: string[]; permissions?: string[];
@@ -74,7 +75,7 @@ export function usePermission() {
const userArea = rootData?.userArea || ''; const userArea = rootData?.userArea || '';
// 🔑 根据当前路由获取权限列表 // 🔑 根据当前路由获取权限列表
const currentPath = location.pathname; const currentPath = normalizeRoutePathForPermission(location.pathname);
// console.log('currentPath', currentPath) // console.log('currentPath', currentPath)
// 获取当前路由的权限:优先使用 permissionMap,否则使用交叉评查默认配置 // 获取当前路由的权限:优先使用 permissionMap,否则使用交叉评查默认配置
@@ -118,13 +119,7 @@ export function usePermission() {
return legacyPermissions.includes(permissionKey); return legacyPermissions.includes(permissionKey);
} }
// 降级方案:如果没有权限数据,使用userRole判断(兼容现有系统) // 降级方案:没有权限映射时绝不默认放开写权限,只保留只读能力。
// 包含'provin'的角色拥有所有权限
if (userRole.toLowerCase().includes('provin')) {
return true;
}
// 默认只有查看权限
if (permissionKey.includes(':read')) { if (permissionKey.includes(':read')) {
return true; return true;
} }
@@ -174,8 +169,8 @@ export function usePermission() {
return legacyPermissions.some(p => p.startsWith(`${module}:`)); return legacyPermissions.some(p => p.startsWith(`${module}:`));
} }
// 降级方案 // 降级方案只保留只读模块识别,避免 0/6 权限时被角色名放大。
return userRole.toLowerCase().includes('provin'); return hasPermission(`${module}:list:read`) || hasPermission(`${module}:detail:read`);
}; };
/** /**
+34 -13
View File
@@ -46,6 +46,7 @@ interface LoaderData {
pageSize: number; pageSize: number;
currentPage: number; currentPage: number;
error?: string; error?: string;
accessDenied?: boolean;
} }
function resolveModuleLogoUrl(path?: string | null): string | null { function resolveModuleLogoUrl(path?: string | null): string | null {
@@ -92,16 +93,19 @@ export async function clientLoader({ request }: ClientLoaderFunctionArgs) {
modules: modulesResult, modules: modulesResult,
total: totalCount, total: totalCount,
pageSize, pageSize,
currentPage: page currentPage: page,
accessDenied: false,
}; };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "加载入口模块列表失败";
console.error("加载入口模块列表失败:", error); console.error("加载入口模块列表失败:", error);
return { return {
modules: [], modules: [],
total: 0, total: 0,
pageSize: 10, pageSize: 10,
currentPage: 1, 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 loaderData = useLoaderData<LoaderData>();
const { modules, total, error } = loaderData; const { modules, total, error, accessDenied } = loaderData;
// ✅ 使用权限 Hook // ✅ 使用权限 Hook
const { canCreate, canUpdate, canDelete, canView } = usePermission(); const { canCreate, canUpdate, canDelete, canView } = usePermission();
@@ -145,9 +149,13 @@ export default function EntryModulesList() {
// 处理loader加载数据的时候的错误 // 处理loader加载数据的时候的错误
useEffect(() => { useEffect(() => {
if (error) { if (error) {
if (accessDenied) {
toastService.warning('您当前只有入口模块页面可见权限,没有列表读取权限');
return;
}
toastService.error(error); toastService.error(error);
} }
}, [error]); }, [error, accessDenied]);
// 处理名称搜索 // 处理名称搜索
const handleNameSearch = (value: string) => { const handleNameSearch = (value: string) => {
@@ -413,17 +421,30 @@ export default function EntryModulesList() {
/> />
</FilterPanel> </FilterPanel>
{/* 表格 */} {accessDenied ? (
<Table <div className="empty-state" style={{ padding: '64px 24px', textAlign: 'center' }}>
columns={columns} <div style={{ fontSize: 40, color: '#faad14', marginBottom: 12 }}>
dataSource={modules || []} <i className="ri-lock-line"></i>
rowKey="id" </div>
loading={false} <div style={{ fontSize: 18, fontWeight: 600, color: '#1f2937', marginBottom: 8 }}>
emptyText="暂无入口模块数据"
/> </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 <Pagination
// pageSizeOptions={[10,20]} // pageSizeOptions={[10,20]}
currentPage={currentPage} currentPage={currentPage}
+2 -2
View File
@@ -218,8 +218,8 @@ export default function EntryModuleNew() {
const moduleData = { const moduleData = {
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
// 创建时 path 为 null,编辑时保持原 path(图片上传接口会自动更新) // path 字段在后端表示 route_path,这里编辑时必须保留原 route_path,不能误传 logo path。
path: isEditMode ? module?.path : null, path: isEditMode ? module?.route_path : null,
areas: selectedAreas // 字符串数组,API会自动转换 areas: selectedAreas // 字符串数组,API会自动转换
}; };
+110 -7
View File
@@ -1372,6 +1372,64 @@ export default function RolePermissions() {
return permissions.filter(p => !isAllPermission(p)); 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权限(支持通用权限同步) // v3.7: 切换单个API权限(支持通用权限同步)
const handleTogglePermission = (permission: ApiPermission, checked: boolean) => { const handleTogglePermission = (permission: ApiPermission, checked: boolean) => {
const permissionId = permission.id; const permissionId = permission.id;
@@ -1638,10 +1696,29 @@ export default function RolePermissions() {
const renderPermissionsList = () => { const renderPermissionsList = () => {
if (!hasPermissions || !isExpanded) return null; if (!hasPermissions || !isExpanded) return null;
const routeGuide = getRoutePermissionGuide(route, permissions);
return ( return (
<div className="permissions-list"> <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 => { {permissions.map(permission => {
const isShared = isSharedPermission(permission); const isShared = isSharedPermission(permission);
const permissionHint = getPermissionHint(route, permission);
// 获取通用权限关联的路由名称(排除当前路由) // 获取通用权限关联的路由名称(排除当前路由)
const relatedRouteNames = (() => { const relatedRouteNames = (() => {
@@ -1681,8 +1758,16 @@ export default function RolePermissions() {
</span> </span>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<span style={{ color: isShared ? '#1890ff' : '#333', fontSize: '13px', fontWeight: 500 }}> <span style={{ color: isShared ? '#1890ff' : '#333', fontSize: '13px', fontWeight: 500 }}>
{permission.display_name} {getPermissionShortLabel(permission)}
</span> </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 && ( {isShared && relatedRouteNames.length > 0 && (
<div className="related-routes"> <div className="related-routes">
<i className="ri-link"></i> <i className="ri-link"></i>
@@ -1693,9 +1778,10 @@ export default function RolePermissions() {
</div> </div>
)} )}
</div> </div>
<span style={{ color: '#999', fontSize: '11px', flexShrink: 0, fontFamily: 'Consolas, Monaco, monospace' }}> <div style={{ color: '#999', fontSize: '11px', flexShrink: 0, textAlign: 'right' }}>
{permission.api_path} <div style={{ fontFamily: 'Consolas, Monaco, monospace' }}>{permission.api_path}</div>
</span> <div style={{ marginTop: '2px' }}>{permission.permission_key}</div>
</div>
</label> </label>
); );
})} })}
@@ -1872,15 +1958,16 @@ export default function RolePermissions() {
<i className="ri-shield-user-line"></i> <i className="ri-shield-user-line"></i>
</h2> </h2>
{/* <div className="page-actions"> <div className="page-actions">
<Button <Button
type="primary" type="primary"
icon="ri-add-line" icon="ri-add-line"
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
disabled={!isProvincialAdmin}
> >
</Button> </Button>
</div> */} </div>
</div> </div>
<div className="permissions-container"> <div className="permissions-container">
@@ -2012,7 +2099,7 @@ export default function RolePermissions() {
onClick={handleSavePermissions} onClick={handleSavePermissions}
disabled={!isProvincialAdmin || savingPermissions} disabled={!isProvincialAdmin || savingPermissions}
> >
{savingPermissions ? '保存中...' : '保存权限'} {savingPermissions ? '保存中...' : '保存菜单与接口权限'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -2025,6 +2112,22 @@ export default function RolePermissions() {
</div> </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: 路由树容器 - 可滚动区域 */} {/* v3.8: 路由树容器 - 可滚动区域 */}