Files
EchoChat/frontend/src/store/chat.js

612 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 即时通讯状态 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
}
})