Files
zhiyin/zhiyin-app/src/pages/login/login.vue
T

371 lines
16 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="page fade-in">
<view class="hero">
<view class="brand">
<text class="brand-name">AI 磁场</text>
<text class="brand-tagline">AI 助力你的求职之路</text>
</view>
</view>
<view class="form-section">
<!-- Tab登录 / 注册 / 微信 -->
<view class="tab-bar">
<text class="tab" :class="{ active: mainTab === 'login' }" @click="mainTab='login'">登录</text>
<text class="tab" :class="{ active: mainTab === 'register' }" @click="mainTab='register'">注册</text>
<text class="tab" :class="{ active: mainTab === 'wechat' }" @click="mainTab='wechat'" v-if="isMp">微信登录</text>
</view>
<!-- ========== 登录 ========== -->
<view class="card" v-if="mainTab === 'login'">
<!-- Tab密码 / 验证码 -->
<view class="sub-tab-bar">
<text class="sub-tab" :class="{ active: loginMode === 'password' }" @click="loginMode='password'">密码登录</text>
<text class="sub-tab" :class="{ active: loginMode === 'code' }" @click="loginMode='code'">验证码登录</text>
</view>
<!-- 密码登录 -->
<view v-if="loginMode === 'password'">
<view class="field">
<text class="field-label">邮箱</text>
<input class="input" type="text" v-model="email" placeholder="请输入邮箱" />
</view>
<view class="field">
<text class="field-label">密码</text>
<input class="input" type="password" v-model="password" placeholder="请输入密码" @confirm="doPasswordLogin" />
</view>
<button class="login-btn" :disabled="!canPasswordLogin || pwdLoading" @click="doPasswordLogin">
{{ pwdLoading ? '登录中...' : '登录' }}
</button>
<view class="switch-hint" @click="loginMode='code'">忘记密码使用验证码登录</view>
</view>
<!-- 验证码登录 -->
<view v-else>
<view class="field">
<text class="field-label">邮箱</text>
<view class="inline-row">
<input class="input inline-input" type="text" v-model="email" placeholder="请输入邮箱" />
<button class="code-btn" :disabled="cooldown > 0 || !email" @click="sendEmailCode">
{{ cooldown > 0 ? cooldown + 's' : (emailSent ? '重新获取' : '获取验证码') }}
</button>
</view>
</view>
<view class="field" v-if="emailSent">
<text class="field-label">验证码</text>
<input class="input" type="number" maxlength="6" v-model="emailCode" placeholder="请输入6位验证码" />
</view>
<button class="login-btn" :disabled="!emailSent || !emailCode || emailLoading" @click="doEmailLogin">
{{ emailLoading ? '登录中...' : '登录' }}
</button>
<view class="switch-hint" @click="loginMode='password'">已有密码使用密码登录</view>
</view>
</view>
<!-- ========== 注册 ========== -->
<view class="card" v-if="mainTab === 'register'">
<text class="card-title">创建账号</text>
<text class="card-sub">注册后享受 AI 面试模拟服务</text>
<view class="field">
<text class="field-label">邮箱</text>
<input class="input" type="text" v-model="email" placeholder="请输入邮箱" />
</view>
<view class="field">
<text class="field-label">密码</text>
<input class="input" type="password" v-model="password" placeholder="至少6位密码" />
</view>
<view class="field">
<text class="field-label">确认密码</text>
<input class="input" type="password" v-model="confirmPassword" placeholder="再次输入密码" @confirm="doRegister" />
</view>
<button class="login-btn" :disabled="!canRegister || regLoading" @click="doRegister">
{{ regLoading ? '注册中...' : '注册' }}
</button>
<view class="switch-hint" @click="mainTab='login'">已有账号去登录</view>
</view>
<!-- ========== 微信一键登录 ========== -->
<view class="card" v-if="mainTab === 'wechat' && isMp">
<text class="card-title">微信一键登录</text>
<text class="card-sub">授权后自动创建账号</text>
<button class="login-btn wx-btn" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
</view>
<!-- 法律声明 -->
<view class="legal">
<text class="legal-text">登录即表示同意</text>
<text class="legal-link" @click="goAgreement">用户协议</text>
<text class="legal-text"></text>
<text class="legal-link" @click="goPrivacy">隐私政策</text>
</view>
</view>
<!-- 设置密码弹窗验证码登录后引导 -->
<view class="overlay" v-if="showSetPwd" @click="showSetPwd=false"></view>
<view class="pwd-modal" v-if="showSetPwd">
<text class="modal-title">设置登录密码</text>
<text class="modal-desc">设置密码后下次可直接用密码登录无需等待验证码</text>
<input class="input" type="password" v-model="newPassword" placeholder="至少6位密码" />
<view class="modal-btns">
<text class="modal-btn skip" @click="skipSetPwd">暂不设置</text>
<text class="modal-btn confirm" @click="doSetPassword">确认设置</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { api } from '../../config'
const mainTab = ref('login')
const loginMode = ref('password') // 'password' | 'code'
const isMp = ref(false)
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const emailCode = ref('')
const emailSent = ref(false)
const emailLoading = ref(false)
const pwdLoading = ref(false)
const regLoading = ref(false)
const wxLoading = ref(false)
const cooldown = ref(0)
let timer = null
// 设置密码弹窗
const showSetPwd = ref(false)
const newPassword = ref('')
const canPasswordLogin = computed(() => email.value.trim() && password.value.length >= 6 && !pwdLoading.value)
const canRegister = computed(() => email.value.trim() && password.value.length >= 6 && password.value === confirmPassword.value && !regLoading.value)
onMounted(() => {
// #ifdef MP-WEIXIN
isMp.value = true
mainTab.value = 'wechat'
// #endif
})
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
// 辅助
const showToast = (title, icon = 'none') => uni.showToast({ title, icon })
const loginSuccess = (data) => {
uni.setStorageSync('token', data.token)
if (data.user) uni.setStorageSync('userInfo', JSON.stringify(data.user))
showToast('登录成功', 'success')
setTimeout(() => uni.navigateBack(), 500)
}
// ====== 密码登录 ======
const doPasswordLogin = async () => {
if (!canPasswordLogin.value) return
pwdLoading.value = true
try {
const res = await uni.request({
url: api('/user/password-login'), method: 'POST',
header: { 'Content-Type': 'application/json' },
data: { email: email.value.trim(), password: password.value },
})
if (res.statusCode === 200 && res.data?.token) {
loginSuccess(res.data)
} else {
showToast(res.data?.message || '登录失败')
}
} catch { showToast('网络错误') }
finally { pwdLoading.value = false }
}
// ====== 验证码 ======
const sendEmailCode = () => {
if (cooldown.value > 0) { showToast('请稍后再试'); return }
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!re.test(email.value)) { showToast('请输入正确的邮箱'); return }
uni.request({
url: api('/user/send-email-code'),
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: { email: email.value },
success: (res) => {
if (res.statusCode === 200) {
emailSent.value = true
showToast('验证码已发送', 'success')
startCooldown()
} else {
const msg = (res.data && res.data.message) || '发送失败'
showToast(msg)
}
},
fail: (err) => {
console.error('[sendEmailCode] fail:', err)
showToast('网络错误')
}
})
}
const startCooldown = () => {
cooldown.value = 60
if (timer) clearTimeout(timer)
const tick = () => {
cooldown.value--
if (cooldown.value <= 0) {
timer = null
return
}
timer = setTimeout(tick, 1000)
}
timer = setTimeout(tick, 1000)
}
const doEmailLogin = async () => {
if (!emailCode.value) return
emailLoading.value = true
try {
const res = await uni.request({
url: api('/user/email-login'), method: 'POST',
header: { 'Content-Type': 'application/json' },
data: { email: email.value.trim(), code: emailCode.value },
})
if (res.statusCode === 200 && res.data?.token) {
loginSuccess(res.data)
// 新用户(isNew)且没有密码 → 引导设置密码
if (res.data.isNew || !res.data.hasPassword) {
setTimeout(() => { showSetPwd.value = true; newPassword.value = '' }, 800)
}
} else {
showToast(res.data?.message || '登录失败')
}
} catch { showToast('网络错误') }
finally { emailLoading.value = false }
}
// ====== 注册 ======
const doRegister = async () => {
if (!canRegister.value) return
regLoading.value = true
try {
const res = await uni.request({
url: api('/user/register'), method: 'POST',
header: { 'Content-Type': 'application/json' },
data: { email: email.value.trim(), password: password.value },
})
if (res.statusCode === 200 && res.data?.token) {
loginSuccess(res.data)
} else if (res.statusCode === 409) {
showToast('该邮箱已注册,请直接登录')
mainTab.value = 'login'
} else {
showToast(res.data?.message || '注册失败')
}
} catch { showToast('网络错误') }
finally { regLoading.value = false }
}
// ====== 设置密码 ======
const doSetPassword = async () => {
if (newPassword.value.length < 6) { showToast('密码至少6位'); return }
const token = uni.getStorageSync('token')
try {
const res = await uni.request({
url: api('/user/set-password'), method: 'POST',
header: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { password: newPassword.value },
})
if (res.statusCode === 200 || res.statusCode === 201) {
showToast('密码设置成功', 'success')
showSetPwd.value = false
}
} catch { showToast('设置失败') }
}
const skipSetPwd = () => { showSetPwd.value = false }
// ====== 微信登录 ======
const doWxLogin = async () => {
// #ifdef MP-WEIXIN
wxLoading.value = true
try {
const wxResp = await uni.login()
console.log('[wxLogin] uni.login success:', JSON.stringify(wxResp).slice(0, 300))
const { code, errMsg } = wxResp
if (!code) { console.error('[wxLogin] no code:', errMsg); showToast('获取微信凭证失败'); return }
const res = await uni.request({
url: api('/user/wx-login'), method: 'POST',
header: { 'Content-Type': 'application/json' },
data: { code },
})
console.log('[wxLogin] server response:', res.statusCode, JSON.stringify(res.data).slice(0, 500))
if (res.statusCode === 200 && res.data?.token) {
loginSuccess(res.data)
} else {
showToast(res.data?.message || `登录失败(${res.statusCode})`)
}
} catch (e) {
console.error('[wxLogin] error:', JSON.stringify(e).slice(0, 500))
showToast('微信登录失败')
}
finally { wxLoading.value = false }
// #endif
}
// ====== 法律页面 ======
const goAgreement = () => uni.navigateTo({ url: '/pages/agreement/agreement' })
const goPrivacy = () => uni.navigateTo({ url: '/pages/privacy/privacy' })
</script>
<style scoped>
.page { min-height: 100vh; background: var(--color-bg); display: flex; flex-direction: column; }
.hero { padding: 80rpx 32rpx 60rpx; text-align: center; }
.brand-name { font-size: 48rpx; font-weight: 800; color: var(--color-primary); }
.brand-tagline { font-size: 24rpx; color: var(--color-text-tertiary); margin-top: 8rpx; display: block; }
.form-section { padding: 0 32rpx; flex: 1; }
/* ===== Main Tab ===== */
.tab-bar { display: flex; gap: 0; margin-bottom: 24rpx; background: #FFFFFF; border-radius: var(--radius-md); padding: 4rpx; }
.tab { flex: 1; text-align: center; padding: 16rpx; font-size: 26rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); transition: all 0.2s;}
.tab.active { background: var(--color-primary); color: #FFFFFF; font-weight: 600; }
/* ===== Sub Tab ===== */
.sub-tab-bar { display: flex; gap: 0; margin-bottom: 24rpx; background: #F9FAFB; border-radius: var(--radius-sm); padding: 4rpx; }
.sub-tab { flex: 1; text-align: center; padding: 12rpx; font-size: 24rpx; color: var(--color-text-tertiary); border-radius: var(--radius-sm); transition: all 0.2s;}
.sub-tab.active { background: #FFFFFF; color: var(--color-text); font-weight: 600; box-shadow: 0 1rpx 4rpx rgba(0,0,0,0.06); }
/* ===== Card ===== */
.card { background: #FFFFFF; border-radius: var(--radius-lg); padding: 32rpx; box-shadow: var(--shadow-sm); }
.card-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); display: block; }
.card-sub { font-size: 22rpx; color: var(--color-text-tertiary); margin-top: 6rpx; margin-bottom: 24rpx; display: block; }
/* ===== Fields ===== */
.field { margin-bottom: 20rpx; }
.field-label { font-size: 22rpx; color: var(--color-text-secondary); margin-bottom: 8rpx; display: block; }
.input { width: 100%; height: 72rpx; background: #F9FAFB; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 26rpx; box-sizing: border-box; }
.inline-row { display: flex; gap: 12rpx; align-items: center; }
.inline-input { flex: 1; }
.code-btn { height: 72rpx; padding: 0 20rpx; background: var(--color-primary); color: #FFFFFF; border: none; border-radius: var(--radius-sm); font-size: 24rpx; white-space: nowrap; flex-shrink: 0; line-height: 72rpx; }
.code-btn:disabled { background: #D1D5DB; color: #9CA3AF; }
/* ===== Buttons ===== */
.login-btn { width: 100%; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none; margin-top: 16rpx; display: flex; align-items: center; justify-content: center; }
.login-btn:disabled { opacity: 0.5; }
.wx-btn { background: linear-gradient(135deg, #07C160, #06AD56); }
/* ===== Switch Hint ===== */
.switch-hint { text-align: center; font-size: 22rpx; color: var(--color-primary); padding: 20rpx 0 4rpx; }
/* ===== Legal ===== */
.legal { display: flex; justify-content: center; align-items: center; gap: 4rpx; margin-top: 24rpx; flex-wrap: wrap; }
.legal-text { font-size: 22rpx; color: var(--color-text-tertiary); }
.legal-link { font-size: 22rpx; color: var(--color-primary); }
/* ===== Password Modal ===== */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 100; }
.pwd-modal { position: fixed; left: 32rpx; right: 32rpx; top: 50%; transform: translateY(-50%); background: #FFFFFF; border-radius: var(--radius-lg); padding: 40rpx 32rpx; z-index: 101; }
.modal-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); display: block; text-align: center; }
.modal-desc { font-size: 22rpx; color: var(--color-text-tertiary); text-align: center; display: block; margin: 12rpx 0 24rpx; line-height: 1.5; }
.modal-btns { display: flex; gap: 16rpx; margin-top: 24rpx; }
.modal-btn { flex: 1; text-align: center; padding: 20rpx; font-size: 26rpx; border-radius: var(--radius-sm); }
.modal-btn.skip { background: #F3F4F6; color: var(--color-text-secondary); }
.modal-btn.confirm { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; font-weight: 600; }
</style>