Files
aurak/web/components/views/PermissionSettingsView.tsx
T
Developer ffe365201a fix(ui): 统一设计语言——颜色、字号、间距、控件样式
UI 修正清单:
┌────────────────────────────────────────────────────────────┐
│ 问题                     │ 修复                          │
├──────────────────────────┼────────────────────────────────┤
│ 登录页 blue → 不一致     │ 统一改为 indigo 系             │
│ text-[10px] 标签难读     │ → text-xs (设置页所有标签)     │
│ text-[9px] 徽章太小      │ → text-xs (角色/租户/权限)    │
│ text-[14px] 输入框不统一  │ → text-sm (编辑弹窗)          │
│ py-3 vs py-3.5 不一致    │ → py-3 统一 (所有输入框)      │
│ 表格头 py-4 过大         │ → py-3                        │
│ 编辑弹窗 max-w-md 太窄   │ → max-w-lg (更宽松)           │
│ 操作列 opacity-0 隐藏     │ → opacity-60 始终可见         │
│ 原生 checkbox 不匹配      │ → 自定义圆角 checkbox + Check  │
│ 登录页 rounded-lg         │ → rounded-xl 统一             │
│ 登录按钮缺阴影            │ → 加 shadow-lg                │
└────────────────────────────────────────────────────────────┘

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:28:49 +08:00

451 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-xs 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',
)}
>
<div className={cn(
'w-5 h-5 rounded-md flex items-center justify-center border-2 transition-all shrink-0',
editingPermissions.has(perm.key)
? 'bg-indigo-600 border-indigo-600 text-white'
: 'border-slate-300 bg-white',
selectedRole.isSystem ? 'opacity-40' : '',
)}>
{editingPermissions.has(perm.key) && <Check size={14} strokeWidth={3} />}
</div>
{/* hidden native checkbox for a11y */}
<input
type="checkbox"
checked={editingPermissions.has(perm.key)}
onChange={() => togglePermission(perm.key)}
disabled={selectedRole.isSystem}
className="sr-only"
/>
<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-xs text-slate-300 font-mono shrink-0">{perm.key}</code>
</label>
))}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};