Files
hejiu/pages/login/login.vue
T
cg d335ec290a refactor(app): 重构应用主题和页面结构
- 移除动态主题切换功能,改为全局统一深色主题
- 实现自定义TabBar组件替换原生tabBar,解决iOS闪白问题
- 创建main容器页面统一管理三个tab页面的生命周期和状态
- 将所有页面的onShow/onHide等生命周期方法迁移到子组件内部
- 更新页面路径从index改为main,并调整路由跳转逻辑
- 移除theme-mixin和相关主题工具函数
- 统一页面背景色设置,优化用户体验一致性
2026-09-23 20:56:14 +08:00

585 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-container login-page">
<!-- 顶部品牌区 -->
<view class="brand-area">
<view class="brand-glow"></view>
<view class="brand-icon">
<text class="brand-emoji">🍻</text>
</view>
<text class="brand-name">碰盏日记</text>
<text class="brand-slogan">让每一杯酒都有迹可循</text>
</view>
<!-- 登录区域 -->
<view class="login-area">
<!-- 理性饮酒须知 -->
<view v-if="showNotice" class="notice-card card">
<view class="notice-header">
<text class="notice-icon">📋</text>
<text class="notice-title">理性饮酒须知</text>
</view>
<view class="notice-content">
<text class="notice-item">• 过量饮酒有害健康,请理性记录</text>
<text class="notice-item">• 本应用不鼓励未成年人使用</text>
<text class="notice-item">• 不涉及任何酒类销售与推荐</text>
<text class="notice-item">• 您的数据仅用于个人记录</text>
</view>
<button class="btn-primary notice-btn" @click="acceptNotice">我已了解</button>
</view>
<!-- 主登录区域 -->
<view v-else class="login-actions">
<!-- 头像 + 昵称填写(微信新规范) -->
<view class="profile-form card">
<view class="form-title-row">
<text class="form-title">完善你的资料</text>
</view>
<!-- 头像选择 -->
<view class="avatar-row">
<text class="form-label">头像</text>
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image v-if="avatarUrl" class="avatar-preview" :src="avatarUrl" mode="aspectFill"></image>
<view v-else class="avatar-placeholder">
<text class="avatar-placeholder-icon">👤</text>
</view>
<text class="avatar-edit">{{ avatarUrl ? '换头像' : '选头像' }}</text>
</button>
</view>
<!-- 昵称输入 -->
<view class="nickname-row">
<text class="form-label">昵称</text>
<input
type="nickname"
class="nickname-input"
v-model="nickname"
placeholder="点击获取微信昵称"
@blur="onNicknameBlur"
/>
</view>
</view>
<!-- 确认登录 -->
<button class="btn-cta login-btn" :loading="loginLoading" :disabled="!agreed || loginLoading" @click="handleLogin">
<text class="login-btn-text">{{ loginLoading ? '登录中...' : '完成并进入' }}</text>
</button>
<!-- 协议勾选 -->
<view class="agreement-check" @click="agreed = !agreed">
<view class="check-box" :class="{ 'check-box-active': agreed }">
<text v-if="agreed" class="check-mark">✓</text>
</view>
<text class="agreement-check-text">我已阅读并同意</text>
<text class="agreement-check-text text-amber" @click.stop="openAgreement('user')">《用户协议》</text>
<text class="agreement-check-text">和</text>
<text class="agreement-check-text text-amber" @click.stop="openAgreement('privacy')">《隐私政策》</text>
</view>
</view>
</view>
<!-- 游客模式入口:不强制登录,可先进首页浏览体验,需要时再自行登录 -->
<view class="guest-entry" @click="enterGuest">
<text class="guest-entry-text">暂不登录,先逛逛</text>
<text class="guest-entry-arrow">›</text>
</view>
<!-- 底部协议查阅入口(仅提供查看,不构成默认同意;是否同意由用户在下一屏主动勾选) -->
<view v-if="showNotice" class="agreement">
<text class="text-tiny agreement-link" @click="openAgreement('user')">《用户协议》</text>
<text class="text-tiny agreement-dot">·</text>
<text class="text-tiny agreement-link" @click="openAgreement('privacy')">《隐私政策》</text>
</view>
</view>
</template>
<script>
import client, { saveAuthTokens, UploadImage, UpdateUserAvatar } from '../../common/api'
import { handlePendingInvite } from '../../common/invite'
import wsManager from '../../common/websocket'
export default {
data() {
return {
showNotice: true,
loginLoading: false,
agreed: false,
avatarUrl: '',
avatarTempPath: '', // 待上传的 wxfile:// 临时路径(选头像时暂存,登录后带 token 上传)
nickname: ''
}
},
onShareAppMessage() {
return {
title: '碰盏日记 - 让每一杯酒都有迹可循',
path: '/pages/main/main?tab=0'
}
},
onShareTimeline() {
return {
title: '碰盏日记 - 让每一杯酒都有迹可循',
query: ''
}
},
methods: {
acceptNotice() {
this.showNotice = false
},
// 游客模式:不登录直接进入首页浏览(微信审核要求:先体验功能,登录由用户自行选择)
enterGuest() {
uni.setStorageSync('is_first_launch', 'false')
uni.setStorageSync('is_guest', 'true')
uni.reLaunch({ url: '/pages/main/main?tab=0' })
},
// ===== 微信新规范:头像选择 =====
// chooseAvatar 返回的是 wxfile://tmp_ 本地临时路径(微信随时会清理、仅本机有效),
// 上传接口需要登录 token,而选头像时还未登录,因此这里只暂存临时路径,
// 等 handleLogin 拿到 token 后再上传
onChooseAvatar(e) {
const tempUrl = e.detail && e.detail.avatarUrl
if (!tempUrl) return
this.avatarUrl = tempUrl
this.avatarTempPath = tempUrl
},
// ===== 微信新规范:昵称输入 =====
onNicknameBlur(e) {
// 用户输入内容会自动更新到 v-model,这里可以做额外处理
if (e.detail && e.detail.value) {
this.nickname = e.detail.value
}
},
// ===== 确认登录 =====
async handleLogin() {
if (this.loginLoading) return
if (!this.agreed) {
uni.showToast({ title: '请先同意用户协议和隐私政策', icon: 'none', duration: 2000 })
return
}
if (!this.nickname.trim()) {
uni.showToast({ title: '请先填写昵称', icon: 'none', duration: 2000 })
return
}
this.loginLoading = true
let loginOk = false
let loginErr = null
try {
// 1. 获取微信登录 code
const loginRes = await this.wxLogin()
if (!loginRes || !loginRes.code) {
throw new Error('获取微信code失败')
}
// 2. 调用后端微信登录接口换取 token(上传头像接口需要鉴权)
const firstResp = await client.WechatLogin({
code: loginRes.code,
nickName: this.nickname.trim() || '',
avatarUrl: ''
})
// errcode 兜底:后端业务错误码非 0 时视为登录失败
if (firstResp && firstResp.errcode && firstResp.errcode !== 0) {
throw { msg: firstResp.errmsg || '登录失败' }
}
if (!firstResp || !firstResp.token) {
throw { msg: '登录响应缺少 token' }
}
// 3. 保存 token 和用户信息(此时已有鉴权能力)
saveAuthTokens(firstResp)
if (firstResp.user) {
uni.setStorageSync('user_info', JSON.stringify(firstResp.user))
}
uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_guest', 'false')
uni.setStorageSync('is_first_launch', 'false')
loginOk = true
// 4. 有头像时:带 token 上传临时文件,再调 UpdateUserAvatar 写入后端
// (头像环节失败不阻断登录)
if (this.avatarTempPath) {
try {
const upRes = await UploadImage({ filePath: this.avatarTempPath })
const remoteUrl = upRes && upRes.data && upRes.data.url
if (remoteUrl && /^https?:\/\//.test(remoteUrl)) {
const avatarResp = await UpdateUserAvatar({ avatar: remoteUrl })
if (avatarResp && avatarResp.data && avatarResp.data.ok) {
// 同步更新本地缓存的用户头像
try {
const cached = JSON.parse(uni.getStorageSync('user_info') || '{}')
cached.avatar = remoteUrl
uni.setStorageSync('user_info', JSON.stringify(cached))
} catch (cacheErr) { /* ignore */ }
} else {
uni.showToast({ title: '头像保存失败,可稍后在个人主页修改', icon: 'none' })
}
} else {
uni.showToast({ title: '头像上传失败,可稍后在个人主页修改', icon: 'none' })
}
} catch (avatarErr) {
console.warn('头像上传/保存失败,不影响登录:', avatarErr)
uni.showToast({ title: '头像上传失败,可稍后在个人主页修改', icon: 'none' })
}
}
} catch (e) {
// 打印完整错误,方便定位具体失败环节
console.warn('后端登录失败,降级为本地模式:', e && e.stack ? e.stack : e)
loginErr = e
}
if (!loginOk) {
const msg = (loginErr && (loginErr.msg || loginErr.message)) || ''
uni.showToast({ title: msg ? `登录失败:${msg}` : '登录失败,使用本地模式', icon: 'none', duration: 2000 })
// 降级为本地登录
const userInfo = {
id: `user_${Date.now()}`,
nickname: this.nickname.trim() || '酒友',
avatar: '',
joinedAt: new Date().toISOString().slice(0, 10),
preferences: null
}
uni.setStorageSync('user_info', JSON.stringify(userInfo))
uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_first_launch', 'false')
}
this.loginLoading = false
// 收尾逻辑移出 try/catch:避免 WS 重建/邀请补发等非登录环节异常
// 被误判为登录失败(后端已成功却弹"登录失败"的根因)
this.finishLogin()
},
// 微信login获取code
wxLogin() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: res => resolve(res),
fail: err => reject(err)
})
})
},
finishLogin() {
uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_first_launch', 'false')
// 登录完成后用新 token 重建 WebSocket 连接
// (启动时可能用旧 token 连接被拒,不重建会一直用旧 token 重试)
try {
const token = uni.getStorageSync('auth_token')
if (token) {
wsManager.disconnect()
wsManager.connect(token)
}
} catch (e) {
console.warn('WS 重建失败,不影响登录:', e)
}
// 登录完成后补发分享邀请的好友请求(若有暂存的邀请人)
try {
handlePendingInvite()
} catch (e) {
console.warn('补发邀请失败,不影响登录:', e)
}
uni.reLaunch({ url: '/pages/main/main?tab=0' })
},
// ===== 协议查看:跳转协议页(user=用户协议 / privacy=隐私政策)=====
openAgreement(type) {
uni.navigateTo({ url: `/pages/agreement/agreement?type=${type}` })
}
}
}
</script>
<style lang="scss" scoped>
.login-page {
display: flex;
flex-direction: column;
height: 100vh;
padding: $sp-lg;
}
.brand-area {
flex: 0.6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding-top: 60rpx;
}
.brand-glow {
position: absolute;
width: 300rpx;
height: 300rpx;
background: radial-gradient(circle, rgba(232,168,56,0.15) 0%, transparent 70%);
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
filter: blur(30rpx);
}
.brand-icon {
width: 140rpx;
height: 140rpx;
background: $bg-card;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-md;
margin-bottom: $sp-md;
position: relative;
z-index: 2;
}
.brand-emoji {
font-size: 64rpx;
}
.brand-name {
font-size: $fs-3xl;
font-weight: $fw-black;
color: $text-primary;
letter-spacing: -2rpx;
margin-bottom: $sp-xs;
}
.brand-slogan {
font-size: $fs-sm;
color: $text-secondary;
}
.login-area {
padding-bottom: $sp-lg;
}
/* 理性饮酒须知 */
.notice-card {
padding: $sp-lg;
}
.notice-header {
display: flex;
align-items: center;
gap: $sp-sm;
margin-bottom: $sp-md;
}
.notice-icon {
font-size: $fs-lg;
}
.notice-title {
font-size: $fs-base;
font-weight: $fw-bold;
}
.notice-content {
margin-bottom: $sp-md;
}
.notice-item {
display: block;
font-size: $fs-xs;
color: $text-secondary;
line-height: $lh-normal;
}
.notice-btn {
width: 100%;
}
/* 资料表单 */
.profile-form {
padding: $sp-lg;
margin-bottom: 0;
}
.form-title-row {
margin-bottom: $sp-md;
}
.form-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.avatar-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-sm 0;
border-bottom: 2rpx solid var(--border-micro, rgba(255, 255, 255, 0.05));
}
.avatar-btn {
display: flex;
align-items: center;
gap: $sp-sm;
background: none;
border: none;
padding: 0;
margin: 0;
line-height: normal;
&::after { display: none; }
}
.avatar-preview {
width: 80rpx;
height: 80rpx;
border-radius: $radius-full;
border: 3rpx solid $amber;
flex-shrink: 0;
}
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
border-radius: $radius-full;
border: 3rpx dashed rgba(232,168,56,0.4);
background: $bg-elevated;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-placeholder-icon {
font-size: 40rpx;
opacity: 0.5;
}
.avatar-edit {
font-size: $fs-sm;
color: $amber;
}
.nickname-row {
display: flex;
align-items: center;
gap: $sp-lg;
padding: $sp-sm 0;
}
.form-label {
font-size: $fs-base;
color: $text-secondary;
flex-shrink: 0;
min-width: 80rpx;
}
.nickname-input {
flex: 1;
font-size: $fs-base;
color: $text-primary;
padding: $sp-xs 0;
}
/* 登录按钮 */
.login-actions {
display: flex;
flex-direction: column;
gap: $sp-md;
}
.login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-sm;
&[disabled] {
opacity: 0.5;
}
}
.login-btn-text {
font-size: $fs-lg;
font-weight: $fw-bold;
}
/* 协议勾选 */
.agreement-check {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: $sp-xs;
padding-top: $sp-xs;
}
.check-box {
width: 36rpx;
height: 36rpx;
border-radius: $radius-sm;
border: 2rpx solid $text-tertiary;
display: flex;
align-items: center;
justify-content: center;
transition: all $duration-fast $ease-out;
flex-shrink: 0;
}
.check-box-active {
background: $amber;
border-color: $amber;
}
.check-mark {
font-size: $fs-xs;
color: $text-on-amber;
font-weight: $fw-bold;
}
.agreement-check-text {
font-size: $fs-xs;
color: $text-tertiary;
}
/* 游客入口 */
.guest-entry {
display: flex;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: $sp-md 0;
margin-top: auto;
&:active {
opacity: 0.6;
}
}
.guest-entry-text {
font-size: $fs-sm;
color: $text-secondary;
}
.guest-entry-arrow {
font-size: $fs-md;
color: $text-secondary;
}
/* 底部协议查阅入口 */
.agreement {
display: flex;
justify-content: center;
align-items: center;
gap: $sp-xs;
padding: $sp-sm 0;
}
.agreement-link {
color: $text-tertiary;
text-decoration: underline;
}
.agreement-dot {
color: $text-tertiary;
}
</style>