From ea89cd3907aca841f47f8e89c63d012a4c09ab96 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 14 Sep 2026 18:29:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=87=8D=E6=9E=84=E5=BF=98?= =?UTF-8?q?=E8=AE=B0=E5=AF=86=E7=A0=81=E9=A1=B5=E9=9D=A2=E4=B8=BA=E6=B8=85?= =?UTF-8?q?=E6=99=B0=E4=B8=89=E6=AE=B5=E5=BC=8F=E5=90=91=E5=AF=BC=E4=B8=8E?= =?UTF-8?q?=E7=B4=A7=E5=87=91=E8=BE=93=E5=85=A5=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PasswordRecovery/PasswordRecovery.css | 336 +++++++++----- .../PasswordRecovery/PasswordRecovery.js | 270 +++++++---- .../PasswordRecovery/PasswordRecovery.vue | 418 ++++++++++++------ 3 files changed, 693 insertions(+), 331 deletions(-) diff --git a/frontend/src/views/PasswordRecovery/PasswordRecovery.css b/frontend/src/views/PasswordRecovery/PasswordRecovery.css index a2ea6dec..b7867e51 100644 --- a/frontend/src/views/PasswordRecovery/PasswordRecovery.css +++ b/frontend/src/views/PasswordRecovery/PasswordRecovery.css @@ -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; - } -} diff --git a/frontend/src/views/PasswordRecovery/PasswordRecovery.js b/frontend/src/views/PasswordRecovery/PasswordRecovery.js index 12b42afb..459f7f85 100644 --- a/frontend/src/views/PasswordRecovery/PasswordRecovery.js +++ b/frontend/src/views/PasswordRecovery/PasswordRecovery.js @@ -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 } } - diff --git a/frontend/src/views/PasswordRecovery/PasswordRecovery.vue b/frontend/src/views/PasswordRecovery/PasswordRecovery.vue index ea509f12..a977471d 100644 --- a/frontend/src/views/PasswordRecovery/PasswordRecovery.vue +++ b/frontend/src/views/PasswordRecovery/PasswordRecovery.vue @@ -1,4 +1,4 @@ - @@ -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()