Files
sub2api/frontend/src/components/common/Pagination.vue
2026-02-02 22:13:50 +08:00

238 lines
7.7 KiB
Vue

<template>
<div
class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 dark:border-dark-700 dark:bg-dark-800 sm:px-6"
>
<div class="flex flex-1 items-center justify-between sm:hidden">
<!-- Mobile pagination -->
<button
@click="goToPage(page - 1)"
:disabled="page === 1"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
>
{{ t('pagination.previous') }}
</button>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('pagination.pageOf', { page, total: totalPages }) }}
</span>
<button
@click="goToPage(page + 1)"
:disabled="page === totalPages"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
>
{{ t('pagination.next') }}
</button>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<!-- Desktop pagination info -->
<div class="flex items-center space-x-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('pagination.showing') }}
<span class="font-medium">{{ fromItem }}</span>
{{ t('pagination.to') }}
<span class="font-medium">{{ toItem }}</span>
{{ t('pagination.of') }}
<span class="font-medium">{{ total }}</span>
{{ t('pagination.results') }}
</p>
<!-- Page size selector -->
<div v-if="showPageSizeSelector" class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300"
>{{ t('pagination.perPage') }}:</span
>
<div class="page-size-select w-20">
<Select
:model-value="pageSize"
:options="pageSizeSelectOptions"
@update:model-value="handlePageSizeChange"
/>
</div>
</div>
<div v-if="showJump" class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.jumpTo') }}</span>
<input
v-model="jumpPage"
type="number"
min="1"
:max="totalPages"
class="input w-20 text-sm"
:placeholder="t('pagination.jumpPlaceholder')"
@keyup.enter="submitJump"
/>
<button type="button" class="btn btn-ghost btn-sm" @click="submitJump">
{{ t('pagination.jumpAction') }}
</button>
</div>
</div>
<!-- Desktop pagination buttons -->
<nav
class="relative z-0 inline-flex -space-x-px rounded-md shadow-sm"
aria-label="Pagination"
>
<!-- Previous button -->
<button
@click="goToPage(page - 1)"
:disabled="page === 1"
class="relative inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
:aria-label="t('pagination.previous')"
>
<Icon name="chevronLeft" size="md" />
</button>
<!-- Page numbers -->
<button
v-for="pageNum in visiblePages"
:key="pageNum"
@click="typeof pageNum === 'number' && goToPage(pageNum)"
:disabled="typeof pageNum !== 'number'"
:class="[
'relative inline-flex items-center border px-4 py-2 text-sm font-medium',
pageNum === page
? 'z-10 border-primary-500 bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600',
typeof pageNum !== 'number' && 'cursor-default'
]"
:aria-label="
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined
"
:aria-current="pageNum === page ? 'page' : undefined"
>
{{ pageNum }}
</button>
<!-- Next button -->
<button
@click="goToPage(page + 1)"
:disabled="page === totalPages"
class="relative inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
:aria-label="t('pagination.next')"
>
<Icon name="chevronRight" size="md" />
</button>
</nav>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import Select from './Select.vue'
const { t } = useI18n()
interface Props {
total: number
page: number
pageSize: number
pageSizeOptions?: number[]
showPageSizeSelector?: boolean
showJump?: boolean
}
interface Emits {
(e: 'update:page', page: number): void
(e: 'update:pageSize', pageSize: number): void
}
const props = withDefaults(defineProps<Props>(), {
pageSizeOptions: () => [10, 20, 50, 100],
showPageSizeSelector: true,
showJump: false
})
const emit = defineEmits<Emits>()
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
const fromItem = computed(() => {
if (props.total === 0) return 0
return (props.page - 1) * props.pageSize + 1
})
const toItem = computed(() => {
const to = props.page * props.pageSize
return to > props.total ? props.total : to
})
const pageSizeSelectOptions = computed(() => {
return props.pageSizeOptions.map((size) => ({
value: size,
label: String(size)
}))
})
const jumpPage = ref('')
const visiblePages = computed(() => {
const pages: (number | string)[] = []
const maxVisible = 7
const total = totalPages.value
if (total <= maxVisible) {
// Show all pages if total is small
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// Always show first page
pages.push(1)
const start = Math.max(2, props.page - 2)
const end = Math.min(total - 1, props.page + 2)
// Add ellipsis before if needed
if (start > 2) {
pages.push('...')
}
// Add middle pages
for (let i = start; i <= end; i++) {
pages.push(i)
}
// Add ellipsis after if needed
if (end < total - 1) {
pages.push('...')
}
// Always show last page
pages.push(total)
}
return pages
})
const goToPage = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages.value && newPage !== props.page) {
emit('update:page', newPage)
}
}
const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize)
}
const submitJump = () => {
const value = jumpPage.value.trim()
if (!value) return
const pageNum = Number.parseInt(value, 10)
if (Number.isNaN(pageNum)) return
const nextPage = Math.min(Math.max(pageNum, 1), totalPages.value)
jumpPage.value = ''
goToPage(nextPage)
}
</script>
<style scoped>
.page-size-select :deep(.select-trigger) {
@apply px-3 py-1.5 text-sm;
}
</style>