Files
simple-memo-wx/pages/login/login.vue
T

1093 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="login-page" :style="{ background: themeConfig.bgColor }">
<!-- 顶部品牌区 -->
<view class="brand-container">
<image class="logo-image" :src="logoConfig.path" mode="aspectFit" />
<view class="logo-text" :style="{ color: themeConfig.textPrimary }">{{ logoConfig.name }}</view>
<view class="desc" :style="{ color: themeConfig.textSecondary }">{{ logoConfig.subtitle }}</view>
</view>
<!-- 登录方式切换 -->
<view class="login-tabs" :style="{ background: themeConfig.cardBgColor }">
<view
class="tab-item"
:class="{ active: loginType === 'wechat' }"
:style="loginType === 'wechat' ? {
background: themeConfig.gradientSoft,
color: '#fff',
boxShadow: `0 4rpx 16rpx ${themeConfig.shadowColor}`
} : { color: themeConfig.textSecondary }"
@click="switchToWechat"
>微信登录</view>
<view
class="tab-item"
:class="{ active: loginType === 'email' }"
:style="loginType === 'email' ? {
background: themeConfig.gradientSoft,
color: '#fff',
boxShadow: `0 4rpx 16rpx ${themeConfig.shadowColor}`
} : { color: themeConfig.textSecondary }"
@click="openEmailModal"
>邮箱登录</view>
</view>
<!-- 微信登录区 -->
<view v-if="loginType === 'wechat'" class="login-container">
<!-- 协议勾选框微信登录必须勾选 -->
<view
class="agreement-checkbox"
:class="{ shake: wechatShake }"
@click="toggleWechatAgree"
>
<view
class="checkbox-box"
:class="{ checked: isWechatAgree }"
:style="isWechatAgree ? {
background: themeConfig.primaryColor,
borderColor: themeConfig.primaryColor
} : {
background: themeConfig.cardBgColor,
borderColor: themeConfig.borderColor
}"
>
<FaIcon v-if="isWechatAgree" icon="check" :size="16" color="#fff" />
</view>
<view class="checkbox-text-wrapper">
<text class="checkbox-text" :style="{ color: themeConfig.textTertiary }">我已阅读并同意</text>
<text class="link-text" :style="{ color: themeConfig.primaryColor }" @click.stop="gotoUserAgreement">用户协议</text>
<text class="checkbox-text" :style="{ color: themeConfig.textTertiary }"></text>
<text class="link-text" :style="{ color: themeConfig.primaryColor }" @click.stop="gotoPrivacyPolicy">隐私政策</text>
</view>
</view>
<view
class="login-btn btn btn--primary btn--block"
:class="{ disabled: isLoading }"
@click="wxLogin"
>
<text v-if="isLoading" class="btn-text">登录中...</text>
<template v-else>
<FaIcon icon="comment" :size="20" color="#fff" />
<text class="btn-text">微信一键登录</text>
</template>
</view>
<view class="tips" :style="{ color: themeConfig.textTertiary }">登录后即可使用全部功能</view>
</view>
<!-- 邮箱登录引导区tab 选中后引导打开弹窗 -->
<view v-else class="login-container email-quick">
<view class="email-subtitle" :style="{ color: themeConfig.textTertiary }">点击下方按钮在弹窗中输入邮箱和密码</view>
<view
class="login-btn btn btn--primary btn--block"
:class="{ disabled: isLoading }"
@click="openEmailModal"
>
<FaIcon icon="envelope" :size="20" color="#fff" />
<text class="btn-text">打开邮箱登录</text>
</view>
</view>
<!-- 邮箱登录弹窗 -->
<CenterModal
:visible.sync="showEmailModal"
title="邮箱登录"
cancelText="取消"
confirmText="登录"
:confirmDisabled="!canEmailLogin"
@confirm="handleEmailLogin"
@cancel="showEmailModal = false"
>
<view class="modal-form">
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">邮箱地址</text>
<input
v-model="emailForm.email"
type="text"
placeholder="请输入邮箱"
class="form-input"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">密码</text>
<view class="password-input-wrapper">
<input
v-model="emailForm.password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
class="form-input password-input"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
<FaIcon class="password-toggle" :style="{ color: themeConfig.textTertiary }" :icon="showPassword ? 'eye-slash' : 'eye'" :size="20" @click="showPassword = !showPassword" />
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">图形验证码</text>
<view class="captcha-row">
<input
v-model="emailForm.captcha"
type="text"
placeholder="请输入验证码"
class="form-input captcha-input"
maxlength="4"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
<view
class="captcha-image"
:style="{ background: themeConfig.inputBgColor, borderColor: themeConfig.borderColor }"
@click="refreshEmailCaptcha"
>
<image :src="emailCaptchaUrl" mode="aspectFit" class="captcha-img" />
</view>
</view>
</view>
<view class="agreement-checkbox" :class="{ shake: emailShake }" @click="isEmailAgree = !isEmailAgree">
<view
class="checkbox-box"
:class="{ checked: isEmailAgree }"
:style="isEmailAgree ? {
background: themeConfig.primaryColor,
borderColor: themeConfig.primaryColor
} : {
background: themeConfig.cardBgColor,
borderColor: themeConfig.borderColor
}"
>
<FaIcon v-if="isEmailAgree" icon="check" :size="16" color="#fff" />
</view>
<view class="checkbox-text-wrapper">
<text class="checkbox-text" :style="{ color: themeConfig.textTertiary }">我已阅读并同意</text>
<text class="link-text" :style="{ color: themeConfig.primaryColor }" @click.stop="gotoUserAgreement">用户协议</text>
<text class="checkbox-text" :style="{ color: themeConfig.textTertiary }"></text>
<text class="link-text" :style="{ color: themeConfig.primaryColor }" @click.stop="gotoPrivacyPolicy">隐私政策</text>
</view>
</view>
<!-- 弹窗内的底部快捷链接 -->
<view class="modal-bottom-links">
<view class="register-link" :style="{ color: themeConfig.primaryColor }" @click="switchToRegister">
还没有账号立即注册
</view>
<view class="forgot-link" :style="{ color: themeConfig.textTertiary }" @click="switchToForgot">
忘记密码
</view>
</view>
</view>
</CenterModal>
<!-- 注册弹窗 -->
<CenterModal
:visible.sync="showRegisterModal"
title="注册账号"
:showClose="true"
:showCancel="true"
cancelText="取消"
confirmText="注册"
:confirmDisabled="!canRegister"
@confirm="handleRegister"
@cancel="showRegisterModal = false"
>
<view class="modal-form">
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">邮箱地址</text>
<input
v-model="registerForm.email"
type="text"
placeholder="请输入邮箱"
class="form-input"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">设置密码</text>
<view class="password-input-wrapper">
<input
v-model="registerForm.password"
:type="showRegisterPassword ? 'text' : 'password'"
placeholder="请设置密码(6位以上)"
class="form-input password-input"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
<FaIcon class="password-toggle" :style="{ color: themeConfig.textTertiary }" :icon="showRegisterPassword ? 'eye-slash' : 'eye'" :size="20" @click="showRegisterPassword = !showRegisterPassword" />
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">确认密码</text>
<view class="password-input-wrapper">
<input
v-model="registerForm.confirmPassword"
:type="showRegisterPassword ? 'text' : 'password'"
placeholder="请再次输入密码"
class="form-input password-input"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">图形验证码</text>
<view class="captcha-row">
<input
v-model="registerForm.captcha"
type="text"
placeholder="请输入验证码"
class="form-input captcha-input"
maxlength="4"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
<view
class="captcha-image"
:style="{ background: themeConfig.inputBgColor, borderColor: themeConfig.borderColor }"
@click="refreshCaptcha"
>
<image :src="captchaUrl" mode="aspectFit" class="captcha-img" />
</view>
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">邮箱验证码</text>
<view class="captcha-row">
<input
v-model="registerForm.code"
type="text"
placeholder="请输入邮箱验证码"
class="form-input captcha-input"
maxlength="6"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
<view
class="send-code-btn"
:class="{ disabled: registerCountdown > 0 || !registerForm.email || !registerForm.captcha }"
:style="(registerCountdown > 0 || !registerForm.email || !registerForm.captcha) ? { background: themeConfig.borderColor, color: themeConfig.textTertiary } : { background: themeConfig.primaryColor, color: '#fff' }"
@click="sendRegisterCode"
>
<text>{{ registerCountdown > 0 ? registerCountdown + 's' : '发送验证码' }}</text>
</view>
</view>
</view>
<view v-if="registerForm.password && registerForm.confirmPassword && registerForm.password !== registerForm.confirmPassword" class="form-error" :style="{ color: themeConfig.dangerColor }">两次密码不一致</view>
</view>
</CenterModal>
<!-- 找回密码弹窗 -->
<CenterModal
:visible.sync="showForgotModal"
title="找回密码"
:showClose="true"
:showCancel="true"
cancelText="取消"
confirmText="重置密码"
:confirmDisabled="!canForgot"
@confirm="handleForgotPassword"
@cancel="showForgotModal = false"
>
<view class="modal-form">
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">邮箱地址</text>
<input
v-model="forgotForm.email"
type="text"
placeholder="请输入注册邮箱"
class="form-input"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">图形验证码</text>
<view class="captcha-row">
<input
v-model="forgotForm.captcha"
type="text"
placeholder="请输入验证码"
class="form-input captcha-input"
maxlength="4"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
<view
class="captcha-image"
:style="{ background: themeConfig.inputBgColor, borderColor: themeConfig.borderColor }"
@click="refreshForgotCaptcha"
>
<image :src="forgotCaptchaUrl" mode="aspectFit" class="captcha-img" />
</view>
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">邮箱验证码</text>
<view class="captcha-row">
<input
v-model="forgotForm.code"
type="text"
placeholder="请输入邮箱验证码"
class="form-input captcha-input"
maxlength="6"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
<view
class="send-code-btn"
:class="{ disabled: forgotCountdown > 0 || !forgotForm.email || !forgotForm.captcha }"
:style="(forgotCountdown > 0 || !forgotForm.email || !forgotForm.captcha) ? { background: themeConfig.borderColor, color: themeConfig.textTertiary } : { background: themeConfig.primaryColor, color: '#fff' }"
@click="sendForgotCode"
>
<text>{{ forgotCountdown > 0 ? forgotCountdown + 's' : '发送验证码' }}</text>
</view>
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">新密码</text>
<view class="password-input-wrapper">
<input
v-model="forgotForm.password"
:type="showForgotPassword ? 'text' : 'password'"
placeholder="请设置新密码(6位以上)"
class="form-input password-input"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
<FaIcon class="password-toggle" :style="{ color: themeConfig.textTertiary }" :icon="showForgotPassword ? 'eye-slash' : 'eye'" :size="20" @click="showForgotPassword = !showForgotPassword" />
</view>
</view>
<view class="form-item">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">确认新密码</text>
<view class="password-input-wrapper">
<input
v-model="forgotForm.confirmPassword"
:type="showForgotPassword ? 'text' : 'password'"
placeholder="请再次输入新密码"
class="form-input password-input"
:style="{ background: themeConfig.inputBgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
placeholder-class="form-input-placeholder"
/>
</view>
</view>
<view v-if="forgotForm.password && forgotForm.confirmPassword && forgotForm.password !== forgotForm.confirmPassword" class="form-error" :style="{ color: themeConfig.dangerColor }">两次密码不一致</view>
</view>
</CenterModal>
</view>
</template>
<script>
import { login, getUserConfig, emailLogin, register, sendEmailCode, getCaptcha, resetPassword } from '@/api/index.js'
import { setToken } from '@/utils/auth.js'
import { injectTheme, THEME_CHANGE_EVENT, getThemeConfigFromStorage, THEME_PRESETS, DEFAULT_THEME_KEY } from '@/utils/theme.js'
import { LOGO_CONFIG } from '@/utils/constants.js'
import FaIcon from '@/components/FaIcon.vue'
import CenterModal from '@/components/CenterModal.vue'
export default {
components: {
FaIcon,
CenterModal
},
data() {
return {
logoConfig: LOGO_CONFIG,
themeConfig: getThemeConfigFromStorage() || THEME_PRESETS[DEFAULT_THEME_KEY],
loginType: 'wechat',
isLoading: false,
isEmailAgree: false,
isWechatAgree: false,
wechatShake: false,
emailShake: false,
showPassword: false,
emailForm: {
email: '',
password: '',
captcha: '',
captchaId: ''
},
showEmailModal: false,
showRegisterModal: false,
showForgotModal: false,
showRegisterPassword: false,
showForgotPassword: false,
registerCountdown: 0,
forgotCountdown: 0,
captchaUrl: '',
emailCaptchaUrl: '',
forgotCaptchaUrl: '',
registerForm: {
email: '',
password: '',
confirmPassword: '',
captcha: '',
captchaId: '',
code: ''
},
forgotForm: {
email: '',
captcha: '',
captchaId: '',
code: '',
password: '',
confirmPassword: ''
}
}
},
computed: {
canRegister() {
const { email, password, confirmPassword, captcha, code } = this.registerForm
return !!(email && password && confirmPassword && captcha && code && password === confirmPassword)
},
canEmailLogin() {
const { email, password, captcha } = this.emailForm
// 协议勾选不在这里校验,统一在 handleEmailLogin 中通过抖动+toast 提示
return !!(email && password && captcha && !this.isLoading)
},
canForgot() {
const { email, captcha, code, password, confirmPassword } = this.forgotForm
return !!(email && captcha && code && password && confirmPassword && password === confirmPassword)
}
},
onLoad() {
this.syncThemeConfig()
},
onShow() {
this.syncThemeConfig()
},
beforeUnmount() {
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
},
methods: {
syncThemeConfig() {
const appInstance = getApp({ allowDefault: true })
const globalTheme = appInstance?.globalData?.themeConfig
this.themeConfig = globalTheme
|| getThemeConfigFromStorage()
|| THEME_PRESETS[DEFAULT_THEME_KEY]
if (!this.themeChangeHandler) {
this.themeChangeHandler = ({ key, config }) => {
if (key && config) this.themeConfig = config
}
uni.$on(THEME_CHANGE_EVENT, this.themeChangeHandler)
}
},
gotoUserAgreement() {
uni.navigateTo({ url: '/pages/userAgreement/userAgreement' })
},
gotoPrivacyPolicy() {
uni.navigateTo({ url: '/pages/privacyPolicy/privacyPolicy' })
},
switchToWechat() {
this.loginType = 'wechat'
this.showEmailModal = false
},
openEmailModal() {
this.loginType = 'email'
this.showEmailModal = true
if (!this.emailCaptchaUrl) {
this.refreshEmailCaptcha()
}
},
// 切换到注册(从邮箱弹窗底部链接)
switchToRegister() {
this.showEmailModal = false
// 等弹窗关闭动画结束再打开注册弹窗
this.$nextTick(() => {
setTimeout(() => this.openRegisterModal(), 250)
})
},
// 切换到忘记密码(从邮箱弹窗底部链接)
switchToForgot() {
this.showEmailModal = false
this.$nextTick(() => {
setTimeout(() => this.openForgotModal(), 250)
})
},
// 微信登录协议勾选切换
toggleWechatAgree() {
this.isWechatAgree = !this.isWechatAgree
},
// 触发抖动动画(提示用户需勾选协议)
triggerShake(type) {
const flag = type === 'wechat' ? 'wechatShake' : 'emailShake'
this[flag] = false
this.$nextTick(() => {
this[flag] = true
setTimeout(() => { this[flag] = false }, 600)
})
},
async wxLogin() {
// 微信登录必须先勾选协议
if (!this.isWechatAgree) {
this.triggerShake('wechat')
uni.showToast({ title: '请先勾选并同意《用户协议》和《隐私政策》', icon: 'none', duration: 2000 })
return
}
if (this.isLoading) return
this.isLoading = true
try {
const loginRes = await uni.login().catch(() => { throw new Error('获取微信登录凭证失败,请检查网络') })
const code = loginRes.code
if (!code) throw new Error('微信登录凭证为空')
const result = await login(code)
if (!result?.token) throw new Error('登录失败,未获取到token')
setToken(result.token)
await this.syncUserConfig()
uni.showToast({ title: '登录成功', icon: 'success', duration: 1500 })
setTimeout(() => {
uni.switchTab({
url: '/pages/index/index',
fail: () => { uni.redirectTo({ url: '/pages/index/index' }) }
})
}, 1500)
} catch (err) {
console.error('登录失败:', err)
uni.showToast({ title: err?.message || '登录失败,请重试', icon: 'none', duration: 2000 })
} finally {
this.isLoading = false
}
},
async handleEmailLogin() {
const { email, password } = this.emailForm
if (!email || !password) {
return uni.showToast({ title: '请填写邮箱和密码', icon: 'none' })
}
// 邮箱登录必须先勾选协议
if (!this.isEmailAgree) {
this.triggerShake('email')
uni.showToast({ title: '请先勾选并同意《用户协议》和《隐私政策》', icon: 'none', duration: 2000 })
return
}
this.isLoading = true
try {
const result = await emailLogin({
email,
password,
captcha: this.emailForm.captcha,
captchaId: this.emailForm.captchaId
})
if (!result?.token) throw new Error('登录失败')
setToken(result.token)
await this.syncUserConfig()
uni.showToast({ title: '登录成功', icon: 'success', duration: 1500 })
this.showEmailModal = false
setTimeout(() => {
uni.switchTab({
url: '/pages/index/index',
fail: () => { uni.redirectTo({ url: '/pages/index/index' }) }
})
}, 1500)
} catch (err) {
console.error('邮箱登录失败:', err)
if (err.message && err.message.includes('账号不存在')) {
uni.showModal({
title: '提示',
content: '该邮箱尚未注册,是否立即注册?',
confirmText: '去注册',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
this.showEmailModal = false
setTimeout(() => this.openRegisterModal(), 300)
}
}
})
} else {
uni.showToast({ title: err?.message || '登录失败,请重试', icon: 'none', duration: 2000 })
}
} finally {
this.isLoading = false
}
},
async refreshCaptcha() {
try {
const res = await getCaptcha()
if (res?.image) {
this.captchaUrl = res.image
this.registerForm.captchaId = res.captchaId
}
} catch (err) { console.error('获取验证码失败:', err) }
},
async sendRegisterCode() {
if (this.registerCountdown > 0) return
const { email, captcha } = this.registerForm
if (!email) return uni.showToast({ title: '请先输入邮箱', icon: 'none' })
if (!captcha) return uni.showToast({ title: '请先输入图形验证码', icon: 'none' })
try {
await sendEmailCode({ email, captcha, captchaId: this.registerForm.captchaId, type: 'register' })
uni.showToast({ title: '验证码已发送', icon: 'success' })
this.registerCountdown = 60
const timer = setInterval(() => {
this.registerCountdown--
if (this.registerCountdown <= 0) clearInterval(timer)
}, 1000)
} catch (err) {
uni.showToast({ title: err?.message || '发送失败', icon: 'none' })
this.refreshCaptcha()
this.registerForm.captcha = ''
}
},
async handleRegister() {
const { email, password, confirmPassword, code } = this.registerForm
if (!email || !password || !confirmPassword || !code) {
return uni.showToast({ title: '请填写完整信息', icon: 'none' })
}
if (password !== confirmPassword) return uni.showToast({ title: '两次密码不一致', icon: 'none' })
if (password.length < 6) return uni.showToast({ title: '密码长度不能少于6位', icon: 'none' })
this.isLoading = true
try {
const result = await register({ email, password, confirmPassword, code })
if (!result?.token) throw new Error('注册失败')
setToken(result.token)
await this.syncUserConfig()
uni.showToast({ title: '注册成功', icon: 'success', duration: 1500 })
this.showRegisterModal = false
setTimeout(() => {
uni.switchTab({
url: '/pages/index/index',
fail: () => { uni.redirectTo({ url: '/pages/index/index' }) }
})
}, 1500)
} catch (err) {
uni.showToast({ title: err?.message || '注册失败,请重试', icon: 'none', duration: 2000 })
this.refreshCaptcha()
this.registerForm.captcha = ''
} finally {
this.isLoading = false
}
},
openRegisterModal() {
this.showRegisterModal = true
if (!this.captchaUrl) this.refreshCaptcha()
},
openForgotModal() {
this.showForgotModal = true
if (!this.forgotCaptchaUrl) this.refreshForgotCaptcha()
},
async refreshEmailCaptcha() {
try {
const res = await getCaptcha()
if (res?.image) {
this.emailCaptchaUrl = res.image
this.emailForm.captchaId = res.captchaId
}
} catch (err) { console.error('获取邮箱登录验证码失败:', err) }
},
async refreshForgotCaptcha() {
try {
const res = await getCaptcha()
if (res?.image) {
this.forgotCaptchaUrl = res.image
this.forgotForm.captchaId = res.captchaId
}
} catch (err) { console.error('获取找回密码验证码失败:', err) }
},
async sendForgotCode() {
if (this.forgotCountdown > 0) return
const { email, captcha } = this.forgotForm
if (!email) return uni.showToast({ title: '请先输入邮箱', icon: 'none' })
if (!captcha) return uni.showToast({ title: '请先输入图形验证码', icon: 'none' })
try {
await sendEmailCode({ email, captcha, captchaId: this.forgotForm.captchaId, type: 'forgot' })
uni.showToast({ title: '验证码已发送', icon: 'success' })
this.forgotCountdown = 60
const timer = setInterval(() => {
this.forgotCountdown--
if (this.forgotCountdown <= 0) clearInterval(timer)
}, 1000)
} catch (err) {
uni.showToast({ title: err?.message || '发送失败', icon: 'none' })
this.refreshForgotCaptcha()
this.forgotForm.captcha = ''
}
},
closeForgotModal() {
this.showForgotModal = false
},
async handleForgotPassword() {
const { email, captcha, code, password, confirmPassword } = this.forgotForm
if (!email || !captcha || !code || !password || !confirmPassword) {
return uni.showToast({ title: '请填写完整信息', icon: 'none' })
}
if (password !== confirmPassword) return uni.showToast({ title: '两次密码不一致', icon: 'none' })
if (password.length < 6) return uni.showToast({ title: '密码长度不能少于6位', icon: 'none' })
this.isLoading = true
try {
await resetPassword({ email, password, confirmPassword, code, captchaId: this.forgotForm.captchaId })
uni.showToast({ title: '密码重置成功', icon: 'success', duration: 1500 })
this.showForgotModal = false
setTimeout(() => {
this.openEmailModal()
this.emailForm.email = email
}, 1500)
} catch (err) {
uni.showToast({ title: err?.message || '重置失败,请重试', icon: 'none', duration: 2000 })
this.refreshForgotCaptcha()
this.forgotForm.captcha = ''
} finally {
this.isLoading = false
}
},
async syncUserConfig() {
try {
const res = await getUserConfig()
if (res?.theme) injectTheme(res.theme)
} catch (error) {
console.warn('用户配置同步失败:', error)
}
}
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/buttons.scss';
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 40rpx 40rpx;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
position: relative;
overflow-x: hidden;
}
.brand-container {
text-align: center;
margin-bottom: 48rpx;
z-index: 1;
}
.logo-image {
width: 160rpx;
height: 160rpx;
display: block;
margin: 0 auto 28rpx;
}
.logo-text {
font-size: 52rpx;
font-weight: 700;
letter-spacing: 4rpx;
margin-bottom: 14rpx;
display: block;
}
.desc {
font-size: 26rpx;
line-height: 1.5;
letter-spacing: 1rpx;
}
.login-tabs {
display: flex;
border-radius: 16rpx;
padding: 8rpx;
margin-bottom: 40rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
z-index: 1;
}
.tab-item {
padding: 20rpx 56rpx;
font-size: 28rpx;
border-radius: 12rpx;
transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
font-weight: 500;
}
.login-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
z-index: 1;
flex: 1;
justify-content: flex-start;
padding-top: 16rpx;
}
.email-quick {
align-items: center;
text-align: center;
padding-top: 0;
justify-content: flex-start;
min-height: 30vh;
}
/* 微信登录区的小字协议 */
.agreement-link {
margin-top: 32rpx;
font-size: 24rpx;
text-align: center;
line-height: 1.6;
}
.email-icon-wrap {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.email-subtitle {
font-size: 26rpx;
margin-bottom: 48rpx;
line-height: 1.6;
padding: 0 40rpx;
}
.modal-form {
padding: 8rpx 0;
}
.form-item {
margin-bottom: 28rpx;
}
.form-label {
font-size: 26rpx;
margin-bottom: 12rpx;
display: block;
font-weight: 500;
}
.form-input {
width: 100%;
height: 84rpx;
border-radius: 14rpx;
padding: 0 24rpx;
font-size: 28rpx;
box-sizing: border-box;
border: 2rpx solid;
transition: all 0.25s ease;
}
.form-input-placeholder {
color: var(--textTertiary, #C0C4CC);
font-size: 28rpx;
}
.password-input-wrapper {
position: relative;
}
.password-input {
padding-right: 80rpx;
}
.password-toggle {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
font-size: 36rpx;
padding: 8rpx;
}
.agreement-checkbox {
display: flex;
align-items: flex-start;
margin: 8rpx 0 24rpx;
padding: 0 4rpx;
}
/* 未勾选协议抖动提示 */
.agreement-checkbox.shake {
animation: shakeX 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translateZ(0);
}
@keyframes shakeX {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-8rpx); }
20%, 40%, 60%, 80% { transform: translateX(8rpx); }
}
/* 弹窗内底部快捷链接(注册 / 忘记密码) */
.modal-bottom-links {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24rpx;
padding: 0 4rpx;
font-size: 26rpx;
}
.checkbox-box {
width: 38rpx;
height: 38rpx;
border: 2rpx solid;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 14rpx;
margin-top: 4rpx;
flex-shrink: 0;
transition: all 0.25s;
}
.checkbox-text-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1;
}
.checkbox-text {
font-size: 24rpx;
line-height: 1.5;
}
.link-text {
font-size: 24rpx;
text-decoration: underline;
margin: 0 4rpx;
}
/* 登录页主按钮:直接使用 .btn + .btn--primary 统一样式 */
.btn-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
margin-left: 10rpx;
}
.tips {
text-align: center;
font-size: 24rpx;
margin-top: 24rpx;
}
/* .login-bottom-links 已迁移到 .modal-bottom-links(弹窗内),此处保留以防引用 */
.login-bottom-links {
display: none;
}
.register-link,
.forgot-link {
font-size: 26rpx;
font-weight: 500;
}
.captcha-row {
display: flex;
gap: 14rpx;
align-items: center;
}
.captcha-input {
flex: 1;
}
.captcha-image {
width: 220rpx;
height: 84rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid;
overflow: hidden;
flex-shrink: 0;
}
.captcha-img {
width: 100%;
height: 100%;
}
.send-code-btn {
width: 200rpx;
height: 84rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 500;
flex-shrink: 0;
transition: all 0.25s ease;
}
.send-code-btn.disabled {
pointer-events: none;
}
.form-error {
font-size: 24rpx;
margin-top: 8rpx;
display: block;
text-align: center;
}
</style>