| 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 |