gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,292 @@
<script lang="ts" setup>
import type { Conversation } from './types'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { preferences } from '#/packages/preferences/src'
import { ImConversationType } from '../utils/constants'
import { initDb, stopRequests, StorageKeys } from '../utils/db'
import { ContextMenu, ToolBar } from './components'
import { GroupInfoCard } from './components/group'
import { RtcCallContainer } from './components/rtc'
import { UserInfoCard } from './components/user'
import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender'
import { useVoicePlayer } from './composables/useVoicePlayer'
import { useChannelStore } from './store/channelStore'
import { useConversationStore } from './store/conversationStore'
import { useFaceStore } from './store/faceStore'
import { useFriendStore } from './store/friendStore'
import { useGroupRequestStore } from './store/groupRequestStore'
import { useGroupStore } from './store/groupStore'
import { useMessageStore } from './store/messageStore'
import { useImWebSocketStore } from './store/websocketStore'
defineOptions({ name: 'ImIndex' })
const route = useRoute()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const webSocketStore = useImWebSocketStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const faceStore = useFaceStore()
const channelStore = useChannelStore()
const { pullOnce, cancelPull } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender()
const voicePlayer = useVoicePlayer()
const childRouteReady = ref(false) // å­è·¯ç”±æ˜¯å¦å¯æŒ‚è½½
/** åˆå§‹åŒ–:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */
onMounted(async () => {
  // 0.1 ç³»ç»Ÿè¡¨æƒ…包后台预拉:独立链路与首屏 IDB / è¿œç«¯æ‹‰å–并发,消除表情面板首次展开白屏;失败仅记日志,不阻塞主流程
  void faceStore.ensureFacePackList().catch((error) => console.warn('[IM] åŽå°é¢„拉表情包失败', error))
  // 1.1 æ•´æ®µ loading=true é˜»æ–­ä¼šè¯åˆ—表抖动写盘 + WebSocket æ™®é€šæ¶ˆæ¯è¿›ç¼“冲,避免 connect åˆ° pullOnce ä¹‹é—´æ”¶åˆ°çš„实时消息推进 maxId å¯¼è‡´ pull è·³è¿‡æ–­çº¿ç§¯åŽ‹æ¶ˆæ¯
  conversationStore.loading = true
  try {
    // 1.2 æ‰“开当前用户 IM DB
    await initDb()
    // 1.3 å¤šä¸ª store å¹¶å‘从 IDB è¯»å–本地缓存
    const cacheResults = await Promise.all([
      conversationStore.loadConversationList(),
      messageStore.loadMessageCursorList(),
      friendStore.loadFriendData(),
      groupStore.loadGroupList(),
      channelStore.loadChannelList(),
      groupRequestStore.loadGroupRequestList()
    ])
    const hasFriendRows = cacheResults[2]
    const hasGroupRows = cacheResults[3]
    const hasChannelRows = cacheResults[4]
    groupStore.markAllGroupActiveCallsExpired()
    groupStore.markAllGroupMembersExpired()
    childRouteReady.value = true
    // 1.4 æˆ‘管理的群下未处理加群申请红点:首登用 unhandled-list(服务端直接过滤未处理,语义精准、启动轻);
    //     pullGroupRequests åªåœ¨é‡è¿ž / åŽç»­è¡¥å¿æ—¶è·‘(见 useMessagePuller.pullStateEvents),不进首登主链路
    void groupRequestStore
      .fetchUnhandledGroupRequestList()
      .catch((error) => console.warn('[IM] æ‹‰å–未处理加群申请失败', error))
    // 2. å¥½å‹ä¸»æ•°æ®æ¢å¤èµ° pull;群列表用快照刷新,覆盖离线期间自己的入群 / é€€ç¾¤çŠ¶æ€å˜åŒ–
    // 2.1 æœ‰ç¼“存:异步背景增量刷新,失败仅记日志(IDB æ•°æ®å·²ç»å¤Ÿæ’‘首屏,pullOnce ä¹Ÿèƒ½æ­£å¸¸å…¥åº“)
    // 2.2 æ— ç¼“存(首登 / åˆ‡è´¦å·å›žåˆ‡ï¼‰ï¼šå¿…é¡» await + å¤±è´¥æŠ›å‡ºä¸­æ–­æœ¬è½® onMounted,
    //     å¦åˆ™ pullOnce ä¼šç”¨ senderId æ•°å­—给会话起名落到 IDB åŽç»­åŸºæœ¬æ— æ³•自愈;无缓存分支并发 Promise.all çœä¸€ä¸ª RTT
    const requiredFetches: Promise<unknown>[] = []
    if (hasFriendRows) {
      void friendStore.pullFriends().catch((error) => console.warn('[IM] åŽå°å¢žé‡æ‹‰å¥½å‹å¤±è´¥', error))
    } else {
      requiredFetches.push(friendStore.pullFriends())
    }
    if (hasGroupRows) {
      void groupStore.fetchGroupList(true).catch((error) => console.warn('[IM] åŽå°åˆ·æ–°ç¾¤åˆ—表失败', error))
    } else {
      requiredFetches.push(groupStore.fetchGroupList(true))
    }
    // 2.3 é¢‘道无增量 pull æŽ¥å£ï¼Œç»§ç»­èµ° list
    if (hasChannelRows) {
      void channelStore.fetchChannelList().catch((error) => console.warn('[IM] åŽå°åˆ·é¢‘道列表失败', error))
    } else {
      requiredFetches.push(channelStore.fetchChannelList())
    }
    // 2.4 æ‰§è¡ŒåŠ è½½
    if (requiredFetches.length > 0) {
      await Promise.all(requiredFetches)
    }
    // 2.5 å¥½å‹ç”³è¯·å¢žé‡è¡¥å¿ï¼šé¦–登也要跑,离线期间好友申请变更不会影响好友主表
    void friendStore
      .pullFriendRequests()
      .catch((error) => console.warn('[IM] åŽå°å¢žé‡æ‹‰å¥½å‹ç”³è¯·å¤±è´¥', error))
    // 3. ä¼šè¯è¯»ä½ç½®å…ˆè¡¥å¿ï¼Œæ¶ˆæ¯å…¥åº“时可直接过滤已读历史消息
    await conversationStore
      .pullConversationReads()
      .catch((error) => console.warn('[IM] æ‹‰å–会话读位置失败', error))
    // 4. å®žæ—¶é€šä¿¡ï¼šå»º WebSocket é•¿è¿žæŽ¥ + æ‹‰ç¦»çº¿æ¶ˆæ¯ï¼ˆpullOnce finally æŠŠ loading å½’位)
    webSocketStore.connect()
    await pullOnce()
    // 5. é»˜è®¤é€‰ä¸­ç¬¬ä¸€ä¸ªä¼šè¯ï¼›è‹¥ç½®é¡¶åˆ†ç»„处于折叠态,需跳过被折叠隐藏的置顶项,避免自动展开折叠
    const sorted = conversationStore.getSortedConversationList
    const firstVisible = pickFirstVisibleConversation(sorted)
    if (firstVisible && !conversationStore.activeConversation) {
      conversationStore.setActiveConversation(firstVisible)
    }
  } catch (error) {
    // 1. é¦–拉失败:手动复位 loading(pullOnce æ²¡è·‘到,它的 finally å…œä¸åˆ°è¿™é‡Œï¼‰ï¼Œå¦åˆ™åŽç»­ä¼šè¯åˆ—表写入全被早 return é˜»æ–­
    // 2. WebSocket ä¸åœ¨è¿™é‡Œ disconnect——路由离开会走 onUnmounted è‡ªç„¶æ¸…理,用户也可以刷新重试
    conversationStore.loading = false
    console.error('[IM] åˆå§‹åŒ–失败', error)
  }
})
/**
 * é€‰é¦–屏自动激活的会话;置顶分组折叠时跳过被折叠隐藏的置顶项,避免激活后被自动顶上来
 *
 * æŠ˜å æ€åˆ¤å®šåªçœ‹ç”¨æˆ·å¼€å…³ï¼›éžç½®é¡¶ / æœ‰æœªè¯»çš„置顶项始终可见,全是可折叠置顶时回退到 sorted[0] å…œåº•
 */
