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