返回 AiToEarn
pagination.tsx
根目录 / project / aitoearn-web / src / components / ui / pagination.tsx
1 /**
2 * Pagination - 分页组件
3 * 用于显示分页导航
4 * 支持两种使用方式:
5 * 1. 简化版:直接使用 Pagination 组件
6 * 2. 组合版:使用 PaginationContent, PaginationItem 等子组件
7 */
8
9 'use client'
10
11 import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
12 import * as React from 'react'
13 import { useTransClient } from '@/app/i18n/client'
14 import { cn } from '@/utils/className'
15 import { Button, buttonVariants } from './button'
16 import { Input } from './input'
17 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
18
19 // ==================== 组合式分页组件 ====================
20
21 function PaginationRoot({ className, ...props }: React.ComponentProps<'nav'>) {
22 return (
23 <nav
24 role="navigation"
25 aria-label="pagination"
26 className={cn('mx-auto flex w-full justify-center', className)}
27 {...props}
28 />
29 )
30 }
31 PaginationRoot.displayName = 'Pagination'
32
33 const PaginationContent = React.forwardRef<
34 HTMLUListElement,
35 React.ComponentProps<'ul'>
36 >(({ className, ...props }, ref) => (
37 <ul
38 ref={ref}
39 className={cn('flex flex-row items-center gap-1', className)}
40 {...props}
41 />
42 ))
43 PaginationContent.displayName = 'PaginationContent'
44
45 const PaginationItem = React.forwardRef<
46 HTMLLIElement,
47 React.ComponentProps<'li'>
48 >(({ className, ...props }, ref) => (
49 <li ref={ref} className={cn('', className)} {...props} />
50 ))
51 PaginationItem.displayName = 'PaginationItem'
52
53 type PaginationLinkProps = {
54 isActive?: boolean
55 } & Pick<React.ComponentProps<typeof Button>, 'size'>
56 & React.ComponentProps<'a'>
57
58 function PaginationLink({
59 className,
60 isActive,
61 size = 'icon',
62 ...props
63 }: PaginationLinkProps) {
64 return (
65 <a
66 aria-current={isActive ? 'page' : undefined}
67 className={cn(
68 buttonVariants({
69 variant: isActive ? 'outline' : 'ghost',
70 size,
71 }),
72 'h-8 w-8',
73 isActive && 'border-primary bg-primary/10',
74 className,
75 )}
76 {...props}
77 />
78 )
79 }
80 PaginationLink.displayName = 'PaginationLink'
81
82 function PaginationPrevious({
83 className,
84 ...props
85 }: React.ComponentProps<typeof PaginationLink>) {
86 return (
87 <PaginationLink
88 aria-label="Go to previous page"
89 size="default"
90 className={cn('gap-1 pl-2.5 w-auto', className)}
91 {...props}
92 >
93 <ChevronLeft className="h-4 w-4" />
94 <span className="hidden sm:inline">上一页</span>
95 </PaginationLink>
96 )
97 }
98 PaginationPrevious.displayName = 'PaginationPrevious'
99
100 function PaginationNext({
101 className,
102 ...props
103 }: React.ComponentProps<typeof PaginationLink>) {
104 return (
105 <PaginationLink
106 aria-label="Go to next page"
107 size="default"
108 className={cn('gap-1 pr-2.5 w-auto', className)}
109 {...props}
110 >
111 <span className="hidden sm:inline">下一页</span>
112 <ChevronRight className="h-4 w-4" />
113 </PaginationLink>
114 )
115 }
116 PaginationNext.displayName = 'PaginationNext'
117
118 function PaginationEllipsis({
119 className,
120 ...props
121 }: React.ComponentProps<'span'>) {
122 return (
123 <span
124 aria-hidden
125 className={cn('flex h-9 w-9 items-center justify-center', className)}
126 {...props}
127 >
128 <MoreHorizontal className="h-4 w-4" />
129 <span className="sr-only">More pages</span>
130 </span>
131 )
132 }
133 PaginationEllipsis.displayName = 'PaginationEllipsis'
134
135 // ==================== 简化版分页组件 ====================
136
137 const PAGE_LABEL_COMPACT_THRESHOLD = 10000
138
139 function formatPageLabel(page: number) {
140 if (page < PAGE_LABEL_COMPACT_THRESHOLD) {
141 return String(page)
142 }
143
144 return new Intl.NumberFormat(undefined, {
145 notation: 'compact',
146 maximumFractionDigits: 1,
147 }).format(page)
148 }
149
150 interface PaginationProps {
151 /** 当前页码 */
152 current?: number
153 /** 每页条数 */
154 pageSize?: number
155 /** 总条数 */
156 total?: number
157 /** 页码改变回调 */
158 onChange?: (page: number, pageSize?: number) => void
159 /** 每页条数改变回调 */
160 onShowSizeChange?: (current: number, size: number) => void
161 /** 是否显示每页条数选择器 */
162 showSizeChanger?: boolean
163 /** 是否显示快速跳转 */
164 showQuickJumper?: boolean
165 /** 显示总数 */
166 showTotal?: (total: number, range: [number, number]) => React.ReactNode
167 /** 每页条数选项 */
168 pageSizeOptions?: string[]
169 /** 自定义类名 */
170 className?: string
171 /** 子元素(用于组合式) */
172 children?: React.ReactNode
173 }
174
175 function Pagination({
176 current = 1,
177 pageSize = 10,
178 total = 0,
179 onChange,
180 onShowSizeChange,
181 showSizeChanger = false,
182 showQuickJumper = false,
183 showTotal,
184 pageSizeOptions = ['10', '20', '50', '100'],
185 className,
186 children,
187 }: PaginationProps) {
188 const { t } = useTransClient('common')
189
190 // 如果有 children,使用组合式渲染
191 if (children) {
192 return (
193 <PaginationRoot className={className}>
194 {children}
195 </PaginationRoot>
196 )
197 }
198
199 const totalPages = Math.max(1, Math.ceil(total / pageSize))
200 const start = (current - 1) * pageSize + 1
201 const end = Math.min(current * pageSize, total)
202 const showPageControls = totalPages > 1
203
204 const handlePageChange = (page: number) => {
205 if (page >= 1 && page <= totalPages) {
206 onChange?.(page, pageSize)
207 }
208 }
209
210 const handleSizeChange = (size: number) => {
211 onShowSizeChange?.(current, size)
212 onChange?.(1, size)
213 }
214
215 const getPageNumbers = () => {
216 const pages: (number | string)[] = []
217 const maxVisible = 7
218
219 if (totalPages <= maxVisible) {
220 for (let i = 1; i <= totalPages; i++) {
221 pages.push(i)
222 }
223 }
224 else {
225 if (current <= 3) {
226 for (let i = 1; i <= 5; i++) {
227 pages.push(i)
228 }
229 pages.push('ellipsis')
230 pages.push(totalPages)
231 }
232 else if (current >= totalPages - 2) {
233 pages.push(1)
234 pages.push('ellipsis')
235 for (let i = totalPages - 4; i <= totalPages; i++) {
236 pages.push(i)
237 }
238 }
239 else {
240 pages.push(1)
241 pages.push('ellipsis')
242 for (let i = current - 1; i <= current + 1; i++) {
243 pages.push(i)
244 }
245 pages.push('ellipsis')
246 pages.push(totalPages)
247 }
248 }
249
250 return pages
251 }
252
253 if (total === 0) {
254 return null
255 }
256
257 return (
258 <div className={cn('flex flex-wrap items-center justify-center gap-2 text-sm', className)}>
259 {/* 显示总数 */}
260 {showTotal && (
261 <div className="whitespace-nowrap rounded-full border border-border/70 bg-muted/30 px-2.5 py-1 text-xs font-medium text-muted-foreground">
262 {showTotal(total, [start, end])}
263 </div>
264 )}
265
266 {/* 分页按钮组 */}
267 {showPageControls && (
268 <div className="inline-flex items-center gap-1 rounded-xl border border-border/70 bg-card/95 p-1 shadow-sm shadow-primary/5">
269 {/* 上一页 */}
270 <Button
271 variant="ghost"
272 size="icon"
273 className="size-7 rounded-lg"
274 onClick={() => handlePageChange(current - 1)}
275 disabled={current === 1}
276 >
277 <ChevronLeft className="size-4" />
278 </Button>
279
280 {/* 页码 */}
281 {getPageNumbers().map((page, index) => {
282 if (page === 'ellipsis') {
283 return (
284 <span
285 key={`ellipsis-${index}`}
286 className="flex h-7 items-center justify-center px-1.5 text-xs text-muted-foreground"
287 >
288 ...
289 </span>
290 )
291 }
292
293 const pageNum = page as number
294 const pageLabel = formatPageLabel(pageNum)
295
296 return (
297 <Button
298 key={pageNum}
299 variant={current === pageNum ? 'default' : 'ghost'}
300 size="icon"
301 className="h-7 w-auto min-w-7 max-w-14 rounded-lg px-2 text-xs tabular-nums"
302 title={String(pageNum)}
303 onClick={() => handlePageChange(pageNum)}
304 >
305 <span className="truncate">{pageLabel}</span>
306 </Button>
307 )
308 })}
309
310 {/* 下一页 */}
311 <Button
312 variant="ghost"
313 size="icon"
314 className="size-7 rounded-lg"
315 onClick={() => handlePageChange(current + 1)}
316 disabled={current === totalPages}
317 >
318 <ChevronRight className="size-4" />
319 </Button>
320 </div>
321 )}
322
323 {/* 每页条数选择器 */}
324 {showSizeChanger && (
325 <div className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-border/70 bg-card/95 px-2 py-1 text-xs text-muted-foreground shadow-sm shadow-primary/5">
326 <span>{t('pagination.perPage')}</span>
327 <Select value={String(pageSize)} onValueChange={value => handleSizeChange(Number(value))}>
328 <SelectTrigger className="h-7 w-16 rounded-lg px-2 text-xs">
329 <SelectValue />
330 </SelectTrigger>
331 <SelectContent align="end">
332 {pageSizeOptions.map(option => (
333 <SelectItem key={option} value={option}>
334 {option}
335 </SelectItem>
336 ))}
337 </SelectContent>
338 </Select>
339 <span>{t('pagination.items')}</span>
340 </div>
341 )}
342
343 {/* 快速跳转 */}
344 {showQuickJumper && (
345 <div className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-border/70 bg-card/95 px-2 py-1 text-xs text-muted-foreground shadow-sm shadow-primary/5">
346 <span className="text-muted-foreground">{t('pagination.jumpTo')}</span>
347 <Input
348 type="text"
349 inputMode="numeric"
350 pattern="[0-9]*"
351 className="h-7 w-14 rounded-lg px-2 text-center text-xs"
352 placeholder={t('pagination.page')}
353 onKeyDown={(e) => {
354 if (e.key === 'Enter') {
355 const page = Number((e.target as HTMLInputElement).value)
356 handlePageChange(page)
357 ;(e.target as HTMLInputElement).value = ''
358 }
359 }}
360 />
361 <span className="text-muted-foreground">{t('pagination.page')}</span>
362 </div>
363 )}
364 </div>
365 )
366 }
367
368 export {
369 Pagination,
370 PaginationContent,
371 PaginationEllipsis,
372 PaginationItem,
373 PaginationLink,
374 PaginationNext,
375 PaginationPrevious,
376 }
377
378 export type { PaginationProps }
379
379 lines Plain Text