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

456 lines
13 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'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>
);
}