From 57eef74eb11765599df3fb1a937012a81001e8ee Mon Sep 17 00:00:00 2001 From: cheng <545895878@qq.com> Date: Tue, 4 Aug 2026 23:32:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E5=8D=87=E7=BA=A7HaveADrink=20SDK?= =?UTF-8?q?=E5=B9=B6=E9=9B=86=E6=88=90=E9=82=80=E8=AF=B7=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将HaveADrink依赖从1.0.8升级至1.0.12版本 - 集成邀请码功能,新增common/invite.js处理邀请链路 - 实现发送好友请求返回邀请码的新流程 - 添加删除动态功能,新增DeleteFeed接口 - 集成UniAppWebSocket适配器,重构WebSocket连接管理 - 优化好友请求支持关键词搜索功能 - 在FeedCard组件中添加删除按钮和分享按钮 - 更新SDK类型定义文件以匹配新接口规范 --- common/api.js | 24 ++- common/invite.js | 86 ++++++++ common/uni-websocket.js | 156 +++++++++++++++ common/websocket.js | 260 +++++++++++-------------- components/FeedCard.vue | 39 +++- node_modules/.package-lock.json | 6 +- node_modules/HaveADrink/README.md | 101 +++++++++- node_modules/HaveADrink/index.d.ts | 270 ++++++++++++++++++++++++-- node_modules/HaveADrink/index.js | 262 +++++++++++++++++++++++-- node_modules/HaveADrink/package.json | 2 +- package-lock.json | 8 +- package.json | 2 +- pages/chat/chat.vue | 51 +++-- pages/circle-detail/circle-detail.vue | 83 +++++++- pages/circle/circle.vue | 233 +++++++++++++++++++++- pages/friends/friends.vue | 77 +++++++- pages/index/index.vue | 10 + pages/login/login.vue | 11 ++ pages/profile/profile.vue | 3 +- 19 files changed, 1461 insertions(+), 223 deletions(-) create mode 100644 common/invite.js create mode 100644 common/uni-websocket.js diff --git a/common/api.js b/common/api.js index 692999f..2dd8795 100644 --- a/common/api.js +++ b/common/api.js @@ -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 内部调用) @@ -113,6 +114,7 @@ const client = new HaveADrink({ host: API_HOST, http_request: httpRequest, upload: uploadRequest, + websocket: UniAppWebSocket, token: uni.getStorageSync('auth_token') || '' }) @@ -186,6 +188,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 }) @@ -210,19 +218,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 } diff --git a/common/invite.js b/common/invite.js new file mode 100644 index 0000000..7da96e5 --- /dev/null +++ b/common/invite.js @@ -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} 邀请码,未登录或失败返回空字符串 + */ +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} 是否成功接受 + */ +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 + } +} diff --git a/common/uni-websocket.js b/common/uni-websocket.js new file mode 100644 index 0000000..356fbe3 --- /dev/null +++ b/common/uni-websocket.js @@ -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) => { + // 诊断:打印关闭码/原因,用于判断是服务端拒连还是网络问题 + // 常见 code:1006 异常断开、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)) + } +} diff --git a/common/websocket.js b/common/websocket.js index bd936e8..114bc78 100644 --- a/common/websocket.js +++ b/common/websocket.js @@ -1,10 +1,34 @@ /* 碰盏日记 - WebSocket 管理器 * 单例模式,负责私信实时通信 - * 协议格式与 HaveADrink SDK ChatWebSocket 一致:{ mesgType, data } - * 支持心跳、自动重连、消息队列 + * 基于 HaveADrink SDK 的 ChatWebSocket(ChatWebSocketConn)实现: + * - 底层 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 -> wss,query 键值对拼接 + * @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)) } } diff --git a/components/FeedCard.vue b/components/FeedCard.vue index ef3e87b..1504f7b 100644 --- a/components/FeedCard.vue +++ b/components/FeedCard.vue @@ -53,8 +53,13 @@ 💬 {{ feed.comments || '' }} - - + + + + 🗑️ + 删除 @@ -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; + } +} diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 1c06d3e..c775d5f 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -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" } } diff --git a/node_modules/HaveADrink/README.md b/node_modules/HaveADrink/README.md index fbd048a..63e00aa 100644 --- a/node_modules/HaveADrink/README.md +++ b/node_modules/HaveADrink/README.md @@ -14,7 +14,7 @@ * 6. 社交模块 - 朋友圈动态、点赞 -**版本:** v1.0.8 +**版本:** v1.0.12 ## 安装 @@ -100,6 +100,7 @@ client.WechatLogin(req).then(...).catch(...) ### 获取微信手机号 +**已废弃:** 未使用 POST `/api/have_a_drink/v1/auth/wechat/get_phone_number` @@ -222,6 +223,7 @@ client.GetAchievements(req).then(...).catch(...) ### 获取朋友圈动态 +**已废弃:** 不使用,待移除 GET `/api/have_a_drink/v1/communication/feed` @@ -1236,6 +1238,33 @@ const req = new PublishFeedReq() client.PublishFeed(req).then(...).catch(...) ``` +### 删除动态 + + + +DELETE `/api/have_a_drink/v1/moments/feeds` + +#### 请求参数 +|名称|类型|校验规则|说明| +|:-|:-|:-|:-| +|id|`string\|number`|required| 动态ID| + + + + +#### 返回值 +|名称|类型|说明| +|:-|:-:|:-| +|ok|`boolean`| 是否成功
| + + + + +```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
| @@ -1687,6 +1717,73 @@ const req = new CreateEventReq() client.CreateEvent(req).then(...).catch(...) ``` +### 修改酒局 + + + +POST `/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`| 是否成功
| + + + + +```javascript +const req = new UpdateEventReq() +client.UpdateEvent(req).then(...).catch(...) +``` + +### 删除酒局 + + + +POST `/api/have_a_drink/v1/events/events/delete` + +#### 请求参数 +|名称|类型|校验规则|说明| +|:-|:-|:-|:-| +|id|`string\|number`|required| 酒局ID| + + + + +#### 返回值 +|名称|类型|说明| +|:-|:-:|:-| +|ok|`boolean`| 是否成功
| + + + + +```javascript +const req = new DeleteEventReq() +client.DeleteEvent(req).then(...).catch(...) +``` + ### 报名酒局 diff --git a/node_modules/HaveADrink/index.d.ts b/node_modules/HaveADrink/index.d.ts index 32e119d..e7f0623 100644 --- a/node_modules/HaveADrink/index.d.ts +++ b/node_modules/HaveADrink/index.d.ts @@ -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; + upload: (url: string, params: any) => Promise; + 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; + upload: (url: string, params: any) => Promise; + 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; + /** + * 删除动态 + * + * @param req DeleteFeedReq 删除动态请求(路径参数) + * @return DeleteFeedResp 删除动态响应 + */ + public DeleteFeed(req: DeleteFeedReq) : Promise; + /** * 点赞 * @@ -4927,6 +5155,22 @@ export default class HaveADrink { */ public CreateEvent(req: CreateEventReq) : Promise; + /** + * 修改酒局 + * + * @param req UpdateEventReq 修改酒局请求 + * @return UpdateEventResp 修改酒局响应 + */ + public UpdateEvent(req: UpdateEventReq) : Promise; + + /** + * 删除酒局 + * + * @param req DeleteEventReq 删除酒局请求 + * @return DeleteEventResp 删除酒局响应 + */ + public DeleteEvent(req: DeleteEventReq) : Promise; + /** * 报名酒局 * @@ -4989,6 +5233,6 @@ export default class HaveADrink { * @param req ChatWebSocket * @return ChatWebSocketConn */ - public ChatWebSocket() : ChatWebSocketConn; + public ChatWebSocket(input: ChatWebSocketReq) : ChatWebSocketConn; } diff --git a/node_modules/HaveADrink/index.js b/node_modules/HaveADrink/index.js index c490464..2391939 100644 --- a/node_modules/HaveADrink/index.js +++ b/node_modules/HaveADrink/index.js @@ -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()); } } diff --git a/node_modules/HaveADrink/package.json b/node_modules/HaveADrink/package.json index 898a687..d4cb546 100644 --- a/node_modules/HaveADrink/package.json +++ b/node_modules/HaveADrink/package.json @@ -1,7 +1,7 @@ { "name": "HaveADrink", "type": "module", - "version": "v1.0.8", + "version": "v1.0.12", "description": "喝酒了么 API 服务", "main": "index.js", "scripts": { diff --git a/package-lock.json b/package-lock.json index 6bbaee1..0a9eccc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } diff --git a/package.json b/package.json index dfd69d8..5d0e79b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "HaveADrink": "^1.0.8" + "HaveADrink": "^1.0.12" } } diff --git a/pages/chat/chat.vue b/pages/chat/chat.vue index b30c041..f9dda98 100644 --- a/pages/chat/chat.vue +++ b/pages/chat/chat.vue @@ -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) + } }, /** 重发失败消息 */ diff --git a/pages/circle-detail/circle-detail.vue b/pages/circle-detail/circle-detail.vue index 14c7d43..19304ae 100644 --- a/pages/circle-detail/circle-detail.vue +++ b/pages/circle-detail/circle-detail.vue @@ -17,7 +17,10 @@ @@ -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' + } } } diff --git a/pages/circle/circle.vue b/pages/circle/circle.vue index a14be59..6a2ed0f 100644 --- a/pages/circle/circle.vue +++ b/pages/circle/circle.vue @@ -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" /> @@ -71,12 +74,23 @@ {{ friendRequests.length }} 条新的好友请求 + + + 👥 + 管理酒友 · 处理好友请求 + + + + { 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 +281,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 +358,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 */ } }, // === 酒局 === @@ -281,6 +386,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: '酒友圈 - 碰盏日记' + } } } @@ -443,6 +591,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; diff --git a/pages/friends/friends.vue b/pages/friends/friends.vue index b3c5b10..f2cf057 100644 --- a/pages/friends/friends.vue +++ b/pages/friends/friends.vue @@ -64,6 +64,11 @@ + + @@ -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; +} diff --git a/pages/index/index.vue b/pages/index/index.vue index 298c92c..e999c47 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -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') diff --git a/pages/login/login.vue b/pages/login/login.vue index 9acaef6..b1d292a 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -91,6 +91,8 @@