diff --git a/src/api/modules/attachment.ts b/src/api/modules/attachment.ts index 06736c6..f08ab51 100644 --- a/src/api/modules/attachment.ts +++ b/src/api/modules/attachment.ts @@ -5,7 +5,7 @@ import request from '../request' import type { Attachment, PaginatedResponse } from '@/types/api' // 上传附件 -export function uploadAttachment(filePath: string, type?: 'image' | 'video' | 'file') { +export function uploadAttachment(filePath: string, type?: 'image' | 'video' | 'file' | 'audio') { return request.upload('/attachments/upload', filePath, 'file', type ? { type } : undefined) } diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue index 5f4d3ab..dfbeef6 100644 --- a/src/pages/chat/index.vue +++ b/src/pages/chat/index.vue @@ -71,29 +71,33 @@ {{ msg.content }} - - - - - + + + + + + {{ msg.isSelf ? (currentUser?.name?.charAt(0) || '?') : (getMessageSenderName(msg)?.charAt(0) || '?') }} + - - - {{ getMessageSenderName(msg) }} + + + + {{ getMessageSenderName(msg) }} - {{ msg.content }} + {{ msg.content }} @@ -125,14 +129,26 @@ - - @@ -386,7 +392,9 @@ const isRecording = ref(false); const isCancelRecording = ref(false); const reco const showFileConfirm = ref(false); const uploading = ref(false); const pendingFile = ref<{ path: string; name: string; size: number; type: 'image' | 'video' | 'file'; messageType: number }>({ path: '', name: '', size: 0, type: 'file', messageType: 8 }); const showMsgActions = ref(false); const selectedMessage = ref(null); const showMentionPicker = ref(false); const groupMembers = ref([]); const isGroupChat = ref(false); -const mentionUsers = computed(() => { return groupMembers.value.filter(m => m.user_id !== currentUser.value?.id).map(m => ({ id: m.user_id, name: m.nickname || m.user?.name || '未知', avatar: m.user?.avatar })) }); +// 用户信息缓存 - 用于群聊中显示成员头像和昵称 +const userCache = ref>(new Map()); +const mentionUsers = computed(() => { return groupMembers.value.filter(m => m.user_id !== currentUser.value?.id).map(m => ({ id: m.user_id, name: m.nickname || m.user?.name || userCache.value.get(m.user_id)?.name || '未知', avatar: m.user?.avatar || userCache.value.get(m.user_id)?.avatar })) }); const currentUser = computed(() => authStore.user); const msgActionItems = computed(() => { if (!selectedMessage.value) return []; const msg = selectedMessage.value; const items: any[] = []; if (msg.message_type === 0) { items.push({ name: '复制', value: 'copy' }) } if (msg.isSelf) { const sendTime = new Date(msg.created_at).getTime(); const now = Date.now(); if (now - sendTime < 2 * 60 * 1000) { items.push({ name: '撤回', value: 'recall' }) } } items.push({ name: '删除', value: 'delete', color: '#ef4444' }); return items }); const displayName = computed(() => targetUser.value?.name || chatName.value || '聊天'); @@ -396,7 +404,31 @@ onLoad((options: any) => { roomId.value = options?.roomId || ''; targetId.value onMounted(() => { if (targetId.value) { conversationStore.clearUnread(targetId.value) } setTimeout(() => { scrollToBottom(false) }, 300) }); onUnmounted(() => { wsManager.offMessage(handleNewMessage); stopAudio(); }); -async function loadGroupMembers() { if (!roomId.value) return; try { groupMembers.value = await roomApi.getGroupMembers(roomId.value) } catch (e) { console.error('加载群成员失败:', e) } } +async function loadGroupMembers() { + if (!roomId.value) return + try { + const members = await roomApi.getGroupMembers(roomId.value) + groupMembers.value = members + + // 构建用户缓存 + members.forEach(member => { + if (member.user_id) { + const userData = { + name: member.nickname || member.user?.name || (member as any).name || '', + avatar: member.user?.avatar || (member as any).avatar || (member as any).user_avatar || '' + } + // 只有有效数据才缓存 + if (userData.name || userData.avatar) { + userCache.value.set(member.user_id, userData) + } + } + }) + + console.log('📋 群成员数据:', members.length, '人,用户缓存:', userCache.value.size, '人') + } catch (e) { + console.error('加载群成员失败:', e) + } +} async function loadMessages() { if (!roomId.value) return; try { const cached = chatStore.getRoomMessages(roomId.value); if (cached.length > 0) { messages.value = cached.map((m) => ({ ...m, isSelf: m.sender_user_id === currentUser.value?.id, extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra })); scrollToBottom(false); return } const res = await messageApi.getMessages(roomId.value, 1, 50); messages.value = (res.data || []).reverse().map((m: ChatMessage) => ({ ...m, isSelf: m.sender_user_id === currentUser.value?.id, extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra })); chatStore.setRoomMessages(roomId.value, messages.value); hasMore.value = res.data.length >= 50; scrollToBottom(false) } catch (error) { console.error('加载消息失败:', error) } } function onScrollToUpper() { if (!loadingMore.value && hasMore.value) { loadMoreMessages() } } async function loadMoreMessages() { if (loadingMore.value || !hasMore.value) { return } loadingMore.value = true; page.value++; const firstMsgId = messages.value.length > 0 ? messages.value[0].id : null; try { const res = await messageApi.getMessages(roomId.value, page.value, 50); const newMessages = (res.data || []).reverse().map((m: ChatMessage) => ({ ...m, isSelf: m.sender_user_id === currentUser.value?.id, extra: typeof m.extra === 'string' ? JSON.parse(m.extra || '{}') : m.extra })); messages.value = [...newMessages, ...messages.value]; hasMore.value = res.data.length >= 50; if (firstMsgId) { nextTick(() => { scrollWithAnimation.value = false; scrollToId.value = `msg-${firstMsgId}` }) } } catch { page.value-- } finally { loadingMore.value = false } } @@ -405,7 +437,34 @@ function startAudioCall() { if (!targetId.value) { toast.show('无法发起通 function startVideoCall() { if (!targetId.value) { toast.show('无法发起通话'); return } webrtc.startCall('video', targetId.value, roomId.value, targetUser.value?.name, targetUser.value?.avatar) } function onMoreAudioCall() { showMore.value = false; startAudioCall() } function onMoreVideoCall() { showMore.value = false; startVideoCall() } -function handleNewMessage(msg: ChatMessage) { if (msg.message_type === 6) return; const parsedExtra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra; const newMsg: ChatMessage = { ...msg, isSelf: msg.sender_user_id === currentUser.value?.id, extra: parsedExtra }; const isCurrentChat = msg.room_id === roomId.value; conversationStore.handleMessageUpdate(newMsg, newMsg.isSelf || false, isCurrentChat); if (!isCurrentChat) return; const exists = messages.value.some( (m) => m.id === newMsg.id || (m.isSelf && m.content === newMsg.content && Math.abs(new Date(m.created_at).getTime() - new Date(newMsg.created_at).getTime()) < 2000) ); if (!exists) { messages.value.push(newMsg); chatStore.addMessage(roomId.value, newMsg); scrollToBottom(true) } } +function handleNewMessage(msg: ChatMessage) { + if (msg.message_type === 6) return + const parsedExtra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra + const newMsg: ChatMessage = { ...msg, isSelf: msg.sender_user_id === currentUser.value?.id, extra: parsedExtra } + const isCurrentChat = msg.room_id === roomId.value + + // 尝试从消息中提取发送者信息并缓存 + const msgAny = msg as any + if (msg.sender_user_id && !userCache.value.has(msg.sender_user_id)) { + const senderName = msgAny.sender?.name || msgAny.sender_name || msgAny.from_user?.name || '' + const senderAvatar = msgAny.sender?.avatar || msgAny.sender_avatar || msgAny.from_user?.avatar || '' + if (senderName || senderAvatar) { + userCache.value.set(msg.sender_user_id, { name: senderName, avatar: senderAvatar }) + } + } + + conversationStore.handleMessageUpdate(newMsg, newMsg.isSelf || false, isCurrentChat) + if (!isCurrentChat) return + + const exists = messages.value.some( + (m) => m.id === newMsg.id || (m.isSelf && m.content === newMsg.content && Math.abs(new Date(m.created_at).getTime() - new Date(newMsg.created_at).getTime()) < 2000) + ) + if (!exists) { + messages.value.push(newMsg) + chatStore.addMessage(roomId.value, newMsg) + scrollToBottom(true) + } +} function scrollToBottom(smooth = true) { nextTick(() => { if (messages.value.length > 0) { scrollWithAnimation.value = smooth; scrollToId.value = `msg-${messages.value[messages.value.length - 1].id}` } }) } async function sendTextMessage() { if (!inputText.value.trim() || !roomId.value) return; const content = inputText.value.trim(); inputText.value = ''; const message: ChatMessage = { id: Date.now(), room_id: roomId.value, sender_user_id: currentUser.value!.id, receiver_user_id: targetId.value || '', message_type: 0, content, duration: 0, extra: {}, created_at: new Date().toISOString(), isSelf: true }; messages.value.push(message); chatStore.addMessage(roomId.value, message); conversationStore.handleMessageUpdate(message, true, true); scrollToBottom(); try { await messageApi.sendMessage({ sender_client_id: wsManager.getClientId() || '', receiver_user_id: targetId.value || '', room_id: roomId.value, message_type: 0, content, extra: JSON.stringify({}) }) } catch { const index = messages.value.findIndex((m) => m.id === message.id); if (index > -1) { messages.value.splice(index, 1) } toast.error('发送失败'); inputText.value = content } } function chooseImage() { showMore.value = false; uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], success: async (res) => { const filePath = res.tempFilePaths[0]; const fileInfo = await getFileInfo(filePath); pendingFile.value = { path: filePath, name: fileInfo.name || '图片', size: fileInfo.size, type: 'image', messageType: 1 }; showFileConfirm.value = true } }) } @@ -429,23 +488,73 @@ function getMediaUrl(msg: ChatMessage): string { let url = ''; if (typeof msg.ex function getFileName(msg: ChatMessage): string { if (typeof msg.extra === 'string') { try { const extra = JSON.parse(msg.extra); return extra.name || '未知文件' } catch { return '未知文件' } } return msg.extra?.name || '未知文件' } function getFileSize(msg: ChatMessage): number { if (typeof msg.extra === 'string') { try { const extra = JSON.parse(msg.extra); return extra.size || 0 } catch { return 0 } } return msg.extra?.size || 0 } function getMessageSenderAvatar(msg: ChatMessage): string { - // 群聊时从群成员列表获取头像 - if (isGroupChat.value && msg.sender_user_id) { - const member = groupMembers.value.find(m => m.user_id === msg.sender_user_id) - if (member?.user?.avatar) return member.user.avatar + const msgAny = msg as any + const senderId = msg.sender_user_id + + // 1. 尝试从消息本身获取发送者头像 + if (msgAny.sender?.avatar) return resolveImageUrl(msgAny.sender.avatar) + if (msgAny.sender_avatar) return resolveImageUrl(msgAny.sender_avatar) + if (msgAny.from_user?.avatar) return resolveImageUrl(msgAny.from_user.avatar) + + // 2. 群聊时获取头像 + if (isGroupChat.value && senderId) { + // 优先从用户缓存获取 + const cachedUser = userCache.value.get(senderId) + if (cachedUser?.avatar) return resolveImageUrl(cachedUser.avatar) + + // 从群成员列表获取 + const member = groupMembers.value.find(m => m.user_id === senderId) + if (member) { + const avatar = member.user?.avatar || (member as any).avatar || (member as any).user_avatar + if (avatar) return resolveImageUrl(avatar) + } + + // 从联系人列表中查找 + const contact = chatStore.contacts.find(c => c.contact_user_id === senderId) + if (contact?.user?.avatar) return resolveImageUrl(contact.user.avatar) + + // 返回空字符串,让 AppAvatar 组件显示首字母头像 + return '' } - // 单聊时使用对方头像 - return targetUser.value?.avatar || '' + + // 3. 单聊时使用对方头像 + return resolveImageUrl(targetUser.value?.avatar || '') } + function getMessageSenderName(msg: ChatMessage): string { - // 群聊时从群成员列表获取名称 - if (isGroupChat.value && msg.sender_user_id) { - const member = groupMembers.value.find(m => m.user_id === msg.sender_user_id) - if (member) return member.nickname || member.user?.name || '群成员' + const msgAny = msg as any + const senderId = msg.sender_user_id + + // 1. 尝试从消息本身获取发送者名称 + if (msgAny.sender?.name) return msgAny.sender.name + if (msgAny.sender_name) return msgAny.sender_name + if (msgAny.from_user?.name) return msgAny.from_user.name + + // 2. 群聊时获取名称 + if (isGroupChat.value && senderId) { + // 优先从用户缓存获取 + const cachedUser = userCache.value.get(senderId) + if (cachedUser?.name) return cachedUser.name + + // 从群成员列表获取 + const member = groupMembers.value.find(m => m.user_id === senderId) + if (member) { + const name = member.nickname || member.user?.name || (member as any).name || (member as any).user_name + if (name) return name + } + + // 从联系人列表中查找 + const contact = chatStore.contacts.find(c => c.contact_user_id === senderId) + if (contact?.user?.name) return contact.remark_name || contact.user.name + + // 返回用户 ID 的后 4 位作为备用名称 + return `用户${senderId.slice(-4)}` } - // 单聊时使用对方名称 + + // 3. 单聊时使用对方名称 return targetUser.value?.name || chatName.value || '未知' } + function onAvatarClick(msg: ChatMessage) { const userId = msg.sender_user_id; if (!userId) return; uni.navigateTo({ url: `/pages/contact/detail?userId=${userId}` }) } function handleMessageLongPress(msg: ChatMessage) { selectedMessage.value = msg; showMsgActions.value = true } async function onMsgActionSelect(action: { value: string }) { if (!selectedMessage.value) return; const msg = selectedMessage.value; showMsgActions.value = false; switch (action.value) { case 'copy': copyMessage(msg); break; case 'recall': await recallMessage(msg); break; case 'delete': deleteMessage(msg); break } } @@ -458,38 +567,238 @@ function handleMentionSelect(user: MentionUser) { if (inputText.value.endsWith(' function handleEmojiSelect(emoji: string) { inputText.value += emoji } function previewImage(msg: ChatMessage) { const url = getMediaUrl(msg); if (url) { uni.previewImage({ urls: [url], current: url }) } } +// 视频相关函数 +function getVideoThumb(msg: ChatMessage): string { + // 尝试从 extra 获取封面图 + const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra + if (extra?.thumb || extra?.cover || extra?.thumbnail) { + return resolveImageUrl(extra.thumb || extra.cover || extra.thumbnail) + } + // 如果没有封面,返回视频 URL(部分平台可以显示视频首帧) + return getMediaUrl(msg) +} + +function getVideoDuration(msg: ChatMessage): string { + const extra = typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra + const duration = extra?.duration || msg.duration || 0 + if (!duration) return '' + const mins = Math.floor(duration / 60) + const secs = Math.floor(duration % 60) + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` +} + +function playVideo(msg: ChatMessage) { + const url = getMediaUrl(msg) + if (!url) { + toast.error('无法获取视频地址') + return + } + // 使用 uni-app 的视频预览 + /* #ifdef H5 */ + window.open(url, '_blank') + /* #endif */ + /* #ifndef H5 */ + uni.navigateTo({ + url: `/pages/common/video-player?url=${encodeURIComponent(url)}` + }) + /* #endif */ +} + // ========== 语音录制处理 ========== function formatRecordDuration(seconds: number): string { const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const s = (seconds % 60).toString().padStart(2, '0'); return `${m}:${s}` } -function initRecorderManager() { if (recorderManager) return recorderManager; recorderManager = uni.getRecorderManager(); recorderManager.onStart(() => { console.log('录音开始'); isRecording.value = true; recordDuration.value = 0; recordTimer = setInterval(() => { recordDuration.value++; if (recordDuration.value >= 60) { stopRecording() } }, 1000) }); recorderManager.onStop((res) => { console.log('录音结束', res); clearRecordTimer(); isRecording.value = false; if (!isCancelRecording.value && res.tempFilePath && recordDuration.value >= 1) { sendVoiceMessage(res.tempFilePath, recordDuration.value) } isCancelRecording.value = false; recordDuration.value = 0 }); recorderManager.onError((err) => { console.error('录音错误', err); clearRecordTimer(); isRecording.value = false; isCancelRecording.value = false; toast.error('录音失败') }); return recorderManager } + +// 检查录音权限 +async function checkRecordPermission(): Promise { + return new Promise((resolve) => { + /* #ifdef APP-PLUS */ + const permission = (plus as any).android?.checkPermission('android.permission.RECORD_AUDIO') + if (permission === (plus as any).android?.PERMISSION_GRANTED) { + resolve(true) + } else { + (plus as any).android?.requestPermission('android.permission.RECORD_AUDIO', (status: any) => { + resolve(status === (plus as any).android?.PERMISSION_GRANTED) + }, () => resolve(false)) + } + /* #endif */ + /* #ifdef MP-WEIXIN */ + uni.authorize({ + scope: 'scope.record', + success: () => resolve(true), + fail: () => { + uni.showModal({ + title: '提示', + content: '需要麦克风权限才能录音,请在设置中开启', + confirmText: '去设置', + success: (res) => { + if (res.confirm) { + uni.openSetting({}) + } + } + }) + resolve(false) + } + }) + /* #endif */ + /* #ifdef H5 */ + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + navigator.mediaDevices.getUserMedia({ audio: true }) + .then(() => resolve(true)) + .catch(() => { + toast.error('请允许使用麦克风') + resolve(false) + }) + } else { + toast.error('当前浏览器不支持录音') + resolve(false) + } + /* #endif */ + }) +} + +function initRecorderManager() { + if (recorderManager) return recorderManager + + // H5 平台不支持 uni.getRecorderManager + /* #ifdef H5 */ + toast.error('当前平台不支持录音') + return null + /* #endif */ + + /* #ifndef H5 */ + const manager = uni.getRecorderManager() + + if (!manager) { + toast.error('录音功能不可用') + return null + } + + manager.onStart(() => { + console.log('录音开始') + isRecording.value = true + recordDuration.value = 0 + recordTimer = setInterval(() => { + recordDuration.value++ + if (recordDuration.value >= 60) { + stopRecording() + } + }, 1000) + }) + + manager.onStop((res) => { + console.log('录音结束', res) + clearRecordTimer() + isRecording.value = false + if (!isCancelRecording.value && res.tempFilePath && recordDuration.value >= 1) { + sendVoiceMessage(res.tempFilePath, recordDuration.value) + } + isCancelRecording.value = false + recordDuration.value = 0 + }) + + manager.onError((err) => { + console.error('录音错误', err) + clearRecordTimer() + isRecording.value = false + isCancelRecording.value = false + toast.error('录音失败:' + (err.errMsg || '未知错误')) + }) + + recorderManager = manager + return recorderManager + /* #endif */ +} + function clearRecordTimer() { if (recordTimer) { clearInterval(recordTimer); recordTimer = null } } -function startRecording(e: TouchEvent) { recordStartY = e.touches[0].clientY; isCancelRecording.value = false; const recorder = initRecorderManager(); recorder.start({ duration: 60000, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: 'mp3' }) } + +async function startRecording(e: TouchEvent) { + recordStartY = e.touches[0].clientY + isCancelRecording.value = false + + // 先检查权限 + const hasPermission = await checkRecordPermission() + if (!hasPermission) { + toast.error('无录音权限') + return + } + + const recorder = initRecorderManager() + if (!recorder) { + return + } + + recorder.start({ + duration: 60000, + sampleRate: 16000, + numberOfChannels: 1, + encodeBitRate: 48000, + format: 'aac' // 使用 aac 格式,跨平台兼容性更好 + }) +} function onRecordingMove(e: TouchEvent) { if (!isRecording.value) return; const currentY = e.touches[0].clientY; const diff = recordStartY - currentY; isCancelRecording.value = diff > 80 } function stopRecording() { if (!isRecording.value) return; if (recorderManager) { recorderManager.stop() } } function cancelRecording() { isCancelRecording.value = true; stopRecording() } -async function sendVoiceMessage(filePath: string, duration: number) { toast.loading('发送中...'); try { const attachment = await attachmentApi.uploadAttachment(filePath, 'audio'); toast.close(); const extra: Record = { url: attachment.file_url, name: attachment.file_name || 'voice.mp3', size: attachment.file_size || 0, duration: duration, attachment_id: attachment.id }; const message: ChatMessage = { id: Date.now(), room_id: roomId.value, sender_user_id: currentUser.value!.id, receiver_user_id: targetId.value || '', message_type: 2, content: attachment.file_url, duration: duration, extra, created_at: new Date().toISOString(), isSelf: true }; messages.value.push(message); chatStore.addMessage(roomId.value, message); conversationStore.handleMessageUpdate(message, true, true); scrollToBottom(true); await messageApi.sendMessage({ sender_client_id: wsManager.getClientId() || '', receiver_user_id: targetId.value || '', room_id: roomId.value, message_type: 2, content: attachment.file_url, duration: duration, extra: JSON.stringify(extra) }); toast.success('发送成功') } catch (error: any) { toast.close(); toast.error(error.message || '发送失败') } } +async function sendVoiceMessage(filePath: string, duration: number) { + toast.loading('发送中...') + try { + const attachment = await attachmentApi.uploadAttachment(filePath, 'audio') + toast.close() + + const extra: Record = { + url: attachment.file_url, + name: attachment.file_name || 'voice.m4a', // 使用 m4a 扩展名(aac 格式) + size: attachment.file_size || 0, + duration: duration, + attachment_id: attachment.id + } + + const message: ChatMessage = { + id: Date.now(), + room_id: roomId.value, + sender_user_id: currentUser.value!.id, + receiver_user_id: targetId.value || '', + message_type: 2, + content: attachment.file_url, + duration: duration, + extra, + created_at: new Date().toISOString(), + isSelf: true + } + + messages.value.push(message) + chatStore.addMessage(roomId.value, message) + conversationStore.handleMessageUpdate(message, true, true) + scrollToBottom(true) + + await messageApi.sendMessage({ + sender_client_id: wsManager.getClientId() || '', + receiver_user_id: targetId.value || '', + room_id: roomId.value, + message_type: 2, + content: attachment.file_url, + duration: duration, + extra: JSON.stringify(extra) + }) + toast.success('发送成功') + } catch (error: any) { + toast.close() + console.error('语音发送失败:', error) + toast.error(error.message || '发送失败') + } +}