2025-12-30 17:15:48 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
v-model:open="isOpen"
|
|
|
|
|
|
size="lg"
|
|
|
|
|
|
>
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<div class="space-y-6">
|
|
|
|
|
|
<!-- Logo 和标题 -->
|
|
|
|
|
|
<div class="flex flex-col items-center text-center">
|
|
|
|
|
|
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
|
2025-12-30 17:15:48 +08:00
|
|
|
|
<img
|
2026-01-01 02:10:19 +08:00
|
|
|
|
src="/aether_adaptive.svg"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
alt="Logo"
|
2026-01-01 02:10:19 +08:00
|
|
|
|
class="h-16 w-16"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
>
|
|
|
|
|
|
</div>
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
2025-12-30 17:15:48 +08:00
|
|
|
|
注册新账户
|
2026-01-01 02:10:19 +08:00
|
|
|
|
</h2>
|
|
|
|
|
|
<p class="mt-1 text-sm text-muted-foreground">
|
2025-12-30 17:15:48 +08:00
|
|
|
|
请填写您的邮箱和个人信息完成注册
|
2026-01-01 02:10:19 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-12-30 17:15:48 +08:00
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<!-- 注册表单 -->
|
2025-12-30 17:15:48 +08:00
|
|
|
|
<form
|
2026-01-01 02:10:19 +08:00
|
|
|
|
class="space-y-4"
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
data-form-type="other"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
@submit.prevent="handleSubmit"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- Email -->
|
|
|
|
|
|
<div class="space-y-2">
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<Label for="reg-email">邮箱 <span class="text-muted-foreground">*</span></Label>
|
2025-12-30 17:15:48 +08:00
|
|
|
|
<Input
|
2026-01-01 02:10:19 +08:00
|
|
|
|
id="reg-email"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
v-model="formData.email"
|
|
|
|
|
|
type="email"
|
2026-01-01 02:10:19 +08:00
|
|
|
|
placeholder="hello@example.com"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
required
|
2026-01-01 02:10:19 +08:00
|
|
|
|
disable-autofill
|
2025-12-30 17:15:48 +08:00
|
|
|
|
:disabled="isLoading || emailVerified"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Verification Code Section -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="requireEmailVerification"
|
2026-01-01 02:10:19 +08:00
|
|
|
|
class="space-y-3"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center justify-between">
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<Label>验证码 <span class="text-muted-foreground">*</span></Label>
|
2025-12-30 17:15:48 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="link"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-auto p-0 text-xs"
|
2026-01-01 02:10:19 +08:00
|
|
|
|
:disabled="isSendingCode || !canSendCode || emailVerified"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
@click="handleSendCode"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ sendCodeButtonText }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<div class="flex justify-center gap-2">
|
|
|
|
|
|
<!-- 发送中显示 loading -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="isSendingCode"
|
|
|
|
|
|
class="flex items-center justify-center gap-2 h-14 text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg
|
|
|
|
|
|
class="animate-spin h-5 w-5"
|
|
|
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
>
|
|
|
|
|
|
<circle
|
|
|
|
|
|
class="opacity-25"
|
|
|
|
|
|
cx="12"
|
|
|
|
|
|
cy="12"
|
|
|
|
|
|
r="10"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
stroke-width="4"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<span class="text-sm">正在发送验证码...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 验证码输入框 -->
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-for="(_, index) in 6"
|
|
|
|
|
|
:key="index"
|
|
|
|
|
|
:ref="(el) => setCodeInputRef(index, el as HTMLInputElement)"
|
|
|
|
|
|
v-model="codeDigits[index]"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
|
maxlength="1"
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
data-form-type="other"
|
|
|
|
|
|
class="w-12 h-14 text-center text-xl font-semibold border-2 rounded-lg bg-background transition-all focus:outline-none focus:ring-2 focus:ring-primary/20"
|
|
|
|
|
|
:class="verificationError ? 'border-destructive' : 'border-border focus:border-primary'"
|
|
|
|
|
|
:disabled="emailVerified"
|
|
|
|
|
|
@input="handleCodeInput(index, $event)"
|
|
|
|
|
|
@keydown="handleCodeKeyDown(index, $event)"
|
|
|
|
|
|
@paste="handleCodePaste"
|
|
|
|
|
|
>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
2025-12-30 17:15:48 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Username -->
|
|
|
|
|
|
<div class="space-y-2">
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<Label for="reg-uname">用户名 <span class="text-muted-foreground">*</span></Label>
|
2025-12-30 17:15:48 +08:00
|
|
|
|
<Input
|
2026-01-01 02:10:19 +08:00
|
|
|
|
id="reg-uname"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
v-model="formData.username"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="请输入用户名"
|
|
|
|
|
|
required
|
2026-01-01 02:10:19 +08:00
|
|
|
|
disable-autofill
|
2025-12-30 17:15:48 +08:00
|
|
|
|
:disabled="isLoading"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Password -->
|
|
|
|
|
|
<div class="space-y-2">
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<Label :for="`pwd-${formNonce}`">密码 <span class="text-muted-foreground">*</span></Label>
|
2025-12-30 17:15:48 +08:00
|
|
|
|
<Input
|
2026-01-01 02:10:19 +08:00
|
|
|
|
:id="`pwd-${formNonce}`"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
v-model="formData.password"
|
2026-01-01 02:10:19 +08:00
|
|
|
|
type="text"
|
|
|
|
|
|
autocomplete="one-time-code"
|
|
|
|
|
|
data-form-type="other"
|
|
|
|
|
|
data-lpignore="true"
|
|
|
|
|
|
data-1p-ignore="true"
|
|
|
|
|
|
:name="`pwd-${formNonce}`"
|
|
|
|
|
|
placeholder="至少 6 个字符"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
required
|
2026-01-01 02:10:19 +08:00
|
|
|
|
class="-webkit-text-security-disc"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
:disabled="isLoading"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Confirm Password -->
|
|
|
|
|
|
<div class="space-y-2">
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-muted-foreground">*</span></Label>
|
2025-12-30 17:15:48 +08:00
|
|
|
|
<Input
|
2026-01-01 02:10:19 +08:00
|
|
|
|
:id="`pwd-confirm-${formNonce}`"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
v-model="formData.confirmPassword"
|
2026-01-01 02:10:19 +08:00
|
|
|
|
type="text"
|
|
|
|
|
|
autocomplete="one-time-code"
|
|
|
|
|
|
data-form-type="other"
|
|
|
|
|
|
data-lpignore="true"
|
|
|
|
|
|
data-1p-ignore="true"
|
|
|
|
|
|
:name="`pwd-confirm-${formNonce}`"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
placeholder="再次输入密码"
|
|
|
|
|
|
required
|
2026-01-01 02:10:19 +08:00
|
|
|
|
class="-webkit-text-security-disc"
|
2025-12-30 17:15:48 +08:00
|
|
|
|
:disabled="isLoading"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
<!-- 登录链接 -->
|
|
|
|
|
|
<div class="text-center text-sm">
|
2025-12-30 17:15:48 +08:00
|
|
|
|
已有账户?
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="link"
|
|
|
|
|
|
class="h-auto p-0"
|
|
|
|
|
|
@click="handleSwitchToLogin"
|
|
|
|
|
|
>
|
|
|
|
|
|
立即登录
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-01-01 02:10:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
|
|
|
|
|
|
:disabled="isLoading"
|
|
|
|
|
|
@click="handleCancel"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
|
|
|
|
|
|
:disabled="isLoading || !canSubmit"
|
|
|
|
|
|
@click="handleSubmit"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ isLoading ? loadingText : '注册' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
2025-12-30 17:15:48 +08:00
|
|
|
|
</Dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-01-01 02:10:19 +08:00
|
|
|
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
2025-12-30 17:15:48 +08:00
|
|
|
|
import { authApi } from '@/api/auth'
|
|
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2026-01-01 02:10:19 +08:00
|
|
|
|
import { Dialog } from '@/components/ui'
|
|
|
|
|
|
import Button from '@/components/ui/button.vue'
|
|
|
|
|
|
import Input from '@/components/ui/input.vue'
|
|
|
|
|
|
import Label from '@/components/ui/label.vue'
|
2025-12-30 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
open?: boolean
|
|
|
|
|
|
requireEmailVerification?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface Emits {
|
|
|
|
|
|
(e: 'update:open', value: boolean): void
|
|
|
|
|
|
(e: 'success'): void
|
|
|
|
|
|
(e: 'switchToLogin'): void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
|
open: false,
|
|
|
|
|
|
requireEmailVerification: false
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<Emits>()
|
2026-01-01 02:10:19 +08:00
|
|
|
|
const { success, error: showError } = useToast()
|
|
|
|
|
|
|
|
|
|
|
|
// Form nonce for password fields (prevent autofill)
|
|
|
|
|
|
const formNonce = ref(createFormNonce())
|
|
|
|
|
|
|
|
|
|
|
|
function createFormNonce(): string {
|
|
|
|
|
|
return Math.random().toString(36).slice(2, 10)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Verification code inputs
|
|
|
|
|
|
const codeInputRefs = ref<(HTMLInputElement | null)[]>([])
|
|
|
|
|
|
const codeDigits = ref<string[]>(['', '', '', '', '', ''])
|
|
|
|
|
|
|
|
|
|
|
|
const setCodeInputRef = (index: number, el: HTMLInputElement | null) => {
|
|
|
|
|
|
codeInputRefs.value[index] = el
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Handle verification code input
|
|
|
|
|
|
const handleCodeInput = (index: number, event: Event) => {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
const value = input.value
|
|
|
|
|
|
|
|
|
|
|
|
// Only allow digits
|
|
|
|
|
|
if (!/^\d*$/.test(value)) {
|
|
|
|
|
|
input.value = codeDigits.value[index]
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
codeDigits.value[index] = value
|
|
|
|
|
|
|
|
|
|
|
|
// Auto-focus next input
|
|
|
|
|
|
if (value && index < 5) {
|
|
|
|
|
|
codeInputRefs.value[index + 1]?.focus()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if all digits are filled
|
|
|
|
|
|
const fullCode = codeDigits.value.join('')
|
|
|
|
|
|
if (fullCode.length === 6 && /^\d+$/.test(fullCode)) {
|
|
|
|
|
|
handleCodeComplete(fullCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCodeKeyDown = (index: number, event: KeyboardEvent) => {
|
|
|
|
|
|
// Handle backspace
|
|
|
|
|
|
if (event.key === 'Backspace') {
|
|
|
|
|
|
if (!codeDigits.value[index] && index > 0) {
|
|
|
|
|
|
// If current input is empty, move to previous and clear it
|
|
|
|
|
|
codeInputRefs.value[index - 1]?.focus()
|
|
|
|
|
|
codeDigits.value[index - 1] = ''
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Clear current input
|
|
|
|
|
|
codeDigits.value[index] = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Handle arrow keys
|
|
|
|
|
|
else if (event.key === 'ArrowLeft' && index > 0) {
|
|
|
|
|
|
codeInputRefs.value[index - 1]?.focus()
|
|
|
|
|
|
} else if (event.key === 'ArrowRight' && index < 5) {
|
|
|
|
|
|
codeInputRefs.value[index + 1]?.focus()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCodePaste = (event: ClipboardEvent) => {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
const pastedData = event.clipboardData?.getData('text') || ''
|
|
|
|
|
|
const cleanedData = pastedData.replace(/\D/g, '').slice(0, 6)
|
|
|
|
|
|
|
|
|
|
|
|
if (cleanedData) {
|
|
|
|
|
|
// Fill digits
|
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
|
codeDigits.value[i] = cleanedData[i] || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Focus the next empty input or the last input
|
|
|
|
|
|
const nextEmptyIndex = codeDigits.value.findIndex((d) => !d)
|
|
|
|
|
|
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : 5
|
|
|
|
|
|
codeInputRefs.value[focusIndex]?.focus()
|
2025-12-30 17:15:48 +08:00
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
// Check if all digits are filled
|
|
|
|
|
|
if (cleanedData.length === 6) {
|
|
|
|
|
|
handleCodeComplete(cleanedData)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const clearCodeInputs = () => {
|
|
|
|
|
|
codeDigits.value = ['', '', '', '', '', '']
|
|
|
|
|
|
codeInputRefs.value[0]?.focus()
|
|
|
|
|
|
}
|
2025-12-30 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
const isOpen = computed({
|
|
|
|
|
|
get: () => props.open,
|
|
|
|
|
|
set: (value) => emit('update:open', value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const formData = ref({
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
confirmPassword: '',
|
|
|
|
|
|
verificationCode: ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const isLoading = ref(false)
|
|
|
|
|
|
const loadingText = ref('注册中...')
|
2026-01-01 02:10:19 +08:00
|
|
|
|
const isSendingCode = ref(false)
|
2025-12-30 17:15:48 +08:00
|
|
|
|
const emailVerified = ref(false)
|
|
|
|
|
|
const verificationError = ref(false)
|
|
|
|
|
|
const codeSentAt = ref<number | null>(null)
|
|
|
|
|
|
const cooldownSeconds = ref(0)
|
2026-01-01 02:10:19 +08:00
|
|
|
|
const expireMinutes = ref(5)
|
2025-12-30 17:15:48 +08:00
|
|
|
|
const cooldownTimer = ref<number | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// Send code cooldown timer
|
|
|
|
|
|
const canSendCode = computed(() => {
|
|
|
|
|
|
if (!formData.value.email) return false
|
|
|
|
|
|
if (cooldownSeconds.value > 0) return false
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const sendCodeButtonText = computed(() => {
|
2026-01-01 02:10:19 +08:00
|
|
|
|
if (isSendingCode.value) return '发送中...'
|
|
|
|
|
|
if (emailVerified.value) return '验证成功'
|
2025-12-30 17:15:48 +08:00
|
|
|
|
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
|
|
|
|
|
if (codeSentAt.value) return '重新发送验证码'
|
|
|
|
|
|
return '发送验证码'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const canSubmit = computed(() => {
|
|
|
|
|
|
const hasBasicInfo =
|
|
|
|
|
|
formData.value.email &&
|
|
|
|
|
|
formData.value.username &&
|
|
|
|
|
|
formData.value.password &&
|
|
|
|
|
|
formData.value.confirmPassword
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasBasicInfo) return false
|
|
|
|
|
|
|
|
|
|
|
|
// If email verification is required, check if verified
|
|
|
|
|
|
if (props.requireEmailVerification && !emailVerified.value) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check password match
|
|
|
|
|
|
if (formData.value.password !== formData.value.confirmPassword) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check password length
|
2026-01-01 02:10:19 +08:00
|
|
|
|
if (formData.value.password.length < 6) {
|
2025-12-30 17:15:48 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
// 查询并恢复验证状态
|
|
|
|
|
|
const checkAndRestoreVerificationStatus = async (email: string) => {
|
|
|
|
|
|
if (!email || !props.requireEmailVerification) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const status = await authApi.getVerificationStatus(email)
|
|
|
|
|
|
|
|
|
|
|
|
// 注意:不恢复 is_verified 状态
|
|
|
|
|
|
// 刷新页面后需要重新发送验证码并验证,防止验证码被他人使用
|
|
|
|
|
|
// 只恢复"有待验证验证码"的状态(冷却时间)
|
|
|
|
|
|
if (status.has_pending_code) {
|
|
|
|
|
|
codeSentAt.value = Date.now()
|
|
|
|
|
|
verificationError.value = false
|
|
|
|
|
|
|
|
|
|
|
|
// 恢复冷却时间
|
|
|
|
|
|
if (status.cooldown_remaining && status.cooldown_remaining > 0) {
|
|
|
|
|
|
startCooldown(status.cooldown_remaining)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 查询失败时静默处理,不影响用户体验
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 邮箱查询防抖定时器
|
|
|
|
|
|
let emailCheckTimer: number | null = null
|
|
|
|
|
|
|
|
|
|
|
|
// 监听邮箱变化,查询验证状态
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => formData.value.email,
|
|
|
|
|
|
(newEmail, oldEmail) => {
|
|
|
|
|
|
// 邮箱变化时重置验证状态
|
|
|
|
|
|
if (newEmail !== oldEmail) {
|
|
|
|
|
|
emailVerified.value = false
|
|
|
|
|
|
verificationError.value = false
|
|
|
|
|
|
codeSentAt.value = null
|
|
|
|
|
|
cooldownSeconds.value = 0
|
|
|
|
|
|
if (cooldownTimer.value !== null) {
|
|
|
|
|
|
clearInterval(cooldownTimer.value)
|
|
|
|
|
|
cooldownTimer.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
codeDigits.value = ['', '', '', '', '', '']
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清除之前的定时器
|
|
|
|
|
|
if (emailCheckTimer !== null) {
|
|
|
|
|
|
clearTimeout(emailCheckTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证邮箱格式
|
|
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
|
|
if (!emailRegex.test(newEmail)) return
|
|
|
|
|
|
|
|
|
|
|
|
// 防抖:500ms 后查询验证状态
|
|
|
|
|
|
emailCheckTimer = window.setTimeout(() => {
|
|
|
|
|
|
checkAndRestoreVerificationStatus(newEmail)
|
|
|
|
|
|
}, 500)
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-30 17:15:48 +08:00
|
|
|
|
// Reset form when dialog opens
|
|
|
|
|
|
watch(isOpen, (newValue) => {
|
|
|
|
|
|
if (newValue) {
|
|
|
|
|
|
resetForm()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Start cooldown timer
|
|
|
|
|
|
const startCooldown = (seconds: number) => {
|
|
|
|
|
|
// Clear existing timer if any
|
|
|
|
|
|
if (cooldownTimer.value !== null) {
|
|
|
|
|
|
clearInterval(cooldownTimer.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cooldownSeconds.value = seconds
|
|
|
|
|
|
cooldownTimer.value = window.setInterval(() => {
|
|
|
|
|
|
cooldownSeconds.value--
|
|
|
|
|
|
if (cooldownSeconds.value <= 0) {
|
|
|
|
|
|
if (cooldownTimer.value !== null) {
|
|
|
|
|
|
clearInterval(cooldownTimer.value)
|
|
|
|
|
|
cooldownTimer.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 1000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cleanup timer on unmount
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (cooldownTimer.value !== null) {
|
|
|
|
|
|
clearInterval(cooldownTimer.value)
|
|
|
|
|
|
}
|
2026-01-01 02:10:19 +08:00
|
|
|
|
if (emailCheckTimer !== null) {
|
|
|
|
|
|
clearTimeout(emailCheckTimer)
|
|
|
|
|
|
}
|
2025-12-30 17:15:48 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const resetForm = () => {
|
|
|
|
|
|
formData.value = {
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
confirmPassword: '',
|
|
|
|
|
|
verificationCode: ''
|
|
|
|
|
|
}
|
|
|
|
|
|
emailVerified.value = false
|
|
|
|
|
|
verificationError.value = false
|
2026-01-01 02:10:19 +08:00
|
|
|
|
isSendingCode.value = false
|
2025-12-30 17:15:48 +08:00
|
|
|
|
codeSentAt.value = null
|
|
|
|
|
|
cooldownSeconds.value = 0
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
// Reset password field nonce
|
|
|
|
|
|
formNonce.value = createFormNonce()
|
|
|
|
|
|
|
2025-12-30 17:15:48 +08:00
|
|
|
|
// Clear timer
|
|
|
|
|
|
if (cooldownTimer.value !== null) {
|
|
|
|
|
|
clearInterval(cooldownTimer.value)
|
|
|
|
|
|
cooldownTimer.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
// Clear verification code inputs
|
|
|
|
|
|
codeDigits.value = ['', '', '', '', '', '']
|
2025-12-30 17:15:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSendCode = async () => {
|
|
|
|
|
|
if (!formData.value.email) {
|
|
|
|
|
|
showError('请输入邮箱')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Basic email validation
|
|
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
|
|
if (!emailRegex.test(formData.value.email)) {
|
|
|
|
|
|
showError('请输入有效的邮箱地址', '邮箱格式错误')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
isSendingCode.value = true
|
2025-12-30 17:15:48 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authApi.sendVerificationCode(formData.value.email)
|
|
|
|
|
|
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
|
codeSentAt.value = Date.now()
|
|
|
|
|
|
if (response.expire_minutes) {
|
|
|
|
|
|
expireMinutes.value = response.expire_minutes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
success(`请查收邮件,验证码有效期 ${expireMinutes.value} 分钟`, '验证码已发送')
|
|
|
|
|
|
|
|
|
|
|
|
// Start 60 second cooldown
|
|
|
|
|
|
startCooldown(60)
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
// Focus the first verification code input
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
codeInputRefs.value[0]?.focus()
|
|
|
|
|
|
})
|
2025-12-30 17:15:48 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
showError(response.message || '请稍后重试', '发送失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
2026-01-01 02:10:19 +08:00
|
|
|
|
const errorMsg = error.response?.data?.detail
|
|
|
|
|
|
|| error.response?.data?.error?.message
|
|
|
|
|
|
|| error.message
|
|
|
|
|
|
|| '网络错误,请重试'
|
|
|
|
|
|
showError(errorMsg, '发送失败')
|
2025-12-30 17:15:48 +08:00
|
|
|
|
} finally {
|
2026-01-01 02:10:19 +08:00
|
|
|
|
isSendingCode.value = false
|
2025-12-30 17:15:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCodeComplete = async (code: string) => {
|
|
|
|
|
|
if (!formData.value.email || code.length !== 6) return
|
|
|
|
|
|
|
2026-01-01 02:10:19 +08:00
|
|
|
|
// 如果已经验证成功,不再重复验证
|
|
|
|
|
|
if (emailVerified.value) return
|
|
|
|
|
|
|
2025-12-30 17:15:48 +08:00
|
|
|
|
isLoading.value = true
|
|
|
|
|
|
loadingText.value = '验证中...'
|
|
|
|
|
|
verificationError.value = false
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authApi.verifyEmail(formData.value.email, code)
|
|
|
|
|
|
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
|
emailVerified.value = true
|
|
|
|
|
|
success('邮箱验证通过,请继续完成注册', '验证成功')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
verificationError.value = true
|
|
|
|
|
|
showError(response.message || '验证码错误', '验证失败')
|
|
|
|
|
|
// Clear the code input
|
2026-01-01 02:10:19 +08:00
|
|
|
|
clearCodeInputs()
|
2025-12-30 17:15:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
verificationError.value = true
|
2026-01-01 02:10:19 +08:00
|
|
|
|
const errorMsg = error.response?.data?.detail
|
|
|
|
|
|
|| error.response?.data?.error?.message
|
|
|
|
|
|
|| error.message
|
|
|
|
|
|
|| '验证码错误,请重试'
|
|
|
|
|
|
showError(errorMsg, '验证失败')
|
2025-12-30 17:15:48 +08:00
|
|
|
|
// Clear the code input
|
2026-01-01 02:10:19 +08:00
|
|
|
|
clearCodeInputs()
|
2025-12-30 17:15:48 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
|
// Validate password match
|
|
|
|
|
|
if (formData.value.password !== formData.value.confirmPassword) {
|
|
|
|
|
|
showError('两次输入的密码不一致', '密码不匹配')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate password length
|
2026-01-01 02:10:19 +08:00
|
|
|
|
if (formData.value.password.length < 6) {
|
|
|
|
|
|
showError('密码长度至少 6 位', '密码过短')
|
2025-12-30 17:15:48 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check email verification if required
|
|
|
|
|
|
if (props.requireEmailVerification && !emailVerified.value) {
|
|
|
|
|
|
showError('请先完成邮箱验证')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isLoading.value = true
|
|
|
|
|
|
loadingText.value = '注册中...'
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authApi.register({
|
|
|
|
|
|
email: formData.value.email,
|
|
|
|
|
|
username: formData.value.username,
|
|
|
|
|
|
password: formData.value.password
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
success(response.message || '欢迎加入!请登录以继续', '注册成功')
|
|
|
|
|
|
|
|
|
|
|
|
emit('success')
|
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
|
} catch (error: any) {
|
2026-01-01 02:10:19 +08:00
|
|
|
|
const errorMsg = error.response?.data?.detail
|
|
|
|
|
|
|| error.response?.data?.error?.message
|
|
|
|
|
|
|| error.message
|
|
|
|
|
|
|| '注册失败,请重试'
|
|
|
|
|
|
showError(errorMsg, '注册失败')
|
2025-12-30 17:15:48 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCancel = () => {
|
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSwitchToLogin = () => {
|
|
|
|
|
|
emit('switchToLogin')
|
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|