feat: 增加 FaPagination 组件

This commit is contained in:
Hooray
2026-04-09 15:49:43 +08:00
parent a476217ffd
commit b25955d8e8
17 changed files with 653 additions and 0 deletions
@@ -190,6 +190,14 @@ const routes: RouteRecordRaw = {
title: '内容块', title: '内容块',
}, },
}, },
{
path: 'pagination',
name: 'componentExamplePagination',
component: () => import('@/views/component_example/pagination/index.vue'),
meta: {
title: '分页',
},
},
{ {
path: 'password_strength', path: 'password_strength',
name: 'componentExamplePasswordStrength', name: 'componentExamplePasswordStrength',
@@ -0,0 +1,9 @@
<script setup lang="ts">
const page = ref(1)
const size = ref(10)
const total = ref(100)
</script>
<template>
<FaPagination :page="page" :size="size" :total="total" />
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
const page = ref(1)
const size = ref(10)
const total = ref(100)
</script>
<template>
<FaPagination :page="page" :size="size" :total="total" layout="jumper, pager, ->, total, sizes" />
<div class="my-4 border-t" />
<FaPagination :page="page" :size="size" :total="total" layout="pager" />
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import Demo1 from './_demo1.vue'
import Demo2 from './_demo2.vue'
</script>
<template>
<div>
<FaPageHeader title="分页" description="FaPagination" />
<FaPageMain main-class="p-0">
<div class="p-4">
<Demo1 />
</div>
</FaPageMain>
<FaPageMain title="布局" main-class="p-0">
<div class="p-4">
<Demo2 />
</div>
</FaPageMain>
</div>
</template>
+1
View File
@@ -27,6 +27,7 @@ export { default as FaModal } from './src/modal/index.vue'
export { default as FaNumberField } from './src/number-field/index.vue' export { default as FaNumberField } from './src/number-field/index.vue'
export { default as FaPageHeader } from './src/page-header/index.vue' export { default as FaPageHeader } from './src/page-header/index.vue'
export { default as FaPageMain } from './src/page-main/index.vue' export { default as FaPageMain } from './src/page-main/index.vue'
export { default as FaPagination } from './src/pagination/index.vue'
export { default as FaPasswordStrength } from './src/password-strength/index.vue' export { default as FaPasswordStrength } from './src/password-strength/index.vue'
export { default as FaPopover } from './src/popover/index.vue' export { default as FaPopover } from './src/popover/index.vue'
export { default as FaProgress } from './src/progress/index.vue' export { default as FaProgress } from './src/progress/index.vue'
+1
View File
@@ -30,6 +30,7 @@ const COMPONENT_NAMES = [
'FaNumberField', 'FaNumberField',
'FaPageHeader', 'FaPageHeader',
'FaPageMain', 'FaPageMain',
'FaPagination',
'FaPasswordStrength', 'FaPasswordStrength',
'FaPopover', 'FaPopover',
'FaProgress', 'FaProgress',
@@ -0,0 +1,240 @@
# FaPagination 分页组件
功能完整的分页组件,支持总条数显示、每页条数选择、页码跳转和自定义布局。
## 基础用法
```vue
<script setup lang="ts">
const page = ref(1)
const size = ref(10)
const total = ref(100)
</script>
<template>
<FaPagination v-model:page="page" v-model:size="size" :total="total" />
</template>
```
## Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `total` | `number` | **必需** | 数据总条数 |
| `sizes` | `number[]` | `[10, 20, 30, 40, 50, 100]` | 每页条数选项 |
| `layout` | `string` | `'total, sizes, ->, pager, jumper'` | 布局配置 |
| `page` | `number` | **必需** | 当前页码(支持 v-model) |
| `size` | `number` | **必需** | 每页条数(支持 v-model) |
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| `pageChange` | `page: number` | 页码变化时触发 |
| `sizeChange` | `size: number` | 每页条数变化时触发 |
## Slots
无
## 示例
### 基础分页
```vue
<script setup lang="ts">
const page = ref(1)
const total = ref(100)
</script>
<template>
<FaPagination v-model:page="page" :total="total" />
</template>
```
### 带每页条数选择
```vue
<script setup lang="ts">
const page = ref(1)
const size = ref(10)
const total = ref(500)
</script>
<template>
<FaPagination v-model:page="page" v-model:size="size" :total="total" />
</template>
```
### 自定义每页条数选项
```vue
<script setup lang="ts">
const page = ref(1)
const size = ref(20)
const total = ref(500)
</script>
<template>
<FaPagination
v-model:page="page"
v-model:size="size"
:total="total"
:sizes="[20, 50, 100, 200]"
/>
</template>
```
### 自定义布局(仅页码)
```vue
<script setup lang="ts">
const page = ref(1)
const total = ref(100)
</script>
<template>
<FaPagination v-model:page="page" :total="total" layout="pager" />
</template>
```
### 自定义布局(总条数 + 页码)
```vue
<template>
<FaPagination
v-model:page="page"
:total="total"
layout="total, ->, pager"
/>
</template>
```
### 完整布局
```vue
<script setup lang="ts">
const page = ref(1)
const size = ref(10)
const total = ref(1000)
</script>
<template>
<FaPagination
v-model:page="page"
v-model:size="size"
:total="total"
layout="total, sizes, ->, pager, jumper"
/>
</template>
```
### 监听分页变化
```vue
<script setup lang="ts">
const page = ref(1)
const size = ref(10)
const total = ref(100)
function onPageChange(newPage: number) {
console.log('页码变为:', newPage)
// 加载新页面数据
}
function onSizeChange(newSize: number) {
console.log('每页条数变为:', newSize)
page.value = 1 // 重置页码
// 加载新数据
}
</script>
<template>
<FaPagination
v-model:page="page"
v-model:size="size"
:total="total"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</template>
```
### 在表格中使用
```vue
<script setup lang="ts">
const page = ref(1)
const size = ref(10)
const total = ref(100)
const data = ref([])
// 加载数据
function loadData() {
// API 调用获取数据
}
watch([page, size], loadData)
</script>
<template>
<div>
<table>
<thead>
<tr>
<th>姓名</th>
<th>邮箱</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data" :key="item.id">
<td>{{ item.name }}</td>
<td>{{ item.email }}</td>
</tr>
</tbody>
</table>
<FaPagination v-model:page="page" v-model:size="size" :total="total" />
</div>
</template>
```
## 布局配置说明
`layout` 属性控制分页组件的布局和元素顺序,支持以下元素:
| 元素 | 说明 |
|------|------|
| `total` | 显示总条数 |
| `sizes` | 每页条数选择器 |
| `pager` | 页码按钮组 |
| `jumper` | 页码跳转输入框 |
| `->` | 弹性占位符(将后续元素推到右侧) |
### 布局示例
```vue
<!-- 左侧总条数,中间页码,右侧跳转 -->
<FaPagination layout="total, ->, pager, jumper" />
<!-- 仅显示页码 -->
<FaPagination layout="pager" />
<!-- 总条数 + 每页条数 + 分页器 -->
<FaPagination layout="total, sizes, pager" />
```
## 注意事项
1. **必需属性**:`total`、`page`、`size` 为必需属性
2. **双向绑定**:`page` 和 `size` 支持 `v-model` 双向绑定
3. **页码范围**:跳转页码会自动限制在有效范围内
4. **自动聚焦**:跳转输入框获得焦点时会自动选中文本
5. **回车跳转**:在跳转输入框中按回车键可触发跳转
## 典型使用场景
- 数据列表分页
- 表格分页
- 搜索结果分页
- 文章列表分页
- 评论列表分页
@@ -0,0 +1,120 @@
<script setup lang="ts">
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationNext,
PaginationPrevious,
} from './pagination'
defineOptions({
name: 'FaPagination',
})
const props = withDefaults(defineProps<{
total: number
sizes?: number[]
layout?: string
}>(), {
sizes: () => [10, 20, 30, 40, 50, 100],
layout: 'total, sizes, ->, pager, jumper',
})
const emits = defineEmits<{
pageChange: [page: number]
sizeChange: [size: number]
}>()
const page = defineModel<number>('page', { required: true })
const size = defineModel<number>('size', { required: true })
const jumpPage = ref<number | string>(page.value)
const totalPages = computed(() => Math.ceil(props.total / size.value))
// Parse layout string to determine order and visibility
const layoutConfig = computed(() => {
const items = props.layout.split(',').map(item => item.trim())
const config: Record<string, { show: boolean, order: number }> = {
'pager': { show: false, order: 0 },
'total': { show: false, order: 0 },
'sizes': { show: false, order: 0 },
'jumper': { show: false, order: 0 },
'->': { show: false, order: 0 },
}
items.forEach((item, index) => {
if (config[item] !== undefined) {
config[item] = { show: true, order: index + 1 }
}
})
return config
})
// Sync jumpPage with page model and emit pageChange event
watch(page, (newPage) => {
jumpPage.value = newPage
emits('pageChange', newPage)
})
// Emit sizeChange event when size changes
watch(size, (newSize) => {
emits('sizeChange', newSize)
})
function handleFocus(event: Event) {
const input = event.target as HTMLInputElement
input.select()
}
function handleInput(event: Event) {
const input = event.target as HTMLInputElement
const value = input.value.replace(/\D/g, '') // Remove non-digit characters
jumpPage.value = value
}
function handleJump() {
const pageNum = Number(jumpPage.value)
if (pageNum && Number.isInteger(pageNum) && pageNum >= 1 && pageNum <= totalPages.value) {
page.value = pageNum
}
else if (pageNum > totalPages.value) {
jumpPage.value = totalPages.value
}
else {
jumpPage.value = page.value
}
}
</script>
<template>
<Pagination v-slot="{ page: currentPage }" :total="props.total" :sibling-count="1" show-edges :page="page" class="gap-4 items-center" :items-per-page="size" @update:page="(val) => page = val">
<PaginationContent v-if="layoutConfig.pager.show" v-slot="{ items }" class="flex-center gap-1" :style="{ order: layoutConfig.pager.order }">
<PaginationFirst size="icon-sm" class="size-8 rtl:rotate-180" />
<PaginationPrevious size="icon-sm" class="size-8 rtl:rotate-180" />
<template v-for="(item, index) in items" :key="index">
<PaginationItem v-if="item.type === 'page'" :value="item.value" :is-active="item.value === currentPage" size="icon-sm" class="size-8">
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<PaginationNext size="icon-sm" class="size-8 rtl:rotate-180" />
<PaginationLast size="icon-sm" class="size-8 rtl:rotate-180" />
</PaginationContent>
<div v-if="layoutConfig.total.show" class="text-sm text-muted-foreground" :style="{ order: layoutConfig.total.order }">
共 {{ props.total }} 条
</div>
<div v-if="layoutConfig.sizes.show" :style="{ order: layoutConfig.sizes.order }">
<FaSelect v-model="size" :options="props.sizes.map(size => ({ label: `${size} 条/页`, value: size }))" class="w-auto" />
</div>
<div v-if="layoutConfig.jumper.show" class="flex-center gap-2" :style="{ order: layoutConfig.jumper.order }">
<span class="text-sm text-muted-foreground">前往</span>
<FaInput v-model="jumpPage" class="h-8 w-16" input-class="text-center" @focus="handleFocus" @input="handleInput" @keyup.enter="handleJump" />
<span class="text-sm text-muted-foreground">页</span>
</div>
<div v-if="layoutConfig['->'].show" class="flex-1" :style="{ order: layoutConfig['->'].order }" />
</Pagination>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PaginationRootEmits, PaginationRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PaginationRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '../../../utils'
const props = defineProps<PaginationRootProps & {
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<PaginationRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PaginationRoot
v-slot="slotProps"
data-slot="pagination"
v-bind="forwarded"
:class="cn('mx-auto flex w-full justify-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { PaginationListProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PaginationList } from 'reka-ui'
import { cn } from '../../../utils'
const props = defineProps<PaginationListProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<PaginationList
v-slot="slotProps"
data-slot="pagination-content"
v-bind="delegatedProps"
:class="cn('flex flex-row items-center gap-1', props.class)"
>
<slot v-bind="slotProps" />
</PaginationList>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { PaginationEllipsisProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { MoreHorizontal } from 'lucide-vue-next'
import { PaginationEllipsis } from 'reka-ui'
import { cn } from '../../../utils'
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<PaginationEllipsis
data-slot="pagination-ellipsis"
v-bind="delegatedProps"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
<span class="sr-only">More pages</span>
</slot>
</PaginationEllipsis>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PaginationFirstProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '../../button/button'
import { reactiveOmit } from '@vueuse/core'
import { ChevronsLeft } from 'lucide-vue-next'
import { PaginationFirst, useForwardProps } from 'reka-ui'
import { cn } from '../../../utils'
import { buttonVariants } from '../../button/button'
const props = withDefaults(defineProps<PaginationFirstProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationFirst
data-slot="pagination-first"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronsLeft />
</slot>
</PaginationFirst>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationListItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '../../button/button'
import { reactiveOmit } from '@vueuse/core'
import { PaginationListItem } from 'reka-ui'
import { cn } from '../../../utils'
import { buttonVariants } from '../../button/button'
const props = withDefaults(defineProps<PaginationListItemProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
isActive?: boolean
}>(), {
size: 'icon',
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'isActive')
</script>
<template>
<PaginationListItem
data-slot="pagination-item"
v-bind="delegatedProps"
:class="cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
props.class)"
>
<slot />
</PaginationListItem>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PaginationLastProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '../../button/button'
import { reactiveOmit } from '@vueuse/core'
import { ChevronsRight } from 'lucide-vue-next'
import { PaginationLast, useForwardProps } from 'reka-ui'
import { cn } from '../../../utils'
import { buttonVariants } from '../../button/button'
const props = withDefaults(defineProps<PaginationLastProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationLast
data-slot="pagination-last"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronsRight />
</slot>
</PaginationLast>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PaginationNextProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '../../button/button'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import { PaginationNext, useForwardProps } from 'reka-ui'
import { cn } from '../../../utils'
import { buttonVariants } from '../../button/button'
const props = withDefaults(defineProps<PaginationNextProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationNext
data-slot="pagination-next"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronRightIcon />
</slot>
</PaginationNext>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PaginationPrevProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '../../button/button'
import { reactiveOmit } from '@vueuse/core'
import { ChevronLeftIcon } from 'lucide-vue-next'
import { PaginationPrev, useForwardProps } from 'reka-ui'
import { cn } from '../../../utils'
import { buttonVariants } from '../../button/button'
const props = withDefaults(defineProps<PaginationPrevProps & {
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}>(), {
size: 'default',
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationPrev
data-slot="pagination-previous"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
</slot>
</PaginationPrev>
</template>
@@ -0,0 +1,8 @@
export { default as Pagination } from './Pagination.vue'
export { default as PaginationContent } from './PaginationContent.vue'
export { default as PaginationEllipsis } from './PaginationEllipsis.vue'
export { default as PaginationFirst } from './PaginationFirst.vue'
export { default as PaginationItem } from './PaginationItem.vue'
export { default as PaginationLast } from './PaginationLast.vue'
export { default as PaginationNext } from './PaginationNext.vue'
export { default as PaginationPrevious } from './PaginationPrevious.vue'