feat: api client, auth store, login, authed shell, data-table + CRUD kit

This commit is contained in:
2026-07-07 22:15:08 +08:00
parent 95a3dee29f
commit eb617ce436
23 changed files with 1002 additions and 352 deletions
+155
View File
@@ -0,0 +1,155 @@
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import type { Pagination } from '@/api/types'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
interface DataTableProps<T> {
columns: ColumnDef<T, any>[]
data: T[]
pagination?: Pagination
loading?: boolean
emptyText?: string
onPageChange?: (page: number) => void
onPageSizeChange?: (pageSize: number) => void
}
/**
* Server-paginated table bound to the backend's Pagination envelope
* (page / page_size request params).
*/
export function DataTable<T>({
columns,
data,
pagination,
loading = false,
emptyText = '暂无数据',
onPageChange,
onPageSizeChange,
}: DataTableProps<T>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
})
return (
<div className="flex flex-col gap-3">
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{columns.map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full max-w-32" />
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
{emptyText}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{pagination && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div> {pagination.total_count} </div>
<div className="flex items-center gap-3">
{onPageSizeChange && (
<Select
value={String(pagination.page_size)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size} /
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
disabled={!pagination.prev_page || loading}
onClick={() => onPageChange?.(pagination.prev_page ?? 1)}
aria-label="上一页"
>
<ChevronLeft className="size-4" />
</Button>
<span className="min-w-16 text-center tabular-nums">
{pagination.current_page} / {Math.max(pagination.total_pages, 1)}
</span>
<Button
variant="outline"
size="icon-sm"
disabled={!pagination.next_page || loading}
onClick={() => onPageChange?.(pagination.next_page ?? 1)}
aria-label="下一页"
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
)}
</div>
)
}