327 lines
7.2 KiB
Vue
327 lines
7.2 KiB
Vue
<template>
|
||
<div class="forgot-password-view">
|
||
<BaseCard class="forgot-password-card">
|
||
<template #header>
|
||
<div class="back-button">
|
||
<QBtn
|
||
flat
|
||
round
|
||
icon="arrow_back"
|
||
size="sm"
|
||
@click="router.back()"
|
||
aria-label="返回上一頁"
|
||
/>
|
||
</div>
|
||
<h2 class="forgot-password-title">忘記密碼</h2>
|
||
<p class="forgot-password-subtitle">
|
||
輸入你的電子郵件地址,我們將發送重設密碼的連結給你
|
||
</p>
|
||
</template>
|
||
|
||
<div v-if="!emailSent" class="forgot-password-form">
|
||
<form @submit.prevent="handleSendResetEmail">
|
||
<BaseInput
|
||
v-model="email"
|
||
type="email"
|
||
label="電子郵件"
|
||
placeholder="請輸入你的電子郵件地址"
|
||
prefix-icon="email"
|
||
:error="emailError"
|
||
:disabled="isLoading"
|
||
required
|
||
/>
|
||
|
||
<BaseButton
|
||
type="submit"
|
||
variant="primary"
|
||
size="lg"
|
||
block
|
||
:loading="isLoading"
|
||
:disabled="!canSubmit"
|
||
>
|
||
發送重設連結
|
||
</BaseButton>
|
||
</form>
|
||
</div>
|
||
|
||
<div v-else class="success-message">
|
||
<div class="success-icon">
|
||
<QIcon name="mark_email_read" />
|
||
</div>
|
||
<h3>郵件已發送</h3>
|
||
<p>
|
||
我們已將重設密碼的連結發送到 <strong>{{ email }}</strong>
|
||
</p>
|
||
<p class="instruction">
|
||
請檢查你的收件匣(也可能在垃圾郵件資料夾中),點擊連結來重設密碼。
|
||
</p>
|
||
|
||
<div class="resend-section">
|
||
<p class="resend-text">沒有收到郵件?</p>
|
||
<BaseButton
|
||
variant="outline"
|
||
size="md"
|
||
:disabled="resendCooldown > 0 || isLoading"
|
||
@click="handleResendEmail"
|
||
>
|
||
<span v-if="resendCooldown > 0">
|
||
重新發送 ({{ resendCooldown }}s)
|
||
</span>
|
||
<span v-else>重新發送</span>
|
||
</BaseButton>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="login-prompt">
|
||
想起密碼了?
|
||
<router-link to="/auth/login" class="login-link">
|
||
返回登入
|
||
</router-link>
|
||
</div>
|
||
</template>
|
||
</BaseCard>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useUIStore } from '@/stores/ui'
|
||
import BaseCard from '@/components/base/BaseCard.vue'
|
||
import BaseInput from '@/components/base/BaseInput.vue'
|
||
import BaseButton from '@/components/base/BaseButton.vue'
|
||
import { isValidEmail } from '@/utils'
|
||
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
const uiStore = useUIStore()
|
||
|
||
// 狀態
|
||
const email = ref('')
|
||
const emailError = ref('')
|
||
const isLoading = ref(false)
|
||
const emailSent = ref(false)
|
||
const resendCooldown = ref(0)
|
||
|
||
// 計算屬性
|
||
const canSubmit = computed(() => {
|
||
return email.value && isValidEmail(email.value) && !isLoading.value
|
||
})
|
||
|
||
// 方法
|
||
const validateEmail = () => {
|
||
emailError.value = ''
|
||
|
||
if (!email.value) {
|
||
emailError.value = '請輸入電子郵件地址'
|
||
return false
|
||
}
|
||
|
||
if (!isValidEmail(email.value)) {
|
||
emailError.value = '請輸入有效的電子郵件地址'
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
const handleSendResetEmail = async () => {
|
||
if (!validateEmail()) return
|
||
|
||
isLoading.value = true
|
||
|
||
try {
|
||
const result = await authStore.forgotPassword(email.value)
|
||
|
||
if (result.success) {
|
||
emailSent.value = true
|
||
uiStore.showSuccessToast('郵件已發送', '請檢查你的收件匣')
|
||
startResendCooldown()
|
||
} else {
|
||
uiStore.showErrorToast('發送失敗', result.error)
|
||
|
||
if (result.error?.includes('email')) {
|
||
emailError.value = result.error
|
||
}
|
||
}
|
||
} catch (error) {
|
||
uiStore.showErrorToast('發送失敗', '發生未知錯誤,請稍後再試')
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleResendEmail = async () => {
|
||
if (resendCooldown.value > 0) return
|
||
|
||
isLoading.value = true
|
||
|
||
try {
|
||
const result = await authStore.forgotPassword(email.value)
|
||
|
||
if (result.success) {
|
||
uiStore.showSuccessToast('郵件已重新發送', '請檢查你的收件匣')
|
||
startResendCooldown()
|
||
} else {
|
||
uiStore.showErrorToast('重新發送失敗', result.error)
|
||
}
|
||
} catch (error) {
|
||
uiStore.showErrorToast('重新發送失敗', '發生未知錯誤,請稍後再試')
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const startResendCooldown = () => {
|
||
resendCooldown.value = 60 // 60秒冷卻時間
|
||
|
||
const countdown = setInterval(() => {
|
||
resendCooldown.value -= 1
|
||
|
||
if (resendCooldown.value <= 0) {
|
||
clearInterval(countdown)
|
||
}
|
||
}, 1000)
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.forgot-password-view {
|
||
width: 100%;
|
||
max-width: 400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.forgot-password-card {
|
||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||
border: 1px solid rgba($divider, 0.2);
|
||
backdrop-filter: blur(10px);
|
||
position: relative;
|
||
}
|
||
|
||
.back-button {
|
||
position: absolute;
|
||
top: -$space-2;
|
||
left: -$space-2;
|
||
|
||
.q-btn {
|
||
color: $text-secondary;
|
||
|
||
&:hover {
|
||
color: $primary-teal;
|
||
background: rgba($primary-teal, 0.1);
|
||
}
|
||
}
|
||
}
|
||
|
||
.forgot-password-title {
|
||
font-size: $text-2xl;
|
||
font-weight: 700;
|
||
color: $text-primary;
|
||
margin: 0 0 $space-3 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.forgot-password-subtitle {
|
||
font-size: $text-sm;
|
||
color: $text-secondary;
|
||
margin: 0;
|
||
text-align: center;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.forgot-password-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: $space-6;
|
||
}
|
||
|
||
.success-message {
|
||
text-align: center;
|
||
|
||
.success-icon {
|
||
margin-bottom: $space-4;
|
||
|
||
.q-icon {
|
||
font-size: 64px;
|
||
color: $success-green;
|
||
}
|
||
}
|
||
|
||
h3 {
|
||
font-size: $text-xl;
|
||
font-weight: 700;
|
||
color: $text-primary;
|
||
margin: 0 0 $space-4 0;
|
||
}
|
||
|
||
p {
|
||
font-size: $text-base;
|
||
color: $text-secondary;
|
||
margin: 0 0 $space-3 0;
|
||
line-height: 1.5;
|
||
|
||
strong {
|
||
color: $text-primary;
|
||
word-break: break-word;
|
||
}
|
||
}
|
||
|
||
.instruction {
|
||
font-size: $text-sm;
|
||
color: $text-tertiary;
|
||
padding: $space-4;
|
||
background: rgba($primary-teal, 0.05);
|
||
border-radius: $radius-md;
|
||
border-left: 4px solid $primary-teal;
|
||
}
|
||
|
||
.resend-section {
|
||
margin-top: $space-6;
|
||
padding-top: $space-4;
|
||
border-top: 1px solid rgba($divider, 0.3);
|
||
|
||
.resend-text {
|
||
font-size: $text-sm;
|
||
margin-bottom: $space-3;
|
||
}
|
||
}
|
||
}
|
||
|
||
.login-prompt {
|
||
text-align: center;
|
||
font-size: $text-sm;
|
||
color: $text-secondary;
|
||
|
||
.login-link {
|
||
color: $primary-teal;
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
margin-left: $space-1;
|
||
transition: color 0.3s ease;
|
||
|
||
&:hover {
|
||
color: $primary-teal-light;
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 響應式設計
|
||
@include respond-to(xs) {
|
||
.forgot-password-view {
|
||
padding: $space-4;
|
||
}
|
||
|
||
.back-button {
|
||
top: $space-2;
|
||
left: $space-2;
|
||
}
|
||
|
||
.forgot-password-title {
|
||
margin-top: $space-8;
|
||
}
|
||
}
|
||
</style> |