Files
xingrin/frontend/components/target/targets-data-table.tsx
2025-12-22 12:06:38 +08:00

437 lines
14 KiB
TypeScript
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 {
ColumnDef,
ColumnFiltersState,
ColumnSizingState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconLayoutColumns,
IconPlus,
IconTrash,
IconSearch,
IconLoader2,
} from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import type { Target } from "@/types/target.types"
interface TargetsDataTableProps {
data: Target[]
columns: ColumnDef<Target>[]
onAddNew?: () => void
onAddHover?: () => void
onBulkDelete?: () => void
onSelectionChange?: (selectedRows: Target[]) => void
searchPlaceholder?: string
searchColumn?: string
searchValue?: string
onSearch?: (value: string) => void
isSearching?: boolean
addButtonText?: string
// 分页相关属性
pagination?: { pageIndex: number, pageSize: number }
onPaginationChange?: (pagination: { pageIndex: number, pageSize: number }) => void
totalCount?: number
manualPagination?: boolean
}
/**
* 目标数据表格组件
* 专门用于显示和管理目标数据的表格
* 包含搜索、分页、列显示控制等功能
*/
export function TargetsDataTable({
data = [],
columns,
onAddNew,
onAddHover,
onBulkDelete,
onSelectionChange,
searchPlaceholder = "搜索目标名称...",
searchColumn = "name",
searchValue,
onSearch,
isSearching = false,
addButtonText = "添加目标",
pagination: externalPagination,
onPaginationChange,
totalCount,
manualPagination = false,
}: TargetsDataTableProps) {
const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({})
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({})
// 使用外部分页状态或内部状态
const [internalPagination, setInternalPagination] = React.useState<{ pageIndex: number, pageSize: number }>({
pageIndex: 0,
pageSize: 10,
})
// 本地搜索输入状态(只在回车或点击按钮时触发搜索)
const [localSearchValue, setLocalSearchValue] = React.useState(searchValue ?? "")
React.useEffect(() => {
setLocalSearchValue(searchValue ?? "")
}, [searchValue])
const handleSearchSubmit = () => {
if (onSearch) {
onSearch(localSearchValue)
} else {
table.getColumn(searchColumn)?.setFilterValue(localSearchValue)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearchSubmit()
}
}
const pagination = externalPagination || internalPagination
// 处理分页状态变化
const handlePaginationChange = React.useCallback((updaterOrValue: any) => {
if (onPaginationChange) {
// 如果是函数,先计算新值
const newPagination = typeof updaterOrValue === 'function'
? updaterOrValue(pagination)
: updaterOrValue
onPaginationChange(newPagination)
} else {
// 使用内部状态
setInternalPagination(updaterOrValue)
}
}, [onPaginationChange, pagination])
const validData = React.useMemo(() => {
const filtered = (data || []).filter(item => item && typeof item.id !== 'undefined' && item.id !== null)
return filtered
}, [data])
const table = useReactTable({
data: validData,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
columnSizing,
},
enableColumnResizing: true,
columnResizeMode: 'onChange',
onColumnSizingChange: setColumnSizing,
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: handlePaginationChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
// 手动分页配置
manualPagination,
pageCount: manualPagination && totalCount ? Math.ceil(totalCount / pagination.pageSize) : undefined,
})
React.useEffect(() => {
if (onSelectionChange) {
const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
onSelectionChange(selectedRows)
}
}, [rowSelection, onSelectionChange, table])
return (
<div className="w-full space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
{/* 搜索框 */}
<div className="flex items-center space-x-2">
<Input
placeholder={searchPlaceholder}
value={localSearchValue}
onChange={(e) => setLocalSearchValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 max-w-sm"
/>
<Button variant="outline" size="sm" onClick={handleSearchSubmit} disabled={isSearching}>
{isSearching ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconSearch className="h-4 w-4" />
)}
</Button>
</div>
{/* 右侧操作按钮 */}
<div className="flex items-center space-x-2">
{/* 列显示控制 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
Columns
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id === "id" && "ID"}
{column.id === "name" && "目标名称"}
{column.id === "type" && "类型"}
{column.id === "organizations" && "所属组织"}
{column.id === "domainCount" && "域名数"}
{column.id === "endpointCount" && "URL 数"}
{!["id", "name", "type", "organizations", "domainCount", "endpointCount"].includes(column.id) && column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
{/* 批量删除按钮 */}
{onBulkDelete && (
<Button
onClick={onBulkDelete}
size="sm"
variant="outline"
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
className={
table.getFilteredSelectedRowModel().rows.length === 0
? "text-muted-foreground"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}
>
<IconTrash />
</Button>
)}
{/* 添加新目标按钮 */}
{onAddNew && (
<Button onClick={onAddNew} onMouseEnter={onAddHover} size="sm">
<IconPlus />
{addButtonText}
</Button>
)}
</div>
</div>
{/* 表格容器 */}
<div className="rounded-md border overflow-x-auto">
<Table style={{ minWidth: table.getCenterTotalSize() }}>
{/* 表头 */}
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={{ width: header.getSize() }}
className="relative group"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onDoubleClick={() => header.column.resetSize()}
className="absolute -right-2.5 top-0 h-full w-8 cursor-col-resize select-none touch-none bg-transparent flex justify-center"
>
<div className="w-1.5 h-full bg-transparent group-hover:bg-border" />
</div>
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
{/* 表体 */}
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="group"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} style={{ width: cell.column.getSize() }}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页控制 */}
<div className="flex items-center justify-between px-2">
{/* 选中行信息 */}
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} {" "}
{manualPagination && totalCount ? totalCount : table.getFilteredRowModel().rows.length}
</div>
{/* 分页控制器 */}
<div className="flex items-center space-x-6 lg:space-x-8">
{/* 每页显示数量选择 */}
<div className="flex items-center space-x-2">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="h-8 w-[90px]" id="rows-per-page">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 50, 100, 200, 500, 1000].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">
{table.getState().pagination.pageIndex + 1} {" "}
{manualPagination && totalCount ? Math.ceil(totalCount / pagination.pageSize) : table.getPageCount()}
</div>
{/* 分页按钮 */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">First page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</div>
)
}