diff --git a/src/api/modules/call.ts b/src/api/modules/call.ts index 4992793..35b1e8b 100644 --- a/src/api/modules/call.ts +++ b/src/api/modules/call.ts @@ -1,82 +1,11 @@ import request from '../request' +import type { ChatMessage } from '@/types/api' /** - * 通话相关 API + * 通话记录相关 API */ -// 拉流地址信息 -export interface PullURLInfo { - user_id: string - url: string - flv_url?: string +/** 获取通话记录(基于 call_id 消息) */ +export function getCallHistory() { + return request.get('/calls/history') } - -// 参与者信息 -export interface ParticipantInfo { - user_id: string - platform: 'h5' | 'web' | 'app' | 'miniprogram' | 'wxapp' - has_audio: boolean - has_video: boolean -} - -// ICE 服务器配置 -export interface ICEServerConfig { - urls: string[] - username?: string - credential?: string -} - -// 加入通话房间请求 -export interface JoinCallRoomRequest { - room_id: string - user_id: string - platform: 'h5' | 'web' | 'app' | 'miniprogram' | 'wxapp' -} - -// 加入通话房间响应 -export interface JoinCallRoomResponse { - room_id: string - platform: string - ice_servers?: ICEServerConfig[] - ws_push_url?: string // H5/App 用 RTMP 模式 WebSocket 推流地址 - self_flv_url?: string // H5/App 用 - 自己的 HTTP-FLV 地址(供小程序拉流) - flv_pull_urls?: PullURLInfo[] // H5/App 拉取小程序流的 FLV 地址 - push_url?: string // 小程序用 RTMP 推流地址 - pull_urls?: PullURLInfo[] // 小程序用 RTMP 拉流地址 - participants: ParticipantInfo[] -} - -// 离开通话房间请求 -export interface LeaveCallRoomRequest { - room_id: string - user_id: string -} - -/** - * 加入通话房间 - * H5/App 加入时会返回房间内小程序用户的 FLV 拉流地址 - */ -export function joinCallRoom(data: JoinCallRoomRequest) { - return request.post('/call/join', data) -} - -/** - * 离开通话房间 - */ -export function leaveCallRoom(data: LeaveCallRoomRequest) { - return request.post<{ left: boolean }>('/call/leave', data) -} - -/** - * 获取通话房间信息 - */ -export function getCallRoom(roomId: string) { - return request.get<{ - room_id: string - call_type: string - is_group_call: boolean - participant_count: number - participants: ParticipantInfo[] - }>(`/call/room/${roomId}`) -} - diff --git a/src/api/modules/contact.ts b/src/api/modules/contact.ts index 0475ab6..c71ec64 100644 --- a/src/api/modules/contact.ts +++ b/src/api/modules/contact.ts @@ -59,15 +59,15 @@ export function deleteGroup(id: number) { // 获取好友详情 export async function getContactDetail(id: string): Promise { - const response = await request.get<{ contact: any; user: User }>(`/contacts/${id}`) - // 合并 contact 和 user 数据为完整的 Contact 对象 + const response = await request.get<{ contact: any; user: User; common_groups?: any[] }>(`/contacts/${id}`) return { ...response.contact, id: response.contact.id?.toString() || response.contact.contact_id, user_id: response.contact.contact_id || response.contact.user_id, contact_user_id: response.contact.contact_id, - room_id: response.contact.room_id, // 确保 room_id 被正确传递 + room_id: response.contact.room_id, user: response.user, + common_groups: response.common_groups || [], } as Contact } diff --git a/src/api/modules/message.ts b/src/api/modules/message.ts index 4020a69..6ac426c 100644 --- a/src/api/modules/message.ts +++ b/src/api/modules/message.ts @@ -7,7 +7,7 @@ import type { ChatMessage, SendMessageRequest, PaginatedResponse } from '@/types // 发送消息 export function sendMessage(data: SendMessageRequest) { - return request.post('/send', data) + return request.post('/send', data) } // 获取历史消息 @@ -24,3 +24,13 @@ export function syncMessages(roomId: string, page = 1, pageSize = 50) { }) } +// 撤回消息 +export function recallMessage(messageId: number) { + return request.post('/messages/recall', { message_id: messageId }) +} + +// 标记消息已读 +export function markMessagesRead(messageIds: number[]) { + return request.post('/messages/read-receipts', { message_ids: messageIds }) +} + diff --git a/src/api/modules/settings.ts b/src/api/modules/settings.ts new file mode 100644 index 0000000..46eb30b --- /dev/null +++ b/src/api/modules/settings.ts @@ -0,0 +1,13 @@ +import request from '../request' + +/** + * 用户设置相关 API + */ + +export function getUserSettings() { + return request.get>('/user/settings') +} + +export function updateUserSettings(settings: Record) { + return request.post>('/user/settings', { settings }) +} diff --git a/src/api/modules/user.ts b/src/api/modules/user.ts index c750d3e..f94fc83 100644 --- a/src/api/modules/user.ts +++ b/src/api/modules/user.ts @@ -17,6 +17,11 @@ export function getUserList(page = 1, pageSize = 20) { }) } +// 获取指定用户详情 +export function getUserById(userId: string) { + return request.get(`/user/${userId}`) +} + // 创建用户(管理员功能) export function createUser(data: Partial) { return request.post('/user/create', data) diff --git a/src/api/websocket/index.ts b/src/api/websocket/index.ts index 21cb865..e10d73a 100644 --- a/src/api/websocket/index.ts +++ b/src/api/websocket/index.ts @@ -10,6 +10,7 @@ export interface WebSocketMessage { export type MessageHandler = (message: ChatMessage) => void export type SignalHandler = (message: ChatMessage) => void export type MomentNotifHandler = (payload: MomentNotifPayload) => void +export type ReadReceiptHandler = (data: { message_ids?: number[]; room_id?: string }) => void class WebSocketManager { private ws: WebSocket | null = null @@ -18,6 +19,7 @@ class WebSocketManager { private messageHandlers: MessageHandler[] = [] private signalHandlers: SignalHandler[] = [] private momentNotifHandlers: MomentNotifHandler[] = [] + private readReceiptHandlers: ReadReceiptHandler[] = [] private reconnectAttempts = 0 private maxReconnectAttempts = 5 private reconnectDelay = 3000 @@ -83,6 +85,11 @@ class WebSocketManager { this.handleMessage(payload.data as ChatMessage) } + // 处理已读回执 + if (payload.request_type === 'messages_read' && payload.data) { + this.handleReadReceipt(payload.data as { message_ids?: number[]; room_id?: string }) + } + // 处理朋友圈通知 if (payload.request_type === 'moment_notification' && payload.data) { this.handleMomentNotification(payload.data as MomentNotifPayload) @@ -217,6 +224,22 @@ class WebSocketManager { } } + /** + * 添加已读回执处理器 + */ + onReadReceipt(handler: ReadReceiptHandler) { + if (!this.readReceiptHandlers.includes(handler)) { + this.readReceiptHandlers.push(handler) + } + } + + /** + * 内部处理已读回执 + */ + private handleReadReceipt(data: { message_ids?: number[]; room_id?: string }) { + this.readReceiptHandlers.forEach(handler => handler(data)) + } + /** * 移除朋友圈通知处理器 */ diff --git a/src/components/chat/GroupInfoPanel.vue b/src/components/chat/GroupInfoPanel.vue index 23bd8e8..f630510 100644 --- a/src/components/chat/GroupInfoPanel.vue +++ b/src/components/chat/GroupInfoPanel.vue @@ -91,6 +91,22 @@ 邀请 + + + + + + + + +
@@ -505,6 +541,7 @@ const members = ref([]) const availableContacts = ref([]) const showInviteModal = ref(false) +const showQrModal = ref(false) const showEditModal = ref(false) const showQuitConfirm = ref(false) const showDissolveConfirm = ref(false) @@ -899,6 +936,19 @@ function getInvitePermissionText(permission: number): string { } } +/** 复制群 ID 到剪贴板 */ +function copyGroupId() { + if (!groupInfo.value?.room_id) return + navigator.clipboard.writeText(groupInfo.value.room_id).then(() => { + toastStore.success('群 ID 已复制') + }).catch(() => toastStore.error('复制失败')) +} + +/** 群文件:跳转附件列表(按群 room_id 筛选) */ +function openGroupFiles() { + toastStore.info(`群文件:${props.roomId}(可在聊天中发送的文件消息查看)`) +} + // 群公告相关方法 function openAnnouncementModal() { isEditingAnnouncement.value = false diff --git a/src/components/chat/MessageBubble.vue b/src/components/chat/MessageBubble.vue index dfa743a..3c0ec9f 100644 --- a/src/components/chat/MessageBubble.vue +++ b/src/components/chat/MessageBubble.vue @@ -40,12 +40,13 @@
- +
- {{ formattedTime }} + {{ formattedTime }}· {{ message.ip_location }} + 已读
@@ -88,4 +89,14 @@ const bubbleShapeClass = computed(() => { const formattedTime = computed(() => { return formatMessageTime(props.message.created_at) }) + +const showIpLocation = computed(() => { + const loc = props.message.ip_location + return !!loc && loc !== '本地' && loc !== '未知' +}) + +/** 单聊已读回执展示(自己发送的消息) */ +const showReadReceipt = computed(() => { + return !!props.message.isSelf && !!props.message.is_read && props.message.message_type === 0 +}) diff --git a/src/components/common/Avatar.vue b/src/components/common/Avatar.vue index bb3ba7a..b1f2e37 100644 --- a/src/components/common/Avatar.vue +++ b/src/components/common/Avatar.vue @@ -7,7 +7,7 @@ > import { computed, ref } from 'vue' import { generateColor } from '@/utils/format' +import { resolveImageUrl } from '@/utils/image' interface Props { avatar?: string @@ -35,28 +36,29 @@ const props = withDefaults(defineProps(), { const imageError = ref(false) +/** 解析后的头像地址 */ +const displayAvatar = computed(() => resolveImageUrl(props.avatar)) + // 判断 avatar 是否是图片URL const isImage = computed(() => { - if (!props.avatar || imageError.value) return false + const avatar = props.avatar + if (!avatar || imageError.value) return false // 检查是否是 base64 图片 - if (props.avatar.startsWith('data:image/')) { + if (avatar.startsWith('data:image/')) { return true } - - // 检查是否是 http/https URL - if (props.avatar.startsWith('http://') || props.avatar.startsWith('https://')) { + + if (avatar.startsWith('http://') || avatar.startsWith('https://')) { return true } - - // 检查是否是相对路径的图片(以 / 开头) - if (props.avatar.startsWith('/')) { + + if (avatar.startsWith('/')) { return true } - - // 检查是否是图片文件扩展名 + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'] - const lowerAvatar = props.avatar.toLowerCase() + const lowerAvatar = avatar.toLowerCase() if (imageExtensions.some(ext => lowerAvatar.endsWith(ext))) { return true } diff --git a/src/config/app.ts b/src/config/app.ts new file mode 100644 index 0000000..fca8399 --- /dev/null +++ b/src/config/app.ts @@ -0,0 +1,9 @@ +/** + * 应用全局配置(API 地址等) + */ +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api' + +/** 静态资源与 uploads 域名(开发环境走 Vite 代理) */ +export const API_ORIGIN = + import.meta.env.VITE_API_ORIGIN || + (typeof window !== 'undefined' ? window.location.origin : '') diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 5fae0c5..a181dc5 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { ChatMessage, Contact } from '@/types/api' import { generateColor } from '@/utils/format' +import { mergeMessageIntoList } from '@/utils/messageMerge' export const useChatStore = defineStore('chat', () => { const currentTarget = ref(null) @@ -26,13 +27,23 @@ export const useChatStore = defineStore('chat', () => { } /** - * 添加消息 + * 添加消息(带去重/合并 pending) */ function addMessage(roomId: string, message: ChatMessage) { if (!messages.value[roomId]) { messages.value[roomId] = [] } - messages.value[roomId].push(message) + mergeMessageIntoList(messages.value[roomId], message) + } + + /** + * 用服务端返回的消息替换本地 pending 项 + */ + function reconcileMessage(roomId: string, serverMessage: ChatMessage) { + if (!messages.value[roomId]) { + messages.value[roomId] = [] + } + mergeMessageIntoList(messages.value[roomId], { ...serverMessage, pending: false }) } /** @@ -100,6 +111,27 @@ export const useChatStore = defineStore('chat', () => { messages.value[roomId] = [] } + /** + * 删除单条消息 + */ + function removeMessage(roomId: string, messageId: number) { + const list = messages.value[roomId] + if (!list) return + messages.value[roomId] = list.filter(m => m.id !== messageId) + } + + /** + * 更新单条消息(如撤回) + */ + function updateMessage(roomId: string, messageId: number, patch: Partial) { + const list = messages.value[roomId] + if (!list) return + const idx = list.findIndex(m => m.id === messageId) + if (idx >= 0) { + list[idx] = { ...list[idx], ...patch } + } + } + // 群通知相关状态 const lastGroupNotification = ref<{ room_id: string; type: string; data: any } | null>(null) const myMuteStatus = ref>({}) // room_id -> muted_until @@ -143,12 +175,15 @@ export const useChatStore = defineStore('chat', () => { myMuteStatus, setCurrentTarget, addMessage, + reconcileMessage, getRoomMessages, setRoomMessages, setContacts, updateContactLastMsg, incrementUnread, clearRoomMessages, + removeMessage, + updateMessage, setLastGroupNotification, setMyMuteStatus, isMyMuted, diff --git a/src/types/api.ts b/src/types/api.ts index 22c2d10..34d6282 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -108,6 +108,8 @@ export interface ChatMessage { id: number room_id: string sender_user_id: string + sender_ip?: string + ip_location?: string receiver_user_id?: string message_type: number // 0:文本 1:图片 2:语音 3:视频 4:系统 5:好友通知 6:信令 7:群通知 8:文件 9:朋友圈通知 content: string @@ -115,6 +117,8 @@ export interface ChatMessage { extra?: string | Record created_at: string isSelf?: boolean // 前端标记 + pending?: boolean // 乐观发送待确认 + is_read?: boolean // 单聊已读回执(前端标记) } // 发送消息请求 diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..34fcd12 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,26 @@ +/** + * 图片 URL 处理工具 + */ +import { API_ORIGIN } from '@/config/app' + +/** + * 解析图片 URL,为相对路径添加域名前缀 + */ +export function resolveImageUrl(url?: string): string { + if (!url) return '' + + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) { + return url + } + + const normalized = url.startsWith('/') ? url : `/${url}` + if (normalized.startsWith('/uploads/') || normalized.startsWith('/upload/')) { + return API_ORIGIN + normalized + } + + return url +} + +export function getImageBaseUrl(): string { + return API_ORIGIN +} diff --git a/src/utils/messageMerge.ts b/src/utils/messageMerge.ts new file mode 100644 index 0000000..fff483a --- /dev/null +++ b/src/utils/messageMerge.ts @@ -0,0 +1,56 @@ +/** + * 消息合并工具:解决乐观发送(临时 ID)与服务端 ID 不一致导致的重复展示 + */ +import type { ChatMessage } from '@/types/api' + +/** 解析 extra 中的 client_msg_id */ +export function getClientMsgId(message: ChatMessage): string | undefined { + const extra = message.extra + if (!extra) return undefined + if (typeof extra === 'string') { + try { + return JSON.parse(extra || '{}')?.client_msg_id + } catch { + return undefined + } + } + return extra.client_msg_id +} + +/** 生成客户端消息唯一标识,写入 extra.client_msg_id */ +export function createClientMsgId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +/** + * 合并消息到列表:按服务端 id 去重,或按 client_msg_id 替换 pending 项 + */ +export function mergeMessageIntoList(list: ChatMessage[], message: ChatMessage): boolean { + if (message.id && list.some((m) => m.id === message.id)) { + return false + } + + const clientMsgId = getClientMsgId(message) + if (clientMsgId) { + const idx = list.findIndex((m) => getClientMsgId(m) === clientMsgId) + if (idx > -1) { + list[idx] = { ...message, isSelf: list[idx].isSelf ?? message.isSelf, pending: false } + return true + } + } + + const fuzzyIdx = list.findIndex( + (m) => + m.pending && + m.sender_user_id === message.sender_user_id && + m.message_type === message.message_type && + m.content === message.content + ) + if (fuzzyIdx > -1) { + list[fuzzyIdx] = { ...message, isSelf: true, pending: false } + return true + } + + list.push(message) + return true +} diff --git a/src/views/chat/ChatView.vue b/src/views/chat/ChatView.vue index f2e7d87..9aa45a8 100644 --- a/src/views/chat/ChatView.vue +++ b/src/views/chat/ChatView.vue @@ -79,6 +79,14 @@ +
+ +
+
@@ -413,6 +422,26 @@ /> + +
+
+
+

通话记录

+ +
+
+
加载中...
+
暂无通话记录
+
+
+
{{ call.call_status || '通话' }}
+
{{ call.created_at }}
+
+
+
+
+
+
@@ -698,12 +727,14 @@ import { useMomentStore } from '@/stores/moment' import { useContextMenu } from '@/composables/useContextMenu' import { wsManager } from '@/api/websocket' import * as messageApi from '@/api/modules/message' +import * as callApi from '@/api/modules/call' import * as contactApi from '@/api/modules/contact' import * as attachmentApi from '@/api/modules/attachment' import * as conversationApi from '@/api/modules/conversation' import * as userApi from '@/api/modules/user' import { formatTime, generateColor } from '@/utils/format' import { getMessageSummary } from '@/utils/messageTypes' +import { createClientMsgId } from '@/utils/messageMerge' import { storage } from '@/utils/storage' import type { Contact, ChatMessage, User } from '@/types/api' import type { Conversation } from '@/types/conversation' @@ -763,6 +794,9 @@ let searchDebounceTimer: ReturnType | null = null const chatVisible = ref(false) const isMobile = ref(window.innerWidth < 768) const showProfileModal = ref(false) +const showCallHistoryModal = ref(false) +const callHistory = ref([]) +const loadingCallHistory = ref(false) const selectedUser = ref(null) const isRecording = ref(false) const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null }) @@ -950,6 +984,11 @@ async function selectChat(contact: Contact) { const sortedMsgs = msgs.reverse() chatStore.setRoomMessages(roomId, sortedMsgs) + const unreadIds = sortedMsgs.filter(m => !m.isSelf && m.id).map(m => m.id!) + if (unreadIds.length) { + messageApi.markMessagesRead(unreadIds).catch(() => {}) + } + if (response.data.length < 50) { hasMoreHistory.value[roomId] = false } @@ -1159,16 +1198,20 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat // 群聊时 receiver_user_id 应该为空或群ID,确保后端能正确识别为群聊 const receiverUserId = isGroup ? '' : (chatStore.currentTarget.user_id || chatStore.currentTarget.id) + const clientMsgId = createClientMsgId() + const extraWithId = { ...extra, client_msg_id: clientMsgId } + const payload = { sender_client_id: wsManager.getClientId() || '', receiver_user_id: receiverUserId, - room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra) + room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extraWithId) } const message: ChatMessage = { id: Date.now(), room_id: roomId, sender_user_id: authStore.user!.id, receiver_user_id: receiverUserId, - message_type: type, content, duration, extra, created_at: new Date().toISOString(), isSelf: true + message_type: type, content, duration, extra: extraWithId, created_at: new Date().toISOString(), isSelf: true, + pending: true } chatStore.addMessage(roomId, message) chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(message), Date.now()) @@ -1176,11 +1219,22 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat scrollToBottom(true) try { - await messageApi.sendMessage(payload) + const saved = await messageApi.sendMessage(payload) + if (saved?.id) { + const normalized: ChatMessage = { + ...saved, + isSelf: true, + extra: typeof saved.extra === 'string' ? JSON.parse(saved.extra || '{}') : saved.extra, + pending: false + } + chatStore.reconcileMessage(roomId, normalized) + } } catch(e: any) { - // 移除已添加的消息(因为发送失败) const messages = chatStore.messages[roomId] || [] - const index = messages.findIndex(m => m.id === message.id) + const index = messages.findIndex(m => { + const ex = m.extra as Record | undefined + return ex?.client_msg_id === clientMsgId || m.id === message.id + }) if (index > -1) { messages.splice(index, 1) } @@ -1190,6 +1244,27 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat } } +async function openCallHistory() { + showCallHistoryModal.value = true + loadingCallHistory.value = true + try { + callHistory.value = await callApi.getCallHistory() + } catch { + toastStore.error('加载通话记录失败') + } finally { + loadingCallHistory.value = false + } +} + +function handleReadReceipt(data: { message_ids?: number[]; room_id?: string }) { + const roomId = data.room_id + const ids = data.message_ids || [] + if (!roomId || !ids.length) return + ids.forEach(id => { + chatStore.updateMessage(roomId, id, { is_read: true }) + }) +} + function handleWebSocketMessage(message: ChatMessage) { if (message.message_type === 6) return const roomId = message.room_id @@ -1440,6 +1515,11 @@ function processMessageAfterConversation(message: ChatMessage, roomId: string) { }) } + // 未读提示音(非当前会话且未免打扰) + if (!isCurrentChat && !contact.is_muted) { + playNotificationSound() + } + // 不在会话tab时显示toast通知 if (currentTab.value !== 'chat') { const senderName = contact.remark_name || contact.user?.name || message.sender_user_id || '未知用户' @@ -1526,6 +1606,20 @@ function stopConnectionCheck() { } function getMsgSummary(msg: ChatMessage): string { return getMessageSummary(msg) } +/** 新消息提示音 */ +let notifyAudio: HTMLAudioElement | null = null +function playNotificationSound() { + try { + if (!notifyAudio) { + notifyAudio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIGWi77+efTRAMUKfj8LZjHAY4kdfyzHksBSR3x/DdkEAKFF606euoVRQKRp/g8r5sIQUrgc7y2Yk2CBlou+/nn00QDFCn4/C2YxwGOJHX8sx5LAUkd8fw3ZBAC') + } + notifyAudio.currentTime = 0 + notifyAudio.play().catch(() => {}) + } catch { + // 忽略播放失败 + } +} + // 复制文本 function copyText(text?: string) { if (!text) return @@ -1813,6 +1907,72 @@ async function handleDeleteConversation() { // ---------------------------------------------- +/** 消息右键菜单:复制 / 撤回 / 删除 */ +function showMessageContextMenu(event: MouseEvent, message: ChatMessage) { + if (!chatStore.currentTarget) return + const roomId = getRoomId(chatStore.currentTarget) + const menuItems: import('@/stores/contextMenu').MenuItem[] = [] + + if (message.message_type === 0 && message.content) { + menuItems.push({ + label: '复制', + icon: 'fas fa-copy', + action: () => { + navigator.clipboard.writeText(message.content).then(() => { + toastStore.success('已复制') + }).catch(() => toastStore.error('复制失败')) + }, + }) + menuItems.push({ + label: '转发', + icon: 'fas fa-share', + action: () => { + navigator.clipboard.writeText(message.content).then(() => { + toastStore.success('内容已复制,可粘贴到其他会话转发') + }).catch(() => toastStore.error('复制失败')) + }, + }) + } + + if (message.isSelf && message.id && message.message_type !== 4) { + const sendTime = new Date(message.created_at).getTime() + if (Date.now() - sendTime < 2 * 60 * 1000) { + menuItems.push({ + label: '撤回', + icon: 'fas fa-undo', + action: async () => { + try { + const updated = await messageApi.recallMessage(message.id!) + chatStore.updateMessage(roomId, message.id!, { + message_type: updated.message_type ?? 4, + content: updated.content || '撤回了一条消息', + }) + toastStore.success('已撤回') + } catch (e: any) { + toastStore.error(e?.message || '撤回失败') + } + }, + }) + } + } + + menuItems.push({ + label: '删除', + icon: 'fas fa-trash-alt', + danger: true, + action: () => { + if (message.id) { + chatStore.removeMessage(roomId, message.id) + } + toastStore.success('已从本地删除') + }, + }) + + if (menuItems.length) { + showContextMenu(event, menuItems) + } +} + function showChatOptionsMenu(event: MouseEvent, contact: Contact) { const menuItems = [ { @@ -2297,6 +2457,31 @@ function getUserEmail(user: Contact | User | null): string | null { return null } +/** 登录后预拉取有未读会话的最新消息(bulk sync 轻量版) */ +async function prefetchUnreadMessages() { + const targets = conversationStore.conversations + .filter(c => (c.unread_count || 0) > 0) + .slice(0, 5) + + for (const conv of targets) { + const roomId = conv.room_id || conv.target_id + if (!roomId || chatStore.getRoomMessages(roomId).length > 0) continue + try { + const response = await messageApi.syncMessages(roomId, 1, 30) + if (response?.data?.length) { + const msgs = response.data.map((msg: ChatMessage) => ({ + ...msg, + isSelf: msg.sender_user_id === authStore.user!.id, + extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra, + })) + chatStore.setRoomMessages(roomId, msgs.reverse()) + } + } catch { + // 预拉取失败不影响主流程 + } + } +} + watch(currentTab, (newTab) => { storage.setCurrentTab(newTab) if (newTab === 'contact' || newTab === 'moment') { @@ -2326,11 +2511,13 @@ onMounted(async () => { } wsManager.onMessage(handleWebSocketMessage) + wsManager.onReadReceipt(handleReadReceipt) wsManager.onSignal(webrtc.handleSignaling) // 朋友圈通知已在 App.vue 中统一注册,避免重复 } await loadContacts() await conversationStore.loadConversations() + await prefetchUnreadMessages() // 获取朋友圈未读数 momentStore.fetchUnreadCount() // 开始检查连接状态 diff --git a/src/views/contact/ContactDetail.vue b/src/views/contact/ContactDetail.vue index 911f1c5..50261fe 100644 --- a/src/views/contact/ContactDetail.vue +++ b/src/views/contact/ContactDetail.vue @@ -129,6 +129,12 @@ > {{ contact.is_muted ? '取消免打扰' : '免打扰' }} + +