/** * 即时通讯状态 Store * * 管理会话列表、消息、未读数,提供: * - 会话列表 CRUD + 排序(置顶优先 → 最后消息时间降序) * - 当前会话消息管理(发送/接收/撤回/历史加载) * - 全局未读数(TabBar badge 显示) * - WebSocket 事件监听(im.message.new / im.message.recalled / im.offline.sync) * - 三态消息确认(sending → sent → failed) * * 对应后端 API:/api/v1/im/* * 对应 WS 事件:im.message.send / im.message.recall / im.conversation.read / im.typing */ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import imApi from '@/api/im' import wsService from '@/services/websocket' import { useUserStore } from '@/store/user' export const useChatStore = defineStore('chat', () => { /** 会话列表 */ const conversationList = ref([]) /** 当前聊天会话 ID */ const currentConversationId = ref(null) /** 消息缓存 conversationId -> Message[] */ const messagesMap = ref({}) /** 发送中消息队列 clientMsgId -> { status, ... } */ const pendingMessages = ref({}) /** 全局未读消息总数(用于 TabBar badge) */ const totalUnread = ref(0) /** 正在输入状态 conversationId -> boolean */ const typingMap = ref({}) /** 是否还有更多历史消息 conversationId -> boolean */ const hasMoreMap = ref({}) /** 单聊已读状态:conversationId -> lastReadMsgId(对方已读到的消息 ID) */ const readStatusMap = ref({}) /** 群聊已读计数:messageId -> readCount */ const groupReadCountMap = ref({}) /** * 语音"已播放"状态:messageId -> true * * 说明:区别于通用"已读"(基于滚动可见);此状态记录用户是否"真的点击播放过"对方发来的语音。 * - 仅对"对方发来的语音"有意义(自己发的无需标记) * - 本地持久化(按 userId 隔离),换设备会重新显示红点(与微信行为一致) * - 不同步到服务端:属于私人视图态,不影响对方 */ const voicePlayedMap = ref({}) /** 已加载 voicePlayed 的 userId,避免重复加载 */ let _voicePlayedLoadedUserId = null /** 生成 voicePlayed 的存储 key(按 userId 隔离) */ const _voicePlayedStorageKey = (userId) => `echo:voice-played:${userId}` /** * 从本地存储加载指定用户的语音已播放状态 * 幂等:同一 userId 只会加载一次;切换用户时自动重新加载 */ const loadVoicePlayedState = (userId) => { if (!userId) return if (_voicePlayedLoadedUserId === userId) return try { const raw = uni.getStorageSync(_voicePlayedStorageKey(userId)) voicePlayedMap.value = raw && typeof raw === 'object' ? { ...raw } : {} } catch (_) { voicePlayedMap.value = {} } _voicePlayedLoadedUserId = userId } /** * 标记某条语音消息为"已播放",并持久化到本地存储 * @param {number|string} messageId */ const markVoicePlayed = (messageId) => { if (!messageId) return const key = String(messageId) if (voicePlayedMap.value[key]) return voicePlayedMap.value[key] = true try { const userStore = useUserStore() const userId = userStore.userInfo?.id if (userId) { uni.setStorageSync(_voicePlayedStorageKey(userId), { ...voicePlayedMap.value }) } } catch (_) { /* ignore storage errors */ } } /** * 查询某条语音是否已播放 * @param {number|string} messageId * @returns {boolean} */ const isVoicePlayed = (messageId) => { if (!messageId) return false return !!voicePlayedMap.value[String(messageId)] } /** 重置语音已播放状态(登出时调用) */ const resetVoicePlayedState = () => { voicePlayedMap.value = {} _voicePlayedLoadedUserId = null } // ==================== Computed ==================== /** 当前会话的消息列表(conversationId 为 null 时取 pending 队列) */ const currentMessages = computed(() => { if (!currentConversationId.value) { return messagesMap.value['pending'] || [] } return messagesMap.value[currentConversationId.value] || [] }) /** 按排序规则的会话列表(置顶优先 → 最后消息时间降序) */ const sortedConversations = computed(() => { return [...conversationList.value].sort((a, b) => { if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1 const timeA = a.last_msg_time ? new Date(a.last_msg_time).getTime() : 0 const timeB = b.last_msg_time ? new Date(b.last_msg_time).getTime() : 0 return timeB - timeA }) }) // ==================== Actions ==================== /** 加载会话列表 */ const fetchConversations = async () => { const res = await imApi.getConversations() conversationList.value = (res.data && res.data.list) || [] _recalcTotalUnread() return conversationList.value } /** 加载全局未读数(从后端获取,同步 Redis 数据) */ const fetchTotalUnread = async () => { const res = await imApi.getTotalUnread() if (res.data) { totalUnread.value = res.data.total_unread || 0 } } /** * 发送消息(统一入口,支持文本 + 富媒体) * @param {Object} params - { conversationId, targetUserId, content, type, extra, at_user_ids } * @returns {string} clientMsgId */ const sendMessage = (params) => { const clientMsgId = _generateClientMsgId() const { conversationId, targetUserId, content = '', type = 1, extra = '', at_user_ids } = params const userStore = useUserStore() const tempMsg = { id: 0, conversation_id: conversationId || 0, sender_id: Number(userStore.userInfo?.id) || 0, type, content, extra: extra || undefined, status: 1, client_msg_id: clientMsgId, created_at: new Date().toISOString().replace('T', ' ').substring(0, 19), _sending: true } const displayConvId = conversationId || 'pending' _appendMessage(displayConvId, tempMsg) pendingMessages.value[clientMsgId] = { status: 'sending', tempMsg } const payload = { conversation_id: conversationId || 0, target_user_id: targetUserId || 0, type, content, extra, client_msg_id: clientMsgId } if (at_user_ids && at_user_ids.length > 0) { payload.at_user_ids = at_user_ids } const seq = wsService.send('im.message.send', payload) if (seq === -1) { pendingMessages.value[clientMsgId].status = 'failed' tempMsg._sending = false tempMsg._failed = true } return clientMsgId } /** * 发送图片消息 * @param {Object} params - { conversationId, targetUserId, images } * images: [{ url, thumbnail_url, width, height, size, file_name }] */ const sendImageMessage = (params) => { const { conversationId, targetUserId, images } = params const extra = JSON.stringify({ images }) return sendMessage({ conversationId, targetUserId, content: '', type: 2, extra }) } /** * 发送语音消息 * @param {Object} params - { conversationId, targetUserId, voice } * voice: { url, duration, size, file_name } */ const sendVoiceMessage = (params) => { const { conversationId, targetUserId, voice } = params const extra = JSON.stringify({ voice }) return sendMessage({ conversationId, targetUserId, content: '', type: 3, extra }) } /** * 发送文件消息 * @param {Object} params - { conversationId, targetUserId, file } * file: { url, file_name, size, mime_type, ext } */ const sendFileMessage = (params) => { const { conversationId, targetUserId, file } = params const extra = JSON.stringify({ file }) return sendMessage({ conversationId, targetUserId, content: '', type: 5, extra }) } /** 撤回消息 */ const recallMessage = (messageId) => { wsService.send('im.message.recall', { message_id: messageId }) } /** 标记会话已读 */ const markRead = (conversationId) => { const conv = conversationList.value.find(c => c.id === conversationId) if (conv && conv.unread_count > 0) { totalUnread.value = Math.max(0, totalUnread.value - conv.unread_count) conv.unread_count = 0 } wsService.send('im.conversation.read', { conversation_id: conversationId }) } /** 发送正在输入通知 */ const sendTyping = (conversationId) => { wsService.send('im.typing', { conversation_id: conversationId }) } /** 加载历史消息 */ const loadHistoryMessages = async (conversationId) => { if (!conversationId) return const messages = messagesMap.value[conversationId] || [] const beforeId = messages.length > 0 ? messages[0].id : 0 // 仅在「首次加载(本地还没缓存任何消息)」时回填已读状态; // 滚动加载更多历史消息时不覆盖,避免把已通过 WS 增量到达的最新状态冲回旧值 const isInitialLoad = messages.length === 0 const res = await imApi.getHistoryMessages(conversationId, beforeId) if (res.data) { const list = res.data.list || [] hasMoreMap.value[conversationId] = res.data.has_more if (list.length > 0) { const existing = messagesMap.value[conversationId] || [] messagesMap.value[conversationId] = [...list.reverse(), ...existing] } // ========== 已读状态回填(修复页面刷新后已读标签变未读的 Bug) ========== if (isInitialLoad) { // 单聊:对方 last_read_msg_id → 写入 readStatusMap const peerLastRead = Number(res.data.peer_last_read_msg_id) || 0 if (peerLastRead > 0) { readStatusMap.value[conversationId] = peerLastRead } // 群聊:自己发送消息的 read_count → 合并到 groupReadCountMap const rcMap = res.data.read_count_map if (rcMap && typeof rcMap === 'object') { Object.keys(rcMap).forEach(mid => { const count = Number(rcMap[mid]) || 0 if (count > 0) groupReadCountMap.value[mid] = count }) } } } } /** 置顶/取消置顶 */ const pinConversation = async (conversationId, isPinned) => { await imApi.pinConversation(conversationId, isPinned) const conv = conversationList.value.find(c => c.id === conversationId) if (conv) conv.is_pinned = isPinned } /** 删除会话 */ const deleteConversation = async (conversationId) => { await imApi.deleteConversation(conversationId) conversationList.value = conversationList.value.filter(c => c.id !== conversationId) if (currentConversationId.value === conversationId) { currentConversationId.value = null } _recalcTotalUnread() } /** 清空聊天记录 */ const clearHistory = async (conversationId) => { await imApi.clearHistory(conversationId) messagesMap.value[conversationId] = [] const conv = conversationList.value.find(c => c.id === conversationId) if (conv) { conv.last_msg_content = '' conv.last_msg_time = '' } } /** 设置当前会话 */ const setCurrentConversation = (conversationId) => { currentConversationId.value = conversationId if (conversationId) { markRead(conversationId) } } // ==================== WS 事件监听 ==================== let _wsInitialized = false /** 初始化 WebSocket 事件监听(幂等) */ const initWsListeners = () => { // 懒加载语音播放态(按当前用户隔离,每次调用都会检查 userId 是否变更) try { const userStore = useUserStore() const uid = userStore.userInfo?.id if (uid) loadVoicePlayedState(uid) } catch (_) { /* ignore */ } if (_wsInitialized) return _wsInitialized = true wsService.on('im.message.new', _onNewMessage) wsService.on('im.message.recalled', _onMessageRecalled) wsService.on('im.message.send.ack', _onSendACK) wsService.on('im.offline.sync', _onOfflineSync) wsService.on('im.typing', _onTyping) wsService.on('im.message.read.ack', _onReadACK) wsService.on('im.message.read.count', _onReadCount) } /** 收到新消息推送 */ const _onNewMessage = (msg) => { if (!msg || !msg.data) return const data = msg.data _appendMessage(data.conversation_id, { id: data.id, conversation_id: data.conversation_id, sender_id: data.sender_id, type: data.type, content: data.content, extra: data.extra || undefined, status: 1, client_msg_id: data.client_msg_id || '', created_at: data.created_at }) const existingConv = conversationList.value.find(c => c.id === data.conversation_id) if (existingConv) { const preview = _getMessagePreview(data.type, data.content, data.extra) _updateConversationPreview(data.conversation_id, preview, data.created_at, data.sender_id) } else { fetchConversations() } if (data.conversation_id !== currentConversationId.value) { if (existingConv) { existingConv.unread_count = (existingConv.unread_count || 0) + 1 } totalUnread.value++ } // 注意:当前会话的 markRead 由聊天页面根据滚动位置决定 // (仅当用户视图贴近底部、能看到新消息时才触发),避免"视图未看到但已读"的 UX 错位 } /** 消息撤回推送 */ const _onMessageRecalled = (msg) => { if (!msg || !msg.data) return const { message_id, conversation_id } = msg.data const messages = messagesMap.value[conversation_id] if (messages) { const idx = messages.findIndex(m => m.id === message_id) if (idx !== -1) { messages[idx].status = 2 messages[idx].content = '[消息已撤回]' } } } /** 发送消息 ACK 响应 */ const _onSendACK = (msg) => { if (!msg) return const { code, data } = msg if (!data || !data.client_msg_id) return const pending = pendingMessages.value[data.client_msg_id] if (!pending) return if (code === 0) { pending.status = 'sent' const convId = data.conversation_id if (pending.tempMsg && pending.tempMsg.conversation_id === 0 && convId) { currentConversationId.value = convId const pendingMsgs = messagesMap.value['pending'] || [] if (pendingMsgs.length > 0) { if (!messagesMap.value[convId]) messagesMap.value[convId] = [] messagesMap.value[convId].push(...pendingMsgs) delete messagesMap.value['pending'] } } const messages = messagesMap.value[convId] if (messages) { const idx = messages.findIndex(m => m.client_msg_id === data.client_msg_id) if (idx !== -1) { messages[idx] = { ...messages[idx], ...data, _sending: false } } else { _appendMessage(convId, { ...data, _sending: false }) } } else { _appendMessage(convId, { ...data, _sending: false }) } _updateConversationPreview(convId, data.content, data.created_at, data.sender_id) } else { pending.status = 'failed' if (pending.tempMsg) { pending.tempMsg._sending = false pending.tempMsg._failed = true } } delete pendingMessages.value[data.client_msg_id] } /** 离线消息同步推送 */ const _onOfflineSync = (msg) => { if (!msg || !msg.data) return const { total_unread } = msg.data totalUnread.value = total_unread || 0 fetchConversations() } /** 单聊已读确认(对方已读到某条消息) */ const _onReadACK = (msg) => { if (!msg || !msg.data) return const { conversation_id, last_read_msg_id } = msg.data readStatusMap.value[conversation_id] = last_read_msg_id } /** 群聊已读计数更新 */ const _onReadCount = (msg) => { if (!msg || !msg.data) return const { message_id, read_count } = msg.data groupReadCountMap.value[message_id] = read_count } /** 群聊标记已读(发送 WS 事件) */ const markGroupRead = (conversationId, messageIds) => { if (!messageIds || messageIds.length === 0) return wsService.send('im.group.read', { conversation_id: conversationId, message_ids: messageIds }) } /** 正在输入通知 */ const _onTyping = (msg) => { if (!msg || !msg.data) return const { conversation_id } = msg.data typingMap.value[conversation_id] = true setTimeout(() => { typingMap.value[conversation_id] = false }, 3000) } // ==================== 内部工具方法 ==================== /** 追加消息到缓存(带去重检查,防止重连/ACK 导致重复) */ const _appendMessage = (conversationId, message) => { if (!messagesMap.value[conversationId]) { messagesMap.value[conversationId] = [] } const list = messagesMap.value[conversationId] const isDup = list.some(m => (message.id && m.id === message.id) || (message.client_msg_id && m.client_msg_id === message.client_msg_id) ) if (!isDup) { list.push(message) } } /** 更新会话列表中的预览信息 */ const _updateConversationPreview = (conversationId, content, time, senderId) => { const conv = conversationList.value.find(c => c.id === conversationId) if (conv) { conv.last_msg_content = content conv.last_msg_time = time conv.last_msg_sender_id = senderId } } /** 根据会话列表重新计算总未读数 */ const _recalcTotalUnread = () => { totalUnread.value = conversationList.value.reduce((sum, c) => sum + (c.unread_count || 0), 0) } /** 根据消息类型生成会话预览文案 */ const _getMessagePreview = (type, content, extra) => { switch (type) { case 2: { let count = 1 try { const parsed = typeof extra === 'string' ? JSON.parse(extra) : extra if (parsed?.images?.length > 1) count = parsed.images.length } catch {} return count > 1 ? `[图片x${count}]` : '[图片]' } case 3: { let dur = 0 try { const parsed = typeof extra === 'string' ? JSON.parse(extra) : extra dur = parsed?.voice?.duration || 0 } catch {} return dur > 0 ? `[语音 ${dur}"]` : '[语音]' } case 5: { let name = '' try { const parsed = typeof extra === 'string' ? JSON.parse(extra) : extra name = parsed?.file?.file_name || '' } catch {} return name ? `[文件] ${name}` : '[文件]' } default: return content || '' } } /** 生成客户端消息唯一 ID */ const _generateClientMsgId = () => { return `${Date.now()}-${Math.random().toString(36).substring(2, 10)}` } return { conversationList, currentConversationId, messagesMap, pendingMessages, totalUnread, typingMap, hasMoreMap, readStatusMap, groupReadCountMap, voicePlayedMap, currentMessages, sortedConversations, fetchConversations, fetchTotalUnread, sendMessage, sendImageMessage, sendVoiceMessage, sendFileMessage, recallMessage, markRead, markGroupRead, sendTyping, loadHistoryMessages, pinConversation, deleteConversation, clearHistory, setCurrentConversation, initWsListeners, markVoicePlayed, isVoicePlayed, loadVoicePlayedState, resetVoicePlayedState } })