function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | undefined {
  if (sorted.length === 0) {
    return undefined
  }
  const pinnedExpanded =
    localStorage.getItem(StorageKeys.localStorage.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() {
  conversationStore.flushConversationDraftSave()
}
window.addEventListener('beforeunload', onBeforeUnload)
/** ç¦»å¼€ IM ä¸»å£³ï¼šå–消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload,并结束当前 IM session */
onUnmounted(() => {
  cancelPull()
  webSocketStore.disconnect()
  conversationStore.flushConversationDraftSave()
  faceStore.clear()
  // æ¨¡å—级单例 audio ä¸ä¼šéšè§†å›¾å¸è½½è‡ªåŠ¨åœï¼Œä¸»åŠ¨åœæŽ‰é¿å…åˆ‡è·¯ç”±åŽè¯­éŸ³ç»§ç»­å“
  voicePlayer.stop()
  window.removeEventListener('beforeunload', onBeforeUnload)
  // åœæ­¢å½“前 IM session å¹¶æ¸…理各 store å†…å­˜
  void stopRequests()
})
/**
 * å½“前会话切换:本地清零未读 + ä¸ŠæŠ¥åŽç«¯å·²è¯» + ç§èŠè¡¥"对方已读到哪条"
 *
 * type+targetId ä¸€èµ·ç›‘听:私聊与群聊 id åŒå·æ—¶åˆ‡æ¢ä¹Ÿèƒ½è§¦å‘;其它会话已读状态由 WebSocket
 * READ / RECEIPT äº‹ä»¶è¢«åŠ¨åŒæ­¥ã€‚ç§èŠè¡¥ä¸€æ¬¡æ‹‰å¯¹æ–¹å·²è¯»ä½ç½®ï¼Œå¼¥è¡¥ç¦»çº¿ / å¤šç«¯æ¼æŽ‰çš„ RECEIPT æŽ¨é€
 */
watch(
  () => [
    conversationStore.activeConversation?.type,
    conversationStore.activeConversation?.targetId
  ],
  async ([type, targetId]) => {
    if (!targetId) {
      return
    }
    // æœ¬åœ°æ¸…零未读 + ä¸ŠæŠ¥åŽç«¯å·²è¯»ï¼Œè®©å…¶å®ƒç«¯ / å¯¹æ–¹ UI åŒæ­¥
    await readActive()
    // ç§èŠè¡¥ä¸€æ¬¡"对方已读到哪条",弥补离线 / å¤šç«¯æ¼æŽ‰çš„ RECEIPT æŽ¨é€
    if (type === ImConversationType.PRIVATE) {
      void syncPrivateReadStatus(targetId)
    }
  }
)
/**
 * æµè§ˆå™¨æ ‡ç­¾ title æ‹¼ä¸Šæœªè¯»æ•°å‰ç¼€ï¼›ä¾‹ï¼š(63条未读)芋道源码
 *
 * è·¯ç”±åˆ‡æ¢æ—¶ router.afterEach ä¼šè°ƒ useTitle é‡ç½® title;用 nextTick æŽ’在它之后再覆盖
 * ä¸€å¹¶ç›‘听 route.fullPath,IM å­è·¯ç”±åˆ‡æ¢ï¼ˆæ¶ˆæ¯ / é€šè®¯å½•)也能重新加上前缀
 */
watch(
  [() => conversationStore.getTotalUnreadCount, () => route.fullPath],
  ([count]) => {
    nextTick(() => {
      const base = preferences.app.name
      document.title = count > 0 ? `(${count > 99 ? '99+' : count}条未读)${base}` : base
    })
  },
  { immediate: true }
)
</script>
<template>
  <!--
    IM å¤–层容器:聊天模块的全屏沉浸式壳
    - å·¦ä¾§ ToolBar:头像 + ä¸‰ Tab(消息/好友/群聊)+ åº•部设置
    - å³ä¾§ <router-view>:按路由渲染 MessagePage / FriendPage / GroupPage
    - æŒ‚载全局弹层:UserInfoCard / GroupInfoCard / ContextMenu
  -->
  <div
    class="im-home flex w-full h-full overflow-hidden bg-[var(--ant-color-bg-layout)] text-[var(--ant-color-text)]"
  >
    <ToolBar />
    <!--
      keep-alive ç¼“存子页面:
      - åˆ‡ Tab ä¸é‡å»ºç»„件,MessagePanel æ»šåŠ¨ä½ç½®ã€è¾“å…¥æ¡†è‰ç¨¿ç­‰ UI çŠ¶æ€ä¸ä¸¢
      - Vue 3 é‡Œ keep-alive ä¸èƒ½ç›´æŽ¥åŒ… <router-view>(会有警告),必须走 v-slot æ‹¿ Component
    -->
    <router-view v-if="childRouteReady" v-slot="{ Component }">
      <keep-alive>
        <component :is="Component" />
      </keep-alive>
    </router-view>
    <div v-else class="flex-1 bg-[var(--ant-color-bg-container)]"></div>
    <!-- å…¨å±€å¼¹å±‚:由 useImUiStore ç»Ÿä¸€è°ƒåº¦ -->
    <UserInfoCard />
    <GroupInfoCard />
    <ContextMenu />
    <!-- å®žæ—¶é€šè¯æµ®å±‚;监听 rtcStore å…¨å±€çŠ¶æ€ï¼Œå¯åœ¨ä»»æ„ IM å­é¡µå¼¹å‡º -->
    <RtcCallContainer />
  </div>
</template>
<style scoped>
:global(:root) {
  --im-border-color-lighter: #e8eaed;
  --im-resize-line-color: #d8dde5;
  /*
   * antd 4.x ä¸è¾“出 --ant-color-* å…¨å±€ CSS å˜é‡ï¼ˆä»… React antd çš„ theme.cssVar æ”¯æŒï¼‰ï¼Œ
   * è€Œ IM èŠå¤©ç«¯å¤§é‡ç›´æŽ¥ä½¿ç”¨ var(--ant-color-*)。此处用 Vben design token å…œåº•定义,
   * æ˜ å°„规则与 useAntdDesignTokens å–‚ç»™ antd çš„ token å®Œå…¨ä¸€è‡´ï¼Œéšä¸»é¢˜ï¼ˆäº®/暗)自动切换。
   * ç¼ºå¤±æ—¶è¿™äº›å˜é‡ä¸ºç©º â†’ èƒŒæ™¯é€æ˜Žã€è¾¹æ¡†/填充失效(典型:表情面板透出输入框占位文字)。
   */
  --ant-color-bg-container: hsl(var(--card));
  --ant-color-bg-elevated: hsl(var(--popover));
  --ant-color-bg-layout: hsl(var(--background-deep));
  --ant-color-border: hsl(var(--border));
  --ant-color-border-secondary: hsl(var(--border));
  --ant-color-text: hsl(var(--foreground));
  --ant-color-text-secondary: hsl(var(--foreground) / 65%);
  --ant-color-text-placeholder: hsl(var(--foreground) / 45%);
  --ant-color-text-disabled: hsl(var(--foreground) / 30%);
  /*
   * fill ç³»åˆ—:浅色下用 Element Plus é£Žæ ¼çš„「冷调浅灰实色」而非半透明深色,
   * å¦åˆ™é¢æ¿ï¼ˆä¼šè¯åˆ—表 / æ¶ˆæ¯é¢æ¿ç­‰ï¼‰å åœ¨ç°åº•上会显脏发暗,和 Vue3+EP çš„干净白差距明显。
   * å–值依组件实际用法:secondary å¤šç”¨äºŽé¢æ¿åº•(取最浅,对齐 EP --el-bg-color-page #f5f7fa),
   * tertiary å¤šç”¨äºŽ hover / å›¾æ ‡å—(比面板略深,保证 hover å¯è§ï¼‰ã€‚深色在 .dark é‡Œå¦è¡Œè¦†ç›–。
   */
  --ant-color-fill: #e2e6ec;
  --ant-color-fill-secondary: #f4f6f9;
  --ant-color-fill-tertiary: #eaedf2;
  --ant-color-fill-dark: #d5dae2;
  --ant-color-primary: hsl(var(--primary));
  --ant-color-primary-hover: hsl(var(--primary) / 80%);
  --ant-color-primary-bg: hsl(var(--primary) / 12%);
  --ant-color-primary-bg-hover: hsl(var(--primary) / 18%);
  --ant-color-info: hsl(var(--primary));
  --ant-color-success: hsl(var(--success));
  --ant-color-success-bg: hsl(var(--success) / 12%);
  --ant-color-success-bg-hover: hsl(var(--success) / 18%);
  --ant-color-warning: hsl(var(--warning));
  --ant-color-warning-bg: hsl(var(--warning) / 12%);
  --ant-color-warning-bg-hover: hsl(var(--warning) / 18%);
  --ant-color-error: hsl(var(--destructive));
}
:global(.dark) {
  --im-border-color-lighter: rgb(255 255 255 / 12%);
  --im-resize-line-color: rgb(255 255 255 / 18%);
  /* æ·±è‰²ä¸‹ fill å›žåˆ°ã€ŒåŸºäºŽå‰æ™¯è‰²çš„半透明亮色」,叠在深底上得到自然的微亮表面 */
  --ant-color-fill: hsl(var(--foreground) / 12%);
  --ant-color-fill-secondary: hsl(var(--foreground) / 8%);
  --ant-color-fill-tertiary: hsl(var(--foreground) / 4%);
  --ant-color-fill-dark: hsl(var(--foreground) / 18%);
}
</style>