feat(web): 重构忘记密码页面为清晰三段式向导与紧凑输入布局
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-14 18:29:28 +08:00
parent 0252ac6010
commit ea89cd3907
3 changed files with 693 additions and 331 deletions

View File

@ -1,15 +1,120 @@
.recovery-form { /* 密码恢复容器 */
.password-recovery-container {
width: 100%; width: 100%;
max-width: 760px; max-width: 760px;
min-width: 0; min-width: 0;
margin: auto; margin: auto;
padding: clamp(28px, 4vw, 48px); padding: clamp(20px, 3vw, 36px);
box-sizing: border-box;
}
/* 步骤导航条 */
.recovery-steps-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 24px;
padding: 12px 18px;
background: var(--pm-surface-muted, #f8fafc);
border-radius: 14px;
border: 1px solid var(--pm-border-color, #e2e8f0);
}
.step-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--pm-text-secondary, #64748b);
transition: all 0.25s ease;
}
.step-number {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
background: #e2e8f0;
color: #64748b;
transition: all 0.25s ease;
}
.step-label {
font-size: 14px;
font-weight: 500;
}
.step-item.active {
color: var(--pm-primary-color, #2563eb);
}
.step-item.active .step-number {
background: var(--pm-primary-color, #2563eb);
color: #fff;
}
.step-item.current .step-label {
font-weight: 700;
}
.step-divider {
flex: 1;
max-width: 48px;
height: 2px;
background: #e2e8f0;
transition: all 0.25s ease;
}
.step-divider.active {
background: var(--pm-primary-color, #2563eb);
}
/* 步骤内容区域 */
.step-content {
animation: fadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.step-two-intro {
margin-bottom: 20px;
padding: 16px 20px;
background: linear-gradient(135deg, rgba(239, 246, 255, 0.95), rgba(248, 251, 255, 0.9));
border: 1px solid rgba(37, 99, 235, 0.16);
border-radius: 14px;
}
.step-two-intro h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 700;
color: var(--pm-text-primary, #0f172a);
}
.step-two-intro p {
margin: 0;
font-size: 13px;
color: var(--pm-text-secondary, #475569);
line-height: 1.5;
}
/* 表单主体 */
.recovery-form {
width: 100%;
min-width: 0;
box-sizing: border-box; box-sizing: border-box;
} }
.recovery-form :deep(.el-form-item) { .recovery-form :deep(.el-form-item) {
min-width: 0; min-width: 0;
margin-bottom: 10px; margin-bottom: 12px;
} }
.recovery-form :deep(.el-form-item__label) { .recovery-form :deep(.el-form-item__label) {
@ -43,6 +148,7 @@
margin-top: 18px; margin-top: 18px;
} }
/* 方式切换栏 */
.method-switcher { .method-switcher {
position: relative; position: relative;
display: flex; display: flex;
@ -89,7 +195,7 @@
.method-copy strong { .method-copy strong {
color: var(--pm-text-primary); color: var(--pm-text-primary);
font-size: 18px; font-size: 17px;
font-weight: 700; font-weight: 700;
line-height: 1.35; line-height: 1.35;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@ -101,6 +207,7 @@
white-space: nowrap; white-space: nowrap;
} }
/* 面板 */
.verification-panel { .verification-panel {
min-width: 0; min-width: 0;
margin-bottom: 18px; margin-bottom: 18px;
@ -112,46 +219,49 @@
box-shadow: 0 8px 22px rgba(31, 41, 55, 0.06); box-shadow: 0 8px 22px rgba(31, 41, 55, 0.06);
} }
.verification-panel h2 { .panel-header h2 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin: 0 0 8px; margin: 0 0 8px;
color: var(--pm-text-primary); color: var(--pm-text-primary);
font-size: 20px; font-size: 18px;
font-weight: 700; font-weight: 700;
line-height: 1.35; line-height: 1.35;
letter-spacing: -0.01em; letter-spacing: -0.01em;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.verification-panel h2::before { .panel-header h2::before {
width: 5px; width: 5px;
height: 22px; height: 20px;
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 999px; border-radius: 999px;
background: var(--pm-primary-color); background: var(--pm-primary-color);
content: ''; content: '';
} }
.verification-panel p { .panel-header p {
margin: 0 0 16px; margin: 0 0 16px;
color: var(--pm-text-secondary); color: var(--pm-text-secondary);
font-size: 14px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.verification-panel :deep(.el-form-item:last-child), /* 核心紧凑排列组:用户名与外部邮箱相邻 */
.offline-grid :deep(.el-form-item) { .compact-field-group {
margin-bottom: 0; display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 4px;
} }
.verification-code-row { .verification-code-row {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(128px, auto); grid-template-columns: minmax(0, 1fr) 140px;
gap: 10px; gap: 10px;
} }
@ -159,32 +269,102 @@
min-width: 0; min-width: 0;
} }
.verification-code-row :deep(.el-button) { .code-btn {
width: 100%; width: 100%;
font-size: 13px !important;
} }
.verification-state { .verification-state {
display: block; display: block;
min-width: 0; min-width: 0;
margin: -2px 0 10px; margin: 4px 0 14px;
color: var(--pm-text-secondary); padding: 8px 12px;
background: rgba(37, 99, 235, 0.06);
border-radius: 8px;
color: var(--pm-primary-color);
font-size: 13px; font-size: 13px;
line-height: 1.45; line-height: 1.45;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.offline-grid, .offline-grid {
.password-grid {
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.password-grid :deep(.el-form-item) { .offline-grid :deep(.el-form-item) {
margin-bottom: 0; margin-bottom: 0;
} }
/* 步骤二:垂直紧凑密码输入 */
.password-grid-vertical {
display: flex;
flex-direction: column;
gap: 6px;
}
.password-error-msg {
color: #ef4444;
font-size: 13px;
font-weight: 500;
margin-top: 4px;
margin-bottom: 8px;
}
/* 步骤三:成功界面居中展示 */
.step-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.success-icon-wrap {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow: 0 10px 25px rgba(34, 197, 94, 0.2);
}
.success-emoji {
font-size: 28px;
font-weight: 800;
color: #16a34a;
letter-spacing: -1px;
}
.success-title {
font-size: 22px;
font-weight: 700;
color: var(--pm-text-primary, #0f172a);
margin: 0 0 10px;
}
.success-description {
font-size: 14px;
color: var(--pm-text-secondary, #64748b);
margin: 0 0 28px;
}
.success-action {
width: 100%;
max-width: 240px;
}
.success-action :deep(.recovery-action-btn) {
width: 100%;
}
/* 底部操作区 */
.recovery-footer { .recovery-footer {
display: flex; display: flex;
align-items: center; align-items: center;
@ -197,6 +377,7 @@
.actions { .actions {
min-width: 0; min-width: 0;
display: flex; display: flex;
gap: 12px;
justify-content: flex-end; justify-content: flex-end;
} }
@ -215,9 +396,9 @@
.recovery-form :deep(.recovery-action-btn), .recovery-form :deep(.recovery-action-btn),
.recovery-form :deep(.switch-button) { .recovery-form :deep(.switch-button) {
min-height: 44px; min-height: 44px;
padding: 0 14px; padding: 0 18px;
border-radius: 14px; border-radius: 12px;
font-size: 15px; font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: 1.35; line-height: 1.35;
white-space: normal; white-space: normal;
@ -229,12 +410,12 @@
} }
.recovery-form :deep(.recovery-action-btn) { .recovery-form :deep(.recovery-action-btn) {
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.2); box-shadow: 0 8px 20px rgba(37, 99, 235, 0.2);
} }
.recovery-form :deep(.recovery-action-btn:hover) { .recovery-form :deep(.recovery-action-btn:hover) {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.26); box-shadow: 0 12px 24px rgba(37, 99, 235, 0.26);
} }
.recovery-form :deep(.secondary-action-btn) { .recovery-form :deep(.secondary-action-btn) {
@ -253,9 +434,8 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.recovery-form { .password-recovery-container {
max-width: none; padding: 16px 14px 20px;
padding: 18px 16px 22px;
} }
.reset-boundary { .reset-boundary {
@ -264,40 +444,51 @@
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.recovery-steps-bar {
padding: 8px 10px;
gap: 6px;
}
.step-label {
font-size: 12px;
}
.step-divider {
max-width: 24px;
}
.method-switcher { .method-switcher {
gap: 8px; gap: 8px;
padding: 14px; padding: 12px;
} }
.switch-button { .switch-button {
max-width: 48%; max-width: 48%;
font-size: 13px; font-size: 12px;
} }
.verification-panel { .verification-panel {
padding: 16px 14px 18px; padding: 14px 12px 16px;
} }
.verification-panel h2 { .panel-header h2 {
font-size: 18px; font-size: 16px;
} }
.verification-panel p { .panel-header p {
font-size: 13px; font-size: 12px;
} }
.verification-code-row { .verification-code-row {
grid-template-columns: minmax(0, 1fr) minmax(118px, auto); grid-template-columns: minmax(0, 1fr) 110px;
} }
.offline-grid, .offline-grid {
.password-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 0; gap: 0;
} }
.offline-grid :deep(.el-form-item), .offline-grid :deep(.el-form-item) {
.password-grid :deep(.el-form-item) {
margin-bottom: 10px; margin-bottom: 10px;
} }
@ -313,68 +504,3 @@
width: 100%; width: 100%;
} }
} }
@media (max-width: 380px) {
.recovery-form {
padding: 16px 12px 20px;
}
.recovery-form :deep(.el-form-item) {
margin-bottom: 7px;
}
.recovery-form :deep(.el-form-item__label) {
font-size: 12px;
line-height: 1.2;
}
.recovery-form :deep(.el-input__wrapper),
.recovery-form :deep(.recovery-action-btn),
.recovery-form :deep(.switch-button) {
min-height: 38px;
}
.method-switcher,
.verification-panel {
padding: 12px 10px;
}
.method-switcher {
align-items: stretch;
flex-direction: column;
}
.switch-button {
width: 100%;
max-width: none;
}
.verification-code-row {
grid-template-columns: minmax(0, 1fr);
}
.method-label {
font-size: 11px;
}
.method-copy strong {
font-size: 15px;
}
.verification-panel h2 {
font-size: 16px;
}
.verification-panel p,
.verification-state {
font-size: 11px;
line-height: 1.35;
}
.recovery-form :deep(.recovery-action-btn),
.recovery-form :deep(.switch-button) {
padding-right: 10px;
padding-left: 10px;
font-size: 12px;
}
}

View File

@ -1,19 +1,18 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { http } from '@/utils/axios' import { http } from '@/utils/axios'
import { isValidPasswordLength } from '@/utils/password' import { isValidPasswordLength } from '@/utils/password'
export function usePage() { export function usePage() {
const currentStep = ref(1)
const loading = ref(false) const loading = ref(false)
const sendingCode = ref(false) const sendingCode = ref(false)
const countdown = ref(0)
let timer = null
const verificationTicket = ref('') const verificationTicket = ref('')
const verificationState = ref('') const verificationState = ref('')
const passwordMismatchError = ref('')
const recovery = reactive({ const recovery = reactive({
method: 'email', method: 'email',
@ -28,6 +27,13 @@ export function usePage() {
confirmPassword: '' confirmPassword: ''
}) })
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
const currentMethodLabel = computed(() => recovery.method === 'email' const currentMethodLabel = computed(() => recovery.method === 'email'
? '第三方安全邮箱验证码' ? '第三方安全邮箱验证码'
: '密保问题 + 离线恢复码') : '密保问题 + 离线恢复码')
@ -36,6 +42,20 @@ export function usePage() {
? '切换为离线恢复' ? '切换为离线恢复'
: '切换为邮箱验证') : '切换为邮箱验证')
const isActionDisabled = computed(() => loading.value || sendingCode.value)
const isCodeButtonDisabled = computed(() =>
sendingCode.value ||
countdown.value > 0 ||
!recovery.identifier ||
!recovery.email
)
const onIdentifierChange = () => {
verificationTicket.value = ''
verificationState.value = ''
}
const clearMethodState = () => { const clearMethodState = () => {
recovery.email = '' recovery.email = ''
recovery.code = '' recovery.code = ''
@ -45,6 +65,11 @@ export function usePage() {
recovery.recoveryCode = '' recovery.recoveryCode = ''
verificationTicket.value = '' verificationTicket.value = ''
verificationState.value = '' verificationState.value = ''
if (timer) {
clearInterval(timer)
timer = null
}
countdown.value = 0
} }
const switchRecoveryMethod = () => { const switchRecoveryMethod = () => {
@ -52,9 +77,23 @@ export function usePage() {
clearMethodState() clearMethodState()
} }
const startCountdown = () => {
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
if (countdown.value > 1) {
countdown.value--
} else {
countdown.value = 0
clearInterval(timer)
timer = null
}
}, 1000)
}
const requestRecoveryCode = async () => { const requestRecoveryCode = async () => {
if (!recovery.identifier || !recovery.email) { if (!recovery.identifier || !recovery.email) {
ElMessage.warning('请先填写用户名或完整邮箱以及外部安全邮箱') ElMessage.warning('请填写用户名或完整邮箱以及外部安全邮箱')
return return
} }
recovery.code = '' recovery.code = ''
@ -67,17 +106,100 @@ export function usePage() {
identifier: recovery.identifier, identifier: recovery.identifier,
email: recovery.email email: recovery.email
}) })
if (result.errorNo !== 0 || !result.data?.id) throw new Error('请求已受理') if (result.errorNo !== 0 || !result.data?.id) {
throw new Error(result.errorMsg || '请求已受理')
}
recovery.challengeId = result.data.id recovery.challengeId = result.data.id
verificationState.value = '如果账号和外部安全邮箱匹配,验证码将发送到该邮箱' verificationState.value = '如果账号和外部安全邮箱匹配,验证码将发送到该邮箱'
} catch (_) { startCountdown()
ElMessage.success('验证码已发送,请检查外部安全邮箱')
} catch (err) {
verificationState.value = '请求已受理,请稍后检查外部安全邮箱' verificationState.value = '请求已受理,请稍后检查外部安全邮箱'
ElMessage.info('请求已受理,请稍后检查外部安全邮箱')
startCountdown()
} finally { } finally {
sendingCode.value = false sendingCode.value = false
} }
} }
const validatePassword = () => { const validatePasswordMatch = () => {
if (!recovery.newPassword || !recovery.confirmPassword) {
passwordMismatchError.value = ''
return
}
if (recovery.newPassword !== recovery.confirmPassword) {
passwordMismatchError.value = '两次输入的新密码不一致'
} else {
passwordMismatchError.value = ''
}
}
const validateStepOne = () => {
if (!recovery.identifier) {
ElMessage.warning('请填写用户名或完整邮箱')
return false
}
if (recovery.method === 'email') {
if (!recovery.email) {
ElMessage.warning('请填写外部安全邮箱')
return false
}
if (!recovery.challengeId || !recovery.code) {
ElMessage.warning('请先获取并填写邮箱验证码')
return false
}
return true
}
if (!recovery.question || !recovery.answer || !recovery.recoveryCode) {
ElMessage.warning('请完整填写密保问题、密保答案和离线恢复码')
return false
}
return true
}
const handleStepOneNext = async () => {
if (!validateStepOne()) return
loading.value = true
verificationState.value = ''
try {
const payload = recovery.method === 'email'
? {
method: 'email',
id: recovery.challengeId,
identifier: recovery.identifier,
email: recovery.email,
code: recovery.code,
recovery_code: recovery.recoveryCode
}
: {
method: 'offline',
identifier: recovery.identifier,
question: recovery.question,
answer: recovery.answer,
recovery_code: recovery.recoveryCode
}
const result = await http.post('/api/v1/security/recovery/verify', payload)
if (result.errorNo !== 0 || !result.data?.ticket) {
throw new Error(result.errorMsg || '身份验证失败')
}
verificationTicket.value = result.data.ticket
currentStep.value = 2
ElMessage.success('身份核验通过,请设置新密码')
} catch (err) {
verificationTicket.value = ''
verificationState.value = err.message || '身份验证失败,请核对信息后重试'
ElMessage.error(err.message || '身份验证失败,请核对信息后重试')
} finally {
loading.value = false
}
}
const resetStepOne = () => {
recovery.identifier = ''
clearMethodState()
}
const validateStepTwo = () => {
if (!recovery.newPassword || !recovery.confirmPassword) { if (!recovery.newPassword || !recovery.confirmPassword) {
ElMessage.warning('请填写两次新密码') ElMessage.warning('请填写两次新密码')
return false return false
@ -93,98 +215,66 @@ export function usePage() {
return true return true
} }
const validateRecoveryMethod = () => { const handleStepTwoNext = async () => {
if (!recovery.identifier) { if (!validateStepTwo()) return
ElMessage.warning('请填写用户名或完整邮箱') if (!verificationTicket.value) {
return false ElMessage.error('验证凭证已失效,请重新进行身份验证')
currentStep.value = 1
return
} }
if (recovery.method === 'email') {
if (!recovery.email || !recovery.challengeId || !recovery.code) {
ElMessage.warning('请先发送并填写邮箱验证码')
return false
}
return true
}
if (!recovery.question || !recovery.answer || !recovery.recoveryCode) {
ElMessage.warning('请完整填写密保问题、密保答案和离线恢复码')
return false
}
return true
}
const verifyRecovery = async () => {
const payload = recovery.method === 'email'
? {
method: 'email',
id: recovery.challengeId,
identifier: recovery.identifier,
email: recovery.email,
code: recovery.code,
recovery_code: recovery.recoveryCode
}
: {
method: 'offline',
identifier: recovery.identifier,
question: recovery.question,
answer: recovery.answer,
recovery_code: recovery.recoveryCode
}
const result = await http.post('/api/v1/security/recovery/verify', payload)
if (result.errorNo !== 0 || !result.data?.ticket) throw new Error('验证失败')
verificationTicket.value = result.data.ticket
return result.data.ticket
}
const completeRecovery = async () => {
if (!validateRecoveryMethod() || !validatePassword()) return
loading.value = true loading.value = true
verificationTicket.value = ''
verificationState.value = ''
try { try {
const ticket = await verifyRecovery()
const result = await http.post('/api/v1/security/recovery/complete', { const result = await http.post('/api/v1/security/recovery/complete', {
ticket, ticket: verificationTicket.value,
new_password: recovery.newPassword new_password: recovery.newPassword
}) })
if (result.errorNo !== 0 || !result.data?.reset) throw new Error('重置失败') if (result.errorNo !== 0 || !result.data?.reset) {
clearMethodState() throw new Error(result.errorMsg || '密码重置失败')
recovery.identifier = '' }
recovery.newPassword = '' currentStep.value = 3
recovery.confirmPassword = '' ElMessage.success('新登录密码重置成功!')
ElMessage.success('密码已重置,请使用新密码登录') } catch (err) {
goLogin() ElMessage.error(err.message || '密码重置失败,请重试')
} catch (_) {
verificationTicket.value = ''
verificationState.value = '验证或重置失败,请检查输入后重试'
ElMessage.error('验证或重置失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const resetStepTwo = () => {
recovery.newPassword = ''
recovery.confirmPassword = ''
passwordMismatchError.value = ''
}
const goPreviousStep = () => {
if (currentStep.value === 2) {
currentStep.value = 1
}
}
const goLogin = () => window.location.assign('/#/login') const goLogin = () => window.location.assign('/#/login')
return { return {
computed, currentStep,
reactive,
ref,
ElMessage,
http,
loading, loading,
sendingCode, sendingCode,
verificationTicket, countdown,
verificationState, verificationState,
recovery, recovery,
currentMethodLabel, currentMethodLabel,
switchButtonText, switchButtonText,
clearMethodState, isActionDisabled,
isCodeButtonDisabled,
passwordMismatchError,
switchRecoveryMethod, switchRecoveryMethod,
requestRecoveryCode, requestRecoveryCode,
validatePassword, handleStepOneNext,
validateRecoveryMethod, handleStepTwoNext,
verifyRecovery, goPreviousStep,
completeRecovery, resetStepOne,
resetStepTwo,
validatePasswordMatch,
onIdentifierChange,
goLogin goLogin
} }
} }

View File

@ -1,4 +1,4 @@
<template> <template>
<AuthLayout <AuthLayout
brand-caption="账号密码恢复" brand-caption="账号密码恢复"
intro-title="恢复账号访问" intro-title="恢复账号访问"
@ -14,153 +14,288 @@
/> />
</template> </template>
<el-form <div class="password-recovery-container">
class="recovery-form" <!-- 三段式进度向导导航条 -->
label-position="top" <div class="recovery-steps-bar">
@submit.prevent="completeRecovery" <div
> class="step-item"
<el-form-item label="用户名或完整邮箱"> :class="{ active: currentStep >= 1, current: currentStep === 1 }"
<el-input
v-model="recovery.identifier"
autocomplete="username"
placeholder="请输入用户名或完整邮箱"
/>
</el-form-item>
<div class="method-switcher">
<div class="method-copy">
<span class="method-label">当前验证方式</span>
<strong>{{ currentMethodLabel }}</strong>
</div>
<el-button
plain
type="primary"
class="switch-button secondary-action-btn"
:disabled="loading"
@click="switchRecoveryMethod"
> >
{{ switchButtonText }} <div class="step-number">1</div>
</el-button> <div class="step-label">身份验证</div>
</div>
<div class="step-divider" :class="{ active: currentStep >= 2 }"></div>
<div
class="step-item"
:class="{ active: currentStep >= 2, current: currentStep === 2 }"
>
<div class="step-number">2</div>
<div class="step-label">设置新密码</div>
</div>
<div class="step-divider" :class="{ active: currentStep >= 3 }"></div>
<div
class="step-item"
:class="{ active: currentStep >= 3, current: currentStep === 3 }"
>
<div class="step-number">3</div>
<div class="step-label">完成重置</div>
</div>
</div> </div>
<section <!-- 步骤 1:身份验证 -->
v-if="recovery.method === 'email'" <div v-if="currentStep === 1" class="step-content">
class="verification-panel" <div class="method-switcher">
> <div class="method-copy">
<h2>使用第三方安全邮箱</h2> <span class="method-label">当前验证方式</span>
<p>验证码只会发送到该账号已绑定并验证的外部安全邮箱。</p> <strong>{{ currentMethodLabel }}</strong>
<el-form-item label="外部安全邮箱">
<el-input
v-model="recovery.email"
type="email"
autocomplete="email"
placeholder="name@example.com"
/>
</el-form-item>
<el-form-item label="邮箱验证码">
<div class="verification-code-row">
<el-input
v-model="recovery.code"
inputmode="numeric"
maxlength="12"
autocomplete="one-time-code"
placeholder="请输入邮箱验证码"
/>
<el-button
type="primary"
class="recovery-action-btn"
:loading="sendingCode"
:disabled="loading"
@click="requestRecoveryCode"
>
发送验证码
</el-button>
</div> </div>
</el-form-item> <el-button
<el-form-item label="管理员离线恢复码(普通用户可留空)"> plain
<el-input type="primary"
v-model="recovery.recoveryCode" class="switch-button secondary-action-btn"
autocomplete="one-time-code" :disabled="isActionDisabled"
placeholder="管理员账号需要额外输入离线恢复码" @click="switchRecoveryMethod"
/> >
</el-form-item> {{ switchButtonText }}
</section> </el-button>
<section v-else class="verification-panel">
<h2>使用离线恢复方式</h2>
<p>密保问题、密保答案和离线恢复码必须属于同一账号。</p>
<el-form-item label="密保问题">
<el-input
v-model="recovery.question"
autocomplete="off"
placeholder="请输入您设置的密保问题"
/>
</el-form-item>
<div class="offline-grid">
<el-form-item label="密保答案">
<el-input
v-model="recovery.answer"
type="password"
show-password
autocomplete="off"
placeholder="密保答案不区分大小写"
/>
</el-form-item>
<el-form-item label="离线恢复码">
<el-input
v-model="recovery.recoveryCode"
autocomplete="one-time-code"
placeholder="请输入一个未使用的离线恢复码"
/>
</el-form-item>
</div> </div>
</section>
<span v-if="verificationState" class="verification-state"> <el-form
{{ verificationState }} class="recovery-form"
</span> label-position="top"
@submit.prevent="handleStepOneNext"
>
<!-- 邮箱验证方式:用户名与外部安全邮箱紧密相邻 -->
<section
v-if="recovery.method === 'email'"
class="verification-panel"
>
<div class="panel-header">
<h2>使用第三方安全邮箱</h2>
<p>验证码只会发送到该账号已绑定并验证的外部安全邮箱。</p>
</div>
<div class="password-grid"> <div class="compact-field-group">
<el-form-item label="新密码"> <el-form-item label="用户名或完整邮箱" required>
<el-input <el-input
v-model="recovery.newPassword" v-model="recovery.identifier"
type="password" autocomplete="username"
show-password placeholder="请输入用户名或完整邮箱"
autocomplete="new-password" @input="onIdentifierChange"
/> />
</el-form-item> </el-form-item>
<el-form-item label="确认新密码">
<el-input <el-form-item label="外部安全邮箱" required>
v-model="recovery.confirmPassword" <el-input
type="password" v-model="recovery.email"
show-password type="email"
autocomplete="new-password" autocomplete="email"
/> placeholder="name@example.com"
</el-form-item> />
</el-form-item>
</div>
<el-form-item label="邮箱验证码" required>
<div class="verification-code-row">
<el-input
v-model="recovery.code"
inputmode="numeric"
maxlength="12"
autocomplete="one-time-code"
placeholder="请输入邮箱验证码"
/>
<el-button
type="primary"
class="recovery-action-btn code-btn"
:loading="sendingCode"
:disabled="isCodeButtonDisabled"
@click="requestRecoveryCode"
>
{{ countdown > 0 ? (countdown + ' 秒后可重发') : '发送验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="管理员离线恢复码(普通用户留空)">
<el-input
v-model="recovery.recoveryCode"
autocomplete="one-time-code"
placeholder="管理员账号需额外输入离线恢复码"
/>
</el-form-item>
</section>
<!-- 离线恢复方式 -->
<section v-else class="verification-panel">
<div class="panel-header">
<h2>使用离线恢复方式</h2>
<p>密保问题、密保答案和离线恢复码必须属于同一账号。</p>
</div>
<el-form-item label="用户名或完整邮箱" required>
<el-input
v-model="recovery.identifier"
autocomplete="username"
placeholder="请输入用户名或完整邮箱"
@input="onIdentifierChange"
/>
</el-form-item>
<el-form-item label="密保问题" required>
<el-input
v-model="recovery.question"
autocomplete="off"
placeholder="请输入您访置的密保问题"
/>
</el-form-item>
<div class="offline-grid">
<el-form-item label="密保答案" required>
<el-input
v-model="recovery.answer"
type="password"
show-password
autocomplete="off"
placeholder="密保答案不区分大小写"
/>
</el-form-item>
<el-form-item label="离线恢复码" required>
<el-input
v-model="recovery.recoveryCode"
autocomplete="one-time-code"
placeholder="未使用的离线恢复码"
/>
</el-form-item>
</div>
</section>
<div v-if="verificationState" class="verification-state">
{{ verificationState }}
</div>
<div class="recovery-footer">
<el-button
link
type="primary"
class="back-button"
@click="goLogin"
>
返回登录
</el-button>
<div class="actions">
<el-button
plain
class="secondary-action-btn"
:disabled="isActionDisabled"
@click="resetStepOne"
>
重置
</el-button>
<el-button
type="primary"
class="recovery-action-btn"
native-type="submit"
:loading="loading"
:disabled="sendingCode"
>
下一步
</el-button>
</div>
</div>
</el-form>
</div> </div>
<div class="recovery-footer"> <!-- 步骤 2:设置新密码 -->
<el-button <div v-else-if="currentStep === 2" class="step-content">
link <div class="step-two-intro">
type="primary" <h2>设置新密码</h2>
class="back-button" <p>身份核验通过,请为账号 <strong>{{ recovery.identifier }}</strong> 设置新的安全登录密码。</p>
@click="goLogin" </div>
<el-form
class="recovery-form"
label-position="top"
@submit.prevent="handleStepTwoNext"
> >
返回登录 <div class="password-grid-vertical">
</el-button> <el-form-item label="新密码" required>
<div class="actions"> <el-input
v-model="recovery.newPassword"
type="password"
show-password
autocomplete="new-password"
placeholder="请输入 8 到 12 位新密码"
@input="validatePasswordMatch"
/>
</el-form-item>
<el-form-item label="确认新密码" required>
<el-input
v-model="recovery.confirmPassword"
type="password"
show-password
autocomplete="new-password"
placeholder="请再次输入新密码"
@input="validatePasswordMatch"
/>
</el-form-item>
</div>
<div v-if="passwordMismatchError" class="password-error-msg">
{{ passwordMismatchError }}
</div>
<div class="recovery-footer">
<el-button
link
type="primary"
class="back-button"
:disabled="loading"
@click="goPreviousStep"
>
上一步
</el-button>
<div class="actions">
<el-button
plain
class="secondary-action-btn"
:disabled="loading"
@click="resetStepTwo"
>
重置
</el-button>
<el-button
type="primary"
class="recovery-action-btn"
native-type="submit"
:loading="loading"
>
下一步
</el-button>
</div>
</div>
</el-form>
</div>
<!-- 步骤 3:完成重置 -->
<div v-else-if="currentStep === 3" class="step-content step-success">
<div class="success-icon-wrap">
<span class="success-emoji">-^0^-</span>
</div>
<h2 class="success-title">新登录密码重置成功!</h2>
<p class="success-description">请使用新设置的密码重新登录系统。</p>
<div class="success-action">
<el-button <el-button
type="primary" type="primary"
class="recovery-action-btn" class="recovery-action-btn"
native-type="submit" @click="goLogin"
:loading="loading"
:disabled="sendingCode"
> >
验证并重置密码 重新登录
</el-button> </el-button>
</div> </div>
</div> </div>
</el-form> </div>
</AuthLayout> </AuthLayout>
</template> </template>
@ -169,15 +304,26 @@ import AuthLayout from '@/views/components/AuthLayout/AuthLayout.vue'
import { usePage } from './PasswordRecovery.js' import { usePage } from './PasswordRecovery.js'
const { const {
completeRecovery, currentStep,
loading,
sendingCode,
countdown,
verificationState,
recovery, recovery,
currentMethodLabel, currentMethodLabel,
loading,
switchRecoveryMethod,
switchButtonText, switchButtonText,
sendingCode, isActionDisabled,
isCodeButtonDisabled,
passwordMismatchError,
switchRecoveryMethod,
requestRecoveryCode, requestRecoveryCode,
verificationState, handleStepOneNext,
handleStepTwoNext,
goPreviousStep,
resetStepOne,
resetStepTwo,
validatePasswordMatch,
onIdentifierChange,
goLogin goLogin
} = usePage() } = usePage()
</script> </script>