diff --git a/frontend/src/api/im.js b/frontend/src/api/im.js new file mode 100644 index 0000000..191e2fe --- /dev/null +++ b/frontend/src/api/im.js @@ -0,0 +1,57 @@ +/** + * IM 即时通讯模块 API + * + * 对应后端路由:/api/v1/im/* + * 消息发送/撤回/标记已读通过 WebSocket 进行,此处仅包含 REST API + */ + +import { get, put, del } from '@/utils/request' + +/** 获取会话列表 */ +const getConversations = () => { + return get('/api/v1/im/conversations') +} + +/** 获取历史消息(游标分页) */ +const getHistoryMessages = (conversationId, beforeId = 0, limit = 30) => { + return get('/api/v1/im/messages', { + conversation_id: conversationId, + before_id: beforeId, + limit + }) +} + +/** 置顶/取消置顶会话 */ +const pinConversation = (conversationId, isPinned) => { + return put(`/api/v1/im/conversations/${conversationId}/pin`, { is_pinned: isPinned }) +} + +/** 删除会话 */ +const deleteConversation = (conversationId) => { + return del(`/api/v1/im/conversations/${conversationId}`) +} + +/** 清空聊天记录 */ +const clearHistory = (conversationId) => { + return del(`/api/v1/im/conversations/${conversationId}/messages`) +} + +/** 全局消息搜索 */ +const searchMessages = (keyword, limit = 50) => { + return get('/api/v1/im/messages/search', { keyword, limit }) +} + +/** 获取全局未读消息总数 */ +const getTotalUnread = () => { + return get('/api/v1/im/unread') +} + +export default { + getConversations, + getHistoryMessages, + pinConversation, + deleteConversation, + clearHistory, + searchMessages, + getTotalUnread +} diff --git a/frontend/src/components/CustomTabBar.vue b/frontend/src/components/CustomTabBar.vue index 4f2ad82..2d2e6d5 100644 --- a/frontend/src/components/CustomTabBar.vue +++ b/frontend/src/components/CustomTabBar.vue @@ -21,8 +21,10 @@ :class="{ 'tab-active': currentIndex === index }" @tap="switchTo(index)" > - - {{ item.icon }} + + {{ item.icon }} + {{ getBadge(index) > 99 ? '99+' : getBadge(index) }} + {{ item.label }} @@ -35,8 +37,12 @@ * Props: * - current: 当前选中的 Tab 索引 * - * 跳转使用 uni.switchTab,确保与 pages.json tabBar 配置的页面一致 + * 功能: + * - 跳转使用 uni.switchTab,确保与 pages.json tabBar 配置的页面一致 + * - 消息 Tab(index=0)显示全局未读消息 badge */ +import { useChatStore } from '@/store/chat' + export default { name: 'CustomTabBar', props: { @@ -70,6 +76,18 @@ export default { switchTo(index) { if (index === this.currentIndex) return uni.switchTab({ url: this.tabs[index].path }) + }, + /** + * 获取指定 Tab 的 badge 数 + * @param {number} index - Tab 索引 + * @returns {number} badge 数量(0 表示不显示) + */ + getBadge(index) { + if (index === 0) { + const chatStore = useChatStore() + return chatStore.totalUnread + } + return 0 } } } @@ -108,11 +126,33 @@ export default { transition: color 200ms ease; } +.tab-icon-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + .tab-icon { font-size: 40rpx; margin-bottom: 4rpx; } +.tab-badge { + position: absolute; + top: -8rpx; + right: -20rpx; + min-width: 32rpx; + height: 32rpx; + padding: 0 8rpx; + font-size: 20rpx; + line-height: 32rpx; + color: #FFFFFF; + background-color: #EF4444; + border-radius: 16rpx; + text-align: center; +} + .tab-label { font-size: 22rpx; color: #94A3B8; diff --git a/frontend/src/store/chat.js b/frontend/src/store/chat.js new file mode 100644 index 0000000..9456605 --- /dev/null +++ b/frontend/src/store/chat.js @@ -0,0 +1,359 @@ +/** + * 即时通讯状态 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' + +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({}) + + // ==================== Computed ==================== + + /** 当前会话的消息列表 */ + const currentMessages = computed(() => { + if (!currentConversationId.value) return [] + 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 } + * @returns {string} clientMsgId + */ + const sendMessage = (params) => { + const clientMsgId = _generateClientMsgId() + const { conversationId, targetUserId, content, type = 1 } = params + + const tempMsg = { + id: 0, + conversation_id: conversationId || 0, + sender_id: 0, + type, + content, + status: 1, + client_msg_id: clientMsgId, + created_at: new Date().toISOString().replace('T', ' ').substring(0, 19), + _sending: true + } + + if (conversationId) { + _appendMessage(conversationId, tempMsg) + } + + pendingMessages.value[clientMsgId] = { status: 'sending', tempMsg } + + const seq = wsService.send('im.message.send', { + conversation_id: conversationId || 0, + target_user_id: targetUserId || 0, + type, + content, + client_msg_id: clientMsgId + }) + + if (seq === -1) { + pendingMessages.value[clientMsgId].status = 'failed' + tempMsg._sending = false + tempMsg._failed = true + } + + return clientMsgId + } + + /** 撤回消息 */ + 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) => { + const messages = messagesMap.value[conversationId] || [] + const beforeId = messages.length > 0 ? messages[0].id : 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] + } + } + } + + /** 置顶/取消置顶 */ + 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 = () => { + 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) + } + + /** 收到新消息推送 */ + 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, + status: 1, + client_msg_id: data.client_msg_id || '', + created_at: data.created_at + }) + + _updateConversationPreview(data.conversation_id, data.content, data.created_at, data.sender_id) + + if (data.conversation_id !== currentConversationId.value) { + const conv = conversationList.value.find(c => c.id === data.conversation_id) + if (conv) { + conv.unread_count = (conv.unread_count || 0) + 1 + } + totalUnread.value++ + } else { + markRead(data.conversation_id) + } + } + + /** 消息撤回推送 */ + 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 + 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 _onTyping = (msg) => { + if (!msg || !msg.data) return + const { conversation_id } = msg.data + typingMap.value[conversation_id] = true + setTimeout(() => { + typingMap.value[conversation_id] = false + }, 3000) + } + + // ==================== 内部工具方法 ==================== + + /** 追加消息到缓存 */ + const _appendMessage = (conversationId, message) => { + if (!messagesMap.value[conversationId]) { + messagesMap.value[conversationId] = [] + } + messagesMap.value[conversationId].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) + } + + /** 生成客户端消息唯一 ID */ + const _generateClientMsgId = () => { + return `${Date.now()}-${Math.random().toString(36).substring(2, 10)}` + } + + return { + conversationList, + currentConversationId, + messagesMap, + pendingMessages, + totalUnread, + typingMap, + hasMoreMap, + currentMessages, + sortedConversations, + fetchConversations, + fetchTotalUnread, + sendMessage, + recallMessage, + markRead, + sendTyping, + loadHistoryMessages, + pinConversation, + deleteConversation, + clearHistory, + setCurrentConversation, + initWsListeners + } +})