主要变更: 1. 重构API目录结构,采用模块化设计,适配5.x后端接口规范 2. 优化布局组件,提升用户界面体验 3. 增强国际化支持,完善多语言配置 4. 新增权限控制功能,支持前端路由权限管理 5. 优化认证流程,支持JWT认证机制 6. 新增中间件和插件,提升系统扩展性 7. 优化状态管理,采用更清晰的模块化设计 8. 新增搜索框、广告等组件,丰富页面功能 9. 更改包管理工具从npm到pnpm,提升依赖管理效率 10. 完善配置系统,支持多环境配置 适配5.x后端特性: - 采用新的API接口规范 - 支持JWT认证机制 - 适配后端权限控制体系 - 支持后端国际化接口 - 优化数据交互方式
566 lines
14 KiB
Vue
566 lines
14 KiB
Vue
<template>
|
||
<div class="forget-password-container" :class="{'dark-mode': isDarkMode}">
|
||
<div class="forget-password-form-container">
|
||
<!-- 第一步:邮箱验证 -->
|
||
<div v-if="currentStep === 1">
|
||
<div class="forget-password-title">{{ t('auth.forget.password.title') }}</div>
|
||
|
||
<el-form
|
||
:model="step1Data"
|
||
ref="step1FormRef"
|
||
:rules="step1Rules"
|
||
:validate-on-rule-change="false"
|
||
class="forget-password-form"
|
||
>
|
||
<el-form-item prop="email" class="form-item">
|
||
<div class="input-container">
|
||
<el-input
|
||
v-model="step1Data.email"
|
||
:placeholder="t('auth.forget.password.email_placeholder')"
|
||
clearable
|
||
class="forget-password-input"
|
||
>
|
||
<template #prefix>
|
||
<Icon
|
||
icon="ant-design:mail-outlined"
|
||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||
class="input-prefix-icon"
|
||
/>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item prop="captcha_code" class="form-item">
|
||
<div class="input-container">
|
||
<el-input
|
||
v-model="step1Data.captcha_code"
|
||
:placeholder="t('auth.forget.password.captcha_placeholder')"
|
||
class="forget-password-input"
|
||
>
|
||
<template #prefix>
|
||
<Icon
|
||
icon="ant-design:safety-outlined"
|
||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||
class="input-prefix-icon"
|
||
/>
|
||
</template>
|
||
<template #suffix>
|
||
<div class="captcha-image" @click="captcha.refresh()">
|
||
<img v-if="captcha.image.value" :src="captcha.image.value" :alt="t('auth.forget.password.captcha_alt')" />
|
||
<Icon v-else icon="ant-design:loading-outlined" :size="24" />
|
||
</div>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item class="form-item">
|
||
<el-button
|
||
type="primary"
|
||
class="next-step-button"
|
||
@click="handleNextStep"
|
||
>
|
||
{{ t('auth.forget.password.next_step') }}
|
||
</el-button>
|
||
</el-form-item>
|
||
|
||
<div class="login-link">
|
||
{{ t('auth.forget.password.back_to_login') }}
|
||
<span class="login-button" @click="switchToLogin">
|
||
{{ t('auth.forget.password.login') }}
|
||
</span>
|
||
</div>
|
||
</el-form>
|
||
</div>
|
||
|
||
<!-- 第二步:设置新密码 -->
|
||
<div v-else>
|
||
<div class="forget-password-title">{{ t('auth.forget.password.reset_title') }}</div>
|
||
|
||
<!-- 验证码发送提示 -->
|
||
<div class="verification-tip" v-if="verificationSent">
|
||
{{ t('auth.forget.password.verification_sent', { email: step1Data.email }) }}<br>
|
||
{{ t('auth.forget.password.verification_instruction') }}
|
||
</div>
|
||
|
||
<el-form
|
||
:model="step2Data"
|
||
ref="step2FormRef"
|
||
:rules="step2Rules"
|
||
:validate-on-rule-change="false"
|
||
class="forget-password-form"
|
||
>
|
||
<el-form-item prop="password" class="form-item">
|
||
<div class="input-container">
|
||
<el-input
|
||
v-model="step2Data.password"
|
||
:placeholder="t('auth.forget.password.password_placeholder')"
|
||
type="password"
|
||
show-password
|
||
clearable
|
||
class="forget-password-input"
|
||
>
|
||
<template #prefix>
|
||
<Icon
|
||
icon="ant-design:lock-outlined"
|
||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||
class="input-prefix-icon"
|
||
/>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item prop="confirm_password" class="form-item">
|
||
<div class="input-container">
|
||
<el-input
|
||
v-model="step2Data.confirm_password"
|
||
:placeholder="t('auth.forget.password.confirm_password_placeholder')"
|
||
type="password"
|
||
show-password
|
||
clearable
|
||
class="forget-password-input"
|
||
>
|
||
<template #prefix>
|
||
<Icon
|
||
icon="ant-design:lock-outlined"
|
||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||
class="input-prefix-icon"
|
||
/>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item prop="code" class="form-item">
|
||
<div class="email-code-container">
|
||
<div class="input-container">
|
||
<el-input
|
||
v-model="step2Data.code"
|
||
:placeholder="t('auth.forget.password.verification_code_placeholder')"
|
||
class="forget-password-input"
|
||
>
|
||
<template #prefix>
|
||
<Icon
|
||
icon="ant-design:key-outlined"
|
||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||
class="input-prefix-icon"
|
||
/>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
<el-button
|
||
type="primary"
|
||
class="send-email-code-button"
|
||
:disabled="emailCodeCountdown > 0"
|
||
@click="handleSendEmailCode"
|
||
>
|
||
{{ emailCodeCountdown > 0 ? `${emailCodeCountdown}s` : t('auth.forget.password.resend_code') }}
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item class="form-item">
|
||
<el-button
|
||
type="primary"
|
||
class="reset-password-button"
|
||
@click="handleResetPassword"
|
||
>
|
||
{{ t('auth.forget.password.reset_password') }}
|
||
</el-button>
|
||
</el-form-item>
|
||
|
||
<div class="prev-step-container">
|
||
<span class="prev-step" @click="handlePrevStep">
|
||
<Icon icon="ant-design:arrow-left-outlined" />
|
||
{{ t('auth.forget.password.prev_step') }}
|
||
</span>
|
||
</div>
|
||
</el-form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { forgetPassword, verifyEmail, sendEmailCode } from '@/api/auth'
|
||
import type { FormInstance } from 'element-plus'
|
||
import { Icon } from '~/components/icon'
|
||
import { t } from '~/composables/lang'
|
||
import { useCaptcha } from '~/composables/captcha'
|
||
import validate from '~/utils/validate'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
// 暗黑模式状态
|
||
const isDarkMode = computed(() => {
|
||
const root = document.querySelector('html')
|
||
return root?.classList.contains('dark') || false
|
||
})
|
||
|
||
// 当前步骤
|
||
const currentStep = ref(1)
|
||
const isLoading = ref(false)
|
||
const emailCodeCountdown = ref(0)
|
||
const verificationSent = ref(false)
|
||
let emailCodeTimer: any = null
|
||
|
||
// 第一步数据:邮箱 + 图片验证码
|
||
const step1Data = reactive({
|
||
email: '',
|
||
captcha_code: '',
|
||
captcha_key: ''
|
||
})
|
||
|
||
// 第二步数据:新密码 + 确认密码 + 验证码
|
||
const step2Data = reactive({
|
||
password: '',
|
||
confirm_password: '',
|
||
code: ''
|
||
})
|
||
|
||
// 存储邮箱,用于第二步
|
||
const savedEmail = ref('')
|
||
|
||
// 表单引用
|
||
const step1FormRef = ref<FormInstance>()
|
||
const step2FormRef = ref<FormInstance>()
|
||
|
||
// 使用验证码组合式函数
|
||
const captcha = useCaptcha(step1Data)
|
||
|
||
// 第一步验证规则
|
||
const step1Rules = computed(() => ({
|
||
email: [
|
||
{
|
||
type: 'string',
|
||
required: true,
|
||
message: t('auth.forget.password.email_required'),
|
||
trigger: ['blur', 'change']
|
||
},
|
||
{
|
||
validator: (_rule: any, value: string) => {
|
||
return validate.email(value)
|
||
},
|
||
message: t('auth.forget.password.email_invalid'),
|
||
trigger: ['blur']
|
||
}
|
||
],
|
||
captcha_code: {
|
||
type: 'string',
|
||
required: true,
|
||
message: t('auth.forget.password.captcha_required'),
|
||
trigger: ['blur', 'change']
|
||
}
|
||
}) as any)
|
||
|
||
// 第二步验证规则
|
||
const step2Rules = computed(() => ({
|
||
password: {
|
||
type: 'string',
|
||
required: true,
|
||
message: t('auth.forget.password.password_required'),
|
||
trigger: ['blur', 'change']
|
||
},
|
||
confirm_password: [
|
||
{
|
||
type: 'string',
|
||
required: true,
|
||
message: t('auth.forget.password.confirm_password_required'),
|
||
trigger: ['blur', 'change']
|
||
},
|
||
{
|
||
validator: (_rule: any, value: string) => {
|
||
return value === step2Data.password
|
||
},
|
||
message: t('auth.forget.password.password_mismatch'),
|
||
trigger: ['blur', 'change']
|
||
}
|
||
],
|
||
code: {
|
||
type: 'string',
|
||
required: true,
|
||
message: t('auth.forget.password.verification_code_required'),
|
||
trigger: ['blur', 'change']
|
||
}
|
||
}) as any)
|
||
|
||
// 发送邮箱验证码
|
||
const handleSendEmailCode = async () => {
|
||
if (!step1Data.email || !validate.email(step1Data.email)) {
|
||
ElMessage.error(t('auth.forget.password.email_invalid'))
|
||
return
|
||
}
|
||
|
||
if (emailCodeCountdown.value > 0) return
|
||
|
||
try {
|
||
await sendEmailCode({
|
||
email: step1Data.email,
|
||
captcha_key: step1Data.captcha_key,
|
||
captcha_code: step1Data.captcha_code
|
||
})
|
||
|
||
ElMessage.success(t('auth.forget.password.code_sent'))
|
||
emailCodeCountdown.value = 60
|
||
|
||
emailCodeTimer = setInterval(() => {
|
||
emailCodeCountdown.value--
|
||
if (emailCodeCountdown.value <= 0) {
|
||
clearInterval(emailCodeTimer)
|
||
}
|
||
}, 1000)
|
||
} catch (error) {
|
||
captcha.refresh()
|
||
}
|
||
}
|
||
|
||
// 组件挂载时加载验证码
|
||
onMounted(() => {
|
||
captcha.refresh()
|
||
})
|
||
|
||
// 下一步:验证邮箱
|
||
const handleNextStep = async () => {
|
||
await step1FormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
|
||
if (isLoading.value) return
|
||
isLoading.value = true
|
||
|
||
try {
|
||
// 发送验证码
|
||
await sendEmailCode({
|
||
email: step1Data.email,
|
||
captcha_key: step1Data.captcha_key,
|
||
captcha_code: step1Data.captcha_code
|
||
})
|
||
|
||
savedEmail.value = step1Data.email
|
||
verificationSent.value = true
|
||
currentStep.value = 2
|
||
|
||
// 开始倒计时
|
||
emailCodeCountdown.value = 60
|
||
emailCodeTimer = setInterval(() => {
|
||
emailCodeCountdown.value--
|
||
if (emailCodeCountdown.value <= 0) {
|
||
clearInterval(emailCodeTimer)
|
||
}
|
||
}, 1000)
|
||
|
||
ElMessage.success(t('auth.forget.password.code_sent_check_email'))
|
||
} catch (error) {
|
||
captcha.refresh()
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
// 上一步
|
||
const handlePrevStep = () => {
|
||
currentStep.value = 1
|
||
verificationSent.value = false
|
||
if (emailCodeTimer) {
|
||
clearInterval(emailCodeTimer)
|
||
emailCodeCountdown.value = 0
|
||
}
|
||
}
|
||
|
||
// 重置密码
|
||
const handleResetPassword = async () => {
|
||
await step2FormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
|
||
if (isLoading.value) return
|
||
isLoading.value = true
|
||
|
||
try {
|
||
await forgetPassword({
|
||
email: savedEmail.value,
|
||
code: step2Data.code,
|
||
new_password: step2Data.password,
|
||
confirm_password: step2Data.confirm_password
|
||
})
|
||
ElMessage.success(t('auth.forget.password.reset_success'))
|
||
emit('typeChange', 'login')
|
||
} catch (error) {
|
||
captcha.refresh()
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
// 切换到登录
|
||
const emit = defineEmits(['typeChange'])
|
||
const switchToLogin = () => {
|
||
emit('typeChange', 'login')
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.forget-password-container {
|
||
width: 100%;
|
||
|
||
&.dark-mode {
|
||
background: var(--el-bg-color);
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
}
|
||
|
||
.forget-password-form-container {
|
||
background: var(--el-bg-color);
|
||
width: 100%;
|
||
padding: 60px 30px;
|
||
border-radius: var(--rounded-big);
|
||
}
|
||
|
||
.forget-password-title {
|
||
font-size: 24px;
|
||
color: var(--el-text-color-primary);
|
||
font-weight: 600;
|
||
text-align: center;
|
||
margin-bottom: 30px;
|
||
font-family: 'OppoSans-R';
|
||
}
|
||
|
||
.forget-password-tip {
|
||
font-size: 14px;
|
||
color: var(--el-text-color-secondary);
|
||
text-align: center;
|
||
margin-bottom: 40px;
|
||
line-height: 20px;
|
||
}
|
||
|
||
.verification-tip {
|
||
background-color: #E8F5E8;
|
||
color: #2E7D32;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
margin-bottom: 30px;
|
||
text-align: center;
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.forget-password-form {
|
||
width: 100%;
|
||
}
|
||
|
||
.form-item {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.captcha-image {
|
||
height: 30px;
|
||
cursor: pointer;
|
||
|
||
img {
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.iconify {
|
||
font-size: 24px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
}
|
||
|
||
.input-container {
|
||
flex: 1;
|
||
height: 50px;
|
||
border: 1px solid var(--el-border-color);
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
background: var(--el-bg-color);
|
||
}
|
||
|
||
.forget-password-input {
|
||
width: 100%;
|
||
border: none;
|
||
|
||
:deep(.el-input__wrapper) {
|
||
box-shadow: none;
|
||
background: transparent;
|
||
|
||
.el-input__inner {
|
||
color: var(--el-text-color-primary);
|
||
|
||
&::placeholder {
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(.el-input__suffix) {
|
||
padding: 0;
|
||
line-height: 1;
|
||
}
|
||
}
|
||
|
||
.input-prefix-icon {
|
||
margin-right: 14px;
|
||
}
|
||
|
||
.email-code-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
}
|
||
|
||
.send-email-code-button {
|
||
flex-shrink: 0;
|
||
height: 50px;
|
||
white-space: nowrap;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.login-link {
|
||
text-align: center;
|
||
margin-top: 20px;
|
||
font-size: 14px;
|
||
color: var(--el-text-color-secondary);
|
||
|
||
.login-button {
|
||
color: var(--el-color-primary);
|
||
cursor: pointer;
|
||
margin-left: 5px;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
}
|
||
|
||
.next-step-button,
|
||
.reset-password-button {
|
||
width: 100%;
|
||
height: 50px;
|
||
border-radius: 8px;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.prev-step-container {
|
||
text-align: center;
|
||
margin-top: 20px;
|
||
|
||
.prev-step {
|
||
color: var(--el-color-primary);
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
}
|
||
</style>
|