feat: 分层 RBAC 权限管理系统

后端:
- 新增 Role / RolePermission 实体(自动 seed 系统角色)
- PermissionService——通过 isAdmin / TenantMember 链路解析用户权限
- @Permission() 装饰器 + PermissionsGuard 守卫
- /api/permissions 和 /api/roles REST API
- UserController 内联 role 检查迁移到 @Permission()
- PermissionModule 全局注册

前端:
- usePermissions hook——获取当前用户权限集
- PermissionGate 组件级门控
- PermissionSettingsView——角色列表+权限矩阵编辑页面
- SettingsView 新增「权限管理」Tab(仅 admin 可见)
- 权限预览(26 项,7 分类)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-06-08 23:25:22 +08:00
parent c57c3028e2
commit ba33d517c1
17 changed files with 1386 additions and 87 deletions
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { usePermissions } from '../src/hooks/usePermissions';
interface PermissionGateProps {
/** 需要的权限(OR 关系:有任一即可) */
permission?: string;
/** 多个权限 OR 关系 */
any?: string[];
/** 多个权限 AND 关系(必须全部拥有) */
all?: string[];
/** 加载中时显示的内容(默认不显示) */
fallback?: React.ReactNode;
/** 无权限时显示的内容(默认不显示) */
denied?: React.ReactNode;
children: React.ReactNode;
}
/**
* 组件级权限门控
* 根据用户权限集有条件的渲染子组件
*
* @example
* ```tsx
* <PermissionGate permission="user:create">
* <Button>创建用户</Button>
* </PermissionGate>
*
* <PermissionGate any={['user:edit', 'user:delete']}>
* <AdminPanel />
* </PermissionGate>
* ```
*/
export const PermissionGate: React.FC<PermissionGateProps> = ({
permission,
any,
all,
fallback = null,
denied = null,
children,
}) => {
const { hasPermission, hasAnyPermission, hasAllPermissions, isLoading } = usePermissions();
if (isLoading) {
return <>{fallback}</>;
}
let granted = false;
if (permission) {
granted = hasPermission(permission);
} else if (any && any.length > 0) {
granted = hasAnyPermission(...any);
} else if (all && all.length > 0) {
granted = hasAllPermissions(...all);
} else {
// 没有指定权限要求 → 放行
granted = true;
}
return <>{granted ? children : denied}</>;
};
@@ -0,0 +1,440 @@
import React, { useState, useEffect } from 'react';
import {
Shield,
Plus,
Save,
X,
Edit2,
Trash2,
Loader2,
Check,
Users,
Key,
} from 'lucide-react';
import { useAuth } from '../../src/contexts/AuthContext';
import { useConfirm } from '../../contexts/ConfirmContext';
import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../contexts/LanguageContext';
import { cn } from '../../src/utils/cn';
interface PermissionMeta {
key: string;
category: string;
label: string;
description: string;
}
interface Role {
id: string;
name: string;
description?: string;
isSystem: boolean;
baseRole?: string;
tenantId?: string;
}
/** 按分类分组的权限 */
interface PermissionsByCategory {
[category: string]: PermissionMeta[];
}
export const PermissionSettingsView: React.FC = () => {
const { apiKey, activeTenant } = useAuth();
const { confirm } = useConfirm();
const { showError, showSuccess } = useToast();
const { t } = useLanguage();
const [roles, setRoles] = useState<Role[]>([]);
const [allPermissions, setAllPermissions] = useState<PermissionsByCategory>({});
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
const [rolePermissions, setRolePermissions] = useState<Set<string>>(new Set());
const [isLoadingRoles, setIsLoadingRoles] = useState(true);
const [isLoadingPerms, setIsLoadingPerms] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showCreateRole, setShowCreateRole] = useState(false);
const [newRoleName, setNewRoleName] = useState('');
const [newRoleDesc, setNewRoleDesc] = useState('');
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(new Set());
const [hasChanges, setHasChanges] = useState(false);
const headers = {
'x-api-key': apiKey,
'x-tenant-id': activeTenant?.tenantId || '',
};
// 加载角色列表
const fetchRoles = async () => {
try {
setIsLoadingRoles(true);
const res = await fetch('/api/roles', { headers });
if (res.ok) {
const data = await res.json();
setRoles(data);
}
} catch (err: any) {
console.error('Failed to fetch roles:', err);
} finally {
setIsLoadingRoles(false);
}
};
// 加载权限元数据
const fetchPermissions = async () => {
try {
const res = await fetch('/api/permissions', { headers });
if (res.ok) {
const data = await res.json();
setAllPermissions(data);
}
} catch (err: any) {
console.error('Failed to fetch permissions:', err);
}
};
// 加载选中角色的权限
const fetchRolePermissions = async (roleId: string) => {
try {
setIsLoadingPerms(true);
const res = await fetch(`/api/roles/${roleId}/permissions`, { headers });
if (res.ok) {
const data = await res.json();
const permSet = new Set<string>(data.permissions || []);
setRolePermissions(permSet);
setEditingPermissions(new Set(permSet));
}
} catch (err: any) {
console.error('Failed to fetch role permissions:', err);
} finally {
setIsLoadingPerms(false);
}
};
useEffect(() => {
fetchRoles();
fetchPermissions();
}, [apiKey, activeTenant]);
useEffect(() => {
if (selectedRoleId) {
fetchRolePermissions(selectedRoleId);
}
}, [selectedRoleId]);
// 切换权限
const togglePermission = (key: string) => {
setEditingPermissions(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
setHasChanges(true);
return next;
});
};
// 全选/取消分类
const toggleCategory = (category: string, perms: PermissionMeta[]) => {
const keys = perms.map(p => p.key);
const allChecked = keys.every(k => editingPermissions.has(k));
setEditingPermissions(prev => {
const next = new Set(prev);
keys.forEach(k => {
if (allChecked) next.delete(k);
else next.add(k);
});
setHasChanges(true);
return next;
});
};
// 保存权限
const savePermissions = async () => {
if (!selectedRoleId) return;
try {
setIsSaving(true);
const res = await fetch(`/api/roles/${selectedRoleId}/permissions`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ permissions: [...editingPermissions] }),
});
if (res.ok) {
setRolePermissions(new Set(editingPermissions));
setHasChanges(false);
showSuccess?.('权限已保存');
} else {
const err = await res.json();
throw new Error(err.message || '保存失败');
}
} catch (err: any) {
showError?.(err.message);
} finally {
setIsSaving(false);
}
};
// 创建角色
const createRole = async () => {
if (!newRoleName.trim()) return;
try {
const res = await fetch('/api/roles', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newRoleName.trim(), description: newRoleDesc.trim() }),
});
if (res.ok) {
await fetchRoles();
setShowCreateRole(false);
setNewRoleName('');
setNewRoleDesc('');
showSuccess?.('角色已创建');
} else {
const err = await res.json();
throw new Error(err.message || '创建失败');
}
} catch (err: any) {
showError?.(err.message);
}
};
// 删除角色
const deleteRole = async (role: Role) => {
const confirmed = await confirm?.(`确定删除角色"${role.name}"`);
if (!confirmed) return;
try {
const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE', headers });
if (res.ok) {
if (selectedRoleId === role.id) setSelectedRoleId(null);
await fetchRoles();
showSuccess?.('角色已删除');
} else {
const err = await res.json();
throw new Error(err.message || '删除失败');
}
} catch (err: any) {
showError?.(err.message);
}
};
const selectedRole = roles.find(r => r.id === selectedRoleId);
return (
<div className="flex h-full gap-6">
{/* 左:角色列表 */}
<div className="w-72 flex-none space-y-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-black text-slate-900 flex items-center gap-2 uppercase tracking-widest">
<Shield size={16} className="text-indigo-600" />
</h3>
<button
onClick={() => setShowCreateRole(true)}
className="p-1.5 rounded-lg bg-indigo-50 text-indigo-600 hover:bg-indigo-100 transition-colors"
title="新建角色"
>
<Plus size={16} />
</button>
</div>
{isLoadingRoles ? (
<div className="flex justify-center py-8">
<Loader2 size={20} className="animate-spin text-slate-400" />
</div>
) : (
<div className="space-y-1">
{roles.map(role => (
<button
key={role.id}
onClick={() => setSelectedRoleId(role.id)}
className={cn(
'w-full text-left px-4 py-3 rounded-xl text-sm font-medium transition-all flex items-center justify-between',
selectedRoleId === role.id
? 'bg-indigo-50 text-indigo-700 border border-indigo-200 shadow-sm'
: 'text-slate-600 hover:bg-slate-50 border border-transparent',
)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{role.name}</span>
{role.isSystem && (
<span className="text-[9px] font-black text-indigo-400 bg-indigo-100/50 px-1.5 py-0.5 rounded uppercase tracking-wider shrink-0">
</span>
)}
</div>
{!role.isSystem && (
<button
onClick={(e) => { e.stopPropagation(); deleteRole(role); }}
className="p-1 text-slate-300 hover:text-rose-500 transition-colors shrink-0"
>
<Trash2 size={14} />
</button>
)}
</button>
))}
</div>
)}
{/* 创建角色弹窗 */}
{showCreateRole && (
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200 space-y-3">
<input
value={newRoleName}
onChange={e => setNewRoleName(e.target.value)}
placeholder="角色名称"
className="w-full px-3 py-2 text-sm bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none"
/>
<input
value={newRoleDesc}
onChange={e => setNewRoleDesc(e.target.value)}
placeholder="角色描述(可选)"
className="w-full px-3 py-2 text-sm bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none"
/>
<div className="flex gap-2">
<button
onClick={createRole}
disabled={!newRoleName.trim()}
className="flex-1 py-2 bg-indigo-600 text-white text-xs font-bold rounded-lg hover:bg-indigo-700 disabled:bg-slate-300 disabled:cursor-not-allowed transition-colors"
>
</button>
<button
onClick={() => setShowCreateRole(false)}
className="px-3 py-2 text-xs font-bold text-slate-500 hover:text-slate-700 bg-white border border-slate-200 rounded-lg transition-colors"
>
</button>
</div>
</div>
)}
</div>
{/* 右:权限矩阵 */}
<div className="flex-1 min-w-0">
{!selectedRole ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
<Shield size={40} className="opacity-30" />
<p className="text-sm font-bold"></p>
</div>
) : isLoadingPerms ? (
<div className="flex justify-center py-12">
<Loader2 size={24} className="animate-spin text-slate-400" />
</div>
) : (
<div className="space-y-4">
{/* 角色标题 */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-black text-slate-900 flex items-center gap-2">
<Key size={18} className="text-indigo-600" />
{selectedRole.name}
{selectedRole.isSystem && (
<span className="text-[10px] font-black text-slate-400 bg-slate-100 px-2 py-0.5 rounded-full uppercase tracking-wider">
</span>
)}
</h3>
{selectedRole.description && (
<p className="text-xs text-slate-500 mt-1">{selectedRole.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setEditingPermissions(new Set(rolePermissions));
setHasChanges(false);
}}
disabled={!hasChanges}
className="px-3 py-2 text-xs font-bold text-slate-500 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<button
onClick={savePermissions}
disabled={!hasChanges || isSaving || selectedRole.isSystem}
className={cn(
'px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all',
hasChanges && !selectedRole.isSystem
? 'bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm'
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
)}
>
{isSaving ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
</button>
</div>
</div>
{selectedRole.isSystem && (
<div className="px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-700 font-medium">
</div>
)}
{/* 权限矩阵 */}
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
{Object.entries(allPermissions).map(([category, perms]) => {
const allChecked = perms.every(p => editingPermissions.has(p.key));
const someChecked = perms.some(p => editingPermissions.has(p.key));
return (
<div key={category} className="bg-white border border-slate-100 rounded-2xl overflow-hidden">
{/* 分类标题 */}
<button
onClick={() => toggleCategory(category, perms)}
className="w-full flex items-center justify-between px-5 py-3 bg-slate-50/80 hover:bg-slate-100 transition-colors"
>
<span className="text-xs font-black text-slate-700 uppercase tracking-wider">
{category}
</span>
<span className={cn(
'text-[10px] font-bold px-2 py-0.5 rounded-full',
allChecked
? 'bg-indigo-100 text-indigo-600'
: someChecked
? 'bg-amber-100 text-amber-600'
: 'bg-slate-100 text-slate-400',
)}>
{perms.filter(p => editingPermissions.has(p.key)).length}/{perms.length}
</span>
</button>
{/* 权限列表 */}
<div className="divide-y divide-slate-50">
{perms.map(perm => (
<label
key={perm.key}
className={cn(
'flex items-center gap-3 px-5 py-2.5 cursor-pointer transition-colors hover:bg-slate-50',
!selectedRole.isSystem ? 'cursor-pointer' : 'cursor-not-allowed opacity-60',
)}
>
<input
type="checkbox"
checked={editingPermissions.has(perm.key)}
onChange={() => togglePermission(perm.key)}
disabled={selectedRole.isSystem}
className="w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500/20 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-slate-800">{perm.label}</div>
<div className="text-xs text-slate-400">{perm.description}</div>
</div>
<code className="text-[10px] text-slate-300 font-mono shrink-0">{perm.key}</code>
</label>
))}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};
+19 -3
View File
@@ -52,6 +52,7 @@ import { userSettingService } from '../../services/userSettingService';
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
import { apiClient } from '../../services/apiClient';
import { AssessmentTemplateManager } from './AssessmentTemplateManager';
import { PermissionSettingsView } from './PermissionSettingsView';
import { useConfirm } from '../../contexts/ConfirmContext';
import { useToast } from '../../contexts/ToastContext';
@@ -66,7 +67,7 @@ interface SettingsViewProps {
initialTab?: TabType;
}
type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates';
type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates' | 'permissions';
const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
const map = new Map<string, Tenant>();
@@ -2131,6 +2132,16 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
{t('assessmentTemplates')}
</button>
)}
{isAdmin && (
<button
onClick={() => setActiveTab('permissions')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'permissions' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
}`}
>
<Shield size={18} />
</button>
)}
</div>
</div>
@@ -2139,10 +2150,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
{activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : t('assessmentTemplates')}
{activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : activeTab === 'permissions' ? '权限管理' : t('assessmentTemplates')}
</h1>
<p className="text-[15px] text-slate-500 mt-1">
{activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : t('assessmentTemplatesSubtitle')}
{activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : activeTab === 'permissions' ? '管理角色和细粒度权限' : t('assessmentTemplatesSubtitle')}
</p>
</div>
</div>
@@ -2183,6 +2194,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
<AssessmentTemplateManager />
</div>
)}
{activeTab === 'permissions' && isAdmin && (
<div className="flex-1 overflow-y-auto custom-scrollbar" style={{ height: 'calc(100vh - 220px)' }}>
<PermissionSettingsView />
</div>
)}
</motion.div>
</AnimatePresence>
</div>