ba33d517c1
后端: - 新增 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>
441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
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>
|
||
);
|
||
};
|