Files
hejiu/pages/login/login.vue
T
cg b95243c8f8 docs(api): 更新API接口文档并调整应用配置
- 添加完整的后端接口文档,包含用户、打卡记录、统计等模块
- 修复应用名称从"喝酒了么"统一更正为"喝了么"
- 重构App.vue中的启动逻辑,增加数据迁移和路由守卫功能
- 调整DrinkCalendar组件的UI间距和尺寸参数
- 优化DrinkCard组件的品牌展示文案
- 修改manifest.json中的应用描述和微信小程序配置
- 调整pages.json中的页面路径顺序和tabBar配置
- 更新全局样式文件中的应用名称标识
- 修改mock数据中的用户头像默认值
- 优化卡片导出功能的绘制逻辑和视觉效果
2026-07-14 23:27:23 +08:00

673 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="login-divider">
<view class="login-divider-line"></view>
<text class="login-divider-text"></text>
<view class="login-divider-line"></view>
</view>
<button class="btn-secondary" :disabled="loginLoading" @click="handleGuest">
先看看再说
</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 v-if="showPreferences" class="pref-overlay">
<view class="pref-card card-elevated">
<view class="pref-header">
<text class="text-h2">你的饮酒偏好</text>
<text class="text-caption">选择你常喝的酒类可多选</text>
</view>
<view class="pref-section">
<text class="pref-label">常喝的酒类</text>
<view class="pref-tags">
<view
v-for="cat in drinkCategories"
:key="cat.id"
class="tag"
:class="{ 'tag-active': selectedCategories.includes(cat.id) }"
@click="toggleCategory(cat.id)"
>
<text>{{ cat.emoji }} {{ cat.name }}</text>
</view>
</view>
</view>
<view class="pref-section">
<text class="pref-label">饮酒频率</text>
<view class="pref-options">
<view
v-for="freq in frequencies"
:key="freq.id"
class="freq-option"
:class="{ 'freq-active': selectedFrequency === freq.id }"
@click="selectedFrequency = freq.id"
>
<text>{{ freq.name }}</text>
</view>
</view>
</view>
<view class="pref-actions">
<button class="btn-text" @click="skipPreferences">跳过</button>
<button class="btn-primary" :loading="prefSaving" @click="savePreferences">{{ selectedCategories.length > 0 || selectedFrequency ? '保存并进入' : '进入首页' }}</button>
</view>
</view>
</view>
<!-- 底部协议非登录状态时显示 -->
<view v-if="showNotice" class="agreement">
<text class="text-tiny">登录即代表同意</text>
<text class="text-tiny text-amber">用户协议</text>
<text class="text-tiny"></text>
<text class="text-tiny text-amber">隐私政策</text>
</view>
</view>
</template>
<script>
import { DRINK_CATEGORIES } from '../../common/constants'
export default {
data() {
return {
showNotice: true,
showPreferences: false,
loginLoading: false,
prefSaving: false,
agreed: false,
avatarUrl: '',
nickname: '',
drinkCategories: DRINK_CATEGORIES,
selectedCategories: [],
selectedFrequency: '',
frequencies: [
{ id: 'daily', name: '几乎每天' },
{ id: 'often', name: '经常喝' },
{ id: 'sometimes', name: '偶尔喝' },
{ id: 'rarely', name: '很少喝' }
]
}
},
methods: {
acceptNotice() {
this.showNotice = false
},
// ===== 微信新规范:头像选择 =====
onChooseAvatar(e) {
if (e.detail && e.detail.avatarUrl) {
this.avatarUrl = e.detail.avatarUrl
}
},
// ===== 微信新规范:昵称输入 =====
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
}
this.loginLoading = true
try {
// 获取微信登录code(用于后续服务端鉴权)
const loginRes = await this.wxLogin().catch(() => null)
const userInfo = {
id: `user_${Date.now()}`,
nickname: this.nickname.trim() || '酒友',
avatar: this.avatarUrl || '',
wxCode: (loginRes && loginRes.code) || '',
joinedAt: new Date().toISOString().slice(0, 10),
preferences: null
}
// 暂存用户信息,登录完成标记延迟到 finishLogin
uni.setStorageSync('user_info', JSON.stringify(userInfo))
// 显示偏好设置(此时不算正式登录)
this.showPreferences = true
} catch (e) {
console.warn('登录失败,降级为本地登录:', e)
const userInfo = {
id: `user_${Date.now()}`,
nickname: this.nickname.trim() || '酒友',
avatar: '',
joinedAt: new Date().toISOString().slice(0, 10),
preferences: null
}
// 暂存用户信息,登录完成标记延迟到 finishLogin
uni.setStorageSync('user_info', JSON.stringify(userInfo))
this.showPreferences = true
} finally {
this.loginLoading = false
}
},
// 微信login获取code
wxLogin() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: res => resolve(res),
fail: err => reject(err)
})
})
},
// ===== 游客模式 =====
handleGuest() {
if (this.loginLoading) return
uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_guest', 'true')
uni.setStorageSync('is_first_launch', 'false')
// 游客也设置一个基础用户信息
const guestInfo = {
id: `guest_${Date.now()}`,
nickname: '游客模式',
avatar: '',
isGuest: true,
joinedAt: new Date().toISOString().slice(0, 10),
preferences: null
}
uni.setStorageSync('user_info', JSON.stringify(guestInfo))
uni.switchTab({ url: '/pages/index/index' })
},
// ===== 偏好选择 =====
toggleCategory(id) {
const idx = this.selectedCategories.indexOf(id)
if (idx > -1) {
this.selectedCategories.splice(idx, 1)
} else {
this.selectedCategories.push(id)
}
},
skipPreferences() {
this.finishLogin()
},
async savePreferences() {
if (this.prefSaving) return
this.prefSaving = true
try {
const user = JSON.parse(uni.getStorageSync('user_info') || '{}')
user.preferences = {
favoriteCategories: this.selectedCategories,
frequency: this.selectedFrequency
}
uni.setStorageSync('user_info', JSON.stringify(user))
await new Promise(r => setTimeout(r, 300)) // 短暂反馈
this.finishLogin()
} finally {
this.prefSaving = false
}
},
finishLogin() {
uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_first_launch', 'false')
uni.switchTab({ url: '/pages/index/index' })
},
// ===== 协议查看 =====
openAgreement(type) {
const title = type === 'user' ? '用户协议' : '隐私政策'
uni.showToast({ title: `${title}页面开发中`, icon: 'none' })
}
}
}
</script>
<style lang="scss" scoped>
.login-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: $sp-xl;
}
.brand-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding-top: 120rpx;
}
.brand-glow {
position: absolute;
width: 400rpx;
height: 400rpx;
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: 200rpx;
height: 200rpx;
background: $bg-card;
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-md;
margin-bottom: $sp-xl;
position: relative;
z-index: 2;
}
.brand-emoji {
font-size: 96rpx;
}
.brand-name {
font-size: $fs-hero;
font-weight: $fw-black;
color: $text-primary;
letter-spacing: -4rpx;
margin-bottom: $sp-sm;
}
.brand-slogan {
font-size: $fs-md;
color: $text-secondary;
}
.login-area {
padding-bottom: $sp-3xl;
}
/* 理性饮酒须知 */
.notice-card {
padding: $sp-xl;
}
.notice-header {
display: flex;
align-items: center;
gap: $sp-sm;
margin-bottom: $sp-lg;
}
.notice-icon {
font-size: $fs-xl;
}
.notice-title {
font-size: $fs-lg;
font-weight: $fw-bold;
}
.notice-content {
margin-bottom: $sp-lg;
}
.notice-item {
display: block;
font-size: $fs-sm;
color: $text-secondary;
line-height: $lh-loose;
}
.notice-btn {
width: 100%;
}
/* 资料表单 */
.profile-form {
padding: $sp-xl;
margin-bottom: 0;
}
.form-title-row {
margin-bottom: $sp-lg;
}
.form-title {
font-size: $fs-lg;
font-weight: $fw-bold;
color: $text-primary;
}
.avatar-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-md 0;
border-bottom: 2rpx solid rgba(255,255,255,0.04);
}
.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: 96rpx;
height: 96rpx;
border-radius: $radius-full;
border: 3rpx solid $amber;
flex-shrink: 0;
}
.avatar-placeholder {
width: 96rpx;
height: 96rpx;
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: 48rpx;
opacity: 0.5;
}
.avatar-edit {
font-size: $fs-sm;
color: $amber;
}
.nickname-row {
display: flex;
align-items: center;
gap: $sp-lg;
padding: $sp-md 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-lg;
}
.login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-sm;
&[disabled] {
opacity: 0.5;
}
}
.login-btn-text {
font-size: $fs-xl;
font-weight: $fw-bold;
}
.login-divider {
display: flex;
align-items: center;
gap: $sp-md;
}
.login-divider-line {
flex: 1;
height: 2rpx;
background: rgba(255,255,255,0.06);
}
.login-divider-text {
font-size: $fs-sm;
color: $text-tertiary;
}
/* 协议勾选 */
.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;
}
/* 偏好设置弹层 */
.pref-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: flex-end;
z-index: 100;
padding: $sp-lg;
animation: fadeIn $duration-base $ease-out;
}
.pref-card {
width: 100%;
padding: $sp-xl;
border-radius: $radius-xl $radius-xl 0 0;
animation: slideUp $duration-base $ease-out;
}
.pref-header {
margin-bottom: $sp-xl;
.text-h2 {
display: block;
margin-bottom: $sp-xs;
}
}
.pref-section {
margin-bottom: $sp-xl;
}
.pref-label {
display: block;
font-size: $fs-sm;
font-weight: $fw-medium;
color: $text-secondary;
margin-bottom: $sp-md;
}
.pref-tags {
display: flex;
flex-wrap: wrap;
gap: $sp-sm;
}
.pref-options {
display: flex;
gap: $sp-sm;
}
.freq-option {
flex: 1;
text-align: center;
padding: $sp-md $sp-sm;
border-radius: $radius-md;
background: $bg-card;
font-size: $fs-sm;
color: $text-secondary;
border: 2rpx solid transparent;
transition: all $duration-fast $ease-out;
&.freq-active {
background: $amber-glow;
color: $amber;
border-color: rgba(232,168,56,0.3);
}
}
.pref-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: $sp-lg;
.btn-primary {
flex: 1;
}
}
/* 底部协议 */
.agreement {
display: flex;
justify-content: center;
gap: $sp-xs;
padding: $sp-lg 0;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
</style>