* 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 方法调用
221 lines
7.3 KiB
Vue
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>
|