371 lines
16 KiB
Vue
371 lines
16 KiB
Vue
<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>
|