feat(chat): 添加私信功能和WebSocket实时通信
- 集成WebSocket管理器,支持心跳、自动重连、消息队列 - 实现私信相关API接口,包括会话列表、历史消息、已读标记等 - 添加聊天页面和会话列表页面,支持实时消息收发 - 配置地图权限和腾讯地图SDK用于酒局定位功能 - 修改应用名称为"喝酒了么"并更新应用描述 - 在App.vue中初始化WebSocket连接和私信功能 - 添加详细的私信模块接口文档和Mock数据 - 优化单图片动态高度计算和预加载逻辑
This commit is contained in:
+33
-1
@@ -158,7 +158,7 @@ export async function refreshAuthToken() {
|
||||
// ==========================================
|
||||
// 酒友圈 API(当前返回 Mock 数据,后端就绪后切换为 SDK 调用)
|
||||
// ==========================================
|
||||
import { MOCK_FEEDS, MOCK_COMMENTS, MOCK_FRIENDS, MOCK_FRIEND_REQUESTS, MOCK_EVENTS } from './mock-data'
|
||||
import { MOCK_FEEDS, MOCK_COMMENTS, MOCK_FRIENDS, MOCK_FRIEND_REQUESTS, MOCK_EVENTS, MOCK_CONVERSATIONS, MOCK_MESSAGES } from './mock-data'
|
||||
|
||||
/** 模拟网络延迟 */
|
||||
function mockDelay(data, ms = 300) {
|
||||
@@ -258,3 +258,35 @@ export function QuitEvent({ eventId }) {
|
||||
export function CheckInEvent({ eventId }) {
|
||||
return mockDelay({ success: true })
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 私信 API(当前返回 Mock 数据,后端就绪后切换为 SDK 调用)
|
||||
// ==========================================
|
||||
|
||||
/** 获取会话列表 */
|
||||
export function GetConversations() {
|
||||
// TODO: 后端就绪后替换为 client.GetConversations(...)
|
||||
return mockDelay({ list: MOCK_CONVERSATIONS })
|
||||
}
|
||||
|
||||
/** 获取历史消息 */
|
||||
export function GetMessages({ conversationId, page = 1, pageSize = 20 } = {}) {
|
||||
// TODO: 后端就绪后替换为 client.GetMessages(...)
|
||||
const all = MOCK_MESSAGES[conversationId] || []
|
||||
const start = (page - 1) * pageSize
|
||||
const list = all.slice(start, start + pageSize)
|
||||
return mockDelay({ list, hasMore: start + pageSize < all.length })
|
||||
}
|
||||
|
||||
/** 标记会话已读 */
|
||||
export function MarkRead({ conversationId }) {
|
||||
// TODO: 后端就绪后替换为 client.MarkRead(...)
|
||||
return mockDelay({ success: true })
|
||||
}
|
||||
|
||||
/** 获取未读消息总数 */
|
||||
export function GetUnreadCount() {
|
||||
// TODO: 后端就绪后替换为 client.GetUnreadCount(...)
|
||||
const total = MOCK_CONVERSATIONS.reduce((sum, c) => sum + c.unread, 0)
|
||||
return mockDelay({ total })
|
||||
}
|
||||
|
||||
@@ -260,6 +260,46 @@ export const MOCK_COMMENTS = {
|
||||
]
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 私信 Mock 数据
|
||||
// ==========================================
|
||||
|
||||
// 模拟会话列表
|
||||
export const MOCK_CONVERSATIONS = [
|
||||
{ id: 'conv_001', friendId: 'f001', nickname: '老张', avatar: '', lastMessage: '今晚一起喝点?', lastTime: '10:32', unread: 2, online: true },
|
||||
{ id: 'conv_002', friendId: 'f002', nickname: '酒仙李白', avatar: '', lastMessage: '那批啤酒链接发你了', lastTime: '昨天', unread: 0, online: true },
|
||||
{ id: 'conv_003', friendId: 'f003', nickname: '小红', avatar: '', lastMessage: '梅见那个确实好喝!', lastTime: '周一', unread: 1, online: false },
|
||||
{ id: 'conv_004', friendId: 'f005', nickname: '品酒师Tony', avatar: '', lastMessage: '下次品鉴会带你一个', lastTime: '上周', unread: 0, online: true }
|
||||
]
|
||||
|
||||
// 模拟聊天记录(按 conversationId 分组)
|
||||
export const MOCK_MESSAGES = {
|
||||
conv_001: [
|
||||
{ id: 'msg_101', conversationId: 'conv_001', senderId: 'f001', receiverId: 'user_001', type: 'text', content: '在吗?', timestamp: 1721556000000, status: 'read' },
|
||||
{ id: 'msg_102', conversationId: 'conv_001', senderId: 'user_001', receiverId: 'f001', type: 'text', content: '在的,怎么了老张', timestamp: 1721556060000, status: 'read' },
|
||||
{ id: 'msg_103', conversationId: 'conv_001', senderId: 'f001', receiverId: 'user_001', type: 'text', content: '搞了瓶飞天,一个人喝没意思', timestamp: 1721556120000, status: 'read' },
|
||||
{ id: 'msg_104', conversationId: 'conv_001', senderId: 'user_001', receiverId: 'f001', type: 'text', content: '哟,茅台!几点?', timestamp: 1721556180000, status: 'read' },
|
||||
{ id: 'msg_105', conversationId: 'conv_001', senderId: 'f001', receiverId: 'user_001', type: 'text', content: '老地方,7点', timestamp: 1721556240000, status: 'read' },
|
||||
{ id: 'msg_106', conversationId: 'conv_001', senderId: 'f001', receiverId: 'user_001', type: 'text', content: '今晚一起喝点?', timestamp: 1721556300000, status: 'delivered' }
|
||||
],
|
||||
conv_002: [
|
||||
{ id: 'msg_201', conversationId: 'conv_002', senderId: 'user_001', receiverId: 'f002', type: 'text', content: '上次你说的那个精酿啤酒叫什么来着', timestamp: 1721470000000, status: 'read' },
|
||||
{ id: 'msg_202', conversationId: 'conv_002', senderId: 'f002', receiverId: 'user_001', type: 'text', content: '熊猫精酿,蜂蜜艾尔', timestamp: 1721470060000, status: 'read' },
|
||||
{ id: 'msg_203', conversationId: 'conv_002', senderId: 'f002', receiverId: 'user_001', type: 'text', content: '那批啤酒链接发你了', timestamp: 1721470120000, status: 'read' }
|
||||
],
|
||||
conv_003: [
|
||||
{ id: 'msg_301', conversationId: 'conv_003', senderId: 'f003', receiverId: 'user_001', type: 'text', content: '你动态里那个梅见好喝吗?', timestamp: 1721380000000, status: 'read' },
|
||||
{ id: 'msg_302', conversationId: 'conv_003', senderId: 'user_001', receiverId: 'f003', type: 'text', content: '超好喝!酸酸甜甜,推荐原味', timestamp: 1721380060000, status: 'read' },
|
||||
{ id: 'msg_303', conversationId: 'conv_003', senderId: 'f003', receiverId: 'user_001', type: 'text', content: '梅见那个确实好喝!', timestamp: 1721380120000, status: 'delivered' }
|
||||
],
|
||||
conv_004: [
|
||||
{ id: 'msg_401', conversationId: 'conv_004', senderId: 'f005', receiverId: 'user_001', type: 'text', content: '下周有个威士忌品鉴会,来不来?', timestamp: 1721290000000, status: 'read' },
|
||||
{ id: 'msg_402', conversationId: 'conv_004', senderId: 'user_001', receiverId: 'f005', type: 'text', content: '可以啊,都有什么酒?', timestamp: 1721290060000, status: 'read' },
|
||||
{ id: 'msg_403', conversationId: 'conv_004', senderId: 'f005', receiverId: 'user_001', type: 'text', content: '麦卡伦、格兰菲迪、百富,三款12年', timestamp: 1721290120000, status: 'read' },
|
||||
{ id: 'msg_404', conversationId: 'conv_004', senderId: 'f005', receiverId: 'user_001', type: 'text', content: '下次品鉴会带你一个', timestamp: 1721290180000, status: 'read' }
|
||||
]
|
||||
}
|
||||
|
||||
// 模拟酒局活动
|
||||
export const MOCK_EVENTS = [
|
||||
{
|
||||
@@ -268,6 +308,9 @@ export const MOCK_EVENTS = [
|
||||
organizer: { id: 'f001', nickname: '老张', avatar: '' },
|
||||
time: '2026-07-25 19:00',
|
||||
location: '渝味晓宇火锅(解放碑店)',
|
||||
address: '重庆市渝中区解放碑步行街88号',
|
||||
latitude: 29.5580,
|
||||
longitude: 106.5780,
|
||||
maxPeople: 8,
|
||||
joined: 5,
|
||||
participants: [
|
||||
@@ -288,6 +331,9 @@ export const MOCK_EVENTS = [
|
||||
organizer: { id: 'f005', nickname: '品酒师Tony', avatar: '' },
|
||||
time: '2026-07-27 20:00',
|
||||
location: 'Malt Bar(国贸店)',
|
||||
address: '北京市朝阳区建国门外大街1号国贸商城B1',
|
||||
latitude: 39.9087,
|
||||
longitude: 116.4605,
|
||||
maxPeople: 6,
|
||||
joined: 3,
|
||||
participants: [
|
||||
@@ -306,6 +352,9 @@ export const MOCK_EVENTS = [
|
||||
organizer: { id: 'f002', nickname: '酒仙李白', avatar: '' },
|
||||
time: '2026-07-20 18:00',
|
||||
location: '望京啤酒花园',
|
||||
address: '北京市朝阳区望京街9号',
|
||||
latitude: 39.9959,
|
||||
longitude: 116.4767,
|
||||
maxPeople: 12,
|
||||
joined: 10,
|
||||
participants: [],
|
||||
@@ -320,6 +369,9 @@ export const MOCK_EVENTS = [
|
||||
organizer: { id: 'f003', nickname: '小红', avatar: '' },
|
||||
time: '2026-07-15 19:30',
|
||||
location: 'The Grill(三里屯)',
|
||||
address: '北京市朝阳区三里屯路19号',
|
||||
latitude: 39.9325,
|
||||
longitude: 116.4536,
|
||||
maxPeople: 4,
|
||||
joined: 4,
|
||||
participants: [],
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/* 干杯日记 - WebSocket 管理器
|
||||
* 单例模式,负责私信实时通信
|
||||
* 支持心跳、自动重连、消息队列
|
||||
*/
|
||||
|
||||
const WS_HOST = 'wss://dev.wash-painting.cn/ws'
|
||||
|
||||
class WebSocketManager {
|
||||
constructor() {
|
||||
this.socketTask = null
|
||||
this.isConnected = false
|
||||
this.isConnecting = false
|
||||
this.listeners = {} // 事件订阅 { event: [callbacks] }
|
||||
this.messageQueue = [] // 断线期间暂存消息
|
||||
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 = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WebSocket 连接
|
||||
* @param {string} token - 用户认证 token
|
||||
*/
|
||||
connect(token) {
|
||||
if (this.isConnected || this.isConnecting) return
|
||||
this.token = token || uni.getStorageSync('auth_token') || ''
|
||||
if (!this.token) {
|
||||
console.warn('[WS] 无 token,跳过连接')
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
this.isConnecting = true
|
||||
|
||||
const url = `${WS_HOST}?token=${this.token}`
|
||||
console.log('[WS] 正在连接...', url)
|
||||
|
||||
this.socketTask = uni.connectSocket({
|
||||
url,
|
||||
complete: () => {}
|
||||
})
|
||||
|
||||
this.socketTask.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(() => {
|
||||
console.log('[WS] 连接关闭')
|
||||
this.isConnected = false
|
||||
this.isConnecting = false
|
||||
this._stopHeartbeat()
|
||||
this._emit('disconnect', {})
|
||||
if (!this.manualClose) {
|
||||
this._scheduleReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
this.socketTask.onError((err) => {
|
||||
console.warn('[WS] 连接错误', err)
|
||||
this.isConnected = false
|
||||
this.isConnecting = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 主动断开连接 */
|
||||
disconnect() {
|
||||
this.manualClose = true
|
||||
this._stopHeartbeat()
|
||||
this._clearReconnect()
|
||||
if (this.socketTask) {
|
||||
this.socketTask.close()
|
||||
this.socketTask = null
|
||||
}
|
||||
this.isConnected = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @param {string} type - 消息类型: chat | typing | read | ping
|
||||
* @param {object} data - 消息数据
|
||||
*/
|
||||
send(type, data = {}) {
|
||||
const payload = JSON.stringify({ type, data, ts: Date.now() })
|
||||
if (this.isConnected && this.socketTask) {
|
||||
this.socketTask.send({ data: payload })
|
||||
} else {
|
||||
// 断线暂存队列
|
||||
if (type !== 'ping') {
|
||||
this.messageQueue.push(payload)
|
||||
}
|
||||
// 尝试重连
|
||||
if (!this.isConnecting && !this.manualClose) {
|
||||
this._scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
* @param {string} event - message | typing | read | ack | connect | disconnect
|
||||
* @param {function} callback
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = []
|
||||
}
|
||||
this.listeners[event].push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
* @param {string} event
|
||||
* @param {function} callback
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (!this.listeners[event]) return
|
||||
if (callback) {
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
|
||||
} else {
|
||||
delete this.listeners[event]
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 内部方法 ==========
|
||||
|
||||
/** 处理收到的消息 */
|
||||
_handleMessage(msg) {
|
||||
const { type, data } = msg
|
||||
switch (type) {
|
||||
case 'pong':
|
||||
this._onPong()
|
||||
break
|
||||
case 'chat':
|
||||
this._emit('message', data)
|
||||
break
|
||||
case 'typing':
|
||||
this._emit('typing', data)
|
||||
break
|
||||
case 'read':
|
||||
this._emit('read', data)
|
||||
break
|
||||
case 'ack':
|
||||
this._emit('ack', data)
|
||||
break
|
||||
default:
|
||||
console.log('[WS] 未知消息类型', type)
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发事件 */
|
||||
_emit(event, data) {
|
||||
const cbs = this.listeners[event]
|
||||
if (cbs) {
|
||||
cbs.forEach(cb => {
|
||||
try { cb(data) } catch (e) { console.warn('[WS] 事件回调异常', e) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动心跳 */
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send('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
|
||||
}
|
||||
|
||||
/** 重连成功后重发队列消息 */
|
||||
_flushQueue() {
|
||||
if (!this.messageQueue.length) return
|
||||
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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 导出全局单例
|
||||
const wsManager = new WebSocketManager()
|
||||
export default wsManager
|
||||
Reference in New Issue
Block a user