Files
sub2api/frontend/src/views/auth/EmailVerifyView.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

493 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.verifyYourEmail') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
We'll send a verification code to
<span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
</p>
</div>
<!-- No Data Warning -->
<div
v-if="!hasRegisterData"
class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/50 dark:bg-amber-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-amber-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div class="text-sm text-amber-700 dark:text-amber-400">
<p class="font-medium">{{ t('auth.sessionExpired') }}</p>
<p class="mt-1">{{ t('auth.sessionExpiredDesc') }}</p>
</div>
</div>
</div>
<!-- Verification Form -->
<form v-else @submit.prevent="handleVerify" class="space-y-5">
<!-- Verification Code Input -->
<div>
<label for="code" class="input-label text-center">
{{ t('auth.verificationCode') }}
</label>
<input
id="code"
v-model="verifyCode"
type="text"
required
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
:disabled="isLoading"
class="input py-3 text-center font-mono text-xl tracking-[0.5em]"
:class="{ 'input-error': errors.code }"
placeholder="000000"
/>
<p v-if="errors.code" class="input-error-text text-center">
{{ errors.code }}
</p>
<p v-else class="input-hint text-center">{{ t('auth.verificationCodeHint') }}</p>
</div>
<!-- Code Status -->
<div
v-if="codeSent"
class="rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-800/50 dark:bg-green-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p class="text-sm text-green-700 dark:text-green-400">
Verification code sent! Please check your inbox.
</p>
</div>
</div>
<!-- Turnstile Widget for Resend -->
<div v-if="turnstileEnabled && turnstileSiteKey && showResendTurnstile">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button type="submit" :disabled="isLoading || !verifyCode" class="btn btn-primary w-full">
<svg
v-if="isLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ isLoading ? 'Verifying...' : 'Verify & Create Account' }}
</button>
<!-- Resend Code -->
<div class="text-center">
<button
v-if="countdown > 0"
type="button"
disabled
class="cursor-not-allowed text-sm text-gray-400 dark:text-dark-500"
>
Resend code in {{ countdown }}s
</button>
<button
v-else
type="button"
@click="handleResendCode"
:disabled="
isSendingCode || (turnstileEnabled && showResendTurnstile && !resendTurnstileToken)
"
class="text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300"
>
<span v-if="isSendingCode">{{ t('auth.sendingCode') }}</span>
<span v-else-if="turnstileEnabled && !showResendTurnstile">
{{ t('auth.clickToResend') }}
</span>
<span v-else>{{ t('auth.resendCode') }}</span>
</button>
</div>
</form>
</div>
<!-- Footer -->
<template #footer>
<button
@click="handleBack"
class="flex items-center gap-2 text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-gray-300"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
Back to registration
</button>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
const { t } = useI18n()
// ==================== Router & Stores ====================
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false)
const isSendingCode = ref<boolean>(false)
const errorMessage = ref<string>('')
const codeSent = ref<boolean>(false)
const verifyCode = ref<string>('')
const countdown = ref<number>(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
// Registration data from sessionStorage
const email = ref<string>('')
const password = ref<string>('')
const initialTurnstileToken = ref<string>('')
const hasRegisterData = ref<boolean>(false)
// Public settings
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
// Turnstile for resend
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const resendTurnstileToken = ref<string>('')
const showResendTurnstile = ref<boolean>(false)
const errors = ref({
code: '',
turnstile: ''
})
// ==================== Lifecycle ====================
onMounted(async () => {
// Load registration data from sessionStorage
const registerDataStr = sessionStorage.getItem('register_data')
if (registerDataStr) {
try {
const registerData = JSON.parse(registerDataStr)
email.value = registerData.email || ''
password.value = registerData.password || ''
initialTurnstileToken.value = registerData.turnstile_token || ''
hasRegisterData.value = !!(email.value && password.value)
} catch {
hasRegisterData.value = false
}
}
// Load public settings
try {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
} catch (error) {
console.error('Failed to load public settings:', error)
}
// Auto-send verification code if we have valid data
if (hasRegisterData.value) {
await sendCode()
}
})
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
// ==================== Countdown ====================
function startCountdown(seconds: number): void {
countdown.value = seconds
if (countdownTimer) {
clearInterval(countdownTimer)
}
countdownTimer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
} else {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
}, 1000)
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
resendTurnstileToken.value = token
errors.value.turnstile = ''
}
function onTurnstileExpire(): void {
resendTurnstileToken.value = ''
errors.value.turnstile = 'Verification expired, please try again'
}
function onTurnstileError(): void {
resendTurnstileToken.value = ''
errors.value.turnstile = 'Verification failed, please try again'
}
// ==================== Send Code ====================
async function sendCode(): Promise<void> {
isSendingCode.value = true
errorMessage.value = ''
try {
const response = await sendVerifyCode({
email: email.value,
// 优先使用重发时新获取的 token因为初始 token 可能已被使用)
turnstile_token: resendTurnstileToken.value || initialTurnstileToken.value || undefined
})
codeSent.value = true
startCountdown(response.countdown)
// Reset turnstile statetoken 已使用,清除以避免重复使用)
initialTurnstileToken.value = ''
showResendTurnstile.value = false
resendTurnstileToken.value = ''
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Failed to send verification code. Please try again.'
}
appStore.showError(errorMessage.value)
} finally {
isSendingCode.value = false
}
}
// ==================== Handlers ====================
async function handleResendCode(): Promise<void> {
// If turnstile is enabled and we haven't shown it yet, show it
if (turnstileEnabled.value && !showResendTurnstile.value) {
showResendTurnstile.value = true
return
}
// If turnstile is enabled but no token yet, wait
if (turnstileEnabled.value && !resendTurnstileToken.value) {
errors.value.turnstile = 'Please complete the verification'
return
}
await sendCode()
}
function validateForm(): boolean {
errors.value.code = ''
if (!verifyCode.value.trim()) {
errors.value.code = 'Verification code is required'
return false
}
if (!/^\d{6}$/.test(verifyCode.value.trim())) {
errors.value.code = 'Please enter a valid 6-digit code'
return false
}
return true
}
async function handleVerify(): Promise<void> {
errorMessage.value = ''
if (!validateForm()) {
return
}
isLoading.value = true
try {
// Register with verification code
await authStore.register({
email: email.value,
password: password.value,
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined
})
// Clear session data
sessionStorage.removeItem('register_data')
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.')
// Redirect to dashboard
await router.push('/dashboard')
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Verification failed. Please try again.'
}
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false
}
}
function handleBack(): void {
// Clear session data
sessionStorage.removeItem('register_data')
// Go back to registration
router.push('/register')
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>