casualroom/apps/fenghuo/web/components/organization/users-data-table.tsx

456 lines
13 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@nice/ui/components/avatar';
import { Badge } from '@nice/ui/components/badge';
import { Button } from '@nice/ui/components/button';
import { Checkbox } from '@nice/ui/components/checkbox';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@nice/ui/components/dropdown-menu';
import { Input } from '@nice/ui/components/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@nice/ui/components/table';
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import {
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconDots,
IconEdit,
IconEye,
IconSearch,
IconTrash,
} from '@tabler/icons-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@nice/ui/components/select';
import { Label } from '@nice/ui/components/label';
import { useUser } from '@fenghuo/client';
import { toast } from '@nice/ui/components/sonner';
import { Organization } from '@fenghuo/db';
import { UserWithRelations } from '@fenghuo/common/user';
// 分页信息类型
export interface PaginationInfo {
totalPages: number;
totalCount: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
// 角色颜色映射
const roleColors: Record<string, string> = {
admin: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
user: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
editor: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
viewer: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300',
manager: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
};
// 创建列定义的工厂函数
const createColumns = (router: ReturnType<typeof useRouter>): ColumnDef<UserWithRelations>[] => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="全选"
className="h-4 w-4"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="选择行"
className="h-4 w-4"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'username',
header: '用户名',
cell: ({ row }) => (
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{row.original.username.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5">
<span className="font-medium text-sm">{row.original.username}</span>
</div>
</div>
),
},
{
accessorKey: 'organization',
header: '部门',
cell: ({ row }) => {
console.log(row)
return <span className="text-sm">{row.original.organization.name}</span>;
},
},
{
accessorKey: 'role',
header: '角色',
cell: ({ row }) => {
const user = row.original;
// 获取所有角色信息
const userRoles = user.roles || [];
// 如果没有角色信息,显示默认信息
if (!userRoles || userRoles.length === 0) {
return (
<Badge
variant="secondary"
className="text-xs px-2 py-0.5 bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300"
>
</Badge>
);
}
// 显示所有角色标签
return (
<div className="flex flex-wrap gap-1">
{userRoles.map((roleInfo, index: number) => {
const roleName = roleInfo.name;
const roleSlug = roleInfo.slug || roleName.toLowerCase();
return (
<Badge
key={roleInfo.id || index}
variant="secondary"
className={`text-xs px-2 py-0.5 ${roleColors[roleSlug] || roleColors.user}`}
>
{roleName}
</Badge>
);
})}
</div>
);
},
},
{
id: 'actions',
header: '操作',
cell: ({ row }) => {
const user = row.original;
const handleViewUser = () => {
router.push(`/dashboard/user/${user.id}`);
};
const handleEditUser = () => {
router.push(`/dashboard/user/${user.id}`);
};
const userMutations = useUser();
const handleDeleteUser = (userIds: string[]) => {
userMutations.softDeleteByIds.mutate(
{
ids: userIds,
},
{
onSuccess: () => {
toast.success('删除成功');
},
onError: (error) => {
toast.error(`删除失败: ${error.message}`);
},
},
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-7 w-7 p-0 cursor-pointer">
<span className="sr-only cursor-pointer"></span>
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleViewUser} className="cursor-pointer">
<IconEye className="mr-2 h-4 w-4 cursor-pointer" />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleEditUser} className="cursor-pointer">
<IconEdit className="mr-2 h-4 w-4 cursor-pointer" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteUser([user.id])}
className="text-red-600 focus:text-red-600 cursor-pointer"
>
<IconTrash className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
enableSorting: false,
enableHiding: false,
},
];
interface UsersDataTableProps {
data: UserWithRelations[];
organizations?: Organization[]; // 新增:部门数据
onAddUser?: () => void;
// 新增:服务端分页和筛选相关的 props
pagination: {
page: number;
pageSize: number;
};
paginationInfo: PaginationInfo;
onPaginationChange: (pagination: { page: number; pageSize: number }) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
selectedRole: string;
onRoleChange: (role: string) => void;
selectedOrganizationId: string;
onOrganizationChange: (organizationId: string) => void;
isLoading?: boolean;
availableRoles: Array<{ value: string; label: string }>;
}
export function UsersDataTable({
data,
pagination,
paginationInfo,
onPaginationChange,
searchQuery,
onSearchChange,
selectedRole,
onRoleChange,
isLoading = false,
availableRoles,
}: UsersDataTableProps) {
const router = useRouter();
const columns = React.useMemo(() => createColumns(router), [router]);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
// 使用 React Table但移除客户端分页逻辑
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
// 移除分页相关的配置,使用服务端分页
manualPagination: true,
pageCount: paginationInfo.totalPages,
// 添加 getRowId 函数,确保使用正确的 ID
getRowId: (row) => row.id,
state: {
sorting,
columnVisibility,
rowSelection,
pagination: {
pageIndex: pagination.page - 1, // React Table 使用 0-based index
pageSize: pagination.pageSize,
},
},
});
// 处理搜索输入的防抖
const [searchInputValue, setSearchInputValue] = React.useState(searchQuery);
React.useEffect(() => {
setSearchInputValue(searchQuery);
}, [searchQuery]);
React.useEffect(() => {
const timer = setTimeout(() => {
if (searchInputValue !== searchQuery) {
onSearchChange(searchInputValue);
}
}, 300); // 300ms 防抖
return () => clearTimeout(timer);
}, [searchInputValue, searchQuery, onSearchChange]);
// 当数据变化时清理选择状态(分页、搜索、筛选等)
React.useEffect(() => {
setRowSelection({});
}, [pagination.page, searchQuery, selectedRole]);
return (
<div className="w-full">
{/* 工具栏 */}
<div className="flex items-center justify-between pb-3">
<div className="flex items-center space-x-2">
{/* 搜索框 */}
<div className="relative">
<IconSearch className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索用户名..."
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
className="pl-8 max-w-sm h-8 text-sm"
/>
</div>
{/* 角色筛选 */}
<Select value={selectedRole || 'all'} onValueChange={(value) => onRoleChange(value === 'all' ? '' : value)}>
<SelectTrigger className="w-[120px] h-8 text-sm">
<SelectValue placeholder="筛选角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{availableRoles.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 表格 */}
<div className="rounded-md border border-border">
<Table>
<TableHeader className='bg-muted'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="h-10">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="py-2 text-sm font-medium">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-16 text-center">
<div className="animate-pulse">...</div>
</TableCell>
</TableRow>
) : data?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-12" data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-2">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-16 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页控制 */}
<div className="flex items-center justify-between space-x-2 py-3">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} {paginationInfo.totalCount}
</div>
<div className="flex items-center space-x-4 lg:space-x-6">
<div className="flex items-center space-x-2">
<Label htmlFor="page-size" className="text-sm font-medium">
</Label>
<Select
value={`${pagination.pageSize}`}
onValueChange={(value) => {
onPaginationChange({
page: 1, // 重置到第一页
pageSize: Number(value),
});
}}
>
<SelectTrigger className="h-7 w-[70px] text-sm" id="page-size">
<SelectValue placeholder={pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 15, 20, 30, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
{pagination.page} {paginationInfo.totalPages}
</div>
<div className="flex items-center space-x-1">
<Button
variant="outline"
className="hidden h-7 w-7 p-0 lg:flex"
onClick={() => onPaginationChange({ ...pagination, page: 1 })}
disabled={!paginationInfo.hasPreviousPage || isLoading}
>
<span className="sr-only"></span>
<IconChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-7 w-7 p-0"
onClick={() => onPaginationChange({ ...pagination, page: pagination.page - 1 })}
disabled={!paginationInfo.hasPreviousPage || isLoading}
>
<span className="sr-only"></span>
<IconChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-7 w-7 p-0"
onClick={() => onPaginationChange({ ...pagination, page: pagination.page + 1 })}
disabled={!paginationInfo.hasNextPage || isLoading}
>
<span className="sr-only"></span>
<IconChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-7 w-7 p-0 lg:flex"
onClick={() => onPaginationChange({ ...pagination, page: paginationInfo.totalPages })}
disabled={!paginationInfo.hasNextPage || isLoading}
>
<span className="sr-only"></span>
<IconChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}