feat(event): 添加酒局报名审核功能并优化前端组件

- 新增 ApproveEvent API 接口用于审核报名用户
- 在 EventCard 组件中显示待审核状态(⏳ 待审核)
- 实现酒局发起人审核报名用户的完整流程
- 添加用户协议和隐私政策页面
- 优化 FeedCard 组件中的图片展示逻辑
- 修复连续打卡天数计算问题
- 更新 HaveADrink SDK 至 1.0.15 版本
This commit is contained in:
cg
2026-08-16 14:50:50 +08:00
parent 527b0e8a8d
commit 81e7a3afa5
20 changed files with 1470 additions and 249 deletions
+6
View File
@@ -293,6 +293,12 @@ export async function CheckInEvent({ eventId }) {
return { data: res } return { data: res }
} }
/** 审核报名(仅发起人可操作;approve: 1=待审核 2=通过 3=拒绝) */
export async function ApproveEvent({ eventId, userId, approve }) {
const res = await client.ApproveEvent({ id: eventId, userId, approve })
return { data: res }
}
// ========================================== // ==========================================
// 私信 API(基于 HaveADrink SDK 真实接口) // 私信 API(基于 HaveADrink SDK 真实接口)
// ========================================== // ==========================================
+63
View File
@@ -205,6 +205,69 @@ export function calcStats(records) {
} }
} }
/**
* 本地计算连续打卡天数(修正后端 streak 不区分饮酒/戒酒模式的问题)
* 双轨计算:
* - 连续饮酒:从今天的饮酒记录向前追溯;今天未饮酒(戒酒/未打卡)则从昨天算起
* - 连续戒酒:从今天的戒酒记录向前追溯;今天饮酒/未打卡则从昨天算起
* @param {Array} records 记录列表,每项需含 date 和 mode 字段
* @returns {{ streak, streakType, drankStreak, abstainStreak }}
* streak/streakType:主展示值(今天有记录取今天模式,否则取最近记录模式)
*/
export function calcLocalStreak(records) {
if (!records || !records.length) {
return { streak: 0, streakType: 'drank', drankStreak: 0, abstainStreak: 0 }
}
const today = formatDate(new Date(), 'YYYY-MM-DD')
// 按日期去重(同一天多条取第一条)
const byDate = {}
records.forEach(r => {
if (r && r.date && byDate[r.date] === undefined) byDate[r.date] = r
})
const dates = Object.keys(byDate).sort()
// 单模式连续链:优先从今天起算,今天非该模式则宽限到昨天
const chainOf = (mode) => {
const todayMode = byDate[today] ? byDate[today].mode : null
let base = null
if (todayMode === mode) {
base = today
} else {
const y = new Date()
y.setDate(y.getDate() - 1)
const yStr = formatDate(y, 'YYYY-MM-DD')
if (byDate[yStr] && byDate[yStr].mode === mode) base = yStr
}
if (!base) return 0
let count = 1
const d = new Date(base)
for (let i = 1; i < 366; i++) {
const prev = new Date(d)
prev.setDate(prev.getDate() - i)
const r = byDate[formatDate(prev, 'YYYY-MM-DD')]
if (r && r.mode === mode) {
count++
} else {
break
}
}
return count
}
const drankStreak = chainOf('drank')
const abstainStreak = chainOf('abstain')
// 主展示值:今天有记录取今天模式,否则取最近一次记录的模式
let streak
let streakType
if (byDate[today]) {
streakType = byDate[today].mode || 'drank'
streak = streakType === 'drank' ? drankStreak : abstainStreak
} else {
const lastDate = dates[dates.length - 1]
streakType = byDate[lastDate].mode || 'drank'
streak = streakType === 'drank' ? drankStreak : abstainStreak
}
return { streak, streakType, drankStreak, abstainStreak }
}
/** /**
* 主题系统:日夜自动切换 * 主题系统:日夜自动切换
* 白天:6:00-16:00 → light * 白天:6:00-16:00 → light
+3 -1
View File
@@ -97,7 +97,9 @@ class WebSocketManager {
} }
// ===== 服务端协议消息(SDK 已解析 { mesgType, data }) ===== // ===== 服务端协议消息(SDK 已解析 { mesgType, data }) =====
conn.onServerChatData = (data) => { conn.onServerChatData = (data) =>
console.log(data,'===============');
{
this._emit('message', data) this._emit('message', data)
} }
+11 -1
View File
@@ -42,7 +42,8 @@
</view> </view>
</view> </view>
<view class="event-action" v-else-if="statusKey === 'open' || statusKey === 'upcoming'"> <view class="event-action" v-else-if="statusKey === 'open' || statusKey === 'upcoming'">
<text class="event-action-text" v-if="event.isJoined">✓ 已报名</text> <text class="event-action-text event-action-pending" v-if="event.isJoined && isPending">⏳ 待审核</text>
<text class="event-action-text" v-else-if="event.isJoined">✓ 已报名</text>
<text class="event-action-text event-action-join" v-else>报名</text> <text class="event-action-text event-action-join" v-else>报名</text>
</view> </view>
</view> </view>
@@ -68,6 +69,10 @@ export default {
statusLabel() { statusLabel() {
const map = { open: '报名中', upcoming: '待开始', ongoing: '进行中', ended: '已结束' } const map = { open: '报名中', upcoming: '待开始', ongoing: '进行中', ended: '已结束' }
return map[this.statusKey] || '报名中' return map[this.statusKey] || '报名中'
},
/** 已报名但审核状态为待审核(1) */
isPending() {
return Number(this.event.approveStatus) === 1
} }
} }
} }
@@ -199,6 +204,11 @@ export default {
font-weight: $fw-bold; font-weight: $fw-bold;
} }
.event-action-pending {
color: $amber;
background: rgba(232,168,56,0.12);
}
.event-manage { .event-manage {
display: flex; display: flex;
align-items: center; align-items: center;
+86 -16
View File
@@ -31,15 +31,16 @@
<text class="feed-feeling-text">{{ feelingLabel(feed.feeling) }}</text> <text class="feed-feeling-text">{{ feelingLabel(feed.feeling) }}</text>
</view> </view>
<!-- 图片 --> <!-- 图片:按原图比例自适应高度,极端比例才裁剪 -->
<view class="feed-images" v-if="feed.images && feed.images.length"> <view class="feed-images" v-if="feed.images && feed.images.length">
<image <image
v-for="(img, i) in feed.images" v-for="(img, i) in feed.images"
:key="i" :key="i"
class="feed-img" class="feed-img"
:class="'feed-img-' + Math.min(feed.images.length, 3)"
:src="img" :src="img"
mode="aspectFill" mode="aspectFill"
:style="imgStyle(i)"
@click.stop="previewImage(i)"
></image> ></image>
</view> </view>
@@ -76,9 +77,87 @@ export default {
canDelete: { type: Boolean, default: false } canDelete: { type: Boolean, default: false }
}, },
emits: ['item-click', 'like', 'comment', 'share', 'delete'], emits: ['item-click', 'like', 'comment', 'share', 'delete'],
data() {
return {
// 图片宽高比缓存:url -> width/height
imgRatios: {}
}
},
watch: {
'feed.images': {
handler(list) { this.loadImgMeta(list) },
immediate: true
}
},
methods: { methods: {
getCatIcon, getCatIcon,
isIconPath, isIconPath,
/** 拉取图片真实尺寸,用于按比例计算展示高度 */
loadImgMeta(list) {
if (!list || !list.length) return
list.forEach(url => {
if (!url || this.imgRatios[url] !== undefined) return
uni.getImageInfo({
src: url,
success: res => {
if (res.width && res.height) {
this.imgRatios[url] = res.width / res.height
}
}
})
})
},
/** 每行图片数量:4张用2x2,其余每行最多3张 */
perRow() {
const n = (this.feed.images || []).length
return n === 4 ? 2 : Math.min(n || 1, 3)
},
/** 列宽(rpx):750 - 页面边距64 - 卡片内边距96 - 行间gap */
colWidth() {
const gap = 16
const total = 750 - 64 - 96
const cols = this.perRow()
return (total - gap * (cols - 1)) / cols
},
/** 单张图片的目标高度:按真实比例,夹在上下限内 */
targetHeight(ratio, colW, minH, maxH) {
const r = ratio && ratio > 0 ? ratio : 1
return Math.max(minH, Math.min(colW / r, maxH))
},
/** 同行图片统一高度(取同行目标高度的均值) */
rowHeight(rowIdx) {
const list = this.feed.images || []
const cols = this.perRow()
const colW = this.colWidth()
const single = cols === 1
const minH = single ? 300 : 180
const maxH = single ? 720 : 400
const start = rowIdx * cols
const items = list.slice(start, start + cols)
let sum = 0
items.forEach(url => {
sum += this.targetHeight(this.imgRatios[url], colW, minH, maxH)
})
return Math.round(sum / items.length)
},
/** 点击图片预览大图(支持左右滑动切换) */
previewImage(i) {
const urls = this.feed.images || []
if (!urls.length) return
uni.previewImage({
urls,
current: urls[i]
})
},
imgStyle(i) {
const cols = this.perRow()
const gap = 16
const width = cols === 1 ? '100%' : `calc(${(100 / cols).toFixed(4)}% - ${((gap * (cols - 1)) / cols).toFixed(2)}rpx)`
return {
width,
height: this.rowHeight(Math.floor(i / cols)) + 'rpx'
}
},
feelingLabel(f) { feelingLabel(f) {
const val = String(f).toLowerCase() const val = String(f).toLowerCase()
const map = { const map = {
@@ -211,20 +290,11 @@ export default {
.feed-img { .feed-img {
border-radius: $radius-md; border-radius: $radius-md;
height: 200rpx; display: block;
} /* 比例未获取前的占位尺寸,避免布局跳动 */
width: 33%;
.feed-img-1 { height: 220rpx;
width: 100%; background: $bg-elevated;
height: 320rpx;
}
.feed-img-2 {
width: calc(50% - 8rpx);
}
.feed-img-3 {
width: calc(33.33% - 11rpx);
} }
.feed-actions { .feed-actions {
+3 -3
View File
@@ -4,9 +4,9 @@
"requires": true, "requires": true,
"packages": { "packages": {
"node_modules/HaveADrink": { "node_modules/HaveADrink": {
"version": "1.0.12", "version": "1.0.15",
"resolved": "https://npm.wash-painting.cn/HaveADrink/-/HaveADrink-1.0.12.tgz", "resolved": "https://npm.wash-painting.cn/HaveADrink/-/HaveADrink-1.0.15.tgz",
"integrity": "sha512-B9/2sOCIvMczQzsWA1qqzEOlwKxl9eC5OEqvRgh1dewgsbA4zzOkCQztuJxZOq9Sz5mwzEBM6/bcYdJyGAox3A==", "integrity": "sha512-ZKgrIKuwXXuKRY2D34FRNUwlr1Y4bAUtXwrjpoHwLPa30KNGVvuXhfYvxAWf5cDvTJw3b/lm8JSSYwCqcIZ3Bw==",
"license": "ISC" "license": "ISC"
} }
} }
+62 -1
View File
@@ -14,7 +14,7 @@
* 6. 社交模块 - 朋友圈动态、点赞 * 6. 社交模块 - 朋友圈动态、点赞
**版本:** v1.0.12 **版本:** v1.0.15
## 安装 ## 安装
@@ -1116,6 +1116,34 @@ const req = new UpdatePreferencesReq()
client.UpdatePreferences(req).then(...).catch(...) client.UpdatePreferences(req).then(...).catch(...)
``` ```
### 获取用户基础信息
<font color="green">GET</font> `/api/have_a_drink/v1/user/user/basic_info`
#### 请求参数
|名称|类型|校验规则|说明|
|:-|:-|:-|:-|
|id|`string\|number`|| 用户ID|
#### 返回值
|名称|类型|说明|
|:-|:-:|:-|
|nickname|`string`| 用户昵称<br>|
|avatar|`string`| 头像URL<br>|
```javascript
const req = new GetUserBasicInfoReq()
client.GetUserBasicInfo(req).then(...).catch(...)
```
### 上传文件 ### 上传文件
@@ -1865,6 +1893,35 @@ const req = new CheckInEventReq()
client.CheckInEvent(req).then(...).catch(...) client.CheckInEvent(req).then(...).catch(...)
``` ```
### 酒局发起者审核报名用户
<font color="green">POST</font> `/api/have_a_drink/v1/events/events/approve`
#### 请求参数
|名称|类型|校验规则|说明|
|:-|:-|:-|:-|
|id|`string\|number`|| 酒局ID|
|userId|`string\|number`|| 报名用户ID|
|approve|`ApproveStatus`|待审核: ApproveStatus.Pending = 1<br>已通过: ApproveStatus.Approved = 2<br>已拒绝: ApproveStatus.Rejected = 3| 审核结果|
#### 返回值
|名称|类型|说明|
|:-|:-:|:-|
|ok|`boolean`| 是否成功<br>|
```javascript
const req = new ApproveEventReq()
client.ApproveEvent(req).then(...).catch(...)
```
### 获取会话列表 ### 获取会话列表
@@ -1935,6 +1992,10 @@ client.GetConversations(req).then(...).catch(...)
|conversationId|`string`| 会话ID| |conversationId|`string`| 会话ID|
|senderId|`string\|number`| 发送者ID| |senderId|`string\|number`| 发送者ID|
|receiverId|`string\|number`| 接收者ID| |receiverId|`string\|number`| 接收者ID|
|senderName|`string`| 发送者昵称|
|receiverName|`string`| 接收者昵称|
|senderAvatar|`string`| 发送者头像URL|
|receiverAvatar|`string`| 接收者头像URL|
|type|`MessageType`| 消息类型| |type|`MessageType`| 消息类型|
|content|`string`| 消息内容| |content|`string`| 消息内容|
|timestamp|`number`| 发送时间(毫秒时间戳)| |timestamp|`number`| 发送时间(毫秒时间戳)|
+209 -9
View File
@@ -297,6 +297,27 @@ export declare class EventStatus extends Enum {
static getLabel(v: number|string): string; static getLabel(v: number|string): string;
} }
/**
* 报名审核状态
*/
export declare class ApproveStatus extends Enum {
/**
* 待审核
*/
static Pending: ApproveStatus;
/**
* 已通过
*/
static Approved: ApproveStatus;
/**
* 已拒绝
*/
static Rejected: ApproveStatus;
static entries(): Array<option>;
static getLabel(v: number|string): string;
}
/** /**
* 消息类型(文本/图片) * 消息类型(文本/图片)
*/ */
@@ -2471,6 +2492,87 @@ export declare class UpdatePreferencesResp {
static fromObject(o: Object): UpdatePreferencesResp; static fromObject(o: Object): UpdatePreferencesResp;
} }
/**
* 获取用户基础信息请求
*/
export declare class GetUserBasicInfoReq {
[key: string]: any;
/**
* 用户ID
*/
id: string|number;
/**
* @param id string|number 用户ID
*/
constructor(id: string|number,);
/**
* 从对象创建 GetUserBasicInfoReq
*
* @param o Object
* - id: string|number, // 用户ID
*/
static fromObject(o: Object): GetUserBasicInfoReq;
}
/**
*
*/
export declare class UserBasicInfo {
[key: string]: any;
/**
* 用户昵称
*/
nickname: string;
/**
* 头像URL
*/
avatar: string;
/**
* @param nickname string 用户昵称
* @param avatar string 头像URL
*/
constructor(nickname: string,avatar: string,);
/**
* 从对象创建 UserBasicInfo
*
* @param o Object
* - nickname: string, // 用户昵称
* - avatar: string, // 头像URL
*/
static fromObject(o: Object): UserBasicInfo;
}
/**
* 获取用户基础信息响应
*/
export declare class GetUserBasicInfoResp {
[key: string]: any;
/**
* 用户昵称
*/
nickname: string;
/**
* 头像URL
*/
avatar: string;
/**
* @param nickname string 用户昵称
* @param avatar string 头像URL
*/
constructor(nickname: string,avatar: string,);
/**
* 从对象创建 GetUserBasicInfoResp
*
* @param o Object
* - nickname: string, // 用户昵称
* - avatar: string, // 头像URL
*/
static fromObject(o: Object): GetUserBasicInfoResp;
}
/** /**
* 上传图片参数 * 上传图片参数
*/ */
@@ -4115,6 +4217,64 @@ export declare class CheckInEventResp {
static fromObject(o: Object): CheckInEventResp; static fromObject(o: Object): CheckInEventResp;
} }
/**
*
*/
export declare class ApproveEventReq {
[key: string]: any;
/**
* 酒局ID
*/
id: string|number;
/**
* 报名用户ID
*/
userId: string|number;
/**
* 审核结果
*/
approve: ApproveStatus;
/**
* @param id string|number 酒局ID
* @param userId string|number 报名用户ID
* @param approve ApproveStatus 审核结果
*/
constructor(id: string|number,userId: string|number,approve: ApproveStatus,);
/**
* 从对象创建 ApproveEventReq
*
* @param o Object
* - id: string|number, // 酒局ID
* - userId: string|number, // 报名用户ID
* - approve: ApproveStatus, // 审核结果
*/
static fromObject(o: Object): ApproveEventReq;
}
/**
*
*/
export declare class ApproveEventResp {
[key: string]: any;
/**
* 是否成功
*/
ok: boolean;
/**
* @param ok boolean 是否成功
*/
constructor(ok: boolean,);
/**
* 从对象创建 ApproveEventResp
*
* @param o Object
* - ok: boolean, // 是否成功
*/
static fromObject(o: Object): ApproveEventResp;
}
/** /**
* 会话信息 * 会话信息
*/ */
@@ -4277,6 +4437,22 @@ export declare class Message {
*/ */
receiverId: string|number; receiverId: string|number;
/** /**
* 发送者昵称
*/
senderName: string;
/**
* 接收者昵称
*/
receiverName: string;
/**
* 发送者头像URL
*/
senderAvatar: string;
/**
* 接收者头像URL
*/
receiverAvatar: string;
/**
* 消息类型 * 消息类型
*/ */
type: MessageType; type: MessageType;
@@ -4298,12 +4474,16 @@ export declare class Message {
* @param conversationId string 会话ID * @param conversationId string 会话ID
* @param senderId string|number 发送者ID * @param senderId string|number 发送者ID
* @param receiverId string|number 接收者ID * @param receiverId string|number 接收者ID
* @param senderName string 发送者昵称
* @param receiverName string 接收者昵称
* @param senderAvatar string 发送者头像URL
* @param receiverAvatar string 接收者头像URL
* @param type MessageType 消息类型 * @param type MessageType 消息类型
* @param content string 消息内容 * @param content string 消息内容
* @param timestamp number 发送时间(毫秒时间戳) * @param timestamp number 发送时间(毫秒时间戳)
* @param status MessageStatus 消息状态 * @param status MessageStatus 消息状态
*/ */
constructor(id: string|number,conversationId: string,senderId: string|number,receiverId: string|number,type: MessageType,content: string,timestamp: number,status: MessageStatus,); constructor(id: string|number,conversationId: string,senderId: string|number,receiverId: string|number,senderName: string,receiverName: string,senderAvatar: string,receiverAvatar: string,type: MessageType,content: string,timestamp: number,status: MessageStatus,);
/** /**
* 从对象创建 Message * 从对象创建 Message
* *
@@ -4312,6 +4492,10 @@ export declare class Message {
* - conversationId: string, // 会话ID * - conversationId: string, // 会话ID
* - senderId: string|number, // 发送者ID * - senderId: string|number, // 发送者ID
* - receiverId: string|number, // 接收者ID * - receiverId: string|number, // 接收者ID
* - senderName: string, // 发送者昵称
* - receiverName: string, // 接收者昵称
* - senderAvatar: string, // 发送者头像URL
* - receiverAvatar: string, // 接收者头像URL
* - type: MessageType, // 消息类型 * - type: MessageType, // 消息类型
* - content: string, // 消息内容 * - content: string, // 消息内容
* - timestamp: number, // 发送时间(毫秒时间戳) * - timestamp: number, // 发送时间(毫秒时间戳)
@@ -4455,15 +4639,15 @@ export declare class ClientChatData {
/** /**
* 客户端消息ID * 客户端消息ID
*/ */
clientMesgId: string|number; clientMesgId: string;
/** /**
* @param receiverId string|number 接收者ID * @param receiverId string|number 接收者ID
* @param content string 消息内容 * @param content string 消息内容
* @param msgType MessageType 消息类型 * @param msgType MessageType 消息类型
* @param clientMesgId string|number 客户端消息ID * @param clientMesgId string 客户端消息ID
*/ */
constructor(receiverId: string|number,content: string,msgType: MessageType,clientMesgId: string|number,); constructor(receiverId: string|number,content: string,msgType: MessageType,clientMesgId: string,);
/** /**
* 从对象创建 ClientChatData * 从对象创建 ClientChatData
* *
@@ -4471,7 +4655,7 @@ export declare class ClientChatData {
* - receiverId: string|number, // 接收者ID * - receiverId: string|number, // 接收者ID
* - content: string, // 消息内容 * - content: string, // 消息内容
* - msgType: MessageType, // 消息类型 * - msgType: MessageType, // 消息类型
* - clientMesgId: string|number, // 客户端消息ID * - clientMesgId: string, // 客户端消息ID
*/ */
static fromObject(o: Object): ClientChatData; static fromObject(o: Object): ClientChatData;
} }
@@ -4664,7 +4848,7 @@ export declare class ServerAckData {
/** /**
* 客户端消息ID * 客户端消息ID
*/ */
clientMsgId: string|number; clientMsgId: string;
/** /**
* 消息ID * 消息ID
*/ */
@@ -4675,16 +4859,16 @@ export declare class ServerAckData {
status: MessageStatus; status: MessageStatus;
/** /**
* @param clientMsgId string|number 客户端消息ID * @param clientMsgId string 客户端消息ID
* @param msgId string|number 消息ID * @param msgId string|number 消息ID
* @param status MessageStatus 消息状态 * @param status MessageStatus 消息状态
*/ */
constructor(clientMsgId: string|number,msgId: string|number,status: MessageStatus,); constructor(clientMsgId: string,msgId: string|number,status: MessageStatus,);
/** /**
* 从对象创建 ServerAckData * 从对象创建 ServerAckData
* *
* @param o Object * @param o Object
* - clientMsgId: string|number, // 客户端消息ID * - clientMsgId: string, // 客户端消息ID
* - msgId: string|number, // 消息ID * - msgId: string|number, // 消息ID
* - status: MessageStatus, // 消息状态 * - status: MessageStatus, // 消息状态
*/ */
@@ -5027,6 +5211,14 @@ export default class HaveADrink {
*/ */
public UpdatePreferences(req: UpdatePreferencesReq) : Promise<UpdatePreferencesResp>; public UpdatePreferences(req: UpdatePreferencesReq) : Promise<UpdatePreferencesResp>;
/**
* 获取用户基础信息
*
* @param req GetUserBasicInfoReq 获取用户基础信息请求
* @return GetUserBasicInfoResp 获取用户基础信息响应
*/
public GetUserBasicInfo(req: GetUserBasicInfoReq) : Promise<GetUserBasicInfoResp>;
/** /**
* 上传文件 * 上传文件
* *
@@ -5195,6 +5387,14 @@ export default class HaveADrink {
*/ */
public CheckInEvent(req: CheckInEventReq) : Promise<CheckInEventResp>; public CheckInEvent(req: CheckInEventReq) : Promise<CheckInEventResp>;
/**
* 酒局发起者审核报名用户
*
* @param req ApproveEventReq
* @return ApproveEventResp
*/
public ApproveEvent(req: ApproveEventReq) : Promise<ApproveEventResp>;
/** /**
* 获取会话列表 * 获取会话列表
* *
+197 -4
View File
@@ -436,6 +436,39 @@ export class EventStatus {
} }
} }
/**
* 报名审核状态
*/
export class ApproveStatus {
/**
* 待审核
*/
static Pending = 1;
/**
* 已通过
*/
static Approved = 2;
/**
* 已拒绝
*/
static Rejected = 3;
static entries() {
return [
{ value: ApproveStatus.Pending, label: '待审核' },
{ value: ApproveStatus.Approved, label: '已通过' },
{ value: ApproveStatus.Rejected, label: '已拒绝' },
];
}
static getLabel(v) {
switch(v) {
case ApproveStatus.Pending: return '待审核';
case ApproveStatus.Approved: return '已通过';
case ApproveStatus.Rejected: return '已拒绝';
}
}
}
/** /**
* 消息类型(文本/图片) * 消息类型(文本/图片)
*/ */
@@ -1779,6 +1812,58 @@ export class UpdatePreferencesResp {
} }
} }
/**
* 获取用户基础信息请求
*/
export class GetUserBasicInfoReq {
/**
* @param id: string|number 用户ID
*/
constructor(id,) {
this.id = id;
}
static fromObject(o) {
return new GetUserBasicInfoReq(o.id,);
}
}
/**
*
*/
export class UserBasicInfo {
/**
* @param nickname: string 用户昵称
* @param avatar: string 头像URL
*/
constructor(nickname,avatar,) {
this.nickname = nickname;
this.avatar = avatar;
}
static fromObject(o) {
return new UserBasicInfo(o.nickname,o.avatar,);
}
}
/**
* 获取用户基础信息响应
*/
export class GetUserBasicInfoResp {
/**
* @param nickname: string 用户昵称
* @param avatar: string 头像URL
*/
constructor(nickname,avatar,) {
this.nickname = nickname;
this.avatar = avatar;
}
static fromObject(o) {
return new GetUserBasicInfoResp(o.nickname,o.avatar,);
}
}
/** /**
* 上传图片参数 * 上传图片参数
*/ */
@@ -2777,6 +2862,42 @@ export class CheckInEventResp {
} }
} }
/**
*
*/
export class ApproveEventReq {
/**
* @param id: string|number 酒局ID
* @param userId: string|number 报名用户ID
* @param approve: ApproveStatus 审核结果
*/
constructor(id,userId,approve,) {
this.id = id;
this.userId = userId;
this.approve = approve;
}
static fromObject(o) {
return new ApproveEventReq(o.id,o.userId,o.approve,);
}
}
/**
*
*/
export class ApproveEventResp {
/**
* @param ok: boolean 是否成功
*/
constructor(ok,) {
this.ok = ok;
}
static fromObject(o) {
return new ApproveEventResp(o.ok,);
}
}
/** /**
* 会话信息 * 会话信息
*/ */
@@ -2866,16 +2987,24 @@ export class Message {
* @param conversationId: string 会话ID * @param conversationId: string 会话ID
* @param senderId: string|number 发送者ID * @param senderId: string|number 发送者ID
* @param receiverId: string|number 接收者ID * @param receiverId: string|number 接收者ID
* @param senderName: string 发送者昵称
* @param receiverName: string 接收者昵称
* @param senderAvatar: string 发送者头像URL
* @param receiverAvatar: string 接收者头像URL
* @param type: MessageType 消息类型 * @param type: MessageType 消息类型
* @param content: string 消息内容 * @param content: string 消息内容
* @param timestamp: number 发送时间(毫秒时间戳) * @param timestamp: number 发送时间(毫秒时间戳)
* @param status: MessageStatus 消息状态 * @param status: MessageStatus 消息状态
*/ */
constructor(id,conversationId,senderId,receiverId,type,content,timestamp,status,) { constructor(id,conversationId,senderId,receiverId,senderName,receiverName,senderAvatar,receiverAvatar,type,content,timestamp,status,) {
this.id = id; this.id = id;
this.conversationId = conversationId; this.conversationId = conversationId;
this.senderId = senderId; this.senderId = senderId;
this.receiverId = receiverId; this.receiverId = receiverId;
this.senderName = senderName;
this.receiverName = receiverName;
this.senderAvatar = senderAvatar;
this.receiverAvatar = receiverAvatar;
this.type = type; this.type = type;
this.content = content; this.content = content;
this.timestamp = timestamp; this.timestamp = timestamp;
@@ -2883,7 +3012,7 @@ export class Message {
} }
static fromObject(o) { static fromObject(o) {
return new Message(o.id,o.conversationId,o.senderId,o.receiverId,o.type,o.content,o.timestamp,o.status,); return new Message(o.id,o.conversationId,o.senderId,o.receiverId,o.senderName,o.receiverName,o.senderAvatar,o.receiverAvatar,o.type,o.content,o.timestamp,o.status,);
} }
} }
@@ -2975,7 +3104,7 @@ export class ClientChatData {
* @param receiverId: string|number 接收者ID * @param receiverId: string|number 接收者ID
* @param content: string 消息内容 * @param content: string 消息内容
* @param msgType: MessageType 消息类型 * @param msgType: MessageType 消息类型
* @param clientMesgId: string|number 客户端消息ID * @param clientMesgId: string 客户端消息ID
*/ */
constructor(receiverId,content,msgType,clientMesgId,) { constructor(receiverId,content,msgType,clientMesgId,) {
this.receiverId = receiverId; this.receiverId = receiverId;
@@ -3104,7 +3233,7 @@ export class ServerReadData {
*/ */
export class ServerAckData { export class ServerAckData {
/** /**
* @param clientMsgId: string|number 客户端消息ID * @param clientMsgId: string 客户端消息ID
* @param msgId: string|number 消息ID * @param msgId: string|number 消息ID
* @param status: MessageStatus 消息状态 * @param status: MessageStatus 消息状态
*/ */
@@ -3991,6 +4120,38 @@ export default class HaveADrink {
}); });
} }
/**
* 获取用户基础信息
*/
GetUserBasicInfo(req) {
return new Promise((reslove, reject)=>{
let data = req;
let url = `${this.host}/api/have_a_drink/v1/user/user/basic_info`;
const query = Object.keys(data).map((x)=>(`${x}=${data[x]}`)).join('&');
this.http_request(url, {
uri: '/api/have_a_drink/v1/user/user/basic_info',
method: 'GET',
query: query,
data: data,
responseType: 'json',
headers: {
},
}).then((data)=>{
if (data.hasOwnProperty("fail")) {
if (data.fail) {
reject(data.msg);
} else {
reslove(data.data);
}
} else {
reslove(data);
}
}).catch((err)=>reject(err));
});
}
/** /**
* 上传文件 * 上传文件
*/ */
@@ -4659,6 +4820,38 @@ export default class HaveADrink {
}); });
} }
/**
* 酒局发起者审核报名用户
*/
ApproveEvent(req) {
return new Promise((reslove, reject)=>{
let data = req;
let url = `${this.host}/api/have_a_drink/v1/events/events/approve`;
this.http_request(url, {
uri: '/api/have_a_drink/v1/events/events/approve',
method: 'POST',
data: data,
responseType: 'json',
headers: {
'Content-Type': 'application/json',
},
}).then((data)=>{
if (data.hasOwnProperty("fail")) {
if (data.fail) {
reject(data.msg);
} else {
reslove(data.data);
}
} else {
reslove(data);
}
}).catch((err)=>reject(err));
});
}
/** /**
* 获取会话列表 * 获取会话列表
*/ */
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "HaveADrink", "name": "HaveADrink",
"type": "module", "type": "module",
"version": "v1.0.12", "version": "v1.0.15",
"description": "喝酒了么 API 服务", "description": "喝酒了么 API 服务",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+4 -4
View File
@@ -5,13 +5,13 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"HaveADrink": "^1.0.12" "HaveADrink": "^1.0.15"
} }
}, },
"node_modules/HaveADrink": { "node_modules/HaveADrink": {
"version": "1.0.12", "version": "1.0.15",
"resolved": "https://npm.wash-painting.cn/HaveADrink/-/HaveADrink-1.0.12.tgz", "resolved": "https://npm.wash-painting.cn/HaveADrink/-/HaveADrink-1.0.15.tgz",
"integrity": "sha512-B9/2sOCIvMczQzsWA1qqzEOlwKxl9eC5OEqvRgh1dewgsbA4zzOkCQztuJxZOq9Sz5mwzEBM6/bcYdJyGAox3A==", "integrity": "sha512-ZKgrIKuwXXuKRY2D34FRNUwlr1Y4bAUtXwrjpoHwLPa30KNGVvuXhfYvxAWf5cDvTJw3b/lm8JSSYwCqcIZ3Bw==",
"license": "ISC" "license": "ISC"
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"dependencies": { "dependencies": {
"HaveADrink": "^1.0.12" "HaveADrink": "^1.0.15"
} }
} }
+7
View File
@@ -117,6 +117,13 @@
"backgroundColorTop": "#0B0B14", "backgroundColorTop": "#0B0B14",
"backgroundColorBottom": "#0B0B14" "backgroundColorBottom": "#0B0B14"
} }
},
{
"path": "pages/agreement/agreement",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
}
} }
], ],
"globalStyle": { "globalStyle": {
+290
View File
@@ -0,0 +1,290 @@
<template>
<view :class="themeClass" class="page-container agreement-page">
<!-- 自定义导航栏(避让胶囊) -->
<view class="agree-nav" :style="{ paddingTop: navPaddingTop }">
<view class="agree-nav-row" :style="{ paddingRight: navPaddingRight }">
<view class="agree-back" @click="goBack">
<text class="agree-back-icon">‹</text>
</view>
<text class="agree-nav-title">{{ title }}</text>
<view class="agree-back agree-back-ph"></view>
</view>
</view>
<scroll-view class="agree-scroll" scroll-y :show-scrollbar="false">
<view class="agree-content">
<text class="agree-update">更新日期:2026年8月1日 生效日期:2026年8月1日</text>
<view v-for="(sec, i) in sections" :key="i" class="agree-section">
<text class="agree-section-title">{{ sec.title }}</text>
<text v-for="(p, j) in sec.paras" :key="j" class="agree-p">{{ p }}</text>
</view>
<view class="agree-footer">
<text class="agree-footer-text">碰盏日记团队</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
const sysInfo = uni.getSystemInfoSync()
return {
type: 'user',
navPaddingTop: menuBtn.top + 'px',
navPaddingRight: (sysInfo.windowWidth - menuBtn.left + 8) + 'px',
sections: []
}
},
computed: {
title() {
return this.type === 'privacy' ? '隐私政策' : '用户协议'
}
},
onLoad(options) {
this.type = options && options.type === 'privacy' ? 'privacy' : 'user'
this.sections = this.type === 'privacy' ? this.privacySections() : this.userSections()
uni.setNavigationBarTitle({ title: this.title })
},
methods: {
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.switchTab({ url: '/pages/index/index' })
}
},
userSections() {
return [
{
title: '一、服务说明',
paras: [
'「碰盏日记」是一款个人饮酒记录与生活管理工具,为用户提供饮酒打卡、日历回顾、统计成就、酒友圈动态、私聊互动等功能。',
'本小程序仅提供记录与社交工具,不从事任何酒类的销售、推广或推荐行为,亦不接受任何形式的酒类广告投放。',
'我们倡导理性饮酒。过量饮酒有害健康,请您在记录的同时关注自身身体状况,未成年人请勿饮酒。'
]
},
{
title: '二、账号注册与使用',
paras: [
'您通过微信授权登录并填写昵称、头像完成注册。您应保证所填资料真实、合法,不使用他人身份或侵权内容。',
'您应妥善保管自己的账号。因您个人原因导致的账号信息泄露,由您自行承担相应责任。',
'本小程序面向年满18周岁的成年人提供服务。若您未满18周岁,请勿注册和使用。'
]
},
{
title: '三、用户行为规范',
paras: [
'您在酒友圈发布的动态、评论、图片及私聊内容,应遵守国家法律法规,不得包含色情、暴力、赌博、欺诈、侵权或其他违法和不良信息。',
'您不得发布诱导饮酒、劝酒、拼酒等可能对他人造成伤害的内容,不得向未成年人传播任何饮酒相关信息。',
'您不得利用本小程序从事任何干扰服务正常运行、侵害他人合法权益的行为。若违反上述约定,我们有权对相关内容进行处理,并视情况暂停或终止您的账号。'
]
},
{
title: '四、知识产权',
paras: [
'本小程序的软件、界面设计、图标、文案等知识产权归开发者所有,未经许可不得复制、传播或用于商业用途。',
'您发布的内容(文字、图片等)著作权归您所有。您同意授予我们在提供服务所必需的范围内(如向您的酒友展示动态)使用上述内容。'
]
},
{
title: '五、免责声明',
paras: [
'本小程序记录的饮酒数据仅作为个人生活记录参考,不构成任何医疗、健康或营养建议。如您有饮酒相关的健康困扰,请咨询专业医疗机构。',
'因不可抗力、网络故障、微信平台服务调整等非我们可控因素导致的服务中断或数据延迟,我们不承担责任,但会尽力恢复服务。',
'请您自行备份重要记录。因您主动删除或账号注销导致的数据丢失,由您自行承担。'
]
},
{
title: '六、协议的变更与终止',
paras: [
'我们可能根据法律法规变化或功能调整修订本协议,修订后将在小程序内公示。若您继续使用,视为接受修订后的协议。',
'您可随时停止使用本小程序。我们也可在法律法规允许的范围内终止提供服务。'
]
},
{
title: '七、其他',
paras: [
'本协议的订立、执行与解释均适用中华人民共和国法律。因本协议产生的争议,双方应友好协商解决。',
'如您对本协议有任何疑问,可通过小程序内的意见反馈渠道与我们联系。'
]
}
]
},
privacySections() {
return [
{
title: '引言',
paras: [
'「碰盏日记」(以下简称"我们")深知个人信息对您的重要性,将严格遵守法律法规,审慎处理您的个人信息。本政策将说明我们如何收集、使用、存储和保护您的信息,请您仔细阅读。'
]
},
{
title: '一、我们收集的信息',
paras: [
'1. 注册资料:您主动填写的昵称、头像,用于展示您的个人身份。',
'2. 打卡记录:您记录的日期、饮酒/戒酒状态、酒水种类与用量、配餐、感受、酒言酒语等,用于日历展示、统计与成就功能。',
'3. 图片信息:您主动上传的饮酒照片,仅随对应记录存储展示。',
'4. 位置信息:仅在您创建酒局并主动选择地点时,经您授权获取位置用于酒局展示;我们不会持续追踪您的位置。',
'5. 社交信息:您添加酒友、发布动态、发送私信时产生的关系与内容数据。',
'6. 设备与日志信息:微信登录产生的必要标识(如 openid),用于识别您的账号身份,我们不会获取您的微信号、手机号等额外信息。'
]
},
{
title: '二、我们如何使用信息',
paras: [
'您的信息仅用于提供本小程序的核心功能:记录打卡、日历回顾、统计成就、酒友圈展示与私信互动。',
'动态可见范围由您控制:设为"仅酒友可见"的内容只有与您互加酒友的用户可见。',
'我们不会将您的信息用于酒类营销、广告推送或任何商业推广。'
]
},
{
title: '三、信息的共享与披露',
paras: [
'我们不会向任何第三方出售您的个人信息。',
'除以下情形外,我们不会向第三方提供您的信息:(1)经您明确同意;(2)根据法律法规或主管部门要求;(3)为保护用户或公众的人身财产安全所必需。',
'分享名片功能会生成您主动选择的公开信息(昵称、统计数字等),是否分享由您自行决定。'
]
},
{
title: '四、信息的存储与保护',
paras: [
'您的数据存储于服务器,传输过程采用加密方式。我们采取访问控制、安全审计等措施保护数据安全。',
'我们仅在实现服务目的所必需的期限内保存您的信息。账号注销后,我们将删除或匿名化处理您的个人信息,法律法规另有规定的除外。'
]
},
{
title: '五、您的权利',
paras: [
'您可以随时查看、修改自己的资料与记录;可以删除任意一条打卡记录或动态。',
'您可以删除与酒友的关系、撤回不当内容。',
'如需注销账号或删除全部数据,请通过小程序内的反馈渠道联系我们,我们将在核实身份后15个工作日内处理。'
]
},
{
title: '六、未成年人保护',
paras: [
'本小程序不面向未成年人提供服务。若我们发现误收了未成年人的个人信息,将及时删除。'
]
},
{
title: '七、政策更新与联系我们',
paras: [
'本政策可能随功能调整而更新,更新后将在小程序内公示。',
'如您对本政策或个人信息处理有任何疑问、投诉,可通过小程序内的意见反馈渠道与我们联系,我们将尽快答复。'
]
}
]
}
}
}
</script>
<style lang="scss" scoped>
.agreement-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.agree-nav {
background: $bg-base;
position: relative;
z-index: 10;
padding-left: $sp-lg;
padding-right: $sp-lg;
}
.agree-nav-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.agree-back {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: $bg-card;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
display: flex;
align-items: center;
justify-content: center;
&:active {
background: $bg-card-alt;
}
}
.agree-back-ph {
visibility: hidden;
}
.agree-back-icon {
font-size: 48rpx;
color: $text-primary;
line-height: 1;
margin-top: -6rpx;
}
.agree-nav-title {
font-size: $fs-lg;
font-weight: $fw-bold;
color: $text-primary;
}
.agree-scroll {
flex: 1;
height: 0;
}
.agree-content {
padding: $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + 64rpx);
}
.agree-update {
display: block;
font-size: $fs-xs;
color: $text-tertiary;
margin-bottom: $sp-lg;
}
.agree-section {
margin-bottom: $sp-xl;
}
.agree-section-title {
display: block;
font-size: $fs-md;
font-weight: $fw-bold;
color: $text-primary;
margin-bottom: $sp-sm;
}
.agree-p {
display: block;
font-size: $fs-sm;
color: $text-secondary;
line-height: $lh-loose;
margin-bottom: $sp-sm;
}
.agree-footer {
padding: $sp-xl 0 $sp-lg;
text-align: center;
}
.agree-footer-text {
font-size: $fs-xs;
color: $text-tertiary;
}
</style>
+20 -2
View File
@@ -131,6 +131,7 @@ export default {
headerPaddingTop: (menuBtn.top + 8) + 'px', headerPaddingTop: (menuBtn.top + 8) + 'px',
friendId: '', friendId: '',
nickname: '', nickname: '',
myUserId: '',
conversationId: '', conversationId: '',
messages: [], messages: [],
inputText: '', inputText: '',
@@ -148,6 +149,11 @@ export default {
this.friendId = options.friendId || '' this.friendId = options.friendId || ''
this.nickname = decodeURIComponent(options.nickname || '') this.nickname = decodeURIComponent(options.nickname || '')
this.conversationId = options.conversationId || '' this.conversationId = options.conversationId || ''
// 读取当前用户ID(与 circle/profile 页同源:user_info.id),用于区分消息发送方
try {
const user = JSON.parse(uni.getStorageSync('user_info') || '{}')
this.myUserId = user.id !== undefined && user.id !== null ? String(user.id) : ''
} catch (e) { this.myUserId = '' }
this.initChat() this.initChat()
// 监听键盘高度变化,动态调整输入栏位置(避免键盘顶起整个页面) // 监听键盘高度变化,动态调整输入栏位置(避免键盘顶起整个页面)
uni.onKeyboardHeightChange(res => { uni.onKeyboardHeightChange(res => {
@@ -308,7 +314,7 @@ export default {
const msg = { const msg = {
id: clientMsgId, id: clientMsgId,
conversationId: this.conversationId, conversationId: this.conversationId,
senderId: wsManager.userId || 'self', senderId: this.myUserId || wsManager.userId || 'self',
receiverId: this.friendId, receiverId: this.friendId,
type: 'text', type: 'text',
content: text, content: text,
@@ -370,8 +376,20 @@ export default {
}, },
// === 辅助方法 === // === 辅助方法 ===
/**
* 判断消息是否自己发的:直接靠 senderId/receiverId 与当前用户ID(user_info.id)对比。
* 不能用 wsManager.userId:WS 连接成功前为空,会导致历史消息全部误判。
*/
isSelf(msg) { isSelf(msg) {
return String(msg.senderId) === String(wsManager.userId) || msg.senderId === 'self' const myId = this.myUserId || (wsManager.userId ? String(wsManager.userId) : '')
if (myId) {
if (String(msg.senderId) === myId) return true
if (String(msg.receiverId) === myId) return false
}
// 兜底(无当前用户ID时):会话内发送者只会是双方之一
if (msg.senderId === 'self') return true
if (this.friendId && String(msg.senderId) === String(this.friendId)) return false
return true
}, },
showTimeDivider(idx) { showTimeDivider(idx) {
+37 -100
View File
@@ -4,15 +4,20 @@
<view class="circle-header" :style="{ paddingTop: headerPaddingTop }"> <view class="circle-header" :style="{ paddingTop: headerPaddingTop }">
<view class="circle-title-row" :style="{ paddingRight: headerPaddingRight }"> <view class="circle-title-row" :style="{ paddingRight: headerPaddingRight }">
<text class="circle-title">酒友圈</text> <text class="circle-title">酒友圈</text>
<view class="msg-entry" @click="goChatList"> <!-- 发布入口:标题栏右侧胶囊按钮,替代悬浮 FAB 彻底避免遮挡 -->
<view class="publish-btn" @click="goPublish">
<text class="publish-btn-icon">✏️</text>
<text class="publish-btn-text">发布</text>
</view>
<!-- <view class="msg-entry" @click="goChatList">
<text class="msg-entry-icon">💬</text> <text class="msg-entry-icon">💬</text>
<view v-if="unreadTotal > 0" class="msg-badge"> <view v-if="unreadTotal > 0" class="msg-badge">
<text class="msg-badge-text">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</text> <text class="msg-badge-text">{{ unreadTotal > 99 ? '99+' : unreadTotal }}</text>
</view> -->
<!-- </view> -->
</view> </view>
</view> <!-- Tab 切换(暂只保留动态) -->
</view> <!-- <view class="circle-tabs">
<!-- Tab 切换 -->
<view class="circle-tabs">
<view <view
v-for="(tab, i) in tabs" v-for="(tab, i) in tabs"
:key="i" :key="i"
@@ -23,12 +28,13 @@
<text class="circle-tab-text">{{ tab }}</text> <text class="circle-tab-text">{{ tab }}</text>
<view v-if="currentTab === i" class="circle-tab-line"></view> <view v-if="currentTab === i" class="circle-tab-line"></view>
</view> </view>
</view> </view> -->
</view> </view>
<!-- 动态 Tab --> <!-- 动态 Tab -->
<!-- v-if="currentTab === 0" -->
<scroll-view <scroll-view
v-if="currentTab === 0"
class="circle-scroll" class="circle-scroll"
scroll-y scroll-y
:refresher-enabled="true" :refresher-enabled="true"
@@ -64,71 +70,6 @@
/> />
</view> </view>
</scroll-view> </scroll-view>
<!-- 酒友 Tab -->
<scroll-view v-if="currentTab === 1" class="circle-scroll" scroll-y>
<view class="circle-content">
<!-- 好友请求提醒 -->
<view v-if="friendRequests.length" class="request-banner" @click="goFriends">
<text class="request-icon">🔔</text>
<text class="request-text">{{ friendRequests.length }} 条新的好友请求</text>
<text class="request-arrow">›</text>
</view>
<!-- 酒友管理入口(有好友时也可进入:处理请求/删除酒友) -->
<view v-if="friends.length && !friendRequests.length" class="friends-entry" @click="goFriends">
<text class="friends-entry-icon">👥</text>
<text class="friends-entry-text">管理酒友 · 处理好友请求</text>
<text class="friends-entry-arrow">›</text>
</view>
<FriendItem
v-for="f in friends"
:key="f.id"
:friend="f"
@item-click="goChat(f)"
/>
<!-- 有好友时也保留邀请入口,直接触发带邀请码的转发 -->
<button v-if="friends.length" class="invite-btn" open-type="share" @click="prepareInviteShare">
<text class="invite-btn-icon">👥</text>
<text class="invite-btn-text">邀请更多酒友</text>
</button>
<EmptyState
v-if="!friends.length"
icon="👥"
title="还没有酒友"
desc="邀请好友一起记录饮酒生活"
actionText="邀请好友"
@action="goFriends"
/>
</view>
</scroll-view>
<!-- 酒局 Tab -->
<scroll-view v-if="currentTab === 2" class="circle-scroll" scroll-y>
<view class="circle-content">
<EventCard
v-for="evt in events"
:key="evt.id"
:event="evt"
:can-manage="evt.isOrganizer === true"
@edit="goEditEvent"
@delete="confirmDeleteEvent"
@item-click="goEventDetail"
/>
<EmptyState
v-if="!events.length"
icon="🎉"
title="暂无酒局"
desc="发起一场酒局,约上酒友一起喝"
actionText="发起酒局"
@action="goCreateEvent"
/>
</view>
</scroll-view>
<!-- FAB 发布按钮 -->
<view class="fab" @click="handleFab">
<text class="fab-icon">{{ currentTab === 2 ? '🎉' : '✏️' }}</text>
</view>
</view> </view>
</template> </template>
@@ -149,7 +90,7 @@ export default {
const menuBtn = uni.getMenuButtonBoundingClientRect() const menuBtn = uni.getMenuButtonBoundingClientRect()
const sysInfo = uni.getSystemInfoSync() const sysInfo = uni.getSystemInfoSync()
return { return {
tabs: ['动态', '酒友', '酒局'], tabs: ['动态'],
currentTab: 0, currentTab: 0,
headerPaddingTop: (menuBtn.top + 8) + 'px', headerPaddingTop: (menuBtn.top + 8) + 'px',
headerPaddingRight: (sysInfo.windowWidth - menuBtn.left + 8) + 'px', headerPaddingRight: (sysInfo.windowWidth - menuBtn.left + 8) + 'px',
@@ -178,17 +119,14 @@ export default {
}, },
onShow() { onShow() {
this.loadFeeds(true) this.loadFeeds(true)
this.loadFriends() // 消息入口已隐藏,未读角标暂不展示,空转请求先停掉(恢复入口时打开)
this.loadEvents() // this.loadUnread()
this.loadUnread() // this.bindWsEvents()
this.bindWsEvents()
// 读取当前用户ID(删除自己的动态时用) // 读取当前用户ID(删除自己的动态时用)
try { try {
const user = JSON.parse(uni.getStorageSync('user_info') || '{}') const user = JSON.parse(uni.getStorageSync('user_info') || '{}')
this.myUserId = user.id !== undefined && user.id !== null ? String(user.id) : '' this.myUserId = user.id !== undefined && user.id !== null ? String(user.id) : ''
} catch (e) { this.myUserId = '' } } catch (e) { this.myUserId = '' }
// 预生成邀请码(仅首次/用完后生成,避免每次进页都创建无效邀请)
this.ensureInviteCode()
// 页面显示时确保 WS 已连接(登录后/断线后兼容) // 页面显示时确保 WS 已连接(登录后/断线后兼容)
wsManager.connect() wsManager.connect()
}, },
@@ -406,12 +344,8 @@ export default {
}, },
// === FAB === // === FAB ===
handleFab() { handleFab() {
if (this.currentTab === 2) {
this.goCreateEvent()
} else {
this.goPublish() this.goPublish()
} }
}
}, },
onShareAppMessage() { onShareAppMessage() {
// 邀请好友分享:链接携带一次性邀请码,好友点开后自动接受邀请 // 邀请好友分享:链接携带一次性邀请码,好友点开后自动接受邀请
@@ -470,7 +404,6 @@ export default {
.circle-header { .circle-header {
padding-left: $sp-lg; padding-left: $sp-lg;
padding-right: $sp-lg; padding-right: $sp-lg;
padding-bottom: $sp-md;
background: $bg-base; background: $bg-base;
position: relative; position: relative;
z-index: 10; z-index: 10;
@@ -569,7 +502,7 @@ export default {
.circle-content { .circle-content {
padding: $sp-md $sp-lg; padding: $sp-md $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + 140rpx); padding-bottom: calc(env(safe-area-inset-bottom) + 48rpx);
} }
.circle-loading, .circle-loading,
@@ -684,27 +617,31 @@ export default {
font-weight: $fw-bold; font-weight: $fw-bold;
} }
/* FAB */ /* 发布入口:标题栏右侧胶囊按钮(替代悬浮 FAB,不遮挡任何内容) */
.fab { .publish-btn {
position: fixed;
right: $sp-xl;
bottom: calc(env(safe-area-inset-bottom) + 180rpx);
width: 108rpx;
height: 108rpx;
border-radius: 50%;
background: linear-gradient(135deg, $amber, $amber-deep);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 6rpx;
box-shadow: $shadow-amber; height: 60rpx;
z-index: 50; padding: 0 24rpx;
border-radius: $radius-full;
background: rgba(232,168,56,0.12);
border: 1rpx solid rgba(232,168,56,0.30);
transition: transform $duration-fast $ease-out;
&:active { &:active {
transform: scale(0.9); background: rgba(232,168,56,0.20);
transform: scale(0.94);
} }
} }
.fab-icon { .publish-btn-icon {
font-size: 44rpx; font-size: 26rpx;
}
.publish-btn-text {
font-size: $fs-sm;
color: $amber;
font-weight: $fw-bold;
} }
</style> </style>
+167 -16
View File
@@ -80,11 +80,32 @@
</view> </view>
</view> </view>
<!-- 参与者 --> <!-- 待审核报名(发起人可见,可同意/拒绝) -->
<view class="evt-participants" v-if="event.participants && event.participants.length"> <view class="evt-pending" v-if="event.isOrganizer && pendingParticipants.length">
<text class="evt-section-label">已报名 ({{ event.participants.length }})</text> <text class="evt-section-label">待审核报名 ({{ pendingParticipants.length }})</text>
<view class="pending-item" v-for="p in pendingParticipants" :key="p.id">
<view class="pending-user">
<view class="evt-person-avatar">
<text class="evt-person-text">{{ p.nickname[0] }}</text>
</view>
<text class="pending-name">{{ p.nickname }}</text>
</view>
<view class="pending-actions">
<view class="pending-btn pending-approve" @click="handleApprove(p, 2)">
<text class="pending-btn-text">✓ 同意</text>
</view>
<view class="pending-btn pending-reject" @click="handleApprove(p, 3)">
<text class="pending-btn-text">✕ 拒绝</text>
</view>
</view>
</view>
</view>
<!-- 参与者(已通过审核) -->
<view class="evt-participants" v-if="approvedParticipants.length">
<text class="evt-section-label">已报名 ({{ approvedParticipants.length }})</text>
<view class="evt-people-grid"> <view class="evt-people-grid">
<view class="evt-person" v-for="p in event.participants" :key="p.id"> <view class="evt-person" v-for="p in approvedParticipants" :key="p.id">
<view class="evt-person-avatar"> <view class="evt-person-avatar">
<text class="evt-person-text">{{ p.nickname[0] }}</text> <text class="evt-person-text">{{ p.nickname[0] }}</text>
</view> </view>
@@ -97,21 +118,33 @@
<!-- 底部操作栏 --> <!-- 底部操作栏 -->
<view class="evt-action-bar" v-if="event"> <view class="evt-action-bar" v-if="event">
<button <button
v-if="canJoin && !event.isJoined && !event.isOrganizer" v-if="canJoin && !event.isOrganizer && (!event.isJoined || myApprove === 3)"
class="evt-btn evt-btn-primary" class="evt-btn evt-btn-primary"
@click="handleJoin" @click="handleJoin"
> >
报名参加 {{ myApprove === 3 ? '重新报名' : '报名参加' }}
</button> </button>
<view
v-if="event.isJoined && myApprove === 1 && !event.isOrganizer"
class="evt-ended-tip"
>
<text class="evt-ended-text">⏳ 已提交报名,等待发起人审核</text>
</view>
<view
v-if="event.isJoined && myApprove === 3 && !event.isOrganizer"
class="evt-ended-tip"
>
<text class="evt-ended-text">报名未通过,可重新申请</text>
</view>
<button <button
v-if="canJoin && event.isJoined && !event.isOrganizer" v-if="canJoin && event.isJoined && myApprove !== 3 && !event.isOrganizer"
class="evt-btn evt-btn-ghost" class="evt-btn evt-btn-ghost"
@click="handleQuit" @click="handleQuit"
> >
取消报名 取消报名
</button> </button>
<button <button
v-if="statusKey === 'ongoing' && event.isJoined" v-if="statusKey === 'ongoing' && event.isJoined && myApprove === 2"
class="evt-btn evt-btn-primary" class="evt-btn evt-btn-primary"
@click="handleCheckIn" @click="handleCheckIn"
> >
@@ -134,7 +167,8 @@
</template> </template>
<script> <script>
import { GetEventDetail, JoinEvent, QuitEvent, CheckInEvent, DeleteEvent } from '../../common/api' import { GetEventDetail, JoinEvent, QuitEvent, CheckInEvent, DeleteEvent, ApproveEvent } from '../../common/api'
import wsManager from '../../common/websocket'
import themeMixin from '../../common/theme-mixin' import themeMixin from '../../common/theme-mixin'
export default { export default {
@@ -159,6 +193,23 @@ export default {
canJoin() { canJoin() {
return this.statusKey === 'open' || this.statusKey === 'upcoming' return this.statusKey === 'open' || this.statusKey === 'upcoming'
}, },
/** 我的报名审核状态:1=待审核 2=通过 3=拒绝(兼容后端未返回字段的旧数据) */
myApprove() {
if (!this.event) return 0
const raw = this.event.approveStatus
if (raw !== undefined && raw !== null && raw !== '') return Number(raw)
return this.event.isJoined ? 2 : 0
},
/** 待审核报名列表(发起人审核用) */
pendingParticipants() {
if (!this.event || !this.event.participants) return []
return this.event.participants.filter(p => Number(p.approveStatus) === 1)
},
/** 已通过审核的参与者(无字段的旧数据默认视为已通过) */
approvedParticipants() {
if (!this.event || !this.event.participants) return []
return this.event.participants.filter(p => Number(p.approveStatus) !== 1)
},
statusLabel() { statusLabel() {
const map = { open: '报名中', upcoming: '待开始', ongoing: '进行中', ended: '已结束' } const map = { open: '报名中', upcoming: '待开始', ongoing: '进行中', ended: '已结束' }
return map[this.statusKey] || '报名中' return map[this.statusKey] || '报名中'
@@ -224,11 +275,46 @@ export default {
async handleJoin() { async handleJoin() {
try { try {
await JoinEvent({ eventId: this.eventId }) await JoinEvent({ eventId: this.eventId })
this.event.isJoined = true uni.showToast({ title: '申请已提交,等待发起人审核', icon: 'none', duration: 2500 })
this.event.joined++ // 后端不会自动推送通知,前端主动给发起人发一条私信,
uni.showToast({ title: '报名成功 🎉', icon: 'none' }) // 使其在消息列表(chat-list)看到未读提醒
this.notifyOrganizer()
this.loadDetail()
} catch (e) { } catch (e) {
uni.showToast({ title: '报名失败', icon: 'none' }) // httpRequest 已全局弹错误提示
}
},
/** 通过 WebSocket 给发起人发送报名提醒私信 */
notifyOrganizer() {
const organizer = this.event && this.event.organizer
if (!organizer || organizer.id === undefined || organizer.id === null) return
wsManager.send('chat', {
receiverId: organizer.id,
content: `我报名了你的酒局「${this.event.title}」,请审核 🍻`,
msgType: 'text',
clientMesgId: 'msg_' + Date.now()
})
},
/** 发起人审核报名:approve 2=同意 3=拒绝 */
async handleApprove(user, approve) {
try {
await ApproveEvent({ eventId: this.eventId, userId: user.id, approve })
uni.showToast({ title: approve === 2 ? '已同意报名' : '已拒绝报名', icon: 'none' })
// 审核结果私信通知报名用户
if (user && user.id !== undefined && user.id !== null) {
const resultText = approve === 2
? `你报名的酒局「${this.event.title}」已通过审核 🎉`
: `很遗憾,你报名的酒局「${this.event.title}」未通过审核`
wsManager.send('chat', {
receiverId: user.id,
content: resultText,
msgType: 'text',
clientMesgId: 'msg_' + Date.now()
})
}
this.loadDetail()
} catch (e) {
// httpRequest 已全局弹错误提示
} }
}, },
async handleQuit() { async handleQuit() {
@@ -241,11 +327,10 @@ export default {
if (res.confirm) { if (res.confirm) {
try { try {
await QuitEvent({ eventId: this.eventId }) await QuitEvent({ eventId: this.eventId })
this.event.isJoined = false
this.event.joined--
uni.showToast({ title: '已取消报名', icon: 'none' }) uni.showToast({ title: '已取消报名', icon: 'none' })
this.loadDetail()
} catch (e) { } catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' }) // httpRequest 已全局弹错误提示
} }
} }
} }
@@ -584,4 +669,70 @@ export default {
color: $coral; color: $coral;
border: 1rpx solid rgba(255,107,107,0.25); border: 1rpx solid rgba(255,107,107,0.25);
} }
/* 待审核报名 */
.evt-pending {
margin-bottom: $sp-xl;
}
.pending-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-md $sp-lg;
background: $bg-card;
border: 1rpx solid rgba(232,168,56,0.25);
border-radius: $radius-lg;
margin-bottom: $sp-sm;
}
.pending-user {
display: flex;
align-items: center;
gap: $sp-md;
min-width: 0;
}
.pending-name {
font-size: $fs-base;
font-weight: $fw-medium;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pending-actions {
display: flex;
gap: $sp-sm;
flex-shrink: 0;
}
.pending-btn {
padding: 10rpx 24rpx;
border-radius: $radius-full;
&:active {
opacity: 0.7;
}
}
.pending-approve {
background: linear-gradient(135deg, $amber, $amber-deep);
}
.pending-reject {
background: rgba(255,107,107,0.1);
border: 1rpx solid rgba(255,107,107,0.25);
}
.pending-btn-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $text-on-amber;
.pending-reject & {
color: $coral;
}
}
</style> </style>
+190 -22
View File
@@ -32,10 +32,12 @@
<text class="stat-label">标准杯</text> <text class="stat-label">标准杯</text>
</view> </view>
<view class="stat-card card"> <view class="stat-card card">
<text class="stat-value text-num" :class="stats.streakType === 'drank' ? 'text-amber' : 'text-mint'"> <text class="stat-value text-num text-amber">{{ stats.drankStreak }}</text>
{{ stats.streak }} <text class="stat-label">连喝天数</text>
</text> </view>
<text class="stat-label">{{ stats.streakType === 'drank' ? '连喝天数' : '戒酒天数' }}</text> <view class="stat-card card">
<text class="stat-value text-num text-mint">{{ stats.abstainStreak }}</text>
<text class="stat-label">戒酒天数</text>
</view> </view>
</view> </view>
@@ -83,9 +85,10 @@
<!-- 日期记录弹窗 --> <!-- 日期记录弹窗 -->
<view v-if="showDayDetail" class="day-overlay" @click="showDayDetail = false"> <view v-if="showDayDetail" class="day-overlay" @click="showDayDetail = false">
<view class="day-detail card-elevated" @click.stop> <view class="day-detail card-elevated" @click.stop>
<view class="day-detail-handle"></view>
<view class="day-detail-header flex-between"> <view class="day-detail-header flex-between">
<text class="text-h3">{{ selectedDate }}</text> <text class="day-detail-date text-h3">{{ selectedDate }}</text>
<text class="btn-text" @click="showDayDetail = false">关闭</text> <text class="day-close btn-text" @click="showDayDetail = false">关闭</text>
</view> </view>
<view v-if="selectedRecord"> <view v-if="selectedRecord">
<view v-if="selectedRecord.mode === 'drank'" class="day-record-info"> <view v-if="selectedRecord.mode === 'drank'" class="day-record-info">
@@ -101,9 +104,14 @@
</view> </view>
</view> </view>
</view> </view>
<view class="day-meta flex-between"> <view class="day-meta">
<text class="text-caption">{{ getFeelingName(selectedRecord.feeling) }}</text> <view class="day-meta-chip day-feeling-chip">
<text class="text-caption text-amber text-num">{{ selectedRecord.standardCupsTotal }} 标准杯</text> <text class="day-feeling-text">{{ getFeelingName(selectedRecord.feeling) }}</text>
</view>
<view class="day-meta-chip day-cups-chip">
<text class="day-cups-num text-num">{{ selectedRecord.standardCupsTotal }}</text>
<text class="day-cups-unit">标准杯</text>
</view>
</view> </view>
<view class="day-action-row"> <view class="day-action-row">
<button class="btn-ghost day-action-btn" @click="editRecord">修改</button> <button class="btn-ghost day-action-btn" @click="editRecord">修改</button>
@@ -120,8 +128,9 @@
</view> </view>
<view v-else class="day-empty"> <view v-else class="day-empty">
<text class="text-caption">当天无记录</text> <text class="text-caption">当天无记录</text>
<button v-if="isSelectedToday" class="btn-primary day-record-btn" @click="goRecordToday">去记录</button> <button v-if="isSelectedToday" class="btn-primary day-record-btn" @click="goRecordToday">记录饮酒</button>
<button v-else class="btn-ghost" @click="backfillDate">补打卡</button> <button v-else class="btn-ghost day-record-btn" @click="backfillDate">补打卡饮酒</button>
<button class="btn-ghost day-abstain-btn" @click="markAbstain">未饮酒</button>
</view> </view>
</view> </view>
</view> </view>
@@ -132,7 +141,7 @@
import DrinkCalendar from '../../components/DrinkCalendar.vue' import DrinkCalendar from '../../components/DrinkCalendar.vue'
import client from '../../common/api' import client from '../../common/api'
import { GetCalendarReq, DeleteRecordReq, CreateRecordReq, GetRecordDetailReq } from 'HaveADrink' import { GetCalendarReq, DeleteRecordReq, CreateRecordReq, GetRecordDetailReq } from 'HaveADrink'
import { getGreeting, formatDate, getWeekStart, getCatIcon, isIconPath } from '../../common/utils' import { getGreeting, formatDate, getWeekStart, getCatIcon, isIconPath, calcLocalStreak } from '../../common/utils'
import { FEELINGS, DRINK_CATEGORIES, DRINK_UNITS } from '../../common/constants' import { FEELINGS, DRINK_CATEGORIES, DRINK_UNITS } from '../../common/constants'
import { savePendingInvite, handlePendingInvite } from '../../common/invite' import { savePendingInvite, handlePendingInvite } from '../../common/invite'
import themeMixin from '../../common/theme-mixin' import themeMixin from '../../common/theme-mixin'
@@ -236,6 +245,8 @@ export default {
favCategory: s.favCategory ? String(s.favCategory) : null, favCategory: s.favCategory ? String(s.favCategory) : null,
categoryVariety: s.categoryVariety || 0 categoryVariety: s.categoryVariety || 0
} }
// 后端 streak 不区分饮酒/戒酒模式,拉取记录本地重算修正
this.refreshLocalStreak()
} }
// 日历数据 // 日历数据
@@ -270,6 +281,22 @@ export default {
// 切换月份时重新加载记录 // 切换月份时重新加载记录
this.loadRecordsForMonth(year, month) this.loadRecordsForMonth(year, month)
}, },
/** 后端 streak 不区分饮酒/戒酒模式,拉取全部记录后本地重算连续天数 */
async refreshLocalStreak() {
try {
const resp = await client.GetRecords({ page: 1, pageSize: 100 })
const payload = resp.data || resp
const list = payload.list || []
if (!list.length) return
const { streak, streakType, drankStreak, abstainStreak } = calcLocalStreak(list)
this.stats.streak = streak
this.stats.streakType = streakType
this.stats.drankStreak = drankStreak
this.stats.abstainStreak = abstainStreak
} catch (e) {
console.warn('本地计算连续天数失败:', e)
}
},
async loadRecordsForMonth(year, month) { async loadRecordsForMonth(year, month) {
try { try {
const resp = await client.GetCalendar(new GetCalendarReq(year, month)) const resp = await client.GetCalendar(new GetCalendarReq(year, month))
@@ -415,7 +442,31 @@ export default {
this.showDayDetail = false this.showDayDetail = false
uni.navigateTo({ url: '/pages/record/record' }) uni.navigateTo({ url: '/pages/record/record' })
}, },
// 标记为未饮酒(直接创建 abstain 记录)
async markAbstain() {
const recordData = {
date: this.selectedDate,
mode: 'abstain',
drinks: [],
food: null,
feeling: null,
photos: [],
visibility: 'friends',
quote: ''
}
try {
uni.showLoading({ title: '保存中...' })
await client.CreateRecord(recordData)
uni.hideLoading()
this.showDayDetail = false
uni.showToast({ title: '已记录未饮酒', icon: 'success' })
this.loadData()
} catch (e) {
uni.hideLoading()
console.warn('标记未饮酒失败:', e)
uni.showToast({ title: (e && e.msg) || '保存失败,请重试', icon: 'none' })
}
},
} }
} }
</script> </script>
@@ -434,7 +485,7 @@ export default {
.greeting-text { .greeting-text {
display: block; display: block;
font-size:38rpx; font-size:42rpx;
font-weight: $fw-bold; font-weight: $fw-bold;
color: $text-primary; color: $text-primary;
} }
@@ -632,24 +683,83 @@ export default {
align-items: flex-end; align-items: flex-end;
z-index: 100; z-index: 100;
padding: $sp-lg; padding: $sp-lg;
animation: overlayIn 0.2s ease-out;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
} }
.day-detail { .day-detail {
position: relative;
width: 100%; width: 100%;
padding: $sp-xl; padding: $sp-md $sp-xl $sp-xl;
color: $text-primary; color: $text-primary;
border-radius: $radius-xl $radius-xl 0 0; border-radius: $radius-xl;
/* 背景层次:底部卡片色 + 顶部琥珀光晕,日夜主题自适应 */
background:
radial-gradient(120% 60% at 50% 0%, var(--amber-glow, rgba(232, 168, 56, 0.15)) 0%, transparent 60%),
linear-gradient(180deg, var(--bg-elevated, #28283E) 0%, var(--bg-card, #1A1A28) 55%);
border: 2rpx solid var(--border-faint, rgba(255, 255, 255, 0.08));
box-shadow: var(--shadow-lg);
animation: sheetIn 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes sheetIn {
from { opacity: 0; transform: translateY(48rpx); }
to { opacity: 1; transform: translateY(0); }
}
/* 顶部拖拽把手 */
.day-detail-handle {
width: 64rpx;
height: 8rpx;
border-radius: $radius-full;
background: var(--border-active, rgba(255, 255, 255, 0.12));
margin: 0 auto $sp-md;
} }
.day-detail-header { .day-detail-header {
margin-bottom: $sp-lg; margin-bottom: $sp-lg;
} }
.day-detail-date {
position: relative;
padding-left: $sp-md;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 70%;
border-radius: $radius-full;
background: linear-gradient(180deg, var(--amber-light, #F5C563), var(--amber-deep, #C47F17));
}
}
.day-close {
padding: $sp-xs $sp-sm;
}
.day-drinks {
background: var(--bg-subtle, rgba(255, 255, 255, 0.03));
border-radius: $radius-md;
padding: $sp-xs $sp-md;
}
.day-drink-item { .day-drink-item {
padding: $sp-sm 0; padding: $sp-md 0;
font-size: $fs-base; font-size: $fs-base;
color: $text-primary; color: $text-primary;
border-bottom: 2rpx solid var(--border-micro, rgba(255, 255, 255, 0.05)); border-bottom: 2rpx solid var(--border-micro, rgba(255, 255, 255, 0.05));
&:last-child {
border-bottom: none;
}
} }
.day-drink-row { .day-drink-row {
@@ -680,9 +790,44 @@ export default {
} }
.day-meta { .day-meta {
display: flex;
gap: $sp-sm;
margin-top: $sp-lg; margin-top: $sp-lg;
} }
.day-meta-chip {
display: flex;
align-items: center;
gap: $sp-xs;
padding: $sp-sm $sp-md;
border-radius: $radius-full;
}
.day-feeling-chip {
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}
.day-feeling-text {
font-size: $fs-sm;
color: $text-secondary;
}
.day-cups-chip {
background: var(--amber-glow, rgba(232, 168, 56, 0.15));
border: 2rpx solid rgba(232, 168, 56, 0.25);
}
.day-cups-num {
font-size: $fs-lg;
font-weight: $fw-bold;
color: $amber;
}
.day-cups-unit {
font-size: $fs-xs;
color: $amber;
}
.day-abstain { .day-abstain {
text-align: center; text-align: center;
padding: $sp-xl 0; padding: $sp-xl 0;
@@ -702,6 +847,12 @@ export default {
margin-top: $sp-lg; margin-top: $sp-lg;
width: 100%; width: 100%;
} }
.day-abstain-btn {
color: $mint;
border-color: rgba(94, 198, 160, 0.35);
background: rgba(94, 198, 160, 0.08);
}
} }
.day-action-row { .day-action-row {
@@ -710,20 +861,37 @@ export default {
margin-top: $sp-lg; margin-top: $sp-lg;
} }
/* 修改:琥珀主操作 */
.day-action-btn { .day-action-btn {
flex: 1; flex: 1;
font-size: $fs-sm; font-size: $fs-sm;
color: $text-secondary;
border-color: var(--border-active, rgba(255, 255, 255, 0.12)); &:first-child {
color: $text-on-amber;
background: linear-gradient(135deg, var(--amber-light, #F5C563), var(--amber-deep, #C47F17));
border: none;
box-shadow: var(--shadow-amber);
}
/* 改为未饮酒:薄荷淡底 */
&:last-child {
color: $mint;
background: rgba(94, 198, 160, 0.08);
border: 2rpx solid rgba(94, 198, 160, 0.30);
}
} }
.day-delete-btn { .day-delete-btn {
margin-top: $sp-md; margin-top: $sp-md;
width: 100%; width: 100%;
font-size: $fs-sm; font-size: $fs-sm;
color: #e85d3a; color: #E85D3A;
background: rgba(232, 93, 58, 0.08); background: rgba(232, 93, 58, 0.06);
border: 2rpx solid rgba(232, 93, 58, 0.2); border: 2rpx solid rgba(232, 93, 58, 0.30);
&:active {
background: rgba(232, 93, 58, 0.14);
}
} }
.day-toggle-btn { .day-toggle-btn {
+34 -12
View File
@@ -81,9 +81,9 @@
<!-- 底部协议(非登录状态时显示) --> <!-- 底部协议(非登录状态时显示) -->
<view v-if="showNotice" class="agreement"> <view v-if="showNotice" class="agreement">
<text class="text-tiny">登录即代表同意</text> <text class="text-tiny">登录即代表同意</text>
<text class="text-tiny text-amber">《用户协议》</text> <text class="text-tiny text-amber" @click="openAgreement('user')">《用户协议》</text>
<text class="text-tiny">和</text> <text class="text-tiny">和</text>
<text class="text-tiny text-amber">《隐私政策》</text> <text class="text-tiny text-amber" @click="openAgreement('privacy')">《隐私政策》</text>
</view> </view>
</view> </view>
</template> </template>
@@ -149,6 +149,8 @@ export default {
return return
} }
this.loginLoading = true this.loginLoading = true
let loginOk = false
let loginErr = null
try { try {
// 1. 获取微信登录 code // 1. 获取微信登录 code
const loginRes = await this.wxLogin() const loginRes = await this.wxLogin()
@@ -162,6 +164,13 @@ export default {
avatarUrl: this.avatarUrl || '' avatarUrl: this.avatarUrl || ''
} }
const resp = await client.WechatLogin(loginParams) const resp = await client.WechatLogin(loginParams)
// errcode 兜底:后端业务错误码非 0 时视为登录失败
if (resp && resp.errcode && resp.errcode !== 0) {
throw { msg: resp.errmsg || '登录失败' }
}
if (!resp || !resp.token) {
throw { msg: '登录响应缺少 token' }
}
// 3. 保存 token 和用户信息 // 3. 保存 token 和用户信息
saveAuthTokens(resp) saveAuthTokens(resp)
@@ -171,11 +180,16 @@ export default {
uni.setStorageSync('is_logged_in', 'true') uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_guest', 'false') uni.setStorageSync('is_guest', 'false')
uni.setStorageSync('is_first_launch', 'false') uni.setStorageSync('is_first_launch', 'false')
loginOk = true
this.finishLogin()
} catch (e) { } catch (e) {
console.warn('后端登录失败,降级为本地模式:', e) // 打印完整错误,方便定位具体失败环节
uni.showToast({ title: e.msg || '登录失败,使用本地模式', icon: 'none', duration: 2000 }) console.warn('后端登录失败,降级为本地模式:', e && e.stack ? e.stack : e)
loginErr = e
}
if (!loginOk) {
const msg = (loginErr && (loginErr.msg || loginErr.message)) || ''
uni.showToast({ title: msg ? `登录失败:${msg}` : '登录失败,使用本地模式', icon: 'none', duration: 2000 })
// 降级为本地登录 // 降级为本地登录
const userInfo = { const userInfo = {
id: `user_${Date.now()}`, id: `user_${Date.now()}`,
@@ -187,10 +201,11 @@ export default {
uni.setStorageSync('user_info', JSON.stringify(userInfo)) uni.setStorageSync('user_info', JSON.stringify(userInfo))
uni.setStorageSync('is_logged_in', 'true') uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_first_launch', 'false') uni.setStorageSync('is_first_launch', 'false')
this.finishLogin()
} finally {
this.loginLoading = false
} }
this.loginLoading = false
// 收尾逻辑移出 try/catch:避免 WS 重建/邀请补发等非登录环节异常
// 被误判为登录失败(后端已成功却弹"登录失败"的根因)
this.finishLogin()
}, },
// 微信login获取code // 微信login获取code
@@ -209,20 +224,27 @@ export default {
uni.setStorageSync('is_first_launch', 'false') uni.setStorageSync('is_first_launch', 'false')
// 登录完成后用新 token 重建 WebSocket 连接 // 登录完成后用新 token 重建 WebSocket 连接
// (启动时可能用旧 token 连接被拒,不重建会一直用旧 token 重试) // (启动时可能用旧 token 连接被拒,不重建会一直用旧 token 重试)
try {
const token = uni.getStorageSync('auth_token') const token = uni.getStorageSync('auth_token')
if (token) { if (token) {
wsManager.disconnect() wsManager.disconnect()
wsManager.connect(token) wsManager.connect(token)
} }
} catch (e) {
console.warn('WS 重建失败,不影响登录:', e)
}
// 登录完成后补发分享邀请的好友请求(若有暂存的邀请人) // 登录完成后补发分享邀请的好友请求(若有暂存的邀请人)
try {
handlePendingInvite() handlePendingInvite()
} catch (e) {
console.warn('补发邀请失败,不影响登录:', e)
}
uni.switchTab({ url: '/pages/index/index' }) uni.switchTab({ url: '/pages/index/index' })
}, },
// ===== 协议查看 ===== // ===== 协议查看:跳转协议页(user=用户协议 / privacy=隐私政策)=====
openAgreement(type) { openAgreement(type) {
const title = type === 'user' ? '用户协议' : '隐私政策' uni.navigateTo({ url: `/pages/agreement/agreement?type=${type}` })
uni.showToast({ title: `${title}页面开发中`, icon: 'none' })
} }
} }
} }
+72 -49
View File
@@ -9,7 +9,7 @@
</view> </view>
<!-- 右上角分享按钮(避让胶囊) --> <!-- 右上角分享按钮(避让胶囊) -->
<button class="hero-share-btn" open-type="share" :style="shareBtnStyle"> <button class="hero-share-btn" open-type="share" :style="shareBtnStyle">
<text class="hero-share-icon">↗</text> <text class="hero-share-icon">分享</text>
</button> </button>
<view class="hero-content"> <view class="hero-content">
<view class="avatar-ring"> <view class="avatar-ring">
@@ -71,25 +71,33 @@
</view> </view>
</view> </view>
<!-- 连续打卡卡片(含连续戒酒状态,暂时注释隐藏)
<view class="streak-section"> <view class="streak-section">
<view class="streak-card" :class="stats.streakType === 'drank' ? 'streak-drank' : 'streak-abstain'"> <view class="streak-row">
<view class="streak-left"> <view class="streak-card streak-drank">
<view class="streak-badge"> <view class="streak-card-top">
<text class="streak-badge-emoji">{{ stats.streakType === 'drank' ? '🔥' : '💪' }}</text> <text class="streak-badge-emoji">🔥</text>
<text class="streak-title">连续饮酒</text>
</view> </view>
<view class="streak-info"> <text class="streak-sub">保持记录,理性饮酒</text>
<text class="streak-title">{{ stats.streakType === 'drank' ? '连续饮酒' : '连续戒酒' }}</text> <view class="streak-num-row">
<text class="streak-sub">{{ stats.streakType === 'drank' ? '保持记录,理性饮酒' : '自律即自由,继续坚持' }}</text> <text class="streak-days text-num">{{ stats.drankStreak || 0 }}</text>
<text class="streak-days-unit">天</text>
</view> </view>
</view> </view>
<view class="streak-right"> <view class="streak-card streak-abstain">
<text class="streak-days text-num">{{ stats.streak }}</text> <view class="streak-card-top">
<text class="streak-badge-emoji">💪</text>
<text class="streak-title">连续戒酒</text>
</view>
<text class="streak-sub">自律即自由,继续坚持</text>
<view class="streak-num-row">
<text class="streak-days text-num">{{ stats.abstainStreak || 0 }}</text>
<text class="streak-days-unit">天</text> <text class="streak-days-unit">天</text>
</view> </view>
</view> </view>
</view> </view>
--> </view>
<!-- 饮酒人格卡片 --> <!-- 饮酒人格卡片 -->
<view class="persona-card"> <view class="persona-card">
@@ -166,7 +174,7 @@
<script> <script>
import AchievementBadge from '../../components/AchievementBadge.vue' import AchievementBadge from '../../components/AchievementBadge.vue'
import client, { clearAuth } from '../../common/api' import client, { clearAuth } from '../../common/api'
import { checkAchievements, getCatIcon, isIconPath } from '../../common/utils' import { checkAchievements, getCatIcon, isIconPath, calcLocalStreak } from '../../common/utils'
import { ACHIEVEMENTS, DRINK_CATEGORIES } from '../../common/constants' import { ACHIEVEMENTS, DRINK_CATEGORIES } from '../../common/constants'
import themeMixin from '../../common/theme-mixin' import themeMixin from '../../common/theme-mixin'
@@ -309,6 +317,8 @@ export default {
favCategory: s.favCategory ? String(s.favCategory) : null, favCategory: s.favCategory ? String(s.favCategory) : null,
categoryVariety: s.categoryVariety || 0 categoryVariety: s.categoryVariety || 0
} }
// 后端 streak 不区分饮酒/戒酒模式,拉取记录本地重算修正
this.refreshLocalStreak()
} }
// 成就列表 // 成就列表
@@ -346,6 +356,22 @@ export default {
this.detailAchievement = a this.detailAchievement = a
this.showDetail = true this.showDetail = true
}, },
/** 后端 streak 不区分饮酒/戒酒模式,拉取全部记录后本地重算连续天数 */
async refreshLocalStreak() {
try {
const resp = await client.GetRecords({ page: 1, pageSize: 100 })
const payload = resp.data || resp
const list = payload.list || []
if (!list.length) return
const { streak, streakType, drankStreak, abstainStreak } = calcLocalStreak(list)
this.stats.streak = streak
this.stats.streakType = streakType
this.stats.drankStreak = drankStreak
this.stats.abstainStreak = abstainStreak
} catch (e) {
console.warn('本地计算连续天数失败:', e)
}
},
// 后端返回的 icon 是无效图片路径,按索引复用本地成就的 emoji 图标(超出后循环取) // 后端返回的 icon 是无效图片路径,按索引复用本地成就的 emoji 图标(超出后循环取)
resolveAchievementIcon(index) { resolveAchievementIcon(index) {
if (!ACHIEVEMENTS.length) return '🏅' if (!ACHIEVEMENTS.length) return '🏅'
@@ -417,30 +443,36 @@ export default {
.hero-share-btn { .hero-share-btn {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
width: 64rpx;
height: 64rpx; height: 64rpx;
padding: 0 32rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255,255,255,0.06); /* 玻璃模态效果:半透明白 + 强模糊 + 高光描边 */
border: 1rpx solid rgba(255,255,255,0.10); background: rgba(255,255,255,0.14);
border-radius: 50%; border: 1rpx solid rgba(255,255,255,0.30);
backdrop-filter: blur(10px); border-radius: $radius-full;
backdrop-filter: blur(20px) saturate(160%);
box-shadow: inset 0 1rpx 0 rgba(255,255,255,0.35), 0 8rpx 24rpx rgba(0,0,0,0.18);
&::after { &::after {
border: none; border: none;
} }
&:active { &:active {
background: rgba(255,255,255,0.12); background: rgba(255,255,255,0.22);
transform: scale(0.9); transform: scale(0.94);
} }
} }
/* 分享胶囊按钮文字:与微信胶囊同构 */
.hero-share-icon { .hero-share-icon {
font-size: $fs-base; font-size: $fs-sm;
color: $text-secondary; color: $amber;
font-weight: $fw-bold; font-weight: $fw-bold;
line-height: 1;
letter-spacing: 4rpx;
text-shadow: 0 2rpx 8rpx rgba(0,0,0,0.15);
} }
.hero-bg { .hero-bg {
@@ -610,12 +642,17 @@ export default {
padding: $sp-lg $sp-lg 0; padding: $sp-lg $sp-lg 0;
} }
.streak-card { .streak-row {
display: flex; display: flex;
align-items: center; gap: $sp-md;
justify-content: space-between; }
padding: $sp-xl;
border-radius: $radius-xl; .streak-card {
flex: 1;
display: flex;
flex-direction: column;
padding: $sp-lg;
border-radius: $radius-lg;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -630,34 +667,19 @@ export default {
} }
} }
.streak-left { .streak-card-top {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $sp-md; gap: $sp-xs;
} margin-bottom: 6rpx;
.streak-badge {
width: 88rpx; height: 88rpx;
border-radius: 50%;
background: $bg-card;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-sm;
} }
.streak-badge-emoji { .streak-badge-emoji {
font-size: 44rpx; font-size: 32rpx;
}
.streak-info {
display: flex;
flex-direction: column;
gap: 4rpx;
} }
.streak-title { .streak-title {
font-size: $fs-base; font-size: $fs-sm;
font-weight: $fw-bold; font-weight: $fw-bold;
color: $text-primary; color: $text-primary;
} }
@@ -665,16 +687,17 @@ export default {
.streak-sub { .streak-sub {
font-size: $fs-xs; font-size: $fs-xs;
color: $text-tertiary; color: $text-tertiary;
margin-bottom: $sp-md;
} }
.streak-right { .streak-num-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 4rpx; gap: 4rpx;
} }
.streak-days { .streak-days {
font-size: $fs-hero; font-size: $fs-3xl;
font-weight: $fw-black; font-weight: $fw-black;
color: $amber; color: $amber;
line-height: 1; line-height: 1;