feat(web): 重构忘记密码页面为清晰三段式向导与紧凑输入布局
Some checks are pending
Security Checks / security (push) Waiting to run
Some checks are pending
Security Checks / security (push) Waiting to run
This commit is contained in:
parent
0252ac6010
commit
ea89cd3907
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user