feat(web): 实现角色管理功能

- 添加角色列表页面和管理组件
- 实现角色 CRUD 操作
- 支持角色权限和菜单分配
- 添加角色表单和权限选择器

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-17 14:06:34 +08:00
parent 3cfc6bf15c
commit 213a19ee7f
6 changed files with 839 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
'use client';
import { RolesTable } from '@/components/roles/RolesTable';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function RolesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<RolesTable />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,347 @@
'use client';
import type { RoleResponse, PermissionResponse } from '@seclusion/shared';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { useAllPermissions, useCreateRole, useRole, useUpdateRole } from '@/hooks/useRoles';
interface RoleEditDialogProps {
role: RoleResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface RoleFormValues {
code: string;
name: string;
description: string;
isEnabled: boolean;
sort: number;
permissionIds: string[];
}
export function RoleEditDialog({ role, open, onOpenChange }: RoleEditDialogProps) {
const isEditing = !!role;
const [activeTab, setActiveTab] = useState('basic');
// 获取角色详情(包含权限)
const { data: roleDetail, isLoading: isLoadingRole } = useRole(role?.id || '');
// 获取所有权限
const { data: permissions = [], isLoading: isLoadingPermissions } = useAllPermissions();
// 创建/更新
const createRole = useCreateRole();
const updateRole = useUpdateRole();
const form = useForm<RoleFormValues>({
defaultValues: {
code: '',
name: '',
description: '',
isEnabled: true,
sort: 0,
permissionIds: [],
},
});
// 当对话框打开或角色数据变化时,重置表单
useEffect(() => {
if (open) {
if (roleDetail) {
form.reset({
code: roleDetail.code,
name: roleDetail.name,
description: roleDetail.description || '',
isEnabled: roleDetail.isEnabled,
sort: roleDetail.sort,
permissionIds: roleDetail.permissions?.map((p) => p.id) || [],
});
} else if (!role) {
form.reset({
code: '',
name: '',
description: '',
isEnabled: true,
sort: 0,
permissionIds: [],
});
}
setActiveTab('basic');
}
}, [open, roleDetail, role, form]);
const onSubmit = async (values: RoleFormValues) => {
try {
if (isEditing && role) {
await updateRole.mutateAsync({
id: role.id,
data: {
name: values.name,
description: values.description || undefined,
isEnabled: values.isEnabled,
sort: values.sort,
permissionIds: values.permissionIds,
},
});
toast.success('角色更新成功');
} else {
await createRole.mutateAsync({
code: values.code,
name: values.name,
description: values.description || undefined,
isEnabled: values.isEnabled,
sort: values.sort,
permissionIds: values.permissionIds,
});
toast.success('角色创建成功');
}
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : '操作失败');
}
};
// 按资源分组权限
const groupedPermissions = permissions.reduce<Record<string, PermissionResponse[]>>(
(acc, permission) => {
const resource = permission.resource;
if (!acc[resource]) {
acc[resource] = [];
}
acc[resource].push(permission);
return acc;
},
{}
);
const isLoading = isLoadingRole || isLoadingPermissions;
const isMutating = createRole.isPending || updateRole.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEditing ? '编辑角色' : '新建角色'}</DialogTitle>
<DialogDescription>
{isEditing ? '修改角色信息和权限配置。' : '创建新角色并配置权限。'}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">...</div>
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="permissions"></TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4 mt-4">
<FormField
control={form.control}
name="code"
rules={{
required: '请输入角色编码',
pattern: {
value: /^[a-z][a-z0-9_]*$/,
message: '编码只能包含小写字母、数字和下划线,且以字母开头',
},
}}
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
placeholder="如manager"
{...field}
disabled={isEditing || role?.isSystem}
/>
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
rules={{ required: '请输入角色名称' }}
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="如:项目经理" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="角色描述(可选)"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="sort"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel></FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="permissions" className="space-y-4 mt-4">
<div className="text-sm text-muted-foreground mb-4">
</div>
<FormField
control={form.control}
name="permissionIds"
render={({ field }) => (
<FormItem>
<div className="space-y-4">
{Object.entries(groupedPermissions).map(([resource, perms]) => (
<div key={resource} className="rounded-lg border p-4">
<div className="font-medium mb-3 capitalize">{resource}</div>
<div className="grid grid-cols-2 gap-2">
{perms.map((permission) => (
<div
key={permission.id}
className="flex items-center space-x-2"
>
<Checkbox
id={permission.id}
checked={field.value.includes(permission.id)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...field.value, permission.id]);
} else {
field.onChange(
field.value.filter((id) => id !== permission.id)
);
}
}}
/>
<label
htmlFor={permission.id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{permission.name}
<span className="ml-2 text-xs text-muted-foreground">
({permission.code})
</span>
</label>
</div>
))}
</div>
</div>
))}
{Object.keys(groupedPermissions).length === 0 && (
<div className="text-center text-muted-foreground py-8">
</div>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isMutating}
>
</Button>
<Button type="submit" disabled={isMutating}>
{isMutating ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,312 @@
'use client';
import type { RoleResponse } from '@seclusion/shared';
import { formatDate } from '@seclusion/shared';
import type { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Trash2, Pencil, Plus, Shield } from 'lucide-react';
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { RoleEditDialog } from './RoleEditDialog';
import {
DataTable,
DataTableColumnHeader,
type PaginationState,
type SortingParams,
type SearchConfig,
type SearchParams,
} from '@/components/shared/DataTable';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PAGINATION } from '@/config/constants';
import { useRoles, useDeleteRole } from '@/hooks/useRoles';
interface RoleActionsProps {
role: RoleResponse;
onEdit: (role: RoleResponse) => void;
onDelete: (id: string) => void;
}
function RoleActions({ role, onEdit, onDelete }: RoleActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only"></span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onEdit(role)}>
<Pencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
{!role.isSystem && (
<DropdownMenuItem
onClick={() => onDelete(role.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function RolesTable() {
// 分页状态
const [pagination, setPagination] = useState<PaginationState>({
page: PAGINATION.DEFAULT_PAGE,
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
});
// 排序状态
const [sorting, setSorting] = useState<SortingParams>({});
// 搜索状态
const [searchParams, setSearchParams] = useState<SearchParams>({});
// 对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [roleToDelete, setRoleToDelete] = useState<string | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [roleToEdit, setRoleToEdit] = useState<RoleResponse | null>(null);
// 查询
const { data: rolesData, isLoading, refetch } = useRoles({
page: pagination.page,
pageSize: pagination.pageSize,
search: typeof searchParams.search === 'string' ? searchParams.search : undefined,
...sorting,
});
// 变更
const deleteRole = useDeleteRole();
const handleDelete = useCallback((id: string) => {
setRoleToDelete(id);
setDeleteDialogOpen(true);
}, []);
const confirmDelete = useCallback(async () => {
if (!roleToDelete) return;
try {
await deleteRole.mutateAsync(roleToDelete);
toast.success('角色已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败');
} finally {
setDeleteDialogOpen(false);
setRoleToDelete(null);
}
}, [roleToDelete, deleteRole]);
const handleEdit = useCallback((role: RoleResponse) => {
setRoleToEdit(role);
setEditDialogOpen(true);
}, []);
const handleCreate = useCallback(() => {
setRoleToEdit(null);
setEditDialogOpen(true);
}, []);
// 分页变化处理
const handlePaginationChange = useCallback((newPagination: PaginationState) => {
setPagination(newPagination);
}, []);
// 排序变化处理
const handleSortingChange = useCallback((newSorting: SortingParams) => {
setSorting(newSorting);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 搜索变化处理
const handleSearchChange = useCallback((params: SearchParams) => {
setSearchParams(params);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 搜索配置
const searchConfig: SearchConfig<RoleResponse> = {
fields: [
{
type: 'text',
key: 'search',
label: '关键词',
placeholder: '搜索角色编码或名称',
colSpan: 2,
},
],
columns: 4,
};
// 列定义
const columns: ColumnDef<RoleResponse>[] = [
{
accessorKey: 'code',
header: ({ column }) => (
<DataTableColumnHeader
title="角色编码"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<code className="text-sm">{row.original.code}</code>
</div>
),
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader
title="角色名称"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
},
{
accessorKey: 'description',
header: '描述',
cell: ({ row }) => row.original.description || '-',
},
{
accessorKey: 'isSystem',
header: '类型',
cell: ({ row }) => (
<Badge variant={row.original.isSystem ? 'secondary' : 'outline'}>
{row.original.isSystem ? '系统内置' : '自定义'}
</Badge>
),
},
{
accessorKey: 'isEnabled',
header: '状态',
cell: ({ row }) => (
<Badge variant={row.original.isEnabled ? 'default' : 'secondary'}>
{row.original.isEnabled ? '启用' : '禁用'}
</Badge>
),
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader
title="创建时间"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) =>
formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
},
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<RoleActions
role={row.original}
onEdit={handleEdit}
onDelete={handleDelete}
/>
),
},
];
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
</Button>
</div>
{/* 数据表格 */}
<DataTable
columns={columns}
data={rolesData?.items ?? []}
pagination={pagination}
paginationInfo={
rolesData ? { total: rolesData.total, totalPages: rolesData.totalPages } : undefined
}
onPaginationChange={handlePaginationChange}
manualPagination
sorting={sorting}
onSortingChange={handleSortingChange}
manualSorting
searchConfig={searchConfig}
searchParams={searchParams}
onSearchChange={handleSearchChange}
isLoading={isLoading}
emptyMessage="暂无角色"
/>
{/* 删除确认弹窗 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 编辑/新建角色弹窗 */}
<RoleEditDialog
role={roleToEdit}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { RolesTable } from './RolesTable';
export { RoleEditDialog } from './RoleEditDialog';

View File

@@ -0,0 +1,90 @@
import type { CreateRoleDto, UpdateRoleDto } from '@seclusion/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { roleService, type GetRolesParams } from '@/services/role.service';
import { useIsAuthenticated } from '@/stores';
// Query Keys
export const roleKeys = {
all: ['roles'] as const,
lists: () => [...roleKeys.all, 'list'] as const,
list: (params: GetRolesParams) => [...roleKeys.lists(), params] as const,
details: () => [...roleKeys.all, 'detail'] as const,
detail: (id: string) => [...roleKeys.details(), id] as const,
};
export const permissionKeys = {
all: ['permissions'] as const,
list: () => [...permissionKeys.all, 'list'] as const,
};
// 获取角色列表
export function useRoles(params: GetRolesParams = {}) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: roleKeys.list(params),
queryFn: () => roleService.getRoles(params),
enabled: isAuthenticated,
});
}
// 获取单个角色详情
export function useRole(id: string) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: roleKeys.detail(id),
queryFn: () => roleService.getRole(id),
enabled: isAuthenticated && !!id,
});
}
// 获取所有权限
export function useAllPermissions() {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: permissionKeys.list(),
queryFn: () => roleService.getAllPermissions(),
enabled: isAuthenticated,
});
}
// 创建角色
export function useCreateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateRoleDto) => roleService.createRole(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roleKeys.lists() });
},
});
}
// 更新角色
export function useUpdateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateRoleDto }) =>
roleService.updateRole(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: roleKeys.lists() });
queryClient.invalidateQueries({ queryKey: roleKeys.detail(id) });
},
});
}
// 删除角色
export function useDeleteRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => roleService.deleteRole(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roleKeys.lists() });
},
});
}

