Compare commits

...
3 Commits
Author SHA1 Message Date
cg 527b0e8a8d feat(event): 添加酒局编辑删除功能并优化状态管理
- 移除 UniAppWebSocket 类实现,使用独立的 websocket 模块
- 添加 UpdateEvent 和 DeleteEvent API 接口
- 在 EventCard 组件中添加发起人管理操作按钮
- 实现酒局状态数字枚举到字符串的映射转换
- 在 circle 页面集成编辑删除事件处理
- 重构 event-create 页面支持编辑模式
- 在 event-detail 页面添加发起人操作区域
- 实现删除确认对话框和页面返回刷新逻辑
2026-08-05 22:58:26 +08:00
cg 9f23ccae46 Merge branch 'main' of https://git.wash-painting.cn/cg/hejiu 2026-08-04 23:32:52 +08:00
cg 57eef74eb1 feat(sdk): 升级HaveADrink SDK并集成邀请系统
- 将HaveADrink依赖从1.0.8升级至1.0.12版本
- 集成邀请码功能,新增common/invite.js处理邀请链路
- 实现发送好友请求返回邀请码的新流程
- 添加删除动态功能,新增DeleteFeed接口
- 集成UniAppWebSocket适配器,重构WebSocket连接管理
- 优化好友请求支持关键词搜索功能
- 在FeedCard组件中添加删除按钮和分享按钮
- 更新SDK类型定义文件以匹配新接口规范
2026-08-04 23:32:45 +08:00
22 changed files with 1682 additions and 394 deletions
+29 -144
View File
@@ -2,11 +2,12 @@
* 基于 HaveADrink SDK 封装后端接口
*/
import HaveADrink from 'HaveADrink'
import UniAppWebSocket from './uni-websocket'
// ==========================================
// 后端服务地址(切换环境只需改这里)
// ==========================================
const API_HOST = 'https://dev.wash-painting.cn'
export const API_HOST = 'https://dev.wash-painting.cn'
// ==========================================
// HTTP 请求函数(供 SDK 内部调用)
@@ -106,144 +107,9 @@ function uploadRequest(url, params) {
})
}
/**
* 使用 uni-app WebSocket API 实现的 WebSocket 类
* 符合之前定义的 WebsocketInterface(无类型约束)
*/
class UniAppWebSocket {
/**
* 构造函数:传入 WebSocket 服务器地址
* @param {string} host - 完整的 WebSocket URL,例如 'wss://example.com/ws'
*/
constructor(host) {
this.host = host;
// 事件回调属性(外部可赋值)
this.close = null; // 可赋值为函数,同时也可用主动关闭连接
this.onopen = null;
this.onmessage = null;
// 内部 SocketTask 实例
this.socketTask = null;
// 初始化连接
this._initSocket();
}
/**
* 初始化 WebSocket 连接(私有方法,约定以 _ 开头)
*/
_initSocket() {
const self = this;
// 创建 SocketTask
this.socketTask = uni.connectSocket({
url: this.host,
success() {
// 连接创建成功(但尚未打开)
},
fail(err) {
// 连接创建失败,触发错误事件
const errorEvent = { type: 'error', error: err, target: self };
self.onerror(errorEvent);
}
});
// 监听打开事件
this.socketTask.onOpen((res) => {
if (self.onopen) {
// 构造一个简单的事件对象
const event = { type: 'open', target: self, ...res };
self.onopen(event);
}
});
// 监听消息事件
this.socketTask.onMessage((res) => {
if (self.onmessage) {
// res.data 是消息数据
const event = {
type: 'message',
data: res.data,
target: self
};
self.onmessage(event);
}
});
// 监听关闭事件
this.socketTask.onClose((res) => {
// 构造关闭事件
const event = {
type: 'close',
code: res.code,
reason: res.reason,
wasClean: res.wasClean,
target: self
};
// 调用 onclose 方法(可被重写)
self.onclose(event);
});
// 监听错误事件
this.socketTask.onError((err) => {
const errorEvent = { type: 'error', error: err, target: self };
self.onerror(errorEvent);
});
// 将 close 属性设置为一个可调用函数,用于主动断开连接
// 同时也满足接口的 “回调类型” 定义
this.close = function(ev) {
if (self.socketTask) {
const code = ev && ev.code ? ev.code : 1000;
const reason = ev && ev.reason ? ev.reason : '';
self.socketTask.close({ code, reason });
}
// 如果作为回调函数,返回 null 即可(无实际意义)
return null;
};
}
/**
* 关闭事件处理方法(可被外部重写)
* @param {Object} event - 关闭事件对象
*/
onclose(event) {
// 默认空实现,你可以在外部覆盖此方法
// console.log('连接已关闭', event);
}
/**
* 错误事件处理方法(可被外部重写)
* @param {Object} event - 错误事件对象
*/
onerror(event) {
// 默认空实现
// console.error('连接错误', event);
}
/**
* 发送数据
* @param {string|ArrayBuffer|Blob} data - 要发送的数据
* @throws {Error} 如果 WebSocket 未初始化
*/
send(data) {
if (!this.socketTask) {
throw new Error('WebSocket 未初始化,无法发送数据');
}
this.socketTask.send({
data: data,
success() {
// 发送成功,可添加日志
},
fail(err) {
// 发送失败,触发错误事件
const errorEvent = { type: 'error', error: err, target: this };
this.onerror(errorEvent);
}
});
}
}
// ==========================================
// 初始化 SDK 实例
// WebSocket 适配器使用 common/uni-websocket.js,已修复 wx.connectSocket 降级问题)
// ==========================================
const client = new HaveADrink({
host: API_HOST,
@@ -323,6 +189,12 @@ export async function UnlikeFeed({ feedId }) {
return { data: res }
}
/** 删除动态(仅本人可删,后端会校验归属) */
export async function DeleteFeed({ feedId }) {
const res = await client.DeleteFeed({ id: feedId })
return { data: res }
}
/** 获取动态评论 */
export async function GetFeedComments({ feedId }) {
const res = await client.GetFeedComments({ id: feedId })
@@ -347,19 +219,19 @@ export async function GetFriends({ keyword = '' } = {}) {
return { data: res }
}
/** 获取好友请求 */
export async function GetFriendRequests() {
const res = await client.GetFriendRequests({})
/** 获取好友请求(支持关键词搜索) */
export async function GetFriendRequests({ keyword = '' } = {}) {
const res = await client.GetFriendRequests({ keyword })
return { data: res }
}
/** 发送好友请求 */
export async function SendFriendRequest({ userId, message = '' }) {
const res = await client.SendFriendRequest({ userId, message })
/** 发送好友请求(创建邀请,SDK v1.0.12:无需 userId,响应返回 inviteCode 邀请码) */
export async function SendFriendRequest({ message = '' } = {}) {
const res = await client.SendFriendRequest({ message })
return { data: res }
}
/** 接受好友请求 */
/** 接受好友请求(id 为好友请求ID,即邀请码 inviteCode */
export async function AcceptFriendRequest({ requestId }) {
const res = await client.AcceptFriendRequest({ id: requestId })
return { data: res }
@@ -390,6 +262,19 @@ export async function CreateEvent({ title, time, location, maxPeople, note, lati
return { data: res }
}
/** 修改酒局(仅发起人可改,后端会校验归属) */
export async function UpdateEvent({ eventId, title, time, location, maxPeople, note, latitude, longitude }) {
const geo = (latitude != null && longitude != null) ? { latitude, longitude } : undefined
const res = await client.UpdateEvent({ id: eventId, title, time, location, maxPeople, geo, note })
return { data: res }
}
/** 删除酒局(仅发起人可删,后端会校验归属) */
export async function DeleteEvent({ eventId }) {
const res = await client.DeleteEvent({ id: eventId })
return { data: res }
}
/** 报名酒局 */
export async function JoinEvent({ eventId }) {
const res = await client.JoinEvent({ id: eventId })
+86
View File
@@ -0,0 +1,86 @@
/* 碰盏日记 - 邀请链路处理(邀请码模式,SDK v1.0.12
*
* 后端规则:每次邀请都生成新的邀请码(一次性,不复用)
*
* 新流程:
* 1. 邀请人调用 SendFriendRequest({ message }) 创建邀请 → 后端返回新的 inviteCode(好友请求ID
* 2. 分享链接携带 inviteCode、inviterNick;每次分享后重新生成新码供下次使用
* 3. 被邀请人打开小程序 → index.vue / friends.vue onLoad 收到 inviteCode → savePendingInvite 暂存
* 4. 被邀请人登录后调用 AcceptFriendRequest({ id: inviteCode }) 接受邀请,双方成为酒友
*/
import { SendFriendRequest, AcceptFriendRequest } from './api'
const INVITE_KEY = 'pending_invite' // 我收到的邀请(待接受)
// 内存记录最近一次自己生成的邀请码(仅用于过滤自己点开自己的分享)
let myLastCode = ''
// 清理旧版本遗留的永久缓存键(旧方案曾永久缓存邀请码,新方案每次生成新码)
try { uni.removeStorageSync('my_invite_code') } catch (e) { /* ignore */ }
/**
* 生成新的邀请码(用于分享)
* 后端每次邀请都生成新码,故不做持久化缓存,每次调用都创建新邀请
* @returns {Promise<string>} 邀请码,未登录或失败返回空字符串
*/
export async function getMyInviteCode() {
if (uni.getStorageSync('is_logged_in') !== 'true') return ''
try {
const res = await SendFriendRequest({ message: '邀请你成为酒友,一起记录饮酒生活 🍻' })
const code = res.data && res.data.inviteCode
if (code !== undefined && code !== null && code !== '') {
myLastCode = String(code)
return myLastCode
}
} catch (e) {
console.warn('[invite] 创建邀请码失败', e)
}
return ''
}
/**
* 暂存收到的邀请(由分享落地页 onLoad 调用)
* @param {string|number} inviteCode - 邀请码(好友请求ID
* @param {string} inviterNick - 邀请人昵称(用于提示文案)
*/
export function savePendingInvite(inviteCode, inviterNick) {
if (!inviteCode) return
try {
// 自己分享给自己的邀请码,不处理(同一会话内自己点开自己的分享卡片)
if (myLastCode && String(myLastCode) === String(inviteCode)) return
uni.setStorageSync(INVITE_KEY, JSON.stringify({
inviteCode: String(inviteCode),
// uni-app onLoad options 已自动解码,此处直接存储
inviterNick: inviterNick || ''
}))
} catch (e) { /* ignore */ }
}
/**
* 接受暂存的邀请(已登录时调用)
* 用邀请码调用 AcceptFriendRequest,成功后清除暂存
* @returns {Promise<boolean>} 是否成功接受
*/
export async function handlePendingInvite() {
// 未登录时不处理,等登录完成后再调用
if (uni.getStorageSync('is_logged_in') !== 'true') return false
let invite = null
try {
invite = JSON.parse(uni.getStorageSync(INVITE_KEY) || '')
} catch (e) { /* ignore */ }
if (!invite || !invite.inviteCode) return false
try {
await AcceptFriendRequest({ requestId: invite.inviteCode })
uni.removeStorageSync(INVITE_KEY)
const nick = invite.inviterNick || '邀请人'
uni.showToast({ title: `已接受邀请,和「${nick}」成为酒友 🍻`, icon: 'none', duration: 2500 })
return true
} catch (e) {
// 后端可能返回"已是好友/邀请已失效"等业务失败,同样清除暂存避免重复提示
uni.removeStorageSync(INVITE_KEY)
console.warn('[invite] 接受邀请失败', e)
return false
}
}
+156
View File
@@ -0,0 +1,156 @@
/* 碰盏日记 - UniAppWebSocket 适配器
* 将 uni-app 的 uni.connectSocket 适配为 HaveADrink SDK WebsocketInterface
* (标准 WebSocket 风格:constructor(host)、onopen/onmessage/onclose/onerror、send、close
* 用法:在 SDK 初始化时传入 websocket: UniAppWebSocket
*/
export default class UniAppWebSocket {
/**
* @param {string} host - 完整 ws/wss 连接地址(SDK 已处理 https→wss 协议转换)
*/
constructor(host) {
this.url = host
// 可赋值的事件回调(SDK ChatWebSocketConn.connect() 中直接赋值)
this.onopen = null
this.onmessage = null
this.onclose = null
this.onerror = null
// 连接状态: connecting | open | closed
this.readyState = 0
this._sendQueue = []
this._closedByUser = false
this._connect()
}
_connect() {
// 微信小程序下直接用 wx.connectSocket
// uni 的 API 运行时(uni.api.esm.js)会向 connectSocket 注入 success/fail/complete 回调,
// 而微信规则是“传入回调后不再返回 SocketTask”,导致 uni.connectSocket 永远拿不到 task。
// wx.connectSocket 不传回调时正常返回 SocketTask,避免降级到全局 socket API
// (全局 API 重连时会重复注册监听器,引发重连风暴)
console.log('[UniAppWebSocket] 发起连接:', this.url)
const rawConnect = (typeof wx !== 'undefined' && typeof wx.connectSocket === 'function')
? wx.connectSocket.bind(wx)
: uni.connectSocket
const task = rawConnect({ url: this.url })
// 部分平台 uni.connectSocket 无返回值,降级为全局 API 监听
if (task && typeof task.onOpen === 'function') {
this.socketTask = task
task.onOpen((res) => {
console.log('[UniAppWebSocket] 连接已打开(open)')
this.readyState = 1
this._flush()
if (typeof this.onopen === 'function') {
this.onopen({ type: 'open' })
}
})
task.onMessage((res) => {
if (typeof this.onmessage === 'function') {
// 与浏览器 MessageEvent 对齐:消息内容放在 event.data
this.onmessage({ type: 'message', data: res.data })
}
})
task.onClose((event) => {
// 诊断:打印关闭码/原因,用于判断是服务端拒连还是网络问题
// 常见 code1006 异常断开、1000 正常关闭;reason 由服务端给出
console.warn('[UniAppWebSocket] 连接被关闭(close) code:', event && event.code,
'reason:', event && event.reason)
this.readyState = 3
if (!this._closedByUser && typeof this.onclose === 'function') {
this.onclose(Object.assign({ type: 'close' }, event || {}))
}
})
task.onError((err) => {
// 诊断:微信会在 errMsg 中给出具体原因(域名不在合法列表、TLS 失败等)
console.warn('[UniAppWebSocket] 连接错误(error):', err && (err.errMsg || err.message || JSON.stringify(err)))
if (typeof this.onerror === 'function') {
this.onerror(Object.assign({ type: 'error' }, err || {}))
}
})
} else {
console.warn('[UniAppWebSocket] 未获得 SocketTask,降级到全局 socket API')
// 兜底:旧版小程序全局 socket API
uni.onSocketOpen(() => {
this.readyState = 1
this._flush()
if (typeof this.onopen === 'function') {
this.onopen({ type: 'open' })
}
})
uni.onSocketMessage((res) => {
if (typeof this.onmessage === 'function') {
this.onmessage({ type: 'message', data: res.data })
}
})
uni.onSocketClose((event) => {
this.readyState = 3
if (!this._closedByUser && typeof this.onclose === 'function') {
this.onclose(Object.assign({ type: 'close' }, event || {}))
}
})
uni.onSocketError((err) => {
if (typeof this.onerror === 'function') {
this.onerror(Object.assign({ type: 'error' }, err || {}))
}
})
}
}
/**
* 发送数据(与浏览器 WebSocket.send 签名一致)
* @param {string} data
*/
send(data) {
if (this.readyState !== 1) {
// 连接未就绪时暂存,连接建立后补发
this._sendQueue.push(data)
return
}
if (this.socketTask) {
this.socketTask.send({
data,
fail: (err) => {
console.warn('[UniAppWebSocket] 发送失败', err)
}
})
} else {
// 兜底:旧版小程序全局 socket API
uni.sendSocketMessage({
data,
fail: (err) => {
console.warn('[UniAppWebSocket] 发送失败', err)
}
})
}
}
/** 主动关闭连接(主动关闭不再触发 onclose,由 SDK 自行控制重连) */
close() {
this._closedByUser = true
this.readyState = 3
if (this.socketTask) {
this.socketTask.close({
complete: () => {}
})
this.socketTask = null
} else {
uni.closeSocket({
complete: () => {}
})
}
}
/** 连接建立后补发暂存消息 */
_flush() {
if (!this._sendQueue.length) return
const queue = [...this._sendQueue]
this._sendQueue = []
queue.forEach((data) => this.send(data))
}
}
+115 -145
View File
@@ -1,10 +1,34 @@
/* 碰盏日记 - WebSocket 管理器
* 单例模式,负责私信实时通信
* 协议格式与 HaveADrink SDK ChatWebSocket 一致:{ mesgType, data }
* 支持心跳、自动重连、消息队列
* 基于 HaveADrink SDK ChatWebSocketChatWebSocketConn)实现:
* - 底层 socket 通过 UniAppWebSocket 适配器注入(common/uni-websocket.js
* - 连接地址、token query、指数退避重连均由 SDK 管理
* - 消息协议:{ mesgType, data }
* 对外保持 on/off/send/connect/disconnect/userId 接口,页面层无感知
*/
const WS_HOST = 'wss://dev.wash-painting.cn/api/have_a_drink/v1/chat/chat'
import client, { API_HOST } from './api'
// SDK 具名导出的连接类(绕开 client.ChatWebSocket 方法内部使用的浏览器 API)
import { ChatWebSocketConn } from 'HaveADrink'
/**
* 自行解析 host 并构造 ChatWebSocketConn
* SDK 的 client.ChatWebSocket() 内部使用 new URL() / new URLSearchParams()
* 微信小程序沙箱中裸标识符 URL 解析到不可构造的空壳,全局 polyfill 无法生效,
* 因此这里手动完成同样的逻辑:https -> wssquery 键值对拼接
* @param {object} input - { token }
* @returns {ChatWebSocketConn}
*/
function createChatConn(input) {
const m = String(API_HOST).match(/^(https?|wss?):\/\/([^/?#\s]+)/i)
const scheme = m && m[1].toLowerCase() === 'https' ? 'wss' : 'ws'
const host = `${scheme}://${m ? m[2] : API_HOST}`
const query = Object.keys(input || {})
.filter((k) => input[k] !== '' && input[k] !== null && input[k] !== undefined)
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(input[k])}`)
.join('&')
return new ChatWebSocketConn(host, query)
}
// 客户端消息类型(与 SDK ClientMesgType 枚举值一致)
const ClientMesgType = {
@@ -14,42 +38,26 @@ const ClientMesgType = {
Ping: 'ping'
}
// 服务端消息类型(与 SDK ServerMesgType 枚举值一致)
const ServerMesgType = {
Chat: 'chat',
Typing: 'typing',
Read: 'read',
Ack: 'ack',
Pong: 'pong',
Connected: 'connected',
Kicked: 'kicked'
}
class WebSocketManager {
constructor() {
this.socketTask = null
this.conn = null // SDK ChatWebSocketConn 实例
this.isConnected = false
this.isConnecting = false
this.listeners = {} // 事件订阅 { event: [callbacks] }
this.messageQueue = [] // 断线期间暂存消息
this.messageQueue = [] // 断线期间暂存消息 { mesgType, data }
this.heartbeatTimer = null
this.heartbeatTimeout = null
this.reconnectTimer = null
this.reconnectAttempts = 0
this.maxReconnectDelay = 30000
this.heartbeatInterval = 30000 // 30s 发一次 ping
this.heartbeatWait = 60000 // 60s 无 pong 则重连
this.manualClose = false
this.token = ''
this.userId = '' // 连接成功后服务端返回的用户ID
this.userId = '' // 服务端 connected 事件返回的用户ID
}
/**
* 建立 WebSocket 连接
* 建立 WebSocket 连接(走 SDK ChatWebSocket
* @param {string} token - 用户认证 token
*/
connect(token) {
if (this.isConnected || this.isConnecting) return
if (this.conn || this.isConnecting) return
this.token = token || uni.getStorageSync('auth_token') || ''
if (!this.token) {
console.warn('[WS] 无 token,跳过连接')
@@ -57,65 +65,80 @@ class WebSocketManager {
}
this.manualClose = false
this.isConnecting = true
console.log('[WS] 正在连接(SDK ChatWebSocket...')
const url = WS_HOST
console.log('[WS] 正在连接...')
// SDK 内部:new websocket(`${host}/api/have_a_drink/v1/chat/chat?token=xxx`)
// websocket 即注入的 UniAppWebSocket 适配器,重连由 SDK 指数退避管理
// 注意:不用 client.ChatWebSocket()(其内部 new URL 在小程序沙箱不可用),
// 直接用 SDK 导出的 ChatWebSocketConn 构造,等价实现
const conn = createChatConn({ token: this.token })
this.socketTask = uni.connectSocket({
url,
header: {
'Authorization': `Bearer ${this.token}`
},
complete: () => {}
})
this.socketTask.onOpen(() => {
conn.onopen = () => {
console.log('[WS] 连接成功')
this.isConnected = true
this.isConnecting = false
this.reconnectAttempts = 0
this._startHeartbeat()
this._flushQueue()
this._emit('connect', {})
})
}
this.socketTask.onMessage((res) => {
try {
const msg = JSON.parse(res.data)
this._handleMessage(msg)
} catch (e) {
console.warn('[WS] 消息解析失败', res.data)
}
})
this.socketTask.onClose(() => {
conn.onclose = () => {
console.log('[WS] 连接关闭')
this.isConnected = false
this.isConnecting = false
this._stopHeartbeat()
this._emit('disconnect', {})
if (!this.manualClose) {
this._scheduleReconnect()
}
})
// 非主动关闭时,SDK 已自动安排指数退避重连,无需手动处理
}
this.socketTask.onError((err) => {
conn.onerror = (err) => {
console.warn('[WS] 连接错误', err)
this.isConnected = false
this.isConnecting = false
})
}
// ===== 服务端协议消息(SDK 已解析 { mesgType, data } =====
conn.onServerChatData = (data) => {
this._emit('message', data)
}
conn.onServerTypingData = (data) => {
this._emit('typing', data)
}
conn.onServerReadData = (data) => {
this._emit('read', data)
}
conn.onServerAckData = (data) => {
this._emit('ack', data)
}
conn.onServerConnectedData = (data) => {
// 服务端验证 token 成功后推送,携带 userId
this.userId = data && data.userId ? data.userId : ''
console.log('[WS] 服务端确认连接, userId:', this.userId)
this._emit('connected', data)
}
conn.onServerKickedData = (data) => {
console.warn('[WS] 被踢下线')
this._emit('kicked', data)
this.disconnect()
}
this.conn = conn
}
/** 主动断开连接 */
/** 主动断开连接SDK close() 会关闭重连标志并断开 socket) */
disconnect() {
this.manualClose = true
this._stopHeartbeat()
this._clearReconnect()
if (this.socketTask) {
this.socketTask.close()
this.socketTask = null
if (this.conn) {
this.conn.close()
this.conn = null
}
this.isConnected = false
this.isConnecting = false
}
/**
@@ -124,17 +147,16 @@ class WebSocketManager {
* @param {object} data - 消息数据
*/
send(mesgType, data = {}) {
const payload = JSON.stringify({ mesgType, data })
if (this.isConnected && this.socketTask) {
this.socketTask.send({ data: payload })
if (this.isConnected && this.conn) {
this._write(mesgType, data)
} else {
// 断线暂存队列(ping 不暂存)
if (mesgType !== ClientMesgType.Ping) {
this.messageQueue.push(payload)
this.messageQueue.push({ mesgType, data })
}
// 尝试重连
if (!this.isConnecting && !this.manualClose) {
this._scheduleReconnect()
// 未连接时触发(重)连接
if (!this.conn && !this.manualClose) {
this.connect(this.token)
}
}
}
@@ -167,38 +189,31 @@ class WebSocketManager {
// ========== 内部方法 ==========
/** 处理收到的消息(SDK ServerMesgType 协议) */
_handleMessage(msg) {
const { mesgType, data } = msg
switch (mesgType) {
case ServerMesgType.Chat:
this._emit('message', data)
break
case ServerMesgType.Typing:
this._emit('typing', data)
break
case ServerMesgType.Read:
this._emit('read', data)
break
case ServerMesgType.Ack:
this._emit('ack', data)
break
case ServerMesgType.Pong:
this._onPong()
break
case ServerMesgType.Connected:
// 服务端验证 token 成功后推送,携带 userId
this.userId = data && data.userId ? data.userId : ''
console.log('[WS] 服务端确认连接, userId:', this.userId)
this._emit('connected', data)
break
case ServerMesgType.Kicked:
console.warn('[WS] 被踢下线')
this._emit('kicked', data)
this.disconnect()
break
default:
console.log('[WS] 未知消息类型', mesgType)
/** 通过 SDK writeClient* 方法写入消息 */
_write(mesgType, data) {
try {
switch (mesgType) {
case ClientMesgType.Chat:
this.conn.writeClientChatData(data)
break
case ClientMesgType.Typing:
this.conn.writeClientTypingData(data)
break
case ClientMesgType.Read:
this.conn.writeClientReadData(data)
break
case ClientMesgType.Ping:
this.conn.writeClientPingData(data)
break
default:
console.warn('[WS] 未知发送类型', mesgType)
}
} catch (e) {
console.warn('[WS] 发送异常', e)
// 发送失败时暂存,待重连后补发
if (mesgType !== ClientMesgType.Ping) {
this.messageQueue.push({ mesgType, data })
}
}
}
@@ -212,61 +227,20 @@ class WebSocketManager {
}
}
/** 启动心跳 */
/** 启动心跳(保活,防运营商/小程序后台掐连接) */
_startHeartbeat() {
this._stopHeartbeat()
this.heartbeatTimer = setInterval(() => {
this.send(ClientMesgType.Ping, {})
// 设置超时检测
this.heartbeatTimeout = setTimeout(() => {
console.warn('[WS] 心跳超时,触发重连')
if (this.socketTask) {
this.socketTask.close()
}
}, this.heartbeatWait)
}, this.heartbeatInterval)
}
/** 收到 pong,清除超时 */
_onPong() {
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout)
this.heartbeatTimeout = null
}
}
/** 停止心跳 */
_stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout)
this.heartbeatTimeout = null
}
}
/** 指数退避重连 */
_scheduleReconnect() {
if (this.manualClose || this.reconnectTimer) return
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay)
this.reconnectAttempts++
console.log(`[WS] ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.isConnecting = false
this.connect(this.token)
}, delay)
}
/** 清除重连计时器 */
_clearReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.reconnectAttempts = 0
}
/** 重连成功后重发队列消息 */
@@ -275,11 +249,7 @@ class WebSocketManager {
console.log(`[WS] 重发 ${this.messageQueue.length} 条暂存消息`)
const queue = [...this.messageQueue]
this.messageQueue = []
queue.forEach(payload => {
if (this.socketTask && this.isConnected) {
this.socketTask.send({ data: payload })
}
})
queue.forEach(({ mesgType, data }) => this._write(mesgType, data))
}
}
+47 -6
View File
@@ -1,7 +1,7 @@
<template>
<view class="event-card" @click="$emit('item-click', event)">
<!-- 状态标签 -->
<view class="event-status" :class="'status-' + event.status">
<view class="event-status" :class="'status-' + statusKey">
<text class="event-status-text">{{ statusLabel }}</text>
</view>
@@ -32,7 +32,16 @@
</view>
<text class="event-org-name">{{ event.organizer ? event.organizer.nickname : '' }}</text>
</view>
<view class="event-action" v-if="event.status === 'open'">
<!-- 发起人编辑/删除 -->
<view class="event-manage" v-if="canManage" @click.stop>
<view class="event-manage-btn" @click.stop="$emit('edit', event)">
<text class="event-manage-text"> 编辑</text>
</view>
<view class="event-manage-btn" @click.stop="$emit('delete', event)">
<text class="event-manage-text event-manage-del">🗑 删除</text>
</view>
</view>
<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-join" v-else>报名</text>
</view>
@@ -44,14 +53,21 @@
export default {
name: 'EventCard',
props: {
event: { type: Object, default: () => ({}) }
event: { type: Object, default: () => ({}) },
/** 是否为发起人(可编辑/删除) */
canManage: { type: Boolean, default: false }
},
emits: ['item-click'],
emits: ['item-click', 'edit', 'delete'],
computed: {
/** 后端状态为数字枚举:1=报名中 2=待开始 3=进行中 4=已结束(兼容字符串返回) */
statusKey() {
const map = { 1: 'open', 2: 'upcoming', 3: 'ongoing', 4: 'ended' }
const raw = this.event.status
return map[Number(raw)] || String(raw).toLowerCase()
},
statusLabel() {
const val = String(this.event.status).toLowerCase()
const map = { open: '报名中', upcoming: '待开始', ongoing: '进行中', ended: '已结束' }
return map[val] || '报名中'
return map[this.statusKey] || '报名中'
}
}
}
@@ -182,4 +198,29 @@ export default {
background: linear-gradient(135deg, $amber, $amber-deep);
font-weight: $fw-bold;
}
.event-manage {
display: flex;
align-items: center;
gap: $sp-md;
}
.event-manage-btn {
padding: 8rpx 20rpx;
border-radius: $radius-full;
background: $bg-elevated;
&:active {
opacity: 0.7;
}
}
.event-manage-text {
font-size: $fs-sm;
color: $text-secondary;
}
.event-manage-del {
color: $coral;
}
</style>
+35 -4
View File
@@ -53,8 +53,13 @@
<text class="feed-action-icon">💬</text>
<text class="feed-action-num">{{ feed.comments || '' }}</text>
</view>
<view class="feed-action" @click.stop="$emit('share', feed)">
<text class="feed-action-icon"></text>
<button class="feed-action share-btn" open-type="share" @click.stop="$emit('share', feed)">
<text class="feed-action-icon">📤</text>
</button>
<!-- 删除仅自己的动态显示 -->
<view v-if="canDelete" class="feed-action feed-delete" @click.stop="$emit('delete', feed)">
<text class="feed-action-icon">🗑</text>
<text class="feed-delete-text">删除</text>
</view>
</view>
</view>
@@ -66,9 +71,11 @@ import { getCatIcon, isIconPath } from '../common/utils'
export default {
name: 'FeedCard',
props: {
feed: { type: Object, default: () => ({}) }
feed: { type: Object, default: () => ({}) },
// 是否允许删除(仅自己的动态传 true)
canDelete: { type: Boolean, default: false }
},
emits: ['item-click', 'like', 'comment', 'share'],
emits: ['item-click', 'like', 'comment', 'share', 'delete'],
methods: {
getCatIcon,
isIconPath,
@@ -228,6 +235,16 @@ export default {
border-top: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
/* 删除按钮靠右 */
.feed-delete {
margin-left: auto;
}
.feed-delete-text {
font-size: $fs-sm;
color: $text-tertiary;
}
.feed-action {
display: flex;
align-items: center;
@@ -251,4 +268,18 @@ export default {
font-size: $fs-sm;
color: $text-tertiary;
}
/* 分享按钮:重置 button 默认样式,保持与其他操作项视觉一致 */
.feed-action.share-btn {
margin: 0;
padding: 8rpx 0;
background: transparent;
line-height: 1;
border-radius: 0;
font-size: inherit;
&::after {
border: none;
}
}
</style>
+3 -3
View File
@@ -4,9 +4,9 @@
"requires": true,
"packages": {
"node_modules/HaveADrink": {
"version": "1.0.8",
"resolved": "https://npm.wash-painting.cn/HaveADrink/-/HaveADrink-1.0.8.tgz",
"integrity": "sha512-YOaqEZg+T1MKz1OtRJc09niyxEwm+52g91Yj0L2aGEy6bK6pTLJYlmREdneIStiniUGHVjL2/U5Qk57lTBAuFw==",
"version": "1.0.12",
"resolved": "https://npm.wash-painting.cn/HaveADrink/-/HaveADrink-1.0.12.tgz",
"integrity": "sha512-B9/2sOCIvMczQzsWA1qqzEOlwKxl9eC5OEqvRgh1dewgsbA4zzOkCQztuJxZOq9Sz5mwzEBM6/bcYdJyGAox3A==",
"license": "ISC"
}
}
+99 -2
View File
@@ -14,7 +14,7 @@
* 6. 社交模块 - 朋友圈动态、点赞
**版本:** v1.0.8
**版本:** v1.0.12
## 安装
@@ -100,6 +100,7 @@ client.WechatLogin(req).then(...).catch(...)
### 获取微信手机号
**已废弃:** 未使用
<font color="green">POST</font> `/api/have_a_drink/v1/auth/wechat/get_phone_number`
@@ -222,6 +223,7 @@ client.GetAchievements(req).then(...).catch(...)
### 获取朋友圈动态
**已废弃:** 不使用,待移除
<font color="green">GET</font> `/api/have_a_drink/v1/communication/feed`
@@ -1236,6 +1238,33 @@ const req = new PublishFeedReq()
client.PublishFeed(req).then(...).catch(...)
```
### 删除动态
<font color="green">DELETE</font> `/api/have_a_drink/v1/moments/feeds`
#### 请求参数
|名称|类型|校验规则|说明|
|:-|:-|:-|:-|
|id|`string\|number`|required| 动态ID|
#### 返回值
|名称|类型|说明|
|:-|:-:|:-|
|ok|`boolean`| 是否成功<br>|
```javascript
const req = new DeleteFeedReq()
client.DeleteFeed(req).then(...).catch(...)
```
### 点赞
@@ -1408,6 +1437,7 @@ client.GetFriends(req).then(...).catch(...)
#### 请求参数
|名称|类型|校验规则|说明|
|:-|:-|:-|:-|
|keyword|`string`|| 搜索关键词|
@@ -1445,7 +1475,6 @@ client.GetFriendRequests(req).then(...).catch(...)
#### 请求参数
|名称|类型|校验规则|说明|
|:-|:-|:-|:-|
|userId|`string\|number`|| 酒友ID|
|message|`string`|| 消息|
@@ -1454,6 +1483,7 @@ client.GetFriendRequests(req).then(...).catch(...)
#### 返回值
|名称|类型|说明|
|:-|:-:|:-|
|inviteCode|`string\|number`| 好友请求ID<br>|
@@ -1687,6 +1717,73 @@ const req = new CreateEventReq()
client.CreateEvent(req).then(...).catch(...)
```
### 修改酒局
<font color="green">POST</font> `/api/have_a_drink/v1/events/events/update`
#### 请求参数
|名称|类型|校验规则|说明|
|:-|:-|:-|:-|
|id|`string\|number`|required| 酒局ID|
|title|`string`|| 酒局标题|
|time|`string`|| 酒局时间|
|location|`string`|| 酒局地点|
|maxPeople|`number`|| 最大人数|
|geo|`GeoPoint`|| 坐标信息|
|note|`string`|| 酒局备注|
**GeoPoint**
|名称|类型|校验规则|说明|
|:-|:-|:-|:-|
|latitude|`number`|| 纬度|
|longitude|`number`|| 经度|
#### 返回值
|名称|类型|说明|
|:-|:-:|:-|
|ok|`boolean`| 是否成功<br>|
```javascript
const req = new UpdateEventReq()
client.UpdateEvent(req).then(...).catch(...)
```
### 删除酒局
<font color="green">POST</font> `/api/have_a_drink/v1/events/events/delete`
#### 请求参数
|名称|类型|校验规则|说明|
|:-|:-|:-|:-|
|id|`string\|number`|required| 酒局ID|
#### 返回值
|名称|类型|说明|
|:-|:-:|:-|
|ok|`boolean`| 是否成功<br>|
```javascript
const req = new DeleteEventReq()
client.DeleteEvent(req).then(...).catch(...)
```
### 报名酒局
+257 -13
View File
@@ -17,6 +17,15 @@ export declare interface option {
text: string;
}
export interface WebsocketInterface {
close: ((this: WebsocketInterface, ev: CloseEvent) => any) | null;
onopen: ((this: WebsocketInterface, ev: Event) => any) | null;
onmessage: ((this: WebsocketInterface, ev: MessageEvent) => any) | null;
onclose(event: CloseEvent): void;
onerror(event: ErrorEvent): void;
send(data: string | BufferSource | Blob): void;
}
/**
* 枚举类型基类
*/
@@ -2934,6 +2943,52 @@ export declare class PublishFeedResp {
static fromObject(o: Object): PublishFeedResp;
}
/**
* 删除动态请求(路径参数)
*/
export declare class DeleteFeedReq {
[key: string]: any;
/**
* 动态ID
*/
id: string|number;
/**
* @param id string|number 动态ID
*/
constructor(id: string|number,);
/**
* 从对象创建 DeleteFeedReq
*
* @param o Object
* - id: string|number, // 动态ID
*/
static fromObject(o: Object): DeleteFeedReq;
}
/**
* 删除动态响应
*/
export declare class DeleteFeedResp {
[key: string]: any;
/**
* 是否成功
*/
ok: boolean;
/**
* @param ok boolean 是否成功
*/
constructor(ok: boolean,);
/**
* 从对象创建 DeleteFeedResp
*
* @param o Object
* - ok: boolean, // 是否成功
*/
static fromObject(o: Object): DeleteFeedResp;
}
/**
* 点赞/取消点赞请求(路径参数)
*/
@@ -3265,14 +3320,20 @@ export declare class Friend {
*/
export declare class GetFriendRequestsReq {
[key: string]: any;
/**
* 搜索关键词
*/
keyword: string;
/**
* @param keyword string 搜索关键词
*/
constructor();
constructor(keyword: string,);
/**
* 从对象创建 GetFriendRequestsReq
*
* @param o Object
* - keyword: string, // 搜索关键词
*/
static fromObject(o: Object): GetFriendRequestsReq;
}
@@ -3405,24 +3466,18 @@ export declare class FriendRequestListData {
export declare class SendFriendRequestReq {
[key: string]: any;
/**
* 酒友ID
*/
userId: string|number;
/**
* 消息
*/
message: string;
/**
* @param userId string|number 酒友ID
* @param message string 消息
*/
constructor(userId: string|number,message: string,);
constructor(message: string,);
/**
* 从对象创建 SendFriendRequestReq
*
* @param o Object
* - userId: string|number, // 酒友ID
* - message: string, // 消息
*/
static fromObject(o: Object): SendFriendRequestReq;
@@ -3433,14 +3488,20 @@ export declare class SendFriendRequestReq {
*/
export declare class SendFriendRequestResp {
[key: string]: any;
/**
* 好友请求ID
*/
inviteCode: string|number;
/**
* @param inviteCode string|number 好友请求ID
*/
constructor();
constructor(inviteCode: string|number,);
/**
* 从对象创建 SendFriendRequestResp
*
* @param o Object
* - inviteCode: string|number, // 好友请求ID
*/
static fromObject(o: Object): SendFriendRequestResp;
}
@@ -3788,6 +3849,134 @@ export declare class CreateEventResp {
static fromObject(o: Object): CreateEventResp;
}
/**
* 修改酒局请求
*/
export declare class UpdateEventReq {
[key: string]: any;
/**
* 酒局ID
*/
id: string|number;
/**
* 酒局标题
*/
title: string;
/**
* 酒局时间
*/
time: string;
/**
* 酒局地点
*/
location: string;
/**
* 最大人数
*/
maxPeople: number;
/**
* 坐标信息
*/
geo: GeoPoint;
/**
* 酒局备注
*/
note: string;
/**
* @param id string|number 酒局ID
* @param title string 酒局标题
* @param time string 酒局时间
* @param location string 酒局地点
* @param maxPeople number 最大人数
* @param geo GeoPoint 坐标信息
* @param note string 酒局备注
*/
constructor(id: string|number,title: string,time: string,location: string,maxPeople: number,geo: GeoPoint,note: string,);
/**
* 从对象创建 UpdateEventReq
*
* @param o Object
* - id: string|number, // 酒局ID
* - title: string, // 酒局标题
* - time: string, // 酒局时间
* - location: string, // 酒局地点
* - maxPeople: number, // 最大人数
* - geo: GeoPoint, // 坐标信息
* - note: string, // 酒局备注
*/
static fromObject(o: Object): UpdateEventReq;
}
/**
* 修改酒局响应
*/
export declare class UpdateEventResp {
[key: string]: any;
/**
* 是否成功
*/
ok: boolean;
/**
* @param ok boolean 是否成功
*/
constructor(ok: boolean,);
/**
* 从对象创建 UpdateEventResp
*
* @param o Object
* - ok: boolean, // 是否成功
*/
static fromObject(o: Object): UpdateEventResp;
}
/**
* 删除酒局请求
*/
export declare class DeleteEventReq {
[key: string]: any;
/**
* 酒局ID
*/
id: string|number;
/**
* @param id string|number 酒局ID
*/
constructor(id: string|number,);
/**
* 从对象创建 DeleteEventReq
*
* @param o Object
* - id: string|number, // 酒局ID
*/
static fromObject(o: Object): DeleteEventReq;
}
/**
* 删除酒局响应
*/
export declare class DeleteEventResp {
[key: string]: any;
/**
* 是否成功
*/
ok: boolean;
/**
* @param ok boolean 是否成功
*/
constructor(ok: boolean,);
/**
* 从对象创建 DeleteEventResp
*
* @param o Object
* - ok: boolean, // 是否成功
*/
static fromObject(o: Object): DeleteEventResp;
}
/**
* 报名/取消/签到请求(路径参数)
*/
@@ -4617,6 +4806,29 @@ export declare class ChatWebSocketResp {
static fromObject(o: Object): ChatWebSocketResp;
}
/**
*
*/
export declare class ChatWebSocketReq {
[key: string]: any;
/**
* 连接 token
*/
token: string;
/**
* @param token string 连接 token
*/
constructor(token: string,);
/**
* 从对象创建 ChatWebSocketReq
*
* @param o Object
* - token: string, // 连接 token
*/
static fromObject(o: Object): ChatWebSocketReq;
}
export type ClientMessage = ClientChatData | ClientTypingData | ClientReadData | ClientPingData | null
export type ServerMessage = ServerChatData | ServerTypingData | ServerReadData | ServerAckData | ServerPongData | ServerConnectedData | ServerKickedData | null
@@ -4645,6 +4857,12 @@ export class ChatWebSocketConn {
public close(): void;
}
export class Config {
host: string;
http_request: (url: string, params: any) => Promise<any>;
upload: (url: string, params: any) => Promise<any>;
websocket: WebsocketInterface | undefined;
}
/**
* * 喝酒了么 - 后端接口 API
* 小程序:喝酒了么(uni-app 微信小程序)
@@ -4660,9 +4878,9 @@ export class ChatWebSocketConn {
*/
export default class HaveADrink {
host: string;
http_request: any;
upload: any;
constructor(conf: any);
http_request: (url: string, params: any) => Promise<any>;
upload: (url: string, params: any) => Promise<any>;
constructor(conf: Config);
/**
* 设置 token
@@ -4682,6 +4900,7 @@ export default class HaveADrink {
/**
* 获取微信手机号
*
* @deprecated 未使用
* @param req WechatGetPhoneNumberReq 获取微信手机号
* @return WechatGetPhoneNumberResp 获取微信手机号返回值
*/
@@ -4706,6 +4925,7 @@ export default class HaveADrink {
/**
* 获取朋友圈动态
*
* @deprecated 不使用,待移除
* @param req GetFeedReq 获取朋友圈动态请求
* @return GetFeedResp 获取朋友圈动态响应
*/
@@ -4831,6 +5051,14 @@ export default class HaveADrink {
*/
public PublishFeed(req: PublishFeedReq) : Promise<PublishFeedResp>;
/**
* 删除动态
*
* @param req DeleteFeedReq 删除动态请求(路径参数)
* @return DeleteFeedResp 删除动态响应
*/
public DeleteFeed(req: DeleteFeedReq) : Promise<DeleteFeedResp>;
/**
* 点赞
*
@@ -4927,6 +5155,22 @@ export default class HaveADrink {
*/
public CreateEvent(req: CreateEventReq) : Promise<CreateEventResp>;
/**
* 修改酒局
*
* @param req UpdateEventReq 修改酒局请求
* @return UpdateEventResp 修改酒局响应
*/
public UpdateEvent(req: UpdateEventReq) : Promise<UpdateEventResp>;
/**
* 删除酒局
*
* @param req DeleteEventReq 删除酒局请求
* @return DeleteEventResp 删除酒局响应
*/
public DeleteEvent(req: DeleteEventReq) : Promise<DeleteEventResp>;
/**
* 报名酒局
*
@@ -4989,6 +5233,6 @@ export default class HaveADrink {
* @param req ChatWebSocket
* @return ChatWebSocketConn
*/
public ChatWebSocket() : ChatWebSocketConn;
public ChatWebSocket(input: ChatWebSocketReq) : ChatWebSocketConn;
}
+250 -12
View File
@@ -13,6 +13,8 @@
* 6. 社交模块 - 朋友圈动态点赞
*/
let websocket = WebSocket;
export class Enum {
constructor(value) {
this.v = value;
@@ -2051,6 +2053,38 @@ export class PublishFeedResp {
}
}
/**
* 删除动态请求路径参数
*/
export class DeleteFeedReq {
/**
* @param id: string|number 动态ID
*/
constructor(id,) {
this.id = id;
}
static fromObject(o) {
return new DeleteFeedReq(o.id,);
}
}
/**
* 删除动态响应
*/
export class DeleteFeedResp {
/**
* @param ok: boolean 是否成功
*/
constructor(ok,) {
this.ok = ok;
}
static fromObject(o) {
return new DeleteFeedResp(o.ok,);
}
}
/**
* 点赞/取消点赞请求路径参数
*/
@@ -2248,12 +2282,14 @@ export class Friend {
*/
export class GetFriendRequestsReq {
/**
* @param keyword: string 搜索关键词
*/
constructor() {
constructor(keyword,) {
this.keyword = keyword;
}
static fromObject(o) {
return new GetFriendRequestsReq();
return new GetFriendRequestsReq(o.keyword,);
}
}
@@ -2336,16 +2372,14 @@ export class FriendRequestListData {
*/
export class SendFriendRequestReq {
/**
* @param userId: string|number 酒友ID
* @param message: string 消息
*/
constructor(userId,message,) {
this.userId = userId;
constructor(message,) {
this.message = message;
}
static fromObject(o) {
return new SendFriendRequestReq(o.userId,o.message,);
return new SendFriendRequestReq(o.message,);
}
}
@@ -2354,12 +2388,14 @@ export class SendFriendRequestReq {
*/
export class SendFriendRequestResp {
/**
* @param inviteCode: string|number 好友请求ID
*/
constructor() {
constructor(inviteCode,) {
this.inviteCode = inviteCode;
}
static fromObject(o) {
return new SendFriendRequestResp();
return new SendFriendRequestResp(o.inviteCode,);
}
}
@@ -2569,6 +2605,82 @@ export class CreateEventResp {
}
}
/**
* 修改酒局请求
*/
export class UpdateEventReq {
/**
* @param id: string|number 酒局ID
* @param title: string 酒局标题
* @param time: string 酒局时间
* @param location: string 酒局地点
* @param maxPeople: number 最大人数
* @param geo: GeoPoint 坐标信息
* @param note: string 酒局备注
*/
constructor(id,title,time,location,maxPeople,geo,note,) {
this.id = id;
this.title = title;
this.time = time;
this.location = location;
this.maxPeople = maxPeople;
this.geo = geo;
this.note = note;
}
static fromObject(o) {
return new UpdateEventReq(o.id,o.title,o.time,o.location,o.maxPeople,o.geo,o.note,);
}
}
/**
* 修改酒局响应
*/
export class UpdateEventResp {
/**
* @param ok: boolean 是否成功
*/
constructor(ok,) {
this.ok = ok;
}
static fromObject(o) {
return new UpdateEventResp(o.ok,);
}
}
/**
* 删除酒局请求
*/
export class DeleteEventReq {
/**
* @param id: string|number 酒局ID
*/
constructor(id,) {
this.id = id;
}
static fromObject(o) {
return new DeleteEventReq(o.id,);
}
}
/**
* 删除酒局响应
*/
export class DeleteEventResp {
/**
* @param ok: boolean 是否成功
*/
constructor(ok,) {
this.ok = ok;
}
static fromObject(o) {
return new DeleteEventResp(o.ok,);
}
}
/**
* 报名/取消/签到请求路径参数
*/
@@ -3087,19 +3199,36 @@ export class ChatWebSocketResp {
}
}
/**
*
*/
export class ChatWebSocketReq {
/**
* @param token: string 连接 token
*/
constructor(token,) {
this.token = token;
}
static fromObject(o) {
return new ChatWebSocketReq(o.token,);
}
}
export class ChatWebSocketConn {
/**
* 构造函数
*
* @param host string 主机名
*/
constructor(host) {
constructor(host, query) {
this.host = host;
this.reconnect = true;
this.reconnectDelay = 1000;
this.maxReconnectDelay = 5000;
this.randomizationFactor = 0.5;
this.attempt = 0;
this.query = query;
this.connect();
}
backoff(attempt) {
@@ -3115,7 +3244,7 @@ export class ChatWebSocketConn {
return delayWithJitter;
}
connect() {
let socket = new WebSocket(`ws://${this.host}/api/have_a_drink/v1/chat/chat`);
let socket = new websocket(`${this.host}/api/have_a_drink/v1/chat/chat?${this.query}`);
socket.onopen = (event) => {
this.onopen(event);
};
@@ -3281,6 +3410,12 @@ export class ChatWebSocketConn {
}
}
export class Config {
host = "";
http_request = (url, params) => {};
upload = (url, params) => {};
websocket = undefined;
}
/**
* * 喝酒了么 - 后端接口 API
* 小程序喝酒了么uni-app 微信小程序
@@ -3296,6 +3431,9 @@ export class ChatWebSocketConn {
*/
export default class HaveADrink {
constructor(conf) {
if (conf.websocket) {
websocket = conf.websocket;
}
this.host = conf.host;
this.http_request = conf.http_request;
this.upload = conf.upload;
@@ -3945,6 +4083,38 @@ export default class HaveADrink {
});
}
/**
* 删除动态
*/
DeleteFeed(req) {
return new Promise((reslove, reject)=>{
let data = req;
let url = `${this.host}/api/have_a_drink/v1/moments/feeds`;
this.http_request(url, {
uri: '/api/have_a_drink/v1/moments/feeds',
method: 'DELETE',
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));
});
}
/**
* 点赞
*/
@@ -4329,6 +4499,70 @@ export default class HaveADrink {
});
}
/**
* 修改酒局
*/
UpdateEvent(req) {
return new Promise((reslove, reject)=>{
let data = req;
let url = `${this.host}/api/have_a_drink/v1/events/events/update`;
this.http_request(url, {
uri: '/api/have_a_drink/v1/events/events/update',
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));
});
}
/**
* 删除酒局
*/
DeleteEvent(req) {
return new Promise((reslove, reject)=>{
let data = req;
let url = `${this.host}/api/have_a_drink/v1/events/events/delete`;
this.http_request(url, {
uri: '/api/have_a_drink/v1/events/events/delete',
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));
});
}
/**
* 报名酒局
*/
@@ -4556,9 +4790,13 @@ export default class HaveADrink {
/**
* 注意连接建立后服务端会先验证 token然后推送 connected 事件
*/
ChatWebSocket() {
ChatWebSocket(input) {
const url = new URL(this.host);
return new ChatWebSocketConn(url.host);
let host = "ws://" + url.host;
if (url.protocol == 'https:') {
host = "wss://" + url.host;
}
return new ChatWebSocketConn(host, new URLSearchParams(Object.fromEntries(Object.entries(input).filter(([_, v]) => v !== '' && v !== null && v !== undefined))).toString());
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "HaveADrink",
"type": "module",
"version": "v1.0.8",
"version": "v1.0.12",
"description": "喝酒了么 API 服务",
"main": "index.js",
"scripts": {
+4 -4
View File
@@ -5,13 +5,13 @@
"packages": {
"": {
"dependencies": {
"HaveADrink": "^1.0.8"
"HaveADrink": "^1.0.12"
}
},
"node_modules/HaveADrink": {
"version": "1.0.8",
"resolved": "https://npm.wash-painting.cn/HaveADrink/-/HaveADrink-1.0.8.tgz",
"integrity": "sha512-YOaqEZg+T1MKz1OtRJc09niyxEwm+52g91Yj0L2aGEy6bK6pTLJYlmREdneIStiniUGHVjL2/U5Qk57lTBAuFw==",
"version": "1.0.12",
"resolved": "https://npm.wash-painting.cn/HaveADrink/-/HaveADrink-1.0.12.tgz",
"integrity": "sha512-B9/2sOCIvMczQzsWA1qqzEOlwKxl9eC5OEqvRgh1dewgsbA4zzOkCQztuJxZOq9Sz5mwzEBM6/bcYdJyGAox3A==",
"license": "ISC"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"dependencies": {
"HaveADrink": "^1.0.8"
"HaveADrink": "^1.0.12"
}
}
+39 -12
View File
@@ -178,20 +178,30 @@ export default {
async initChat() {
// conversationId
if (!this.conversationId && this.friendId) {
try {
const res = await GetConversations()
const conv = (res.data.list || []).find(c => String(c.friendId) === String(this.friendId))
if (conv) {
this.conversationId = conv.id
}
} catch (e) { /* ignore */ }
await this.ensureConversationId()
}
this.loadMessages()
this.markRead()
this.bindWsEvents()
},
/** 从会话列表按 friendId 查找 conversationId(首次聊天时会话尚未创建,返回 false) */
async ensureConversationId() {
if (this.conversationId) return true
if (!this.friendId) return false
try {
const res = await GetConversations()
const conv = (res.data.list || []).find(c => String(c.friendId) === String(this.friendId))
if (conv) {
this.conversationId = conv.id
return true
}
} catch (e) { /* ignore */ }
return false
},
// === ===
async loadMessages() {
// ID
if (!this.conversationId) return
try {
const res = await GetMessages({ conversationId: this.conversationId, lastID: this.lastID, pageSize: 20 })
this.messages = res.data.list || []
@@ -209,6 +219,8 @@ export default {
},
async markRead() {
// ID
if (!this.conversationId) return
try {
await MarkRead({ conversationId: this.conversationId })
// WebSocket
@@ -238,6 +250,10 @@ export default {
onWsMessage(data) {
//
if (String(data.senderId) !== String(this.friendId)) return
// conversationId
if (!this.conversationId && data.conversationId) {
this.conversationId = data.conversationId
}
const msg = {
id: data.msgId ? String(data.msgId) : 'msg_' + Date.now(),
conversationId: data.conversationId || this.conversationId,
@@ -251,11 +267,13 @@ export default {
this.messages.push(msg)
this.peerTyping = false
this.$nextTick(() => this.scrollToBottom())
//
wsManager.send('read', {
conversationId: this.conversationId,
lastMsgId: msg.id
})
// ID
if (this.conversationId) {
wsManager.send('read', {
conversationId: this.conversationId,
lastMsgId: msg.id
})
}
},
/** 对方正在输入 */
@@ -270,6 +288,10 @@ export default {
/** 消息送达确认 */
onWsAck(data) {
// ack ID
if (!this.conversationId && data.conversationId) {
this.conversationId = data.conversationId
}
const msg = this.messages.find(m => m.id === data.clientMsgId || m.id === String(data.clientMsgId))
if (msg) {
msg.id = data.msgId ? String(data.msgId) : msg.id
@@ -308,6 +330,11 @@ export default {
msgType: 'text',
clientMesgId: clientMsgId
})
// ID
if (!this.conversationId) {
setTimeout(() => { this.ensureConversationId() }, 800)
}
},
/** 重发失败消息 */
+79 -4
View File
@@ -17,7 +17,10 @@
<FeedCard
v-if="feed"
:feed="feed"
:can-delete="isMyFeed(feed)"
@like="handleLike"
@share="handleShare"
@delete="confirmDeleteFeed"
/>
<!-- 评论区 -->
@@ -62,7 +65,7 @@
import FeedCard from '../../components/FeedCard.vue'
import CommentItem from '../../components/CommentItem.vue'
import EmptyState from '../../components/EmptyState.vue'
import { GetFeedComments, AddComment, LikeFeed, UnlikeFeed } from '../../common/api'
import { GetFeedComments, AddComment, LikeFeed, UnlikeFeed, DeleteFeed } from '../../common/api'
import themeMixin from '../../common/theme-mixin'
export default {
@@ -75,11 +78,16 @@ export default {
feedId: '',
feed: null,
comments: [],
commentText: ''
commentText: '',
//
shareFeed: null,
//
shareOptions: {}
}
},
onLoad(options) {
this.feedId = options.id || ''
this.shareOptions = options || {}
this.loadFeed()
this.loadComments()
},
@@ -88,10 +96,27 @@ export default {
uni.navigateBack()
},
async loadFeed() {
// globalData
// globalData
const app = getApp()
const feeds = (app.globalData && app.globalData.circleFeeds) || []
this.feed = feeds.find(f => String(f.id) === String(this.feedId)) || null
const found = feeds.find(f => String(f.id) === String(this.feedId)) || null
if (found) {
this.feed = found
return
}
//
const o = this.shareOptions
if (o.nick || o.text) {
this.feed = {
id: this.feedId,
nickname: decodeURIComponent(o.nick || ''),
text: decodeURIComponent(o.text || ''),
time: decodeURIComponent(o.time || ''),
avatar: '',
likes: 0,
comments: 0
}
}
},
async loadComments() {
try {
@@ -109,6 +134,39 @@ export default {
else await UnlikeFeed({ feedId: feed.id })
} catch (e) { /* ignore */ }
},
/** 记录当前要分享的动态,供 onShareAppMessage 读取 */
handleShare(feed) {
this.shareFeed = feed
},
/** 判断是否为自己发布的动态(仅自己可删) */
isMyFeed(feed) {
let myId = ''
try {
const user = JSON.parse(uni.getStorageSync('user_info') || '{}')
myId = user.id !== undefined && user.id !== null ? String(user.id) : ''
} catch (e) { /* ignore */ }
if (!myId) return false
return feed && feed.userId !== undefined && feed.userId !== null && String(feed.userId) === myId
},
/** 删除自己的动态(二次确认),成功后返回列表 */
confirmDeleteFeed(feed) {
uni.showModal({
title: '删除动态',
content: '确定要删除这条动态吗?删除后不可恢复',
confirmText: '删除',
confirmColor: '#FF6B6B',
success: async (res) => {
if (!res.confirm) return
try {
await DeleteFeed({ feedId: feed.id })
uni.showToast({ title: '已删除', icon: 'none' })
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {
// httpRequest
}
}
})
},
async submitComment() {
const text = this.commentText.trim()
if (!text) return
@@ -128,6 +186,23 @@ export default {
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
},
onShareAppMessage() {
const feed = this.shareFeed || this.feed
if (feed && feed.id) {
const text = (feed.text || '').replace(/\s+/g, ' ').trim()
const title = text
? `${feed.nickname || '酒友'}${text.length > 24 ? text.slice(0, 24) + '…' : text}`
: `${feed.nickname || '酒友'}的动态 - 碰盏日记`
return {
title,
path: `/pages/circle-detail/circle-detail?id=${feed.id}&nick=${encodeURIComponent(feed.nickname || '')}&text=${encodeURIComponent(text.slice(0, 60))}&time=${encodeURIComponent(feed.time || '')}`
}
}
return {
title: '酒友圈 - 碰盏日记',
path: '/pages/index/index'
}
}
}
</script>
+250 -9
View File
@@ -41,8 +41,11 @@
v-for="feed in feeds"
:key="feed.id"
:feed="feed"
:can-delete="isMyFeed(feed)"
@like="handleLike"
@comment="goDetail"
@share="handleShare"
@delete="confirmDeleteFeed"
@item-click="goDetail"
/>
<view v-if="loading" class="circle-loading">
@@ -71,12 +74,23 @@
<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="👥"
@@ -95,6 +109,9 @@
v-for="evt in events"
:key="evt.id"
:event="evt"
:can-manage="evt.isOrganizer === true"
@edit="goEditEvent"
@delete="confirmDeleteEvent"
@item-click="goEventDetail"
/>
<EmptyState
@@ -120,8 +137,9 @@ import FeedCard from '../../components/FeedCard.vue'
import FriendItem from '../../components/FriendItem.vue'
import EventCard from '../../components/EventCard.vue'
import EmptyState from '../../components/EmptyState.vue'
import { GetCircleFeeds, LikeFeed, UnlikeFeed, GetFriends, GetFriendRequests, GetEvents, GetUnreadCount } from '../../common/api'
import { GetCircleFeeds, LikeFeed, UnlikeFeed, DeleteFeed, GetFriends, GetFriendRequests, GetEvents, DeleteEvent, GetUnreadCount } from '../../common/api'
import wsManager from '../../common/websocket'
import { getMyInviteCode } from '../../common/invite'
import themeMixin from '../../common/theme-mixin'
export default {
@@ -147,7 +165,15 @@ export default {
//
events: [],
//
unreadTotal: 0
unreadTotal: 0,
//
shareFeed: null,
// feed() / invite() / default
shareMode: 'default',
//
inviteCode: '',
// ID
myUserId: ''
}
},
onShow() {
@@ -155,16 +181,56 @@ export default {
this.loadFriends()
this.loadEvents()
this.loadUnread()
this._wsHandler = () => { this.unreadTotal++ }
wsManager.on('message', this._wsHandler)
this.bindWsEvents()
// 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.ensureInviteCode()
// WS /线
wsManager.connect()
},
onHide() {
if (this._wsHandler) {
wsManager.off('message', this._wsHandler)
this._wsHandler = null
}
this.unbindWsEvents()
},
onUnload() {
this.unbindWsEvents()
},
methods: {
// === WebSocket ===
bindWsEvents() {
if (this._wsBound) return
this._wsBound = true
this._wsMsgHandler = this.onWsMessage
this._wsConnectHandler = this.onWsConnect
wsManager.on('message', this._wsMsgHandler)
wsManager.on('connect', this._wsConnectHandler)
},
unbindWsEvents() {
if (!this._wsBound) return
this._wsBound = false
if (this._wsMsgHandler) {
wsManager.off('message', this._wsMsgHandler)
this._wsMsgHandler = null
}
if (this._wsConnectHandler) {
wsManager.off('connect', this._wsConnectHandler)
this._wsConnectHandler = null
}
},
/** 收到新私信:未读角标实时 +1(自己发的/自己读的不计数) */
onWsMessage(data) {
if (!data) return
const senderId = data.senderId
if (senderId && wsManager.userId && String(senderId) === String(wsManager.userId)) return
this.unreadTotal++
},
/** (重)连成功后拉取真实未读数,修正断线期间的漏计/多计 */
onWsConnect() {
this.loadUnread()
},
switchTab(i) {
this.currentTab = i
},
@@ -218,9 +284,49 @@ export default {
}
} catch (e) { /* ignore */ }
},
/** 判断是否为自己发布的动态(仅自己可删) */
isMyFeed(feed) {
if (!this.myUserId) return false
return feed && feed.userId !== undefined && feed.userId !== null && String(feed.userId) === this.myUserId
},
/** 删除自己的动态(二次确认) */
confirmDeleteFeed(feed) {
uni.showModal({
title: '删除动态',
content: '确定要删除这条动态吗?删除后不可恢复',
confirmText: '删除',
confirmColor: '#FF6B6B',
success: async (res) => {
if (!res.confirm) return
try {
await DeleteFeed({ feedId: feed.id })
this.feeds = this.feeds.filter(f => String(f.id) !== String(feed.id))
uni.showToast({ title: '已删除', icon: 'none' })
} catch (e) {
// httpRequest
}
}
})
},
goDetail(feed) {
uni.navigateTo({ url: `/pages/circle-detail/circle-detail?id=${feed.id}` })
},
/** 记录当前要分享的动态,供 onShareAppMessage 读取 */
handleShare(feed) {
this.shareMode = 'feed'
this.shareFeed = feed
},
/** 点击邀请按钮:标记本次分享为邀请模式 */
prepareInviteShare() {
this.shareMode = 'invite'
},
/** 确保邀请码已生成(仅缺失时拉取,避免频繁创建一次性邀请) */
ensureInviteCode() {
if (this.inviteCode) return
getMyInviteCode().then(code => {
if (code) this.inviteCode = code
})
},
goPublish() {
uni.navigateTo({ url: '/pages/circle-publish/circle-publish' })
},
@@ -255,7 +361,9 @@ export default {
async loadUnread() {
try {
const res = await GetUnreadCount()
this.unreadTotal = res.data.total
if (res.data && typeof res.data.total === 'number') {
this.unreadTotal = res.data.total
}
} catch (e) { /* ignore */ }
},
// === ===
@@ -270,6 +378,29 @@ export default {
goEventDetail(evt) {
uni.navigateTo({ url: `/pages/event-detail/event-detail?id=${evt.id}` })
},
/** 编辑自己发起的酒局(复用发起页的编辑模式) */
goEditEvent(evt) {
uni.navigateTo({ url: `/pages/event-create/event-create?id=${evt.id}` })
},
/** 删除自己发起的酒局(二次确认) */
confirmDeleteEvent(evt) {
uni.showModal({
title: '删除酒局',
content: '确定要删除这场酒局吗?删除后不可恢复',
confirmText: '删除',
confirmColor: '#FF6B6B',
success: async (res) => {
if (!res.confirm) return
try {
await DeleteEvent({ eventId: evt.id })
this.events = this.events.filter(e => String(e.id) !== String(evt.id))
uni.showToast({ title: '已删除', icon: 'none' })
} catch (e) {
// httpRequest
}
}
})
},
goCreateEvent() {
uni.navigateTo({ url: '/pages/event-create/event-create' })
},
@@ -281,6 +412,49 @@ export default {
this.goPublish()
}
}
},
onShareAppMessage() {
//
if (this.shareMode === 'invite') {
let user = {}
try {
user = JSON.parse(uni.getStorageSync('user_info') || '{}')
} catch (e) { /* ignore */ }
const parts = []
if (this.inviteCode) parts.push(`inviteCode=${encodeURIComponent(this.inviteCode)}`)
if (user.nickname) parts.push(`inviterNick=${encodeURIComponent(user.nickname)}`)
const query = parts.length ? `?${parts.join('&')}` : ''
const nick = user.nickname ? `${user.nickname}` : ''
// 使
this.inviteCode = ''
this.ensureInviteCode()
this.shareMode = 'default'
return {
title: `${nick}邀请你成为酒友,一起记录饮酒生活 🍻`,
path: `/pages/index/index${query}`
}
}
const feed = this.shareFeed
if (this.shareMode === 'feed' && feed && feed.id) {
this.shareMode = 'default'
const text = (feed.text || '').replace(/\s+/g, ' ').trim()
const title = text
? `${feed.nickname || '酒友'}${text.length > 24 ? text.slice(0, 24) + '…' : text}`
: `${feed.nickname || '酒友'}的动态 - 碰盏日记`
return {
title,
path: `/pages/circle-detail/circle-detail?id=${feed.id}&nick=${encodeURIComponent(feed.nickname || '')}&text=${encodeURIComponent(text.slice(0, 60))}&time=${encodeURIComponent(feed.time || '')}`
}
}
return {
title: '酒友圈 - 碰盏日记',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '酒友圈 - 碰盏日记'
}
}
}
</script>
@@ -443,6 +617,73 @@ export default {
opacity: 0.6;
}
/* 酒友管理入口 */
.friends-entry {
display: flex;
align-items: center;
gap: $sp-sm;
padding: $sp-md $sp-lg;
background: $bg-card;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
border-radius: $radius-lg;
margin-bottom: $sp-md;
&:active {
background: $bg-card-alt;
}
}
.friends-entry-icon {
font-size: $fs-base;
}
.friends-entry-text {
flex: 1;
font-size: $fs-sm;
color: $text-secondary;
font-weight: $fw-medium;
}
.friends-entry-arrow {
font-size: $fs-xl;
color: $text-tertiary;
opacity: 0.6;
}
/* 邀请更多酒友按钮(重置 button 默认样式) */
.invite-btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-sm;
width: 100%;
margin: $sp-md 0 0;
padding: $sp-lg 0;
background: rgba(232,168,56,0.08);
border: 1rpx dashed rgba(232,168,56,0.35);
border-radius: $radius-lg;
line-height: 1;
font-size: inherit;
&:active {
background: rgba(232,168,56,0.14);
}
&::after {
border: none;
}
}
.invite-btn-icon {
font-size: $fs-base;
}
.invite-btn-text {
font-size: $fs-sm;
color: $amber;
font-weight: $fw-bold;
}
/* FAB */
.fab {
position: fixed;
+64 -17
View File
@@ -6,7 +6,7 @@
<view class="create-back" @click="goBack">
<text class="create-back-icon"></text>
</view>
<text class="create-nav-title">发起酒局</text>
<text class="create-nav-title">{{ isEdit ? '编辑酒局' : '发起酒局' }}</text>
<view class="create-nav-ph"></view>
</view>
</view>
@@ -98,9 +98,9 @@
/>
</view>
<!-- 发布按钮 -->
<!-- 发布/保存按钮 -->
<button class="create-submit" :class="{ disabled: !canSubmit }" @click="handleSubmit">
发布酒局
{{ isEdit ? '保存修改' : '发布酒局' }}
</button>
</view>
</scroll-view>
@@ -108,7 +108,7 @@
</template>
<script>
import { CreateEvent } from '../../common/api'
import { CreateEvent, UpdateEvent, GetEventDetail } from '../../common/api'
import themeMixin from '../../common/theme-mixin'
export default {
@@ -132,12 +132,24 @@ export default {
maxPeople: 6,
note: ''
},
submitting: false
submitting: false,
// ID
editId: '',
loadingDetail: false
}
},
onLoad(options) {
if (options && options.id) {
this.editId = options.id
this.loadEventDetail()
}
},
computed: {
isEdit() {
return !!this.editId
},
canSubmit() {
return this.form.title.trim() && this.form.date && this.form.time && this.form.location.trim() && !this.submitting
return this.form.title.trim() && this.form.date && this.form.time && this.form.location.trim() && !this.submitting && !this.loadingDetail
},
locationMarkers() {
if (!this.form.latitude) return []
@@ -155,6 +167,35 @@ export default {
goBack() {
uni.navigateBack()
},
/** 编辑模式:拉取酒局详情回填表单 */
async loadEventDetail() {
this.loadingDetail = true
try {
const res = await GetEventDetail({ eventId: this.editId })
const evt = res.data || {}
this.form.title = evt.title || ''
// time "2026-08-08 20:00:00"/
const timeStr = String(evt.time || '')
const m = timeStr.match(/^(\d{4}-\d{2}-\d{2})[ T]?(\d{2}:\d{2})?/)
if (m) {
this.form.date = m[1]
this.form.time = m[2] || ''
}
this.form.location = evt.location || ''
this.form.address = evt.address || ''
// geo
const geo = evt.geo || {}
this.form.latitude = evt.latitude != null ? evt.latitude : (geo.latitude != null ? geo.latitude : null)
this.form.longitude = evt.longitude != null ? evt.longitude : (geo.longitude != null ? geo.longitude : null)
this.form.maxPeople = Number(evt.maxPeople) || this.form.maxPeople
this.form.note = evt.note || ''
} catch (e) {
//
uni.showToast({ title: '加载酒局失败', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
}
this.loadingDetail = false
},
onDateChange(e) {
this.form.date = e.detail.value
},
@@ -201,18 +242,24 @@ export default {
async handleSubmit() {
if (!this.canSubmit) return
this.submitting = true
const payload = {
title: this.form.title,
time: `${this.form.date} ${this.form.time}:00`,
location: this.form.location,
address: this.form.address,
latitude: this.form.latitude,
longitude: this.form.longitude,
maxPeople: this.form.maxPeople,
note: this.form.note
}
try {
await CreateEvent({
title: this.form.title,
time: `${this.form.date} ${this.form.time}:00`,
location: this.form.location,
address: this.form.address,
latitude: this.form.latitude,
longitude: this.form.longitude,
maxPeople: this.form.maxPeople,
note: this.form.note
})
uni.showToast({ title: '酒局已发布 🎉', icon: 'none' })
if (this.isEdit) {
await UpdateEvent({ eventId: this.editId, ...payload })
uni.showToast({ title: '修改已保存 ✅', icon: 'none' })
} else {
await CreateEvent(payload)
uni.showToast({ title: '酒局已发布 🎉', icon: 'none' })
}
setTimeout(() => uni.navigateBack(), 800)
} catch (e) {
//
+71 -12
View File
@@ -14,7 +14,7 @@
<scroll-view class="evt-scroll" scroll-y>
<view class="evt-body" v-if="event">
<!-- 状态 -->
<view class="evt-status" :class="'status-' + event.status">
<view class="evt-status" :class="'status-' + statusKey">
<text class="evt-status-text">{{ statusLabel }}</text>
</view>
@@ -94,42 +94,47 @@
</view>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="evt-action-bar" v-if="event">
<button
v-if="(event.status === 'open' || event.status === 'upcoming') && !event.isJoined && !event.isOrganizer"
v-if="canJoin && !event.isJoined && !event.isOrganizer"
class="evt-btn evt-btn-primary"
@click="handleJoin"
>
报名参加
</button>
<button
v-if="(event.status === 'open' || event.status === 'upcoming') && event.isJoined && !event.isOrganizer"
v-if="canJoin && event.isJoined && !event.isOrganizer"
class="evt-btn evt-btn-ghost"
@click="handleQuit"
>
取消报名
</button>
<button
v-if="event.status === 'ongoing' && event.isJoined"
v-if="statusKey === 'ongoing' && event.isJoined"
class="evt-btn evt-btn-primary"
@click="handleCheckIn"
>
到场签到
</button>
<view v-if="event.status === 'ended'" class="evt-ended-tip">
<view v-if="statusKey === 'ended' && !event.isOrganizer" class="evt-ended-tip">
<text class="evt-ended-text">酒局已结束</text>
</view>
<view v-if="event.isOrganizer" class="evt-ended-tip">
<text class="evt-ended-text">你是发起人</text>
<!-- 发起人编辑/删除自己的酒局 -->
<view v-if="event.isOrganizer" class="evt-org-actions">
<button class="evt-btn evt-btn-ghost" @click="goEdit">
编辑酒局
</button>
<button class="evt-btn evt-btn-danger" @click="confirmDelete">
🗑 删除酒局
</button>
</view>
</view>
</view>
</template>
<script>
import { GetEventDetail, JoinEvent, QuitEvent, CheckInEvent } from '../../common/api'
import { GetEventDetail, JoinEvent, QuitEvent, CheckInEvent, DeleteEvent } from '../../common/api'
import themeMixin from '../../common/theme-mixin'
export default {
@@ -143,11 +148,20 @@ export default {
}
},
computed: {
statusLabel() {
/** 后端状态为数字枚举:1=报名中 2=待开始 3=进行中 4=已结束(兼容字符串返回) */
statusKey() {
if (!this.event) return ''
const val = String(this.event.status).toLowerCase()
const map = { 1: 'open', 2: 'upcoming', 3: 'ongoing', 4: 'ended' }
const raw = this.event.status
return map[Number(raw)] || String(raw).toLowerCase()
},
/** 报名中/待开始阶段可报名或取消 */
canJoin() {
return this.statusKey === 'open' || this.statusKey === 'upcoming'
},
statusLabel() {
const map = { open: '报名中', upcoming: '待开始', ongoing: '进行中', ended: '已结束' }
return map[val] || '报名中'
return map[this.statusKey] || '报名中'
},
eventMarkers() {
if (!this.event || !this.event.latitude) return []
@@ -165,6 +179,12 @@ export default {
this.eventId = options.id || ''
this.loadDetail()
},
onShow() {
//
if (this.eventId && this._hasLoaded) {
this.loadDetail()
}
},
methods: {
goBack() {
uni.navigateBack()
@@ -173,10 +193,34 @@ export default {
try {
const res = await GetEventDetail({ eventId: this.eventId })
this.event = res.data
this._hasLoaded = true
} catch (e) {
console.warn('加载酒局详情失败', e)
}
},
/** 发起人编辑酒局(复用发起页的编辑模式) */
goEdit() {
uni.navigateTo({ url: `/pages/event-create/event-create?id=${this.eventId}` })
},
/** 发起人删除酒局(二次确认,成功后返回上一页) */
confirmDelete() {
uni.showModal({
title: '删除酒局',
content: '确定要删除这场酒局吗?删除后不可恢复',
confirmText: '删除',
confirmColor: '#FF6B6B',
success: async (res) => {
if (!res.confirm) return
try {
await DeleteEvent({ eventId: this.eventId })
uni.showToast({ title: '已删除', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
} catch (e) {
// httpRequest
}
}
})
},
async handleJoin() {
try {
await JoinEvent({ eventId: this.eventId })
@@ -525,4 +569,19 @@ export default {
font-size: $fs-base;
color: $text-tertiary;
}
.evt-org-actions {
display: flex;
gap: $sp-md;
.evt-btn {
flex: 1;
}
}
.evt-btn-danger {
background: rgba(255,107,107,0.1);
color: $coral;
border: 1rpx solid rgba(255,107,107,0.25);
}
</style>
+73 -4
View File
@@ -64,6 +64,11 @@
</view>
</template>
</FriendItem>
<!-- 有好友时也保留邀请入口直接触发带邀请码的转发 -->
<button v-if="!keyword" class="invite-btn" open-type="share">
<text class="invite-btn-icon">👥</text>
<text class="invite-btn-text">邀请更多酒友</text>
</button>
</view>
<!-- 空状态 -->
@@ -90,6 +95,7 @@
import FriendItem from '../../components/FriendItem.vue'
import EmptyState from '../../components/EmptyState.vue'
import { GetFriends, GetFriendRequests, AcceptFriendRequest, RemoveFriend } from '../../common/api'
import { savePendingInvite, handlePendingInvite, getMyInviteCode } from '../../common/invite'
import themeMixin from '../../common/theme-mixin'
export default {
@@ -102,13 +108,30 @@ export default {
keyword: '',
friends: [],
requests: [],
searchTimer: null
searchTimer: null,
//
inviteCode: ''
}
},
onLoad() {
onLoad(options) {
this.loadData()
//
if (options && options.inviteCode) {
savePendingInvite(options.inviteCode, options.inviterNick)
if (uni.getStorageSync('is_logged_in') === 'true') {
handlePendingInvite().then(() => this.loadData())
}
}
// 使
this.refreshInviteCode()
},
methods: {
/** 生成新的邀请码(每次分享后调用,保证下次分享用新码) */
refreshInviteCode() {
getMyInviteCode().then(code => {
this.inviteCode = code
})
},
goBack() {
uni.navigateBack()
},
@@ -173,9 +196,21 @@ export default {
}
},
onShareAppMessage() {
//
let user = {}
try {
user = JSON.parse(uni.getStorageSync('user_info') || '{}')
} catch (e) { /* ignore */ }
const parts = []
if (this.inviteCode) parts.push(`inviteCode=${encodeURIComponent(this.inviteCode)}`)
if (user.nickname) parts.push(`inviterNick=${encodeURIComponent(user.nickname)}`)
const query = parts.length ? `?${parts.join('&')}` : ''
const nick = user.nickname ? `${user.nickname}` : ''
//
this.refreshInviteCode()
return {
title: '来「碰盏日记」一起记录饮酒生活吧!',
path: '/pages/index/index'
title: `${nick}邀请你成为酒友,一起记录饮酒生活 🍻`,
path: `/pages/index/index${query}`
}
}
}
@@ -375,4 +410,38 @@ export default {
font-size: $fs-xs;
color: $coral;
}
/* 邀请更多酒友按钮(重置 button 默认样式) */
.invite-btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-sm;
width: 100%;
margin: $sp-lg 0 0;
padding: $sp-lg 0;
background: rgba(232,168,56,0.08);
border: 1rpx dashed rgba(232,168,56,0.35);
border-radius: $radius-lg;
line-height: 1;
font-size: inherit;
&:active {
background: rgba(232,168,56,0.14);
}
&::after {
border: none;
}
}
.invite-btn-icon {
font-size: $fs-base;
}
.invite-btn-text {
font-size: $fs-sm;
color: $amber;
font-weight: $fw-bold;
}
</style>
+10
View File
@@ -134,6 +134,7 @@ import client from '../../common/api'
import { GetCalendarReq, DeleteRecordReq, CreateRecordReq, GetRecordDetailReq } from 'HaveADrink'
import { getGreeting, formatDate, getWeekStart, getCatIcon, isIconPath } from '../../common/utils'
import { FEELINGS, DRINK_CATEGORIES, DRINK_UNITS } from '../../common/constants'
import { savePendingInvite, handlePendingInvite } from '../../common/invite'
import themeMixin from '../../common/theme-mixin'
export default {
@@ -170,6 +171,15 @@ export default {
return this.selectedDate === formatDate(new Date(), 'YYYY-MM-DD')
}
},
onLoad(options) {
// inviteCode
if (options && options.inviteCode) {
savePendingInvite(options.inviteCode, options.inviterNick)
if (uni.getStorageSync('is_logged_in') === 'true') {
handlePendingInvite()
}
}
},
onShow() {
//
const isFirst = uni.getStorageSync('is_first_launch')
+11
View File
@@ -91,6 +91,8 @@
<script>
import themeMixin from '../../common/theme-mixin'
import client, { saveAuthTokens } from '../../common/api'
import { handlePendingInvite } from '../../common/invite'
import wsManager from '../../common/websocket'
export default {
mixins: [themeMixin],
@@ -205,6 +207,15 @@ export default {
finishLogin() {
uni.setStorageSync('is_logged_in', 'true')
uni.setStorageSync('is_first_launch', 'false')
// token WebSocket
// token token
const token = uni.getStorageSync('auth_token')
if (token) {
wsManager.disconnect()
wsManager.connect(token)
}
//
handlePendingInvite()
uni.switchTab({ url: '/pages/index/index' })
},
+2 -1
View File
@@ -71,7 +71,7 @@
</view>
</view>
<!-- 连续打卡卡片 -->
<!-- 连续打卡卡片含连续戒酒状态暂时注释隐藏
<view class="streak-section">
<view class="streak-card" :class="stats.streakType === 'drank' ? 'streak-drank' : 'streak-abstain'">
<view class="streak-left">
@@ -89,6 +89,7 @@
</view>
</view>
</view>
-->
<!-- 饮酒人格卡片 -->
<view class="persona-card">