mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-22 07:34:45 +08:00
* 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 方法调用
155 lines
4.5 KiB
Vue
155 lines
4.5 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
|
|
<div
|
|
v-if="windowStats"
|
|
class="mb-0.5 flex items-center justify-between"
|
|
:title="t('admin.accounts.usageWindow.statsTitle')"
|
|
>
|
|
<div
|
|
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
|
|
>
|
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
|
{{ formatRequests }} req
|
|
</span>
|
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
|
{{ formatTokens }}
|
|
</span>
|
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress bar row -->
|
|
<div class="flex items-center gap-1">
|
|
<!-- Label badge (fixed width for alignment) -->
|
|
<span
|
|
:class="['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
|
|
>
|
|
{{ label }}
|
|
</span>
|
|
|
|
<!-- Progress bar container -->
|
|
<div class="h-1.5 w-8 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
|
<div
|
|
:class="['h-full transition-all duration-300', barClass]"
|
|
:style="{ width: barWidth }"
|
|
></div>
|
|
</div>
|
|
|
|
<!-- Percentage -->
|
|
<span :class="['w-[32px] shrink-0 text-right text-[10px] font-medium', textClass]">
|
|
{{ displayPercent }}
|
|
</span>
|
|
|
|
<!-- Reset time -->
|
|
<span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
|
|
{{ formatResetTime }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import type { WindowStats } from '@/types'
|
|
|
|
const props = defineProps<{
|
|
label: string
|
|
utilization: number // Percentage (0-100+)
|
|
resetsAt?: string | null
|
|
color: 'indigo' | 'emerald' | 'purple'
|
|
windowStats?: WindowStats | null
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
// Label background colors
|
|
const labelClass = computed(() => {
|
|
const colors = {
|
|
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
|
emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
|
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
|
|
}
|
|
return colors[props.color]
|
|
})
|
|
|
|
// Progress bar color based on utilization
|
|
const barClass = computed(() => {
|
|
if (props.utilization >= 100) {
|
|
return 'bg-red-500'
|
|
} else if (props.utilization >= 80) {
|
|
return 'bg-amber-500'
|
|
} else {
|
|
return 'bg-green-500'
|
|
}
|
|
})
|
|
|
|
// Text color based on utilization
|
|
const textClass = computed(() => {
|
|
if (props.utilization >= 100) {
|
|
return 'text-red-600 dark:text-red-400'
|
|
} else if (props.utilization >= 80) {
|
|
return 'text-amber-600 dark:text-amber-400'
|
|
} else {
|
|
return 'text-gray-600 dark:text-gray-400'
|
|
}
|
|
})
|
|
|
|
// Bar width (capped at 100%)
|
|
const barWidth = computed(() => {
|
|
return `${Math.min(props.utilization, 100)}%`
|
|
})
|
|
|
|
// Display percentage (cap at 999% for readability)
|
|
const displayPercent = computed(() => {
|
|
const percent = Math.round(props.utilization)
|
|
return percent > 999 ? '>999%' : `${percent}%`
|
|
})
|
|
|
|
// Format reset time
|
|
const formatResetTime = computed(() => {
|
|
if (!props.resetsAt) return 'N/A'
|
|
const date = new Date(props.resetsAt)
|
|
const now = new Date()
|
|
const diffMs = date.getTime() - now.getTime()
|
|
|
|
if (diffMs <= 0) return 'Now'
|
|
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
|
|
|
if (diffHours >= 24) {
|
|
const days = Math.floor(diffHours / 24)
|
|
return `${days}d ${diffHours % 24}h`
|
|
} else if (diffHours > 0) {
|
|
return `${diffHours}h ${diffMins}m`
|
|
} else {
|
|
return `${diffMins}m`
|
|
}
|
|
})
|
|
|
|
// Format window stats
|
|
const formatRequests = computed(() => {
|
|
if (!props.windowStats) return ''
|
|
const r = props.windowStats.requests
|
|
if (r >= 1000000) return `${(r / 1000000).toFixed(1)}M`
|
|
if (r >= 1000) return `${(r / 1000).toFixed(1)}K`
|
|
return r.toString()
|
|
})
|
|
|
|
const formatTokens = computed(() => {
|
|
if (!props.windowStats) return ''
|
|
const t = props.windowStats.tokens
|
|
if (t >= 1000000000) return `${(t / 1000000000).toFixed(1)}B`
|
|
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
|
|
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
|
|
return t.toString()
|
|
})
|
|
|
|
const formatCost = computed(() => {
|
|
if (!props.windowStats) return '0.00'
|
|
return props.windowStats.cost.toFixed(2)
|
|
})
|
|
</script>
|