556 lines
25 KiB
Vue
556 lines
25 KiB
Vue
<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"
|
||
@scroll="handleScroll"
|
||
/>
|
||
|
||
<!-- 新消息提示/回到底部按钮 -->
|
||
<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 />
|
||
<CallWindow ref="callWindowRef" :call="webrtc.call" :target="chatStore.currentTarget" :is-mobile="isMobile" :local-stream="webrtc.localStream.value" :remote-stream="webrtc.remoteStream.value" @end-call="webrtc.endCall" @accept-call="webrtc.acceptCall" @toggle-mute="webrtc.toggleMute" @toggle-camera="webrtc.toggleCamera" @toggle-minimize="webrtc.call.minimized = !webrtc.call.minimized" />
|
||
<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 { useWebRTC } from '@/composables/useWebRTC'
|
||
// Components...
|
||
import Avatar from '@/components/common/Avatar.vue'
|
||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||
import FileConfirmModal from '@/components/common/FileConfirmModal.vue'
|
||
import CallWindow from '@/components/call/CallWindow.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()
|
||
|
||
// WebRTC logic...
|
||
function handleIncomingCall(senderUserId: string) {
|
||
const contact = chatStore.contacts.find((c) => c.user_id === senderUserId || c.id === senderUserId)
|
||
if (contact && (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id)) {
|
||
selectChat(contact)
|
||
}
|
||
}
|
||
const webrtc = useWebRTC(authStore.user?.id || '', handleIncomingCall)
|
||
|
||
// 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 callWindowRef = ref<InstanceType<typeof CallWindow> | null>(null)
|
||
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 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) {
|
||
// 保存选中的会话
|
||
storage.setSelectedConversation(contact.user_id || contact.id)
|
||
chatStore.setCurrentTarget(contact)
|
||
chatVisible.value = true
|
||
unreadCount.value = 0 // 切换聊天时重置
|
||
|
||
const roomId = getRoomId(contact)
|
||
|
||
if (chatStore.getRoomMessages(roomId).length === 0) {
|
||
try {
|
||
const response = await messageApi.getMessages(roomId, 1, 50)
|
||
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 (error) { console.error('Failed to load messages:', error) }
|
||
}
|
||
|
||
scrollToBottom(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())
|
||
conversationStore.handleMessageUpdate(message, message.isSelf === true)
|
||
if (!message.isSelf) {
|
||
// 如果不是当前聊天窗口,或者是当前窗口但用户不在底部
|
||
if (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id) {
|
||
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; await webrtc.startCall(type, chatStore.currentTarget.user_id || chatStore.currentTarget.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)
|
||
})
|
||
|
||
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()
|
||
|
||
// 恢复选中的会话
|
||
const savedTargetId = storage.getSelectedConversation()
|
||
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>
|