Files
zhiyin/zhiyin-app/src/pages/login/login.vue
T
yuzhiran 7049071b42 feat: 品牌更新 + 每日一题公开随机 + 面试计时器修复 + 反馈功能 + 营销文案
- 品牌名统一:职引 → 宇之然AI磁场,SEO 双名策略保留搜索关键词
- 每日一题:放开未登录访问,后端改用 \ 随机取题,灌入 23 题覆盖 8 个岗位
- 面试计时器:timerSeconds 改为 ref 修复 UI 不更新,完成自动停止,增加轮次提示
- 反馈功能:FeedbackModule 前后端完整实现 + admin 面板 + Playwright 测试
- 管理后台:所有列表分页改为 limit=10 + 加载更多按钮
- 录音 UX:修复异步竞态 + 点击开始/停止 + 空语音友好提示 + 发送按钮录音中禁用
- 营销文案:三端平台(公众号/知乎/小红书)推广方案 + 可复制 HTML 文件
2026-07-09 16:27:57 +08:00

416 lines
18 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="promo-banner">
<text class="promo-icon">🎁</text>
<view class="promo-texts">
<text class="promo-title">注册即送 {{ registrationGravity }} 引力值</text>
<text class="promo-desc">免费体验 AI 面试模拟</text>
</view>
</view>
<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" :disabled="wxLoading" @click="doWxLogin">{{ wxLoading ? '登录中...' : '微信一键登录' }}</button>
</view>
<!-- 法律声明 - 用户自主勾选同意 -->
<view class="legal">
<label class="agree-label">
<checkbox :checked="agreed" @tap="agreed = !agreed" color="#4F46E5" style="transform:scale(0.85)" />
<text class="agree-text">我已阅读并同意</text>
<text class="legal-link" @tap.stop="goAgreement">用户服务协议</text>
<text class="agree-text"></text>
<text class="legal-link" @tap.stop="goPrivacy">隐私政策</text>
</label>
</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'
// #ifdef MP-WEIXIN
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
// #endif
import { api } from '../../config'
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({ title: '宇之然AI磁场 - 登录注册 | AI模拟面试', path: '/pages/login/login' }))
onShareTimeline(() => ({ title: '宇之然AI磁场 - 登录注册 | AI模拟面试' }))
// #endif
const agreed = ref(false)
const mainTab = ref('login')
const loginMode = ref('password') // 'password' | 'code'
const isMp = ref(false)
const registrationGravity = ref(50)
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
// 获取注册赠送引力值配置
uni.request({
url: api('/member/plans'),
method: 'GET',
success: (res) => {
if (res.statusCode === 200 && res.data?.registrationGravity) {
registrationGravity.value = res.data.registrationGravity
}
},
fail: () => {} // 使用默认值 50
})
})
onBeforeUnmount(() => { if (timer) { clearTimeout(timer); timer = null } })
// 辅助
const showToast = (title, icon = 'none') => uni.showToast({ title, icon })
const checkAgreed = () => {
if (!agreed.value) { showToast('请阅读并同意《用户服务协议》和《隐私政策》'); return false }
return true
}
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 || !checkAgreed()) 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()
if (res.data?.devCode) {
uni.showModal({ title: '开发模式', content: `验证码:${res.data.devCode}`, showCancel: false })
}
} 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 || !checkAgreed()) 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 || !checkAgreed()) 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
if (!checkAgreed()) { wxLoading.value = false; return }
wxLoading.value = true
try {
const wxResp = await uni.login()
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 },
})
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; }
/* ===== Promo Banner ===== */
.promo-banner { background: linear-gradient(135deg, #FEF3C7, #FDE68A); border-radius: var(--radius-md); padding: 20rpx 24rpx; margin-bottom: 24rpx; display: flex; align-items: center; gap: 16rpx; }
.promo-icon { font-size: 36rpx; }
.promo-title { font-size: 26rpx; font-weight: 700; color: #92400E; display: block; }
.promo-desc { font-size: 22rpx; color: #B45309; display: block; margin-top: 4rpx; }
/* ===== 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; margin-top: 24rpx; flex-wrap: wrap; }
.agree-label { display: flex; align-items: center; gap: 6rpx; flex-wrap: wrap; justify-content: center; }
.agree-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>