853 lines
28 KiB
TypeScript
853 lines
28 KiB
TypeScript
|
|
'use client';
|
|||
|
|
|
|||
|
|
import * as React from 'react';
|
|||
|
|
import { useState } from 'react';
|
|||
|
|
import { SiteHeader, PageInfo } from '@/components/site-header';
|
|||
|
|
import {
|
|||
|
|
IconPlus,
|
|||
|
|
IconEdit,
|
|||
|
|
IconTrash,
|
|||
|
|
IconSearch,
|
|||
|
|
IconUserShield,
|
|||
|
|
IconShield,
|
|||
|
|
IconEye,
|
|||
|
|
IconSettings,
|
|||
|
|
IconCheck,
|
|||
|
|
IconX,
|
|||
|
|
IconChevronDown,
|
|||
|
|
IconChevronRight,
|
|||
|
|
} from '@tabler/icons-react';
|
|||
|
|
|
|||
|
|
import { Button } from '@nice/ui/components/button';
|
|||
|
|
import { Input } from '@nice/ui/components/input';
|
|||
|
|
import { Badge } from '@nice/ui/components/badge';
|
|||
|
|
import {
|
|||
|
|
DropdownMenu,
|
|||
|
|
DropdownMenuContent,
|
|||
|
|
DropdownMenuItem,
|
|||
|
|
DropdownMenuSeparator,
|
|||
|
|
DropdownMenuTrigger,
|
|||
|
|
} from '@nice/ui/components/dropdown-menu';
|
|||
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@nice/ui/components/dialog';
|
|||
|
|
import { Label } from '@nice/ui/components/label';
|
|||
|
|
import { Textarea } from '@nice/ui/components/textarea';
|
|||
|
|
import { Switch } from '@nice/ui/components/switch';
|
|||
|
|
import { ScrollArea } from '@nice/ui/components/scroll-area';
|
|||
|
|
import { Checkbox } from '@nice/ui/components/checkbox';
|
|||
|
|
import { toast } from '@nice/ui/components/sonner';
|
|||
|
|
import {
|
|||
|
|
SystemRole,
|
|||
|
|
SystemRoleConfig,
|
|||
|
|
SystemPermission,
|
|||
|
|
SystemPermissionMeta,
|
|||
|
|
PermissionGroup,
|
|||
|
|
PermissionUtils,
|
|||
|
|
PermissionGroupNames,
|
|||
|
|
PermissionLevelBadge,
|
|||
|
|
PermissionLevelLabel,
|
|||
|
|
} from '@fenghuo/common';
|
|||
|
|
import { useQuery } from '@tanstack/react-query';
|
|||
|
|
import { useTRPC, useRole } from '@fenghuo/client';
|
|||
|
|
import { Role } from '@fenghuo/db';
|
|||
|
|
import { useSetPageInfo } from '@/components/providers/dashboard-provider';
|
|||
|
|
|
|||
|
|
// 模拟角色数据
|
|||
|
|
type RoleData = Role & {
|
|||
|
|
userCount?: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 远程数据将从 API 获取,这里不再使用模拟数据
|
|||
|
|
|
|||
|
|
// 权限分组组件
|
|||
|
|
interface PermissionGroupProps {
|
|||
|
|
group: PermissionGroup;
|
|||
|
|
permissions: SystemPermission[];
|
|||
|
|
selectedPermissions: Set<SystemPermission>;
|
|||
|
|
onPermissionToggle: (permission: SystemPermission) => void;
|
|||
|
|
onGroupToggle: (permissions: SystemPermission[], shouldSelect: boolean) => void;
|
|||
|
|
disabled?: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function PermissionGroupComponent({
|
|||
|
|
group,
|
|||
|
|
permissions,
|
|||
|
|
selectedPermissions,
|
|||
|
|
onPermissionToggle,
|
|||
|
|
onGroupToggle,
|
|||
|
|
disabled = false,
|
|||
|
|
}: PermissionGroupProps) {
|
|||
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|||
|
|
|
|||
|
|
const groupPermissions = permissions.filter((p) => SystemPermissionMeta[p]?.group === group);
|
|||
|
|
|
|||
|
|
if (groupPermissions.length === 0) return null;
|
|||
|
|
|
|||
|
|
const allSelected = groupPermissions.length > 0 && groupPermissions.every((p) => selectedPermissions.has(p));
|
|||
|
|
const someSelected = groupPermissions.some((p) => selectedPermissions.has(p));
|
|||
|
|
|
|||
|
|
const handleGroupToggle = () => {
|
|||
|
|
if (disabled) return;
|
|||
|
|
onGroupToggle(groupPermissions, !allSelected);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-gray-900/30">
|
|||
|
|
<div
|
|||
|
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/70 transition-colors bg-white dark:bg-gray-800/50"
|
|||
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 hover:bg-gray-200 dark:hover:bg-gray-700">
|
|||
|
|
{isExpanded ? <IconChevronDown className="h-4 w-4" /> : <IconChevronRight className="h-4 w-4" />}
|
|||
|
|
</Button>
|
|||
|
|
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
|||
|
|
<Checkbox
|
|||
|
|
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
|
|||
|
|
onCheckedChange={handleGroupToggle}
|
|||
|
|
disabled={disabled}
|
|||
|
|
/>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
|||
|
|
{PermissionGroupNames[group]}
|
|||
|
|
</span>
|
|||
|
|
<Badge variant="secondary" className="text-xs font-medium">
|
|||
|
|
{someSelected ? `${groupPermissions.filter((p) => selectedPermissions.has(p)).length}/` : ''}
|
|||
|
|
{groupPermissions.length}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{disabled && (
|
|||
|
|
<Badge variant="outline" className="text-xs text-gray-500">
|
|||
|
|
锁定
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{isExpanded && (
|
|||
|
|
<div className="bg-white dark:bg-gray-800/30">
|
|||
|
|
<div className="p-2 space-y-1">
|
|||
|
|
{groupPermissions.map((permission) => {
|
|||
|
|
const meta = SystemPermissionMeta[permission];
|
|||
|
|
const isSelected = selectedPermissions.has(permission);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
key={permission}
|
|||
|
|
className="flex items-center justify-between p-3 rounded-lg border border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors bg-white dark:bg-gray-800/20"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|||
|
|
<Checkbox
|
|||
|
|
checked={isSelected}
|
|||
|
|
onCheckedChange={() => onPermissionToggle(permission)}
|
|||
|
|
disabled={disabled}
|
|||
|
|
className={disabled ? 'opacity-40' : ''}
|
|||
|
|
/>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">{meta.name}</div>
|
|||
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1">{meta.description}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<Badge variant={PermissionLevelBadge[meta.level]} className="text-xs font-medium ml-2 shrink-0">
|
|||
|
|
{PermissionLevelLabel[meta.level]}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function PermissionsPage() {
|
|||
|
|
// 状态管理
|
|||
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|||
|
|
const [roles, setRoles] = useState<RoleData[]>([]);
|
|||
|
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|||
|
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
|||
|
|
const [editingRole, setEditingRole] = useState<RoleData | null>(null);
|
|||
|
|
|
|||
|
|
// 表单状态
|
|||
|
|
const [formData, setFormData] = useState({
|
|||
|
|
name: '',
|
|||
|
|
slug: '',
|
|||
|
|
description: '',
|
|||
|
|
isActive: true,
|
|||
|
|
});
|
|||
|
|
const [selectedPermissions, setSelectedPermissions] = useState<Set<SystemPermission>>(new Set());
|
|||
|
|
|
|||
|
|
useSetPageInfo({
|
|||
|
|
title: '权限管理',
|
|||
|
|
subtitle: '管理系统角色和权限分配',
|
|||
|
|
})
|
|||
|
|
// tRPC & TanStack Query
|
|||
|
|
const trpc = useTRPC();
|
|||
|
|
const roleMutations = useRole();
|
|||
|
|
|
|||
|
|
// 获取角色列表(包含用户数量统计)
|
|||
|
|
const { data: rolePagination } = useQuery(
|
|||
|
|
trpc.role.findManyWithPagination.queryOptions({
|
|||
|
|
page: 1,
|
|||
|
|
pageSize: 100,
|
|||
|
|
select: {
|
|||
|
|
id: true,
|
|||
|
|
name: true,
|
|||
|
|
slug: true,
|
|||
|
|
description: true,
|
|||
|
|
permissions: true,
|
|||
|
|
isSystem: true,
|
|||
|
|
isActive: true,
|
|||
|
|
createdAt: true,
|
|||
|
|
updatedAt: true,
|
|||
|
|
_count: {
|
|||
|
|
select: {
|
|||
|
|
users: {
|
|||
|
|
where: {
|
|||
|
|
deletedAt: null
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 同步远程数据到本地状态
|
|||
|
|
React.useEffect(() => {
|
|||
|
|
if (rolePagination?.items) {
|
|||
|
|
console.log('rolePagination', rolePagination);
|
|||
|
|
const rolesWithUserCount = rolePagination.items.map((role: any) => ({
|
|||
|
|
...role,
|
|||
|
|
userCount: role._count?.users || 0,
|
|||
|
|
_count: undefined, // 移除 _count 字段,避免混淆
|
|||
|
|
}));
|
|||
|
|
setRoles(rolesWithUserCount as RoleData[]);
|
|||
|
|
}
|
|||
|
|
}, [rolePagination]);
|
|||
|
|
|
|||
|
|
// 获取所有权限与分组(仅初始化时计算)
|
|||
|
|
const allPermissions = React.useMemo(() => Object.values(SystemPermission) as SystemPermission[], []);
|
|||
|
|
const permissionGroups = React.useMemo(() => Object.values(PermissionGroup) as PermissionGroup[], []);
|
|||
|
|
|
|||
|
|
// 根据搜索关键字过滤角色
|
|||
|
|
const filteredRoles = React.useMemo(
|
|||
|
|
() =>
|
|||
|
|
roles.filter(
|
|||
|
|
(role) =>
|
|||
|
|
role.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|||
|
|
role.description?.toLowerCase().includes(searchTerm.toLowerCase()),
|
|||
|
|
),
|
|||
|
|
[roles, searchTerm],
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 权限切换
|
|||
|
|
const handlePermissionToggle = (permission: SystemPermission) => {
|
|||
|
|
const newSelected = new Set(selectedPermissions);
|
|||
|
|
if (newSelected.has(permission)) {
|
|||
|
|
newSelected.delete(permission);
|
|||
|
|
} else {
|
|||
|
|
newSelected.add(permission);
|
|||
|
|
}
|
|||
|
|
setSelectedPermissions(newSelected);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Batch toggle permissions for a group
|
|||
|
|
const handleGroupPermissionToggle = (permissions: SystemPermission[], shouldSelect: boolean) => {
|
|||
|
|
setSelectedPermissions((prev) => {
|
|||
|
|
const newSelected = new Set(prev);
|
|||
|
|
if (shouldSelect) {
|
|||
|
|
permissions.forEach((p) => newSelected.add(p));
|
|||
|
|
} else {
|
|||
|
|
permissions.forEach((p) => newSelected.delete(p));
|
|||
|
|
}
|
|||
|
|
return newSelected;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 重置表单
|
|||
|
|
const resetForm = () => {
|
|||
|
|
setFormData({
|
|||
|
|
name: '',
|
|||
|
|
slug: '',
|
|||
|
|
description: '',
|
|||
|
|
isActive: true,
|
|||
|
|
});
|
|||
|
|
setSelectedPermissions(new Set());
|
|||
|
|
setEditingRole(null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 创建角色
|
|||
|
|
const handleCreateRole = () => {
|
|||
|
|
setShowCreateDialog(true);
|
|||
|
|
resetForm();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 编辑角色
|
|||
|
|
const handleEditRole = (role: RoleData) => {
|
|||
|
|
setEditingRole(role);
|
|||
|
|
setFormData({
|
|||
|
|
name: role.name,
|
|||
|
|
slug: role.slug,
|
|||
|
|
description: role.description || '',
|
|||
|
|
isActive: role.isActive,
|
|||
|
|
});
|
|||
|
|
setSelectedPermissions(new Set(role.permissions as SystemPermission[]));
|
|||
|
|
setShowEditDialog(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 删除角色
|
|||
|
|
const handleDeleteRole = (role: RoleData) => {
|
|||
|
|
if (role.isSystem) {
|
|||
|
|
toast.error('系统角色不能删除');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (confirm(`确定要删除角色"${role.name}"吗?`)) {
|
|||
|
|
roleMutations.softDeleteByIds.mutate(
|
|||
|
|
{ ids: [role.id] },
|
|||
|
|
{
|
|||
|
|
onSuccess: () => {
|
|||
|
|
toast.success('角色删除成功');
|
|||
|
|
},
|
|||
|
|
onError: (error) => {
|
|||
|
|
toast.error('删除失败: ' + error.message);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 保存角色
|
|||
|
|
const handleSaveRole = () => {
|
|||
|
|
if (!formData.name.trim()) {
|
|||
|
|
toast.error('请输入角色名称');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!formData.slug.trim()) {
|
|||
|
|
toast.error('请输入角色标识');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (selectedPermissions.size === 0) {
|
|||
|
|
toast.error('请至少选择一个权限');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const onSuccess = (result: any) => {
|
|||
|
|
// 根据操作类型更新本地列表,避免等待重新拉取
|
|||
|
|
setRoles((prev) => {
|
|||
|
|
if (editingRole) {
|
|||
|
|
return prev.map((r) => (r.id === (result?.id || editingRole.id) ? { ...r, ...result } : r));
|
|||
|
|
}
|
|||
|
|
return [...prev, result];
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
toast.success(editingRole ? '角色更新成功' : '角色创建成功');
|
|||
|
|
setShowCreateDialog(false);
|
|||
|
|
setShowEditDialog(false);
|
|||
|
|
resetForm();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onError = (error: any) => {
|
|||
|
|
toast.error('操作失败: ' + error.message);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (editingRole) {
|
|||
|
|
roleMutations.update.mutate(
|
|||
|
|
{
|
|||
|
|
where: { id: editingRole.id },
|
|||
|
|
data: {
|
|||
|
|
name: formData.name,
|
|||
|
|
slug: formData.slug,
|
|||
|
|
description: formData.description,
|
|||
|
|
permissions: Array.from(selectedPermissions),
|
|||
|
|
isActive: formData.isActive,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{ onSuccess, onError },
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
roleMutations.create.mutate(
|
|||
|
|
{
|
|||
|
|
data: {
|
|||
|
|
name: formData.name,
|
|||
|
|
slug: formData.slug,
|
|||
|
|
description: formData.description,
|
|||
|
|
permissions: Array.from(selectedPermissions),
|
|||
|
|
isActive: formData.isActive,
|
|||
|
|
isSystem: false,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{ onSuccess, onError },
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 后续逻辑在 onSuccess 内处理
|
|||
|
|
return;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 切换角色状态
|
|||
|
|
const handleToggleRoleStatus = (role: RoleData) => {
|
|||
|
|
if (role.isSystem && role.slug === 'super_admin') {
|
|||
|
|
toast.error('超级管理员角色不能禁用');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
roleMutations.update.mutate(
|
|||
|
|
{
|
|||
|
|
where: { id: role.id },
|
|||
|
|
data: { isActive: !role.isActive },
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
onSuccess: () => {
|
|||
|
|
toast.success(role.isActive ? '角色已禁用' : '角色已启用');
|
|||
|
|
},
|
|||
|
|
onError: (error) => {
|
|||
|
|
toast.error('操作失败: ' + error.message);
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="h-full flex flex-col">
|
|||
|
|
|
|||
|
|
{/* 工具栏 */}
|
|||
|
|
<div className="flex items-center justify-between gap-4 p-6 border-b">
|
|||
|
|
<div className="flex items-center gap-4 flex-1">
|
|||
|
|
<div className="relative max-w-md">
|
|||
|
|
<IconSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
|||
|
|
<Input
|
|||
|
|
placeholder="搜索角色名称或描述..."
|
|||
|
|
value={searchTerm}
|
|||
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|||
|
|
className="pl-10"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<Button onClick={handleCreateRole} className="flex items-center gap-2">
|
|||
|
|
<IconPlus className="h-4 w-4" />
|
|||
|
|
创建角色
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 角色列表 */}
|
|||
|
|
<div className="flex-1 p-6">
|
|||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
|||
|
|
{filteredRoles.map((role) => (
|
|||
|
|
<div key={role.id} className="border rounded-lg p-6 space-y-4 hover:shadow-md transition-shadow">
|
|||
|
|
{/* 角色头部 */}
|
|||
|
|
<div className="flex items-start justify-between">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/20 rounded-lg">
|
|||
|
|
<IconUserShield className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<div className="font-semibold text-lg flex items-center gap-2">
|
|||
|
|
{role.name}
|
|||
|
|
{role.isSystem && (
|
|||
|
|
<Badge variant="outline" className="text-xs">
|
|||
|
|
系统
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
{!role.isActive && (
|
|||
|
|
<Badge variant="destructive" className="text-xs">
|
|||
|
|
已禁用
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">/{role.slug}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<DropdownMenu>
|
|||
|
|
<DropdownMenuTrigger asChild>
|
|||
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
|||
|
|
<IconSettings className="h-4 w-4" />
|
|||
|
|
</Button>
|
|||
|
|
</DropdownMenuTrigger>
|
|||
|
|
<DropdownMenuContent align="end">
|
|||
|
|
<DropdownMenuItem onClick={() => handleEditRole(role)} className="cursor-pointer">
|
|||
|
|
<IconEdit className="mr-2 h-4 w-4" />
|
|||
|
|
编辑角色
|
|||
|
|
</DropdownMenuItem>
|
|||
|
|
<DropdownMenuItem onClick={() => handleToggleRoleStatus(role)} className="cursor-pointer">
|
|||
|
|
{role.isActive ? (
|
|||
|
|
<>
|
|||
|
|
<IconX className="mr-2 h-4 w-4" />
|
|||
|
|
禁用角色
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<IconCheck className="mr-2 h-4 w-4" />
|
|||
|
|
启用角色
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</DropdownMenuItem>
|
|||
|
|
{!role.isSystem && (
|
|||
|
|
<>
|
|||
|
|
<DropdownMenuSeparator />
|
|||
|
|
<DropdownMenuItem
|
|||
|
|
onClick={() => handleDeleteRole(role)}
|
|||
|
|
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
|
|||
|
|
>
|
|||
|
|
<IconTrash className="mr-2 h-4 w-4" />
|
|||
|
|
删除角色
|
|||
|
|
</DropdownMenuItem>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</DropdownMenuContent>
|
|||
|
|
</DropdownMenu>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 角色描述 */}
|
|||
|
|
{role.description && <p className="text-sm text-gray-600 dark:text-gray-300">{role.description}</p>}
|
|||
|
|
|
|||
|
|
{/* 统计信息 */}
|
|||
|
|
<div className="flex items-center justify-between text-sm">
|
|||
|
|
<div className="flex items-center gap-4">
|
|||
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|||
|
|
用户数: <span className="font-medium text-gray-900 dark:text-gray-100">{role.userCount || 0}</span>
|
|||
|
|
</span>
|
|||
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|||
|
|
权限数:{' '}
|
|||
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">{role.permissions.length}</span>
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 权限预览 */}
|
|||
|
|
<div>
|
|||
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">权限预览</div>
|
|||
|
|
<div className="flex flex-wrap gap-1">
|
|||
|
|
{role.permissions.slice(0, 6).map((permission) => {
|
|||
|
|
const meta = SystemPermissionMeta[permission];
|
|||
|
|
return (
|
|||
|
|
<Badge key={permission} variant="outline" className="text-xs">
|
|||
|
|
{meta?.name || permission}
|
|||
|
|
</Badge>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
{role.permissions.length > 6 && (
|
|||
|
|
<Badge variant="outline" className="text-xs">
|
|||
|
|
+{role.permissions.length - 6}
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{filteredRoles.length === 0 && (
|
|||
|
|
<div className="text-center py-12">
|
|||
|
|
<div className="text-gray-500 dark:text-gray-400">{searchTerm ? '未找到匹配的角色' : '暂无角色数据'}</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 创建角色对话框 */}
|
|||
|
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
|||
|
|
<DialogContent className="w-full max-h-[90vh] flex flex-col sm:max-w-2xl md:max-w-4xl lg:max-w-5xl xl:max-w-screen-xl">
|
|||
|
|
<DialogHeader className="shrink-0 pb-6">
|
|||
|
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
|||
|
|
<IconUserShield className="h-5 w-5 text-blue-600" />
|
|||
|
|
创建新角色
|
|||
|
|
</DialogTitle>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
<ScrollArea className="flex-1 -mx-6 px-6">
|
|||
|
|
{/* 卡片采用两列布局:基本信息左,权限配置右;在小屏依旧垂直 */}
|
|||
|
|
<div className="flex flex-col lg:flex-row gap-6 py-2">
|
|||
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-6 flex-1 lg:w-1/2">
|
|||
|
|
<div className="flex items-center gap-2 mb-4">
|
|||
|
|
<IconEdit className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
|||
|
|
<h3 className="font-semibold text-base">基本信息</h3>
|
|||
|
|
</div>
|
|||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label htmlFor="name" className="text-sm font-medium">
|
|||
|
|
角色名称 <span className="text-red-500">*</span>
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="name"
|
|||
|
|
value={formData.name}
|
|||
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
|||
|
|
placeholder="如:内容编辑"
|
|||
|
|
className="bg-white dark:bg-gray-800"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label htmlFor="slug" className="text-sm font-medium">
|
|||
|
|
角色标识 <span className="text-red-500">*</span>
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="slug"
|
|||
|
|
value={formData.slug}
|
|||
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
|||
|
|
placeholder="如:content_editor"
|
|||
|
|
className="bg-white dark:bg-gray-800"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="lg:col-span-2 space-y-2">
|
|||
|
|
<Label htmlFor="description" className="text-sm font-medium">
|
|||
|
|
角色描述
|
|||
|
|
</Label>
|
|||
|
|
<Textarea
|
|||
|
|
id="description"
|
|||
|
|
value={formData.description}
|
|||
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
|||
|
|
placeholder="描述这个角色的职责和用途..."
|
|||
|
|
rows={2}
|
|||
|
|
className="bg-white dark:bg-gray-800"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border">
|
|||
|
|
<div>
|
|||
|
|
<Label htmlFor="isActive" className="text-sm font-medium">
|
|||
|
|
启用角色
|
|||
|
|
</Label>
|
|||
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">角色创建后是否立即生效</p>
|
|||
|
|
</div>
|
|||
|
|
<Switch
|
|||
|
|
id="isActive"
|
|||
|
|
checked={formData.isActive}
|
|||
|
|
onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, isActive: checked }))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-6 flex-1 lg:w-1/2">
|
|||
|
|
<div className="flex items-center justify-between mb-4">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<IconShield className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
|||
|
|
<h3 className="font-semibold text-base">权限配置</h3>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-4">
|
|||
|
|
<div className="flex items-center gap-2 text-sm">
|
|||
|
|
<span className="text-gray-500 dark:text-gray-400">已选择:</span>
|
|||
|
|
<Badge variant="secondary" className="font-medium">
|
|||
|
|
{selectedPermissions.size} / {allPermissions.length}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => setSelectedPermissions(new Set(allPermissions))}
|
|||
|
|
>
|
|||
|
|
全选
|
|||
|
|
</Button>
|
|||
|
|
<Button variant="outline" size="sm" onClick={() => setSelectedPermissions(new Set())}>
|
|||
|
|
清空
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border max-h-80 overflow-y-auto">
|
|||
|
|
<div className="p-4 space-y-3">
|
|||
|
|
{permissionGroups.map((group) => (
|
|||
|
|
<PermissionGroupComponent
|
|||
|
|
key={group}
|
|||
|
|
group={group}
|
|||
|
|
permissions={allPermissions}
|
|||
|
|
selectedPermissions={selectedPermissions}
|
|||
|
|
onPermissionToggle={handlePermissionToggle}
|
|||
|
|
onGroupToggle={handleGroupPermissionToggle}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
|
|||
|
|
<div className="flex justify-between items-center gap-3 mt-6 pt-6 border-t shrink-0">
|
|||
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|||
|
|
{selectedPermissions.size === 0 && '请至少选择一个权限'}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-3">
|
|||
|
|
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
|||
|
|
取消
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
onClick={handleSaveRole}
|
|||
|
|
disabled={!formData.name.trim() || !formData.slug.trim() || selectedPermissions.size === 0}
|
|||
|
|
>
|
|||
|
|
<IconPlus className="h-4 w-4 mr-2" />
|
|||
|
|
创建角色
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
|
|||
|
|
{/* 编辑角色对话框 */}
|
|||
|
|
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
|||
|
|
<DialogContent className="w-full max-h-[90vh] flex flex-col sm:max-w-2xl md:max-w-4xl lg:max-w-5xl xl:max-w-screen-xl">
|
|||
|
|
<DialogHeader className="shrink-0 pb-6">
|
|||
|
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
|||
|
|
<IconEdit className="h-5 w-5 text-blue-600" />
|
|||
|
|
编辑角色
|
|||
|
|
{editingRole?.isSystem && (
|
|||
|
|
<Badge variant="outline" className="ml-2 text-xs">
|
|||
|
|
系统角色
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</DialogTitle>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
<ScrollArea className="flex-1 -mx-6 px-6">
|
|||
|
|
{/* 卡片采用两列布局:基本信息左,权限配置右;在小屏依旧垂直 */}
|
|||
|
|
<div className="flex flex-col lg:flex-row gap-6 py-2">
|
|||
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-6 flex-1 lg:w-1/2">
|
|||
|
|
<div className="flex items-center gap-2 mb-4">
|
|||
|
|
<IconEdit className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
|||
|
|
<h3 className="font-semibold text-base">基本信息</h3>
|
|||
|
|
{editingRole?.isSystem && (
|
|||
|
|
<Badge variant="secondary" className="text-xs">
|
|||
|
|
系统角色,部分信息不可修改
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label htmlFor="edit-name" className="text-sm font-medium">
|
|||
|
|
角色名称 <span className="text-red-500">*</span>
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="edit-name"
|
|||
|
|
value={formData.name}
|
|||
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
|||
|
|
placeholder="如:内容编辑"
|
|||
|
|
disabled={editingRole?.isSystem}
|
|||
|
|
className="bg-white dark:bg-gray-800 disabled:opacity-60"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label htmlFor="edit-slug" className="text-sm font-medium">
|
|||
|
|
角色标识 <span className="text-red-500">*</span>
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="edit-slug"
|
|||
|
|
value={formData.slug}
|
|||
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
|||
|
|
placeholder="如:content_editor"
|
|||
|
|
disabled={editingRole?.isSystem}
|
|||
|
|
className="bg-white dark:bg-gray-800 disabled:opacity-60"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="lg:col-span-2 space-y-2">
|
|||
|
|
<Label htmlFor="edit-description" className="text-sm font-medium">
|
|||
|
|
角色描述
|
|||
|
|
</Label>
|
|||
|
|
<Textarea
|
|||
|
|
id="edit-description"
|
|||
|
|
value={formData.description}
|
|||
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
|||
|
|
placeholder="描述这个角色的职责和用途..."
|
|||
|
|
rows={2}
|
|||
|
|
className="bg-white dark:bg-gray-800"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border">
|
|||
|
|
<div>
|
|||
|
|
<Label htmlFor="edit-isActive" className="text-sm font-medium">
|
|||
|
|
启用角色
|
|||
|
|
</Label>
|
|||
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|||
|
|
{editingRole?.slug === 'super_admin' ? '超级管理员角色无法禁用' : '控制角色是否生效'}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<Switch
|
|||
|
|
id="edit-isActive"
|
|||
|
|
checked={formData.isActive}
|
|||
|
|
onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, isActive: checked }))}
|
|||
|
|
disabled={editingRole?.slug === 'super_admin'}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-6 flex-1 lg:w-1/2">
|
|||
|
|
<div className="flex items-center justify-between mb-4">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<IconShield className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
|||
|
|
<h3 className="font-semibold text-base">权限配置</h3>
|
|||
|
|
{editingRole?.slug === 'super_admin' && (
|
|||
|
|
<Badge variant="destructive" className="text-xs">
|
|||
|
|
超级管理员权限不可修改
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-4">
|
|||
|
|
<div className="flex items-center gap-2 text-sm">
|
|||
|
|
<span className="text-gray-500 dark:text-gray-400">已选择:</span>
|
|||
|
|
<Badge variant="secondary" className="font-medium">
|
|||
|
|
{selectedPermissions.size} / {allPermissions.length}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
{editingRole?.slug !== 'super_admin' && (
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => setSelectedPermissions(new Set(allPermissions))}
|
|||
|
|
>
|
|||
|
|
全选
|
|||
|
|
</Button>
|
|||
|
|
<Button variant="outline" size="sm" onClick={() => setSelectedPermissions(new Set())}>
|
|||
|
|
清空
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border max-h-80 overflow-y-auto">
|
|||
|
|
<div className="p-4 space-y-3">
|
|||
|
|
{permissionGroups.map((group) => (
|
|||
|
|
<PermissionGroupComponent
|
|||
|
|
key={group}
|
|||
|
|
group={group}
|
|||
|
|
permissions={allPermissions}
|
|||
|
|
selectedPermissions={selectedPermissions}
|
|||
|
|
onPermissionToggle={handlePermissionToggle}
|
|||
|
|
disabled={editingRole?.slug === 'super_admin'}
|
|||
|
|
onGroupToggle={handleGroupPermissionToggle}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</ScrollArea>
|
|||
|
|
|
|||
|
|
<div className="flex justify-between items-center gap-3 mt-6 pt-6 border-t shrink-0">
|
|||
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|||
|
|
{selectedPermissions.size === 0 && '请至少选择一个权限'}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-3">
|
|||
|
|
<Button variant="outline" onClick={() => setShowEditDialog(false)}>
|
|||
|
|
取消
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
onClick={handleSaveRole}
|
|||
|
|
disabled={!formData.name.trim() || !formData.slug.trim() || selectedPermissions.size === 0}
|
|||
|
|
>
|
|||
|
|
<IconCheck className="h-4 w-4 mr-2" />
|
|||
|
|
保存更改
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|