feat(rbac): F3 前端 RBAC 按钮级控制

后端:
- 新增 GET /user/permissions 端点返回当前用户权限码
- Login 响应附带 permissions 数组
- collectUserPermissionCodes 用 raw SQL JOIN 避免 GORM many2many 表名问题
- admin 用户返回 ['*'] 通配符

前端:
- usePermission hook (hasPermission/hasAnyPermission/hasAllPermissions)
- <HasPermission> 组件 (permission/anyOf/allOf + fallback)
- zustand store 新增 permissions 字段
- Sidebar 菜单递归过滤 (42 菜单项全部标注 permission)
- ServerManager: 添加/导入/批量删除按钮按权限显隐
- ServerTableRow: 终端/编辑/文件/删除按权限显隐
- SystemUserTab: 新建/导入/编辑/删除按钮按权限显隐
- MainLayout: 页面刷新自动拉取权限
- Login: 登录时存储 permissions

验证:
- admin: permissions=['*'] 通配符
- viewer: 7 个只读权限 (dashboard:view/server:view/...)
- viewer 无 server:create/delete
- 10/10 回归测试通过
- npm run build 零错误
This commit is contained in:
attacker committed 2026-06-25 22:51:25 +08:00
1 parent 77c234b060
commit be2a377f01
30 files changed
+2328 -462

No files matched your search

