Files
aurak/web/components/views/PermissionSettingsView.tsx
T
Developer ba33d517c1 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>
2026-06-08 23:25:22 +08:00

441 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};