feat: 忘記密碼

This commit is contained in:
skytek_xinliang
2026-10-06 09:29:08 +08:00
parent a8fdb157a2
commit 12b067632f
20 changed files with 916 additions and 234 deletions
@@ -0,0 +1,54 @@
<script setup lang="ts">
import PasswordRecoveryShell from "@/components/auth/PasswordRecoveryShell.vue";
import ResetPasswordForm from "@/components/auth/ResetPasswordForm.vue";
import { usePasswordReset } from "@/composables/usePasswordReset";
const {
hasToken,
formVersion,
loading,
disabled,
errorMessage,
fieldErrors,
remainingSeconds,
submit,
} = usePasswordReset();
</script>
<template>
<PasswordRecoveryShell
title="重設密碼"
description="請設定新的登入密碼,完成後使用新密碼登入。"
>
<template v-if="!hasToken">
<v-alert type="warning" variant="tonal" role="alert">
密碼重設連結無效或已過期。若剛重新整理頁面,請重新開啟信件連結或重新申請密碼重設。
</v-alert>
<v-btn
block
class="mt-4"
color="secondary"
to="/forgot-password"
variant="flat"
>重新申請密碼重設</v-btn
>
</template>
<template v-else>
<v-alert
v-if="errorMessage"
class="mb-4"
type="error"
variant="tonal"
role="alert"
>{{ errorMessage }}</v-alert
>
<ResetPasswordForm
:key="formVersion"
:loading="loading"
:disabled="disabled"
:errors="fieldErrors"
:remaining-seconds="remainingSeconds"
@submit="submit"
/>
</template>
</PasswordRecoveryShell>
</template>
@@ -1,58 +1,54 @@
<script setup lang="ts">
import { shallowRef } from "vue";
import { useRoute, useRouter } from "vue-router";
import EmailCompletionForm from "@/components/auth/EmailCompletionForm.vue";
import Layout from "@/components/layout/DefaultLayout.vue";
import { EMAIL_COMPLETION_REQUIRED } from "@/config/features";
import { useAppStore } from "@/stores/app";
import { getApiErrorStatus } from "@/utils/api-error";
import { shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import EmailCompletionForm from '@/components/auth/EmailCompletionForm.vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import { EMAIL_COMPLETION_REQUIRED } from '@/config/features'
import { useAppStore } from '@/stores/app'
import { getApiErrorStatus } from '@/utils/api-error'
const store = useAppStore();
const route = useRoute();
const router = useRouter();
const loading = shallowRef(false);
const errorMessage = shallowRef("");
const store = useAppStore()
const route = useRoute()
const router = useRouter()
const loading = shallowRef(false)
const errorMessage = shallowRef('')
function redirectTarget() {
const target = String(route.query.redirect ?? "");
if (
!target.startsWith("/") ||
target.startsWith("//") ||
target.startsWith("/complete-email")
) {
return "/";
const target = String(route.query.redirect ?? '')
if (!target.startsWith('/') || target.startsWith('//') || target.startsWith('/complete-email')) {
return '/'
}
return target;
return target
}
async function handleSubmit(email: string) {
errorMessage.value = "";
loading.value = true;
errorMessage.value = ''
loading.value = true
try {
await store.updateEmail(email);
await router.replace(redirectTarget());
await store.updateEmail(email)
await router.replace(redirectTarget())
} catch (error) {
const status = getApiErrorStatus(error);
const status = getApiErrorStatus(error)
if (status === 409) {
errorMessage.value = "使用者資料已更新,請重新整理後再試。";
errorMessage.value = '使用者資料已更新,請重新整理後再試。'
} else if (status === 422) {
errorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
errorMessage.value = '請輸入正確且不超過 50 碼的 E-Mail。'
} else if (status !== 401) {
errorMessage.value = "E-Mail 儲存失敗,請稍後再試。";
errorMessage.value = 'E-Mail 儲存失敗,請稍後再試。'
}
} finally {
loading.value = false;
loading.value = false
}
}
async function skipForNow() {
if (EMAIL_COMPLETION_REQUIRED) {
return;
return
}
await router.replace(redirectTarget());
await router.replace(redirectTarget())
}
</script>
@@ -62,13 +58,9 @@ async function skipForNow() {
<v-row justify="center" no-gutters>
<v-col cols="12" sm="8" md="6">
<v-card class="rounded-xl mt-6" color="surface">
<v-card-title class="text-primary font-weight-bold">
補登 E-Mail
</v-card-title>
<v-card-title class="text-primary font-weight-bold"> 註冊 E-Mail </v-card-title>
<v-card-text>
<p class="text-body-1 mb-5">
設定 E-Mail,有助於日後接收帳號安全與重要服務通知。
</p>
<p class="text-body-1 mb-5">填寫 E-Mail,有助於日後接收帳號安全與重要服務通知。</p>
<EmailCompletionForm
:error-message="errorMessage"
@@ -0,0 +1,107 @@
<script setup lang="ts">
import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue'
import PasswordRecoveryShell from '@/components/auth/PasswordRecoveryShell.vue'
import { useForgotPassword } from '@/composables/useForgotPassword'
const recoverySteps = [
{ title: '輸入已註冊手機門號', icon: 'mdi-phone' },
{ title: '收起E-Mail,使用連結進行重設密碼', icon: 'mdi-email-outline' },
]
const { accepted, loading, disabled, errorMessage, fieldErrors, remainingSeconds, submit } =
useForgotPassword()
</script>
<template>
<PasswordRecoveryShell title="忘記密碼">
<template #description>
<v-sheet class="pa-4 mb-6" color="background" rounded="lg">
<ol aria-label="密碼重設流程" class="recovery-flow ma-0 pa-0">
<li v-for="(step, index) in recoverySteps" :key="step.title" class="recovery-step">
<v-avatar
class="recovery-step-icon"
color="primary"
rounded="lg"
size="40"
variant="tonal"
>
<v-icon aria-hidden="true" :icon="step.icon" size="22" />
</v-avatar>
<div class="recovery-step-content">
<span class="recovery-step-number text-primary">
步驟 {{ String(index + 1).padStart(2, '0') }}
</span>
<p class="text-title-small text-high-emphasis ma-0">
{{ step.title }}
</p>
</div>
</li>
</ol>
</v-sheet>
</template>
<template v-if="accepted">
<v-alert type="success" variant="tonal" role="status">
如果帳號存在,系統將寄送密碼重設信件。
</v-alert>
<p class="mt-4 text-body-1">請查看信箱與垃圾郵件匣。</p>
</template>
<template v-else>
<v-alert v-if="errorMessage" class="mb-4" type="error" variant="tonal" role="alert">{{
errorMessage
}}</v-alert>
<ForgotPasswordForm
:loading="loading"
:disabled="disabled"
:errors="fieldErrors"
:remaining-seconds="remainingSeconds"
@submit="submit"
/>
</template>
</PasswordRecoveryShell>
</template>
<style scoped>
.recovery-flow {
list-style: none;
}
.recovery-step {
position: relative;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
align-items: center;
column-gap: 16px;
}
.recovery-step:not(:last-child) {
padding-bottom: 24px;
}
.recovery-step:not(:last-child)::after {
position: absolute;
top: 48px;
bottom: 4px;
left: 19px;
width: 2px;
background: rgba(var(--v-theme-primary), 0.18);
content: '';
}
.recovery-step-icon {
align-self: start;
margin-top: 4px;
}
.recovery-step-content {
min-width: 0;
}
.recovery-step-number {
display: block;
margin-bottom: 2px;
font-size: 0.75rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1.5;
letter-spacing: 0.04em;
}
</style>
+13
View File
@@ -198,6 +198,19 @@
既有帳號建立密碼
</v-btn>
</v-col>
<v-col cols="12">
<v-btn
block
class="rounded-lg"
color="primary"
prepend-icon="mdi-lock-reset"
to="/forgot-password"
variant="text"
>
忘記密碼
</v-btn>
</v-col>
</v-row>
</v-card-text>
<div class="d-flex align-center mt-4 px-4">
+11
View File
@@ -19,6 +19,14 @@
<v-card class="rounded-lg mt-4" color="surface">
<v-card-title> 登入 </v-card-title>
<v-card-text> 請輸入您的手機號碼與登入密碼 </v-card-text>
<v-alert
v-if="route.query.passwordReset === 'success'"
class="mx-4 mb-4"
type="success"
variant="tonal"
>
密碼已更新,請使用新密碼登入。
</v-alert>
<!-- 表單 -->
<v-form class="pa-4" @submit.prevent="handleLogin">
<v-alert
@@ -91,6 +99,9 @@
>
既有帳號建立密碼
</v-btn>
<v-btn block color="primary" to="/forgot-password" variant="text">
忘記密碼
</v-btn>
</v-form>
<!-- <div class="d-flex align-center justify-center my-4">
<v-divider class="mr-3" />