improve(ui):优化租户端表格分页组件

This commit is contained in:
qian committed 2026-08-01 23:37:44 +08:00
1 parent 657c452ea1
commit ec4a4682e5
15 files changed
+159 -128

No files matched your search

@@ -1,20 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
const props = withDefaults(defineProps<{
modelValue: number
pageSize: number
total: number
}>()
pageSizeOptions?: number[]
showSizeChanger?: boolean
}>(), {
pageSizeOptions: () => [10, 20, 50, 100],
showSizeChanger: true,
})
const emit = defineEmits<{
'update:modelValue': [page: number]
'update:pageSize': [size: number]
change: [page: number]
}>()
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
// 当前 pageSize 不在候选列表时兜底追加,避免 select 找不到选中项
const sizeOptions = computed(() => {
const opts = [...props.pageSizeOptions]
if (!opts.includes(props.pageSize)) opts.push(props.pageSize)
return opts.sort((a, b) => a - b)
})
const pageItems = computed(() => {
const count = totalPages.value
if (count <= 7) {
@@ -47,6 +60,14 @@ function goToPage(page: number) {
emit('change', target)
}
function changePageSize(size: number) {
if (!Number.isFinite(size) || size === props.pageSize) return
emit('update:pageSize', size)
// 切换每页条数后回到第一页并触发刷新(即使原本就在第一页也要重新拉取)
emit('update:modelValue', 1)
emit('change', 1)
}
function submitJump() {
const target = Number(jumpPage.value)
if (!Number.isFinite(target)) return
@@ -57,7 +78,20 @@ function submitJump() {
<template>
<div v-if="total > 0" class="table-pagination">
<span>共 {{ total }} 条记录</span>
<div class="pagination-meta">
<div v-if="showSizeChanger" class="pagination-size">
<span>每页</span>
<select
class="pagination-size-select"
:value="pageSize"
aria-label="每页条数"
@change="changePageSize(Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="opt in sizeOptions" :key="opt" :value="opt">{{ opt }} 条/页</option>
</select>
</div>
<span>共 {{ total }} 条记录</span>
</div>
<div class="pagination-controls">
<button
@@ -114,6 +148,44 @@ function submitJump() {
</template>
<style scoped>
.pagination-meta {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
font-size: 0.75rem;
color: #64748b;
}
.pagination-size {
display: flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
}
.pagination-size-select {
height: 2rem;
border: 1px solid rgba(203, 213, 225, 0.82);
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.72);
padding: 0 0.5rem;
font-size: 0.75rem;
color: #334155;
outline: none;
cursor: pointer;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.pagination-size-select:hover {
border-color: rgba(20, 184, 166, 0.45);
}
.pagination-size-select:focus {
border-color: #14b8a6;
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.12);
}
.pagination-controls,
.pagination-pages,
.pagination-jump {
@@ -209,6 +281,10 @@ function submitJump() {
}
@media (max-width: 640px) {
.pagination-meta {
order: 1;
}
.pagination-controls,
.pagination-jump {
width: 100%;
+3 -3
View File
@@ -8,7 +8,7 @@ import { useExport } from '@/composables/useExport'
const loading = ref(false)
const orders = ref<any[]>([])
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const statusFilter = ref('')
@@ -60,7 +60,7 @@ const statusFilterOptions = [
async function fetchOrders() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
const params: any = { page: page.value, page_size: pageSize.value }
if (statusFilter.value) params.status = statusFilter.value
const res = await request.get('/tenant/orders', { params })
const raw = res.data?.data
@@ -199,7 +199,7 @@ onMounted(() => {
</table>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchOrders" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchOrders" />
</div>
</div>
</template>
@@ -9,7 +9,7 @@ import { useExport } from '@/composables/useExport'
const loading = ref(false)
const transactions = ref<any[]>([])
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
// Filter state
@@ -80,7 +80,7 @@ function formatAmount(amount: number): string {
async function fetchTransactions() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
const params: any = { page: page.value, page_size: pageSize.value }
if (filterType.value) params.type = filterType.value
if (filterStartDate.value) params.start_date = filterStartDate.value
if (filterEndDate.value) params.end_date = filterEndDate.value
@@ -229,7 +229,7 @@ onMounted(fetchTransactions)
<p class="empty-state-description">交易记录将在 API 调用和充值后展示</p>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchTransactions" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchTransactions" />
</div>
</div>
</template>
+38 -28
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import Icon from '@/components/common/Icon.vue'
import BasePagination from '@/components/common/BasePagination.vue'
import MarkdownRenderer from '@/components/common/MarkdownRenderer.vue'
import request from '@/utils/request'
@@ -48,9 +49,9 @@ const articleDetail = ref<ArticleDetail | null>(null)
const searchQuery = ref('')
const isSearchMode = ref(false)
const searchPage = ref(1)
const searchTotal = ref(0)
const pageSize = 20
const listPage = ref(1)
const listTotal = ref(0)
const pageSize = ref(20)
const categoryColors = [
{ bg: 'bg-primary-50', text: 'text-primary-600', dot: 'bg-primary-500' },
@@ -122,25 +123,41 @@ async function fetchCategories() {
}
}
async function fetchArticles(slug: string) {
async function loadList() {
articlesLoading.value = true
activeCategorySlug.value = slug
activeArticleSlug.value = null
articleDetail.value = null
isSearchMode.value = false
try {
const res: any = await request.get(`/tenant/help/categories/${slug}/articles`, {
params: { page: 1, page_size: pageSize },
})
const raw = res.data?.data
let raw: any = null
if (isSearchMode.value) {
const res: any = await request.get('/tenant/help/search', {
params: { q: searchQuery.value.trim(), page: listPage.value, page_size: pageSize.value },
})
raw = res.data?.data
} else if (activeCategorySlug.value) {
const res: any = await request.get(`/tenant/help/categories/${activeCategorySlug.value}/articles`, {
params: { page: listPage.value, page_size: pageSize.value },
})
raw = res.data?.data
}
articles.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
// 分类列表若未返回 total,按当前条数兜底(不足一页时不展示翻页)
listTotal.value = raw?.total ?? (isSearchMode.value ? 0 : articles.value.length)
} catch {
articles.value = []
listTotal.value = 0
} finally {
articlesLoading.value = false
}
}
async function fetchArticles(slug: string) {
activeCategorySlug.value = slug
activeArticleSlug.value = null
isSearchMode.value = false
listPage.value = 1
await loadList()
}
async function fetchArticleDetail(slug: string) {
articleLoading.value = true
activeArticleSlug.value = slug
@@ -160,22 +177,13 @@ async function handleSearch() {
isSearchMode.value = true
activeCategorySlug.value = null
activeArticleSlug.value = null
articleDetail.value = null
articlesLoading.value = true
searchPage.value = 1
try {
const res: any = await request.get('/tenant/help/search', {
params: { q, page: 1, page_size: pageSize },
})
const raw = res.data?.data
articles.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
searchTotal.value = raw?.total || 0
} catch {
articles.value = []
searchTotal.value = 0
} finally {
articlesLoading.value = false
}
listPage.value = 1
await loadList()
}
// 翻页 / 切换每页条数时重新加载(listPage 已由分页组件 v-model 更新)
function handleListPageChange() {
loadList()
}
function goBackToList() {
@@ -390,7 +398,7 @@ onMounted(fetchCategories)
<template v-else-if="articles.length > 0">
<div v-if="isSearchMode" class="flex items-center gap-2 text-sm text-gray-500 mb-4">
<Icon name="search" size="sm" class="text-gray-400" />
搜索 "{{ searchQuery }}" 找到 <span class="font-medium text-gray-700">{{ searchTotal }}</span> 条结果
搜索 "{{ searchQuery }}" 找到 <span class="font-medium text-gray-700">{{ listTotal }}</span> 条结果
</div>
<div v-else-if="activeCategorySlug" class="flex items-center gap-2 text-sm text-gray-500 mb-4">
<span class="w-2 h-2 rounded-full" :class="catColor(activeColorIndex).dot" />
@@ -425,6 +433,8 @@ onMounted(fetchCategories)
</div>
</div>
</div>
<BasePagination v-model="listPage" v-model:page-size="pageSize" :total="listTotal" @change="handleListPageChange" />
</template>
<!-- Empty search results -->
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted } from 'vue'
import Icon from '@/components/common/Icon.vue'
import BasePagination from '@/components/common/BasePagination.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
import { useNotificationCount } from '@/composables/useNotificationCount'
@@ -18,7 +19,7 @@ interface Notification {
const notifications = ref<Notification[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const activeTab = ref<'all' | 'unread'>('all')
@@ -29,8 +30,6 @@ const { exporting, exportFile } = useExport({
const { unreadCount, decrement: decrementUnread, reset: resetUnread } = useNotificationCount()
const totalPages = computed(() => Math.ceil(total.value / pageSize))
const typeLabel: Record<string, string> = {
security: '安全',
billing: '账单',
@@ -50,7 +49,7 @@ const typeBadgeClass: Record<string, string> = {
async function fetchNotifications() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
const params: any = { page: page.value, page_size: pageSize.value }
if (activeTab.value === 'unread') params.is_read = false
const res: any = await request.get('/tenant/notifications', { params })
const raw = res.data?.data
@@ -110,11 +109,6 @@ function switchTab(tab: 'all' | 'unread') {
fetchNotifications()
}
function handlePageChange(newPage: number) {
page.value = newPage
fetchNotifications()
}
onMounted(() => {
fetchNotifications()
fetchUnreadCount()
@@ -232,26 +226,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-1 pt-3">
<span class="text-xs text-gray-500">共 {{ total }} 条通知</span>
<div class="flex items-center gap-2">
<button
class="btn btn-ghost btn-sm"
:disabled="page <= 1"
@click="handlePageChange(page - 1)"
>
上一页
</button>
<span class="text-sm text-gray-600">{{ page }} / {{ totalPages }}</span>
<button
class="btn btn-ghost btn-sm"
:disabled="page >= totalPages"
@click="handlePageChange(page + 1)"
>
下一页
</button>
</div>
</div>
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchNotifications" />
</div>
</div>
</template>
+3 -3
View File
@@ -33,7 +33,7 @@ interface ApiKey {
const keys = ref<ApiKey[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const showExportDropdown = ref(false)
@@ -88,7 +88,7 @@ async function fetchKeys() {
loading.value = true
try {
const res: any = await request.get('/tenant/api-keys', {
params: { page: page.value, page_size: pageSize, key_type: 'personal' },
params: { page: page.value, page_size: pageSize.value, key_type: 'personal' },
})
const raw = res.data?.data
keys.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
@@ -256,7 +256,7 @@ onMounted(() => {
<p class="empty-state-description">创建第一个密钥以开始使用 AI 模型</p>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchKeys" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchKeys" />
</div>
<!-- Create Modal -->
@@ -25,7 +25,7 @@ interface RequestLog {
const logs = ref<RequestLog[]>([])
const logsLoading = ref(false)
const logPage = ref(1)
const logPageSize = 20
const logPageSize = ref(20)
const logTotal = ref(0)
const logFilter = reactive({
@@ -117,7 +117,7 @@ async function fetchRequestLogs() {
try {
const params: Record<string, any> = {
page: logPage.value,
page_size: logPageSize,
page_size: logPageSize.value,
}
if (logFilter.username) params.username = logFilter.username
if (logFilter.request_id) params.request_id = logFilter.request_id
@@ -307,7 +307,7 @@ onMounted(() => {
</table>
</div>
<BasePagination v-model="logPage" :page-size="logPageSize" :total="logTotal" @change="fetchRequestLogs" />
<BasePagination v-model="logPage" v-model:page-size="logPageSize" :total="logTotal" @change="fetchRequestLogs" />
</div>
</div>
@@ -31,7 +31,7 @@ interface TaskItem {
const loading = ref(false)
const tasks = ref<TaskItem[]>([])
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const filterStatus = ref('')
@@ -94,7 +94,7 @@ async function fetchTasks() {
try {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize,
page_size: pageSize.value,
}
if (filterStatus.value) params.status = filterStatus.value
if (filterPlatform.value) params.platform = filterPlatform.value
@@ -263,7 +263,7 @@ onMounted(() => {
</table>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchTasks" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchTasks" />
</div>
</div>
@@ -11,7 +11,7 @@ import { useExport } from '@/composables/useExport'
const loading = ref(false)
const logs = ref<any[]>([])
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const filterUsername = ref('')
@@ -103,7 +103,7 @@ const billingSourceLabel: Record<string, string> = {
async function fetchLogs() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
const params: any = { page: page.value, page_size: pageSize.value }
if (filterUsername.value) params.username = filterUsername.value
if (filterModel.value) params.model = filterModel.value
if (filterStatus.value) params.status = filterStatus.value
@@ -295,7 +295,6 @@ onMounted(() => {
<th class="min-w-50">用户/项目</th>
<th class="min-w-40">API Key</th>
<th class="min-w-45">模型</th>
<th class="min-w-40">模型显示名称</th>
<th class="min-w-30">类型</th>
<th class="min-w-30">Token</th>
<th class="min-w-20">费用</th>
@@ -326,10 +325,6 @@ onMounted(() => {
</div>
<span v-else class="font-medium text-gray-900">{{ log.model_name }}</span>
</td>
<!-- 模型显示名称 -->
<td>
<span class="text-sm text-gray-700">{{ log.model_display_name || '-' }}</span>
</td>
<!-- 请求类型 -->
<td>
@@ -441,7 +436,7 @@ onMounted(() => {
<p class="empty-state-description">日志将在 API 调用后展示</p>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchLogs" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchLogs" />
</div>
<!-- Token Tooltip -->
@@ -8,7 +8,7 @@ import BaseSelect from '../../components/common/BaseSelect.vue'
const list = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const filterUsername = ref('')
@@ -27,7 +27,7 @@ async function fetchData() {
try {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize,
page_size: pageSize.value,
}
if (filterUsername.value) params.username = filterUsername.value
if (filterIpAddress.value) params.ip_address = filterIpAddress.value
@@ -180,7 +180,7 @@ const methodLabel: Record<string, string> = {
</table>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchData" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchData" />
</div>
<!-- Empty state -->
+4 -29
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import BasePagination from '@/components/common/BasePagination.vue'
import Icon from '@/components/common/Icon.vue'
import BaseSelect from '../../components/common/BaseSelect.vue'
import request from '@/utils/request'
@@ -22,7 +23,7 @@ interface FeedbackItem {
const feedbacks = ref<FeedbackItem[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const filterStatus = ref('')
@@ -85,15 +86,13 @@ const categoryIcon: Record<string, string> = {
complaint: 'exclamationCircle',
}
const totalPages = computed(() => Math.ceil(total.value / pageSize))
const filterStatusOptions = computed(() => [{value:'',label:'全部状态'}, ...statusOptions])
const filterCategoryOptions = computed(() => [{value:'',label:'全部类型'}, ...categoryOptions])
async function fetchFeedbacks() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
const params: any = { page: page.value, page_size: pageSize.value }
if (filterStatus.value) params.status = filterStatus.value
if (filterCategory.value) params.category = filterCategory.value
const res: any = await request.get('/tenant/feedbacks', { params })
@@ -143,11 +142,6 @@ async function openDetail(fb: FeedbackItem) {
}
}
function handlePageChange(newPage: number) {
page.value = newPage
fetchFeedbacks()
}
function handleFilter() {
page.value = 1
fetchFeedbacks()
@@ -258,26 +252,7 @@ onMounted(() => {
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="card flex items-center justify-between px-6 py-3">
<span class="text-xs text-gray-500">共 {{ total }} 条记录</span>
<div class="flex items-center gap-2">
<button
class="btn btn-ghost btn-sm"
:disabled="page <= 1"
@click="handlePageChange(page - 1)"
>
上一页
</button>
<span class="text-sm text-gray-600">{{ page }} / {{ totalPages }}</span>
<button
class="btn btn-ghost btn-sm"
:disabled="page >= totalPages"
@click="handlePageChange(page + 1)"
>
下一页
</button>
</div>
</div>
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchFeedbacks" />
</div>
<!-- Create Feedback Modal -->
+3 -3
View File
@@ -31,7 +31,7 @@ interface Reply {
const tickets = ref<Ticket[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const showExportDropdown = ref(false)
@@ -111,7 +111,7 @@ async function fetchTickets() {
loading.value = true
try {
const res: any = await request.get('/tenant/tickets', {
params: { page: page.value, page_size: pageSize },
params: { page: page.value, page_size: pageSize.value },
})
const raw = res.data?.data
tickets.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
@@ -306,7 +306,7 @@ onMounted(() => {
</table>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchTickets" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchTickets" />
</div>
<!-- Create Ticket Modal -->
@@ -29,7 +29,7 @@ const invitations = ref<InvitationItem[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const roleBadgeClass: Record<string, string> = {
owner: 'badge-primary',
@@ -64,7 +64,7 @@ async function fetchInvitations() {
loading.value = true
try {
const res: any = await request.get('/tenant/members/invitations', {
params: { page: page.value, page_size: pageSize },
params: { page: page.value, page_size: pageSize.value },
})
const raw = res.data?.data
invitations.value = raw?.list || []
@@ -183,7 +183,7 @@ onMounted(() => {
<p class="empty-state-description">生成邀请链接后,记录会显示在这里</p>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchInvitations" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchInvitations" />
</div>
</div>
</template>
+3 -3
View File
@@ -38,7 +38,7 @@ const members = ref<Member[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const keyword = ref('')
const showInviteModal = ref(false)
@@ -95,7 +95,7 @@ async function fetchMembers() {
loading.value = true
try {
const res: any = await request.get('/tenant/members', {
params: { page: page.value, page_size: pageSize, keyword: keyword.value },
params: { page: page.value, page_size: pageSize.value, keyword: keyword.value },
})
const raw = res.data?.data; members.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
total.value = raw?.total || 0
@@ -341,7 +341,7 @@ onMounted(() => {
<p class="empty-state-description">邀请第一位团队成员吧</p>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchMembers" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchMembers" />
</div>
<!-- Invite Modal -->
+3 -3
View File
@@ -13,7 +13,7 @@ const router = useRouter()
const loading = ref(false)
const projects = ref<any[]>([])
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const total = ref(0)
const statusLabels: Record<string, string> = {
@@ -85,7 +85,7 @@ async function fetchProjects() {
loading.value = true
try {
const res: any = await request.get('/tenant/projects', {
params: { page: page.value, page_size: pageSize }
params: { page: page.value, page_size: pageSize.value }
})
const raw = res.data?.data
projects.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
@@ -170,7 +170,7 @@ function goToDetail(item: any) {
</table>
</div>
<BasePagination v-model="page" :page-size="pageSize" :total="total" @change="fetchProjects" />
<BasePagination v-model="page" v-model:page-size="pageSize" :total="total" @change="fetchProjects" />
</div>
<!-- Create/Edit Modal -->