Files
nl-um-vue-ts/src/views/chat/ChatView.vue

689 lines
30 KiB
Vue
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.
<template>
<div class="h-screen w-screen flex text-sm sm:text-base bg-dark" @contextmenu.prevent>
<!-- 左侧功能导航 (UI微调:增加阴影和圆角) -->
<div class="w-16 bg-dark border-r border-gray-800 flex flex-col items-center py-6 gap-6 z-20 hidden md:flex shrink-0 shadow-xl">
<Avatar
:name="authStore.user?.name"
:avatar="authStore.user?.avatar"
size="md"
class="cursor-pointer hover:ring-2 ring-primary transition-all duration-300 transform hover:scale-110 shadow-lg"
@click="showProfileModal = true"
/>
<!-- ...导航图标保持不变,增加 hover scale... -->
<div
class="text-gray-400 hover:text-primary cursor-pointer transition transform hover:scale-110 relative w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
:class="{ 'text-primary bg-white/5': currentTab === 'chat' }"
@click="currentTab = 'chat'"
>
<i class="fas fa-comment-dots text-xl"></i>
<div v-if="chatStore.totalUnread > 0" class="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
</div>
<div
class="text-gray-400 hover:text-primary cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
:class="{ 'text-primary bg-white/5': currentTab === 'contact' }"
@click="currentTab = 'contact'"
>
<i class="fas fa-address-book text-xl"></i>
</div>
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
@click="handleLogout"
title="退出登录"
>
<i class="fas fa-sign-out-alt text-xl"></i>
</div>
</div>
<!-- 中间侧边栏 (UI微调) -->
<div
v-show="currentTab === 'chat' && (!isMobile || !chatVisible)"
class="w-full md:w-80 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0"
>
<div class="p-5 border-b border-gray-800">
<div class="relative group">
<i class="fas fa-search absolute left-4 top-3 text-gray-500 group-focus-within:text-primary transition"></i>
<input
v-model="searchQuery"
type="text"
class="w-full bg-input rounded-2xl py-2.5 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500 shadow-inner"
placeholder="搜索会话或联系人..."
/>
</div>
</div>
<div class="flex-1 overflow-y-auto px-2 space-y-1 py-2 custom-scrollbar">
<div
v-for="conv in filteredConversations"
:key="conv.id"
class="flex items-center p-3 cursor-pointer rounded-2xl transition-all duration-200 relative group border border-transparent"
:class="{
'bg-gradient-to-r from-primary/20 to-transparent border-primary/30': chatStore.currentTarget && (chatStore.currentTarget.user_id === conv.target_id || chatStore.currentTarget.id === conv.target_id),
'hover:bg-white/5 hover:border-white/5': !chatStore.currentTarget || (chatStore.currentTarget.user_id !== conv.target_id && chatStore.currentTarget.id !== conv.target_id),
'bg-black/20': conv.is_top,
}"
@click="selectChatByConversation(conv)"
@contextmenu.stop="showConversationMenu($event, conv)"
>
<div class="relative shrink-0">
<Avatar
:name="conv.name"
:avatar="conv.avatar || conv.name?.charAt(0)"
:color="undefined"
size="contact"
rounded="xl"
class="transition transform group-hover:scale-105 shadow-md"
/>
<div
v-if="(conv.unread_count || 0) > 0"
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm animate-bounce"
>
{{ conv.unread_count }}
</div>
</div>
<div class="flex-1 min-w-0 ml-3">
<div class="flex justify-between items-center mb-0.5">
<span class="font-semibold truncate text-gray-200 text-sm group-hover:text-white transition">
{{ conv.name || '未知' }}
</span>
<span class="text-xs text-gray-500 group-hover:text-gray-400">{{ formatTime(conv.last_time || Date.now()) }}</span>
</div>
<div class="text-xs text-gray-400 truncate flex items-center h-4 group-hover:text-gray-300">
<i v-if="conv.is_top" class="fas fa-thumbtack text-yellow-500 mr-1 text-[10px]"></i>
<span v-if="conv.is_muted" class="fas fa-bell-slash text-gray-600 mr-1 text-[10px]"></span>
<span>{{ conv.last_message || '' }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧主聊天区 -->
<div
v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))"
class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden"
>
<!-- 聊天头部 -->
<div class="h-16 border-b border-gray-800 flex justify-between items-center px-6 bg-panel/90 backdrop-blur shrink-0 z-20 shadow-sm">
<div class="flex items-center gap-3 cursor-pointer group" @click="backToList">
<i class="fas fa-chevron-left md:hidden text-gray-400 text-lg p-2 -ml-2"></i>
<Avatar
:name="chatStore.currentTarget.user?.name || chatStore.currentTarget.remark_name"
:avatar="chatStore.currentTarget.user?.avatar || chatStore.currentTarget.remark_name?.charAt(0)"
:color="chatStore.currentTarget.color"
size="sm"
rounded="full"
class="group-hover:ring-2 ring-primary transition"
/>
<div>
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">
{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
<span class="w-2 h-2 bg-success rounded-full animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>
</h3>
</div>
</div>
<div class="flex gap-2 text-gray-400">
<button class="action-btn" title="语音通话" @click="startCall('audio')"><i class="fas fa-phone"></i></button>
<button class="action-btn" title="视频通话" @click="startCall('video')"><i class="fas fa-video"></i></button>
<button class="action-btn" @click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"><i class="fas fa-ellipsis-v"></i></button>
</div>
</div>
<!-- 消息列表 (包含滚动逻辑) -->
<!-- 修复: 添加 flex flex-col 确保 MessageList 的 flex-1 生效 -->
<div class="flex-1 overflow-hidden relative flex flex-col min-h-0">
<MessageList
ref="messageListRef"
:messages="currentMessages"
:current-user="authStore.user!"
:target="chatStore.currentTarget"
:loading-history="loadingHistory"
:has-more-history="chatStore.currentTarget ? (hasMoreHistory[getRoomId(chatStore.currentTarget)] !== false) : true"
@scroll="handleScroll"
@load-more="loadMoreMessages"
/>
<!-- 新消息提示/回到底部按钮 -->
<Transition name="fade-slide">
<button
v-if="showScrollBottomBtn"
class="absolute bottom-6 right-6 bg-primary text-white px-4 py-2 rounded-full shadow-xl hover:bg-indigo-500 transition-all transform hover:scale-105 active:scale-95 flex items-center gap-2 z-30"
@click="scrollToBottom(true)"
>
<i class="fas fa-arrow-down"></i>
<span v-if="unreadCount > 0" class="text-xs font-bold">{{ unreadCount }} 条新消息</span>
<span v-else class="text-xs">回到底部</span>
</button>
</Transition>
</div>
<!-- 输入区 -->
<MessageInput
v-model="inputText"
:is-recording="isRecording"
@send="handleSendText"
@file="handleFileSelect"
@record-start="handleRecordStart"
@record-stop="handleRecordStop"
@record-cancel="handleRecordCancel"
/>
</div>
<!-- 空状态 -->
<div
v-else-if="currentTab === 'chat'"
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 hidden md:flex"
>
<div class="w-32 h-32 bg-panel rounded-full flex items-center justify-center mb-6 shadow-2xl border border-gray-800 animate-float">
<i class="fas fa-comments text-5xl text-primary opacity-80"></i>
</div>
<p class="text-2xl font-medium text-gray-300 tracking-widest">NL-IM</p>
<p class="text-sm mt-3 opacity-60 font-light">Design for Developer</p>
</div>
<!-- 联系人页面:左侧联系人列表 + 右侧联系人操作区(QQ 风格) -->
<div
v-if="currentTab === 'contact'"
class="flex flex-1 bg-panel h-full"
>
<!-- 左侧联系人列表(分组 & QQ 风格) -->
<div class="w-80 border-r border-gray-800 shrink-0">
<ContactCircle />
</div>
<!-- 右侧:根据 leftPanelMode 和选中好友显示不同内容 -->
<div class="flex-1 min-w-0">
<!-- 默认空页面 -->
<div
v-if="contactStore.leftPanelMode === 'default' && !contactStore.selectedContact"
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 h-full"
>
<div class="w-32 h-32 bg-panel rounded-full flex items-center justify-center mb-6 shadow-2xl border border-gray-800 animate-float">
<i class="fas fa-address-book text-5xl text-primary opacity-80"></i>
</div>
<p class="text-2xl font-medium text-gray-300 tracking-widest">NL-IM</p>
<p class="text-sm mt-3 opacity-60 font-light">Design for Developer</p>
</div>
<!-- 好友资料卡视图 -->
<ContactDetailCard
v-else-if="contactStore.selectedContact"
@switch-to-chat="currentTab = 'chat'"
/>
<!-- 好友管理器视图 -->
<ContactView v-else-if="contactStore.leftPanelMode === 'friend-manager'" />
<!-- 好友通知视图 -->
<FriendNotifyView v-else-if="contactStore.leftPanelMode === 'friend-notify'" />
<!-- 群通知视图(预留) -->
<div v-else-if="contactStore.leftPanelMode === 'group-notify'" class="flex-1 flex items-center justify-center text-gray-400">
<div class="text-center">
<i class="fas fa-users text-6xl mb-4 opacity-50"></i>
<p class="text-sm">群通知功能开发中</p>
</div>
</div>
</div>
</div>
<ContextMenu />
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
<!-- Profile Modal (kept simple) -->
<div v-if="showProfileModal && authStore.user" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showProfileModal = false">
<div class="bg-panel rounded-2xl w-80 shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
<div class="h-24 bg-gradient-to-r from-indigo-600 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-12">
<Avatar
:name="authStore.user.name"
:avatar="authStore.user.avatar"
size="xl"
rounded="full"
class="mx-auto border-4 border-panel shadow-xl"
/>
<h2 class="text-xl font-bold mt-3 text-white">{{ authStore.user.name }}</h2>
<p class="text-sm text-gray-400 mt-1">{{ authStore.user.desc || '暂无签名' }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useConversationStore } from '@/stores/conversation'
import { useContactStore } from '@/stores/contact'
import { useToastStore } from '@/stores/toast'
import { useContextMenu } from '@/composables/useContextMenu'
import { wsManager } from '@/api/websocket'
import * as messageApi from '@/api/modules/message'
import * as contactApi from '@/api/modules/contact'
import * as attachmentApi from '@/api/modules/attachment'
import { formatTime } from '@/utils/format'
import { storage } from '@/utils/storage'
import type { Contact, ChatMessage } from '@/types/api'
import type { Conversation } from '@/types/conversation'
import { useWebRTCStore } from '@/stores/webrtc'
// Components...
import Avatar from '@/components/common/Avatar.vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import FileConfirmModal from '@/components/common/FileConfirmModal.vue'
import MessageList from '@/components/chat/MessageList.vue'
import MessageInput from '@/components/chat/MessageInput.vue'
import ContactView from '@/views/contact/ContactView.vue'
import ContactCircle from '@/views/contact/ContactCircle.vue'
import ContactDetailCard from '@/views/contact/ContactDetailCard.vue'
import FriendNotifyView from '@/views/contact/FriendNotifyView.vue'
const router = useRouter()
const authStore = useAuthStore()
const chatStore = useChatStore()
const conversationStore = useConversationStore()
const toastStore = useToastStore()
const contactStore = useContactStore()
const { showContextMenu } = useContextMenu()
const webrtcStore = useWebRTCStore()
const webrtc = webrtcStore.webrtc
// State - 从本地存储恢复
const currentTab = ref<'chat' | 'contact'>(storage.getCurrentTab())
const searchQuery = ref('')
const inputText = ref('')
const chatVisible = ref(false)
const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
const isRecording = ref(false)
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
// Scroll Logic State
const showScrollBottomBtn = ref(false)
const unreadCount = ref(0)
const isNearBottom = ref(true)
const loadingHistory = ref(false)
const currentPage = ref<Record<string, number>>({}) // 记录每个房间已加载的页码
const hasMoreHistory = ref<Record<string, boolean>>({}) // 记录每个房间是否还有更多历史消息
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
const filteredConversations = computed(() => {
const query = searchQuery.value.toLowerCase()
return conversationStore.conversations.filter(conv => {
const name = (conv.name || conv.target_user?.name || '').toLowerCase()
return name.includes(query)
})
})
const currentMessages = computed(() => {
if (!chatStore.currentTarget) return []
const roomId = getRoomId(chatStore.currentTarget)
return chatStore.getRoomMessages(roomId)
})
function getRoomId(contact: Contact): string {
// 优先使用 contact.room_id(雪花ID),如果没有则使用旧的生成方式作为后备
if (contact.room_id) {
return contact.room_id
}
// 后备方案:使用旧的格式(兼容旧数据)
const userIds = [authStore.user!.id, contact.user_id || contact.id || contact.contact_user_id].sort()
return userIds.join('_')
}
// 滚动处理
function handleScroll(e: Event) {
const target = e.target as HTMLElement
// 阈值设为 100px
const threshold = 100
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
isNearBottom.value = distanceToBottom < threshold
// 如果接近底部,隐藏按钮并清空未读
if (isNearBottom.value) {
showScrollBottomBtn.value = false
unreadCount.value = 0
} else {
showScrollBottomBtn.value = true
}
}
function scrollToBottom(smooth = true) {
nextTick(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
showScrollBottomBtn.value = false
unreadCount.value = 0
}
})
}
async function selectChat(contact: Contact) {
// 保存选中的会话和房间ID
storage.setSelectedConversation(contact.user_id || contact.id)
const roomId = getRoomId(contact)
storage.setSelectedRoomId(roomId)
chatStore.setCurrentTarget(contact)
chatVisible.value = true
unreadCount.value = 0 // 切换聊天时重置
// 重置 hasMoreHistory 状态
hasMoreHistory.value[roomId] = true
// 如果还没有加载过消息,初始化加载第一页
const existingMessages = chatStore.getRoomMessages(roomId)
if (existingMessages.length === 0) {
currentPage.value[roomId] = 1
try {
const response = await messageApi.getMessages(roomId, 1, 50)
console.log('Messages API response:', response)
// response 已经是 PaginatedResponse,直接访问 data
if (response && response.data && Array.isArray(response.data)) {
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,
}))
console.log('Processed messages:', msgs)
// 确保消息按时间顺序排列(reverse 后最新的在最后)
const sortedMsgs = msgs.reverse()
chatStore.setRoomMessages(roomId, sortedMsgs)
console.log('Messages set to store, roomId:', roomId, 'count:', sortedMsgs.length)
// 如果返回的消息数量少于 pageSize,说明没有更多历史消息了
if (response.data.length < 50) {
hasMoreHistory.value[roomId] = false
}
// 等待 DOM 更新后再滚动
await nextTick()
scrollToBottom(false) // 初始进入直接跳到底部
} else {
console.warn('No messages in response:', response)
hasMoreHistory.value[roomId] = false
}
} catch (error) {
console.error('Failed to load messages:', error)
toastStore.error('加载消息失败')
hasMoreHistory.value[roomId] = false
}
} else {
// 如果已有消息,直接滚动到底部
console.log('Using existing messages, count:', existingMessages.length)
await nextTick()
scrollToBottom(false)
}
}
// 加载更多历史消息(触顶加载)
async function loadMoreMessages() {
if (!chatStore.currentTarget || loadingHistory.value) return
const roomId = getRoomId(chatStore.currentTarget)
const currentMessages = chatStore.getRoomMessages(roomId)
// 如果当前没有消息,不加载
if (currentMessages.length === 0) return
// 获取当前页码,如果没有则初始化为1
const page = (currentPage.value[roomId] || 1) + 1
loadingHistory.value = true
try {
// 记录加载前的滚动位置和第一条消息的高度
const container = document.getElementById('msgListContainer')
const oldScrollTop = container?.scrollTop || 0
const oldScrollHeight = container?.scrollHeight || 0
// 加载历史消息
const pageSize = 50
const response = await messageApi.getMessages(roomId, page, pageSize)
if (response.data && response.data.length > 0) {
const newMsgs = 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,
}))
// 将新消息添加到现有消息数组的前面(历史消息)
const existingMessages = [...currentMessages]
const allMessages = [...newMsgs.reverse(), ...existingMessages]
chatStore.setRoomMessages(roomId, allMessages)
// 更新页码
currentPage.value[roomId] = page
// 如果返回的消息数量少于 pageSize,说明没有更多历史消息了
if (response.data.length < pageSize) {
hasMoreHistory.value[roomId] = false
}
// 等待DOM更新后恢复滚动位置
await nextTick()
if (container) {
const newScrollHeight = container.scrollHeight
const heightDiff = newScrollHeight - oldScrollHeight
container.scrollTop = oldScrollTop + heightDiff
}
} else {
// 没有更多消息了
hasMoreHistory.value[roomId] = false
console.log('没有更多历史消息')
}
} catch (error) {
console.error('Failed to load more messages:', error)
toastStore.error('加载历史消息失败')
} finally {
loadingHistory.value = false
}
}
// 通过会话点击打开聊天
async function selectChatByConversation(conv: Conversation) {
// 在联系人列表中找到对应联系人(用于沿用现有 chatStore 结构)
const contact = chatStore.contacts.find(
c => c.user_id === conv.target_id || c.id === conv.target_id,
)
if (!contact) {
// 如果本地没有联系人数据,可以在这里补一次联系人列表加载;当前先简单返回
return
}
await selectChat(contact)
// 清空该会话未读
await conversationStore.clearUnread(conv.target_id)
}
// ... Send Logic (Text, File, Audio) ...
// (保持大部分原有逻辑,但在发送成功后调用 scrollToBottom)
async function sendMessage(type: number, content: string, extra: any = {}, duration = 0) {
if (!chatStore.currentTarget) return
const roomId = getRoomId(chatStore.currentTarget)
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra)
}
// Optimistic UI update
const message: ChatMessage = {
id: Date.now(), room_id: roomId, sender_user_id: authStore.user!.id,
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
message_type: type, content, duration, extra, created_at: new Date().toISOString(), isSelf: true
}
chatStore.addMessage(roomId, message)
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(message), Date.now())
scrollToBottom(true) // 发送消息后自动滚动
try {
await messageApi.sendMessage(payload)
} catch(e) { toastStore.error('发送失败'); } // Should handle remove optimistic msg on fail
}
// WebSocket 消息处理核心优化
function handleWebSocketMessage(message: ChatMessage) {
if (message.message_type === 6) return
const roomId = message.room_id
message.isSelf = message.sender_user_id === authStore.user!.id
message.extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
chatStore.addMessage(roomId, message)
const contact = chatStore.contacts.find(c => c.user_id === message.sender_user_id || c.id === message.sender_user_id)
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
// 检查是否是当前选中的聊天(通过 room_id 匹配)
const currentRoomId = chatStore.currentTarget ? getRoomId(chatStore.currentTarget) : null
const isCurrentChat = currentRoomId === roomId
conversationStore.handleMessageUpdate(message, message.isSelf === true, isCurrentChat)
if (!message.isSelf) {
// 如果不是当前聊天窗口,或者是当前窗口但用户不在底部
if (!isCurrentChat) {
chatStore.incrementUnread(contact.id)
} else {
// 当前窗口
if (isNearBottom.value) {
scrollToBottom(true)
} else {
unreadCount.value++
showScrollBottomBtn.value = true
}
}
// 系统通知
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) {
new Notification(contact.remark_name || contact.user?.name || '新消息', {
body: getMsgSummary(message),
icon: '/favicon.ico'
})
}
}
}
}
// Helper functions (backToList, handleLogout, getMsgSummary, etc.) keep same...
function backToList() { if (isMobile.value) chatVisible.value = false }
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
function getMsgSummary(msg: ChatMessage): string { const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]' }; return types[msg.message_type] || msg.content }
async function startCall(type: 'audio' | 'video') {
if (!chatStore.currentTarget) return
const receiverUserId = chatStore.currentTarget.user_id || chatStore.currentTarget.id
// 直接传入 room_id
const roomId = getRoomId(chatStore.currentTarget)
const result = await webrtc.startCall(type, receiverUserId, roomId, chatStore.currentTarget)
if (!result) {
// 显示错误提示
toastStore.error('获取房间ID失败,无法发起通话')
}
}
function showContactMenu(event: MouseEvent, contact: Contact) { showContextMenu(event, [{ label: '删除会话', icon: 'fas fa-trash', danger: true, action: () => {} }]) }
function showChatOptionsMenu(event: MouseEvent, contact: Contact) { showContextMenu(event, [{ label: '清空记录', icon: 'fas fa-eraser', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(contact)) }]) }
// File handlers...
function handleSendText() { if (!inputText.value.trim() || !chatStore.currentTarget) return; sendMessage(0, inputText.value); inputText.value = '' }
function handleFileSelect(type: number, file: File) {
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
if (type === 1) { const reader = new FileReader(); reader.onload = (e) => { fileModal.value.preview = e.target?.result as string }; reader.readAsDataURL(file) }
}
async function confirmSendFile() {
if (!fileModal.value.file || !chatStore.currentTarget) return
try {
const uploadType = fileModal.value.type === 1 ? 'image' : (fileModal.value.type === 3 ? 'video' : 'file')
toastStore.info('正在上传...')
const attachment = await attachmentApi.uploadAttachment(fileModal.value.file, uploadType)
const extra = { name: fileModal.value.name, size: fileModal.value.size, url: attachment.file_url, attachment_id: attachment.id }
await sendMessage(fileModal.value.type, attachment.file_url, extra, 0)
fileModal.value.show = false; toastStore.success('发送成功')
} catch (e: any) { toastStore.error('发送失败') }
}
// Recording handlers same...
function handleRecordStart() { isRecording.value = true }
async function handleRecordStop(blob: Blob, duration: number) { isRecording.value = false; if (!chatStore.currentTarget) return; try { const audioFile = new File([blob], `audio.webm`, { type: 'audio/webm' }); toastStore.info('正在上传语音...'); const attachment = await attachmentApi.uploadAttachment(audioFile, 'file'); await sendMessage(2, attachment.file_url, { duration: `00:${duration}`, url: attachment.file_url }, duration) } catch (e) { toastStore.error('发送失败') } }
function handleRecordCancel() { isRecording.value = false }
async function loadContacts() { try { const contacts = await contactApi.getContacts(); chatStore.setContacts(contacts) } catch (e) { console.error(e) } }
// 监听 currentTab 变化并保存
watch(currentTab, (newTab) => {
storage.setCurrentTab(newTab)
// 当切换到联系人模块时,清除选中的会话和房间ID
if (newTab === 'contact') {
chatStore.setCurrentTarget(null)
storage.setSelectedConversation('')
storage.setSelectedRoomId('')
}
})
onMounted(async () => {
if (!authStore.isAuthenticated) {
const isValid = await authStore.checkAuth()
if (!isValid) { router.push('/login'); return }
}
// Remove permission request here!
if (authStore.user) {
await wsManager.connect(authStore.user.id)
wsManager.onMessage(handleWebSocketMessage)
wsManager.onSignal(webrtc.handleSignaling)
}
await loadContacts()
await conversationStore.loadConversations()
// 恢复选中的会话(优先使用 room_id 查找)
const savedRoomId = storage.getSelectedRoomId()
const savedTargetId = storage.getSelectedConversation()
if (savedRoomId) {
// 优先使用 room_id 查找联系人
const contact = chatStore.contacts.find(c => {
const contactRoomId = getRoomId(c)
return contactRoomId === savedRoomId
})
if (contact) {
await selectChat(contact)
return
}
}
// 如果没有 room_id 或找不到,使用 targetId 查找
if (savedTargetId) {
const contact = chatStore.contacts.find(c => c.user_id === savedTargetId || c.id === savedTargetId)
if (contact) {
await selectChat(contact)
}
}
window.addEventListener('resize', () => isMobile.value = window.innerWidth < 768)
})
onUnmounted(() => {
wsManager.offMessage(handleWebSocketMessage)
wsManager.offSignal(webrtc.handleSignaling)
})
</script>
<style scoped>
.action-btn {
@apply w-10 h-10 rounded-xl hover:bg-white/10 hover:text-white transition flex items-center justify-center transform hover:scale-110 active:scale-95;
}
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.animate-float { animation: float 6s ease-in-out infinite; }
@keyframes float { 0% { transform: translateY(0px); } 50% { transform: translateY(-20px); } 100% { transform: translateY(0px); } }
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.3s ease; }
.fade-slide-enter-from, .fade-slide-leave-to { opacity: 0; transform: translateY(20px); }
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
</style>