mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 01:00:21 +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 方法调用
445 lines
12 KiB
Vue
445 lines
12 KiB
Vue
<template>
|
|
<div class="relative" ref="containerRef">
|
|
<button
|
|
type="button"
|
|
@click="toggle"
|
|
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
|
|
>
|
|
<span class="date-picker-icon">
|
|
<svg
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<span class="date-picker-value">
|
|
{{ displayValue }}
|
|
</span>
|
|
<span class="date-picker-chevron">
|
|
<svg
|
|
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
|
|
<Transition name="date-picker-dropdown">
|
|
<div v-if="isOpen" class="date-picker-dropdown">
|
|
<!-- Quick presets -->
|
|
<div class="date-picker-presets">
|
|
<button
|
|
v-for="preset in presets"
|
|
:key="preset.value"
|
|
@click="selectPreset(preset)"
|
|
:class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
|
|
>
|
|
{{ t(preset.labelKey) }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="date-picker-divider"></div>
|
|
|
|
<!-- Custom date range inputs -->
|
|
<div class="date-picker-custom">
|
|
<div class="date-picker-field">
|
|
<label class="date-picker-label">{{ t('dates.startDate') }}</label>
|
|
<input
|
|
type="date"
|
|
v-model="localStartDate"
|
|
:max="localEndDate || today"
|
|
class="date-picker-input"
|
|
@change="onDateChange"
|
|
/>
|
|
</div>
|
|
<div class="date-picker-separator">
|
|
<svg
|
|
class="h-4 w-4 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="date-picker-field">
|
|
<label class="date-picker-label">{{ t('dates.endDate') }}</label>
|
|
<input
|
|
type="date"
|
|
v-model="localEndDate"
|
|
:min="localStartDate"
|
|
:max="today"
|
|
class="date-picker-input"
|
|
@change="onDateChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Apply button -->
|
|
<div class="date-picker-actions">
|
|
<button @click="apply" class="date-picker-apply">
|
|
{{ t('dates.apply') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
interface DatePreset {
|
|
labelKey: string
|
|
value: string
|
|
getRange: () => { start: string; end: string }
|
|
}
|
|
|
|
interface Props {
|
|
startDate: string
|
|
endDate: string
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'update:startDate', value: string): void
|
|
(e: 'update:endDate', value: string): void
|
|
(e: 'change', range: { startDate: string; endDate: string; preset: string | null }): void
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const { t, locale } = useI18n()
|
|
|
|
const isOpen = ref(false)
|
|
const containerRef = ref<HTMLElement | null>(null)
|
|
const localStartDate = ref(props.startDate)
|
|
const localEndDate = ref(props.endDate)
|
|
const activePreset = ref<string | null>('7days')
|
|
|
|
const today = computed(() => {
|
|
// Use local timezone to avoid UTC timezone issues
|
|
const now = new Date()
|
|
const year = now.getFullYear()
|
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
const day = String(now.getDate()).padStart(2, '0')
|
|
return `${year}-${month}-${day}`
|
|
})
|
|
|
|
// Helper function to format date to YYYY-MM-DD using local timezone
|
|
const formatDateToString = (date: Date): string => {
|
|
const year = date.getFullYear()
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
const presets: DatePreset[] = [
|
|
{
|
|
labelKey: 'dates.today',
|
|
value: 'today',
|
|
getRange: () => {
|
|
const t = today.value
|
|
return { start: t, end: t }
|
|
}
|
|
},
|
|
{
|
|
labelKey: 'dates.yesterday',
|
|
value: 'yesterday',
|
|
getRange: () => {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() - 1)
|
|
const yesterday = formatDateToString(d)
|
|
return { start: yesterday, end: yesterday }
|
|
}
|
|
},
|
|
{
|
|
labelKey: 'dates.last7Days',
|
|
value: '7days',
|
|
getRange: () => {
|
|
const end = today.value
|
|
const d = new Date()
|
|
d.setDate(d.getDate() - 6)
|
|
const start = formatDateToString(d)
|
|
return { start, end }
|
|
}
|
|
},
|
|
{
|
|
labelKey: 'dates.last14Days',
|
|
value: '14days',
|
|
getRange: () => {
|
|
const end = today.value
|
|
const d = new Date()
|
|
d.setDate(d.getDate() - 13)
|
|
const start = formatDateToString(d)
|
|
return { start, end }
|
|
}
|
|
},
|
|
{
|
|
labelKey: 'dates.last30Days',
|
|
value: '30days',
|
|
getRange: () => {
|
|
const end = today.value
|
|
const d = new Date()
|
|
d.setDate(d.getDate() - 29)
|
|
const start = formatDateToString(d)
|
|
return { start, end }
|
|
}
|
|
},
|
|
{
|
|
labelKey: 'dates.thisMonth',
|
|
value: 'thisMonth',
|
|
getRange: () => {
|
|
const now = new Date()
|
|
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
|
|
return { start, end: today.value }
|
|
}
|
|
},
|
|
{
|
|
labelKey: 'dates.lastMonth',
|
|
value: 'lastMonth',
|
|
getRange: () => {
|
|
const now = new Date()
|
|
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
|
|
const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
|
|
return { start, end }
|
|
}
|
|
}
|
|
]
|
|
|
|
const displayValue = computed(() => {
|
|
if (activePreset.value) {
|
|
const preset = presets.find((p) => p.value === activePreset.value)
|
|
if (preset) return t(preset.labelKey)
|
|
}
|
|
|
|
if (localStartDate.value && localEndDate.value) {
|
|
if (localStartDate.value === localEndDate.value) {
|
|
return formatDate(localStartDate.value)
|
|
}
|
|
return `${formatDate(localStartDate.value)} - ${formatDate(localEndDate.value)}`
|
|
}
|
|
|
|
return t('dates.selectDateRange')
|
|
})
|
|
|
|
const formatDate = (dateStr: string): string => {
|
|
const date = new Date(dateStr + 'T00:00:00')
|
|
const dateLocale = locale.value === 'zh' ? 'zh-CN' : 'en-US'
|
|
return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
const isPresetActive = (preset: DatePreset): boolean => {
|
|
return activePreset.value === preset.value
|
|
}
|
|
|
|
const selectPreset = (preset: DatePreset) => {
|
|
const range = preset.getRange()
|
|
localStartDate.value = range.start
|
|
localEndDate.value = range.end
|
|
activePreset.value = preset.value
|
|
}
|
|
|
|
const onDateChange = () => {
|
|
// Check if current dates match any preset
|
|
activePreset.value = null
|
|
for (const preset of presets) {
|
|
const range = preset.getRange()
|
|
if (range.start === localStartDate.value && range.end === localEndDate.value) {
|
|
activePreset.value = preset.value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const toggle = () => {
|
|
isOpen.value = !isOpen.value
|
|
}
|
|
|
|
const apply = () => {
|
|
emit('update:startDate', localStartDate.value)
|
|
emit('update:endDate', localEndDate.value)
|
|
emit('change', {
|
|
startDate: localStartDate.value,
|
|
endDate: localEndDate.value,
|
|
preset: activePreset.value
|
|
})
|
|
isOpen.value = false
|
|
}
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
|
isOpen.value = false
|
|
}
|
|
}
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape' && isOpen.value) {
|
|
isOpen.value = false
|
|
}
|
|
}
|
|
|
|
// Sync local state with props
|
|
watch(
|
|
() => props.startDate,
|
|
(val) => {
|
|
localStartDate.value = val
|
|
onDateChange()
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => props.endDate,
|
|
(val) => {
|
|
localEndDate.value = val
|
|
onDateChange()
|
|
}
|
|
)
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', handleClickOutside)
|
|
document.addEventListener('keydown', handleEscape)
|
|
// Initialize active preset detection
|
|
onDateChange()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', handleClickOutside)
|
|
document.removeEventListener('keydown', handleEscape)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.date-picker-trigger {
|
|
@apply flex items-center gap-2;
|
|
@apply rounded-lg px-3 py-2 text-sm;
|
|
@apply bg-white dark:bg-dark-800;
|
|
@apply border border-gray-200 dark:border-dark-600;
|
|
@apply text-gray-700 dark:text-gray-300;
|
|
@apply transition-all duration-200;
|
|
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
|
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
|
@apply cursor-pointer;
|
|
}
|
|
|
|
.date-picker-trigger-open {
|
|
@apply border-primary-500 ring-2 ring-primary-500/30;
|
|
}
|
|
|
|
.date-picker-icon {
|
|
@apply text-gray-400 dark:text-dark-400;
|
|
}
|
|
|
|
.date-picker-value {
|
|
@apply font-medium;
|
|
}
|
|
|
|
.date-picker-chevron {
|
|
@apply text-gray-400 dark:text-dark-400;
|
|
}
|
|
|
|
.date-picker-dropdown {
|
|
@apply absolute left-0 z-[100] mt-2;
|
|
@apply bg-white dark:bg-dark-800;
|
|
@apply rounded-xl;
|
|
@apply border border-gray-200 dark:border-dark-700;
|
|
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
|
@apply overflow-hidden;
|
|
@apply min-w-[320px];
|
|
}
|
|
|
|
.date-picker-presets {
|
|
@apply grid grid-cols-2 gap-1 p-2;
|
|
}
|
|
|
|
.date-picker-preset {
|
|
@apply rounded-md px-3 py-1.5 text-xs font-medium;
|
|
@apply text-gray-600 dark:text-gray-400;
|
|
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
|
@apply transition-colors duration-150;
|
|
}
|
|
|
|
.date-picker-preset-active {
|
|
@apply bg-primary-100 dark:bg-primary-900/30;
|
|
@apply text-primary-700 dark:text-primary-300;
|
|
}
|
|
|
|
.date-picker-divider {
|
|
@apply border-t border-gray-100 dark:border-dark-700;
|
|
}
|
|
|
|
.date-picker-custom {
|
|
@apply flex items-end gap-2 p-3;
|
|
}
|
|
|
|
.date-picker-field {
|
|
@apply flex-1;
|
|
}
|
|
|
|
.date-picker-label {
|
|
@apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
|
|
}
|
|
|
|
.date-picker-input {
|
|
@apply w-full rounded-md px-2 py-1.5 text-sm;
|
|
@apply bg-gray-50 dark:bg-dark-700;
|
|
@apply border border-gray-200 dark:border-dark-600;
|
|
@apply text-gray-900 dark:text-gray-100;
|
|
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
|
}
|
|
|
|
.date-picker-input::-webkit-calendar-picker-indicator {
|
|
@apply cursor-pointer opacity-60 hover:opacity-100;
|
|
filter: invert(0.5);
|
|
}
|
|
|
|
.dark .date-picker-input::-webkit-calendar-picker-indicator {
|
|
filter: invert(0.7);
|
|
}
|
|
|
|
.date-picker-separator {
|
|
@apply flex items-center justify-center pb-1;
|
|
}
|
|
|
|
.date-picker-actions {
|
|
@apply flex justify-end p-2 pt-0;
|
|
}
|
|
|
|
.date-picker-apply {
|
|
@apply rounded-lg px-4 py-1.5 text-sm font-medium;
|
|
@apply bg-primary-600 text-white;
|
|
@apply hover:bg-primary-700;
|
|
@apply transition-colors duration-150;
|
|
}
|
|
|
|
/* Dropdown animation */
|
|
.date-picker-dropdown-enter-active,
|
|
.date-picker-dropdown-leave-active {
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.date-picker-dropdown-enter-from,
|
|
.date-picker-dropdown-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-8px);
|
|
}
|
|
</style>
|