View File

@@ -0,0 +1,55 @@
import type {
CreateRoleDto,
PaginatedResponse,
PermissionResponse,
RoleDetailResponse,
RoleResponse,
UpdateRoleDto,
} from '@seclusion/shared';
import { API_ENDPOINTS } from '@/config/constants';
import { http } from '@/lib/http';
export interface GetRolesParams {
page?: number;
pageSize?: number;
search?: string;
}
export const roleService = {
// 获取角色列表
getRoles: (params: GetRolesParams = {}): Promise<PaginatedResponse<RoleResponse>> => {
return http.get<PaginatedResponse<RoleResponse>>(API_ENDPOINTS.ROLES, {
params,
});
},
// 获取单个角色详情(包含权限和菜单)
getRole: (id: string): Promise<RoleDetailResponse> => {
return http.get<RoleDetailResponse>(`${API_ENDPOINTS.ROLES}/${id}`);
},
// 创建角色
createRole: (data: CreateRoleDto): Promise<RoleResponse> => {
return http.post<RoleResponse>(API_ENDPOINTS.ROLES, data);
},
// 更新角色
updateRole: (id: string, data: UpdateRoleDto): Promise<RoleResponse> => {
return http.patch<RoleResponse>(`${API_ENDPOINTS.ROLES}/${id}`, data);
},
// 删除角色
deleteRole: (id: string): Promise<void> => {
return http.delete<void>(`${API_ENDPOINTS.ROLES}/${id}`);
},
// 获取所有权限列表(用于分配权限,获取全部不分页)
getAllPermissions: async (): Promise<PermissionResponse[]> => {
const response = await http.get<PaginatedResponse<PermissionResponse>>(
API_ENDPOINTS.PERMISSIONS,
{ params: { pageSize: 1000 } }
);
return response.items;
},
};