sub2api/frontend/src/views/auth/EmailVerifyView.vue
ianshaw 5763f5ced3 style(frontend): 统一 Views 模块代码风格
- 移除语句末尾分号,规范代码格式
- 优化组件结构和类型定义
- 改进视图文档和示例
- 提升代码一致性
2025-12-26 00:10:44 -08:00

484 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">Verify Your Email</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">Session expired</p>
<p class="mt-1">Please go back to the registration page and start again.</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"> Verification Code </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">Enter the 6-digit code sent to your email</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">Sending...</span>
<span v-else-if="turnstileEnabled && !showResendTurnstile">Click to resend code</span>
<span v-else>Resend verification code</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 { AuthLayout } from '@/components/layout'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
// ==================== 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>