Files
nl-um-vue-ts/src/views/chat/ChatView.vue
2025-12-08 15:53:41 +08:00

2173 lines
86 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-screen w-screen flex text-sm sm:text-base bg-dark" @contextmenu.prevent>
<!-- 左侧功能导航 -->
<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>