docs(api): 更新API接口文档并调整应用配置

- 添加完整的后端接口文档,包含用户、打卡记录、统计等模块
- 修复应用名称从"喝酒了么"统一更正为"喝了么"
- 重构App.vue中的启动逻辑,增加数据迁移和路由守卫功能
- 调整DrinkCalendar组件的UI间距和尺寸参数
- 优化DrinkCard组件的品牌展示文案
- 修改manifest.json中的应用描述和微信小程序配置
- 调整pages.json中的页面路径顺序和tabBar配置
- 更新全局样式文件中的应用名称标识
- 修改mock数据中的用户头像默认值
- 优化卡片导出功能的绘制逻辑和视觉效果
This commit is contained in:
cg
2026-07-14 23:27:23 +08:00
parent 93e19caa75
commit b95243c8f8
19 changed files with 1364 additions and 425 deletions
+136 -87
View File
@@ -87,8 +87,8 @@ export default {
if (this.saving) return
this.saving = true
try {
await this.drawExportCard()
const path = await this.exportImg()
const contentH = await this.drawExportCard()
const path = await this.exportImg(contentH)
await this.saveImg(path)
uni.showToast({ title: '已保存到相册', icon: 'success' })
} catch (e) {
@@ -105,64 +105,92 @@ export default {
})
},
// ===== 简洁导出卡片绘制 =====
// ===== 海报导出绘制(优化版) =====
async drawExportCard() {
const ctx = uni.createCanvasContext('shareCanvas', this)
const W = 750, H = 1200
const P = 56 // 全局padding增大
const rec = this.record
const P = 50 // padding
const tpl = this.currentTemplate
// 配色
// 模板配色体系
const TH = {
amber: { bg: '#111111', line: '#2A2218', accent: '#E8A838', tagBg: '#1E1A14' },
aurora: { bg: '#0E0E16', line: '#1E1E2E', accent: '#5EC6A0', tagBg: '#141420' },
film: { bg: '#100F0D', line: '#252218', accent: '#C8B48C', tagBg: '#1A1814' }
amber: { bg: '#111111', bgGrad: '#1A1510', line: '#2A2218', accent: '#E8A838', accentLight: '#F5C963', tagBg: '#1E1A14', tagBorder: '#3A3228' },
aurora: { bg: '#0E0E16', bgGrad: '#141420', line: '#1E1E2E', accent: '#5EC6A0', accentLight: '#8EDCBE', tagBg: '#141420', tagBorder: '#2A2A3E' },
film: { bg: '#100F0D', bgGrad: '#181610', line: '#252218', accent: '#C8B48C', accentLight: '#DED0B0', tagBg: '#1A1814', tagBorder: '#352E22' }
}
const c = TH[tpl] || TH.amber
const WHITE = '#F0F0F0'
const GRAY = '#999999'
const DIM = '#666666'
const F = 'PingFang SC'
const WHITE = '#F2F2F2'
const SUB = '#B0B0B0'
const GRAY = '#888888'
const DIM = '#5A5A5A'
const F = '-apple-system, PingFang SC, Helvetica Neue, sans-serif'
// 背景
// ===== 背景绘制 =====
ctx.setFillStyle(c.bg)
ctx.fillRect(0, 0, W, H)
// 顶部细线装饰
// 底部微渐变装饰
ctx.setFillStyle(c.bgGrad)
ctx.setGlobalAlpha(0.5)
ctx.fillRect(0, H * 0.6, W, H * 0.4)
ctx.setGlobalAlpha(1)
// 顶部强调线条(加粗)
ctx.setFillStyle(c.accent)
ctx.fillRect(0, 0, W, 4)
ctx.fillRect(0, 0, W, 5)
let y = P + 10
let y = P + 16
// === 日期行 ===
ctx.font = `normal 26px ${F}`
ctx.setFillStyle(GRAY)
ctx.fillText(rec.date || '', P, y + 20)
// ===== 顶部行:日期药丸 + 标准杯 =====
// 日期药丸样式
const dateStr = rec.date || ''
ctx.font = `500 22px ${F}`
const dateW = ctx.measureText(dateStr).width + 28
const pillH = 36, pillR = 18
ctx.setFillStyle(c.tagBg)
this.rr(ctx, P, y - 4, dateW, pillH, pillR, 'fill')
ctx.setStrokeStyle(c.tagBorder)
ctx.setLineWidth(1)
this.rr(ctx, P, y - 4, dateW, pillH, pillR, 'stroke')
ctx.font = `500 22px ${F}`
ctx.setFillStyle(SUB)
ctx.fillText(dateStr, P + 14, y + 18)
// 标准杯(右对齐)
ctx.font = `bold 40px ${F}`
ctx.setFillStyle(c.accent)
// 标准杯:数字+文字垂直堆叠(右对齐)
const num = String(rec.standardCupsTotal)
ctx.font = `800 44px ${F}`
ctx.setFillStyle(c.accent)
const nw = ctx.measureText(num).width
ctx.fillText(num, W - P - nw - ctx.measureText(' 标准杯').width - 6, y + 24)
ctx.font = `normal 22px ${F}`
ctx.font = `normal 18px ${F}`
const lw = ctx.measureText('标准杯').width
const cupBlockW = Math.max(nw, lw) + 4
const cupX = W - P - cupBlockW
ctx.font = `800 44px ${F}`
ctx.setFillStyle(c.accent)
ctx.fillText(num, cupX + (cupBlockW - nw) / 2, y + 16)
ctx.font = `normal 18px ${F}`
ctx.setFillStyle(GRAY)
ctx.fillText(' 标准杯', W - P - ctx.measureText(' 标准杯').width, y + 22)
y += 50
ctx.fillText('标准杯', cupX + (cupBlockW - lw) / 2, y + 34)
y += 58
// === 照片 ===
// ===== 照片区域 =====
const photos = rec.photos || []
if (photos.length > 0) {
const loaded = await Promise.all(photos.slice(0, 9).map(p => this.loadImg(p)))
const ph = this.drawPhotos(ctx, loaded, P, y, W - P * 2, c.accent)
// 照片底部渐变蒙版
const gradH = 80
const grd = ctx.createLinearGradient(P, y + ph - gradH, P, y + ph)
grd.addColorStop(0, 'rgba(0,0,0,0)')
grd.addColorStop(1, 'rgba(0,0,0,0.45)')
ctx.setFillStyle(grd)
ctx.fillRect(P, y + ph - gradH, W - P * 2, gradH)
y += ph + 28
}
// === 酒水信息行 ===
// ===== 酒水信息行 =====
const drinks = rec.drinks || []
if (drinks.length > 0) {
ctx.font = `bold 28px ${F}`
ctx.font = `bold 30px ${F}`
ctx.setFillStyle(WHITE)
let line = ''
drinks.forEach(d => {
@@ -170,91 +198,109 @@ export default {
const nm = cat ? cat.name : '酒水'
line += (line ? ' · ' : '') + `${nm} ${d.brand} · ${d.standardCups}`
})
ctx.fillText(line, P, y + 22)
y += 42
ctx.fillText(line, P, y + 24)
y += 48
}
// === 配餐 + 感受 标签 ===
// ===== 配餐 + 感受 胶囊标签 =====
let tags = []
let tagTypes = [] // 'food' | 'feeling'
if (rec.food) {
const food = FOOD_CATEGORIES.find(f => f.id === rec.food.category)
if (food) tags.push(rec.food.name ? `${food.name} · ${rec.food.name}` : food.name)
if (food) {
tags.push(rec.food.name ? `${food.name} · ${rec.food.name}` : food.name)
tagTypes.push('food')
}
}
if (rec.feeling) {
const f = FEELINGS.find(fe => fe.id === rec.feeling)
if (f) tags.push(f.name)
if (f) { tags.push(f.name); tagTypes.push('feeling') }
}
if (tags.length > 0) {
let tx = P
ctx.font = `normal 22px ${F}`
tags.forEach(tag => {
const tw = ctx.measureText(tag).width + 24
tags.forEach((tag, i) => {
const tw = ctx.measureText(tag).width + 32
const th = 36
// 胶囊背景+边框
ctx.setFillStyle(c.tagBg)
this.rr(ctx, tx, y, tw, 32, 6, 'fill')
ctx.setFillStyle(GRAY)
ctx.fillText(tag, tx + 12, y + 22)
tx += tw + 10
this.rr(ctx, tx, y, tw, th, th / 2, 'fill')
ctx.setStrokeStyle(tagTypes[i] === 'food' ? c.accent : c.accentLight)
ctx.setLineWidth(1)
this.rr(ctx, tx, y, tw, th, th / 2, 'stroke')
// 标签文字(场景用强调色,感受用次要白)
ctx.setFillStyle(tagTypes[i] === 'food' ? c.accentLight : SUB)
ctx.fillText(tag, tx + 16, y + 24)
tx += tw + 14
})
y += 48
y += 56
}
// === 分隔线 ===
// ===== 分隔线 =====
ctx.setStrokeStyle(c.line)
ctx.setLineWidth(1)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 24
// === 酒言酒语 ===
const quote = DRINK_QUOTES[Math.floor(Math.random() * DRINK_QUOTES.length)]
// 左侧装饰竖线
ctx.setFillStyle(c.accent)
this.rr(ctx, P, y, 4, 40, 2, 'fill')
// 引号
ctx.font = `bold 48px ${F}`
ctx.setGlobalAlpha(0.2)
ctx.fillText('"', P + 14, y + 38)
ctx.setGlobalAlpha(1)
// 引文
ctx.font = `normal 26px ${F}`
ctx.setFillStyle(GRAY)
const dq = quote.length > 14 ? quote.substring(0, 14) + '...' : quote
ctx.fillText(dq, P + 16, y + 28)
y += 60
// === 底部品牌栏 ===
ctx.setStrokeStyle(c.line)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 28
// 品牌名
ctx.font = `bold 26px ${F}`
// ===== 酒言酒语(优化装饰) =====
const quote = DRINK_QUOTES[Math.floor(Math.random() * DRINK_QUOTES.length)]
// 左侧装饰竖条(加宽、渐变感)
ctx.setFillStyle(c.accent)
this.rr(ctx, P, y + 2, 5, 48, 2.5, 'fill')
// 装饰引号(大字、半透明)
ctx.font = `bold 60px ${F}`
ctx.setGlobalAlpha(0.15)
ctx.setFillStyle(c.accentLight)
ctx.fillText('\u201C', P + 16, y + 44)
ctx.setGlobalAlpha(1)
// 引文内容
ctx.font = `italic 26px ${F}`
ctx.setFillStyle(SUB)
const maxQW = W - P * 2 - 40
let dq = quote
if (ctx.measureText(dq).width > maxQW) {
while (dq.length > 0 && ctx.measureText(dq + '...').width > maxQW) dq = dq.slice(0, -1)
dq += '...'
}
ctx.fillText(dq, P + 20, y + 32)
y += 72
// ===== 底部品牌栏 =====
ctx.setStrokeStyle(c.line)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 32
// 品牌名 + 副标语纵向排列
ctx.font = `bold 28px ${F}`
ctx.setFillStyle(WHITE)
ctx.fillText('喝了么', P, y + 20)
// 副标语
ctx.fillText('喝了么', P, y + 20)
ctx.font = `normal 20px ${F}`
ctx.setFillStyle(DIM)
ctx.fillText('记录每一杯的故事', P, y + 50)
// 二维码占位
const qr = 80, qx = W - P - qr, qy = y
// 二维码占位(优化样式)
const qr = 80, qx = W - P - qr, qy = y + 2
ctx.setFillStyle(c.tagBg)
this.rr(ctx, qx, qy, qr, qr, 8, 'fill')
ctx.setStrokeStyle(c.line)
ctx.setLineWidth(1)
this.rr(ctx, qx, qy, qr, qr, 8, 'stroke')
ctx.font = `normal 18px ${F}`
ctx.setFillStyle(DIM)
this.rr(ctx, qx, qy, qr, qr, 10, 'fill')
ctx.setStrokeStyle(c.tagBorder)
ctx.setLineWidth(1.5)
this.rr(ctx, qx, qy, qr, qr, 10, 'stroke')
ctx.font = `500 16px ${F}`
ctx.setFillStyle(GRAY)
const sw = ctx.measureText('扫码').width
ctx.fillText('扫码', qx + (qr - sw) / 2, qy + qr / 2 + 7)
ctx.fillText('扫码', qx + (qr - sw) / 2, qy + qr / 2 + 6)
// 计算实际内容高度(用于动态裁剪导出)
const contentH = qy + qr + P
return new Promise(resolve => {
ctx.draw(false, () => setTimeout(resolve, 600))
ctx.draw(false, () => setTimeout(() => resolve(contentH), 600))
})
},
@@ -338,11 +384,14 @@ export default {
return [3, 3, 3]
},
// 导出
exportImg() {
// 导出(动态裁剪到实际内容区域)
exportImg(contentH) {
return new Promise((resolve, reject) => {
const cropH = Math.min(contentH || 1200, 1200)
uni.canvasToTempFilePath({
canvasId: 'shareCanvas', quality: 1,
x: 0, y: 0, width: 750, height: cropH,
destWidth: 750, destHeight: cropH,
success: res => resolve(res.tempFilePath),
fail: err => reject({ msg: '导出失败: ' + (err.errMsg || '') })
}, this)
@@ -368,11 +417,11 @@ export default {
},
shareToFriend() { uni.showToast({ title: '点击右上角分享', icon: 'none' }) },
goHome() { uni.reLaunch({ url: '/pages/index/index' }) },
goHome() { uni.switchTab({ url: '/pages/index/index' }) },
goBack() { uni.navigateBack() }
},
onShareAppMessage() {
return { title: '今晚的酒局记录 - 喝了么', path: '/pages/index/index' }
return { title: '今晚的酒局记录 - 喝了么', path: '/pages/index/index' }
}
}
</script>
+69 -99
View File
@@ -1,12 +1,15 @@
<template>
<view class="page-container home safe-bottom">
<view class="page-container home">
<!-- 顶部问候 -->
<view class="greeting-bar">
<view class="greeting-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="greeting-left">
<text class="greeting-text">{{ greeting }}{{ userName }}</text>
<text class="greeting-sub">今晚喝一杯</text>
</view>
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<image v-if="userAvatar" class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view v-else class="avatar avatar-placeholder">
<text class="avatar-placeholder-icon">👤</text>
</view>
</view>
<!-- 核心CTA - 首页最醒目位置 -->
@@ -81,21 +84,9 @@
<text>过量饮酒有害健康 · 请理性记录</text>
</view>
<!-- 底部导航占位 -->
<view class="tab-bar">
<view class="tab-item tab-active" @click="goHome">
<text class="tab-icon">🏠</text>
<text class="tab-label">首页</text>
</view>
<view class="tab-center" @click="goRecord">
<view class="tab-fab">
<text class="tab-fab-icon">+</text>
</view>
</view>
<view class="tab-item" @click="goProfile">
<text class="tab-icon">👤</text>
<text class="tab-label">我的</text>
</view>
<!-- 悬浮记录按钮 -->
<view class="fab-record" @click="goRecord">
<text class="fab-icon">+</text>
</view>
<!-- 日期记录弹窗 -->
@@ -141,14 +132,16 @@ export default {
components: { DrinkCalendar },
data() {
const now = new Date()
const sysInfo = uni.getSystemInfoSync()
return {
statusBarHeight: sysInfo.statusBarHeight || 25,
calendarYear: now.getFullYear(),
calendarMonth: now.getMonth() + 1,
records: [],
stats: {},
greeting: '',
userName: '',
userAvatar: '/static/logo.png',
userAvatar: '',
showDayDetail: false,
selectedDate: '',
selectedRecord: null
@@ -181,7 +174,7 @@ export default {
this.records = getRecords()
const user = getUserInfo()
this.userName = user.nickname || '酒友'
this.userAvatar = user.avatar || '/static/logo.png'
this.userAvatar = user.avatar || ''
this.greeting = getGreeting()
this.stats = calcStats(this.records)
},
@@ -207,11 +200,8 @@ export default {
goRecord() {
uni.navigateTo({ url: '/pages/record/record' })
},
goHome() {
// 已在首页
},
goProfile() {
uni.navigateTo({ url: '/pages/profile/profile' })
uni.switchTab({ url: '/pages/profile/profile' })
}
}
}
@@ -219,7 +209,7 @@ export default {
<style lang="scss" scoped>
.home {
padding: $sp-lg;
padding: $sp-md;
padding-top: 0;
}
@@ -227,50 +217,64 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: $sp-xl 0 $sp-lg;
padding: $sp-md 0 $sp-sm;
}
.greeting-text {
display: block;
font-size: $fs-xl;
font-size: $fs-lg;
font-weight: $fw-bold;
}
.greeting-sub {
display: block;
font-size: $fs-sm;
font-size: $fs-xs;
color: $text-secondary;
margin-top: $sp-xs;
margin-top: 4rpx;
}
.avatar {
width: 80rpx;
height: 80rpx;
width: 72rpx;
height: 72rpx;
border-radius: $radius-full;
border: 4rpx solid $amber-glow;
flex-shrink: 0;
}
.avatar-placeholder {
background: $bg-elevated;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx dashed rgba(232,168,56,0.3);
}
.avatar-placeholder-icon {
font-size: 40rpx;
opacity: 0.5;
}
.calendar-section {
margin-bottom: $sp-lg;
padding: $sp-lg;
margin-bottom: $sp-md;
padding: $sp-md;
}
.stats-row {
display: flex;
gap: $sp-sm;
margin-bottom: $sp-lg;
gap: $sp-xs;
margin-bottom: $sp-md;
}
.stat-card {
flex: 1;
text-align: center;
padding: $sp-lg $sp-sm;
padding: $sp-md $sp-xs;
}
.stat-value {
display: block;
font-size: $fs-2xl;
margin-bottom: $sp-xs;
font-size: $fs-xl;
margin-bottom: 4rpx;
}
.stat-label {
@@ -279,11 +283,12 @@ export default {
}
.week-summary {
margin-bottom: $sp-lg;
margin-bottom: $sp-md;
padding: $sp-md;
}
.week-header {
margin-bottom: $sp-lg;
margin-bottom: $sp-sm;
}
.week-stats {
@@ -297,9 +302,9 @@ export default {
.week-stat-value {
display: block;
font-size: $fs-2xl;
font-size: $fs-xl;
font-weight: $fw-bold;
margin-bottom: $sp-xs;
margin-bottom: 4rpx;
}
.week-stat-label {
@@ -314,7 +319,7 @@ export default {
}
.health-ref {
margin-top: $sp-lg;
margin-top: $sp-md;
position: relative;
height: 12rpx;
background: $bg-elevated;
@@ -339,10 +344,10 @@ export default {
/* 核心CTA - 首页英雄区域 */
.hero-cta {
position: relative;
margin-bottom: $sp-lg;
border-radius: $radius-xl;
margin-bottom: $sp-md;
border-radius: $radius-lg;
overflow: hidden;
padding: $sp-xl $sp-lg;
padding: $sp-md $sp-lg;
cursor: pointer;
&:active {
@@ -381,11 +386,11 @@ export default {
z-index: 1;
display: flex;
align-items: center;
gap: $sp-lg;
gap: $sp-md;
}
.hero-cta-emoji {
font-size: 72rpx;
font-size: 56rpx;
}
.hero-cta-text {
@@ -394,7 +399,7 @@ export default {
.hero-cta-title {
display: block;
font-size: $fs-2xl;
font-size: $fs-xl;
font-weight: $fw-black;
color: $text-on-amber;
letter-spacing: -1rpx;
@@ -402,13 +407,13 @@ export default {
.hero-cta-sub {
display: block;
font-size: $fs-sm;
font-size: $fs-xs;
color: rgba(11, 11, 20, 0.6);
margin-top: $sp-xs;
margin-top: 4rpx;
}
.hero-cta-arrow {
font-size: 56rpx;
font-size: 44rpx;
color: $text-on-amber;
opacity: 0.5;
font-weight: $fw-bold;
@@ -416,58 +421,18 @@ export default {
.bottom-health-tip {
text-align: center;
padding: $sp-lg 0;
padding-bottom: 160rpx;
padding: $sp-sm 0;
padding-bottom: calc(100rpx + env(safe-area-inset-bottom));
font-size: $fs-xs;
color: $text-tertiary;
opacity: 0.4;
}
/* 底部TabBar */
.tab-bar {
/* 悬浮记录按钮 */
.fab-record {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 120rpx;
background: $bg-card;
display: flex;
align-items: center;
justify-content: space-around;
padding-bottom: env(safe-area-inset-bottom);
border-top: 2rpx solid rgba(255,255,255,0.04);
z-index: 50;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-xs;
}
.tab-icon {
font-size: $fs-xl;
opacity: 0.5;
}
.tab-label {
font-size: $fs-xs;
color: $text-tertiary;
}
.tab-active {
.tab-icon { opacity: 1; }
.tab-label { color: $amber; }
}
.tab-center {
position: relative;
top: -20rpx;
}
.tab-fab {
right: 40rpx;
bottom: calc(140rpx + env(safe-area-inset-bottom));
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, $amber-light, $amber-deep);
@@ -476,9 +441,14 @@ export default {
align-items: center;
justify-content: center;
box-shadow: $shadow-amber;
z-index: 50;
&:active {
transform: scale(0.92);
}
}
.tab-fab-icon {
.fab-icon {
font-size: $fs-2xl;
font-weight: $fw-bold;
color: $text-on-amber;
+303 -25
View File
@@ -6,7 +6,7 @@
<view class="brand-icon">
<text class="brand-emoji">🍻</text>
</view>
<text class="brand-name">了么</text>
<text class="brand-name">喝了么</text>
<text class="brand-slogan">让每一杯酒都有迹可循</text>
</view>
@@ -27,11 +27,42 @@
<button class="btn-primary notice-btn" @click="acceptNotice">我已了解</button>
</view>
<!-- 主登录按钮 -->
<!-- 主登录区域 -->
<view v-else class="login-actions">
<button class="btn-cta login-btn" @click="handleLogin">
<text class="login-btn-icon">📱</text>
<text class="login-btn-text">微信一键登录</text>
<!-- 头像 + 昵称填写微信新规范 -->
<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">
@@ -40,9 +71,20 @@
<view class="login-divider-line"></view>
</view>
<button class="btn-secondary" @click="handleGuest">
<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>
@@ -51,7 +93,7 @@
<view class="pref-card card-elevated">
<view class="pref-header">
<text class="text-h2">你的饮酒偏好</text>
<text class="text-caption">可跳过稍后在设置中修改</text>
<text class="text-caption">选择你常喝的酒类可多选</text>
</view>
<view class="pref-section">
@@ -86,13 +128,13 @@
<view class="pref-actions">
<button class="btn-text" @click="skipPreferences">跳过</button>
<button class="btn-primary" @click="savePreferences">保存并进入</button>
<button class="btn-primary" :loading="prefSaving" @click="savePreferences">{{ selectedCategories.length > 0 || selectedFrequency ? '保存并进入' : '进入首页' }}</button>
</view>
</view>
</view>
<!-- 底部协议 -->
<view class="agreement">
<!-- 底部协议非登录状态时显示 -->
<view v-if="showNotice" class="agreement">
<text class="text-tiny">登录即代表同意</text>
<text class="text-tiny text-amber">用户协议</text>
<text class="text-tiny"></text>
@@ -109,6 +151,11 @@ export default {
return {
showNotice: true,
showPreferences: false,
loginLoading: false,
prefSaving: false,
agreed: false,
avatarUrl: '',
nickname: '',
drinkCategories: DRINK_CATEGORIES,
selectedCategories: [],
selectedFrequency: '',
@@ -124,15 +171,94 @@ export default {
acceptNotice() {
this.showNotice = false
},
handleLogin() {
// Mock登录,直接显示偏好设置
this.showPreferences = true
// ===== 微信新规范:头像选择 =====
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')
uni.reLaunch({ url: '/pages/index/index' })
// 游客也设置一个基础用户信息
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) {
@@ -141,22 +267,38 @@ export default {
this.selectedCategories.push(id)
}
},
skipPreferences() {
this.finishLogin()
},
savePreferences() {
const user = JSON.parse(uni.getStorageSync('user_info') || '{}')
user.preferences = {
favoriteCategories: this.selectedCategories,
frequency: this.selectedFrequency
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
}
uni.setStorageSync('user_info', JSON.stringify(user))
this.finishLogin()
},
finishLogin() {
uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_first_launch', 'false')
uni.reLaunch({ url: '/pages/index/index' })
uni.switchTab({ url: '/pages/index/index' })
},
// ===== 协议查看 =====
openAgreement(type) {
const title = type === 'user' ? '用户协议' : '隐私政策'
uni.showToast({ title: `${title}页面开发中`, icon: 'none' })
}
}
}
@@ -263,6 +405,93 @@ export default {
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;
@@ -275,10 +504,10 @@ export default {
align-items: center;
justify-content: center;
gap: $sp-sm;
}
.login-btn-icon {
font-size: $fs-xl;
&[disabled] {
opacity: 0.5;
}
}
.login-btn-text {
@@ -303,6 +532,43 @@ export default {
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;
@@ -315,12 +581,14 @@ export default {
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 {
@@ -391,4 +659,14 @@ export default {
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>
+64 -77
View File
@@ -3,7 +3,10 @@
<!-- 用户头部 -->
<view class="profile-header">
<view class="profile-glow"></view>
<image class="profile-avatar" :src="user.avatar" mode="aspectFill"></image>
<image v-if="user.avatar" class="profile-avatar" :src="user.avatar" mode="aspectFill"></image>
<view v-else class="profile-avatar profile-avatar-placeholder">
<text class="profile-avatar-icon">👤</text>
</view>
<text class="profile-name">{{ user.nickname }}</text>
<text class="profile-joined">加入于 {{ user.joinedAt || '2026' }}</text>
</view>
@@ -73,26 +76,19 @@
<view class="divider"></view>
<view class="settings-item" @click="showComingSoon('关于')">
<text class="settings-icon"></text>
<text class="settings-name">关于喝了么</text>
<text class="settings-name">关于喝了么</text>
<text class="settings-arrow"></text>
</view>
</view>
<!-- 底部TabBar -->
<view class="tab-bar">
<view class="tab-item" @click="goHome">
<text class="tab-icon">🏠</text>
<text class="tab-label">首页</text>
</view>
<view class="tab-center" @click="goRecord">
<view class="tab-fab">
<text class="tab-fab-icon">+</text>
</view>
</view>
<view class="tab-item tab-active" @click="goProfile">
<text class="tab-icon">👤</text>
<text class="tab-label">我的</text>
</view>
<!-- 登出/登录按钮 -->
<view class="logout-area">
<button v-if="isGuest" class="btn-ghost logout-btn" @click="goLogin">
登录账号同步数据
</button>
<button v-else class="btn-text logout-btn" @click="handleLogout">
退出登录
</button>
</view>
<!-- 成就详情弹窗 -->
@@ -124,7 +120,8 @@ export default {
stats: {},
achievementList: [],
showDetail: false,
detailAchievement: {}
detailAchievement: {},
isGuest: false
}
},
computed: {
@@ -135,6 +132,7 @@ export default {
}
},
onShow() {
this.checkLoginState()
this.loadData()
},
methods: {
@@ -152,13 +150,38 @@ export default {
uni.showToast({ title: `${name}即将上线`, icon: 'none' })
},
goHome() {
uni.reLaunch({ url: '/pages/index/index' })
uni.switchTab({ url: '/pages/index/index' })
},
goRecord() {
uni.navigateTo({ url: '/pages/record/record' })
},
goProfile() {
// 已在个人页面
checkLoginState() {
const isLoggedIn = uni.getStorageSync('is_logged_in')
const isGuest = uni.getStorageSync('is_guest')
if (!isLoggedIn || isLoggedIn !== 'true') {
uni.reLaunch({ url: '/pages/login/login' })
return
}
this.isGuest = isGuest === 'true'
},
handleLogout() {
uni.showModal({
title: '退出登录',
content: '退出后本地数据将保留,重新登录后可同步',
confirmText: '退出',
confirmColor: '#FF6B6B',
success: (res) => {
if (res.confirm) {
uni.removeStorageSync('is_logged_in')
uni.removeStorageSync('is_guest')
uni.removeStorageSync('user_info')
uni.reLaunch({ url: '/pages/login/login' })
}
}
})
},
goLogin() {
uni.navigateTo({ url: '/pages/login/login' })
}
}
}
@@ -167,7 +190,7 @@ export default {
<style lang="scss" scoped>
.profile-page {
padding: $sp-lg;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
}
/* 头部 */
@@ -199,6 +222,20 @@ export default {
z-index: 2;
}
.profile-avatar-placeholder {
background: $bg-elevated;
display: flex;
align-items: center;
justify-content: center;
border-style: dashed;
border-color: rgba(232,168,56,0.4);
}
.profile-avatar-icon {
font-size: 80rpx;
opacity: 0.5;
}
.profile-name {
font-size: $fs-xl;
font-weight: $fw-bold;
@@ -309,65 +346,15 @@ export default {
color: $text-tertiary;
}
/* TabBar */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 120rpx;
background: $bg-card;
/* 登出区域 */
.logout-area {
padding: $sp-xl 0 $sp-3xl;
display: flex;
align-items: center;
justify-content: space-around;
padding-bottom: env(safe-area-inset-bottom);
border-top: 2rpx solid rgba(255,255,255,0.04);
z-index: 50;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-xs;
}
.tab-icon {
font-size: $fs-xl;
opacity: 0.5;
}
.tab-label {
font-size: $fs-xs;
color: $text-tertiary;
}
.tab-active {
.tab-icon { opacity: 1; }
.tab-label { color: $amber; }
}
.tab-center {
position: relative;
top: -20rpx;
}
.tab-fab {
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, $amber-light, $amber-deep);
border-radius: $radius-full;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-amber;
}
.tab-fab-icon {
font-size: $fs-2xl;
font-weight: $fw-bold;
color: $text-on-amber;
.logout-btn {
min-width: 300rpx;
}
/* 成就详情弹窗 */