Files
xinghuoapi/frontend/src/components/common/Pagination.vue
IanShaw 254f12543c feat(frontend): 前端界面优化与使用统计功能增强 (#46)
* feat(frontend): 前端界面优化与使用统计功能增强

主要改动:

1. 表格布局统一优化
   - 新增 TablePageLayout 通用布局组件
   - 统一所有管理页面的表格样式和交互
   - 优化 DataTable、Pagination、Select 等通用组件

2. 使用统计功能增强
   - 管理端: 添加完整的筛选和显示功能
   - 用户端: 完善 API Key 列显示
   - 后端: 优化使用统计数据结构和查询

3. 账户组件优化
   - 优化 AccountStatsModal、AccountUsageCell 等组件
   - 统一进度条和统计显示样式

4. 其他改进
   - 完善中英文国际化
   - 统一页面样式和交互体验
   - 优化各视图页面的响应式布局

* fix(test): 修复 stubUsageLogRepo.ListWithFilters 测试 stub

测试用例 GET /api/v1/usage 返回 500 是因为 stub 方法未实现,
现在正确返回基于 UserID 过滤的日志数据。

* feat(frontend): 统一日期时间显示格式

**主要改动**:
1. 增强 utils/format.ts:
   - 新增 formatDateOnly() - 格式: YYYY-MM-DD
   - 新增 formatDateTime() - 格式: YYYY-MM-DD HH:mm:ss

2. 全局替换视图中的格式化函数:
   - 移除各视图中的自定义 formatDate 函数
   - 统一导入使用 @/utils/format 中的函数
   - created_at/updated_at 使用 formatDateTime
   - expires_at 使用 formatDateOnly

3. 受影响的视图 (8个):
   - frontend/src/views/user/KeysView.vue
   - frontend/src/views/user/DashboardView.vue
   - frontend/src/views/user/UsageView.vue
   - frontend/src/views/user/RedeemView.vue
   - frontend/src/views/admin/UsersView.vue
   - frontend/src/views/admin/UsageView.vue
   - frontend/src/views/admin/RedeemView.vue
   - frontend/src/views/admin/SubscriptionsView.vue

**效果**:
- 日期统一显示为 YYYY-MM-DD
- 时间统一显示为 YYYY-MM-DD HH:mm:ss
- 提升可维护性,避免格式不一致

* fix(frontend): 补充遗漏的时间格式化统一

**补充修复**(基于 code review 发现的遗漏):

1. 增强 utils/format.ts:
   - 新增 formatTime() - 格式: HH:mm

2. 修复 4 个遗漏的文件:
   - src/views/admin/UsersView.vue
     * 删除 formatExpiresAt(),改用 formatDateTime()
     * 修复订阅过期时间 tooltip 显示格式不一致问题

   - src/views/user/ProfileView.vue
     * 删除 formatMemberSince(),改用 formatDate(date, 'YYYY-MM')
     * 统一会员起始时间显示格式

   - src/views/user/SubscriptionsView.vue
     * 修改 formatExpirationDate() 使用 formatDateOnly()
     * 保留天数计算逻辑

   - src/components/account/AccountStatusIndicator.vue
     * 删除本地 formatTime(),改用 utils/format 中的统一函数
     * 修复 rate limit 和 overload 重置时间显示

**验证**:
- TypeScript 类型检查通过 ✓
- 前端构建成功 ✓
- 所有剩余的 toLocaleString() 都是数字格式化,属于正确用法 ✓

**效果**:
- 订阅过期时间统一为 YYYY-MM-DD HH:mm:ss
- 会员起始时间统一为 YYYY-MM
- 重置时间统一为 HH:mm
- 消除所有不规范的原生 locale 方法调用
2025-12-27 10:50:25 +08:00

221 lines
7.3 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 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>
<!-- 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')"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</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')"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</button>
</nav>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from './Select.vue'
const { t } = useI18n()
interface Props {
total: number
page: number
pageSize: number
pageSizeOptions?: number[]
}
interface Emits {
(e: 'update:page', page: number): void
(e: 'update:pageSize', pageSize: number): void
}
const props = withDefaults(defineProps<Props>(), {
pageSizeOptions: () => [10, 20, 50, 100]
})
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 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)
// Reset to first page when page size changes
if (props.page !== 1) {
emit('update:page', 1)
}
}
</script>
<style scoped>
.page-size-select :deep(.select-trigger) {
@apply px-3 py-1.5 text-sm;
}
</style>