gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
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>