167 lines
7.3 KiB
Vue
167 lines
7.3 KiB
Vue
<template>
|
||
<!--
|
||
IM 外层容器:聊天模块的全屏沉浸式壳
|
||
- 左侧 ToolBar:头像 + 三 Tab(消息/好友/群聊)+ 底部设置
|
||
- 右侧 <router-view>:按路由渲染 MessagePage / FriendPage / GroupPage
|
||
- 挂载全局弹层:UserInfoCard / GroupInfoCard / ContextMenu
|
||
-->
|
||
<div class="flex w-full h-full overflow-hidden">
|
||
<ToolBar />
|
||
<!--
|
||
keep-alive 缓存子页面:
|
||
- 切 Tab 不重建组件,MessagePanel 滚动位置、输入框草稿等 UI 状态不丢
|
||
- Vue 3 里 keep-alive 不能直接包 <router-view>(会有警告),必须走 v-slot 拿 Component
|
||
-->
|
||
<router-view v-slot="{ Component }">
|
||
<keep-alive>
|
||
<component :is="Component" />
|
||
</keep-alive>
|
||
</router-view>
|
||
|
||
<!-- 全局弹层:由 useImUiStore 统一调度 -->
|
||
<UserInfoCard />
|
||
<GroupInfoCard />
|
||
<ContextMenu />
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { onMounted, onUnmounted, watch } from 'vue'
|
||
|
||
import { useConversationStore } from './store/conversationStore'
|
||
import { useImWebSocketStore } from './store/websocketStore'
|
||
import { useFriendStore } from './store/friendStore'
|
||
import { useGroupStore } from './store/groupStore'
|
||
import { useGroupRequestStore } from './store/groupRequestStore'
|
||
import { useDraftStore } from './store/draftStore'
|
||
import { useFaceStore } from './store/faceStore'
|
||
import { useMessagePuller } from './composables/useMessagePuller'
|
||
import { useMessageSender } from './composables/useMessageSender'
|
||
import { ImConversationType } from '../utils/constants'
|
||
import { StorageKeys } from '../utils/storage'
|
||
import type { Conversation } from './types'
|
||
import ToolBar from './components/ToolBar.vue'
|
||
import UserInfoCard from './components/user/UserInfoCard.vue'
|
||
import GroupInfoCard from './components/group/GroupInfoCard.vue'
|
||
import ContextMenu from './components/ContextMenu.vue'
|
||
|
||
defineOptions({ name: 'ImIndex' })
|
||
|
||
const conversationStore = useConversationStore()
|
||
const webSocketStore = useImWebSocketStore()
|
||
const friendStore = useFriendStore()
|
||
const groupStore = useGroupStore()
|
||
const groupRequestStore = useGroupRequestStore()
|
||
const draftStore = useDraftStore()
|
||
const faceStore = useFaceStore()
|
||
const { pullOnce } = useMessagePuller()
|
||
const { readActive, syncPrivateReadStatus } = useMessageSender()
|
||
|
||
/** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */
|
||
onMounted(async () => {
|
||
// 0.1 系统表情包后台预拉:独立链路与首屏 IDB / 远端拉取并发,消除表情面板首次展开白屏;失败仅记日志,不阻塞主流程
|
||
void faceStore.ensureFacePacks().catch((e) => console.warn('[IM] 后台预拉表情包失败', e))
|
||
// 0.2 我管理的群下未处理加群申请:会话列表全局入口 / 群顶部横幅都从这份 store 派生;后台拉,不阻断
|
||
void groupRequestStore.fetchUnhandledList().catch((e) =>
|
||
console.warn('[IM] 拉取未处理加群申请失败', e)
|
||
)
|
||
|
||
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||
conversationStore.loading = true
|
||
try {
|
||
// 1.2 四个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups} 返回是否命中缓存)
|
||
const [, hasCachedFriends, hasCachedGroups] = await Promise.all([
|
||
conversationStore.loadConversations(),
|
||
friendStore.loadFriends(),
|
||
groupStore.loadGroups(),
|
||
draftStore.loadDrafts()
|
||
])
|
||
|
||
// 2.1 有缓存:异步背景刷新,失败仅记日志(IDB 数据已经够撑首屏,pullOnce 也能正常入库)
|
||
// 2.2 无缓存(首登 / 切账号回切):必须 await + 失败抛出中断本轮 onMounted,
|
||
// 否则 pullOnce 会用 senderId 数字给会话起名落到 IDB 后续基本无法自愈;无缓存分支两个 fetch 并发 Promise.all 省一个 RTT
|
||
const requiredFetches: Promise<unknown>[] = []
|
||
if (hasCachedFriends) {
|
||
void friendStore.fetchFriends().catch((e) => console.warn('[IM] 后台刷好友失败', e))
|
||
} else {
|
||
requiredFetches.push(friendStore.fetchFriends())
|
||
}
|
||
if (hasCachedGroups) {
|
||
void groupStore.fetchGroups().catch((e) => console.warn('[IM] 后台刷群列表失败', e))
|
||
} else {
|
||
requiredFetches.push(groupStore.fetchGroups())
|
||
}
|
||
if (requiredFetches.length > 0) {
|
||
await Promise.all(requiredFetches)
|
||
}
|
||
|
||
// 3. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位)
|
||
webSocketStore.connect()
|
||
await pullOnce()
|
||
|
||
// 4. 默认选中第一个会话;若置顶分组处于折叠态,需跳过被折叠隐藏的置顶项,避免自动展开折叠
|
||
const sorted = conversationStore.getSortedConversations
|
||
const firstVisible = pickFirstVisibleConversation(sorted)
|
||
if (firstVisible && !conversationStore.activeConversation) {
|
||
conversationStore.setActiveConversation(firstVisible)
|
||
}
|
||
} catch (e) {
|
||
// 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续 saveConversations 全被早 return 阻断
|
||
// 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试
|
||
conversationStore.loading = false
|
||
console.error('[IM] 初始化失败', e)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 选首屏自动激活的会话;置顶分组折叠时跳过被折叠隐藏的置顶项,避免激活后被自动顶上来
|
||
*
|
||
* 折叠态判定只看用户开关;非置顶 / 有未读的置顶项始终可见,全是可折叠置顶时回退到 sorted[0] 兜底
|
||
*/
|
||
function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | undefined {
|
||
if (sorted.length === 0) {
|
||
return undefined
|
||
}
|
||
const pinnedExpanded = localStorage.getItem(StorageKeys.conversationPinnedExpanded) === 'true'
|
||
if (pinnedExpanded) {
|
||
return sorted[0]
|
||
}
|
||
return sorted.find((c) => !c.top || (!c.silent && (c.unreadCount || 0) > 0)) ?? sorted[0]
|
||
}
|
||
|
||
/** 标签关闭前 flush 草稿队列;debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
|
||
function onBeforeUnload() {
|
||
draftStore.flushPersist()
|
||
}
|
||
window.addEventListener('beforeunload', onBeforeUnload)
|
||
|
||
/** 离开 IM 主壳:主动断 WebSocket(disconnect 内部已清掉 onclose 防自动重连)+ flush 草稿 + 表情缓存 reset + 解绑 unload */
|
||
onUnmounted(() => {
|
||
webSocketStore.disconnect()
|
||
draftStore.flushPersist()
|
||
faceStore.reset()
|
||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||
})
|
||
|
||
/**
|
||
* 当前会话切换:本地清零未读 + 上报后端已读 + 私聊补"对方已读到哪条"
|
||
*
|
||
* 只针对当前 active 会话做处理,其它会话已读状态由 WebSocket READ/RECEIPT 事件被动同步。
|
||
* 私聊补一次拉对方已读位置,弥补离线 / 多端漏掉的 RECEIPT 推送
|
||
*/
|
||
watch(
|
||
() => conversationStore.activeConversation?.targetId,
|
||
async (targetId) => {
|
||
if (!targetId) {
|
||
return
|
||
}
|
||
// 本地清零未读 + 上报后端已读,让其它端 / 对方 UI 同步
|
||
await readActive()
|
||
// 私聊补一次"对方已读到哪条",弥补离线 / 多端漏掉的 RECEIPT 推送
|
||
if (conversationStore.activeConversation?.type === ImConversationType.PRIVATE) {
|
||
void syncPrivateReadStatus(targetId)
|
||
}
|
||
}
|
||
)
|
||
</script>
|