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%;
max-width: 760px;
min-width: 0;
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;
}
.recovery-form :deep(.el-form-item) {
min-width: 0;
margin-bottom: 10px;
margin-bottom: 12px;
}
.recovery-form :deep(.el-form-item__label) {
@ -43,6 +148,7 @@
margin-top: 18px;
}
/* 方式切换栏 */
.method-switcher {
position: relative;
display: flex;
@ -89,7 +195,7 @@
.method-copy strong {
color: var(--pm-text-primary);
font-size: 18px;
font-size: 17px;
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
@ -101,6 +207,7 @@
white-space: nowrap;
}
/* 面板 */
.verification-panel {
min-width: 0;
margin-bottom: 18px;
@ -112,46 +219,49 @@
box-shadow: 0 8px 22px rgba(31, 41, 55, 0.06);
}
.verification-panel h2 {
.panel-header h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 8px;
color: var(--pm-text-primary);
font-size: 20px;
font-size: 18px;
font-weight: 700;
line-height: 1.35;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.verification-panel h2::before {
.panel-header h2::before {
width: 5px;
height: 22px;
height: 20px;
flex: 0 0 auto;
border-radius: 999px;
background: var(--pm-primary-color);
content: '';
}
.verification-panel p {
.panel-header p {
margin: 0 0 16px;
color: var(--pm-text-secondary);
font-size: 14px;
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.verification-panel :deep(.el-form-item:last-child),
.offline-grid :deep(.el-form-item) {
margin-bottom: 0;
/* 核心紧凑排列组:用户名与外部邮箱相邻 */
.compact-field-group {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 4px;
}
.verification-code-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(128px, auto);
grid-template-columns: minmax(0, 1fr) 140px;
gap: 10px;
}
@ -159,32 +269,102 @@
min-width: 0;
}
.verification-code-row :deep(.el-button) {
.code-btn {
width: 100%;
font-size: 13px !important;
}
.verification-state {
display: block;
min-width: 0;
margin: -2px 0 10px;
color: var(--pm-text-secondary);
margin: 4px 0 14px;
padding: 8px 12px;
background: rgba(37, 99, 235, 0.06);
border-radius: 8px;
color: var(--pm-primary-color);
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.offline-grid,
.password-grid {
.offline-grid {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.password-grid :deep(.el-form-item) {
.offline-grid :deep(.el-form-item) {
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 {
display: flex;
align-items: center;
@ -197,6 +377,7 @@
.actions {
min-width: 0;
display: flex;
gap: 12px;
justify-content: flex-end;
}
@ -215,9 +396,9 @@
.recovery-form :deep(.recovery-action-btn),
.recovery-form :deep(.switch-button) {
min-height: 44px;
padding: 0 14px;
border-radius: 14px;
font-size: 15px;
padding: 0 18px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
line-height: 1.35;
white-space: normal;
@ -229,12 +410,12 @@
}
.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) {
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) {
@ -253,9 +434,8 @@
}
@media (max-width: 760px) {
.recovery-form {
max-width: none;
padding: 18px 16px 22px;
.password-recovery-container {
padding: 16px 14px 20px;
}
.reset-boundary {
@ -264,40 +444,51 @@
}
@media (max-width: 560px) {
.recovery-steps-bar {
padding: 8px 10px;
gap: 6px;
}
.step-label {
font-size: 12px;
}
.step-divider {
max-width: 24px;
}
.method-switcher {
gap: 8px;
padding: 14px;
padding: 12px;
}
.switch-button {
max-width: 48%;
font-size: 13px;
font-size: 12px;
}
.verification-panel {
padding: 16px 14px 18px;
padding: 14px 12px 16px;
}
.verification-panel h2 {
font-size: 18px;
.panel-header h2 {
font-size: 16px;
}
.verification-panel p {
font-size: 13px;
.panel-header p {
font-size: 12px;
}
.verification-code-row {
grid-template-columns: minmax(0, 1fr) minmax(118px, auto);
grid-template-columns: minmax(0, 1fr) 110px;
}
.offline-grid,
.password-grid {
.offline-grid {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.offline-grid :deep(.el-form-item),
.password-grid :deep(.el-form-item) {
.offline-grid :deep(.el-form-item) {
margin-bottom: 10px;
}
@ -313,68 +504,3 @@
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,20 +1,19 @@
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { http } from '@/utils/axios'
import { isValidPasswordLength } from '@/utils/password'
export function usePage() {
const currentStep = ref(1)
const loading = ref(false)
const sendingCode = ref(false)
const countdown = ref(0)
let timer = null
const verificationTicket = ref('')
const verificationState = ref('')
const passwordMismatchError = ref('')
const recovery = reactive({
method: 'email',
identifier: '',
@ -27,15 +26,36 @@ export function usePage() {
newPassword: '',
confirmPassword: ''
})
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
const currentMethodLabel = computed(() => recovery.method === 'email'
? '第三方安全邮箱验证码'
: '密保问题 + 离线恢复码')
const switchButtonText = computed(() => recovery.method === 'email'
? '切换为离线恢复'
: '切换为邮箱验证')
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 = () => {
recovery.email = ''
recovery.code = ''
@ -45,16 +65,35 @@ export function usePage() {
recovery.recoveryCode = ''
verificationTicket.value = ''
verificationState.value = ''
if (timer) {
clearInterval(timer)
timer = null
}
countdown.value = 0
}
const switchRecoveryMethod = () => {
recovery.method = recovery.method === 'email' ? 'offline' : 'email'
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 () => {
if (!recovery.identifier || !recovery.email) {
ElMessage.warning('请先填写用户名或完整邮箱以及外部安全邮箱')
ElMessage.warning('请填写用户名或完整邮箱以及外部安全邮箱')
return
}
recovery.code = ''
@ -67,17 +106,100 @@ export function usePage() {
identifier: recovery.identifier,
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
verificationState.value = '如果账号和外部安全邮箱匹配,验证码将发送到该邮箱'
} catch (_) {
startCountdown()
ElMessage.success('验证码已发送,请检查外部安全邮箱')
} catch (err) {
verificationState.value = '请求已受理,请稍后检查外部安全邮箱'
ElMessage.info('请求已受理,请稍后检查外部安全邮箱')
startCountdown()
} finally {
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) {
ElMessage.warning('请填写两次新密码')
return false
@ -92,99 +214,67 @@ export function usePage() {
}
return true
}
const validateRecoveryMethod = () => {
if (!recovery.identifier) {
ElMessage.warning('请填写用户名或完整邮箱')
return false
const handleStepTwoNext = async () => {
if (!validateStepTwo()) return
if (!verificationTicket.value) {
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
verificationTicket.value = ''
verificationState.value = ''
try {
const ticket = await verifyRecovery()
const result = await http.post('/api/v1/security/recovery/complete', {
ticket,
ticket: verificationTicket.value,
new_password: recovery.newPassword
})
if (result.errorNo !== 0 || !result.data?.reset) throw new Error('重置失败')
clearMethodState()
recovery.identifier = ''
recovery.newPassword = ''
recovery.confirmPassword = ''
ElMessage.success('密码已重置,请使用新密码登录')
goLogin()
} catch (_) {
verificationTicket.value = ''
verificationState.value = '验证或重置失败,请检查输入后重试'
ElMessage.error('验证或重置失败')
if (result.errorNo !== 0 || !result.data?.reset) {
throw new Error(result.errorMsg || '密码重置失败')
}
currentStep.value = 3
ElMessage.success('新登录密码重置成功!')
} catch (err) {
ElMessage.error(err.message || '密码重置失败,请重试')
} finally {
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')
return {
computed,
reactive,
ref,
ElMessage,
http,
currentStep,
loading,
sendingCode,
verificationTicket,
countdown,
verificationState,
recovery,
currentMethodLabel,
switchButtonText,
clearMethodState,
isActionDisabled,
isCodeButtonDisabled,
passwordMismatchError,
switchRecoveryMethod,
requestRecoveryCode,
validatePassword,
validateRecoveryMethod,
verifyRecovery,
completeRecovery,
handleStepOneNext,
handleStepTwoNext,
goPreviousStep,
resetStepOne,
resetStepTwo,
validatePasswordMatch,
onIdentifierChange,
goLogin
}
}

View File

@ -1,4 +1,4 @@
<template>
<template>
<AuthLayout
brand-caption="账号密码恢复"
intro-title="恢复账号访问"
@ -14,153 +14,288 @@
/>
</template>
<el-form
class="recovery-form"
label-position="top"
@submit.prevent="completeRecovery"
>
<el-form-item label="用户名或完整邮箱">
<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"
<div class="password-recovery-container">
<!-- 三段式进度向导导航条 -->
<div class="recovery-steps-bar">
<div
class="step-item"
:class="{ active: currentStep >= 1, current: currentStep === 1 }"
>
{{ switchButtonText }}
</el-button>
<div class="step-number">1</div>
<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>
<section
v-if="recovery.method === 'email'"
class="verification-panel"
>
<h2>使用第三方安全邮箱</h2>
<p>验证码只会发送到该账号已绑定并验证的外部安全邮箱。</p>
<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>
<!-- 步骤 1:身份验证 -->
<div v-if="currentStep === 1" class="step-content">
<div class="method-switcher">
<div class="method-copy">
<span class="method-label">当前验证方式</span>
<strong>{{ currentMethodLabel }}</strong>
</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">
<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>
<el-button
plain
type="primary"
class="switch-button secondary-action-btn"
:disabled="isActionDisabled"
@click="switchRecoveryMethod"
>
{{ switchButtonText }}
</el-button>
</div>
</section>
<span v-if="verificationState" class="verification-state">
{{ verificationState }}
</span>
<el-form
class="recovery-form"
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">
<el-form-item label="新密码">
<el-input
v-model="recovery.newPassword"
type="password"
show-password
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="recovery.confirmPassword"
type="password"
show-password
autocomplete="new-password"
/>
</el-form-item>
<div class="compact-field-group">
<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.email"
type="email"
autocomplete="email"
placeholder="name@example.com"
/>
</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 class="recovery-footer">
<el-button
link
type="primary"
class="back-button"
@click="goLogin"
<!-- 步骤 2:设置新密码 -->
<div v-else-if="currentStep === 2" class="step-content">
<div class="step-two-intro">
<h2>设置新密码</h2>
<p>身份核验通过,请为账号 <strong>{{ recovery.identifier }}</strong> 设置新的安全登录密码。</p>
</div>
<el-form
class="recovery-form"
label-position="top"
@submit.prevent="handleStepTwoNext"
>
返回登录
</el-button>
<div class="actions">
<div class="password-grid-vertical">
<el-form-item label="新密码" required>
<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
type="primary"
class="recovery-action-btn"
native-type="submit"
:loading="loading"
:disabled="sendingCode"
@click="goLogin"
>
验证并重置密码
重新登录
</el-button>
</div>
</div>
</el-form>
</div>
</AuthLayout>
</template>
@ -169,15 +304,26 @@ import AuthLayout from '@/views/components/AuthLayout/AuthLayout.vue'
import { usePage } from './PasswordRecovery.js'
const {
completeRecovery,
currentStep,
loading,
sendingCode,
countdown,
verificationState,
recovery,
currentMethodLabel,
loading,
switchRecoveryMethod,
switchButtonText,
sendingCode,
isActionDisabled,
isCodeButtonDisabled,
passwordMismatchError,
switchRecoveryMethod,
requestRecoveryCode,
verificationState,
handleStepOneNext,
handleStepTwoNext,
goPreviousStep,
resetStepOne,
resetStepTwo,
validatePasswordMatch,
onIdentifierChange,
goLogin
} = usePage()
</script>