完善资源列表查询行为,提供表格公共组件

This commit is contained in:
2025-04-10 17:49:02 +08:00
parent 74c6c01b7d
commit 7238166a95
15 changed files with 1057 additions and 28 deletions

View File

@@ -0,0 +1,83 @@
'use client'
import {Table as TableRoot, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table'
import {ColumnDef, flexRender, getCoreRowModel, useReactTable} from '@tanstack/react-table'
import {Pagination, PaginationProps} from '@/components/ui/pagination'
import {Loader} from 'lucide-react'
export type DataTableProps<T> = {
data: T[]
status: 'load' | 'done' | 'fail'
columns: ColumnDef<T>[]
pagination: PaginationProps
}
export default function DataTable<T extends Record<string, unknown>>(props: DataTableProps<T>) {
const table = useReactTable({
data: props.data,
columns: props.columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
rowCount: props.pagination.total,
state: {
pagination: {
pageIndex: props.pagination.page,
pageSize: props.pagination.size,
},
columnFilters: [],
},
})
return (<>
{/* 数据表*/}
<div className={`border rounded-md overflow-hidden relative`}>
<TableRoot>
<TableHeader>
{table.getHeaderGroups().map(group => (
<TableRow key={group.id}>
{group.headers.map(header => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{props.status === 'fail' ? (
<TableRow>
<TableCell colSpan={props.columns.length} className={`text-center text-destructive`}></TableCell>
</TableRow>
) : !props.data?.length ? (
<TableRow>
<TableCell colSpan={props.columns.length} className={`text-center`}></TableCell>
</TableRow>
) : table.getRowModel().rows.map(row => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</TableRoot>
{props.status === 'load' && (
<div className={`absolute inset-0 bg-white/10 backdrop-blur-xs flex items-center justify-center gap-2 transition`}>
<Loader className={`animate-spin`}/>
<span></span>
</div>
)}
</div>
{/* 分页器 */}
<Pagination {...props.pagination}/>
</>)
}

View File

@@ -0,0 +1,56 @@
'use client'
import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'
import {Button} from './ui/button'
import {merge} from '@/lib/utils'
import {CalendarIcon} from 'lucide-react'
import {format} from 'date-fns'
import {Calendar} from './ui/calendar'
export type DatePickerProps = {
className?: string
onChange?: (date: Date | undefined) => void
value?: Date
disabled?: boolean
required?: boolean
placeholder?: string
error?: boolean
errorMessage?: string
description?: string
helperText?: string
format?: string
}
export default function DatePicker(props: DatePickerProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={'outline'}
className={merge(
'w-40 justify-start text-left font-normal h-9',
!props.value && 'text-muted-foreground',
props.disabled && 'cursor-not-allowed opacity-70',
props.className,
)}
>
<CalendarIcon/>
<span className={`text-sm`}>
{props.value
? format(props.value, props.format || 'yyyy-MM-dd HH:mm:ss')
: props.placeholder || '选择日期'
}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={props.value}
onSelect={props.onChange}
initialFocus
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,6 +1,6 @@
import * as React from 'react'
import {Slot} from '@radix-ui/react-slot'
import {merge} from '@/lib/utils'
import {cva} from 'class-variance-authority'
type ButtonProps = React.ComponentProps<'button'> & {
variant?: 'default' | 'outline' | 'gradient' | 'danger' | 'accent'
@@ -34,4 +34,34 @@ function Button(rawProps: ButtonProps) {
)
}
export {Button}
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-secondary hover:text-secondary-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-secondary hover:text-secondary-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export {Button, buttonVariants}

View File

@@ -0,0 +1,75 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { merge } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={merge("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: merge(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: merge(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-secondary [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: merge(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-secondary text-secondary-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-secondary aria-selected:text-secondary-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={merge("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={merge("size-4", className)} {...props} />
),
}}
{...props}
/>
)
}
export { Calendar }

View File

@@ -22,9 +22,9 @@ export interface PaginationProps {
page: number
size: number
total: number
pageSizeOptions?: number[]
onPageChange: (page: number) => void
onPageSizeChange?: (size: number) => void
sizeOptions?: number[]
onPageChange?: (page: number) => void
onSizeChange?: (size: number) => void
className?: string
}
@@ -32,9 +32,9 @@ function Pagination({
page,
size,
total,
pageSizeOptions = [10, 20, 50, 100],
sizeOptions = [10, 20, 50, 100],
onPageChange,
onPageSizeChange,
onSizeChange,
className,
}: PaginationProps) {
const [currentPage, setCurrentPage] = useState(page)
@@ -94,13 +94,13 @@ function Pagination({
return
}
setCurrentPage(newPage)
onPageChange(newPage)
onPageChange?.(newPage)
}
const handlePageSizeChange = (newSize: string) => {
const parsedSize = parseInt(newSize, 10)
if (onPageSizeChange) {
onPageSizeChange(parsedSize)
if (onSizeChange) {
onSizeChange(parsedSize)
}
}
@@ -118,7 +118,7 @@ function Pagination({
<SelectValue/>
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map(option => (
{sizeOptions.map(option => (
<SelectItem key={option} value={option.toString()}>
{option}
</SelectItem>
@@ -216,7 +216,7 @@ function PaginationLink({
data-slot="pagination-link"
data-active={isActive}
className={merge(
'inline-flex items-center justify-center text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-9 w-9 rounded-md border border-input hover:bg-accent hover:text-accent-foreground',
'inline-flex items-center justify-center text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-9 w-9 rounded-md border border-input hover:bg-secondary hover:text-secondary-foreground',
isActive && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground',
className,
)}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { merge } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={merge(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { merge } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={merge(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -107,7 +107,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={merge(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
'focus:bg-secondary focus:text-secondary-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
className,
)}
{...props}