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
+35 -2
View File
@@ -1,7 +1,7 @@
<script>
export default {
onLaunch() {
console.log('喝了么 App Launch')
console.log('喝了么 App Launch')
// 初始化Mock数据(仅首次)
const hasData = uni.getStorageSync('drink_records')
if (!hasData) {
@@ -10,19 +10,52 @@ export default {
console.log('Mock数据已初始化')
})
}
// 数据迁移:清除旧版 logo.png 头像引用
this.migrateUserData()
// 启动路由守卫
this.checkLaunchRoute()
},
onShow() {
console.log('App Show')
},
onHide() {
console.log('App Hide')
},
methods: {
// 数据迁移:清除旧版 logo.png 头像引用
migrateUserData() {
try {
const raw = uni.getStorageSync('user_info')
if (raw) {
const user = JSON.parse(raw)
if (user.avatar && user.avatar.includes('logo.png')) {
user.avatar = ''
uni.setStorageSync('user_info', JSON.stringify(user))
}
}
} catch (e) { /* ignore */ }
},
// 启动时检查登录与引导状态,决定跳转目标
checkLaunchRoute() {
const isFirst = uni.getStorageSync('is_first_launch')
const isLoggedIn = uni.getStorageSync('is_logged_in')
if (!isFirst || isFirst === 'true') {
// 首次使用 → 引导页
uni.reLaunch({ url: '/pages/onboarding/onboarding' })
} else if (!isLoggedIn || isLoggedIn !== 'true') {
// 未登录 → 登录页
uni.reLaunch({ url: '/pages/login/login' })
}
// 否则正常进入首页(index)
}
}
}
</script>
<style lang="scss">
/* ==========================================
* 喝了么 - 全局样式
* 喝了么 - 全局样式
* 设计概念: 琥珀夜光 Amber Night
* ========================================== */
+1 -1
View File
@@ -1,4 +1,4 @@
/* 喝了么 - 常量定义 */
/* 喝了么 - 常量定义 */
// 酒类分类
export const DRINK_CATEGORIES = [
+3 -3
View File
@@ -1,11 +1,11 @@
/* 喝了么 - Mock 数据 */
/* 喝了么 - Mock 数据 */
import { formatDate, calcStandardCups, unitToMl } from './utils'
// 模拟用户数据
export const MOCK_USER = {
id: 'user_001',
nickname: '酒友小王',
avatar: '/static/logo.png',
avatar: '',
preferences: {
favoriteCategories: ['baijiu', 'beer'],
frequency: 'often'
@@ -113,7 +113,7 @@ export function generateMockRecords() {
}
// 初始化并缓存Mock数据
export function initMockData() {自己看
export function initMockData() {
const records = generateMockRecords()
uni.setStorageSync('drink_records', JSON.stringify(records))
uni.setStorageSync('user_info', JSON.stringify(MOCK_USER))
+1 -1
View File
@@ -1,4 +1,4 @@
/* 喝了么 - 工具函数 */
/* 喝了么 - 工具函数 */
/**
* 标准杯换算引擎
+11 -11
View File
@@ -94,12 +94,12 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $sp-lg;
margin-bottom: $sp-sm;
}
.cal-nav {
width: 64rpx;
height: 64rpx;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
@@ -123,20 +123,20 @@ export default {
.cal-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: $sp-sm;
margin-bottom: $sp-xs;
}
.cal-weekday {
text-align: center;
font-size: $fs-xs;
color: $text-tertiary;
padding: $sp-xs 0;
padding: 4rpx 0;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: $sp-xs;
gap: 4rpx;
}
.cal-cell {
@@ -145,7 +145,7 @@ export default {
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: $radius-md;
border-radius: $radius-sm;
position: relative;
transition: all $duration-fast $ease-out;
@@ -163,17 +163,17 @@ export default {
}
.cal-day-num {
font-size: $fs-sm;
font-size: $fs-xs;
font-weight: $fw-medium;
color: $text-primary;
font-family: $font-num;
}
.cal-dot {
width: 10rpx;
height: 10rpx;
width: 8rpx;
height: 8rpx;
border-radius: $radius-full;
margin-top: $sp-xs;
margin-top: 4rpx;
&.cal-dot-drank {
background: $amber;
+1 -1
View File
@@ -183,7 +183,7 @@
<!-- 统一底部品牌 + 二维码 -->
<view class="card-brand-bar">
<view class="brand-left">
<text class="brand-name">🍻 喝了么</text>
<text class="brand-name">🍻 喝了么</text>
<text class="brand-slogan">记录每一杯的故事</text>
</view>
<view class="brand-qr">
+700
View File
@@ -0,0 +1,700 @@
# 喝了么 - 后端接口文档
> 版本: v1.0
> 日期: 2026-07-13
> 小程序: 喝了么(uni-app 微信小程序)
> 基础URL: `https://your-domain.com/api/v1`
---
## 1. 通用说明
### 1.1 请求格式
- Content-Type: `application/json`
- 鉴权方式: 请求头 `Authorization: Bearer {token}`(微信登录获取)
- 所有时间字段使用 ISO 8601 格式
### 1.2 统一响应格式
```json
{
"code": 0,
"message": "success",
"data": {}
}
```
| code | 含义 |
|------|------|
| 0 | 成功 |
| 400 | 参数错误 |
| 401 | 未登录/token过期 |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 500 | 服务器错误 |
### 1.3 分页参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码,默认1 |
| pageSize | int | 否 | 每页数量,默认20 |
### 1.4 分页响应
```json
{
"code": 0,
"data": {
"list": [],
"total": 100,
"page": 1,
"pageSize": 20
}
}
```
---
## 2. 用户模块
### 2.1 微信登录
```
POST /auth/wechat-login
```
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| code | string | 是 | 微信登录codewx.login获取) |
| nickName | string | 否 | 用户昵称 |
| avatarUrl | string | 否 | 头像URL |
**响应 data**
```json
{
"token": "jwt_token_xxx",
"user": {
"id": "user_001",
"nickname": "酒友小王",
"avatar": "https://xxx/avatar.jpg",
"joinedAt": "2026-01-15T00:00:00.000Z",
"preferences": {
"favoriteCategories": ["baijiu", "beer"],
"frequency": "often"
}
},
"isNew": false
}
```
### 2.2 获取用户信息
```
GET /user/profile
```
**响应 data**
```json
{
"id": "user_001",
"nickname": "酒友小王",
"avatar": "https://xxx/avatar.jpg",
"joinedAt": "2026-01-15T00:00:00.000Z",
"preferences": {
"favoriteCategories": ["baijiu", "beer"],
"frequency": "often"
}
}
```
### 2.3 更新用户偏好
```
PUT /user/preferences
```
**请求参数:**
```json
{
"favoriteCategories": ["baijiu", "beer"],
"frequency": "often"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| favoriteCategories | string[] | 偏好酒类ID列表 |
| frequency | string | 饮酒频率:rarely / sometimes / often |
---
## 3. 打卡记录模块(核心)
### 3.1 创建打卡记录
```
POST /records
```
**请求参数:**
```json
{
"date": "2026-07-13",
"mode": "drank",
"drinks": [
{
"category": "baijiu",
"brand": "茅台",
"product": "飞天茅台53度",
"amount": 100,
"unit": "ml",
"degree": 53,
"standardCups": 2.12
}
],
"food": {
"category": "hotpot",
"name": "重庆老火锅"
},
"feeling": "tipsy",
"photos": [],
"visibility": "friends",
"quote": "人生得意须尽欢,莫使金樽空对月"
}
```
**字段说明:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| date | string | 是 | 日期 YYYY-MM-DD |
| mode | string | 是 | 打卡模式:`drank`(喝了) / `abstain`(未喝) |
| drinks | DrinkItem[] | 否 | 酒水列表(mode=abstain时为空数组) |
| food | FoodInfo / null | 否 | 配餐信息 |
| feeling | string / null | 否 | 感受ID |
| photos | string[] | 否 | 照片URL数组(最多9张) |
| visibility | string | 否 | 可见范围:`public` / `friends` / `private`,默认 `friends` |
| quote | string | 否 | 酒言酒语(用户选择或自定义) |
**DrinkItem 结构:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| category | string | 是 | 酒类分类ID |
| brand | string | 是 | 品牌名称 |
| product | string | 否 | 产品名 |
| amount | number | 是 | 饮用量数值 |
| unit | string | 是 | 单位:ml / liang(两) / bottle(瓶) / cup(杯) / can(听) / shot |
| degree | number | 是 | 酒精度数(%) |
| standardCups | number | 否 | 标准杯数(前端计算,后端应校验) |
**FoodInfo 结构:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| category | string | 是 | 配餐分类ID |
| name | string | 否 | 具体菜名 |
**响应 data**
```json
{
"id": "record_1689234567890",
"date": "2026-07-13",
"mode": "drank",
"drinks": [...],
"food": {...},
"feeling": "tipsy",
"photos": [],
"visibility": "friends",
"quote": "...",
"standardCupsTotal": 2.12,
"createdAt": "2026-07-13T22:30:00.000Z"
}
```
> **标准杯计算公式:**
> `标准杯 = (饮用量ml × 酒精度数% × 0.8) / 10`
> 1标准杯 = 10g纯酒精
> **单位换算为ml**
> | 单位 | 换算 |
> |------|------|
> | ml | ×1 |
> | liang(两) | ×50 |
> | bottle(瓶) | ×500 |
> | cup(杯) | ×150 |
> | can(听) | ×330 |
> | shot | ×30 |
### 3.2 获取记录列表
```
GET /records
```
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码 |
| pageSize | int | 否 | 每页数量 |
| month | string | 否 | 月份筛选 YYYY-MM |
| mode | string | 否 | 打卡模式筛选 drank/abstain |
**响应 data**
```json
{
"list": [
{
"id": "record_xxx",
"date": "2026-07-13",
"mode": "drank",
"drinks": [...],
"food": {...},
"feeling": "tipsy",
"photos": [],
"visibility": "friends",
"standardCupsTotal": 2.12,
"createdAt": "2026-07-13T22:30:00.000Z"
}
],
"total": 30,
"page": 1,
"pageSize": 20
}
```
### 3.3 获取单条记录详情
```
GET /records/:id
```
**响应 data** 同 3.1 创建记录的返回结构
### 3.4 删除记录
```
DELETE /records/:id
```
### 3.5 获取日历打卡数据
```
GET /records/calendar
```
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| year | int | 是 | 年份 |
| month | int | 是 | 月份(1-12) |
**响应 data**
```json
{
"year": 2026,
"month": 7,
"days": {
"2026-07-01": { "mode": "drank", "standardCupsTotal": 2.5 },
"2026-07-02": { "mode": "abstain", "standardCupsTotal": 0 },
"2026-07-03": null
}
}
```
> 前端根据此数据渲染日历打卡状态。null表示当天无记录。
---
## 4. 照片上传
### 4.1 上传照片
```
POST /upload/photo
Content-Type: multipart/form-data
```
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| file | File | 是 | 图片文件(jpg/png,最大10MB |
| recordId | string | 否 | 关联记录ID(可选,用于后续关联) |
**响应 data**
```json
{
"url": "https://cdn.xxx.com/photos/2026/07/abc123.jpg",
"width": 1080,
"height": 1920,
"size": 256000
}
```
### 4.2 批量上传
```
POST /upload/photos
Content-Type: multipart/form-data
```
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| files | File[] | 是 | 图片文件数组(最多9张) |
**响应 data**
```json
{
"photos": [
{ "url": "https://...", "width": 1080, "height": 1920, "size": 256000 },
...
]
}
```
---
## 5. 统计模块
### 5.1 获取用户统计概览
```
GET /stats/overview
```
**响应 data**
```json
{
"weekDrinkCount": 3,
"weekCups": 6.5,
"monthDrinkCount": 12,
"monthCups": 28.3,
"streak": 5,
"streakType": "drank",
"totalDays": 45,
"totalRecords": 48,
"totalCups": 134.4,
"favCategory": "baijiu",
"categoryVariety": 5
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| weekDrinkCount | int | 本周饮酒天数 |
| weekCups | float | 本周标准杯总数 |
| monthDrinkCount | int | 本月饮酒天数 |
| monthCups | float | 本月标准杯总数 |
| streak | int | 当前连续打卡天数 |
| streakType | string | 连续类型:drank/abstain |
| totalDays | int | 总饮酒天数(去重) |
| totalRecords | int | 总记录数 |
| totalCups | float | 历史累计标准杯 |
| favCategory | string/null | 最爱酒类ID |
| categoryVariety | int | 尝试过的酒类品种数 |
### 5.2 获取月度统计
```
GET /stats/monthly
```
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| year | int | 否 | 年份,默认当前年 |
| month | int | 否 | 月份,默认当前月 |
**响应 data**
```json
{
"year": 2026,
"month": 7,
"drinkDays": 18,
"abstainDays": 12,
"totalCups": 45.6,
"avgCupsPerDay": 2.53,
"categoryBreakdown": [
{ "category": "baijiu", "count": 8, "cups": 22.4 },
{ "category": "beer", "count": 6, "cups": 15.2 },
{ "category": "wine", "count": 4, "cups": 8.0 }
],
"feelingBreakdown": [
{ "feeling": "tipsy", "count": 10 },
{ "feeling": "buzzed", "count": 5 },
{ "feeling": "drunk", "count": 2 },
{ "feeling": "blackout", "count": 1 }
],
"foodBreakdown": [
{ "category": "hotpot", "count": 5 },
{ "category": "bbq", "count": 4 }
]
}
```
### 5.3 获取酒类分布统计
```
GET /stats/categories
```
**响应 data**
```json
{
"categories": [
{
"id": "baijiu",
"name": "白酒",
"recordCount": 20,
"totalCups": 56.8,
"percentage": 42.3
},
{
"id": "beer",
"name": "啤酒",
"recordCount": 15,
"totalCups": 38.2,
"percentage": 28.5
}
]
}
```
---
## 6. 成就模块
### 6.1 获取成就列表
```
GET /achievements
```
**响应 data**
```json
{
"achievements": [
{
"id": "first_checkin",
"name": "初来乍到",
"desc": "完成第一次打卡,开启你的饮酒记录之旅",
"icon": "🏅",
"unlocked": true,
"unlockedAt": "2026-01-15T20:00:00.000Z",
"condition": {
"type": "total_records",
"value": 1
},
"progress": 1.0
},
{
"id": "hundred_cups",
"name": "百杯不醉",
"desc": "累计饮用100标准杯",
"icon": "🏆",
"unlocked": false,
"unlockedAt": null,
"condition": {
"type": "total_standard_cups",
"value": 100
},
"progress": 0.75
}
]
}
```
**成就条件类型:**
| type | 说明 | value含义 |
|------|------|-----------|
| total_records | 总记录数 | 次数 |
| total_standard_cups | 累计标准杯 | 杯数 |
| streak_days | 连续打卡天数 | 天数 |
| category_records | 某酒类打卡次数 | 次数(需配合category字段) |
| category_variety | 尝试酒类品种数 | 品种数 |
---
## 7. 社交模块(朋友圈/卡片分享)
### 7.1 获取朋友圈动态
```
GET /feed
```
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码 |
| pageSize | int | 否 | 每页数量 |
| lastId | string | 否 | 游标分页,上一页最后一条ID |
**响应 data**
```json
{
"list": [
{
"id": "record_xxx",
"user": {
"id": "user_002",
"nickname": "酒友小李",
"avatar": "https://xxx/avatar2.jpg"
},
"date": "2026-07-13",
"mode": "drank",
"drinks": [...],
"food": {...},
"feeling": "tipsy",
"photos": ["https://..."],
"standardCupsTotal": 3.5,
"quote": "酒逢知己千杯少",
"likeCount": 5,
"commentCount": 2,
"liked": false,
"createdAt": "2026-07-13T22:30:00.000Z"
}
],
"total": 100,
"page": 1,
"pageSize": 20
}
```
### 7.2 点赞/取消点赞
```
POST /records/:id/like
POST /records/:id/unlike
```
---
## 8. 数据字典
### 8.1 酒类分类 (category)
| ID | 名称 | 默认度数 |
|----|------|----------|
| baijiu | 白酒 | 52 |
| beer | 啤酒 | 5 |
| wine | 红酒 | 13 |
| whisky | 洋酒 | 40 |
| huangjiu | 黄酒 | 15 |
| sake | 清酒 | 15 |
| fruit | 果酒 | 8 |
| cocktail | 调酒 | 15 |
| other | 其他 | 10 |
### 8.2 饮用量单位 (unit)
| ID | 名称 | 换算(ml) |
|----|------|----------|
| ml | ml | 1 |
| liang | 两 | 50 |
| bottle | 瓶 | 500 |
| cup | 杯 | 150 |
| can | 听 | 330 |
| shot | shot | 30 |
### 8.3 配餐分类 (food category)
| ID | 名称 |
|----|------|
| hotpot | 火锅 |
| bbq | 烧烤 |
| stirfry | 炒菜 |
| seafood | 海鲜 |
| japanese | 日料 |
| western | 西餐 |
| snack | 小吃卤味 |
| junk | 零食 |
| none | 无配餐 |
### 8.4 饮用感受 (feeling)
| ID | 名称 | 描述 |
|----|------|------|
| tipsy | 微醺 | 刚刚好的快乐 |
| buzzed | 到位 | 今夜刚刚好 |
| drunk | 醉了 | 有点上头了 |
| blackout | 断片 | 什么都不记得了 |
### 8.5 可见范围 (visibility)
| ID | 名称 | 说明 |
|----|------|------|
| public | 公开 | 所有人可见 |
| friends | 仅好友 | 好友可见 |
| private | 仅自己 | 仅自己可见 |
### 8.6 打卡模式 (mode)
| ID | 名称 | 说明 |
|----|------|------|
| drank | 今日喝了 | 有饮酒记录 |
| abstain | 今日未喝 | 戒酒打卡 |
---
## 9. 数据库表设计参考
### 9.1 用户表 (users)
```sql
CREATE TABLE users (
id VARCHAR(32) PRIMARY KEY,
openid VARCHAR(64) UNIQUE NOT NULL,
union_id VARCHAR(64),
nickname VARCHAR(64),
avatar VARCHAR(512),
preferences JSON,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
### 9.2 打卡记录表 (records)
```sql
CREATE TABLE records (
id VARCHAR(32) PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
date DATE NOT NULL,
mode ENUM('drank', 'abstain') NOT NULL,
drinks JSON,
food JSON,
feeling VARCHAR(16),
photos JSON,
visibility ENUM('public', 'friends', 'private') DEFAULT 'friends',
quote VARCHAR(256),
standard_cups_total DECIMAL(8,2) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date),
INDEX idx_user_mode (user_id, mode, date)
);
```
### 9.3 照片表 (photos)
```sql
CREATE TABLE photos (
id VARCHAR(32) PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
record_id VARCHAR(32),
url VARCHAR(512) NOT NULL,
width INT,
height INT,
size INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_record (record_id),
INDEX idx_user (user_id)
);
```
### 9.4 点赞表 (likes)
```sql
CREATE TABLE likes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
record_id VARCHAR(32) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_user_record (user_id, record_id)
);
```
### 9.5 成就记录表 (user_achievements)
```sql
CREATE TABLE user_achievements (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
achievement_id VARCHAR(32) NOT NULL,
unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_user_achievement (user_id, achievement_id)
);
```
---
## 10. 备注
1. **标准杯计算建议由后端完成**,前端传入的 `standardCups` 仅作参考,后端应以 `amount``unit``degree` 为准重新计算
2. **照片上传**建议走对象存储(OSS/COS),返回CDN链接
3. **日历接口**建议缓存,避免每次查全月记录
4. **成就检测**可在创建记录后异步触发,避免影响主流程
5. **可见范围**控制需在 feed 接口和记录详情接口中做权限过滤
6. 前端目前使用 Mock 数据(`mock-data.js`),切换到真实接口时只需替换 `common/mock-data.js` 中的方法为 HTTP 调用即可
+2 -71
View File
@@ -1,5 +1,5 @@
{
"name": "喝了么",
"name" : "喝了么",
"appid" : "__UNI__871D23D",
"description" : "让每一杯酒都有迹可循",
"versionName" : "1.0.0",
@@ -33,7 +33,7 @@
},
"quickapp" : {},
"mp-weixin" : {
"appid": "",
"appid" : "wx16e19044f4f57f71",
"setting" : {
"urlCheck" : false,
"es6" : true,
@@ -57,72 +57,3 @@
},
"vueVersion" : "3"
}
{
"name" : "hekjiu",
"appid" : "__UNI__871D23D",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"vueVersion" : "3"
}
+24 -21
View File
@@ -7,6 +7,13 @@
"navigationBarTitleText": ""
}
},
{
"path": "pages/profile/profile",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/onboarding/onboarding",
"style": {
@@ -34,39 +41,35 @@
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
},
{
"path": "pages/profile/profile",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "喝了么",
"navigationBarTitleText": "喝了么",
"navigationBarBackgroundColor": "#0B0B14",
"backgroundColor": "#0B0B14",
"backgroundColorTop": "#0B0B14",
"backgroundColorBottom": "#0B0B14"
},
"uniIdRouter": {}
}
"tabBar": {
"color": "#4A4A62",
"selectedColor": "#E8A838",
"backgroundColor": "#151520",
"borderStyle": "black",
"list": [
{
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/tab-home.png",
"selectedIconPath": "static/tab-home-active.png"
},
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "uni-app"
"pagePath": "pages/profile/profile",
"text": "我的",
"iconPath": "static/tab-profile.png",
"selectedIconPath": "static/tab-profile-active.png"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
]
},
"uniIdRouter": {}
}
+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;
+296 -18
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
// ===== =====
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
}
},
// logincode
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() {
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.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;
}
/* 成就详情弹窗 */
Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

+1 -1
View File
@@ -1,5 +1,5 @@
/* ==========================================
* 了么 - 设计令牌 (Design Tokens)
* 喝了么 - 设计令牌 (Design Tokens)
* 设计概念: 琥珀夜光 Amber Night
* ========================================== */
-12
View File
@@ -1,12 +0,0 @@
// 本文件用于,使用JQL语法操作项目关联的uniCloud空间的数据库,方便开发调试和远程数据库管理
// 编写clientDB的js API(也支持常规js语法,比如var),可以对云数据库进行增删改查操作。不支持uniCloud-db组件写法
// 可以全部运行,也可以选中部分代码运行。点击工具栏上的运行按钮或者按下【F5】键运行代码
// 如果文档中存在多条JQL语句,只有最后一条语句生效
// 如果混写了普通js,最后一条语句需是数据库操作语句
// 此处代码运行不受DB Schema的权限控制,移植代码到实际业务中注意在schema中配好permission
// 不支持clientDB的action
// 数据库查询有最大返回条数限制,详见:https://uniapp.dcloud.net.cn/uniCloud/cf-database.html#limit
// 详细JQL语法,请参考:https://uniapp.dcloud.net.cn/uniCloud/jql.html
// 下面示例查询uni-id-users表的所有数据
db.collection('uni-id-users').get();