2173 lines
86 KiB
Vue
2173 lines
86 KiB
Vue
<template>
|
||
<div class="h-screen w-screen flex text-sm sm:text-base bg-dark" @contextmenu.prevent>
|
||
<!-- 左侧功能导航 -->
|
||
<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"
|
||
/>
|
||
<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="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 === 'moment' }"
|
||
@click="currentTab = 'moment'"
|
||
title="朋友圈"
|
||
>
|
||
<i class="fas fa-camera text-xl"></i>
|
||
<div v-if="momentUnreadCount > 0" class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||
</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>
|
||
|
||
<!-- 中间侧边栏 (会话列表) -->
|
||
<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="flex gap-2 mb-2">
|
||
<button
|
||
class="flex-1 px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition flex items-center justify-center gap-2 text-sm"
|
||
@click="showCreateGroupModal = true"
|
||
>
|
||
<i class="fas fa-users"></i>
|
||
<span>发起群聊</span>
|
||
</button>
|
||
<button
|
||
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center gap-2 text-sm"
|
||
@click="refreshConversations"
|
||
title="刷新会话列表"
|
||
>
|
||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshingConversations }"></i>
|
||
</button>
|
||
</div>
|
||
<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-300 relative group border"
|
||
:class="{
|
||
// 选中状态:恢复渐变背景,边框变细且带有透明度,增加微弱阴影
|
||
'bg-gradient-to-r from-primary/20 via-primary/5 to-transparent border-primary/30 shadow-[0_0_20px_rgba(var(--primary-rgb),0.1)]': chatStore.currentTarget && (chatStore.currentTarget.user_id === conv.target_id || chatStore.currentTarget.id === conv.target_id),
|
||
|
||
// 置顶状态 (非选中时):深色背景,无边框
|
||
'bg-black/20 border-transparent': conv.is_top && (!chatStore.currentTarget || (chatStore.currentTarget.user_id !== conv.target_id && chatStore.currentTarget.id !== conv.target_id)),
|
||
|
||
// 普通状态:透明边框,Hover 变亮
|
||
'border-transparent hover:bg-white/5': !conv.is_top && (!chatStore.currentTarget || (chatStore.currentTarget.user_id !== conv.target_id && chatStore.currentTarget.id !== conv.target_id))
|
||
}"
|
||
@click="selectChatByConversation(conv)"
|
||
@contextmenu.stop="showConversationMenu($event, conv)"
|
||
>
|
||
<!-- 头像区域 -->
|
||
<div class="relative shrink-0">
|
||
<Avatar
|
||
:name="conv.displayName"
|
||
:avatar="conv.displayAvatar || conv.displayName?.charAt(0)"
|
||
:color="undefined"
|
||
size="contact"
|
||
rounded="xl"
|
||
class="transition transform group-hover:scale-105 shadow-md"
|
||
:class="{'ring-2 ring-pink-500/50': conv.is_special_care}"
|
||
/>
|
||
<!-- 群聊标识(头像右上角) -->
|
||
<div v-if="conv.is_group || conv.room_type === 'group'" class="absolute -top-1 -right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center border-2 border-panel shadow-sm">
|
||
<i class="fas fa-users text-[8px] text-white"></i>
|
||
</div>
|
||
<!-- 特别关心标识(头像角标,在群聊标识下方) -->
|
||
<div v-if="conv.is_special_care && !(conv.is_group || conv.room_type === 'group')" class="absolute -bottom-1 -right-1 bg-pink-500 text-white rounded-full p-0.5 border-2 border-panel shadow-sm">
|
||
<i class="fas fa-heart text-[8px] block"></i>
|
||
</div>
|
||
<!-- 特别关心标识(头像角标,群聊时在左下角) -->
|
||
<div v-if="conv.is_special_care && (conv.is_group || conv.room_type === 'group')" class="absolute -bottom-1 -left-1 bg-pink-500 text-white rounded-full p-0.5 border-2 border-panel shadow-sm">
|
||
<i class="fas fa-heart text-[8px] block"></i>
|
||
</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-sm transition"
|
||
:class="conv.is_special_care ? 'text-pink-400' : 'text-gray-200 group-hover:text-white'"
|
||
>
|
||
{{ conv.displayName }}
|
||
</span>
|
||
<!-- 时间 -->
|
||
<span class="text-xs text-gray-500 group-hover:text-gray-400 shrink-0">{{ formatTime(conv.last_time || Date.now()) }}</span>
|
||
</div>
|
||
|
||
<div class="flex justify-between items-center h-5">
|
||
<!-- 消息摘要 -->
|
||
<div class="text-xs text-gray-400 truncate flex items-center group-hover:text-gray-300 flex-1 min-w-0 mr-2">
|
||
<!-- 置顶标识 -->
|
||
<i v-if="conv.is_top" class="fas fa-thumbtack text-primary/70 mr-1.5 text-[10px] transform rotate-45"></i>
|
||
<span v-if="conv.is_muted" class="fas fa-bell-slash text-gray-600 mr-1 text-[10px]"></span>
|
||
<span class="truncate">{{ conv.last_message || '' }}</span>
|
||
</div>
|
||
|
||
<!-- 优化后的未读徽标 -->
|
||
<div
|
||
v-if="(conv.unread_count || 0) > 0"
|
||
class="h-[18px] min-w-[18px] px-1.5 rounded-full flex items-center justify-center font-bold shadow-sm cursor-pointer transition-all duration-300 shrink-0 hover:scale-110"
|
||
:class="[
|
||
'bg-red-500 text-white hover:bg-emerald-500' // 默认红,悬浮绿
|
||
]"
|
||
title="标记已读"
|
||
@click.stop="handleMarkRead(conv)"
|
||
@mouseenter="hoveredBadgeId = conv.id"
|
||
@mouseleave="hoveredBadgeId = null"
|
||
>
|
||
<!-- 默认显示数字 -->
|
||
<span v-if="hoveredBadgeId !== conv.id" class="text-[10px]">
|
||
{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}
|
||
</span>
|
||
|
||
<!-- 悬浮显示对号 -->
|
||
<i v-if="hoveredBadgeId === conv.id" class="fas fa-check text-[10px]"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧主聊天区 -->
|
||
<div
|
||
v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))"
|
||
class="flex-1 flex bg-dark relative w-full h-full overflow-hidden"
|
||
>
|
||
<!-- 聊天内容区域 -->
|
||
<div class="flex-1 flex flex-col min-w-0 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.remark_name || chatStore.currentTarget.user?.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">
|
||
{{ getCurrentTargetName() }}
|
||
<span v-if="!isGroupChat" class="w-2 h-2 bg-success rounded-full animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>
|
||
<span v-else class="text-xs text-gray-400">({{ chatStore.currentTarget.member_count || 0 }}人)</span>
|
||
</h3>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-2 text-gray-400">
|
||
<!-- 单聊才显示通话按钮 -->
|
||
<template v-if="!isGroupChat">
|
||
<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>
|
||
</template>
|
||
<button class="action-btn" @click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"><i class="fas fa-ellipsis-v"></i></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 群通话进行中提示Banner -->
|
||
<GroupCallBanner
|
||
v-if="isGroupChat && chatStore.currentTarget?.room_id"
|
||
:room-id="chatStore.currentTarget.room_id"
|
||
/>
|
||
|
||
<!-- 消息列表 -->
|
||
<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"
|
||
:room-members="currentRoomMembers"
|
||
@scroll="handleScroll"
|
||
@load-more="loadMoreMessages"
|
||
@avatar-click="handleAvatarClick"
|
||
/>
|
||
|
||
<!-- 新消息提示/回到底部按钮 -->
|
||
<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"
|
||
:is-blocked="isBlockedFromGroup"
|
||
:is-muted="isMyMutedInCurrentGroup"
|
||
:muted-until="myMutedUntilInCurrentGroup"
|
||
:is-group-chat="isGroupChat"
|
||
:room-members="currentRoomMembers"
|
||
@send="handleSendText"
|
||
@file="handleFileSelect"
|
||
@record-start="handleRecordStart"
|
||
@record-stop="handleRecordStop"
|
||
@record-cancel="handleRecordCancel"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 右侧群面板(仅群聊显示) -->
|
||
<GroupChatPanel
|
||
v-if="isGroupChat && chatStore.currentTarget?.room_id"
|
||
:room-id="chatStore.currentTarget.room_id"
|
||
@member-removed="handleGroupMemberRemoved"
|
||
@member-clicked="handleGroupMemberClicked"
|
||
@group-updated="handleGroupUpdated"
|
||
/>
|
||
</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>
|
||
|
||
<!-- 联系人页面 -->
|
||
<div
|
||
v-if="currentTab === 'contact'"
|
||
class="flex flex-1 bg-panel h-full"
|
||
>
|
||
<!-- 左侧联系人列表 -->
|
||
<div class="w-80 border-r border-gray-800 shrink-0">
|
||
<ContactCircle @select-chat="selectChat" />
|
||
</div>
|
||
|
||
<!-- 右侧 -->
|
||
<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'" />
|
||
<GroupNotifyView v-else-if="contactStore.leftPanelMode === 'group-notify'" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 朋友圈页面 -->
|
||
<div
|
||
v-if="currentTab === 'moment'"
|
||
class="flex-1 bg-dark h-full overflow-hidden"
|
||
>
|
||
<MomentPanel />
|
||
</div>
|
||
<ContextMenu />
|
||
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
|
||
<ConfirmModal
|
||
:show="showDeleteConversationConfirm"
|
||
title="删除会话"
|
||
message="确认删除该会话记录吗?"
|
||
type="danger"
|
||
confirm-text="确认删除"
|
||
@confirm="handleDeleteConversation"
|
||
@cancel="showDeleteConversationConfirm = false; pendingDeleteConversation = null"
|
||
/>
|
||
|
||
<!-- 创建群聊弹窗 -->
|
||
<SelectContactsModal
|
||
:show="showCreateGroupModal"
|
||
:contacts="chatStore.contacts"
|
||
@close="showCreateGroupModal = false"
|
||
@create="handleCreateGroup"
|
||
/>
|
||
|
||
<!-- 群资料面板 -->
|
||
<GroupInfoPanel
|
||
v-if="chatStore.currentTarget?.is_group"
|
||
:show="showGroupInfoPanel"
|
||
:room-id="chatStore.currentTarget.room_id || ''"
|
||
@close="showGroupInfoPanel = false"
|
||
@updated="handleGroupUpdated"
|
||
@quit="handleGroupQuit"
|
||
@dissolve="handleGroupDissolve"
|
||
/>
|
||
|
||
<!-- Profile Modal (自己的) -->
|
||
<div v-if="showProfileModal && authStore.user && !selectedUser" 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-[#111827] rounded-2xl w-full max-w-2xl shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[90vh] flex flex-col relative">
|
||
<!-- 背景装饰 -->
|
||
<div class="absolute top-0 left-0 right-0 h-48 bg-gradient-to-b from-primary/20 to-transparent pointer-events-none"></div>
|
||
|
||
<!-- 关闭按钮 -->
|
||
<button
|
||
class="absolute top-5 right-5 z-50 w-9 h-9 rounded-full bg-black/20 hover:bg-white/10 text-gray-300 hover:text-white flex items-center justify-center transition backdrop-blur-md border border-white/5"
|
||
@click="showProfileModal = false"
|
||
>
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
|
||
<!-- 内容区域 -->
|
||
<div class="flex-1 overflow-y-auto custom-scrollbar relative z-10 flex flex-col">
|
||
<!-- 头部:头像与核心信息 -->
|
||
<div class="pt-14 pb-8 px-8 text-center flex flex-col items-center animate-fade-in">
|
||
<div class="relative group cursor-pointer" @click="showAvatarEditModal = true">
|
||
<Avatar
|
||
:name="authStore.user.name"
|
||
:avatar="authStore.user.avatar"
|
||
size="2xl"
|
||
rounded="full"
|
||
class="border-[6px] border-[#111827] shadow-2xl relative z-10 transition-transform duration-300 group-hover:scale-105"
|
||
/>
|
||
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||
<i class="fas fa-camera text-white text-xl"></i>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 class="text-2xl font-bold text-white mt-5 flex items-center gap-2 justify-center select-text">
|
||
{{ authStore.user.name }}
|
||
</h3>
|
||
|
||
<p class="text-gray-400 text-sm mt-2 max-w-md truncate px-4 opacity-80">
|
||
{{ authStore.user.desc || '暂无签名' }}
|
||
</p>
|
||
|
||
<div class="flex gap-3 mt-4 text-xs font-medium text-gray-400">
|
||
<span class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5 select-text">ID: {{ authStore.user.id }}</span>
|
||
<span v-if="authStore.user.region" class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5">{{ authStore.user.region }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细信息列表 -->
|
||
<div class="px-8 py-4 space-y-3 max-w-xl mx-auto w-full animate-slide-up">
|
||
<div class="bg-white/5 rounded-2xl border border-white/5 overflow-hidden">
|
||
<!-- 网名 -->
|
||
<div class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 cursor-pointer" @click="showNameEditModal = true">
|
||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||
<i class="far fa-user"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0 mr-8">
|
||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">网名</div>
|
||
<div class="text-sm text-gray-200 font-medium truncate select-text">{{ authStore.user.name || '未知' }}</div>
|
||
</div>
|
||
<i class="fas fa-edit text-xs text-gray-600 absolute right-4 group-hover:text-gray-400 transition-colors"></i>
|
||
</div>
|
||
|
||
<!-- 手机号 -->
|
||
<div class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 cursor-pointer" @click="showPhoneEditModal = true">
|
||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||
<i class="fas fa-phone-alt"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0 mr-8">
|
||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">手机号</div>
|
||
<div class="text-sm text-gray-200 font-medium truncate select-text font-mono">{{ authStore.user.phone || '未设置' }}</div>
|
||
</div>
|
||
<i class="fas fa-edit text-xs text-gray-600 absolute right-4 group-hover:text-gray-400 transition-colors"></i>
|
||
</div>
|
||
|
||
<!-- 邮箱 -->
|
||
<div v-if="authStore.user.email" class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
|
||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||
<i class="far fa-envelope"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0 mr-8">
|
||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">邮箱</div>
|
||
<div class="text-sm text-gray-200 font-medium truncate select-text font-mono">{{ authStore.user.email }}</div>
|
||
</div>
|
||
<button class="absolute right-4 w-8 h-8 rounded-lg hover:bg-white/10 text-gray-500 hover:text-primary transition flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||
@click.stop="copyText(authStore.user.email)" title="复制">
|
||
<i class="far fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 地区 -->
|
||
<div v-if="authStore.user.region" class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
|
||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||
<i class="fas fa-map-marker-alt"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0 mr-8">
|
||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">地区</div>
|
||
<div class="text-sm text-gray-200 font-medium truncate select-text">{{ authStore.user.region }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 签名 -->
|
||
<div class="flex items-start p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
|
||
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
|
||
<i class="far fa-comment"></i>
|
||
</div>
|
||
<div class="flex-1 min-w-0 mr-8">
|
||
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">个性签名</div>
|
||
<div class="text-sm text-gray-200 font-medium break-words">{{ authStore.user.desc || '暂无签名' }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 头像编辑弹窗 -->
|
||
<div v-if="showAvatarEditModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showAvatarEditModal = false">
|
||
<div class="bg-panel w-96 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
|
||
<h3 class="text-lg font-bold text-white mb-4">编辑头像</h3>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-2">当前头像</label>
|
||
<div class="flex justify-center mb-4">
|
||
<Avatar
|
||
:name="authStore.user?.name || ''"
|
||
:avatar="editingAvatar || authStore.user?.avatar"
|
||
size="xl"
|
||
rounded="full"
|
||
class="border-4 border-gray-700"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-2">上传图片</label>
|
||
<input
|
||
ref="avatarFileInput"
|
||
type="file"
|
||
accept="image/*"
|
||
class="hidden"
|
||
@change="handleAvatarFileSelect"
|
||
/>
|
||
<button
|
||
class="w-full py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition mb-2"
|
||
@click="avatarFileInput?.click()"
|
||
>
|
||
<i class="fas fa-upload mr-2"></i>选择文件
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-2">或输入图片URL</label>
|
||
<input
|
||
v-model="editingAvatar"
|
||
type="text"
|
||
class="w-full bg-black/30 text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none"
|
||
placeholder="https://example.com/avatar.jpg"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-3 mt-6">
|
||
<button class="flex-1 py-2.5 rounded-lg text-gray-400 hover:bg-white/5 transition" @click="showAvatarEditModal = false; editingAvatar = ''">取消</button>
|
||
<button class="flex-1 py-2.5 rounded-lg bg-primary text-white font-bold hover:bg-primary-hover transition" @click="handleUpdateAvatar">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 昵称编辑弹窗 -->
|
||
<div v-if="showNameEditModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showNameEditModal = false">
|
||
<div class="bg-panel w-96 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
|
||
<h3 class="text-lg font-bold text-white mb-4">编辑昵称</h3>
|
||
<input
|
||
v-model="editingName"
|
||
type="text"
|
||
class="w-full bg-black/30 text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none"
|
||
placeholder="请输入昵称"
|
||
maxlength="20"
|
||
@keyup.enter="handleUpdateName"
|
||
/>
|
||
<div class="flex gap-3 mt-6">
|
||
<button class="flex-1 py-2.5 rounded-lg text-gray-400 hover:bg-white/5 transition" @click="showNameEditModal = false; editingName = ''">取消</button>
|
||
<button class="flex-1 py-2.5 rounded-lg bg-primary text-white font-bold hover:bg-primary-hover transition" @click="handleUpdateName">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 手机号编辑弹窗 -->
|
||
<div v-if="showPhoneEditModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showPhoneEditModal = false">
|
||
<div class="bg-panel w-96 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
|
||
<h3 class="text-lg font-bold text-white mb-4">编辑手机号</h3>
|
||
<input
|
||
v-model="editingPhone"
|
||
type="tel"
|
||
class="w-full bg-black/30 text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none font-mono"
|
||
placeholder="请输入手机号"
|
||
maxlength="20"
|
||
@keyup.enter="handleUpdatePhone"
|
||
/>
|
||
<div class="flex gap-3 mt-6">
|
||
<button class="flex-1 py-2.5 rounded-lg text-gray-400 hover:bg-white/5 transition" @click="showPhoneEditModal = false; editingPhone = ''">取消</button>
|
||
<button class="flex-1 py-2.5 rounded-lg bg-primary text-white font-bold hover:bg-primary-hover transition" @click="handleUpdatePhone">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- User Info Modal (点击对方头像) -->
|
||
<div v-if="selectedUser" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="selectedUser = null">
|
||
<div class="bg-panel rounded-2xl w-96 shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
|
||
<div class="h-32 bg-gradient-to-r from-indigo-600 to-purple-600"></div>
|
||
<div class="px-6 pb-6 text-center -mt-16">
|
||
<Avatar
|
||
:name="getUserDisplayName(selectedUser)"
|
||
:avatar="getUserAvatar(selectedUser)"
|
||
size="xl"
|
||
rounded="full"
|
||
class="mx-auto border-4 border-panel shadow-xl"
|
||
/>
|
||
<h2 class="text-xl font-bold mt-3 text-white">{{ getUserDisplayName(selectedUser) }}</h2>
|
||
<p class="text-sm text-gray-400 mt-1">{{ getUserDesc(selectedUser) || '暂无签名' }}</p>
|
||
|
||
<!-- 用户信息 -->
|
||
<div class="mt-4 space-y-2 text-left">
|
||
<div v-if="getUserId(selectedUser)" class="flex items-center justify-between text-sm">
|
||
<span class="text-gray-400">账号ID:</span>
|
||
<span class="text-white">{{ getUserId(selectedUser) }}</span>
|
||
</div>
|
||
<div v-if="getUserPhone(selectedUser)" class="flex items-center justify-between text-sm">
|
||
<span class="text-gray-400">手机号:</span>
|
||
<span class="text-white">{{ getUserPhone(selectedUser) }}</span>
|
||
</div>
|
||
<div v-if="getUserEmail(selectedUser)" class="flex items-center justify-between text-sm">
|
||
<span class="text-gray-400">邮箱:</span>
|
||
<span class="text-white">{{ getUserEmail(selectedUser) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="mt-6 flex gap-3">
|
||
<button
|
||
v-if="!isFriend(selectedUser as Contact)"
|
||
class="flex-1 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition flex items-center justify-center gap-2"
|
||
@click="handleAddFriend"
|
||
>
|
||
<i class="fas fa-user-plus"></i>
|
||
<span>添加好友</span>
|
||
</button>
|
||
<button
|
||
v-else
|
||
class="flex-1 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition flex items-center justify-center gap-2"
|
||
@click="handleSendMessageToUser"
|
||
>
|
||
<i class="fas fa-comment"></i>
|
||
<span>发消息</span>
|
||
</button>
|
||
<button
|
||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg transition"
|
||
@click="selectedUser = null"
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 用户信息卡片(群聊消息列表点击头像) -->
|
||
<UserInfoCard
|
||
:show="showUserInfoCard"
|
||
:user="selectedUserForInfo as User | null"
|
||
@close="showUserInfoCard = false; selectedUserForInfo = null"
|
||
@send-message="handleSendMessageFromInfoCard"
|
||
@audio-call="handleAudioCallFromInfoCard"
|
||
@video-call="handleVideoCallFromInfoCard"
|
||
/>
|
||
</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 { useMomentStore } from '@/stores/moment'
|
||
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 * 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 { storage } from '@/utils/storage'
|
||
import type { Contact, ChatMessage, User } 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 ConfirmModal from '@/components/common/ConfirmModal.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'
|
||
import GroupNotifyView from '@/views/contact/GroupNotifyView.vue'
|
||
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
|
||
import GroupInfoPanel from '@/components/chat/GroupInfoPanel.vue'
|
||
import GroupChatPanel from '@/components/chat/GroupChatPanel.vue'
|
||
import GroupCallBanner from '@/components/chat/GroupCallBanner.vue'
|
||
import UserInfoCard from '@/components/common/UserInfoCard.vue'
|
||
import MomentPanel from '@/components/moment/MomentPanel.vue'
|
||
import * as groupApi from '@/api/modules/room'
|
||
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
const chatStore = useChatStore()
|
||
const conversationStore = useConversationStore()
|
||
const toastStore = useToastStore()
|
||
const contactStore = useContactStore()
|
||
const momentStore = useMomentStore()
|
||
const { showContextMenu } = useContextMenu()
|
||
const webrtcStore = useWebRTCStore()
|
||
const webrtc = webrtcStore.webrtc
|
||
|
||
// 朋友圈未读数
|
||
const momentUnreadCount = computed(() => momentStore.unreadCount)
|
||
|
||
// State
|
||
const currentTab = ref<'chat' | 'contact' | 'moment'>(storage.getCurrentTab() as 'chat' | 'contact' | 'moment')
|
||
const searchQuery = ref('')
|
||
const inputText = ref('')
|
||
const chatVisible = ref(false)
|
||
const isMobile = ref(window.innerWidth < 768)
|
||
const showProfileModal = ref(false)
|
||
const selectedUser = ref<Contact | User | null>(null)
|
||
const isRecording = ref(false)
|
||
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
|
||
const showCreateGroupModal = ref(false)
|
||
const showDeleteConversationConfirm = ref(false)
|
||
const pendingDeleteConversation = ref<Conversation | null>(null)
|
||
const showGroupInfoPanel = ref(false)
|
||
const hoveredBadgeId = ref<number | null>(null)
|
||
const refreshingConversations = ref(false)
|
||
const showAvatarEditModal = ref(false)
|
||
const showNameEditModal = ref(false)
|
||
const showPhoneEditModal = ref(false)
|
||
const editingAvatar = ref('')
|
||
const editingName = ref('')
|
||
const editingPhone = ref('')
|
||
const avatarFileInput = ref<HTMLInputElement | null>(null)
|
||
const selectedAvatarFile = ref<File | null>(null)
|
||
const showUserInfoCard = ref(false)
|
||
const selectedUserForInfo = ref<User | Contact | null>(null)
|
||
const roomMembers = ref<Record<string, Record<string, { name: string; avatar?: string }>>>({})
|
||
|
||
// 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()
|
||
|
||
// 1. 基础映射:合并 contactStore 中的最新状态
|
||
let convs = conversationStore.conversations.map(conv => {
|
||
// 查找对应的联系人信息,以获取最新的 is_top, is_special_care 等状态
|
||
const contact = chatStore.contacts.find(c => c.user_id === conv.target_id || c.id === conv.target_id)
|
||
return {
|
||
...conv,
|
||
// 优先使用联系人中的状态 (因为右键菜单更新的是 contactStore)
|
||
is_top: contact?.is_top ?? conv.is_top,
|
||
is_muted: contact?.is_muted ?? conv.is_muted,
|
||
is_special_care: contact?.is_special_care ?? false,
|
||
// 使用备注名优先
|
||
displayName: contact?.remark_name || contact?.user?.name || conv.name || '未知',
|
||
displayAvatar: contact?.user?.avatar || conv.avatar
|
||
}
|
||
})
|
||
|
||
// 2. 搜索过滤(支持群名称和用户名称)
|
||
if (query) {
|
||
convs = convs.filter(c => {
|
||
const name = c.displayName.toLowerCase()
|
||
const memberCount = c.member_count ? `${c.member_count}人` : ''
|
||
return name.includes(query) || memberCount.includes(query)
|
||
})
|
||
}
|
||
|
||
// 3. 排序:置顶优先 > 时间倒序
|
||
return convs.sort((a, b) => {
|
||
if (!!a.is_top !== !!b.is_top) return a.is_top ? -1 : 1
|
||
return (b.last_time || 0) - (a.last_time || 0)
|
||
})
|
||
})
|
||
|
||
const currentMessages = computed(() => {
|
||
if (!chatStore.currentTarget) return []
|
||
const roomId = getRoomId(chatStore.currentTarget)
|
||
return chatStore.getRoomMessages(roomId)
|
||
})
|
||
|
||
function getRoomId(contact: Contact): string {
|
||
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('_')
|
||
}
|
||
|
||
// 判断是否为群聊
|
||
const isGroupChat = computed(() => chatStore.currentTarget?.is_group || chatStore.currentTarget?.room_type === 'group')
|
||
|
||
// 获取当前房间的成员映射(用于 MessageList)
|
||
const currentRoomMembers = computed(() => {
|
||
if (!chatStore.currentTarget?.room_id) {
|
||
return {}
|
||
}
|
||
return roomMembers.value[chatStore.currentTarget.room_id] || {}
|
||
})
|
||
|
||
// 判断当前用户是否被移除(不在群成员列表中)
|
||
const isBlockedFromGroup = computed(() => {
|
||
if (!isGroupChat.value || !chatStore.currentTarget?.room_id || !authStore.user) {
|
||
return false
|
||
}
|
||
const currentMember = roomMembers.value[chatStore.currentTarget.room_id]?.[authStore.user.id]
|
||
// 如果当前用户不在群成员列表中,则认为被移除
|
||
return !currentMember
|
||
})
|
||
|
||
// 判断当前用户是否在当前群中被禁言
|
||
const isMyMutedInCurrentGroup = computed(() => {
|
||
if (!isGroupChat.value || !chatStore.currentTarget?.room_id) {
|
||
return false
|
||
}
|
||
return chatStore.isMyMuted(chatStore.currentTarget.room_id)
|
||
})
|
||
|
||
// 获取当前用户在当前群的禁言到期时间
|
||
const myMutedUntilInCurrentGroup = computed(() => {
|
||
if (!isGroupChat.value || !chatStore.currentTarget?.room_id) {
|
||
return null
|
||
}
|
||
return chatStore.getMyMutedUntil(chatStore.currentTarget.room_id)
|
||
})
|
||
|
||
// 获取当前目标名称
|
||
function getCurrentTargetName(): string {
|
||
if (!chatStore.currentTarget) return ''
|
||
if (isGroupChat.value) {
|
||
return chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name || '群聊'
|
||
}
|
||
return chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name || '未知'
|
||
}
|
||
|
||
// 滚动处理
|
||
function handleScroll(e: Event) {
|
||
const target = e.target as HTMLElement
|
||
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)
|
||
const roomId = getRoomId(contact)
|
||
storage.setSelectedRoomId(roomId)
|
||
chatStore.setCurrentTarget(contact)
|
||
chatVisible.value = true
|
||
unreadCount.value = 0
|
||
|
||
// 如果是群聊,加载群成员信息
|
||
if (contact.is_group || contact.room_type === 'group') {
|
||
await loadGroupMembers(roomId)
|
||
}
|
||
|
||
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)
|
||
|
||
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,
|
||
}))
|
||
|
||
const sortedMsgs = msgs.reverse()
|
||
chatStore.setRoomMessages(roomId, sortedMsgs)
|
||
|
||
if (response.data.length < 50) {
|
||
hasMoreHistory.value[roomId] = false
|
||
}
|
||
|
||
await nextTick()
|
||
scrollToBottom(false)
|
||
} else {
|
||
hasMoreHistory.value[roomId] = false
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load messages:', error)
|
||
toastStore.error('加载消息失败')
|
||
hasMoreHistory.value[roomId] = false
|
||
}
|
||
} else {
|
||
await nextTick()
|
||
scrollToBottom(false)
|
||
}
|
||
}
|
||
|
||
// 加载群成员信息
|
||
async function loadGroupMembers(roomId: string) {
|
||
try {
|
||
const response = await groupApi.getGroupMembers(roomId)
|
||
const members = Array.isArray(response) ? response : (response.data || [])
|
||
const membersMap: Record<string, { name: string; avatar?: string }> = {}
|
||
members.forEach((m: any) => {
|
||
membersMap[m.user_id] = {
|
||
name: m.user?.name || m.nickname || '未知',
|
||
avatar: m.user?.avatar
|
||
}
|
||
})
|
||
roomMembers.value[roomId] = membersMap
|
||
} catch (error) {
|
||
console.error('Failed to load group members:', error)
|
||
// 如果加载失败,清空成员列表(表示用户可能已被移除)
|
||
roomMembers.value[roomId] = {} as Record<string, { name: string; avatar?: string }>
|
||
}
|
||
}
|
||
|
||
async function loadMoreMessages() {
|
||
if (!chatStore.currentTarget || loadingHistory.value) return
|
||
|
||
const roomId = getRoomId(chatStore.currentTarget)
|
||
const currentMessages = chatStore.getRoomMessages(roomId)
|
||
|
||
if (currentMessages.length === 0) return
|
||
|
||
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
|
||
|
||
if (response.data.length < pageSize) {
|
||
hasMoreHistory.value[roomId] = false
|
||
}
|
||
|
||
await nextTick()
|
||
if (container) {
|
||
const newScrollHeight = container.scrollHeight
|
||
const heightDiff = newScrollHeight - oldScrollHeight
|
||
container.scrollTop = oldScrollTop + heightDiff
|
||
}
|
||
} else {
|
||
hasMoreHistory.value[roomId] = false
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load more messages:', error)
|
||
toastStore.error('加载历史消息失败')
|
||
} finally {
|
||
loadingHistory.value = false
|
||
}
|
||
}
|
||
|
||
async function selectChatByConversation(conv: Conversation) {
|
||
// 如果是群聊,从会话的 room 信息创建 Contact
|
||
if (conv.type === 2 || conv.is_group) {
|
||
const room = (conv as any).room || conv.target_group
|
||
if (room) {
|
||
const groupContact: Contact = {
|
||
id: room.room_id || conv.target_id,
|
||
user_id: room.room_id || conv.target_id,
|
||
contact_user_id: room.room_id || conv.target_id,
|
||
room_id: room.room_id || conv.room_id || conv.target_id,
|
||
room_type: 'group',
|
||
is_group: true,
|
||
remark_name: room.room_name || room.name || '未知群聊',
|
||
is_top: conv.is_top,
|
||
is_muted: conv.is_muted,
|
||
user: {
|
||
id: room.room_id || conv.target_id,
|
||
name: room.room_name || room.name || '未知群聊',
|
||
avatar: room.room_avatar || room.avatar || '',
|
||
} as any,
|
||
}
|
||
await selectChat(groupContact)
|
||
await conversationStore.clearUnread(conv.target_id)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 私聊:从联系人列表查找
|
||
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)
|
||
}
|
||
|
||
async function sendMessage(type: number, content: string, extra: any = {}, duration = 0) {
|
||
if (!chatStore.currentTarget) return
|
||
|
||
// 检查是否被禁言(群聊中被移除或退出)
|
||
if (isGroupChat.value && isBlockedFromGroup.value) {
|
||
toastStore.error('您已被移出群聊,无法发送消息')
|
||
return
|
||
}
|
||
|
||
const roomId = getRoomId(chatStore.currentTarget)
|
||
const isGroup = chatStore.currentTarget.is_group || chatStore.currentTarget.room_type === 'group'
|
||
|
||
// 发送消息前,确保会话存在(如果不存在则创建)
|
||
const existingConv = conversationStore.conversations.find(c =>
|
||
isGroup
|
||
? c.room_id === roomId
|
||
: (c.target_id === (chatStore.currentTarget?.user_id || chatStore.currentTarget?.id))
|
||
)
|
||
|
||
if (!existingConv) {
|
||
try {
|
||
// 调用接口创建会话
|
||
const conv: any = await conversationApi.getConversationByRoom(roomId)
|
||
// 检查会话是否已存在(可能在其他地方已添加)
|
||
const exists = conversationStore.conversations.find(c =>
|
||
c.room_id === conv.room_id ||
|
||
(c.target_id === conv.target_id && c.type === conv.type)
|
||
)
|
||
if (!exists) {
|
||
const isGroupConv = conv.type === 2
|
||
const targetGroup = conv.room || conv.target_group
|
||
|
||
const formattedConv: Conversation = {
|
||
id: conv.id,
|
||
target_id: conv.target_id,
|
||
room_id: conv.room_id,
|
||
type: conv.type,
|
||
is_group: isGroupConv,
|
||
is_top: conv.is_top || false,
|
||
is_muted: conv.is_muted || false,
|
||
unread_count: conv.unread_count || 0,
|
||
last_message: conv.last_message || '',
|
||
last_message_time: conv.last_time,
|
||
name: isGroupConv
|
||
? (targetGroup?.room_name || targetGroup?.name || conv.name || conv.room_id || '群聊')
|
||
: (conv.target_user?.name || conv.name || '未知用户'),
|
||
display_name: isGroupConv
|
||
? (targetGroup?.room_name || targetGroup?.name || conv.name || conv.room_id || '群聊')
|
||
: (conv.target_user?.name || conv.name || '未知用户'),
|
||
avatar: isGroupConv
|
||
? (targetGroup?.room_avatar || targetGroup?.avatar || conv.avatar || '')
|
||
: (conv.target_user?.avatar || conv.avatar || ''),
|
||
user: conv.target_user,
|
||
room: conv.room,
|
||
target_group: targetGroup ? {
|
||
id: targetGroup.room_id || targetGroup.id,
|
||
room_id: targetGroup.room_id,
|
||
room_type: 'group',
|
||
name: targetGroup.room_name || targetGroup.name,
|
||
avatar: targetGroup.room_avatar || targetGroup.avatar,
|
||
owner_id: targetGroup.owner_id,
|
||
member_count: targetGroup.member_count,
|
||
created_at: targetGroup.created_at,
|
||
} : undefined
|
||
}
|
||
conversationStore.conversations.push(formattedConv)
|
||
conversationStore.sortConversations()
|
||
}
|
||
} catch (error) {
|
||
// 如果创建会话失败,仍然继续发送消息(后端会在发送消息时创建会话)
|
||
console.warn('Failed to create conversation before sending message:', error)
|
||
}
|
||
}
|
||
|
||
// 群聊时 receiver_user_id 应该为空或群ID,确保后端能正确识别为群聊
|
||
const receiverUserId = isGroup ? '' : (chatStore.currentTarget.user_id || chatStore.currentTarget.id)
|
||
|
||
const payload = {
|
||
sender_client_id: wsManager.getClientId() || '',
|
||
receiver_user_id: receiverUserId,
|
||
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra)
|
||
}
|
||
|
||
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
|
||
}
|
||
chatStore.addMessage(roomId, message)
|
||
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(message), Date.now())
|
||
|
||
scrollToBottom(true)
|
||
|
||
try {
|
||
await messageApi.sendMessage(payload)
|
||
} catch(e: any) {
|
||
// 移除已添加的消息(因为发送失败)
|
||
const messages = chatStore.messages[roomId] || []
|
||
const index = messages.findIndex(m => m.id === message.id)
|
||
if (index > -1) {
|
||
messages.splice(index, 1)
|
||
}
|
||
|
||
const errorMsg = e?.response?.data?.message || e?.message || '发送失败'
|
||
toastStore.error(errorMsg)
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
// 处理好友通知
|
||
if (message.message_type === 5) {
|
||
const extra = message.extra as any
|
||
|
||
// 处理好友资料更新通知
|
||
if (extra.type === 'profile_update') {
|
||
// 更新联系人列表中的用户信息
|
||
const contact = chatStore.contacts.find(c => c.user_id === extra.user_id || c.id === extra.user_id)
|
||
if (contact && contact.user) {
|
||
if (extra.name) contact.user.name = extra.name
|
||
if (extra.avatar) contact.user.avatar = extra.avatar
|
||
}
|
||
|
||
// 更新当前聊天对象(如果正在和这个人聊天)
|
||
const currentTarget = chatStore.currentTarget
|
||
if (currentTarget && (currentTarget.user_id === extra.user_id || currentTarget.id === extra.user_id)) {
|
||
if (currentTarget.user) {
|
||
if (extra.name) currentTarget.user.name = extra.name
|
||
if (extra.avatar) currentTarget.user.avatar = extra.avatar
|
||
}
|
||
}
|
||
|
||
// 刷新会话列表
|
||
conversationStore.loadConversations()
|
||
return
|
||
}
|
||
|
||
// 处理好友申请通知
|
||
toastStore.showToast(
|
||
'收到好友申请',
|
||
'info',
|
||
5000,
|
||
extra.title || message.content || '有人申请添加你为好友',
|
||
{
|
||
label: '去处理',
|
||
handler: () => {
|
||
currentTab.value = 'contact'
|
||
contactStore.setLeftPanelMode('friend-notify')
|
||
}
|
||
}
|
||
)
|
||
return
|
||
}
|
||
|
||
// 处理群聊通知
|
||
if (message.message_type === 7) {
|
||
const extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : (message.extra || {})
|
||
const notifRoomId = extra.room_id || message.room_id
|
||
|
||
// 根据通知类型进行不同处理
|
||
switch (extra.type) {
|
||
case 'member_mute':
|
||
case 'member_unmute':
|
||
// 禁言/解除禁言通知 - 保存到 chatStore 以便 MessageInput 可以响应
|
||
if (extra.target === authStore.user?.id) {
|
||
// 自己被禁言/解除禁言
|
||
chatStore.setMyMuteStatus(notifRoomId, extra.type === 'member_mute' ? extra.muted_until : null)
|
||
}
|
||
// 触发群成员刷新
|
||
chatStore.setLastGroupNotification({ room_id: notifRoomId, type: extra.type, data: extra })
|
||
break
|
||
|
||
case 'role_change':
|
||
// 角色变更通知 - 触发群成员刷新
|
||
chatStore.setLastGroupNotification({ room_id: notifRoomId, type: extra.type, data: extra })
|
||
break
|
||
|
||
case 'member_invite':
|
||
case 'member_remove':
|
||
case 'member_leave':
|
||
case 'member_join':
|
||
// 成员变动通知 - 触发群成员刷新
|
||
chatStore.setLastGroupNotification({ room_id: notifRoomId, type: extra.type, data: extra })
|
||
break
|
||
}
|
||
|
||
// 显示 Toast 通知(不是自己被禁言时)
|
||
if (!(extra.type === 'member_mute' && extra.target === authStore.user?.id)) {
|
||
toastStore.showToast(
|
||
'群聊通知',
|
||
'info',
|
||
5000,
|
||
message.content || extra.title || '收到群聊通知',
|
||
{
|
||
label: '去查看',
|
||
handler: () => {
|
||
currentTab.value = 'contact'
|
||
contactStore.setLeftPanelMode('group-notify')
|
||
}
|
||
}
|
||
)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 先检查会话是否存在,确保消息添加到正确的会话
|
||
// 对于群聊消息,使用 room_id 查找;对于私聊消息,使用 target_id 查找
|
||
const isGroupMessage = message.room_id && (message.room_id.startsWith('group_') || message.receiver_user_id === message.room_id)
|
||
let existingConv = null
|
||
let actualRoomId = roomId
|
||
|
||
if (isGroupMessage) {
|
||
// 群聊消息:使用 room_id 查找会话
|
||
existingConv = conversationStore.conversations.find(c => c.room_id === roomId)
|
||
} else {
|
||
// 私聊消息:使用 target_id 查找会话
|
||
const targetId = message.isSelf ? message.receiver_user_id : message.sender_user_id
|
||
existingConv = conversationStore.conversations.find(c => c.target_id === targetId || c.room_id === roomId)
|
||
// 如果找到会话,使用会话的 room_id 确保匹配
|
||
if (existingConv && existingConv.room_id) {
|
||
actualRoomId = existingConv.room_id
|
||
}
|
||
}
|
||
|
||
// 如果会话不存在,先获取或创建会话,然后再添加消息
|
||
if (!existingConv && !message.isSelf && roomId) {
|
||
const now = Date.now()
|
||
const lastRefresh = (window as any).__lastConversationRefresh || 0
|
||
// 防抖:1秒内最多请求一次
|
||
if (now - lastRefresh > 1000) {
|
||
(window as any).__lastConversationRefresh = now
|
||
// 调用新接口获取会话信息,只添加新会话到列表
|
||
conversationApi.getConversationByRoom(roomId).then((conv: any) => {
|
||
// 检查会话是否已存在(可能在其他地方已添加)
|
||
const exists = conversationStore.conversations.find(c =>
|
||
c.room_id === conv.room_id ||
|
||
(c.target_id === conv.target_id && c.type === conv.type)
|
||
)
|
||
if (!exists) {
|
||
// 转换格式并添加到列表(使用与 loadConversations 相同的格式)
|
||
const isGroup = conv.type === 2
|
||
// 获取群信息(与 loadConversations 保持一致)
|
||
const targetGroup = conv.room || conv.target_group
|
||
|
||
const formattedConv: Conversation = {
|
||
id: conv.id,
|
||
target_id: conv.target_id,
|
||
room_id: conv.room_id,
|
||
type: conv.type,
|
||
is_group: isGroup,
|
||
is_top: conv.is_top || false,
|
||
is_muted: conv.is_muted || false,
|
||
unread_count: conv.unread_count || 0,
|
||
last_message: conv.last_message || '',
|
||
last_message_time: conv.last_time,
|
||
// 使用与 loadConversations 相同的逻辑获取名称
|
||
name: isGroup
|
||
? (targetGroup?.room_name || targetGroup?.name || conv.name || conv.room_id || '群聊')
|
||
: (conv.target_user?.name || conv.name || '未知用户'),
|
||
display_name: isGroup
|
||
? (targetGroup?.room_name || targetGroup?.name || conv.name || conv.room_id || '群聊')
|
||
: (conv.target_user?.name || conv.name || '未知用户'),
|
||
avatar: isGroup
|
||
? (targetGroup?.room_avatar || targetGroup?.avatar || conv.avatar || '')
|
||
: (conv.target_user?.avatar || conv.avatar || ''),
|
||
user: conv.target_user,
|
||
room: conv.room,
|
||
target_group: targetGroup ? {
|
||
id: targetGroup.room_id || targetGroup.id,
|
||
room_id: targetGroup.room_id,
|
||
room_type: 'group',
|
||
name: targetGroup.room_name || targetGroup.name,
|
||
avatar: targetGroup.room_avatar || targetGroup.avatar,
|
||
owner_id: targetGroup.owner_id,
|
||
member_count: targetGroup.member_count,
|
||
created_at: targetGroup.created_at,
|
||
} : undefined
|
||
}
|
||
conversationStore.conversations.push(formattedConv)
|
||
// 添加新会话后重新排序
|
||
conversationStore.sortConversations()
|
||
}
|
||
// 会话创建后,使用正确的 room_id 添加消息
|
||
const finalRoomId = conv.room_id || roomId
|
||
chatStore.addMessage(finalRoomId, message)
|
||
processMessageAfterConversation(message, finalRoomId)
|
||
}).catch((error) => {
|
||
console.error('Failed to get conversation by room:', error)
|
||
// 如果接口失败,仍然添加消息(使用原始 roomId),然后刷新列表
|
||
chatStore.addMessage(roomId, message)
|
||
processMessageAfterConversation(message, roomId)
|
||
conversationStore.loadConversations()
|
||
})
|
||
return // 异步处理,先返回
|
||
} else {
|
||
// 防抖期间,仍然添加消息(使用原始 roomId)
|
||
actualRoomId = roomId
|
||
}
|
||
} else if (existingConv) {
|
||
// 会话存在,使用会话的 room_id 确保匹配
|
||
actualRoomId = existingConv.room_id || roomId
|
||
}
|
||
|
||
// 添加消息到正确的会话(使用确认后的 roomId)
|
||
chatStore.addMessage(actualRoomId, message)
|
||
|
||
// 处理消息的后续逻辑
|
||
processMessageAfterConversation(message, actualRoomId)
|
||
}
|
||
|
||
// 处理消息的后续逻辑(会话确认后)
|
||
function processMessageAfterConversation(message: ChatMessage, roomId: string) {
|
||
const contact = chatStore.contacts.find(c => c.user_id === message.sender_user_id || c.id === message.sender_user_id)
|
||
|
||
// 检查是否为群聊消息,如果是且当前正在查看该群聊,检查群成员信息
|
||
const isGroup = chatStore.currentTarget?.is_group || chatStore.currentTarget?.room_type === 'group'
|
||
const currentRoomId = chatStore.currentTarget ? getRoomId(chatStore.currentTarget) : null
|
||
const isCurrentGroupChat = isGroup && currentRoomId === roomId
|
||
|
||
// 如果是群聊消息且当前正在查看该群聊,检查发送者是否在 roomMembers 中
|
||
if (isCurrentGroupChat && !message.isSelf && message.sender_user_id) {
|
||
const roomMembersForRoom = roomMembers.value[roomId] || {}
|
||
if (!roomMembersForRoom[message.sender_user_id]) {
|
||
// 发送者不在成员列表中,立即加载群成员
|
||
loadGroupMembers(roomId)
|
||
}
|
||
}
|
||
|
||
const isCurrentChat = currentRoomId === roomId
|
||
|
||
if (contact) {
|
||
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
|
||
|
||
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'
|
||
})
|
||
}
|
||
|
||
// 不在会话tab时显示toast通知
|
||
if (currentTab.value !== 'chat') {
|
||
const senderName = contact.remark_name || contact.user?.name || message.sender_user_id || '未知用户'
|
||
toastStore.showToast(
|
||
'收到新消息',
|
||
'info',
|
||
5000,
|
||
`来自 ${senderName} 的新消息`,
|
||
{
|
||
label: '去查看',
|
||
handler: async () => {
|
||
currentTab.value = 'chat'
|
||
// 尝试查找或创建会话
|
||
await conversationStore.loadConversations()
|
||
const conv = conversationStore.conversations.find(c => c.room_id === roomId || c.target_id === message.sender_user_id)
|
||
if (conv) {
|
||
await selectChatByConversation(conv)
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
} else {
|
||
// 不在会话列表时收到消息,显示提示
|
||
if (!message.isSelf && currentTab.value !== 'chat') {
|
||
const senderName = message.sender_user_id || '未知用户'
|
||
toastStore.showToast(
|
||
'收到新消息',
|
||
'info',
|
||
5000,
|
||
`来自 ${senderName} 的新消息`,
|
||
{
|
||
label: '去查看',
|
||
handler: async () => {
|
||
currentTab.value = 'chat'
|
||
// 尝试查找或创建会话
|
||
await conversationStore.loadConversations()
|
||
const conv = conversationStore.conversations.find(c => c.room_id === roomId || c.target_id === message.sender_user_id)
|
||
if (conv) {
|
||
await selectChatByConversation(conv)
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
function backToList() { if (isMobile.value) chatVisible.value = false }
|
||
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
|
||
function getMsgSummary(msg: ChatMessage): string { return getMessageSummary(msg) }
|
||
|
||
// 复制文本
|
||
function copyText(text?: string) {
|
||
if (!text) return
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
toastStore.success('复制成功')
|
||
}).catch(() => {
|
||
toastStore.error('复制失败')
|
||
})
|
||
}
|
||
|
||
// 处理头像文件选择
|
||
function handleAvatarFileSelect(event: Event) {
|
||
const target = event.target as HTMLInputElement
|
||
const file = target.files?.[0]
|
||
if (!file) return
|
||
|
||
if (!file.type.startsWith('image/')) {
|
||
toastStore.error('请选择图片文件')
|
||
return
|
||
}
|
||
|
||
// 保存文件对象用于上传
|
||
selectedAvatarFile.value = file
|
||
|
||
// 预览图片(用于显示)
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
editingAvatar.value = e.target?.result as string
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
// 更新头像
|
||
async function handleUpdateAvatar() {
|
||
if (!authStore.user) return
|
||
|
||
try {
|
||
let avatarUrl = editingAvatar.value
|
||
|
||
// 如果有选择的文件,先上传文件到服务器
|
||
if (selectedAvatarFile.value) {
|
||
const uploadResult: any = await attachmentApi.uploadAttachment(selectedAvatarFile.value, 'image')
|
||
avatarUrl = uploadResult.file_url || uploadResult.url || editingAvatar.value
|
||
selectedAvatarFile.value = null
|
||
} else if (avatarUrl && !avatarUrl.startsWith('http') && !avatarUrl.startsWith('/') && !avatarUrl.startsWith('data:image/')) {
|
||
// 如果输入的是普通文本,不处理
|
||
toastStore.warning('请输入有效的图片URL或上传图片文件')
|
||
return
|
||
}
|
||
|
||
// 更新用户信息
|
||
await userApi.updateUser({
|
||
id: authStore.user.id,
|
||
updates: { avatar: avatarUrl }
|
||
})
|
||
|
||
// 刷新用户信息
|
||
const updatedUser = await userApi.getMyInfo() as any
|
||
authStore.updateUserInfo(updatedUser)
|
||
|
||
toastStore.success('头像已更新')
|
||
showAvatarEditModal.value = false
|
||
editingAvatar.value = ''
|
||
} catch (e: any) {
|
||
console.error('Failed to update avatar:', e)
|
||
toastStore.error(e?.response?.data?.message || e?.message || '更新失败')
|
||
}
|
||
}
|
||
|
||
// 更新昵称
|
||
async function handleUpdateName() {
|
||
if (!authStore.user || !editingName.value.trim()) {
|
||
toastStore.warning('昵称不能为空')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await userApi.updateUser({
|
||
id: authStore.user.id,
|
||
updates: { name: editingName.value.trim() }
|
||
})
|
||
|
||
// 刷新用户信息
|
||
const updatedUser = await userApi.getMyInfo() as any
|
||
authStore.updateUserInfo(updatedUser)
|
||
|
||
toastStore.success('昵称已更新')
|
||
showNameEditModal.value = false
|
||
editingName.value = ''
|
||
} catch (e: any) {
|
||
console.error('Failed to update name:', e)
|
||
toastStore.error(e?.response?.data?.message || e?.message || '更新失败')
|
||
}
|
||
}
|
||
|
||
// 更新手机号
|
||
async function handleUpdatePhone() {
|
||
if (!authStore.user) return
|
||
|
||
try {
|
||
await userApi.updateUser({
|
||
id: authStore.user.id,
|
||
updates: { phone: editingPhone.value.trim() }
|
||
})
|
||
|
||
// 刷新用户信息
|
||
const updatedUser = await userApi.getMyInfo() as any
|
||
authStore.updateUserInfo(updatedUser)
|
||
|
||
toastStore.success('手机号已更新')
|
||
showPhoneEditModal.value = false
|
||
editingPhone.value = ''
|
||
} catch (e: any) {
|
||
console.error('Failed to update phone:', e)
|
||
toastStore.error(e?.response?.data?.message || e?.message || '更新失败')
|
||
}
|
||
}
|
||
|
||
// 监听编辑弹窗打开,初始化编辑值
|
||
watch(showNameEditModal, (show) => {
|
||
if (show && authStore.user) {
|
||
editingName.value = authStore.user.name || ''
|
||
}
|
||
})
|
||
|
||
watch(showPhoneEditModal, (show) => {
|
||
if (show && authStore.user) {
|
||
editingPhone.value = authStore.user.phone || ''
|
||
}
|
||
})
|
||
|
||
watch(showAvatarEditModal, (show) => {
|
||
if (show && authStore.user) {
|
||
editingAvatar.value = authStore.user.avatar || ''
|
||
selectedAvatarFile.value = null
|
||
}
|
||
})
|
||
async function startCall(type: 'audio' | 'video') {
|
||
if (!chatStore.currentTarget) return
|
||
const receiverUserId = chatStore.currentTarget.user_id || chatStore.currentTarget.id
|
||
const roomId = getRoomId(chatStore.currentTarget)
|
||
const result = await webrtc.startCall(type, receiverUserId, roomId, chatStore.currentTarget)
|
||
if (!result) {
|
||
toastStore.error('获取房间ID失败,无法发起通话')
|
||
}
|
||
}
|
||
|
||
// ---------------- 新增功能函数 ----------------
|
||
|
||
// 会话右键菜单
|
||
function showConversationMenu(event: MouseEvent, conv: any) {
|
||
const isGroup = conv.is_group || conv.room_type === 'group'
|
||
const contact = chatStore.contacts.find(c => c.user_id === conv.target_id || c.id === conv.target_id)
|
||
|
||
const menuItems: Array<{ label: string; icon: string; action: () => void; danger?: boolean }> = [
|
||
// 1. 置顶会话
|
||
{
|
||
label: conv.is_top ? '取消置顶' : '置顶会话',
|
||
icon: 'fas fa-thumbtack',
|
||
action: async () => {
|
||
if (contact) {
|
||
try {
|
||
await contactApi.updateContact(contact.id, { is_top: !contact.is_top })
|
||
contact.is_top = !contact.is_top
|
||
await conversationStore.loadConversations()
|
||
toastStore.success(contact.is_top ? '已置顶' : '已取消置顶')
|
||
} catch(e) {
|
||
toastStore.error('操作失败')
|
||
}
|
||
} else {
|
||
// 群聊可能没有 contact,使用 conversationApi
|
||
try {
|
||
await conversationApi.updateConversation({ target_id: conv.target_id, is_top: !conv.is_top })
|
||
await conversationStore.loadConversations()
|
||
toastStore.success(!conv.is_top ? '已置顶' : '已取消置顶')
|
||
} catch(e) {
|
||
toastStore.error('操作失败')
|
||
}
|
||
}
|
||
}
|
||
},
|
||
// 2. 免打扰
|
||
{
|
||
label: conv.is_muted ? '开启提醒' : '消息免打扰',
|
||
icon: conv.is_muted ? 'fas fa-bell' : 'fas fa-bell-slash',
|
||
action: async () => {
|
||
try {
|
||
if (contact) {
|
||
await contactApi.updateContact(contact.id, { is_muted: !conv.is_muted })
|
||
contact.is_muted = !conv.is_muted
|
||
} else {
|
||
await conversationApi.updateConversation({ target_id: conv.target_id, is_muted: !conv.is_muted })
|
||
}
|
||
await conversationStore.loadConversations()
|
||
toastStore.success(!conv.is_muted ? '已开启免打扰' : '已开启提醒')
|
||
} catch(e) {
|
||
toastStore.error('操作失败')
|
||
}
|
||
}
|
||
}
|
||
]
|
||
|
||
// 3. 特别关心 - 仅私聊显示
|
||
if (!isGroup && contact) {
|
||
menuItems.push({
|
||
label: conv.is_special_care ? '取消特别关心' : '特别关心',
|
||
icon: conv.is_special_care ? 'fas fa-heart' : 'far fa-heart',
|
||
action: async () => {
|
||
try {
|
||
await contactApi.updateContact(contact.id, { is_special_care: !conv.is_special_care })
|
||
contact.is_special_care = !conv.is_special_care
|
||
await conversationStore.loadConversations()
|
||
toastStore.success(!conv.is_special_care ? '已设为特别关心' : '已取消特别关心')
|
||
} catch(e) {
|
||
toastStore.error('操作失败')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 4. 标记已读
|
||
menuItems.push({
|
||
label: '标记已读',
|
||
icon: 'fas fa-check-double',
|
||
action: async () => {
|
||
await conversationStore.clearUnread(conv.target_id)
|
||
toastStore.success('已标记已读')
|
||
}
|
||
})
|
||
|
||
// 5. 删除会话
|
||
menuItems.push({
|
||
label: '删除会话',
|
||
icon: 'fas fa-trash-alt',
|
||
danger: true,
|
||
action: () => {
|
||
pendingDeleteConversation.value = conv
|
||
showDeleteConversationConfirm.value = true
|
||
}
|
||
})
|
||
|
||
showContextMenu(event, menuItems)
|
||
}
|
||
|
||
async function handleMarkRead(conv: Conversation) {
|
||
await conversationStore.clearUnread(conv.target_id)
|
||
toastStore.success('已标记为已读')
|
||
}
|
||
|
||
async function handleDeleteConversation() {
|
||
if (!pendingDeleteConversation.value) return
|
||
const conv = pendingDeleteConversation.value
|
||
|
||
try {
|
||
// 调用后端API真正删除数据库中的会话记录
|
||
await conversationApi.deleteConversation(conv.target_id)
|
||
|
||
// 从前端列表中移除会话
|
||
conversationStore.conversations = conversationStore.conversations.filter(c => c.id !== conv.id)
|
||
|
||
// 如果当前正在查看被删除的会话,关闭聊天窗口
|
||
if (chatStore.currentTarget) {
|
||
const currentRoomId = getRoomId(chatStore.currentTarget)
|
||
const convRoomId = conv.room_id || conv.target_id
|
||
if (currentRoomId === convRoomId ||
|
||
(conv.type === 2 && currentRoomId === conv.room_id) ||
|
||
(!conv.is_group && (chatStore.currentTarget.user_id === conv.target_id || chatStore.currentTarget.id === conv.target_id))) {
|
||
chatStore.setCurrentTarget(null)
|
||
storage.setSelectedConversation('')
|
||
storage.setSelectedRoomId('')
|
||
if (isMobile.value) {
|
||
chatVisible.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
toastStore.success('会话已删除')
|
||
showDeleteConversationConfirm.value = false
|
||
pendingDeleteConversation.value = null
|
||
} catch (e: any) {
|
||
console.error('Failed to delete conversation:', e)
|
||
toastStore.error(e?.response?.data?.message || e?.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------
|
||
|
||
function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
|
||
const menuItems = [
|
||
{
|
||
label: contact.is_group ? '群资料' : '清空记录',
|
||
icon: contact.is_group ? 'fas fa-info-circle' : 'fas fa-eraser',
|
||
action: () => {
|
||
if (contact.is_group) {
|
||
showGroupInfoPanel.value = true
|
||
} else {
|
||
chatStore.clearRoomMessages(getRoomId(contact))
|
||
}
|
||
}
|
||
}
|
||
]
|
||
|
||
if (contact.is_group) {
|
||
menuItems.push({
|
||
label: '清空记录',
|
||
icon: 'fas fa-eraser',
|
||
danger: true,
|
||
action: () => chatStore.clearRoomMessages(getRoomId(contact))
|
||
})
|
||
}
|
||
|
||
showContextMenu(event, menuItems)
|
||
}
|
||
function handleSendText(mentionUserIds?: string[]) {
|
||
if (!inputText.value.trim() || !chatStore.currentTarget) return
|
||
const extra = mentionUserIds && mentionUserIds.length > 0 ? { mention_user_ids: mentionUserIds } : {}
|
||
sendMessage(0, inputText.value, extra)
|
||
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('发送失败') }
|
||
}
|
||
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) } }
|
||
|
||
// 创建群聊
|
||
async function handleCreateGroup(data: { member_ids: string[]; name: string; avatar?: string }) {
|
||
try {
|
||
// 添加当前用户到成员列表
|
||
const memberIds = [...data.member_ids]
|
||
if (!memberIds.includes(authStore.user!.id)) {
|
||
memberIds.push(authStore.user!.id)
|
||
}
|
||
|
||
const group = await groupApi.createGroup({
|
||
name: data.name,
|
||
avatar: data.avatar,
|
||
member_ids: memberIds
|
||
})
|
||
|
||
toastStore.success('群聊创建成功')
|
||
showCreateGroupModal.value = false
|
||
|
||
// 重新加载会话列表
|
||
await conversationStore.loadConversations()
|
||
|
||
// 刷新联系人列表
|
||
const contacts = await contactApi.getContacts()
|
||
chatStore.setContacts(contacts)
|
||
|
||
// 创建群聊联系人对象并进入聊天
|
||
const groupContact: Contact = {
|
||
id: group.room_id || group.id,
|
||
user_id: group.room_id || group.id,
|
||
contact_user_id: '',
|
||
room_id: group.room_id || group.id,
|
||
room_type: 'group',
|
||
is_group: true,
|
||
member_count: group.member_count || memberIds.length,
|
||
owner_id: group.owner_id,
|
||
remark_name: group.name,
|
||
is_top: false,
|
||
is_muted: false,
|
||
user: undefined,
|
||
color: generateColor(group.room_id || group.id)
|
||
}
|
||
|
||
await selectChat(groupContact)
|
||
} catch (error: any) {
|
||
console.error('Failed to create group:', error)
|
||
toastStore.error('创建群聊失败')
|
||
}
|
||
}
|
||
|
||
// 群信息更新处理
|
||
async function handleGroupUpdated() {
|
||
// 重新加载会话列表和群成员
|
||
await conversationStore.loadConversations()
|
||
if (chatStore.currentTarget?.room_id) {
|
||
await loadGroupMembers(chatStore.currentTarget.room_id)
|
||
}
|
||
}
|
||
|
||
// 退出群聊处理
|
||
async function handleGroupQuit() {
|
||
await conversationStore.loadConversations()
|
||
chatStore.setCurrentTarget(null)
|
||
storage.setSelectedConversation('')
|
||
storage.setSelectedRoomId('')
|
||
}
|
||
|
||
// 解散群聊处理
|
||
async function handleGroupDissolve() {
|
||
await conversationStore.loadConversations()
|
||
chatStore.setCurrentTarget(null)
|
||
storage.setSelectedConversation('')
|
||
storage.setSelectedRoomId('')
|
||
}
|
||
|
||
// 群成员移除处理
|
||
async function handleGroupMemberRemoved() {
|
||
// 重新加载群成员信息
|
||
if (chatStore.currentTarget?.room_id) {
|
||
await loadGroupMembers(chatStore.currentTarget.room_id)
|
||
}
|
||
// 刷新会话列表
|
||
await conversationStore.loadConversations()
|
||
}
|
||
|
||
// 刷新会话列表
|
||
async function refreshConversations() {
|
||
if (refreshingConversations.value) return
|
||
refreshingConversations.value = true
|
||
try {
|
||
await conversationStore.loadConversations()
|
||
toastStore.success('会话列表已刷新')
|
||
} catch (error) {
|
||
console.error('Failed to refresh conversations:', error)
|
||
toastStore.error('刷新失败')
|
||
} finally {
|
||
refreshingConversations.value = false
|
||
}
|
||
}
|
||
|
||
// 群成员点击处理
|
||
function handleGroupMemberClicked(member: any) {
|
||
// 显示成员的用户详细信息
|
||
if (member.user) {
|
||
// 如果有完整的用户信息,直接使用
|
||
selectedUser.value = member.user as User
|
||
} else if (member.user_id) {
|
||
// 如果没有完整信息,尝试从联系人列表中查找
|
||
const contact = chatStore.contacts.find(c => c.user_id === member.user_id || c.id === member.user_id)
|
||
if (contact) {
|
||
selectedUser.value = contact
|
||
} else {
|
||
// 如果找不到,创建一个临时的 Contact 对象
|
||
selectedUser.value = {
|
||
id: member.user_id,
|
||
user_id: member.user_id,
|
||
contact_user_id: member.user_id,
|
||
is_top: false,
|
||
is_muted: false,
|
||
user: member.user || undefined
|
||
} as Contact
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理头像点击
|
||
function handleAvatarClick(userOrContact: User | Contact) {
|
||
// 如果是自己,显示自己的资料
|
||
if (('id' in userOrContact && userOrContact.id === authStore.user?.id) ||
|
||
('user_id' in userOrContact && userOrContact.user_id === authStore.user?.id)) {
|
||
showProfileModal.value = true
|
||
return
|
||
}
|
||
|
||
// 显示对方信息 - 使用新的 UserInfoCard 组件
|
||
// 如果是 Contact,提取 User 信息
|
||
if ('user' in userOrContact && userOrContact.user) {
|
||
selectedUserForInfo.value = userOrContact.user
|
||
} else if ('user_id' in userOrContact) {
|
||
// 如果是 Contact 但没有 user 信息,需要查找或构造
|
||
const contact = userOrContact as Contact
|
||
// 尝试从联系人列表中找到完整的用户信息
|
||
const fullContact = chatStore.contacts.find(c => c.user_id === contact.user_id || c.id === contact.user_id)
|
||
if (fullContact?.user) {
|
||
selectedUserForInfo.value = fullContact.user
|
||
} else {
|
||
// 如果没有找到,构造一个基本的 User 对象
|
||
selectedUserForInfo.value = {
|
||
id: contact.user_id || contact.id,
|
||
name: contact.remark_name || contact.user?.name || '未知',
|
||
avatar: contact.user?.avatar || '',
|
||
email: contact.user?.email || '',
|
||
phone: contact.user?.phone || '',
|
||
desc: contact.user?.desc || '',
|
||
region: contact.user?.region || '',
|
||
created_at: contact.user?.created_at || '',
|
||
updated_at: contact.user?.updated_at || '',
|
||
} as User
|
||
}
|
||
} else {
|
||
selectedUserForInfo.value = userOrContact as User
|
||
}
|
||
showUserInfoCard.value = true
|
||
}
|
||
|
||
// 从用户信息卡片发送消息
|
||
function handleSendMessageFromInfoCard() {
|
||
if (!selectedUserForInfo.value) return
|
||
|
||
// 确保是 User 类型
|
||
const user = selectedUserForInfo.value as User
|
||
|
||
// 查找或创建对应的联系人
|
||
const userId = user.id
|
||
let contact = chatStore.contacts.find(c => c.user_id === userId || c.id === userId)
|
||
|
||
if (!contact) {
|
||
// 如果不在联系人列表中,创建一个临时的 Contact 对象
|
||
contact = {
|
||
id: userId,
|
||
user_id: userId,
|
||
contact_user_id: userId,
|
||
room_id: '',
|
||
room_type: 'p2p',
|
||
is_group: false,
|
||
remark_name: user.name,
|
||
is_top: false,
|
||
is_muted: false,
|
||
user: user,
|
||
} as Contact
|
||
}
|
||
|
||
chatStore.setCurrentTarget(contact)
|
||
showUserInfoCard.value = false
|
||
selectedUserForInfo.value = null
|
||
}
|
||
|
||
// 从用户信息卡片发起语音通话
|
||
function handleAudioCallFromInfoCard() {
|
||
if (!selectedUserForInfo.value) return
|
||
// 这里可以调用 WebRTC 相关功能
|
||
showUserInfoCard.value = false
|
||
selectedUserForInfo.value = null
|
||
}
|
||
|
||
// 从用户信息卡片发起视频通话
|
||
function handleVideoCallFromInfoCard() {
|
||
if (!selectedUserForInfo.value) return
|
||
// 这里可以调用 WebRTC 相关功能
|
||
showUserInfoCard.value = false
|
||
selectedUserForInfo.value = null
|
||
}
|
||
|
||
// 判断是否为好友
|
||
function isFriend(user: Contact | User): boolean {
|
||
if (!user) return false
|
||
const userId = 'user_id' in user ? user.user_id : ('id' in user ? user.id : null)
|
||
if (!userId) return false
|
||
|
||
// 检查是否在联系人列表中
|
||
return chatStore.contacts.some(c => c.user_id === userId || c.id === userId)
|
||
}
|
||
|
||
// 添加好友
|
||
async function handleAddFriend() {
|
||
if (!selectedUser.value) return
|
||
|
||
const userId = 'user_id' in selectedUser.value
|
||
? selectedUser.value.user_id
|
||
: ('id' in selectedUser.value ? selectedUser.value.id : null)
|
||
|
||
if (!userId) {
|
||
toastStore.error('无法获取用户ID')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await contactApi.addFriend({ to_user_id: userId })
|
||
toastStore.success('好友申请已发送')
|
||
selectedUser.value = null
|
||
} catch (error: any) {
|
||
console.error('Failed to add friend:', error)
|
||
toastStore.error(error.message || '添加好友失败')
|
||
}
|
||
}
|
||
|
||
// 给用户发消息
|
||
function handleSendMessageToUser() {
|
||
if (!selectedUser.value) return
|
||
|
||
// 查找或创建联系人
|
||
const userId = 'user_id' in selectedUser.value
|
||
? selectedUser.value.user_id
|
||
: ('id' in selectedUser.value ? selectedUser.value.id : null)
|
||
|
||
if (!userId) return
|
||
|
||
let contact = chatStore.contacts.find(c => c.user_id === userId || c.id === userId)
|
||
if (!contact) {
|
||
// 创建临时联系人对象
|
||
contact = {
|
||
id: userId,
|
||
user_id: userId,
|
||
contact_user_id: userId,
|
||
remark_name: 'name' in selectedUser.value ? selectedUser.value.name : selectedUser.value.user?.name,
|
||
is_top: false,
|
||
is_muted: false,
|
||
user: 'user' in selectedUser.value ? selectedUser.value.user : selectedUser.value as any,
|
||
} as Contact
|
||
}
|
||
|
||
selectedUser.value = null
|
||
selectChat(contact)
|
||
}
|
||
|
||
// 辅助函数:获取用户显示名称
|
||
function getUserDisplayName(user: Contact | User | null): string {
|
||
if (!user) return '未知用户'
|
||
if ('remark_name' in user && user.remark_name) return user.remark_name
|
||
if ('name' in user && user.name) return user.name
|
||
if ('user' in user && user.user?.name) return user.user.name
|
||
return '未知用户'
|
||
}
|
||
|
||
// 辅助函数:获取用户头像
|
||
function getUserAvatar(user: Contact | User | null): string {
|
||
if (!user) return ''
|
||
if ('avatar' in user && user.avatar) return user.avatar
|
||
if ('user' in user && user.user?.avatar) return user.user.avatar
|
||
return ''
|
||
}
|
||
|
||
// 辅助函数:获取用户描述
|
||
function getUserDesc(user: Contact | User | null): string {
|
||
if (!user) return ''
|
||
if ('desc' in user && user.desc) return user.desc
|
||
if ('user' in user && user.user?.desc) return user.user.desc
|
||
return ''
|
||
}
|
||
|
||
// 辅助函数:获取用户ID
|
||
function getUserId(user: Contact | User | null): string | null {
|
||
if (!user) return null
|
||
if ('user_id' in user) return user.user_id
|
||
if ('id' in user) return user.id
|
||
if ('user' in user && user.user?.id) return user.user.id
|
||
return null
|
||
}
|
||
|
||
// 辅助函数:获取用户手机号
|
||
function getUserPhone(user: Contact | User | null): string | null {
|
||
if (!user) return null
|
||
if ('phone' in user && user.phone) return user.phone
|
||
if ('user' in user && user.user?.phone) return user.user.phone
|
||
return null
|
||
}
|
||
|
||
// 辅助函数:获取用户邮箱
|
||
function getUserEmail(user: Contact | User | null): string | null {
|
||
if (!user) return null
|
||
if ('email' in user && user.email) return user.email
|
||
if ('user' in user && user.user?.email) return user.user.email
|
||
return null
|
||
}
|
||
|
||
watch(currentTab, (newTab) => {
|
||
storage.setCurrentTab(newTab)
|
||
if (newTab === 'contact' || newTab === 'moment') {
|
||
chatStore.setCurrentTarget(null)
|
||
storage.setSelectedConversation('')
|
||
storage.setSelectedRoomId('')
|
||
}
|
||
})
|
||
|
||
onMounted(async () => {
|
||
if (!authStore.isAuthenticated) {
|
||
const isValid = await authStore.checkAuth()
|
||
if (!isValid) { router.push('/login'); return }
|
||
}
|
||
if (authStore.user) {
|
||
await wsManager.connect(authStore.user.id)
|
||
wsManager.onMessage(handleWebSocketMessage)
|
||
wsManager.onSignal(webrtc.handleSignaling)
|
||
// 朋友圈通知已在 App.vue 中统一注册,避免重复
|
||
}
|
||
await loadContacts()
|
||
await conversationStore.loadConversations()
|
||
// 获取朋友圈未读数
|
||
momentStore.fetchUnreadCount()
|
||
|
||
const savedRoomId = storage.getSelectedRoomId()
|
||
const savedTargetId = storage.getSelectedConversation()
|
||
|
||
if (savedRoomId) {
|
||
const contact = chatStore.contacts.find(c => {
|
||
const contactRoomId = getRoomId(c)
|
||
return contactRoomId === savedRoomId
|
||
})
|
||
if (contact) {
|
||
await selectChat(contact)
|
||
return
|
||
}
|
||
}
|
||
|
||
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)
|
||
|
||
// 监听 tab 切换事件(用于从其他地方触发 tab 切换,如朋友圈通知)
|
||
window.addEventListener('app-tab-change', handleTabChangeEvent)
|
||
})
|
||
|
||
// Tab 切换事件处理
|
||
function handleTabChangeEvent(e: Event) {
|
||
const customEvent = e as CustomEvent<{ tab: 'chat' | 'contact' | 'moment' }>
|
||
if (customEvent.detail?.tab) {
|
||
currentTab.value = customEvent.detail.tab
|
||
}
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
wsManager.offMessage(handleWebSocketMessage)
|
||
wsManager.offSignal(webrtc.handleSignaling)
|
||
wsManager.offMomentNotification(momentStore.handleWsNotification)
|
||
window.removeEventListener('app-tab-change', handleTabChangeEvent)
|
||
})
|
||
</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; }
|
||
.animate-scale-in { animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
|
||
.animate-slide-up { animation: slideUp 0.4s ease-out; }
|
||
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
|
||
@keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||
.animate-pulse-fast { animation: pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
||
</style>
|