+2 -2
View File
@@ -85,8 +85,8 @@ export function Login() {
setError('');
setLoading(true);
try {
const result = await userApi.login(username, password) as { user: any; token: string };
useUserStore.getState().login(result.user, result.token);
const result = await userApi.login(username, password) as { user: any; token: string; permissions?: string[] };
useUserStore.getState().login(result.user, result.token, result.permissions ?? null);
localStorage.setItem('token', result.token);
navigate('/', { replace: true });
} catch (err: any) {
@@ -51,6 +51,7 @@ const ROUTE_NAME_MAP: Record<string, string> = {
'approval-resource': '资源申请',
'approval-app': '应用申请',
'approval-permission': '权限审批',
'approval-workflow': '审批流程',
'scripts': '脚本库',
'ansible': 'Ansible',
'ai-assistant': '智能助手',
+26 -1
View File
@@ -1,9 +1,11 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore } from '@/store';
import { useThemeStore, useUserStore } from '@/store';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { Breadcrumb } from './Breadcrumb';
import { userApi } from '@/services/api';
/**
* MainLayout - 优化后的主布局组件
@@ -14,9 +16,32 @@ import { Breadcrumb } from './Breadcrumb';
* 3. 添加 Breadcrumb 面包屑导航
* 4. 布局过渡动画平滑
* 5. 支持响应式布局
* 6. F3: 页面刷新后自动拉取当前用户权限(RBAC 按钮级控制)
*/
export function MainLayout() {
const { sidebarCollapsed } = useThemeStore();
const token = useUserStore((s) => s.token);
const permissions = useUserStore((s) => s.permissions);
const setPermissions = useUserStore((s) => s.setPermissions);
// 页面刷新后 zustand persist 会恢复 token/user,但 permissions 可能丢失或过期
// 在 MainLayout 挂载时如果 permissions 为 null 则拉取一次
useEffect(() => {
if (!token || permissions !== null) return;
let cancelled = false;
userApi.getMyPermissions()
.then((data: any) => {
if (cancelled) return;
const perms: string[] = data?.permissions ?? [];
setPermissions(Array.isArray(perms) ? perms : []);
})
.catch(() => {
// 拉取失败不阻塞页面,权限保持 null(usePermission 默认放行)
if (!cancelled) setPermissions([]);
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<div
+80 -61
View File
@@ -1,7 +1,8 @@
import { useEffect} from 'react';
import { useEffect } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore, useMenuStore } from '@/store';
import { usePermission } from '@/hooks/usePermission';
import {
LayoutDashboard,
@@ -50,113 +51,116 @@ interface MenuItem {
icon: React.ElementType;
path?: string;
children?: MenuItem[];
/** RBAC 权限码 — 用户需拥有此权限才显示该菜单项;不填=所有人可见 */
permission?: string;
}
const menuItems: MenuItem[] = [
{ id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/' },
{ id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/', permission: 'dashboard:view' },
{
id: 'assets', name: '资产管理', icon: Server,
id: 'assets', name: '资产管理', icon: Server, permission: 'server:view',
children: [
{ id: 'servers', name: '服务器管理', icon: Database, path: '/servers' },
{ id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts' },
{ id: 'domains', name: '域名管理', icon: Globe, path: '/domains' },
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' },
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' },
{ id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing' },
{ id: 'servers', name: '服务器管理', icon: Database, path: '/servers', permission: 'server:view' },
{ id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts', permission: 'cloud:account' },
{ id: 'domains', name: '域名管理', icon: Globe, path: '/domains', permission: 'cloud:domain' },
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions', permission: 'cloud:account' },
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms', permission: 'cloud:account' },
{ id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing', permission: 'cloud:billing' },
],
},
{
id: 'kubernetes', name: 'Kubernetes', icon: Cloud,
id: 'kubernetes', name: 'Kubernetes', icon: Cloud, permission: 'k8s:cluster',
children: [
{ id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes' },
{ id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes' },
{ id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes', permission: 'k8s:cluster' },
{ id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes', permission: 'k8s:nodes' },
{
id: 'k8s-workload', name: '工作负载', icon: Boxes,
id: 'k8s-workload', name: '工作负载', icon: Boxes, permission: 'k8s:workloads',
children: [
{ id: 'k8s-deployments', name: 'Deployments', icon: Boxes, path: '/workloads/deployments' },
{ id: 'k8s-statefulsets', name: 'StatefulSets', icon: Boxes, path: '/workloads/statefulsets' },
{ id: 'k8s-daemonsets', name: 'DaemonSets', icon: Boxes, path: '/workloads/daemonsets' },
{ id: 'k8s-cronjobs', name: 'CronJobs', icon: Clock, path: '/workloads/cronjobs' },
{ id: 'k8s-pods', name: 'Pods', icon: Container, path: '/workloads/pods' },
{ id: 'k8s-deployments', name: 'Deployments', icon: Boxes, path: '/workloads/deployments', permission: 'k8s:workloads' },
{ id: 'k8s-statefulsets', name: 'StatefulSets', icon: Boxes, path: '/workloads/statefulsets', permission: 'k8s:workloads' },
{ id: 'k8s-daemonsets', name: 'DaemonSets', icon: Boxes, path: '/workloads/daemonsets', permission: 'k8s:workloads' },
{ id: 'k8s-cronjobs', name: 'CronJobs', icon: Clock, path: '/workloads/cronjobs', permission: 'k8s:workloads' },
{ id: 'k8s-pods', name: 'Pods', icon: Container, path: '/workloads/pods', permission: 'k8s:workloads' },
],
},
{
id: 'k8s-network', name: '网络', icon: Network,
id: 'k8s-network', name: '网络', icon: Network, permission: 'k8s:workloads',
children: [
{ id: 'k8s-services', name: 'Services', icon: Network, path: '/workloads/services' },
{ id: 'k8s-ingresses', name: 'Ingresses', icon: Globe, path: '/workloads/ingresses' },
{ id: 'k8s-services', name: 'Services', icon: Network, path: '/workloads/services', permission: 'k8s:workloads' },
{ id: 'k8s-ingresses', name: 'Ingresses', icon: Globe, path: '/workloads/ingresses', permission: 'k8s:workloads' },
],
},
{
id: 'k8s-config', name: '配置中心', icon: Settings,
id: 'k8s-config', name: '配置中心', icon: Settings, permission: 'k8s:workloads',
children: [
{ id: 'k8s-configmaps', name: 'ConfigMaps', icon: FileCode, path: '/workloads/configmaps' },
{ id: 'k8s-secrets', name: 'Secrets', icon: Shield, path: '/workloads/secrets' },
{ id: 'k8s-configmaps', name: 'ConfigMaps', icon: FileCode, path: '/workloads/configmaps', permission: 'k8s:workloads' },
{ id: 'k8s-secrets', name: 'Secrets', icon: Shield, path: '/workloads/secrets', permission: 'k8s:workloads' },
],
},
{ id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs' },
{ id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics' },
{ id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events' },
{ id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity' },
{ id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology' },
{ id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs', permission: 'k8s:workloads' },
{ id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics', permission: 'k8s:workloads' },
{ id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events', permission: 'k8s:workloads' },
{ id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity', permission: 'k8s:workloads' },
{ id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology', permission: 'k8s:topology' },
],
},
{
id: 'deploy', name: '应用发布', icon: Rocket,
id: 'deploy', name: '应用发布', icon: Rocket, permission: 'deploy:pipeline',
children: [
{ id: 'pipelines', name: '流水线', icon: GitBranch, path: '/pipelines' },
{ id: 'deploy-credentials', name: '发布配置', icon: Key, path: '/deploy/configureTools' },
{ id: 'deploy-rbac', name: '发布权限', icon: Shield, path: '/deploy/rbac' },
{ id: 'pipelines', name: '流水线', icon: GitBranch, path: '/pipelines', permission: 'deploy:pipeline' },
{ id: 'deploy-credentials', name: '发布配置', icon: Key, path: '/deploy/configureTools', permission: 'deploy:release' },
{ id: 'deploy-rbac', name: '发布权限', icon: Shield, path: '/deploy/rbac', permission: 'deploy:release' },
],
},
{
id: 'monitor', name: '监控中心', icon: Bell,
id: 'monitor', name: '监控中心', icon: Bell, permission: 'monitor:alert',
children: [
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' },
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' },
{ id: 'datasources', name: '数据源', icon: Database, path: '/datasources' },
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards', permission: 'monitor:alert' },
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center', permission: 'agent:monitor' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace', permission: 'monitor:alert' },
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center', permission: 'monitor:alert' },
{ id: 'datasources', name: '数据源', icon: Database, path: '/datasources', permission: 'monitor:alert' },
],
},
{
id: 'automation', name: '自动化运维', icon: PlayCircle,
id: 'automation', name: '自动化运维', icon: PlayCircle, permission: 'auto:cron',
children: [
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
{ id: 'ansible', name: '远程执行', icon: Terminal, path: '/ansible' },
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs', permission: 'auto:cron' },
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts', permission: 'auto:script' },
{ id: 'ansible', name: '远程执行', icon: Terminal, path: '/ansible', permission: 'auto:ansible' },
],
},
{
id: 'aiops', name: 'AIOps智能', icon: Brain,
id: 'aiops', name: 'AIOps智能', icon: Brain, permission: 'aiops:assistant',
children: [
{ id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant' },
{ id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer' },
{ id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive' },
{ id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant', permission: 'aiops:assistant' },
{ id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer', permission: 'aiops:analysis' },
{ id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive', permission: 'aiops:forecast' },
],
},
{ id: 'approval', name: '资源申请', icon: CheckSquare,
{ id: 'approval', name: '资源申请', icon: CheckSquare, permission: 'approval:apply',
children: [
{ id: 'approval-resource', name: '资源申请', icon: Server, path: '/approval/resource' },
{ id: 'approval-app', name: '应用申请', icon: Rocket, path: '/approval/app' },
{ id: 'approval-permission', name: '权限审批', icon: ShieldCheck, path: '/approval/permission' },
{ id: 'approval-resource', name: '资源申请', icon: Server, path: '/approval/resource', permission: 'approval:apply' },
{ id: 'approval-app', name: '应用申请', icon: Rocket, path: '/approval/app', permission: 'approval:apply' },
{ id: 'approval-permission', name: '权限审批', icon: ShieldCheck, path: '/approval/permission', permission: 'approval:apply' },
],
},
{
id: 'audit', name: '安全审计', icon: FileSearch,
id: 'audit', name: '安全审计', icon: FileSearch, permission: 'audit:terminal',
children: [
{ id: 'audit-terminal', name: '终端审计', icon: Terminal, path: '/audit/terminal' },
{ id: 'audit-operations', name: '操作审计', icon: FileCode, path: '/audit/operations' },
{ id: 'audit-login', name: '登录日志', icon: LogIn, path: '/audit/login-logs' },
{ id: 'audit-terminal', name: '终端审计', icon: Terminal, path: '/audit/terminal', permission: 'audit:terminal' },
{ id: 'audit-operations', name: '操作审计', icon: FileCode, path: '/audit/operations', permission: 'audit:operations' },
{ id: 'audit-login', name: '登录日志', icon: LogIn, path: '/audit/login-logs', permission: 'audit:login-logs' },
],
},
{
id: 'system', name: '系统管理', icon: Settings,
id: 'system', name: '系统管理', icon: Settings, permission: 'system:user',
children: [
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials' },
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
{ id: 'users', name: '用户管理', icon: Users, path: '/users', permission: 'system:user' },
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles', permission: 'system:role' },
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials', permission: 'system:user' },
{ id: 'approval-workflow', name: '审批流程', icon: GitBranch, path: '/approval-workflow', permission: 'system:role' },
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings', permission: 'system:user' },
],
},
];
@@ -321,6 +325,7 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
*/
export function Sidebar() {
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
const { hasPermission } = usePermission();
// Listen for keyboard shortcut
useEffect(() => {
@@ -329,6 +334,20 @@ export function Sidebar() {
return () => window.removeEventListener('toggle-sidebar' as any, handler);
}, [toggleSidebar]);
// F3: 按权限过滤菜单(递归)
// 父级菜单:自身权限通过 且 至少有一个子菜单可见
const filterMenu = (items: MenuItem[]): MenuItem[] => {
return items
.filter((item) => !item.permission || hasPermission(item.permission))
.map((item) => {
if (!item.children) return item;
const visibleChildren = filterMenu(item.children);
return { ...item, children: visibleChildren };
})
.filter((item) => !item.children || item.children.length > 0);
};
const visibleMenuItems = filterMenu(menuItems);
return (
<aside
className={cn(
@@ -365,7 +384,7 @@ export function Sidebar() {
{/* Menu Items */}
<ScrollArea className="flex-1 py-2 px-1.5">
<nav className="space-y-0.5">
{menuItems.map((item) => (
{visibleMenuItems.map((item) => (
<MenuItemComponent key={item.id} item={item} />
))}
</nav>
@@ -0,0 +1,51 @@
/**
* HasPermission — 条件渲染子组件(基于 RBAC 权限码)
*
* 用法:
* <HasPermission permission="server:create">
* <Button>新增服务器</Button>
* </HasPermission>
*
* <HasPermission anyOf={['server:update', 'server:delete']}>
* <Button>操作</Button>
* </HasPermission>
*
* <HasPermission allOf={['server:update', 'server:webshell']}>
* <Button>运维操作</Button>
* </HasPermission>
*
* <HasPermission permission="server:delete" fallback={<span className="text-xs text-muted">无权限</span>}>
* <Button>删除</Button>
* </HasPermission>
*/
import type { ReactNode } from 'react';
import { usePermission } from '@/hooks/usePermission';
interface HasPermissionProps {
/** 单个权限码 */
permission?: string;
/** 任意一个即可(OR) */
anyOf?: string[];
/** 全部都要(AND) */
allOf?: string[];
/** 无权限时渲染的兜底内容 */
fallback?: ReactNode;
children: ReactNode;
}
export function HasPermission({
permission,
anyOf,
allOf,
fallback = null,
children,
}: HasPermissionProps) {
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
let ok = true;
if (permission) ok = ok && hasPermission(permission);
if (anyOf && anyOf.length > 0) ok = ok && hasAnyPermission(anyOf);
if (allOf && allOf.length > 0) ok = ok && hasAllPermissions(allOf);
return <>{ok ? children : fallback}</>;
}
@@ -51,9 +51,11 @@ import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
import { getTags } from './serverHelpers';
import { ServerTableRow } from './ServerTableRow';
import { usePermission } from '@/hooks/usePermission';
export function ServerManager() {
const confirm = useConfirm();
const { hasPermission } = usePermission();
const [servers, setServers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
@@ -392,6 +394,7 @@ export function ServerManager() {
<Download className="h-4 w-4 mr-2" />
导出
</Button>
{hasPermission('server:create') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
@@ -420,13 +423,16 @@ export function ServerManager() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
onClick={openCreateForm}
>
<Plus className="h-4 w-4 mr-2" />
添加服务器
</Button>
)}
{hasPermission('server:create') && (
<Button
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
onClick={openCreateForm}
>
<Plus className="h-4 w-4 mr-2" />
添加服务器
</Button>
)}
</div>
</div>
@@ -464,6 +470,7 @@ export function ServerManager() {
{selectedServers.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
{hasPermission('server:delete') && (
<Button
variant="outline"
size="sm"
@@ -473,6 +480,7 @@ export function ServerManager() {
<Trash2 className="h-4 w-4 mr-2" />
批量删除
</Button>
)}
</div>
)}
</div>
@@ -14,6 +14,7 @@ import {
import { TableCell, TableRow } from '@/components/ui/table';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
import { usePermission } from '@/hooks/usePermission';
interface ServerTableRowProps {
server: any;
@@ -34,6 +35,7 @@ export function ServerTableRow({
onOpenFiles,
onDelete,
}: ServerTableRowProps) {
const { hasPermission } = usePermission();
const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server);
@@ -147,9 +149,12 @@ export function ServerTableRow({
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{hasPermission('server:webshell') && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title="终端登录">
<Terminal className="h-4 w-4" />
</Button>
)}
{(hasPermission('server:update') || hasPermission('server:delete') || hasPermission('server:webshell')) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
@@ -159,6 +164,7 @@ export function ServerTableRow({
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{hasPermission('server:update') && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenEdit(server)}
@@ -166,6 +172,8 @@ export function ServerTableRow({
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
)}
{hasPermission('server:webshell') && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenTerminal(server)}
@@ -173,6 +181,8 @@ export function ServerTableRow({
<Terminal className="h-4 w-4 mr-2" />
终端
</DropdownMenuItem>
)}
{hasPermission('server:update') && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenFiles?.(server)}
@@ -180,6 +190,9 @@ export function ServerTableRow({
<FolderOpen className="h-4 w-4 mr-2" />
文件
</DropdownMenuItem>
)}
{hasPermission('server:delete') && (
<>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
@@ -188,8 +201,11 @@ export function ServerTableRow({
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</TableCell>
</TableRow>
+103 -49
View File
@@ -4,12 +4,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Shield } from 'lucide-react';
@@ -38,37 +33,69 @@ interface RoleFormDialogProps {
permissions: PermissionOption[];
}
// 权限按模块分组
const MODULE_GROUPS: { key: string; label: string; prefixes: string[] }[] = [
{ key: 'dashboard', label: '📊 仪表盘', prefixes: ['dashboard:'] },
{ key: 'server', label: '🖥️ 资产管理', prefixes: ['server:'] },
{ key: 'cloud', label: '☁️ 云资源', prefixes: ['cloud:'] },
{ key: 'k8s', label: '☸️ Kubernetes', prefixes: ['k8s:'] },
{ key: 'deploy', label: '🚀 应用发布', prefixes: ['deploy:'] },
{ key: 'monitor', label: '📡 监控告警', prefixes: ['monitor:', 'agent:'] },
{ key: 'auto', label: '⚙️ 自动化运维', prefixes: ['auto:'] },
{ key: 'aiops', label: '🤖 AIOps智能', prefixes: ['aiops:'] },
{ key: 'approval', label: '✅ 资源申请', prefixes: ['approval:'] },
{ key: 'audit', label: '🔍 安全审计', prefixes: ['audit:'] },
{ key: 'system', label: '🔧 系统管理', prefixes: ['system:'] },
];
function groupPermissions(perms: PermissionOption[]): { key: string; label: string; items: PermissionOption[] }[] {
const grouped = MODULE_GROUPS.map(g => ({
key: g.key,
label: g.label,
items: perms.filter(p => g.prefixes.some(pre => p.code.startsWith(pre))),
})).filter(g => g.items.length > 0);
// 未分类的权限
const classified = new Set(grouped.flatMap(g => g.items.map(p => p.id)));
const uncategorized = perms.filter(p => !classified.has(p.id));
if (uncategorized.length > 0) {
grouped.push({ key: 'other', label: '📌 其他', items: uncategorized });
}
return grouped;
}
export function RoleFormDialog({
open,
onOpenChange,
editingRole,
form,
setForm,
saving,
onSubmit,
permissions,
open, onOpenChange, editingRole, form, setForm, saving, onSubmit, permissions,
}: RoleFormDialogProps) {
const togglePermission = (id: number, checked: boolean) => {
setForm((prev) => {
const set = new Set(prev.permission_ids);
if (checked) {
set.add(id);
} else {
set.delete(id);
}
checked ? set.add(id) : set.delete(id);
return { ...prev, permission_ids: Array.from(set) };
});
};
// 全选/取消某个分组
const toggleGroup = (items: PermissionOption[], checked: boolean) => {
setForm((prev) => {
const set = new Set(prev.permission_ids);
items.forEach(p => checked ? set.add(p.id) : set.delete(p.id));
return { ...prev, permission_ids: Array.from(set) };
});
};
const grouped = groupPermissions(permissions);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-400" />
{editingRole ? '编辑角色' : '新建角色'}
</DialogTitle>
<DialogDescription className="text-slate-500">
<DialogDescription className="text-[var(--dg-text-secondary)]">
{editingRole ? '修改角色信息与权限' : '创建新的系统角色并分配权限'}
</DialogDescription>
</DialogHeader>
@@ -76,68 +103,95 @@ export function RoleFormDialog({
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs text-slate-500">角色名称</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">角色名称</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
placeholder="如:运维工程师"
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-slate-500">角色编码</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">角色编码</Label>
<Input
value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value })}
disabled={!!editingRole}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 disabled:opacity-60"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] disabled:opacity-60"
placeholder="如:ops_engineer"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs text-slate-500">描述</Label>
<Label className="text-xs text-[var(--dg-text-secondary)]">描述</Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 resize-none"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] resize-none"
placeholder="角色职责描述(可选)"
rows={2}
/>
</div>
{/* 权限分配 — 按模块分组 */}
<div className="space-y-2">
<Label className="text-xs text-slate-500">
<Label className="text-xs text-[var(--dg-text-secondary)]">
权限分配
{form.permission_ids.length > 0 && (
<span className="ml-2 text-slate-400">已选 {form.permission_ids.length} 项</span>
<span className="ml-2 text-purple-400">已选 {form.permission_ids.length} 项</span>
)}
</Label>
<div className="max-h-48 overflow-y-auto rounded-lg border border-slate-800 bg-slate-800/30 p-3">
{permissions.length === 0 ? (
<p className="text-xs text-slate-500 text-center py-4">暂无可分配权限</p>
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-3 space-y-3">
{grouped.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] text-center py-4">暂无可分配权限</p>
) : (
<div className="grid grid-cols-2 gap-2">
{permissions.map((perm) => (
<Label
key={perm.id}
className="flex items-center gap-2 text-xs text-slate-300 font-normal cursor-pointer hover:text-slate-100"
>
<Checkbox
checked={form.permission_ids.includes(perm.id)}
onCheckedChange={(checked) => togglePermission(perm.id, checked === true)}
className="border-slate-600 data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600"
/>
<span className="truncate">{perm.name}</span>
</Label>
))}
</div>
grouped.map(group => {
const selectedInGroup = group.items.filter(p => form.permission_ids.includes(p.id)).length;
const allSelected = selectedInGroup === group.items.length && group.items.length > 0;
return (
<div key={group.key}>
<div className="flex items-center justify-between mb-1.5">
<button
onClick={() => toggleGroup(group.items, !allSelected)}
className="text-xs font-medium text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors flex items-center gap-1"
>
{group.label}
<span className="text-[var(--dg-text-muted)] text-[10px]">
({selectedInGroup}/{group.items.length})
</span>
</button>
<button
onClick={() => toggleGroup(group.items, !allSelected)}
className="text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
>
{allSelected ? '取消全选' : '全选'}
</button>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 pl-1">
{group.items.map((perm) => (
<Label
key={perm.id}
className="flex items-center gap-2 text-xs text-[var(--dg-text-secondary)] font-normal cursor-pointer hover:text-[var(--dg-text-primary)] py-0.5"
>
<Checkbox
checked={form.permission_ids.includes(perm.id)}
onCheckedChange={(checked) => togglePermission(perm.id, checked === true)}
className="border-[var(--dg-border)] data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600 h-3.5 w-3.5"
/>
<span className="truncate">{perm.name}</span>
</Label>
))}
</div>
</div>
);
})
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => onOpenChange(false)}>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={onSubmit} disabled={saving}>
+157 -140
View File
@@ -4,61 +4,81 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Plus, Shield, Edit, Eye, Trash2 } from 'lucide-react';
import { Plus, Shield, ShieldCheck, Edit, Eye, Trash2, Lock, Star } from 'lucide-react';
import { userApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import type { Role } from '@/types';
import {
RoleFormDialog,
type RoleFormData,
type PermissionOption,
} from './RoleFormDialog';
import { RoleFormDialog, type RoleFormData, type PermissionOption } from './RoleFormDialog';
interface SystemRolesTabProps {
selectedRole: Role | null;
setSelectedRole: (role: Role | null) => void;
}
// 将后端角色数据规范化为前端 Role 类型 // Logan.Li
function normalizeRole( raw: any): Role {
// 角色图标映射
const ROLE_ICONS: Record<string, string> = {
super_admin: '👑',
k8s_admin: '☸️',
resource_admin: '🖥️',
monitor_admin: '📡',
security_auditor: '🔍',
release_admin: '🚀',
viewer: '👁️',
};
// 权限按模块分组
const PERM_MODULE_GROUPS: { label: string; prefixes: string[] }[] = [
{ label: '仪表盘', prefixes: ['dashboard:'] },
{ label: '资产管理', prefixes: ['server:'] },
{ label: '云资源', prefixes: ['cloud:'] },
{ label: 'K8s', prefixes: ['k8s:'] },
{ label: '发布', prefixes: ['deploy:'] },
{ label: '监控', prefixes: ['monitor:', 'agent:'] },
{ label: '自动化', prefixes: ['auto:'] },
{ label: 'AI', prefixes: ['aiops:'] },
{ label: '审批', prefixes: ['approval:'] },
{ label: '审计', prefixes: ['audit:'] },
{ label: '系统', prefixes: ['system:'] },
];
function groupPermNames(perms: string[], codes: string[]): { label: string; count: number }[] {
const grouped = PERM_MODULE_GROUPS.map(g => {
const matched = perms.filter((_, i) => g.prefixes.some(pre => (codes[i] || '').startsWith(pre)));
return { label: g.label, count: matched.length };
}).filter(g => g.count > 0);
return grouped;
}
// 将后端角色数据规范化为前端 Role 类型
function normalizeRole(raw: any): Role {
const rawPerms = Array.isArray(raw.permissions) ? raw.permissions : [];
return {
id: String(raw.id ?? ''),
name: raw.name ?? '',
code: raw.code ?? '',
description: raw.description ?? '',
// 后端返回权限对象数组(含 name/code/type),映射为显示名数组
permissions: rawPerms.map(( p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean),
permissionCodes: rawPerms.map(( p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean),
permissionCodes: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
createdAt: raw.createdAt ?? raw.created_at ?? '',
// 保留原始权限 ID 供编辑使用
isSystem: raw.is_system ?? raw.isSystem ?? false,
_permissionIds: rawPerms
.map(( p: any) => (typeof p === 'object' ? Number(p.id) : 0))
.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0))
.filter(Boolean),
} as Role & { _permissionIds: number[]; permissionCodes: string[] };
_raw: raw,
} as Role & { _permissionIds: number[]; permissionCodes: string[]; isSystem: boolean; _raw: any };
}
const emptyForm: RoleFormData = {
name: '',
code: '',
description: '',
permission_ids: [],
name: '', code: '', description: '', permission_ids: [],
};
export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) {
const confirm = useConfirm();
const [roles, setRoles] = useState<Role[]>([]);
const [roles, setRoles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [permissions, setPermissions] = useState<PermissionOption[]>([]);
// 新建 / 编辑弹窗
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRole, setEditingRole] = useState<any | null>(null);
const [form, setForm] = useState<RoleFormData>({ ...emptyForm });
@@ -82,11 +102,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
const resp = await userApi.getPermissions();
const list = resp?.list ?? [];
setPermissions(
list.map(( p: any) => ({
id: Number(p.id),
name: p.name ?? p.code ?? '',
code: p.code ?? '',
type: p.type,
list.map((p: any) => ({
id: Number(p.id), name: p.name ?? p.code ?? '', code: p.code ?? '', type: p.type,
}))
);
} catch {
@@ -105,8 +122,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
setDialogOpen(true);
};
const openEdit = ( role: any) => {
setEditingRole(role);
const openEdit = (role: any) => {
setEditingRole(role._raw || role);
setForm({
name: role.name ?? '',
code: role.code ?? '',
@@ -117,14 +134,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
};
const handleSubmit = async () => {
if (!form.name.trim()) {
toast.error('请输入角色名称');
return;
}
if (!form.code.trim()) {
toast.error('请输入角色编码');
return;
}
if (!form.name.trim()) { toast.error('请输入角色名称'); return; }
if (!form.code.trim()) { toast.error('请输入角色编码'); return; }
setSaving(true);
try {
const payload = {
@@ -149,7 +160,11 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
}
};
const handleDelete = async ( role: any) => {
const handleDelete = async (role: any) => {
if (role.isSystem) {
toast.error('系统默认角色不可删除');
return;
}
const ok = await confirm({
title: '删除角色',
description: `确认删除角色「${role.name}」吗?该操作不可恢复。`,
@@ -162,16 +177,17 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
toast.success('角色已删除');
await loadRoles();
} catch (err: any) {
toast.error(err?.message || '删除失败');
const msg = err?.response?.data?.message || err?.message || '删除失败';
toast.error(msg);
}
};
return (
<>
<div className="flex items-center justify-end">
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-[var(--dg-text-muted)]">{roles.length} 个角色</span>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
新建角色
<Plus className="h-4 w-4 mr-2" />新建角色
</Button>
</div>
@@ -180,77 +196,83 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : roles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
<Shield className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">暂无角色数据</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{roles.map(( role: any) => (
<Card key={role.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
<Shield className="h-5 w-5 text-purple-400" />
{roles.map((role: any) => {
const perms = role.permissions as string[];
const codes = role.permissionCodes as string[];
const groupedPerms = groupPermNames(perms, codes);
const icon = ROLE_ICONS[role.code] || '🛡️';
return (
<Card key={role.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${!role.isSystem ? '' : 'border-purple-500/20'}`}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center text-lg ${
role.isSystem ? 'bg-purple-500/10' : 'bg-[var(--dg-bg-tertiary)]'
}`}>
{icon}
</div>
<div>
<CardTitle className="text-base text-[var(--dg-text-primary)] flex items-center gap-1.5">
{role.name}
{role.isSystem && (
<Badge className="bg-purple-500/15 text-purple-400 border-purple-500/30 text-[10px] px-1 py-0">
<Lock className="h-2.5 w-2.5 mr-0.5" />系统
</Badge>
)}
</CardTitle>
<CardDescription className="text-[var(--dg-text-muted)] text-xs">{role.code}</CardDescription>
</div>
</div>
<div>
<CardTitle className="text-base text-slate-100">{role.name}</CardTitle>
<CardDescription className="text-slate-500">{role.code}</CardDescription>
<div className="flex items-center gap-1">
<Button
variant="ghost" size="icon"
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
onClick={() => setSelectedRole(role)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon"
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-blue-400"
onClick={() => openEdit(role)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon"
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-red-400"
onClick={() => handleDelete(role)}
disabled={role.isSystem}
title={role.isSystem ? '系统默认角色不可删除' : '删除角色'}
>
<Trash2 className={`h-4 w-4 ${role.isSystem ? 'opacity-30' : ''}`} />
</Button>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedRole(role)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => openEdit(role)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(role)}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{role.description || '暂无描述'}</p>
{/* 按模块显示权限数 */}
<div className="flex flex-wrap gap-1.5">
{groupedPerms.map(g => (
<Badge key={g.label} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px]">
{g.label}·{g.count}
</Badge>
))}
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-400 mb-3">{role.description || '暂无描述'}</p>
{/* 权限按模块分组显示 // Logan.Li */}
<div className="flex flex-wrap gap-1">
{(role.permissions as string[]).slice(0, 8).map((permission, idx) => (
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{permission}
</Badge>
))}
{(role.permissions as string[]).length > 8 && (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 text-xs">
+{(role.permissions as string[]).length - 8} 项
</Badge>
{perms.length === 0 && (
<span className="text-xs text-[var(--dg-text-muted)]">未分配权限</span>
)}
{(role.permissions as string[]).length === 0 && (
<span className="text-xs text-slate-600">未分配权限</span>
)}
</div>
<div className="mt-2 text-xs text-slate-600">
共 {role.permissions?.length || 0} 项权限
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
);
})}
</div>
)}
@@ -259,14 +281,9 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
{/* 新建 / 编辑角色 Dialog */}
<RoleFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingRole={editingRole}
form={form}
setForm={setForm}
saving={saving}
onSubmit={handleSubmit}
permissions={permissions}
open={dialogOpen} onOpenChange={setDialogOpen}
editingRole={editingRole} form={form} setForm={setForm}
saving={saving} onSubmit={handleSubmit} permissions={permissions}
/>
</>
);
@@ -274,50 +291,50 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
// 角色详情查看 Dialog
function RoleDetailDialog({
role,
onClose,
onEdit,
}: {
role: Role | null;
onClose: () => void;
onEdit: (role: Role) => void;
}) {
role, onClose, onEdit,
}: { role: Role | null; onClose: () => void; onEdit: (role: Role) => void }) {
const rawRole = role as any;
const isSystem = rawRole?.isSystem ?? false;
return (
<Dialog open={!!role} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-400" />
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-purple-400" />
{role?.name}
{isSystem && (
<Badge className="bg-purple-500/15 text-purple-400 border-purple-500/30 text-[10px]">
<Lock className="h-2.5 w-2.5 mr-0.5" />系统
</Badge>
)}
</DialogTitle>
<DialogDescription className="text-slate-500">编码:{role?.code}</DialogDescription>
<DialogDescription className="text-[var(--dg-text-muted)]">编码:{role?.code}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-slate-800/50 p-4">
<p className="text-xs text-slate-500 mb-1">描述</p>
<p className="text-sm text-slate-200">{role?.description || '暂无描述'}</p>
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-4">
<p className="text-xs text-[var(--dg-text-muted)] mb-1">描述</p>
<p className="text-sm text-[var(--dg-text-primary)]">{role?.description || '暂无描述'}</p>
</div>
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表 ({role?.permissions?.length || 0} 项)</h4>
{role && role.permissions.length > 0 ? (
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-2">
权限列表 ({role?.permissions?.length || 0} 项)
</h4>
{role && (role.permissions as string[]).length > 0 ? (
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
{role.permissions.map((p: string, idx: number) => (
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-300">
{(role.permissions as string[]).map((p, idx) => (
<Badge key={idx} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{p}
</Badge>
))}
</div>
) : (
<p className="text-xs text-slate-500">未分配权限</p>
<p className="text-xs text-[var(--dg-text-muted)]">未分配权限</p>
)}
</div>
</div>
<DialogFooter>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => role && onEdit(role)}>
<Edit className="h-4 w-4 mr-2" />
编辑
<Edit className="h-4 w-4 mr-2" />编辑
</Button>
</DialogFooter>
</DialogContent>
@@ -25,6 +25,7 @@ import { UserFormDialog, type UserFormData } from './UserFormDialog';
import { UserDetailDialog } from './UserDetailDialog';
import { CsvImportDialog } from './CsvImportDialog';
import { LdapImportDialog } from './LdapImportDialog';
import { usePermission } from '@/hooks/usePermission';
interface SystemUserTabProps {
// Search
@@ -108,6 +109,7 @@ export function SystemUserTab({
handleLdapImport,
setLdapUsers,
}: SystemUserTabProps) {
const { hasPermission } = usePermission();
return (
<>
{/* Filters */}
@@ -122,18 +124,24 @@ export function SystemUserTab({
/>
</div>
<div className="flex items-center gap-2">
{hasPermission('system:user') && (
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}>
<Upload className="h-4 w-4 mr-2" />
批量导入
</Button>
)}
{hasPermission('system:user') && (
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
<Network className="h-4 w-4 mr-2" />
LDAP 导入
</Button>
)}
{hasPermission('system:user') && (
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
新建用户
</Button>
)}
</div>
</div>
@@ -232,6 +240,7 @@ export function SystemUserTab({
>
<Eye className="h-4 w-4" />
</Button>
{hasPermission('system:user') && (
<Button
variant="ghost"
size="icon"
@@ -240,6 +249,8 @@ export function SystemUserTab({
>
<Edit className="h-4 w-4" />
</Button>
)}
{hasPermission('system:user') && (
<Button
variant="ghost"
size="icon"
@@ -248,6 +259,7 @@ export function SystemUserTab({
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>