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

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

@@ -7,12 +7,11 @@ import {PageRecord} from '@/lib/api'
async function listResourcePss(props: {
page: number
size: number
active?: boolean
type?: number
create_after?: number
create_before?: number
expire_after?: number
expire_before?: number
create_after?: Date
create_before?: Date
expire_after?: Date
expire_before?: Date
}){
return await callByUser<PageRecord<ResourcePss>>('/api/resource/list/pss', props)
}

View File

@@ -0,0 +1,10 @@
import {ReactNode} from 'react'
export type BillsPageProps = {
}
export default async function BillsPage(props: BillsPageProps) {
return (
<main></main>
)
}

View File

@@ -56,7 +56,7 @@ export default async function DashboardLayout(props: DashboardLayoutProps) {
<NavItem href={`/admin`} icon={`📝`} label={`修改信息`}/>
<NavItem href={`/admin/identify`} icon={`🆔`} label={`实名认证`}/>
<NavItem href={`/admin/whitelist`} icon={`🔒`} label={`白名单`}/>
<NavItem href={`/admin`} icon={`💰`} label={`我的账单`}/>
<NavItem href={`/admin/bills`} icon={`💰`} label={`我的账单`}/>
<NavTitle label={`套餐管理`}/>
<NavItem href={`/admin/purchase`} icon={`🛒`} label={`购买套餐`}/>
<NavItem href={`/admin/resources`} icon={`📦`} label={`我的套餐`}/>

View File

@@ -1,15 +1,39 @@
'use client'
import {ReactNode, useEffect, useState} from 'react'
import {useEffect, useState} from 'react'
import {PageRecord} from '@/lib/api'
import {ResourcePss} from '@/lib/models'
import {useStatus} from '@/lib/states'
import {listResourcePss} from '@/actions/resource'
import {Box, Eraser, Search, Timer, Trash2} from 'lucide-react'
import {Pagination} from '@/components/ui/pagination'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {Button} from '@/components/ui/button'
import DataTable from '@/components/DataTable'
import {format, intlFormatDistance, isAfter, isEqual, parse} from 'date-fns'
import DatePicker from '@/components/DatePicker'
import {Form, FormField} from '@/components/ui/form'
import {useForm} from 'react-hook-form'
import zod from 'zod'
import {zodResolver} from '@hookform/resolvers/zod'
import {Label} from '@/components/ui/label'
const filterSchema = zod.object({
type: zod.enum(['expire', 'quota', 'all']),
create_after: zod.date().optional(),
create_before: zod.date().optional(),
expire_after: zod.date().optional(),
expire_before: zod.date().optional(),
})
type FilterSchema = zod.infer<typeof filterSchema>
export type ResourcesPageProps = {}
export default function ResourcesPage(props: ResourcesPageProps) {
// region 数据
// ======================
// 查询
// ======================
const [status, setStatus] = useStatus()
const [data, setData] = useState<PageRecord<ResourcePss>>({
@@ -22,11 +46,23 @@ export default function ResourcesPage(props: ResourcesPageProps) {
const refresh = async (page: number, size: number) => {
setStatus('load')
try {
const res = await listResourcePss({page, size})
setStatus('done')
const type = {
all: undefined,
expire: 1,
quota: 2,
}[form.getValues('type')]
const create_after = form.getValues('create_after')
const create_before = form.getValues('create_before')
const expire_after = form.getValues('expire_after')
const expire_before = form.getValues('expire_before')
const res = await listResourcePss({
page, size, type, create_after, create_before, expire_after, expire_before,
})
if (res.success) {
setData(res.data)
setStatus('done')
}
else {
throw new Error('Failed to load resource pss')
@@ -41,12 +77,207 @@ export default function ResourcesPage(props: ResourcesPageProps) {
refresh(1, 10).then()
}, [])
// endregion
// ======================
// 筛选
// ======================
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
type: 'all',
},
})
const onSubmit = async (value: FilterSchema) => {
console.log(value)
await refresh(data.page, data.size)
}
// ======================
// render
return (
<main className={`flex-auto bg-white rounded-tl-xl`}>
// ======================
return (
<main className={`flex-auto bg-white rounded-tl-xl p-4 flex flex-col gap-4`}>
{/* 操作区 */}
<section className={`flex justify-between flex-wrap`}>
<div>
</div>
<Form form={form} onSubmit={onSubmit} className={`flex items-end gap-4`}>
<FormField name={`type`} label={<span className={`text-sm`}></span>}>
{({id, field}) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className={`w-24`}>
<SelectValue placeholder={`选择套餐类型`}/>
</SelectTrigger>
<SelectContent>
<SelectItem value={`all`}></SelectItem>
<SelectItem value={`expire`}></SelectItem>
<SelectItem value={`quota`}></SelectItem>
</SelectContent>
</Select>
)}
</FormField>
<div className={`flex flex-col gap-2`}>
<Label className={`text-sm`}></Label>
<div className={`flex items-center`}>
<FormField name={`create_after`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`开始时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
<span className={`px-1`}>-</span>
<FormField name={`create_before`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`结束时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
</div>
</div>
<div className={`flex flex-col gap-2`}>
<Label className={`text-sm`}>使</Label>
<div className={`flex items-center`}>
<FormField name={`expire_after`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`开始时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
<span className={`px-1`}>-</span>
<FormField name={`expire_before`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`结束时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
</div>
</div>
<Button className={`h-9`}>
<Search/>
<span></span>
</Button>
<Button variant={`outline`} className={`h-9`} onClick={() => form.reset()}>
<Eraser/>
<span></span>
</Button>
</Form>
</section>
{/* 数据表 */}
<DataTable
data={data.list}
status={status}
pagination={{
total: data.total,
page: data.page,
size: data.size,
onPageChange: async (page: number) => {
await refresh(page, data.size)
},
onSizeChange: async (size: number) => {
await refresh(data.page, size)
},
}}
columns={[
{
accessorKey: 'id', header: `编号`,
},
{
accessorKey: 'type', header: `类型`, cell: ({row}) => (
<div className={`flex gap-2 items-center`}>
{row.original.type === 1 && (
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md`}>
<Timer size={20}/>
<span></span>
</div>
)}
{row.original.type === 2 && (
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md`}>
<Box size={20}/>
<span></span>
</div>
)}
</div>
),
},
{
accessorKey: 'live', header: `IP 时效`, cell: ({row}) => (
<span>
{row.original.live / 60}
</span>
),
},
{
accessorKey: 'expire', header: `使用情况`, cell: ({row}) => (
<div className={`flex gap-1`}>
{row.original.type === 1 ? (
<div className={`flex gap-1`}>
{isAfter(row.original.expire, new Date())
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.daily_used} / {row.original.daily_limit}</span>
<span>|</span>
<span>{intlFormatDistance(row.original.expire, new Date())} </span>
</div>
) : row.original.type === 2 ? (
<div className={`flex gap-1`}>
{row.original.used < row.original.quota
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.used} / {row.original.quota}</span>
</div>
) : (
<span>-</span>
)}
</div>
),
},
{
accessorKey: 'daily_last', header: '最近使用时间', cell: ({row}) => {
return (
isEqual(row.original.daily_last, parse('0001-01-01 08:05:43', 'yyyy-MM-dd HH:mm:ss', new Date()))
? '-'
: format(row.original.daily_last, 'yyyy-MM-dd HH:mm')
)
},
},
{
accessorKey: 'created_at', header: '开通时间', cell: ({row}) => (
format(row.getValue('created_at'), 'yyyy-MM-dd HH:mm')
),
},
{
accessorKey: 'action', header: `操作`, cell: (item) => (
<div className={`flex gap-2`}>
-
</div>
),
},
]}
/>
</main>
)
}

View File

@@ -322,9 +322,9 @@ export default function WhitelistPage(props: WhitelistPageProps) {
page={data.page}
size={data.size}
total={data.total}
pageSizeOptions={[10, 20, 50, 100]}
sizeOptions={[10, 20, 50, 100]}
onPageChange={changePage}
onPageSizeChange={changeSize}
onSizeChange={changeSize}
/>
)}

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}