feat(app): 初始化喝酒了么小程序基础架构

- 添加项目配置文件(.editorconfig, .gitignore)
- 创建App.vue主应用文件,包含启动时Mock数据初始化
- 添加index.html和main.js应用入口文件
- 配置manifest.json应用基本信息和权限设置
- 设置pages.json页面路由和全局样式
- 添加uni.promisify.adaptor.js适配器文件
- 创建uni.scss设计令牌系统,定义琥珀夜光主题色彩
- 新增constants.js常量定义,包含酒类分类和品牌数据
This commit is contained in:
cg
2026-07-13 22:15:16 +08:00
parent 35af888097
commit f945ccf4c7
34 changed files with 6447 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
<template>
<view class="page-container card-page">
<!-- 顶部导航 -->
<view class="card-nav">
<view class="step-back" @click="goBack">
<text class="step-back-arrow"></text>
</view>
<text class="card-nav-title">你的酒局卡片</text>
</view>
<!-- 照片提示 -->
<view class="photo-hint" v-if="record">
<text class="photo-hint-text">
{{ photoHintText }}
</text>
</view>
<!-- 模板切换 -->
<view class="template-switcher">
<view
v-for="t in templates"
:key="t.id"
class="template-option"
:class="{ 'template-active': currentTemplate === t.id }"
@click="currentTemplate = t.id"
>
<view class="template-swatch" :style="{ background: t.swatch }">
<view class="swatch-dot" v-if="currentTemplate === t.id"></view>
</view>
<text class="template-label">{{ t.name }}</text>
<text class="template-desc">{{ t.desc }}</text>
</view>
</view>
<!-- 卡片预览 -->
<view class="card-preview-area">
<DrinkCard v-if="record" :record="record" :template="currentTemplate" />
</view>
<!-- 操作按钮 -->
<view class="card-actions">
<button class="btn-cta" @click="saveToAlbum">
💾 保存到相册
</button>
<view class="action-row">
<button class="btn-secondary action-btn" @click="shareToFriend">
🔗 分享好友
</button>
<button class="btn-secondary action-btn" @click="goHome">
🏠 返回首页
</button>
</view>
</view>
<!-- 成功提示 -->
<view v-if="showSuccess" class="success-toast">
<text> 已保存到相册</text>
</view>
</view>
</template>
<script>
import DrinkCard from '../../components/DrinkCard.vue'
import { getRecords } from '../../common/mock-data'
export default {
components: { DrinkCard },
data() {
return {
currentTemplate: 'amber',
record: null,
showSuccess: false,
templates: [
{
id: 'amber',
name: '琥珀流光',
desc: '温暖金色调',
swatch: 'linear-gradient(135deg, #1A1308, #0F0B06)'
},
{
id: 'aurora',
name: '暗夜极光',
desc: '冷色氛围感',
swatch: 'linear-gradient(135deg, #0A0E1A, #0E0A1A)'
},
{
id: 'film',
name: '胶片记忆',
desc: '复古胶片风',
swatch: 'linear-gradient(135deg, #141210, #0D0C0A)'
}
]
}
},
computed: {
photoHintText() {
if (!this.record) return ''
const photos = this.record.photos || []
if (photos.length === 0) return '📷 无照片 · 纯文字排版'
if (photos.length === 1) return '📸 单张照片 · 沉浸式全屏'
return `🖼️ ${photos.length}张照片 · 拼贴布局`
}
},
onLoad(options) {
const records = getRecords()
if (options && options.recordId) {
this.record = records.find(r => r.id === options.recordId)
}
if (!this.record) {
this.record = records.find(r => r.mode === 'drank') || this.getFallbackRecord()
}
},
methods: {
getFallbackRecord() {
return {
date: new Date().toISOString().slice(0, 10),
mode: 'drank',
drinks: [{ category: 'beer', brand: '青岛啤酒', product: '经典', amount: 2, unit: 'bottle', degree: 4.3, standardCups: 3.4 }],
food: { category: 'bbq', name: '烤串' },
feeling: 'tipsy',
photos: [],
standardCupsTotal: 3.4
}
},
saveToAlbum() {
this.showSuccess = true
uni.showToast({ title: '已保存到相册', icon: 'success' })
setTimeout(() => { this.showSuccess = false }, 2000)
},
shareToFriend() {
uni.showToast({ title: '点击右上角分享', icon: 'none' })
},
goHome() {
uni.reLaunch({ url: '/pages/index/index' })
},
goBack() {
uni.navigateBack()
}
},
onShareAppMessage() {
return {
title: '今晚的酒局记录 - 喝酒了么',
path: '/pages/index/index'
}
}
}
</script>
<style lang="scss" scoped>
.card-page {
padding: $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + $sp-xl);
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 导航 */
.card-nav {
display: flex;
align-items: center;
gap: $sp-md;
padding: $sp-md 0 $sp-lg;
}
.step-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
background: $bg-card;
}
.step-back-arrow {
font-size: $fs-xl;
color: $text-secondary;
font-weight: $fw-bold;
}
.card-nav-title {
font-size: $fs-lg;
font-weight: $fw-bold;
}
/* 照片数量提示 */
.photo-hint {
text-align: center;
margin-bottom: $sp-md;
}
.photo-hint-text {
font-size: $fs-xs;
color: $text-tertiary;
}
/* 模板切换 - 横排卡片选择 */
.template-switcher {
display: flex;
gap: $sp-md;
margin-bottom: $sp-xl;
justify-content: center;
}
.template-option {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-xs;
padding: $sp-sm $sp-md;
border-radius: $radius-lg;
border: 2rpx solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.02);
transition: all $duration-fast $ease-out;
flex: 1;
max-width: 220rpx;
&.template-active {
border-color: $amber;
background: rgba(232, 168, 56, 0.05);
}
}
.template-swatch {
width: 64rpx;
height: 80rpx;
border-radius: $radius-md;
border: 2rpx solid rgba(255, 255, 255, 0.1);
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.swatch-dot {
width: 12rpx;
height: 12rpx;
border-radius: $radius-full;
background: $amber;
}
.template-label {
font-size: $fs-xs;
color: $text-primary;
font-weight: $fw-medium;
}
.template-desc {
font-size: 18rpx;
color: $text-tertiary;
}
/* 卡片预览 */
.card-preview-area {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
margin-bottom: $sp-xl;
width: 100%;
}
/* 操作按钮 */
.card-actions {
padding-bottom: $sp-xl;
}
.action-row {
display: flex;
gap: $sp-md;
margin-top: $sp-md;
}
.action-btn {
flex: 1;
}
.success-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: $sp-lg $sp-xl;
border-radius: $radius-lg;
font-size: $fs-base;
z-index: 100;
}
</style>
+540
View File
@@ -0,0 +1,540 @@
<template>
<view class="page-container home safe-bottom">
<!-- 顶部问候 -->
<view class="greeting-bar">
<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>
</view>
<!-- 核心CTA - 首页最醒目位置 -->
<view class="hero-cta" @click="goRecord">
<view class="hero-cta-bg"></view>
<view class="hero-cta-content">
<text class="hero-cta-emoji">🍻</text>
<view class="hero-cta-text">
<text class="hero-cta-title">开始记录</text>
<text class="hero-cta-sub">记录今晚的酒局</text>
</view>
<text class="hero-cta-arrow"></text>
</view>
</view>
<!-- 统计卡片 -->
<view class="stats-row">
<view class="stat-card card" @click="goProfile">
<text class="stat-value text-num text-amber">{{ stats.monthDrinkCount }}</text>
<text class="stat-label">本月饮酒</text>
</view>
<view class="stat-card card" @click="goProfile">
<text class="stat-value text-num">{{ stats.monthCups }}</text>
<text class="stat-label">标准杯</text>
</view>
<view class="stat-card card" @click="goProfile">
<text class="stat-value text-num" :class="stats.streakType === 'drank' ? 'text-amber' : 'text-mint'">
{{ stats.streak }}
</text>
<text class="stat-label">{{ stats.streakType === 'drank' ? '连喝天数' : '戒酒天数' }}</text>
</view>
</view>
<!-- 饮酒日历 -->
<view class="calendar-section card">
<DrinkCalendar
:year="calendarYear"
:month="calendarMonth"
:records="records"
@change-month="onMonthChange"
@select-date="onDateSelect"
/>
</view>
<!-- 本周摘要 -->
<view class="week-summary card">
<view class="week-header flex-between">
<text class="text-h3">本周摘要</text>
<text class="text-caption">{{ weekRange }}</text>
</view>
<view class="week-stats flex">
<view class="week-stat">
<text class="week-stat-value text-num">{{ stats.weekDrinkCount }}</text>
<text class="week-stat-label">次饮酒</text>
</view>
<view class="week-divider"></view>
<view class="week-stat">
<text class="week-stat-value text-num text-amber">{{ stats.weekCups }}</text>
<text class="week-stat-label">标准杯</text>
</view>
</view>
<view class="health-ref" v-if="stats.weekCups > 0">
<view class="health-bar-fill" :style="{ width: healthPercent + '%' }"></view>
<text class="health-text">
{{ stats.weekCups > 14 ? '本周超标了,注意休息' : '本周控制得不错' }}
</text>
</view>
</view>
<!-- 底部健康提示 -->
<view class="bottom-health-tip">
<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>
<!-- 日期记录弹窗 -->
<view v-if="showDayDetail" class="day-overlay" @click="showDayDetail = false">
<view class="day-detail card-elevated" @click.stop>
<view class="day-detail-header flex-between">
<text class="text-h3">{{ selectedDate }}</text>
<text class="btn-text" @click="showDayDetail = false">关闭</text>
</view>
<view v-if="selectedRecord">
<view v-if="selectedRecord.mode === 'drank'" class="day-record-info">
<view class="day-drinks">
<view v-for="(drink, i) in selectedRecord.drinks" :key="i" class="day-drink-item">
<text>{{ drink.emoji || '🥃' }} {{ drink.brand }} {{ drink.product }}</text>
</view>
</view>
<view class="day-meta flex-between">
<text class="text-caption">{{ getFeelingName(selectedRecord.feeling) }}</text>
<text class="text-caption text-amber text-num">{{ selectedRecord.standardCupsTotal }} 标准杯</text>
</view>
</view>
<view v-else class="day-abstain">
<text class="day-abstain-emoji">🚫</text>
<text class="text-caption">今日未饮酒好样的</text>
</view>
</view>
<view v-else class="day-empty">
<text class="text-caption">当天无记录</text>
<button class="btn-ghost" @click="backfillDate">补打卡</button>
</view>
</view>
</view>
</view>
</template>
<script>
import DrinkCalendar from '../../components/DrinkCalendar.vue'
import { getRecords, getUserInfo } from '../../common/mock-data'
import { calcStats, getGreeting, formatDate, getWeekStart } from '../../common/utils'
import { FEELINGS } from '../../common/constants'
export default {
components: { DrinkCalendar },
data() {
const now = new Date()
return {
calendarYear: now.getFullYear(),
calendarMonth: now.getMonth() + 1,
records: [],
stats: {},
greeting: '',
userName: '',
userAvatar: '/static/logo.png',
showDayDetail: false,
selectedDate: '',
selectedRecord: null
}
},
computed: {
weekRange() {
const weekStart = getWeekStart()
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekEnd.getDate() + 6)
return `${formatDate(weekStart, 'MM/DD')} - ${formatDate(weekEnd, 'MM/DD')}`
},
healthPercent() {
// 建议每周不超过14标准杯
return Math.min((this.stats.weekCups / 14) * 100, 100)
}
},
onShow() {
// 首次启动跳转引导页
const isFirst = uni.getStorageSync('is_first_launch')
const isLoggedIn = uni.getStorageSync('is_logged_in')
if (isFirst === 'true' || !isLoggedIn) {
uni.reLaunch({ url: '/pages/onboarding/onboarding' })
return
}
this.loadData()
},
methods: {
loadData() {
this.records = getRecords()
const user = getUserInfo()
this.userName = user.nickname || '酒友'
this.userAvatar = user.avatar || '/static/logo.png'
this.greeting = getGreeting()
this.stats = calcStats(this.records)
},
onMonthChange({ year, month }) {
this.calendarYear = year
this.calendarMonth = month
},
onDateSelect(dateStr) {
this.selectedDate = dateStr
this.selectedRecord = this.records.find(r => r.date === dateStr) || null
this.showDayDetail = true
},
getFeelingName(feelingId) {
const f = FEELINGS.find(f => f.id === feelingId)
return f ? `${f.emoji} ${f.name}` : ''
},
backfillDate() {
this.showDayDetail = false
uni.navigateTo({
url: `/pages/record/record?date=${this.selectedDate}&mode=backfill`
})
},
goRecord() {
uni.navigateTo({ url: '/pages/record/record' })
},
goHome() {
// 已在首页
},
goProfile() {
uni.navigateTo({ url: '/pages/profile/profile' })
}
}
}
</script>
<style lang="scss" scoped>
.home {
padding: $sp-lg;
padding-top: 0;
}
.greeting-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: $sp-xl 0 $sp-lg;
}
.greeting-text {
display: block;
font-size: $fs-xl;
font-weight: $fw-bold;
}
.greeting-sub {
display: block;
font-size: $fs-sm;
color: $text-secondary;
margin-top: $sp-xs;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: $radius-full;
border: 4rpx solid $amber-glow;
}
.calendar-section {
margin-bottom: $sp-lg;
padding: $sp-lg;
}
.stats-row {
display: flex;
gap: $sp-sm;
margin-bottom: $sp-lg;
}
.stat-card {
flex: 1;
text-align: center;
padding: $sp-lg $sp-sm;
}
.stat-value {
display: block;
font-size: $fs-2xl;
margin-bottom: $sp-xs;
}
.stat-label {
font-size: $fs-xs;
color: $text-secondary;
}
.week-summary {
margin-bottom: $sp-lg;
}
.week-header {
margin-bottom: $sp-lg;
}
.week-stats {
gap: 0;
}
.week-stat {
flex: 1;
text-align: center;
}
.week-stat-value {
display: block;
font-size: $fs-2xl;
font-weight: $fw-bold;
margin-bottom: $sp-xs;
}
.week-stat-label {
font-size: $fs-sm;
color: $text-secondary;
}
.week-divider {
width: 2rpx;
background: rgba(255,255,255,0.06);
margin: $sp-xs 0;
}
.health-ref {
margin-top: $sp-lg;
position: relative;
height: 12rpx;
background: $bg-elevated;
border-radius: $radius-full;
overflow: hidden;
}
.health-bar-fill {
height: 100%;
border-radius: $radius-full;
background: linear-gradient(90deg, $mint, $amber);
transition: width $duration-slow $ease-out;
}
.health-text {
display: block;
font-size: $fs-xs;
color: $text-tertiary;
margin-top: $sp-xs;
}
/* 核心CTA - 首页英雄区域 */
.hero-cta {
position: relative;
margin-bottom: $sp-lg;
border-radius: $radius-xl;
overflow: hidden;
padding: $sp-xl $sp-lg;
cursor: pointer;
&:active {
transform: scale(0.98);
}
}
.hero-cta-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, $amber-light, $amber, $amber-deep);
z-index: 0;
&::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
animation: shimmer 4s infinite;
}
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
.hero-cta-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: $sp-lg;
}
.hero-cta-emoji {
font-size: 72rpx;
}
.hero-cta-text {
flex: 1;
}
.hero-cta-title {
display: block;
font-size: $fs-2xl;
font-weight: $fw-black;
color: $text-on-amber;
letter-spacing: -1rpx;
}
.hero-cta-sub {
display: block;
font-size: $fs-sm;
color: rgba(11, 11, 20, 0.6);
margin-top: $sp-xs;
}
.hero-cta-arrow {
font-size: 56rpx;
color: $text-on-amber;
opacity: 0.5;
font-weight: $fw-bold;
}
.bottom-health-tip {
text-align: center;
padding: $sp-lg 0;
padding-bottom: 160rpx;
font-size: $fs-xs;
color: $text-tertiary;
opacity: 0.4;
}
/* 底部TabBar */
.tab-bar {
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 {
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;
}
/* 日期弹窗 */
.day-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: flex-end;
z-index: 100;
padding: $sp-lg;
}
.day-detail {
width: 100%;
padding: $sp-xl;
border-radius: $radius-xl $radius-xl 0 0;
}
.day-detail-header {
margin-bottom: $sp-lg;
}
.day-drink-item {
padding: $sp-sm 0;
font-size: $fs-base;
border-bottom: 2rpx solid rgba(255,255,255,0.04);
}
.day-meta {
margin-top: $sp-lg;
}
.day-abstain {
text-align: center;
padding: $sp-xl 0;
}
.day-abstain-emoji {
display: block;
font-size: $fs-hero;
margin-bottom: $sp-sm;
}
.day-empty {
text-align: center;
padding: $sp-xl 0;
.btn-ghost {
margin-top: $sp-lg;
}
}
</style>
+394
View File
@@ -0,0 +1,394 @@
<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">
<button class="btn-cta login-btn" @click="handleLogin">
<text class="login-btn-icon">📱</text>
<text class="login-btn-text">微信一键登录</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" @click="handleGuest">
先看看再说
</button>
</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" @click="savePreferences">保存并进入</button>
</view>
</view>
</view>
<!-- 底部协议 -->
<view 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,
drinkCategories: DRINK_CATEGORIES,
selectedCategories: [],
selectedFrequency: '',
frequencies: [
{ id: 'daily', name: '几乎每天' },
{ id: 'often', name: '经常喝' },
{ id: 'sometimes', name: '偶尔喝' },
{ id: 'rarely', name: '很少喝' }
]
}
},
methods: {
acceptNotice() {
this.showNotice = false
},
handleLogin() {
// Mock登录,直接显示偏好设置
this.showPreferences = true
},
handleGuest() {
uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_first_launch', 'false')
uni.reLaunch({ 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()
},
savePreferences() {
const user = JSON.parse(uni.getStorageSync('user_info') || '{}')
user.preferences = {
favoriteCategories: this.selectedCategories,
frequency: this.selectedFrequency
}
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' })
}
}
}
</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%;
}
/* 登录按钮 */
.login-actions {
display: flex;
flex-direction: column;
gap: $sp-lg;
}
.login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-sm;
}
.login-btn-icon {
font-size: $fs-xl;
}
.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;
}
/* 偏好设置弹层 */
.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;
}
.pref-card {
width: 100%;
padding: $sp-xl;
border-radius: $radius-xl $radius-xl 0 0;
}
.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;
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<template>
<view class="page-container onboarding">
<!-- 跳过按钮 -->
<view class="skip-bar">
<text class="btn-text" @click="goLogin">跳过</text>
</view>
<!-- 滑动引导 -->
<swiper
class="swiper"
:style="{ height: swiperHeight + 'px' }"
:current="currentSlide"
@change="onSlideChange"
circular
>
<swiper-item v-for="(slide, index) in slides" :key="index">
<view class="slide">
<!-- 图标区域 -->
<view class="slide-icon-area">
<view class="slide-icon">
<text class="slide-emoji">{{ slide.emoji }}</text>
</view>
<!-- 装饰光环 -->
<view class="slide-glow" :style="{ background: slide.glowColor }"></view>
</view>
<!-- 文案区域 -->
<view class="slide-text">
<text class="slide-title">{{ slide.title }}</text>
<text class="slide-desc">{{ slide.desc }}</text>
</view>
</view>
</swiper-item>
</swiper>
<!-- 底部控制区 -->
<view class="bottom-controls">
<!-- 指示器 -->
<view class="dots">
<view
v-for="(slide, index) in slides"
:key="index"
class="dot"
:class="{ 'dot-active': currentSlide === index }"
></view>
</view>
<!-- 按钮 -->
<view class="action-area">
<button
v-if="currentSlide < slides.length - 1"
class="btn-secondary"
@click="nextSlide"
>下一步</button>
<button
v-else
class="btn-cta"
@click="goLogin"
>开始体验</button>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
currentSlide: 0,
swiperHeight: 600,
slides: [
{
emoji: '🍻',
title: '记录每一杯',
desc: '轻松打卡,让每一杯酒都有迹可循。白酒、啤酒、红酒…你的饮酒档案从这里开始',
glowColor: 'radial-gradient(circle, rgba(232,168,56,0.2) 0%, transparent 70%)'
},
{
emoji: '📸',
title: '分享你的酒局',
desc: '自动生成精美卡片,配上酒言酒语,一键分享到微信群和朋友圈',
glowColor: 'radial-gradient(circle, rgba(139,133,184,0.2) 0%, transparent 70%)'
},
{
emoji: '🏆',
title: '解锁成就徽章',
desc: '从"初来乍到"到"年度酒神",每一个成就都记录着你的饮酒故事',
glowColor: 'radial-gradient(circle, rgba(94,198,160,0.2) 0%, transparent 70%)'
}
]
}
},
onLoad() {
// 计算swiper高度: 屏幕高度 - 顶部跳过按钮 - 底部控制区
const sys = uni.getSystemInfoSync()
this.swiperHeight = sys.windowHeight - 60 - 200
},
methods: {
onSlideChange(e) {
this.currentSlide = e.detail.current
},
nextSlide() {
if (this.currentSlide < this.slides.length - 1) {
this.currentSlide++
}
},
goLogin() {
uni.reLaunch({ url: '/pages/login/login' })
}
}
}
</script>
<style lang="scss" scoped>
.onboarding {
display: flex;
flex-direction: column;
overflow: hidden;
}
.skip-bar {
position: fixed;
top: 0;
right: 0;
padding: $sp-xl $sp-lg $sp-sm;
z-index: 10;
}
.swiper {
width: 100%;
}
.slide {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 $sp-xl;
padding-bottom: 200rpx;
}
.slide-icon-area {
position: relative;
width: 360rpx;
height: 360rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: $sp-2xl;
}
.slide-icon {
width: 240rpx;
height: 240rpx;
background: $bg-card;
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-md;
position: relative;
z-index: 2;
}
.slide-emoji {
font-size: 120rpx;
}
.slide-glow {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
z-index: 1;
filter: blur(40rpx);
}
.slide-text {
text-align: center;
max-width: 560rpx;
}
.slide-title {
display: block;
font-size: $fs-2xl;
font-weight: $fw-black;
color: $text-primary;
margin-bottom: $sp-lg;
letter-spacing: -1rpx;
}
.slide-desc {
display: block;
font-size: $fs-md;
color: $text-secondary;
line-height: $lh-loose;
}
.bottom-controls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: $sp-xl $sp-xl;
padding-bottom: calc(env(safe-area-inset-bottom) + $sp-xl);
background: linear-gradient(to top, $bg-base 60%, transparent);
}
.dots {
display: flex;
justify-content: center;
gap: $sp-sm;
margin-bottom: $sp-xl;
}
.dot {
width: 12rpx;
height: 12rpx;
border-radius: $radius-full;
background-color: $text-tertiary;
transition: all $duration-base $ease-out;
&.dot-active {
width: 40rpx;
background-color: $amber;
}
}
.action-area {
.btn-secondary, .btn-cta {
width: 100%;
}
}
</style>
+433
View File
@@ -0,0 +1,433 @@
<template>
<view class="page-container profile-page safe-bottom">
<!-- 用户头部 -->
<view class="profile-header">
<view class="profile-glow"></view>
<image class="profile-avatar" :src="user.avatar" mode="aspectFill"></image>
<text class="profile-name">{{ user.nickname }}</text>
<text class="profile-joined">加入于 {{ user.joinedAt || '2026' }}</text>
</view>
<!-- 饮酒档案统计 -->
<view class="stats-section">
<text class="section-title">饮酒档案</text>
<view class="stats-grid">
<view class="profile-stat card">
<text class="profile-stat-value text-num">{{ stats.totalDays }}</text>
<text class="profile-stat-label">打卡天数</text>
</view>
<view class="profile-stat card">
<text class="profile-stat-value text-num">{{ stats.totalRecords }}</text>
<text class="profile-stat-label">饮酒次数</text>
</view>
<view class="profile-stat card">
<text class="profile-stat-value text-num text-amber">{{ stats.totalCups }}</text>
<text class="profile-stat-label">标准杯</text>
</view>
<view class="profile-stat card">
<text class="profile-stat-value text-num">{{ favCategoryName }}</text>
<text class="profile-stat-label">最爱酒类</text>
</view>
</view>
</view>
<!-- 连续打卡 -->
<view class="streak-card card">
<view class="flex-between">
<view>
<text class="streak-label">{{ stats.streakType === 'drank' ? '连续饮酒' : '连续戒酒' }}</text>
<text class="streak-value text-num" :class="stats.streakType === 'drank' ? 'text-amber' : 'text-mint'">
{{ stats.streak }}
</text>
</view>
<text class="streak-emoji">{{ stats.streakType === 'drank' ? '🔥' : '💪' }}</text>
</view>
</view>
<!-- 成就墙 -->
<view class="achievements-section">
<text class="section-title">成就徽章</text>
<view class="achievements-grid">
<AchievementBadge
v-for="a in achievementList"
:key="a.id"
:achievement="a"
@click="showAchievementDetail(a)"
/>
</view>
</view>
<!-- 设置入口 -->
<view class="settings-section card">
<view class="settings-item" @click="showComingSoon('偏好设置')">
<text class="settings-icon"></text>
<text class="settings-name">饮酒偏好设置</text>
<text class="settings-arrow"></text>
</view>
<view class="divider"></view>
<view class="settings-item" @click="showComingSoon('隐私设置')">
<text class="settings-icon">🔒</text>
<text class="settings-name">隐私设置</text>
<text class="settings-arrow"></text>
</view>
<view class="divider"></view>
<view class="settings-item" @click="showComingSoon('关于')">
<text class="settings-icon"></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>
<!-- 成就详情弹窗 -->
<view v-if="showDetail" class="detail-overlay" @click="showDetail = false">
<view class="detail-card card-elevated" @click.stop>
<text class="detail-emoji">{{ detailAchievement.icon }}</text>
<text class="detail-name">{{ detailAchievement.name }}</text>
<text class="detail-desc">{{ detailAchievement.desc }}</text>
<view class="detail-status" :class="detailAchievement.unlocked ? 'status-unlocked' : 'status-locked'">
<text>{{ detailAchievement.unlocked ? '✓ 已解锁' : '🔒 未解锁' }}</text>
</view>
<button class="btn-secondary" style="width: 100%; margin-top: 24rpx;" @click="showDetail = false">关闭</button>
</view>
</view>
</view>
</template>
<script>
import AchievementBadge from '../../components/AchievementBadge.vue'
import { getRecords, getUserInfo } from '../../common/mock-data'
import { calcStats, checkAchievements } from '../../common/utils'
import { ACHIEVEMENTS, DRINK_CATEGORIES } from '../../common/constants'
export default {
components: { AchievementBadge },
data() {
return {
user: {},
stats: {},
achievementList: [],
showDetail: false,
detailAchievement: {}
}
},
computed: {
favCategoryName() {
if (!this.stats.favCategory) return '-'
const cat = DRINK_CATEGORIES.find(c => c.id === this.stats.favCategory)
return cat ? cat.name : '-'
}
},
onShow() {
this.loadData()
},
methods: {
loadData() {
this.user = getUserInfo()
const records = getRecords()
this.stats = calcStats(records)
this.achievementList = checkAchievements(this.stats, ACHIEVEMENTS)
},
showAchievementDetail(a) {
this.detailAchievement = a
this.showDetail = true
},
showComingSoon(name) {
uni.showToast({ title: `${name}即将上线`, icon: 'none' })
},
goHome() {
uni.reLaunch({ url: '/pages/index/index' })
},
goRecord() {
uni.navigateTo({ url: '/pages/record/record' })
},
goProfile() {
// 已在个人页面
}
}
}
</script>
<style lang="scss" scoped>
.profile-page {
padding: $sp-lg;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
/* 头部 */
.profile-header {
display: flex;
flex-direction: column;
align-items: center;
padding: $sp-2xl 0 $sp-xl;
position: relative;
}
.profile-glow {
position: absolute;
width: 300rpx;
height: 300rpx;
background: radial-gradient(circle, rgba(232,168,56,0.12) 0%, transparent 70%);
border-radius: 50%;
top: 0;
filter: blur(30rpx);
}
.profile-avatar {
width: 160rpx;
height: 160rpx;
border-radius: $radius-full;
border: 4rpx solid $amber;
margin-bottom: $sp-lg;
position: relative;
z-index: 2;
}
.profile-name {
font-size: $fs-xl;
font-weight: $fw-bold;
margin-bottom: $sp-xs;
}
.profile-joined {
font-size: $fs-sm;
color: $text-secondary;
}
/* 统计 */
.stats-section {
margin-bottom: $sp-lg;
}
.section-title {
display: block;
font-size: $fs-lg;
font-weight: $fw-bold;
margin-bottom: $sp-lg;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-md;
}
.profile-stat {
text-align: center;
padding: $sp-lg;
}
.profile-stat-value {
display: block;
font-size: $fs-2xl;
font-weight: $fw-bold;
margin-bottom: $sp-xs;
}
.profile-stat-label {
font-size: $fs-xs;
color: $text-secondary;
}
/* 连续打卡 */
.streak-card {
margin-bottom: $sp-xl;
padding: $sp-xl;
}
.streak-label {
display: block;
font-size: $fs-sm;
color: $text-secondary;
margin-bottom: $sp-sm;
}
.streak-value {
display: block;
font-size: $fs-3xl;
font-weight: $fw-black;
}
.streak-emoji {
font-size: $fs-hero;
}
/* 成就 */
.achievements-section {
margin-bottom: $sp-xl;
}
.achievements-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-md;
}
/* 设置 */
.settings-section {
margin-bottom: $sp-xl;
}
.settings-item {
display: flex;
align-items: center;
gap: $sp-md;
padding: $sp-lg 0;
&:active {
opacity: 0.7;
}
}
.settings-icon {
font-size: $fs-xl;
}
.settings-name {
flex: 1;
font-size: $fs-base;
}
.settings-arrow {
font-size: $fs-xl;
color: $text-tertiary;
}
/* TabBar */
.tab-bar {
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 {
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;
}
/* 成就详情弹窗 */
.detail-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: $sp-xl;
}
.detail-card {
width: 100%;
max-width: 600rpx;
text-align: center;
padding: $sp-2xl $sp-xl;
}
.detail-emoji {
font-size: 120rpx;
display: block;
margin-bottom: $sp-lg;
}
.detail-name {
display: block;
font-size: $fs-xl;
font-weight: $fw-bold;
margin-bottom: $sp-sm;
}
.detail-desc {
display: block;
font-size: $fs-sm;
color: $text-secondary;
margin-bottom: $sp-lg;
line-height: $lh-loose;
}
.detail-status {
display: inline-flex;
padding: $sp-sm $sp-lg;
border-radius: $radius-full;
font-size: $fs-sm;
font-weight: $fw-medium;
&.status-unlocked {
background: rgba(94,198,160,0.15);
color: $mint;
}
&.status-locked {
background: $bg-elevated;
color: $text-tertiary;
}
}
</style>
File diff suppressed because it is too large Load Diff