fix: tighten entry module rbac flows
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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会自动转换
|
||||
};
|
||||
|
||||
|
||||
@@ -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: 路由树容器 - 可滚动区域 */}
|
||||
|
||||
Reference in New Issue
Block a user