mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-14 05:25:19 +08:00
添加完整的邮箱验证注册系统,包括验证码发送、验证和限流控制: - 新增邮箱验证服务模块(email_sender, email_template, email_verification) - 更新认证API支持邮箱验证注册流程 - 添加注册对话框和验证码输入组件 - 完善IP限流器支持邮箱验证场景 - 修复前端类型定义问题,升级esbuild依赖 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
444 lines
11 KiB
Vue
444 lines
11 KiB
Vue
<template>
|
||
<Dialog
|
||
v-model:open="isOpen"
|
||
size="lg"
|
||
>
|
||
<DialogContent>
|
||
<!-- Logo -->
|
||
<div class="flex justify-center mb-6">
|
||
<div
|
||
class="w-16 h-16 rounded-full border-2 border-primary/20 flex items-center justify-center bg-primary/5"
|
||
>
|
||
<img
|
||
src="@/assets/logo.svg"
|
||
alt="Logo"
|
||
class="w-10 h-10"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogHeader>
|
||
<DialogTitle class="text-center text-2xl">
|
||
注册新账户
|
||
</DialogTitle>
|
||
<DialogDescription class="text-center">
|
||
请填写您的邮箱和个人信息完成注册
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form
|
||
class="space-y-4 mt-4"
|
||
@submit.prevent="handleSubmit"
|
||
>
|
||
<!-- Email -->
|
||
<div class="space-y-2">
|
||
<Label for="register-email">邮箱</Label>
|
||
<Input
|
||
id="register-email"
|
||
v-model="formData.email"
|
||
type="email"
|
||
placeholder="your@email.com"
|
||
required
|
||
:disabled="isLoading || emailVerified"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Verification Code Section -->
|
||
<div
|
||
v-if="requireEmailVerification"
|
||
class="space-y-2"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<Label for="verification-code">验证码</Label>
|
||
<Button
|
||
type="button"
|
||
variant="link"
|
||
size="sm"
|
||
class="h-auto p-0 text-xs"
|
||
:disabled="isLoading || !canSendCode || emailVerified"
|
||
@click="handleSendCode"
|
||
>
|
||
{{ sendCodeButtonText }}
|
||
</Button>
|
||
</div>
|
||
<VerificationCodeInput
|
||
ref="codeInputRef"
|
||
v-model="formData.verificationCode"
|
||
:has-error="verificationError"
|
||
:length="6"
|
||
@complete="handleCodeComplete"
|
||
/>
|
||
<p
|
||
v-if="verificationError"
|
||
class="text-xs text-destructive"
|
||
>
|
||
验证码错误,请重新输入
|
||
</p>
|
||
<p
|
||
v-if="emailVerified"
|
||
class="text-xs text-green-600"
|
||
>
|
||
✓ 邮箱验证成功
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Username -->
|
||
<div class="space-y-2">
|
||
<Label for="register-username">用户名</Label>
|
||
<Input
|
||
id="register-username"
|
||
v-model="formData.username"
|
||
type="text"
|
||
placeholder="请输入用户名"
|
||
required
|
||
:disabled="isLoading"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Password -->
|
||
<div class="space-y-2">
|
||
<Label for="register-password">密码</Label>
|
||
<Input
|
||
id="register-password"
|
||
v-model="formData.password"
|
||
type="password"
|
||
placeholder="至少 8 位字符"
|
||
required
|
||
:disabled="isLoading"
|
||
/>
|
||
<p class="text-xs text-muted-foreground">
|
||
密码长度至少 8 位
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Confirm Password -->
|
||
<div class="space-y-2">
|
||
<Label for="register-confirm-password">确认密码</Label>
|
||
<Input
|
||
id="register-confirm-password"
|
||
v-model="formData.confirmPassword"
|
||
type="password"
|
||
placeholder="再次输入密码"
|
||
required
|
||
:disabled="isLoading"
|
||
/>
|
||
</div>
|
||
|
||
<DialogFooter class="gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
:disabled="isLoading"
|
||
@click="handleCancel"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
:disabled="isLoading || !canSubmit"
|
||
>
|
||
<span
|
||
v-if="isLoading"
|
||
class="flex items-center gap-2"
|
||
>
|
||
<span class="animate-spin">⏳</span>
|
||
{{ loadingText }}
|
||
</span>
|
||
<span v-else>注册</span>
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
|
||
<div class="mt-4 text-center text-sm">
|
||
已有账户?
|
||
<Button
|
||
variant="link"
|
||
class="h-auto p-0"
|
||
@click="handleSwitchToLogin"
|
||
>
|
||
立即登录
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||
import { authApi } from '@/api/auth'
|
||
import { useToast } from '@/composables/useToast'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
Button,
|
||
Input,
|
||
Label
|
||
} from '@/components/ui'
|
||
import VerificationCodeInput from '@/components/VerificationCodeInput.vue'
|
||
|
||
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>()
|
||
const { showToast, success, error: showError } = useToast()
|
||
|
||
const codeInputRef = ref<InstanceType<typeof VerificationCodeInput> | null>(null)
|
||
|
||
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('注册中...')
|
||
const emailVerified = ref(false)
|
||
const verificationError = ref(false)
|
||
const codeSentAt = ref<number | null>(null)
|
||
const cooldownSeconds = ref(0)
|
||
const expireMinutes = ref(30)
|
||
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(() => {
|
||
if (emailVerified.value) return '已验证'
|
||
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
|
||
if (formData.value.password.length < 8) {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
})
|
||
|
||
// 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)
|
||
}
|
||
})
|
||
|
||
const resetForm = () => {
|
||
formData.value = {
|
||
email: '',
|
||
username: '',
|
||
password: '',
|
||
confirmPassword: '',
|
||
verificationCode: ''
|
||
}
|
||
emailVerified.value = false
|
||
verificationError.value = false
|
||
codeSentAt.value = null
|
||
cooldownSeconds.value = 0
|
||
|
||
// Clear timer
|
||
if (cooldownTimer.value !== null) {
|
||
clearInterval(cooldownTimer.value)
|
||
cooldownTimer.value = null
|
||
}
|
||
|
||
codeInputRef.value?.clear()
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
isLoading.value = true
|
||
loadingText.value = '发送中...'
|
||
|
||
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)
|
||
|
||
// Focus the verification code input
|
||
setTimeout(() => {
|
||
codeInputRef.value?.focus()
|
||
}, 100)
|
||
} else {
|
||
showError(response.message || '请稍后重试', '发送失败')
|
||
}
|
||
} catch (error: any) {
|
||
showError(error.response?.data?.detail || error.message || '网络错误,请重试', '发送失败')
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleCodeComplete = async (code: string) => {
|
||
if (!formData.value.email || code.length !== 6) return
|
||
|
||
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
|
||
codeInputRef.value?.clear()
|
||
}
|
||
} catch (error: any) {
|
||
verificationError.value = true
|
||
showError(error.response?.data?.detail || error.message || '验证码错误,请重试', '验证失败')
|
||
// Clear the code input
|
||
codeInputRef.value?.clear()
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
// Validate password match
|
||
if (formData.value.password !== formData.value.confirmPassword) {
|
||
showError('两次输入的密码不一致', '密码不匹配')
|
||
return
|
||
}
|
||
|
||
// Validate password length
|
||
if (formData.value.password.length < 8) {
|
||
showError('密码长度至少 8 位', '密码过短')
|
||
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) {
|
||
showError(error.response?.data?.detail || error.message || '注册失败,请重试', '注册失败')
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleCancel = () => {
|
||
isOpen.value = false
|
||
}
|
||
|
||
const handleSwitchToLogin = () => {
|
||
emit('switchToLogin')
|
||
isOpen.value = false
|
||
}
|
||
</script>
|