612 lines
19 KiB
JavaScript
612 lines
19 KiB
JavaScript
/**
|
||
* 即时通讯状态 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
|
||
}
|
||
})
|