feat(frontend): Task 6 - Chat Store + API + WS 事件 + TabBar badge

API (api/im.js):
- getConversations / getHistoryMessages / pinConversation
- deleteConversation / clearHistory / searchMessages / getTotalUnread

Chat Store (store/chat.js):
- 会话列表管理 + 排序(置顶优先)
- 消息缓存 + 三态确认(sending/sent/failed)
- 全局未读数管理(Redis 同步)
- WS 事件监听:im.message.new/recalled/send.ack/offline.sync/typing
- 工具方法:游标分页加载、typing 超时清除

CustomTabBar:
- 消息 Tab 显示全局未读 badge(>99 显示 99+)

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-03 10:58:35 +08:00
parent 2c7be494f2
commit 780dea3c31
3 changed files with 459 additions and 3 deletions

57
frontend/src/api/im.js Normal file
View File

@@ -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
}

View File

@@ -21,8 +21,10 @@
:class="{ 'tab-active': currentIndex === index }"
@tap="switchTo(index)"
>
<!-- 图标区域使用 Unicode 字符占位后续替换为 SVG -->
<view class="tab-icon-wrap">
<text class="tab-icon">{{ item.icon }}</text>
<text v-if="getBadge(index) > 0" class="tab-badge">{{ getBadge(index) > 99 ? '99+' : getBadge(index) }}</text>
</view>
<text class="tab-label">{{ item.label }}</text>
</view>
</view>
@@ -35,8 +37,12 @@
* Props:
* - current: 当前选中的 Tab 索引
*
* 跳转使用 uni.switchTab确保与 pages.json tabBar 配置的页面一致
* 功能:
* - 跳转使用 uni.switchTab确保与 pages.json tabBar 配置的页面一致
* - 消息 Tabindex=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;

359
frontend/src/store/chat.js Normal file
View File

@@ -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
}
})