gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
银川
1.联调产品维护页面
2.添加IM即时通讯模块
已添加190个文件
已修改5个文件
35263 ■■■■■ 文件已修改
package.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/channel/material/index.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/conversation/read/index.ts 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/face/pack/index.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/face/userItem/index.ts 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/friend/index.ts 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/friend/request/index.ts 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/group/index.ts 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/group/member/index.ts 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/group/request/index.ts 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/channel/index.ts 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/channel/material/index.ts 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/channel/message/index.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/face/item/index.ts 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/face/pack/index.ts 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/face/userItem/index.ts 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/friend/index.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/friend/request/index.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/group/index.ts 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/group/request/index.ts 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/message/group/index.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/message/private/index.ts 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/rtc/index.ts 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/sensitiveword/index.ts 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/manager/statistics/index.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/message/channel/index.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/message/group/index.ts 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/message/private/index.ts 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/im/rtc/index.ts 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/im.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/index.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/modules/form.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/card/card-bubble.vue 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/card/card-line-label.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/card/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/context-menu.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/friend/friend-add-dialog.vue 289 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/friend/friend-item.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/friend/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-admin-set-dialog.vue 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-avatar.vue 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-create-dialog.vue 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-info-card.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-info.vue 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-item.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-member-add-dialog.vue 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-member-grid.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-member-item.vue 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-member-remove-dialog.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-member.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-mute-member-dialog.vue 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-owner-transfer-dialog.vue 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/group-request-list-dialog.vue 377 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/group/index.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/index.ts 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/paged-scroller.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/picker/conversation-picker-panel.vue 370 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/picker/friend-picker-panel.vue 245 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/picker/group-member-picker-panel.vue 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/picker/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/picker/picker-dialog.scss 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/resizable-aside.vue 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/rtc/index.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/rtc/rtc-call-container.vue 488 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/rtc/rtc-call-incoming.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/rtc/rtc-call-inviting.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/rtc/rtc-call-member-picker-dialog.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/rtc/rtc-call-participant-tile.vue 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/rtc/rtc-call-running.vue 291 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/rtc/rtc-group-call-banner.vue 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/tool-bar.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/user/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/user/recommend-card-dialog.vue 322 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/user/user-avatar.vue 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/user/user-info-card.vue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/components/user/user-info.vue 447 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useFriendBuckets.ts 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useGroupCallMembers.ts 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useLiveKitRoom.ts 331 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useMediaStreamElement.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useMediaUploader.ts 408 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useMessageMultiSelect.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useMessagePuller.ts 464 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useMessageSender.ts 337 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useMuteOverlay.ts 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useSelectedItems.ts 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/composables/useVoicePlayer.ts 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/index.vue 292 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/contact/friend-list.vue 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/contact/friend-request-detail.vue 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/contact/friend-request-list.vue 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/contact/group-detail.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/contact/group-list.vue 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/contact/index.vue 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/conversation/conversation-group-side.vue 832 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/conversation/conversation-item.vue 330 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/conversation/conversation-private-side.vue 285 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/conversation/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/input/face-picker.vue 353 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/input/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/input/mention-picker.vue 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/input/message-input.vue 1216 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/input/message-multi-select-bar.vue 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/input/voice-recorder.vue 340 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/forward/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/forward/keys.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue 446 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/forward/message-merge-detail-dialog.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/group-pinned-message.vue 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/group-request-pending.vue 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/index.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/material-bubble.vue 170 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/message-bubble.vue 414 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/message-history.vue 807 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/message-item.vue 1055 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/message-panel.vue 805 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/message-read-status.vue 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/reply-preview.vue 234 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/components/message/tip-segments.vue 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/pages/conversation/index.vue 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/channelStore.ts 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/conversationStore.ts 910 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/faceStore.ts 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/friendStore.ts 849 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/groupRequestStore.ts 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/groupStore.ts 1017 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/messageStore.ts 953 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/rtcStore.ts 450 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/uiStore.ts 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/store/websocketStore.ts 1259 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/home/types/index.ts 318 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/list/components/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/list/components/select.vue 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/list/data.ts 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/list/index.vue 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/list/modules/form.vue 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/material/components/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/material/components/select.vue 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/material/data.ts 235 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/material/index.vue 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/material/modules/form.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/message/index.vue 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/channel/message/modules/send-form.vue 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/face/pack/data.ts 375 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/face/pack/index.vue 198 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/face/pack/modules/form.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/face/pack/modules/item-drawer.vue 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/face/pack/modules/item-form.vue 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/face/userItem/index.vue 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/friend/data.ts 264 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/friend/index.vue 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/friend/request/index.vue 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/group/components/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/group/components/select-dialog.vue 271 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/group/components/select.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/group/data.ts 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/group/index.vue 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/group/modules/ban-form.vue 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/group/modules/detail.vue 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/group/request/index.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/message/content-preview.vue 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/message/data.ts 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/message/group/index.vue 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/message/group/modules/detail.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/message/index.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/message/private/index.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/message/private/modules/detail.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/rtc/data.ts 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/rtc/index.vue 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/rtc/modules/detail.vue 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/sensitiveword/data.ts 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/sensitiveword/index.vue 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/sensitiveword/modules/form.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/statistics/components/distribution-chart.vue 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/statistics/components/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/statistics/components/overview-cards.vue 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/statistics/components/trend-chart.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/statistics/index.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/manager/utils/format.ts 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/auth.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/channel.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/config.ts 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/constants.ts 296 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/conversation.ts 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/db.ts 541 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/emoji.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/group.ts 289 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/image.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/message.ts 1035 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/pull.ts 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/time.ts 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/im/utils/user.ts 367 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -57,6 +57,7 @@
    "diagram-js": "catalog:",
    "fast-xml-parser": "catalog:",
    "highlight.js": "catalog:",
    "livekit-client": "^2.20.0",
    "pinia": "catalog:",
    "steady-xml": "catalog:",
    "tinymce": "catalog:",
pnpm-lock.yaml
@@ -629,6 +629,9 @@
      highlight.js:
        specifier: 'catalog:'
        version: 11.11.1
      livekit-client:
        specifier: ^2.20.0
        version: 2.20.0(@types/dom-mediacapture-record@1.0.22)
      pinia:
        specifier: 'catalog:'
        version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
@@ -2328,6 +2331,9 @@
  '@bpmn-io/properties-panel@3.44.1':
    resolution: {integrity: sha512-8kQ59l7a9Bgfq1qvs0sTykcfqimwuJYrZPjNvr1f5OFFr+cpe3e380Pzxz9OGKk4Con1INEqVIePlaNhzrbDJg==}
  '@bufbuild/protobuf@1.10.0':
    resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==}
  '@bufbuild/protobuf@2.12.1':
    resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==}
@@ -3143,6 +3149,12 @@
  '@lezer/markdown@1.6.4':
    resolution: {integrity: sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==}
  '@livekit/mutex@1.1.1':
    resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
  '@livekit/protocol@1.46.6':
    resolution: {integrity: sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==}
  '@lucide/vue@1.21.0':
    resolution: {integrity: sha512-eoFn3tppjKAc12ZqdnRSMFdtwQ1ZMRQFb6SV1Eub6Y8kU28ccnqKeSFdnur9hMg8gIbosU2Y3WFJr/J/xS/IlQ==}
    peerDependencies:
@@ -3171,8 +3183,8 @@
    engines: {node: '>=18'}
    hasBin: true
  '@marijn/find-cluster-break@1.0.2':
    resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
  '@marijn/find-cluster-break@1.0.3':
    resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
  '@microsoft/fetch-event-source@2.0.1':
    resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
@@ -4391,6 +4403,9 @@
  '@types/d3-zoom@3.0.8':
    resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
  '@types/dom-mediacapture-record@1.0.22':
    resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
  '@types/esrecurse@4.3.1':
    resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -5441,8 +5456,8 @@
    resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
    engines: {node: '>= 14'}
  crelt@1.0.6:
    resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
  crelt@1.0.7:
    resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
  croner@10.0.1:
    resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
@@ -6965,6 +6980,9 @@
  jju@1.4.0:
    resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==}
  jose@6.2.3:
    resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
  js-beautify@1.15.4:
    resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
    engines: {node: '>=14'}
@@ -7183,6 +7201,11 @@
    resolution: {integrity: sha512-kfz4C0OrC6IpaVMtYDJtf6PFjurxe9NBBoDAh/o2p587INryFOO4DQ9OetbCdDrWFt1m1CJKvYrzkGsuPHw8nQ==}
    hasBin: true
  livekit-client@2.20.0:
    resolution: {integrity: sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==}
    peerDependencies:
      '@types/dom-mediacapture-record': ^1
  local-pkg@1.2.1:
    resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
    engines: {node: '>=14'}
@@ -7254,6 +7277,10 @@
  log-symbols@7.0.1:
    resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
    engines: {node: '>=18'}
  loglevel@1.9.2:
    resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
    engines: {node: '>= 0.6.0'}
  loose-envify@1.4.0:
    resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
@@ -8421,6 +8448,13 @@
  scule@1.3.0:
    resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
  sdp-transform@2.15.0:
    resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==}
    hasBin: true
  sdp@3.2.2:
    resolution: {integrity: sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==}
  secure-ls@2.0.0:
    resolution: {integrity: sha512-Wgtnw0QSm0v7gVKv11nOoeyGS65EThGXnBB7jfd4IhZd2eq3B4AMPcXAL5qJ1h55+Qolun7TONTwX7H5m6e2pQ==}
    engines: {node: '>=8.0'}
@@ -9056,6 +9090,9 @@
    resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
    engines: {node: '>= 0.4'}
  typed-emitter@2.1.0:
    resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
  typescript@6.0.3:
    resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
    engines: {node: '>=14.17'}
@@ -9542,6 +9579,10 @@
  webpack-virtual-modules@0.6.2:
    resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
  webrtc-adapter@9.0.5:
    resolution: {integrity: sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==}
    engines: {node: '>=6.0.0', npm: '>=3.10.0'}
  whatwg-encoding@3.1.1:
    resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
@@ -10608,6 +10649,8 @@
    transitivePeerDependencies:
      - '@bpmn-io/cm-theme'
  '@bufbuild/protobuf@1.10.0': {}
  '@bufbuild/protobuf@2.12.1': {}
  '@cacheable/memory@2.0.9':
@@ -10676,16 +10719,16 @@
    dependencies:
      '@codemirror/state': 6.6.0
      '@codemirror/view': 6.43.1
      crelt: 1.0.6
      crelt: 1.0.7
  '@codemirror/state@6.6.0':
    dependencies:
      '@marijn/find-cluster-break': 1.0.2
      '@marijn/find-cluster-break': 1.0.3
  '@codemirror/view@6.43.1':
    dependencies:
      '@codemirror/state': 6.6.0
      crelt: 1.0.6
      crelt: 1.0.7
      style-mod: 4.1.3
      w3c-keyname: 2.2.8
@@ -11394,6 +11437,12 @@
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
  '@livekit/mutex@1.1.1': {}
  '@livekit/protocol@1.46.6':
    dependencies:
      '@bufbuild/protobuf': 1.10.0
  '@lucide/vue@1.21.0(vue@3.5.38(typescript@6.0.3))':
    dependencies:
      vue: 3.5.38(typescript@6.0.3)
@@ -11442,7 +11491,7 @@
      - encoding
      - supports-color
  '@marijn/find-cluster-break@1.0.2': {}
  '@marijn/find-cluster-break@1.0.3': {}
  '@microsoft/fetch-event-source@2.0.1': {}
@@ -12357,6 +12406,8 @@
    dependencies:
      '@types/d3-interpolate': 3.0.4
      '@types/d3-selection': 3.0.11
  '@types/dom-mediacapture-record@1.0.22': {}
  '@types/esrecurse@4.3.1': {}
@@ -13583,7 +13634,7 @@
      crc-32: 1.2.2
      readable-stream: 4.7.0
  crelt@1.0.6: {}
  crelt@1.0.7: {}
  croner@10.0.1: {}
@@ -15213,6 +15264,8 @@
  jju@1.4.0: {}
  jose@6.2.3: {}
  js-beautify@1.15.4:
    dependencies:
      config-chain: 1.1.13
@@ -15406,6 +15459,19 @@
    transitivePeerDependencies:
      - srvx
  livekit-client@2.20.0(@types/dom-mediacapture-record@1.0.22):
    dependencies:
      '@livekit/mutex': 1.1.1
      '@livekit/protocol': 1.46.6
      '@types/dom-mediacapture-record': 1.0.22
      events: 3.3.0
      jose: 6.2.3
      loglevel: 1.9.2
      sdp-transform: 2.15.0
      tslib: 2.8.1
      typed-emitter: 2.1.0
      webrtc-adapter: 9.0.5
  local-pkg@1.2.1:
    dependencies:
      mlly: 1.8.2
@@ -15464,6 +15530,8 @@
    dependencies:
      is-unicode-supported: 2.1.0
      yoctocolors: 2.1.2
  loglevel@1.9.2: {}
  loose-envify@1.4.0:
    dependencies:
@@ -16792,6 +16860,10 @@
  scule@1.3.0: {}
  sdp-transform@2.15.0: {}
  sdp@3.2.2: {}
  secure-ls@2.0.0:
    dependencies:
      crypto-js: 4.2.0
@@ -17515,6 +17587,10 @@
      possible-typed-array-names: 1.1.0
      reflect.getprototypeof: 1.0.10
  typed-emitter@2.1.0:
    optionalDependencies:
      rxjs: 7.8.2
  typescript@6.0.3: {}
  uc.micro@2.1.0: {}
@@ -17994,6 +18070,10 @@
  webpack-virtual-modules@0.6.2: {}
  webrtc-adapter@9.0.5:
    dependencies:
      sdp: 3.2.2
  whatwg-encoding@3.1.1:
    dependencies:
      iconv-lite: 0.6.3
src/api/im/channel/material/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
import { requestClient } from '#/api/request';
export namespace ImChannelMaterialApi {
  /** ç”¨æˆ·ç«¯èƒ½çœ‹åˆ°çš„频道素材详情 */
  export interface Material {
    id: number;
    channelId: number;
    type: number;
    title: string;
    coverUrl?: string;
    summary?: string;
    content?: string;
    url?: string;
  }
}
/** èŽ·å–é¢‘é“ç´ æè¯¦æƒ…ï¼›ç”¨äºŽå®¢æˆ·ç«¯ç‚¹å‡»å›¾æ–‡å¡ç‰‡æ¸²æŸ“è¯¦æƒ…é¡µ */
export function getChannelMaterial(id: number) {
  return requestClient.get<ImChannelMaterialApi.Material>(
    '/im/channel/material/get',
    { params: { id } },
  );
}
src/api/im/conversation/read/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
import { requestClient } from '#/api/request';
export namespace ImConversationReadApi {
  /** IM ä¼šè¯è¯»ä½ç½® Response VO */
  export interface ConversationReadRespVO {
    id: number; // è¯»ä½ç½®ç¼–号(增量拉取游标用)
    conversationType: number; // ä¼šè¯ç±»åž‹ï¼Œå‚见 ImConversationType
    targetId: number; // ä¼šè¯ç›®æ ‡ç¼–号
    messageId: number; // æœ€å¤§å·²è¯»æ¶ˆæ¯ç¼–号
    updateTime?: number; // æœ€è¿‘更新时间(毫秒时间戳,增量拉取游标用)
  }
}
/** å¢žé‡æ‹‰å–当前用户的会话读位置(重连 / ç¦»çº¿è¡¥å¿ï¼‰ */
export function pullMyConversationReadList(params: {
  lastId?: number;
  lastUpdateTime?: number;
  limit: number;
}) {
  return requestClient.get<ImConversationReadApi.ConversationReadRespVO[]>(
    '/im/conversation-read/pull',
    { params },
  );
}
src/api/im/face/pack/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
import { requestClient } from '#/api/request';
export namespace ImFacePackApi {
  /** ç”¨æˆ·ç«¯è¡¨æƒ…包项(精简版) */
  export interface FacePackUserItem {
    id: number;
    url: string;
    name?: string;
    width: number;
    height: number;
  }
  /** ç”¨æˆ·ç«¯è¡¨æƒ…包 + åµŒå¥— items */
  export interface FacePackUser {
    id: number;
    name: string;
    icon?: string;
    items: FacePackUserItem[];
  }
}
/** æ‹‰å–所有启用的系统表情包(含表情列表) */
export function getFacePackList() {
  return requestClient.get<ImFacePackApi.FacePackUser[]>('/im/face-pack/list');
}
src/api/im/face/userItem/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
import { requestClient } from '#/api/request';
export namespace ImFaceUserItemApi {
  /** ä¸ªäººè¡¨æƒ… */
  export interface FaceUserItem {
    id: number;
    url: string;
    name?: string;
    width: number;
    height: number;
  }
  /** æ·»åŠ ä¸ªäººè¡¨æƒ…è¯·æ±‚ */
  export interface FaceUserItemSaveReqVO {
    url: string;
    name?: string;
    width: number;
    height: number;
  }
}
/** èŽ·å–æˆ‘çš„ä¸ªäººè¡¨æƒ…åˆ—è¡¨ */
export function getFaceUserItemList() {
  return requestClient.get<ImFaceUserItemApi.FaceUserItem[]>('/im/face-user-item/list');
}
/** æ·»åŠ ä¸ªäººè¡¨æƒ… */
export function createFaceUserItem(data: ImFaceUserItemApi.FaceUserItemSaveReqVO) {
  return requestClient.post<number>('/im/face-user-item/create', data);
}
/** åˆ é™¤ä¸ªäººè¡¨æƒ… */
export function deleteFaceUserItem(id: number) {
  return requestClient.delete<boolean>('/im/face-user-item/delete', {
    params: { id },
  });
}
src/api/im/friend/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,78 @@
import { requestClient } from '#/api/request';
export namespace ImFriendApi {
  /** IM å¥½å‹ Response VO */
  export interface FriendRespVO {
    id: number; // å…³ç³»è®°å½•编号
    friendUserId: number; // å¥½å‹çš„用户编号
    silent?: boolean; // æ˜¯å¦å…æ‰“扰
    displayName?: string; // å¥½å‹å±•示备注(仅自己可见)
    displayNamePinyin?: string; // å¤‡æ³¨çš„æ‹¼éŸ³ï¼ˆå°å†™æ— ç©ºæ ¼ï¼Œå‰ç«¯æŒ‰é¦–字母分桶 / æ‹¼éŸ³æœç´¢ï¼‰
    addSource?: number; // æ·»åŠ æ¥æºï¼›å‚è§ ImFriendAddSourceEnum
    pinned?: boolean; // æ˜¯å¦ç½®é¡¶è”系人
    blocked?: boolean; // æ˜¯å¦æ‹‰é»‘
    status?: number; // å¥½å‹çŠ¶æ€ï¼ˆ0=正常,1=已删除)
    addTime?: string; // æ·»åŠ å¥½å‹æ—¶é—´
    deleteTime?: string; // åˆ é™¤å¥½å‹æ—¶é—´
    updateTime?: number; // æœ€è¿‘更新时间(毫秒时间戳,增量拉取游标用)
    nickname?: string; // å¥½å‹æ˜µç§°
    nicknamePinyin?: string; // æ˜µç§°çš„æ‹¼éŸ³ï¼ˆå°å†™æ— ç©ºæ ¼ï¼Œå‰ç«¯æŒ‰é¦–字母分桶 / æ‹¼éŸ³æœç´¢ï¼‰
    avatar?: string; // å¥½å‹å¤´åƒ
  }
  /** IM å¥½å‹æ›´æ–° Request VO */
  export interface FriendUpdateReqVO {
    friendUserId: number; // å¥½å‹çš„用户编号
    silent?: boolean; // æ˜¯å¦å…æ‰“扰
    displayName?: string; // å¥½å‹å±•示备注
    pinned?: boolean; // æ˜¯å¦ç½®é¡¶è”系人
  }
}
/** èŽ·å¾—å½“å‰ç™»å½•ç”¨æˆ·çš„å¥½å‹åˆ—è¡¨ */
export function getMyFriendList() {
  return requestClient.get<ImFriendApi.FriendRespVO[]>('/im/friend/list');
}
/** å¢žé‡æ‹‰å–当前用户的好友关系(重连 / ç¦»çº¿è¡¥å¿ï¼‰ */
export function pullMyFriendList(params: {
  lastId?: number;
  lastUpdateTime?: number;
  limit: number;
}) {
  return requestClient.get<ImFriendApi.FriendRespVO[]>('/im/friend/pull', { params });
}
/** èŽ·å¾—å¥½å‹è¯¦æƒ… */
export function getFriend(friendUserId: number | string) {
  return requestClient.get<ImFriendApi.FriendRespVO>('/im/friend/get', {
    params: { friendUserId },
  });
}
/** åˆ é™¤å¥½å‹ï¼ˆå•向软删除) */
export function deleteFriend(friendUserId: number | string, clear: boolean) {
  return requestClient.delete<boolean>('/im/friend/delete', {
    params: { friendUserId, clear },
  });
}
/** æ›´æ–°å¥½å‹ä¿¡æ¯ï¼ˆå¤‡æ³¨ / å…æ‰“扰 / è”系人置顶) */
export function updateFriend(data: ImFriendApi.FriendUpdateReqVO) {
  return requestClient.put<boolean>('/im/friend/update', data);
}
/** æ‹‰é»‘好友(必须先是好友;单边屏蔽对方私聊消息) */
export function blockFriend(friendUserId: number | string) {
  return requestClient.put<boolean>('/im/friend/block', undefined, {
    params: { friendUserId },
  });
}
/** ç§»å‡ºé»‘名单 */
export function unblockFriend(friendUserId: number | string) {
  return requestClient.put<boolean>('/im/friend/unblock', undefined, {
    params: { friendUserId },
  });
}
src/api/im/friend/request/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
import { requestClient } from '#/api/request';
export namespace ImFriendRequestApi {
  /** IM å¥½å‹ç”³è¯· Response VO */
  export interface FriendRequestRespVO {
    id: number; // ç”³è¯·ç¼–号
    fromUserId: number; // å‘起方用户编号
    toUserId: number; // æŽ¥æ”¶æ–¹ç”¨æˆ·ç¼–号
    handleResult: number; // å¤„理结果;0=未处理;1=同意;2=拒绝
    applyContent?: string; // ç”³è¯·ç†ç”±
    handleContent?: string; // å¤„理理由(接收方拒绝时可选填)
    addSource?: number; // æ·»åŠ æ¥æºï¼›å‚è§ ImFriendAddSourceEnum
    handleTime?: string; // å¤„理时间
    createTime: string; // ç”³è¯·åˆ›å»ºæ—¶é—´
    updateTime?: number; // æœ€è¿‘更新时间(毫秒时间戳,增量拉取游标用)
    fromNickname?: string; // å‘起方昵称
    fromAvatar?: string; // å‘起方头像
    toNickname?: string; // æŽ¥æ”¶æ–¹æ˜µç§°
    toAvatar?: string; // æŽ¥æ”¶æ–¹å¤´åƒ
  }
  /** IM å¥½å‹ç”³è¯·å‘èµ· Request VO */
  export interface FriendRequestApplyReqVO {
    toUserId: number; // æŽ¥æ”¶æ–¹ç”¨æˆ·ç¼–号
    applyContent?: string; // ç”³è¯·ç†ç”±
    displayName?: string; // å¯¹æŽ¥æ”¶æ–¹çš„备注(仅自己可见)
    addSource?: number; // æ·»åŠ æ¥æº
  }
}
/** å‘起好友申请 */
export function applyFriendRequest(data: ImFriendRequestApi.FriendRequestApplyReqVO) {
  return requestClient.post<null | number>('/im/friend-request/apply', data);
}
/** åŒæ„å¥½å‹ç”³è¯· */
export function agreeFriendRequest(id: number | string) {
  return requestClient.put<boolean>('/im/friend-request/agree', undefined, {
    params: { id },
  });
}
/** æ‹’绝好友申请 */
export function refuseFriendRequest(
  id: number | string,
  handleContent?: string,
) {
  return requestClient.put<boolean>('/im/friend-request/refuse', undefined, {
    params: { id, handleContent },
  });
}
/** æŸ¥è¯¢ã€Œæˆ‘相关」的好友申请列表(游标分页:传 maxId åŠ è½½æ›´å¤šï¼‰ */
export function getMyFriendRequestList(limit: number, maxId?: number) {
  const params: Record<string, number> = { limit };
  if (maxId != null) {
    params.maxId = maxId;
  }
  return requestClient.get<ImFriendRequestApi.FriendRequestRespVO[]>(
    '/im/friend-request/list',
    { params },
  );
}
/** å¢žé‡æ‹‰å–「我相关」的好友申请变更(重连 / ç¦»çº¿è¡¥å¿ï¼‰ */
export function pullMyFriendRequestList(params: {
  lastId?: number;
  lastUpdateTime?: number;
  limit: number;
}) {
  return requestClient.get<ImFriendRequestApi.FriendRequestRespVO[]>(
    '/im/friend-request/pull',
    { params },
  );
}
/** æŒ‰ id å•查「我相关」的申请记录(带越权过滤;WebSocket é€šçŸ¥åˆ°è¾¾åŽç”¨ï¼‰ */
export function getMyFriendRequest(id: number) {
  return requestClient.get<ImFriendRequestApi.FriendRequestRespVO | null>(
    '/im/friend-request/get',
    { params: { id } },
  );
}
src/api/im/group/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
import type { ImGroupMessageApi } from '#/api/im/message/group';
import { requestClient } from '#/api/request';
export namespace ImGroupApi {
  /** ç¾¤ Response VO */
  export interface GroupRespVO {
    id: number; // ç¼–号
    name: string; // ç¾¤åç§°
    ownerUserId: number; // ç¾¤ä¸»ç”¨æˆ·ç¼–号
    avatar?: string; // ç¾¤å¤´åƒ
    notice?: string; // ç¾¤å…¬å‘Š
    banned?: boolean; // æ˜¯å¦å°ç¦
    mutedAll?: boolean; // æ˜¯å¦å…¨ç¾¤ç¦è¨€
    joinApproval?: boolean; // è¿›ç¾¤æ˜¯å¦éœ€ç¾¤ä¸» / ç®¡ç†å‘˜å®¡æ‰¹
    bannedTime?: string; // å°ç¦æ—¶é—´
    status: number; // ç¾¤çŠ¶æ€ï¼ˆ0=正常,1=已解散)
    dissolvedTime?: string; // è§£æ•£æ—¶é—´
    createTime?: string; // åˆ›å»ºæ—¶é—´
    pinnedMessages?: ImGroupMessageApi.GroupMessageRespVO[]; // ç¾¤ç½®é¡¶æ¶ˆæ¯åˆ—表(后端关联回填,仅当登录用户是群成员时非空)
    joinStatus?: number; // å½“前登录用户在该群的成员状态(参见 CommonStatusEnum:0 åœ¨ç¾¤ / 1 å·²é€€ç¾¤ï¼‰ï¼›åŽ†å²é€€ç¾¤ç¾¤ä»è¿”å›žï¼Œä¾›å±•ç¤ºç¦»çº¿æ¶ˆæ¯çš„ç¾¤å / å¤´åƒ
    groupRemark?: string; // å½“前登录用户对该群的备注
    silent?: boolean; // å½“前登录用户是否免打扰
  }
  /** ç¾¤æ¶ˆæ¯ç½®é¡¶ / å–消置顶 Request VO */
  export interface GroupMessagePinReqVO {
    id: number; // ç¾¤ç¼–号
    messageId: number; // æ¶ˆæ¯ç¼–号
  }
  /** ç¾¤åˆ›å»º Request VO */
  export interface GroupCreateReqVO {
    name: string; // ç¾¤åç§°
    memberUserIds?: number[]; // åˆå§‹æˆå‘˜ç”¨æˆ·ç¼–号列表(建群同时邀请的好友,不含创建者自己)
    joinApproval?: boolean; // è¿›ç¾¤æ˜¯å¦éœ€å®¡æ‰¹ï¼›ä¸ä¼ é»˜è®¤ false è‡ªç”±è¿›ç¾¤
  }
  /** ç¾¤æ›´æ–° Request VO */
  export interface GroupUpdateReqVO {
    id: number; // ç¾¤ç¼–号
    name?: string; // ç¾¤åç§°
    avatar?: string; // ç¾¤å¤´åƒ
    notice?: string; // ç¾¤å…¬å‘Š
    joinApproval?: boolean; // è¿›ç¾¤æ˜¯å¦éœ€å®¡æ‰¹
  }
  /** æ·»åŠ  / æ’¤é”€ç¾¤ç®¡ç†å‘˜ Request VO */
  export interface GroupAdminReqVO {
    id: number; // ç¾¤ç¼–号
    userIds: number[]; // ç›®æ ‡ç”¨æˆ·ç¼–号列表
  }
  /** ç¾¤ä¸»è½¬è®© Request VO */
  export interface GroupTransferOwnerReqVO {
    id: number; // ç¾¤ç¼–号
    newOwnerUserId: number; // æ–°ç¾¤ä¸»ç”¨æˆ·ç¼–号
  }
  /** å…¨ç¾¤ç¦è¨€ / å–消 Request VO */
  export interface GroupMuteAllReqVO {
    id: number; // ç¾¤ç¼–号
    mutedAll: boolean; // æ˜¯å¦å…¨ç¾¤ç¦è¨€
  }
  /** æˆå‘˜ç¦è¨€ Request VO */
  export interface GroupMuteMemberReqVO {
    id: number; // ç¾¤ç¼–号
    userId: number; // è¢«ç¦è¨€çš„用户编号
    mutedSeconds: number; // ç¦è¨€æ—¶é•¿ï¼ˆç§’),0 è¡¨ç¤ºæ°¸ä¹…禁言
  }
  /** å–消成员禁言 Request VO */
  export interface GroupCancelMuteMemberReqVO {
    id: number; // ç¾¤ç¼–号
    userId: number; // è¢«å–消禁言的用户编号
  }
}
/** èŽ·å¾—å½“å‰ç™»å½•ç”¨æˆ·çš„ç¾¤åˆ—è¡¨ */
export function getMyGroupList() {
  return requestClient.get<ImGroupApi.GroupRespVO[]>('/im/group/list');
}
/** èŽ·å¾—ç¾¤è¯¦æƒ… */
export function getGroup(id: number | string) {
  return requestClient.get<ImGroupApi.GroupRespVO>('/im/group/get', { params: { id } });
}
/** åˆ›å»ºç¾¤ */
export function createGroup(data: ImGroupApi.GroupCreateReqVO) {
  return requestClient.post<ImGroupApi.GroupRespVO>('/im/group/create', data);
}
/** æ›´æ–°ç¾¤ */
export function updateGroup(data: ImGroupApi.GroupUpdateReqVO) {
  return requestClient.put<ImGroupApi.GroupRespVO>('/im/group/update', data);
}
/** è§£æ•£ç¾¤ */
export function dissolveGroup(id: number | string) {
  return requestClient.delete<boolean>('/im/group/dissolve', {
    params: { id },
  });
}
/** æ·»åŠ ç¾¤ç®¡ç†å‘˜ï¼ˆä»…ç¾¤ä¸»å¯è°ƒï¼‰ */
export function addGroupAdmin(data: ImGroupApi.GroupAdminReqVO) {
  return requestClient.put<boolean>('/im/group/add-admin', data);
}
/** æ’¤é”€ç¾¤ç®¡ç†å‘˜ï¼ˆä»…群主可调) */
export function removeGroupAdmin(data: ImGroupApi.GroupAdminReqVO) {
  return requestClient.put<boolean>('/im/group/remove-admin', data);
}
/** è½¬è®©ç¾¤ä¸»ï¼ˆä»…老群主可调;旧群主转让后降为普通成员) */
export function transferGroupOwner(data: ImGroupApi.GroupTransferOwnerReqVO) {
  return requestClient.put<boolean>('/im/group/transfer-owner', data);
}
/** ç½®é¡¶ç¾¤æ¶ˆæ¯ï¼ˆä»…群主 / ç®¡ç†å‘˜å¯è°ƒï¼‰ */
export function pinGroupMessage(data: ImGroupApi.GroupMessagePinReqVO) {
  return requestClient.put<boolean>('/im/group/pin-message', data);
}
/** å–消置顶群消息(仅群主 / ç®¡ç†å‘˜å¯è°ƒï¼‰ */
export function unpinGroupMessage(data: ImGroupApi.GroupMessagePinReqVO) {
  return requestClient.put<boolean>('/im/group/unpin-message', data);
}
/** å…¨ç¾¤ç¦è¨€ / å–消(仅群主 / ç®¡ç†å‘˜å¯è°ƒï¼‰ */
export function muteAll(data: ImGroupApi.GroupMuteAllReqVO) {
  return requestClient.put<boolean>('/im/group/mute-all', data);
}
/** ç¦è¨€æˆå‘˜ */
export function muteMember(data: ImGroupApi.GroupMuteMemberReqVO) {
  return requestClient.put<boolean>('/im/group/mute-member', data);
}
/** å–消成员禁言 */
export function cancelMuteMember(data: ImGroupApi.GroupCancelMuteMemberReqVO) {
  return requestClient.put<boolean>('/im/group/cancel-mute-member', data);
}
src/api/im/group/member/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,78 @@
import { requestClient } from '#/api/request';
export namespace ImGroupMemberApi {
  /** ç¾¤æˆå‘˜ Response VO */
  export interface GroupMemberRespVO {
    id: number; // ç¼–号
    groupId: number; // ç¾¤ç¼–号
    userId: number; // ç”¨æˆ·ç¼–号
    displayUserName?: string; // ç»„内显示名(群主设置的备注)
    groupRemark?: string; // ç¾¤å¤‡æ³¨ï¼ˆå½“前用户对群的备注)
    silent?: boolean; // æ˜¯å¦å…æ‰“扰
    status?: number; // æˆå‘˜çŠ¶æ€ï¼ˆ0=在群,1=退群)
    role?: number; // æˆå‘˜è§’色,参见 ImGroupMemberRole æžšä¸¾
    joinTime?: string; // å…¥ç¾¤æ—¶é—´
    quitTime?: string; // é€€ç¾¤æ—¶é—´
    muteEndTime?: string; // ç¦è¨€åˆ°æœŸæ—¶é—´
    createTime?: string; // åˆ›å»ºæ—¶é—´
    nickname?: string; // ç”¨æˆ·æ˜µç§°
    avatar?: string; // ç”¨æˆ·å¤´åƒ
  }
  /** ç¾¤æˆå‘˜é‚€è¯· Request VO */
  export interface GroupMemberInviteReqVO {
    groupId: number; // ç¾¤ç¼–号
    memberUserIds: number[]; // è¢«é‚€è¯·çš„用户编号列表
  }
  /** ç¾¤æˆå‘˜ç§»é™¤ Request VO */
  export interface GroupMemberRemoveReqVO {
    groupId: number; // ç¾¤ç¼–号
    memberUserIds: number[]; // è¢«ç§»é™¤çš„用户编号列表
  }
  /** ç¾¤æˆå‘˜æ›´æ–° Request VO */
  export interface GroupMemberUpdateReqVO {
    groupId: number; // ç¾¤ç¼–号
    displayUserName?: string; // ç¾¤å†…昵称
    groupRemark?: string; // ç¾¤å¤‡æ³¨
    silent?: boolean; // æ˜¯å¦å…æ‰“扰
  }
}
/** é‚€è¯·ç”¨æˆ·åŠ å…¥ç¾¤ */
export function inviteGroupMember(data: ImGroupMemberApi.GroupMemberInviteReqVO) {
  return requestClient.post<boolean>('/im/group/invite', data);
}
/** é€€å‡ºç¾¤ */
export function quitGroup(groupId: number | string) {
  return requestClient.delete<boolean>('/im/group/quit', {
    params: { groupId },
  });
}
/** ç§»é™¤ç¾¤æˆå‘˜ */
export function removeGroupMember(data: ImGroupMemberApi.GroupMemberRemoveReqVO) {
  return requestClient.delete<boolean>('/im/group/kicking', { data });
}
/** èŽ·å¾—ç¾¤æˆå‘˜è¯¦æƒ… */
export function getGroupMember(groupId: number, userId: number) {
  return requestClient.get<ImGroupMemberApi.GroupMemberRespVO>('/im/group-member/get', {
    params: { groupId, userId },
  });
}
/** èŽ·å¾—æŒ‡å®šç¾¤çš„æˆå‘˜åˆ—è¡¨ï¼ˆèšåˆ AdminUser æ˜µç§° / å¤´åƒï¼‰ */
export function getGroupMemberList(groupId: number | string) {
  return requestClient.get<ImGroupMemberApi.GroupMemberRespVO[]>('/im/group-member/list', {
    params: { groupId },
  });
}
/** æ›´æ–°ç¾¤æˆå‘˜ */
export function updateGroupMember(data: ImGroupMemberApi.GroupMemberUpdateReqVO) {
  return requestClient.put<boolean>('/im/group-member/update', data);
}
src/api/im/group/request/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
import { requestClient } from '#/api/request';
export namespace ImGroupRequestApi {
  /** IM åŠ ç¾¤ç”³è¯· Response VO */
  export interface GroupRequestRespVO {
    id: number; // ç”³è¯·ç¼–号
    groupId: number; // ç¾¤ç¼–号
    userId: number; // ç”³è¯·äºº / è¢«é‚€è¯·äººç”¨æˆ·ç¼–号
    inviterUserId?: number; // é‚€è¯·äººï¼›NULL è¡¨ç¤ºç”¨æˆ·ä¸»åŠ¨ç”³è¯·
    handleResult: number; // å¤„理结果;0=未处理;1=同意;2=拒绝
    applyContent?: string; // ç”³è¯·ç†ç”±
    handleContent?: string; // å¤„理理由(拒绝时可选填)
    handleUserId?: number; // å¤„理人用户编号
    addSource?: number; // åŠ å…¥æ¥æºï¼›å‚è§ ImGroupAddSourceEnum
    handleTime?: string; // å¤„理时间
    createTime: string; // ç”³è¯·åˆ›å»ºæ—¶é—´
    updateTime?: number; // æœ€è¿‘更新时间(毫秒时间戳,增量拉取游标用)
    userNickname?: string; // ç”³è¯·äºº / è¢«é‚€è¯·äººæ˜µç§°
    userAvatar?: string; // ç”³è¯·äºº / è¢«é‚€è¯·äººå¤´åƒ
    inviterNickname?: string; // é‚€è¯·äººæ˜µç§°
    inviterAvatar?: string; // é‚€è¯·äººå¤´åƒ
    groupName?: string; // ç¾¤åç§°
    groupAvatar?: string; // ç¾¤å¤´åƒ
  }
  /** IM åŠ ç¾¤ç”³è¯·å‘èµ· Request VO */
  export interface GroupRequestApplyReqVO {
    groupId: number; // ç¾¤ç¼–号
    applyContent?: string; // ç”³è¯·ç†ç”±
    addSource?: number; // åŠ å…¥æ¥æº
  }
}
/** ç”³è¯·åŠ ç¾¤ */
export function applyJoinGroup(data: ImGroupRequestApi.GroupRequestApplyReqVO) {
  return requestClient.post<null | number>('/im/group-request/apply', data);
}
/** åŒæ„åŠ ç¾¤ç”³è¯·ï¼ˆç¾¤ä¸»æˆ–ç®¡ç†å‘˜ï¼‰ */
export function agreeGroupRequest(id: number | string) {
  return requestClient.put<boolean>('/im/group-request/agree', undefined, {
    params: { id },
  });
}
/** æ‹’绝加群申请(群主或管理员) */
export function refuseGroupRequest(
  id: number | string,
  handleContent?: string,
) {
  return requestClient.put<boolean>('/im/group-request/refuse', undefined, {
    params: { id, handleContent },
  });
}
/** æŸ¥è¯¢ã€Œæˆ‘管理的所有群」下的未处理加群申请列表(不分页);前端 store æ®æ­¤æ´¾ç”Ÿæ¨ªå¹…红点 + Drawer åˆ—表 */
export function getUnhandledRequestList() {
  return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>(
    '/im/group-request/unhandled-list',
  );
}
/** æŸ¥è¯¢æŒ‡å®šç¾¤ä¸‹çš„全部加群申请(含已处理);仅群主 / ç®¡ç†å‘˜å¯æŸ¥ */
export function getGroupRequestListByGroupId(groupId: number) {
  return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>(
    '/im/group-request/list-by-group',
    { params: { groupId } },
  );
}
/** æŒ‰ id å•查申请记录(带越权过滤;WebSocket é€šçŸ¥åˆ°è¾¾åŽç”¨ï¼‰ */
export function getMyGroupRequest(id: number) {
  return requestClient.get<ImGroupRequestApi.GroupRequestRespVO | null>(
    '/im/group-request/get',
    { params: { id } },
  );
}
/** å¢žé‡æ‹‰å–我管理的所有群下加群申请变更(重连 / ç¦»çº¿è¡¥å¿ï¼‰ */
export function pullMyGroupRequestList(params: {
  lastId?: number;
  lastUpdateTime?: number;
  limit: number;
}) {
  return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>(
    '/im/group-request/pull',
    { params },
  );
}
src/api/im/manager/channel/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
import type { PageParam, PageResult } from '..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerChannelApi {
  /** é¢‘道 */
  export interface Channel {
    id: number;
    code: string;
    name: string;
    avatar?: string;
    sort: number;
    status: number;
    createTime?: Date;
  }
}
/** èŽ·å¾—é¢‘é“åˆ†é¡µ */
export function getManagerChannelPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerChannelApi.Channel>>(
    '/im/manager/channel/page',
    { params },
  );
}
/** èŽ·å¾—é¢‘é“è¯¦æƒ… */
export function getManagerChannel(id: number) {
  return requestClient.get<ImManagerChannelApi.Channel>('/im/manager/channel/get', {
    params: { id },
  });
}
/** æ–°å¢žé¢‘道 */
export function createManagerChannel(data: ImManagerChannelApi.Channel) {
  return requestClient.post<number>('/im/manager/channel/create', data);
}
/** ä¿®æ”¹é¢‘道 */
export function updateManagerChannel(data: ImManagerChannelApi.Channel) {
  return requestClient.put<boolean>('/im/manager/channel/update', data);
}
/** åˆ é™¤é¢‘道 */
export function deleteManagerChannel(id: number) {
  return requestClient.delete<boolean>('/im/manager/channel/delete', {
    params: { id },
  });
}
/** èŽ·å¾—å¯ç”¨çš„é¢‘é“ç²¾ç®€åˆ—è¡¨ï¼ˆè¡¨å•é€‰æ‹©ç”¨ï¼‰ */
export function getSimpleChannelList() {
  return requestClient.get<ImManagerChannelApi.Channel[]>(
    '/im/manager/channel/simple-list',
  );
}
src/api/im/manager/channel/material/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerChannelMaterialApi {
  /** é¢‘道素材 */
  export interface Material {
    id: number;
    channelId: number;
    channelName?: string;
    type: number;
    title: string;
    coverUrl?: string;
    summary?: string;
    content?: string;
    url?: string;
    createTime?: Date;
  }
}
/** èŽ·å¾—ç´ æåˆ†é¡µ */
export function getManagerChannelMaterialPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerChannelMaterialApi.Material>>(
    '/im/manager/channel-material/page',
    { params },
  );
}
/** èŽ·å¾—æŒ‡å®šé¢‘é“ä¸‹çš„ç´ æç²¾ç®€åˆ—è¡¨ */
export function getSimpleManagerChannelMaterialList(channelId: number) {
  return requestClient.get<ImManagerChannelMaterialApi.Material[]>(
    '/im/manager/channel-material/simple-list',
    { params: { channelId } },
  );
}
/** èŽ·å¾—ç´ æè¯¦æƒ… */
export function getManagerChannelMaterial(id: number) {
  return requestClient.get<ImManagerChannelMaterialApi.Material>(
    '/im/manager/channel-material/get',
    { params: { id } },
  );
}
/** æ–°å¢žç´ æ */
export function createManagerChannelMaterial(
  data: ImManagerChannelMaterialApi.Material,
) {
  return requestClient.post<number>('/im/manager/channel-material/create', data);
}
/** ä¿®æ”¹ç´ æ */
export function updateManagerChannelMaterial(
  data: ImManagerChannelMaterialApi.Material,
) {
  return requestClient.put<boolean>(
    '/im/manager/channel-material/update',
    data,
  );
}
/** åˆ é™¤ç´ æ */
export function deleteManagerChannelMaterial(id: number) {
  return requestClient.delete<boolean>('/im/manager/channel-material/delete', {
    params: { id },
  });
}
src/api/im/manager/channel/message/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerChannelMessageApi {
  /** é¢‘道消息 */
  export interface ChannelMessage {
    id: number;
    channelId: number;
    channelName?: string;
    materialId: number;
    materialTitle?: string;
    materialCoverUrl?: string;
    type: number;
    content?: string;
    receiverUserIds?: number[];
    sendTime?: Date;
  }
  /** é¢‘道消息发送请求 */
  export interface ChannelMessageSendReqVO {
    materialId: number;
    receiverUserIds?: number[];
  }
}
/** ç«‹å³æŽ¨é€é¢‘道消息 */
export function sendManagerChannelMessage(
  data: ImManagerChannelMessageApi.ChannelMessageSendReqVO,
) {
  return requestClient.post<number>('/im/manager/channel-message/send', data);
}
/** åˆ é™¤é¢‘道消息 */
export function deleteManagerChannelMessage(id: number) {
  return requestClient.delete<boolean>('/im/manager/channel-message/delete', {
    params: { id },
  });
}
/** èŽ·å¾—é¢‘é“æ¶ˆæ¯åˆ†é¡µ */
export function getManagerChannelMessagePage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerChannelMessageApi.ChannelMessage>>(
    '/im/manager/channel-message/page',
    { params },
  );
}
src/api/im/manager/face/item/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerFacePackItemApi {
  /** è¡¨æƒ…项 */
  export interface FacePackItem {
    id: number;
    packId: number;
    url: string;
    name?: string;
    width: number;
    height: number;
    sort: number;
    status: number;
    createTime?: Date;
  }
}
/** èŽ·å¾—è¡¨æƒ…åˆ†é¡µ */
export function getManagerFacePackItemPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerFacePackItemApi.FacePackItem>>(
    '/im/manager/face-pack-item/page',
    { params },
  );
}
/** èŽ·å¾—è¡¨æƒ…è¯¦æƒ… */
export function getManagerFacePackItem(id: number) {
  return requestClient.get<ImManagerFacePackItemApi.FacePackItem>(
    '/im/manager/face-pack-item/get',
    { params: { id } },
  );
}
/** æ–°å¢žè¡¨æƒ… */
export function createManagerFacePackItem(data: ImManagerFacePackItemApi.FacePackItem) {
  return requestClient.post<number>('/im/manager/face-pack-item/create', data);
}
/** ä¿®æ”¹è¡¨æƒ… */
export function updateManagerFacePackItem(data: ImManagerFacePackItemApi.FacePackItem) {
  return requestClient.put<boolean>(
    '/im/manager/face-pack-item/update',
    data,
  );
}
/** åˆ é™¤è¡¨æƒ… */
export function deleteManagerFacePackItem(id: number) {
  return requestClient.delete<boolean>('/im/manager/face-pack-item/delete', {
    params: { id },
  });
}
/** æ‰¹é‡åˆ é™¤è¡¨æƒ… */
export function deleteManagerFacePackItemList(ids: number[]) {
  return requestClient.delete<boolean>(
    '/im/manager/face-pack-item/delete-list',
    { params: { ids: ids.join(',') } },
  );
}
src/api/im/manager/face/pack/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerFacePackApi {
  /** è¡¨æƒ…包 */
  export interface FacePack {
    id: number;
    name: string;
    icon?: string;
    sort: number;
    status: number;
    createTime?: Date;
  }
}
/** èŽ·å¾—è¡¨æƒ…åŒ…åˆ†é¡µ */
export function getManagerFacePackPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerFacePackApi.FacePack>>(
    '/im/manager/face-pack/page',
    { params },
  );
}
/** èŽ·å¾—è¡¨æƒ…åŒ…è¯¦æƒ… */
export function getManagerFacePack(id: number) {
  return requestClient.get<ImManagerFacePackApi.FacePack>('/im/manager/face-pack/get', {
    params: { id },
  });
}
/** æ–°å¢žè¡¨æƒ…包 */
export function createManagerFacePack(data: ImManagerFacePackApi.FacePack) {
  return requestClient.post<number>('/im/manager/face-pack/create', data);
}
/** ä¿®æ”¹è¡¨æƒ…包 */
export function updateManagerFacePack(data: ImManagerFacePackApi.FacePack) {
  return requestClient.put<boolean>('/im/manager/face-pack/update', data);
}
/** åˆ é™¤è¡¨æƒ…包 */
export function deleteManagerFacePack(id: number) {
  return requestClient.delete<boolean>('/im/manager/face-pack/delete', {
    params: { id },
  });
}
/** æ‰¹é‡åˆ é™¤è¡¨æƒ…包 */
export function deleteManagerFacePackList(ids: number[]) {
  return requestClient.delete<boolean>('/im/manager/face-pack/delete-list', {
    params: { ids: ids.join(',') },
  });
}
src/api/im/manager/face/userItem/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerFaceUserItemApi {
  /** ç”¨æˆ·è¡¨æƒ… */
  export interface FaceUserItem {
    id: number;
    userId: number;
    userNickname?: string;
    url: string;
    name?: string;
    width?: number;
    height?: number;
    createTime?: Date;
  }
}
/** èŽ·å¾—ç”¨æˆ·è¡¨æƒ…åˆ†é¡µ */
export function getManagerFaceUserItemPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerFaceUserItemApi.FaceUserItem>>(
    '/im/manager/face-user-item/page',
    { params },
  );
}
/** åˆ é™¤ç”¨æˆ·è¡¨æƒ… */
export function deleteManagerFaceUserItem(id: number) {
  return requestClient.delete<boolean>('/im/manager/face-user-item/delete', {
    params: { id },
  });
}
src/api/im/manager/friend/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
import type { PageParam, PageResult } from '..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerFriendApi {
  /** å¥½å‹å…³ç³» */
  export interface Friend {
    id: number;
    userId: number;
    userNickname?: string;
    friendUserId: number;
    friendNickname?: string;
    displayName?: string;
    addSource?: number;
    silent: boolean;
    pinned: boolean;
    blocked: boolean;
    status: number;
    addTime?: Date;
    deleteTime?: Date;
    createTime: Date;
  }
}
/** èŽ·å¾—å¥½å‹å…³ç³»åˆ†é¡µ */
export function getManagerFriendPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerFriendApi.Friend>>(
    '/im/manager/friend/page',
    { params },
  );
}
src/api/im/manager/friend/request/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerFriendRequestApi {
  /** å¥½å‹ç”³è¯· */
  export interface FriendRequest {
    id: number;
    fromUserId: number;
    fromNickname?: string;
    toUserId: number;
    toNickname?: string;
    applyContent?: string;
    displayName?: string;
    addSource?: number;
    handleResult: number;
    handleContent?: string;
    handleTime?: Date;
    createTime: Date;
  }
}
/** èŽ·å¾—å¥½å‹ç”³è¯·åˆ†é¡µ */
export function getManagerFriendRequestPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerFriendRequestApi.FriendRequest>>(
    '/im/manager/friend-request/page',
    { params },
  );
}
src/api/im/manager/group/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
import type { PageParam, PageResult } from '..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerGroupApi {
  /** ç¾¤ */
  export interface Group {
    id: number;
    name: string;
    avatar?: string;
    notice?: string;
    ownerUserId: number;
    ownerNickname?: string;
    memberCount?: number;
    status: number;
    banned: boolean;
    mutedAll?: boolean; // æ˜¯å¦å…¨ç¾¤ç¦è¨€
    bannedReason?: string;
    bannedTime?: Date;
    dissolvedTime?: Date;
    createTime: Date;
  }
  /** ç¾¤æˆå‘˜ */
  export interface GroupMember {
    userId: number;
    nickname?: string;
    avatar?: string;
    displayUserName?: string;
    groupRemark?: string;
    silent?: boolean;
    status: number;
    role?: number; // æˆå‘˜è§’色,参见 ImGroupMemberRole æžšä¸¾
    joinTime?: Date;
    quitTime?: Date;
    muteEndTime?: Date; // ç¦è¨€åˆ°æœŸæ—¶é—´
  }
  /** ç¾¤å°ç¦è¯·æ±‚ */
  export interface GroupBanReqVO {
    id: number;
    reason: string;
  }
}
/** èŽ·å¾—ç¾¤åˆ†é¡µ */
export function getManagerGroupPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerGroupApi.Group>>(
    '/im/manager/group/page',
    { params },
  );
}
/** èŽ·å¾—ç¾¤è¯¦æƒ… */
export function getManagerGroup(id: number) {
  return requestClient.get<ImManagerGroupApi.Group>('/im/manager/group/get', {
    params: { id },
  });
}
/** å°ç¦ç¾¤ */
export function banManagerGroup(data: ImManagerGroupApi.GroupBanReqVO) {
  return requestClient.put<boolean>('/im/manager/group/ban', data);
}
/** è§£å°ç¾¤ */
export function unbanManagerGroup(id: number) {
  return requestClient.put<boolean>('/im/manager/group/unban', undefined, {
    params: { id },
  });
}
/** è§£æ•£ç¾¤ */
export function dissolveManagerGroup(id: number) {
  return requestClient.delete<boolean>('/im/manager/group/dissolve', {
    params: { id },
  });
}
/** èŽ·å¾—ç¾¤æˆå‘˜åˆ—è¡¨ï¼ˆå«å·²é€€ç¾¤æˆå‘˜ï¼Œç”±å‰ç«¯æŒ‰éœ€è¿‡æ»¤ï¼‰ */
export function getManagerGroupMemberList(groupId: number) {
  return requestClient.get<ImManagerGroupApi.GroupMember[]>(
    '/im/manager/group/member/list',
    { params: { groupId } },
  );
}
src/api/im/manager/group/request/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerGroupRequestApi {
  /** åŠ ç¾¤ç”³è¯· */
  export interface GroupRequest {
    id: number;
    groupId: number;
    groupName?: string;
    userId: number;
    userNickname?: string;
    inviterUserId?: number;
    inviterNickname?: string;
    applyContent?: string;
    addSource?: number;
    handleResult: number;
    handleUserId?: number;
    handleNickname?: string;
    handleContent?: string;
    handleTime?: Date;
    createTime: Date;
  }
}
/** èŽ·å¾—åŠ ç¾¤ç”³è¯·åˆ†é¡µ */
export function getManagerGroupRequestPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerGroupRequestApi.GroupRequest>>(
    '/im/manager/group-request/page',
    { params },
  );
}
src/api/im/manager/message/group/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerGroupMessageApi {
  /** ç¾¤èŠæ¶ˆæ¯ */
  export interface GroupMessage {
    id: number;
    clientMessageId?: string;
    groupId: number;
    groupName?: string;
    senderId: number;
    senderNickname?: string;
    type: number;
    content: string;
    status: number;
    atUserIds?: number[];
    atUserNicknames?: (null | string)[];
    receiptStatus: number;
    sendTime: Date;
    createTime: Date;
  }
}
/** èŽ·å¾—ç¾¤èŠæ¶ˆæ¯åˆ†é¡µ */
export function getManagerGroupMessagePage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerGroupMessageApi.GroupMessage>>(
    '/im/manager/message/group/page',
    { params },
  );
}
/** èŽ·å¾—ç¾¤èŠæ¶ˆæ¯è¯¦æƒ… */
export function getManagerGroupMessage(id: number) {
  return requestClient.get<ImManagerGroupMessageApi.GroupMessage>(
    '/im/manager/message/group/get',
    { params: { id } },
  );
}
src/api/im/manager/message/private/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
import type { PageParam, PageResult } from '..\..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerPrivateMessageApi {
  /** ç§èŠæ¶ˆæ¯ */
  export interface PrivateMessage {
    id: number;
    clientMessageId?: string;
    senderId: number;
    senderNickname?: string;
    receiverId: number;
    receiverNickname?: string;
    type: number;
    content: string;
    status: number;
    receiptStatus?: number;
    sendTime: Date;
    createTime: Date;
  }
}
/** èŽ·å¾—ç§èŠæ¶ˆæ¯åˆ†é¡µ */
export function getManagerPrivateMessagePage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerPrivateMessageApi.PrivateMessage>>(
    '/im/manager/message/private/page',
    { params },
  );
}
/** èŽ·å¾—ç§èŠæ¶ˆæ¯è¯¦æƒ… */
export function getManagerPrivateMessage(id: number) {
  return requestClient.get<ImManagerPrivateMessageApi.PrivateMessage>(
    '/im/manager/message/private/get',
    { params: { id } },
  );
}
src/api/im/manager/rtc/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
import type { PageParam, PageResult } from '..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerRtcApi {
  /** RTC é€šè¯ */
  export interface RtcCall {
    id: number;
    room: string;
    conversationType: number;
    mediaType: number;
    inviterUserId: number;
    inviterNickname?: string;
    groupId?: number;
    groupName?: string;
    status: number;
    endReason?: number;
    startTime: Date;
    acceptTime?: Date;
    endTime?: Date;
    createTime: Date;
  }
  /** RTC é€šè¯å‚与者 */
  export interface RtcParticipant {
    id: number;
    callId: number;
    userId: number;
    userNickname?: string;
    role: number;
    status: number;
    inviteTime: Date;
    acceptTime?: Date;
    leaveTime?: Date;
  }
}
/** èŽ·å¾—é€šè¯è®°å½•åˆ†é¡µ */
export function getManagerRtcCallPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerRtcApi.RtcCall>>(
    '/im/manager/rtc/page',
    { params },
  );
}
/** èŽ·å¾—é€šè¯å‚ä¸Žè€…åˆ—è¡¨ */
export function getManagerRtcCallParticipantList(id: number) {
  return requestClient.get<ImManagerRtcApi.RtcParticipant[]>(
    '/im/manager/rtc/participant-list',
    { params: { id } },
  );
}
src/api/im/manager/sensitiveword/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
import type { PageParam, PageResult } from '..\..\..\..\packages\effects\request\src';
import { requestClient } from '#/api/request';
export namespace ImManagerSensitiveWordApi {
  /** æ•æ„Ÿè¯ */
  export interface SensitiveWord {
    id: number;
    word: string;
    status: number;
    creator?: string;
    creatorName?: string;
    createTime?: Date;
  }
}
/** èŽ·å¾—æ•æ„Ÿè¯åˆ†é¡µ */
export function getManagerSensitiveWordPage(params: PageParam) {
  return requestClient.get<PageResult<ImManagerSensitiveWordApi.SensitiveWord>>(
    '/im/manager/sensitive-word/page',
    { params },
  );
}
/** èŽ·å¾—æ•æ„Ÿè¯è¯¦æƒ… */
export function getManagerSensitiveWord(id: number) {
  return requestClient.get<ImManagerSensitiveWordApi.SensitiveWord>(
    '/im/manager/sensitive-word/get',
    { params: { id } },
  );
}
/** æ–°å¢žæ•æ„Ÿè¯ */
export function createManagerSensitiveWord(data: ImManagerSensitiveWordApi.SensitiveWord) {
  return requestClient.post<number>('/im/manager/sensitive-word/create', data);
}
/** ä¿®æ”¹æ•æ„Ÿè¯ */
export function updateManagerSensitiveWord(data: ImManagerSensitiveWordApi.SensitiveWord) {
  return requestClient.put<boolean>(
    '/im/manager/sensitive-word/update',
    data,
  );
}
/** åˆ é™¤æ•æ„Ÿè¯ */
export function deleteManagerSensitiveWord(id: number) {
  return requestClient.delete<boolean>('/im/manager/sensitive-word/delete', {
    params: { id },
  });
}
/** æ‰¹é‡åˆ é™¤æ•æ„Ÿè¯ */
export function deleteManagerSensitiveWordList(ids: number[]) {
  return requestClient.delete<boolean>(
    '/im/manager/sensitive-word/delete-list',
    { params: { ids: ids.join(',') } },
  );
}
src/api/im/manager/statistics/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
import { requestClient } from '#/api/request';
export namespace ImManagerStatisticsApi {
  /** ç»Ÿè®¡æ¦‚览 */
  export interface Overview {
    totalUser: number;
    newUserToday: number;
    totalGroup: number;
    newGroupToday: number;
    activeUserDaily: number;
    activeUserWeekly: number;
    activeUserMonthly: number;
    privateMessageToday: number;
    groupMessageToday: number;
    privateMessageYesterday: number;
    groupMessageYesterday: number;
  }
  /** è¶‹åŠ¿æ•°æ® */
  export interface Trend {
    dates: string[];
    series: Record<string, number[]>;
  }
  /** æ¶ˆæ¯ç±»åž‹åˆ†å¸ƒ */
  export interface MessageType {
    type: number; // å‚见 ImContentTypeEnum æžšä¸¾ç±»ï¼Œç”±å‰ç«¯æŒ‰ DICT_TYPE.IM_CONTENT_TYPE ç¿»è¯‘
    value: number;
  }
  /** ç¾¤è§„模分布 */
  export interface GroupSize {
    range: string;
    count: number;
  }
  /** æ¶ˆæ¯å‘送排行 */
  export interface TopSender {
    userId: number;
    nickname: string;
    messageCount: number;
  }
}
/** èŽ·å¾— KPI æ¦‚览 */
export function getStatisticsOverview() {
  return requestClient.get<ImManagerStatisticsApi.Overview>(
    '/im/manager/statistics/overview',
  );
}
/** èŽ·å¾—æ¶ˆæ¯è¶‹åŠ¿ï¼ˆç§èŠ + ç¾¤èŠåŒçº¿ï¼‰ */
export function getMessageTrend(days: number) {
  return requestClient.get<ImManagerStatisticsApi.Trend>(
    '/im/manager/statistics/message-trend',
    { params: { days } },
  );
}
/** èŽ·å¾—ç”¨æˆ·è¶‹åŠ¿ï¼ˆæ–°å¢žæ³¨å†Œ + æ—¥æ´»åŒçº¿ï¼‰ */
export function getUserTrend(days: number) {
  return requestClient.get<ImManagerStatisticsApi.Trend>(
    '/im/manager/statistics/user-trend',
    { params: { days } },
  );
}
/** èŽ·å¾—å†…å®¹ç±»åž‹åˆ†å¸ƒï¼ˆæœ€è¿‘ 30 å¤©ï¼‰ */
export function getMessageTypeDistribution() {
  return requestClient.get<ImManagerStatisticsApi.MessageType[]>(
    '/im/manager/statistics/message-type-distribution',
  );
}
/** èŽ·å¾—ç¾¤è§„æ¨¡åˆ†å¸ƒ */
export function getGroupSizeDistribution() {
  return requestClient.get<ImManagerStatisticsApi.GroupSize[]>(
    '/im/manager/statistics/group-size-distribution',
  );
}
/** èŽ·å¾—æ¶ˆæ¯ TOP å‘送者(最近 30 å¤©ï¼‰ */
export function getTopSenders() {
  return requestClient.get<ImManagerStatisticsApi.TopSender[]>(
    '/im/manager/statistics/top-senders',
  );
}
src/api/im/message/channel/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
import { requestClient } from '#/api/request';
export namespace ImChannelMessageApi {
  /** é¢‘道消息 */
  export interface ChannelMessageRespVO {
    id: number;
    clientMessageId?: string;
    channelId: number;
    materialId: number;
    type: number;
    content: string;
    receiptStatus?: number;
    sendTime: string;
  }
}
/** æ‹‰å–当前用户应收的频道消息(离线增量);按 minId æ¸¸æ ‡åˆ†é¡µ */
export function pullChannelMessageList(
  params: { minId: number; size?: number },
  signal?: AbortSignal,
) {
  return requestClient.get<ImChannelMessageApi.ChannelMessageRespVO[]>(
    '/im/channel/message/pull',
    { params, signal },
  );
}
/** ä¸ŠæŠ¥é¢‘道消息已读位置;切到频道会话或拉到新消息后调 */
export function readChannelMessages(channelId: number, messageId: number) {
  return requestClient.put<boolean>('/im/channel/message/read', undefined, {
    params: { channelId, messageId },
  });
}
src/api/im/message/group/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
import { requestClient } from '#/api/request';
export namespace ImGroupMessageApi {
  /** ç¾¤èŠæ¶ˆæ¯ Response VO */
  export interface GroupMessageRespVO {
    id: number; // æ¶ˆæ¯ç¼–号
    clientMessageId: string; // å®¢æˆ·ç«¯æ¶ˆæ¯ç¼–号
    senderId: number; // å‘送人编号
    groupId: number; // ç¾¤ç¼–号
    type: number; // å†…容类型
    content: string; // æ¶ˆæ¯å†…容(JSON æ ¼å¼ï¼‰
    status: number; // æ¶ˆæ¯çŠ¶æ€
    sendTime: string; // å‘送时间
    atUserIds?: number[]; // @ ç›®æ ‡ç”¨æˆ·ç¼–号列表
    receiverUserIds?: number[]; // å®šå‘接收用户编号列表
    receiptStatus?: number; // å›žæ‰§çŠ¶æ€
    readCount?: number; // å·²è¯»äººæ•°ï¼ˆå›žæ‰§æ¶ˆæ¯ã€ä¸”发送人为当前用户时有值)
  }
  /** ç¾¤èŠæ¶ˆæ¯å‘送 Request VO */
  export interface GroupMessageSendReqVO {
    clientMessageId: string; // å®¢æˆ·ç«¯æ¶ˆæ¯ç¼–号
    groupId: number; // ç¾¤ç¼–号
    type: number; // å†…容类型
    content: string; // æ¶ˆæ¯å†…容(JSON æ ¼å¼ï¼‰
    atUserIds?: number[]; // @ ç›®æ ‡ç”¨æˆ·ç¼–号列表
    receipt?: boolean; // æ˜¯å¦éœ€è¦å›žæ‰§
  }
  /** ç¾¤èŠåŽ†å²æ¶ˆæ¯åˆ—è¡¨ Request VO */
  export interface GroupMessageListReqVO {
    groupId: number | string; // ç¾¤ç¼–号
    maxId?: number | string; // èµ·å§‹æ¶ˆæ¯ç¼–号(不含),为空则从最新消息开始
    limit: number; // æ‹‰å–数量(1 ~ 200)
  }
}
/** å‘送群聊消息 */
export function sendGroupMessage(data: ImGroupMessageApi.GroupMessageSendReqVO) {
  return requestClient.post<ImGroupMessageApi.GroupMessageRespVO>(
    '/im/message/group/send',
    data,
  );
}
/** æ‹‰å–群聊消息(增量) */
export function pullGroupMessageList(
  params: { minId: number | string; size: number },
  signal?: AbortSignal,
) {
  return requestClient.get<ImGroupMessageApi.GroupMessageRespVO[]>(
    '/im/message/group/pull',
    { params, signal },
  );
}
/** æŸ¥è¯¢ç¾¤èŠåŽ†å²æ¶ˆæ¯ */
export function getGroupMessageList(params: ImGroupMessageApi.GroupMessageListReqVO) {
  return requestClient.get<ImGroupMessageApi.GroupMessageRespVO[]>(
    '/im/message/group/list',
    { params },
  );
}
/** æ ‡è®°ç¾¤èŠæ¶ˆæ¯å·²è¯» */
export function readGroupMessages(
  groupId: number | string,
  messageId: number | string,
) {
  return requestClient.put<boolean>('/im/message/group/read', undefined, {
    params: { groupId, messageId },
  });
}
/** æ’¤å›žç¾¤èŠæ¶ˆæ¯ */
export function recallGroupMessage(id: number | string) {
  return requestClient.delete<ImGroupMessageApi.GroupMessageRespVO>(
    '/im/message/group/recall',
    { params: { id } },
  );
}
/** èŽ·å–ç¾¤æ¶ˆæ¯å·²è¯»ç”¨æˆ·åˆ—è¡¨ */
export function getGroupReadUsers(params: {
  groupId: number | string;
  messageId: number | string;
}) {
  return requestClient.get<number[]>('/im/message/group/get-read-user-ids', {
    params,
  });
}
src/api/im/message/private/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,89 @@
import { requestClient } from '#/api/request';
export namespace ImPrivateMessageApi {
  /** ç§èŠæ¶ˆæ¯ Response VO */
  export interface PrivateMessageRespVO {
    id: number; // æ¶ˆæ¯ç¼–号
    clientMessageId: string; // å®¢æˆ·ç«¯æ¶ˆæ¯ç¼–号
    senderId: number; // å‘送人编号
    receiverId: number; // æŽ¥æ”¶äººç¼–号
    type: number; // å†…容类型
    content: string; // æ¶ˆæ¯å†…容(JSON æ ¼å¼ï¼‰
    status: number; // æ¶ˆæ¯çŠ¶æ€ï¼ˆæ­£å¸¸ / å·²æ’¤å›žï¼‰
    receiptStatus?: number; // å›žæ‰§çŠ¶æ€ï¼ˆä¸éœ€è¦ / å¾…完成 / å·²å®Œæˆï¼‰ï¼Œå¯¹é½ ImMessageReceiptStatus
    sendTime: string; // å‘送时间
  }
  /** ç§èŠæ¶ˆæ¯å‘送 Request VO */
  export interface PrivateMessageSendReqVO {
    clientMessageId: string; // å®¢æˆ·ç«¯æ¶ˆæ¯ç¼–号
    receiverId: number; // æŽ¥æ”¶äººç¼–号
    type: number; // å†…容类型
    content: string; // æ¶ˆæ¯å†…容(JSON æ ¼å¼ï¼‰
    receipt?: boolean; // æ˜¯å¦éœ€è¦å›žæ‰§ï¼›ä¸ä¼ åŽç«¯é»˜è®¤ true(普通私聊用户消息)
  }
  /** ç§èŠåŽ†å²æ¶ˆæ¯åˆ—è¡¨ Request VO */
  export interface PrivateMessageListReqVO {
    receiverId: number | string; // æŽ¥æ”¶äººç¼–号(对方)
    maxId?: number | string; // èµ·å§‹æ¶ˆæ¯ç¼–号(不含),为空则从最新消息开始
    limit: number; // æ‹‰å–数量(1 ~ 200)
  }
}
/** å‘送私聊消息 */
export function sendPrivateMessage(data: ImPrivateMessageApi.PrivateMessageSendReqVO) {
  return requestClient.post<ImPrivateMessageApi.PrivateMessageRespVO>(
    '/im/message/private/send',
    data,
  );
}
/** æ‹‰å–私聊消息(增量) */
export function pullPrivateMessageList(
  params: { minId: number | string; size: number },
  signal?: AbortSignal,
) {
  return requestClient.get<ImPrivateMessageApi.PrivateMessageRespVO[]>(
    '/im/message/private/pull',
    { params, signal },
  );
}
/** æŸ¥è¯¢ç§èŠåŽ†å²æ¶ˆæ¯ */
export function getPrivateMessageList(params: ImPrivateMessageApi.PrivateMessageListReqVO) {
  return requestClient.get<ImPrivateMessageApi.PrivateMessageRespVO[]>(
    '/im/message/private/list',
    { params },
  );
}
/** æ ‡è®°ç§èŠæ¶ˆæ¯å·²è¯» */
export function readPrivateMessages(
  receiverId: number | string,
  messageId: number | string,
) {
  return requestClient.put<boolean>('/im/message/private/read', undefined, {
    params: { receiverId, messageId },
  });
}
/** æŸ¥è¯¢å¯¹æ–¹å·²è¯»åˆ°æˆ‘发的最大消息 id(多端 / ç¦»çº¿åŽç”¨äºŽè¡¥é½å·²è¯»çŠ¶æ€ï¼‰ */
export function getPrivateMaxReadMessageId(
  peerId: number | string,
  signal?: AbortSignal,
) {
  return requestClient.get<null | number>(
    '/im/message/private/max-read-message-id',
    { params: { peerId }, signal },
  );
}
/** æ’¤å›žç§èŠæ¶ˆæ¯ */
export function recallPrivateMessage(id: number | string) {
  return requestClient.delete<ImPrivateMessageApi.PrivateMessageRespVO>(
    '/im/message/private/recall',
    { params: { id } },
  );
}
src/api/im/rtc/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,105 @@
import { requestClient } from '#/api/request';
export namespace ImRtcApi {
  /** åˆ›å»ºæ–°é€šè¯è¯·æ±‚ VO */
  export interface RtcCallCreateReqVO {
    conversationType: number;
    mediaType: number;
    groupId?: number;
    inviteeIds: number[]; // è¢«é‚€è¯·çš„用户编号集合;私聊必传 1 ä¸ªå¯¹ç«¯ï¼Œç¾¤èŠå¿…传至少 1 äºº
  }
  /** é€šè¯ä¸­è¿½åŠ é‚€è¯·è¯·æ±‚ VO;仅群通话可用 */
  export interface RtcCallInviteReqVO {
    room: string;
    inviteeIds: number[];
  }
  /** é€šè¯ä¼šè¯ VO;create / join / accept / refreshToken å…±ç”¨ */
  export interface RtcCallRespVO {
    room: string; // ä¸šåŠ¡é€šè¯ç¼–å·ï¼ˆåŒæ—¶ä½œä¸º LiveKit æˆ¿é—´åï¼‰
    livekitUrl: string;
    token?: string; // ENDED çŠ¶æ€æ—¶ä¸º null(无需 connect LiveKit)
    conversationType: number;
    mediaType: number;
    status: number;
    endReason?: number; // ç»“束原因;仅 status=ENDED æ—¶æœ‰å€¼
    inviterId: number;
    groupId?: number;
    inviteeIds?: number[];
    joinedUserIds?: number[];
  }
  /** ç¾¤æ´»è·ƒé€šè¯æŸ¥è¯¢å“åº”;不含 token */
  export interface RtcGroupCallRespVO {
    room: string;
    groupId: number;
    mediaType: number;
    inviterId: number;
    joinedUserIds?: number[];
    inviteeIds?: number[];
  }
}
/** åˆ›å»ºæ–°é€šè¯ï¼›ç§èŠæˆ–群聊根据 conversationType åŒºåˆ† */
export function createCall(data: ImRtcApi.RtcCallCreateReqVO) {
  return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/create', data);
}
/** é€šè¯ä¸­è¿½åŠ é‚€è¯·ï¼›ä»…ç¾¤é€šè¯å¯ç”¨ */
export function inviteCall(data: ImRtcApi.RtcCallInviteReqVO) {
  return requestClient.post<boolean>('/im/rtc/invite', data);
}
/** åŠ å…¥å·²æœ‰ç¾¤é€šè¯ï¼›ç”¨äºŽèƒ¶å›Šæ¡ã€ŒåŠ å…¥ã€æŒ‰é’® */
export function joinCall(room: string) {
  return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/join', undefined, {
    params: { room },
  });
}
/** æŽ¥å¬é€šè¯ */
export function acceptCall(room: string) {
  return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/accept', undefined, {
    params: { room },
  });
}
/** æ‹’绝通话 */
export function rejectCall(room: string) {
  return requestClient.post<boolean>('/im/rtc/reject', undefined, {
    params: { room },
  });
}
/** å–消邀请;主叫接通前调用 */
export function cancelCall(room: string) {
  return requestClient.post<boolean>('/im/rtc/cancel', undefined, {
    params: { room },
  });
}
/** ç¦»å¼€é€šè¯ï¼›æŽ¥é€šåŽè°ƒç”¨ */
export function leaveCall(room: string) {
  return requestClient.post<boolean>('/im/rtc/leave', undefined, {
    params: { room },
  });
}
/** æŒ¯é“ƒè¶…时检查;RUNNING ç«¯ timer å…œåº•,触发后端立即扫描该 room çš„è¶…æ—¶ INVITING(接口静默) */
export function noAnswerCallCheck(room: string) {
  return requestClient.post<boolean>(
    '/im/rtc/no-answer-call-check',
    undefined,
    { params: { room } },
  );
}
/** æŸ¥è¯¢å½“前进行中的通话;目前仅群聊场景(胶囊条),返回 null è¡¨ç¤ºæ— æ´»è·ƒé€šè¯ */
export function getActiveCall(groupId: number) {
  return requestClient.get<ImRtcApi.RtcGroupCallRespVO | null>(
    '/im/rtc/get-active-call',
    { params: { groupId } },
  );
}
src/router/routes/modules/im.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
  {
    path: '/im/home',
    name: 'ImHome',
    component: () => import('#/views/im/home/index.vue'),
    redirect: '/im/home/conversation',
    meta: {
      title: 'IM å³æ—¶é€šè®¯',
      hideInMenu: true,
      hideInTab: true,
      keepAlive: true,
      noBasicLayout: true,
    },
    children: [
      {
        path: 'conversation',
        name: 'ImHomeConversation',
        component: () => import('#/views/im/home/pages/conversation/index.vue'),
        meta: {
          title: '消息',
          activePath: '/im/home/conversation',
          hideInMenu: true,
          hideInTab: true,
        },
      },
      {
        path: 'contact',
        name: 'ImHomeContact',
        component: () => import('#/views/im/home/pages/contact/index.vue'),
        meta: {
          title: '通讯录',
          activePath: '/im/home/contact',
          hideInMenu: true,
          hideInTab: true,
        },
      },
    ],
  },
];
export default routes;
src/views/basicData/mdm/data.ts
@@ -173,6 +173,7 @@
      fieldName: 'isBatchManaged',
      label: '批次管理',
      componentProps: {
        class: 'w-auto',
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
@@ -203,6 +204,7 @@
      component: 'Textarea',
      fieldName: 'remark',
      label: '备注',
      formItemClass: 'col-span-2',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
src/views/basicData/mdm/index.vue
@@ -4,8 +4,8 @@
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { confirm, Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart, isEmpty } from '#/packages/utils/src';
import { message, Tag } from 'ant-design-vue';
src/views/basicData/mdm/modules/form.vue
@@ -3,7 +3,7 @@
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
@@ -28,12 +28,12 @@
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
const [Modal, modalApi] = useVbenModal({
src/views/im/home/components/card/card-bubble.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { isPrivateConversation } from '#/views/im/utils/constants'
import {
  type CardMessage,
  type CardTarget,
  getCardLabelInfo
} from '#/views/im/utils/message'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImCardBubble' })
const props = withDefaults(
  defineProps<{
    /** åç‰‡æ•°æ®ï¼›CardMessage(接收侧消息体)或 CardTarget(发送侧预览)共用结构 */
    card: CardMessage | CardTarget
    /** æ˜¯å¦æ˜¾ç¤º cursor: pointer;调用方负责绑 @click ç›‘听 */
    clickable?: boolean
  }>(),
  { clickable: false }
)
/** æ˜¯å¦ç”¨æˆ·åç‰‡ï¼šå†³å®š UserAvatar æ˜¯å¦å¸¦ id è§¦å‘ UserInfoCard */
const isUser = computed(() => isPrivateConversation(props.card.targetType))
/** åç‰‡æ ‡ç­¾ä¿¡æ¯ */
const labelInfo = computed(() => getCardLabelInfo(props.card))
</script>
<template>
  <!--
    åç‰‡æ¶ˆæ¯æ°”泡 / åç‰‡é¢„览卡(240px):用户名片 + ç¾¤åç‰‡é€šç”¨
    - å¤´åƒ + åå­— + ç¾¤æˆå‘˜æ•°å‰¯æ ‡é¢˜ï¼ˆä»…群名片)+ åº•部分隔条「群名片 / ä¸ªäººåç‰‡ã€
    - ç”¨æˆ·åç‰‡æŠŠ :id ä¼ ç»™ UserAvatar è®©ç‚¹å‡» avatar å¼¹ UserInfoCard;群名片不传 id
    - æ•´å¡ click ç”±è°ƒç”¨æ–¹ç›‘听(@click),组件不内嵌业务逻辑
  -->
  <div
    class="flex flex-col w-[240px] rounded-md overflow-hidden bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]"
    :class="{ 'cursor-pointer': clickable }"
  >
    <div class="flex gap-2.5 items-center px-3 py-2.5">
      <UserAvatar
        :id="isUser ? card.targetId : undefined"
        :url="card.avatar"
        :name="card.name"
        :size="40"
        :clickable="false"
      />
      <div class="flex flex-col flex-1 min-w-0">
        <div class="text-sm font-medium truncate text-[var(--ant-color-text)]">
          {{ card.name }}
        </div>
        <div
          v-if="!isUser && card.memberCount"
          class="text-12px truncate text-[var(--ant-color-text-placeholder)]"
        >
          {{ card.memberCount }} äººç¾¤èŠ
        </div>
      </div>
    </div>
    <div
      class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--im-border-color-lighter)] bg-[var(--ant-color-fill-tertiary)]"
    >
      {{ labelInfo.label }}
    </div>
  </div>
</template>
src/views/im/home/components/card/card-line-label.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { getCardLabelInfo } from '#/views/im/utils/message'
defineOptions({ name: 'ImCardLineLabel' })
const props = withDefaults(
  defineProps<{
    /** åç‰‡æ•°æ®ï¼›åªè¯» targetType / name æ´¾ç”Ÿæ ‡ç­¾ + æ˜¾ç¤ºï¼Œç»“构性类型兼容 CardMessage / å¼•用预览的 partial */
    card: null | undefined | { name?: string; targetType?: number; }
    iconSize?: number
  }>(),
  { iconSize: 14 }
)
/** æ ‡ç­¾ + å›¾æ ‡æŒ‰ targetType äºŒåˆ†ï¼›å…œåº•「个人名片」避免 null æ—¶ UI ç©ºç™½ */
const labelInfo = computed(() => getCardLabelInfo(props.card))
</script>
<template>
  <!-- åç‰‡å•行 inline label:「[icon] ç¾¤åç‰‡ / ä¸ªäººåç‰‡ï¼šxx」;列表摘要 / å¼•用预览 / åŽå°é¢„览复用 -->
  <span class="inline-flex gap-1.5 items-center">
    <Icon :icon="labelInfo.icon" :size="iconSize" />
    <span>{{ labelInfo.label }}:{{ card?.name || '' }}</span>
  </span>
</template>
src/views/im/home/components/card/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2 @@
export { default as CardBubble } from './card-bubble.vue';
export { default as CardLineLabel } from './card-line-label.vue';
src/views/im/home/components/context-menu.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,99 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useImUiStore } from '../store/uiStore'
defineOptions({ name: 'ImContextMenu' })
const uiStore = useImUiStore()
const contextMenu = computed(() => uiStore.contextMenu)
/**
 * è®¡ç®—菜单实际渲染坐标:靠近视口右 / ä¸‹è¾¹ç¼˜æ—¶å›žå¼¹ï¼Œé¿å…èœå•被裁剪
 *
 * itemHeight / menuWidth æ˜¯å’Œæ¨¡æ¿é‡Œ px-4 py-2 + text-13px / min-w-30 é…å¥—的实际尺寸;
 * dividerHeight = 9px(my-1 ä¸Šä¸‹å„ 4 + 1px border),仅非首项的 divided è®¡å…¥ï¼›
 * menuHeight é¢å¤–加 8 æ˜¯å¤–层 py-1 çš„上下 padding ä¹‹å’Œï¼ˆ4px Ã— 2)
 */
const adjustedPosition = computed(() => {
  const items = contextMenu.value.items
  const itemHeight = 34
  const dividerCount = items.filter((it, i) => it.divided && i > 0).length
  const menuHeight = items.length * itemHeight + dividerCount * 9 + 8
  const menuWidth = 120
  let x = contextMenu.value.position.x
  let y = contextMenu.value.position.y
  // SSR å…œåº•:window ä¸å¯ç”¨æ—¶ç›´æŽ¥è¿”回原始坐标
  if (typeof window !== 'undefined') {
    if (y + menuHeight > window.innerHeight) {
      y = window.innerHeight - menuHeight
    }
    if (x + menuWidth > window.innerWidth) {
      x = window.innerWidth - menuWidth
    }
  }
  // è§†å£å¾ˆå° / èœå•项很多时上面减法会算出负值,把菜单顶 / å·¦è¾¹æŽ¨åˆ° 0 å…œåº•
  return { x: Math.max(0, x), y: Math.max(0, y) }
})
type MenuItem = (typeof contextMenu.value.items)[number]
/** é€‰ä¸­èœå•项:disabled é¡¹å¿½ç•¥ï¼›æ­£å¸¸é¡¹è°ƒ onSelect å›žè°ƒåŽå…³é—­èœå• */
function handleSelect(item: MenuItem) {
  if (item.disabled) {
    return
  }
  uiStore.contextMenu.onSelect?.(item)
  uiStore.closeContextMenu()
}
/** å…³é—­èœå•:点遮罩 / åœ¨é®ç½©ä¸Šå†æ¬¡å³é”®éƒ½ä¼šè§¦å‘ */
function handleClose() {
  uiStore.closeContextMenu()
}
</script>
<template>
  <!--
    é€šç”¨å³é”®èœå•
    ç”± useImUiStore.openContextMenu(position, items, onSelect) è§¦å‘全局单例展示
    è°ƒç”¨æ–¹åœ¨ @contextmenu.prevent äº‹ä»¶é‡Œè°ƒ openContextMenu å³å¯ï¼Œä¸éœ€è¦è‡ªå·±æŒ‚组件
  -->
  <teleport to="body">
    <div
      v-if="contextMenu.show"
      class="fixed inset-0 z-9999"
      @click.stop="handleClose"
      @contextmenu.prevent="handleClose"
    >
      <div
        class="fixed min-w-30 py-1 bg-[var(--ant-color-bg-elevated)] rounded-md shadow-lg"
        :style="{ left: `${adjustedPosition.x }px`, top: `${adjustedPosition.y }px` }"
      >
        <template v-for="(item, index) in contextMenu.items" :key="item.key">
          <!-- divided é¡¹ä¸Šæ–¹æ’一条分割线(首项跳过,避免空白) -->
          <div
            v-if="item.divided && index > 0"
            class="my-1 mx-2 h-[1px] bg-[var(--ant-color-border-secondary)]"
          ></div>
          <div
            class="flex gap-2 items-center px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
            :class="[
              item.disabled
                ? '!text-[var(--ant-color-text-disabled)] cursor-not-allowed hover:!bg-transparent'
                : item.danger
                  ? 'text-[#f56c6c]'
                  : 'text-[var(--ant-color-text)]'
            ]"
            @click.stop="handleSelect(item)"
          >
            <Icon v-if="item.icon" :icon="item.icon" :size="14" />
            <span>{{ item.name }}</span>
          </div>
        </template>
      </div>
    </div>
  </teleport>
</template>
src/views/im/home/components/friend/friend-add-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,289 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useUserStore } from '#/packages/stores/src'
import { Button, Input, message, Modal, Spin } from 'ant-design-vue'
import { getSimpleUserListByNickname } from '#/api/system/user'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImFriendAddSource } from '../../../utils/constants'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendAddDialog' })
const visible = ref(false) // å¼¹çª—是否可见
const presetUser = ref<null | SystemUserApi.UserSimple>(null) // é¢„填目标用户:非 null æ—¶è·³è¿‡æœç´¢æ­¥éª¤ï¼Œç›´æŽ¥è¿›å…¥ç”³è¯·è¡¨å•(群成员加好友 / åç‰‡åŠ å¥½å‹ç­‰åœºæ™¯ï¼‰
const addSource = ref<number>(ImFriendAddSource.SEARCH) // æ·»åŠ æ¥æºï¼›å‚è§ ImFriendAddSourceEnum,默认 SEARCH
const addSourceExtra = ref<string>('') // æ¥æºé™„带信息:addSource=ImFriendAddSource.GROUP æ—¶ä¼ ç¾¤åï¼Œè¯æœ¯æ‹¼ä¸ºã€Œæˆ‘是 XX ç¾¤çš„ YY」
defineExpose({
  /** æ‰“开加好友弹窗:reset â†’ çŒå‚ â†’ visible=true;不传 opts èµ°æœç´¢æ¨¡å¼ */
  open(opts?: { addSource?: number; addSourceExtra?: string; presetUser?: null | SystemUserApi.UserSimple; }) {
    presetUser.value = opts?.presetUser ?? null
    addSource.value = opts?.addSource ?? ImFriendAddSource.SEARCH
    addSourceExtra.value = opts?.addSourceExtra ?? ''
    resetAll()
    visible.value = true
  }
})
const friendStore = useFriendStore()
const userStore = useUserStore()
/** å½“前登录用户编号;用 computed åŒ…一层,切账号后随 wsCache é‡å–,避免顶层求值在 keep-alive å®žä¾‹é‡ŒæŒæœ‰æ—§ id */
const currentUserId = computed(() => getCurrentUserId())
/** æœç´¢ç»“果过滤掉自己;用 v-if è€Œéž v-show,避免 DOM å ä½ + å¤´åƒæ— æ•ˆè¯·æ±‚ */
const visibleUsers = computed(() =>
  users.value.filter((user) => user.id !== currentUserId.value)
)
const keyword = ref('') // æœç´¢å…³é”®å­—
const users = ref<SystemUserApi.UserSimple[]>([]) // æœç´¢ç»“æžœ
const searched = ref(false) // æ˜¯å¦å·²æœç´¢
const loading = ref(false) // æœç´¢åŠ è½½ä¸­
const step = ref<'apply' | 'search'>('search') // å½“前步骤:search=搜索列表;apply=申请表单
const targetUser = ref<null | SystemUserApi.UserSimple>(null) // ç”³è¯·ç›®æ ‡ç”¨æˆ·
const applyContent = ref('') // ç”³è¯·ç†ç”±ï¼ˆé»˜è®¤å¡«ã€Œæˆ‘是 ${当前昵称}」,对齐微信交互)
const displayName = ref('') // å¯¹æŽ¥æ”¶æ–¹çš„备注(仅自己可见)
const submitting = ref(false) // æäº¤ä¸­
/** å¼¹çª—标题随步骤切换 */
const dialogTitle = computed(() => (step.value === 'apply' ? '申请添加朋友' : '添加好友'))
/** æ˜¯å¦é¢„填模式(presetUser ä¸ä¸ºç©º â†’ è·³è¿‡æœç´¢ï¼Œå…³é—­å³é”€æ¯ï¼Œæ— ã€Œå–消返回搜索」按钮) */
const presetMode = computed(() => !!presetUser.value)
function resetAll() {
  keyword.value = ''
  users.value = []
  searched.value = false
  // é¢„填模式:直接进申请表单,targetUser å–自 presetUser;申请理由按 addSource åŒºåˆ†è¯æœ¯
  if (presetUser.value) {
    targetUser.value = presetUser.value
    applyContent.value = buildPresetApplyContent()
    displayName.value = ''
    step.value = 'apply'
    return
  }
  // éžé¢„填模式:默认进搜索步骤
  step.value = 'search'
  targetUser.value = null
  applyContent.value = ''
  displayName.value = ''
}
/** é¢„填模式下的申请理由话术:群聊「我是"XX ç¾¤"的 YY」;其它「我是 YY」 */
function buildPresetApplyContent(): string {
  const myNickname = userStore.userInfo?.nickname || ''
  if (!myNickname) {
    return ''
  }
  // ç¾¤èŠåœºæ™¯æ‹¼å¸¦ç¾¤åçš„话术;其它场景默认「我是 YY」
  const groupExtra = addSource.value === ImFriendAddSource.GROUP ? addSourceExtra.value : ''
  return groupExtra ? `我是"${groupExtra}"的${myNickname}` : `我是${myNickname}`
}
/** æŒ‰æ˜µç§°æœç´¢ç”¨æˆ·ï¼šç©ºå…³é”®å­—直接清空结果 */
async function handleSearch() {
  searched.value = true
  if (!keyword.value.trim()) {
    users.value = []
    return
  }
  loading.value = true
  try {
    users.value = (await getSimpleUserListByNickname(keyword.value.trim())) || []
  } finally {
    loading.value = false
  }
}
/** è¿›å…¥ç”³è¯·æ­¥éª¤ï¼šé¢„填申请理由「我是 ${当前用户昵称}」(对齐微信交互) */
function enterApply(user: SystemUserApi.UserSimple) {
  targetUser.value = user
  const myNickname = userStore.userInfo?.nickname || ''
  applyContent.value = myNickname ? `我是${myNickname}` : ''
  displayName.value = ''
  step.value = 'apply'
}
/** å–消申请,回到搜索步骤 */
function backToSearch() {
  step.value = 'search'
  targetUser.value = null
}
/** æäº¤å¥½å‹ç”³è¯·ï¼šè¿”回 requestId èµ°ã€Œç­‰å¾…验证」;返回 null è¡¨ç¤ºåŽç«¯å‘½ä¸­ã€Œå•向好友静默重启」分支,已直接成为好友 */
async function handleSubmitApply() {
  if (!targetUser.value) {
    return
  }
  // é¢„校验:不能加自己(搜索列表已过滤,这里兜底 presetUser / åç‰‡å…¥å£ç­‰åœºæ™¯ï¼‰
  if (targetUser.value.id === currentUserId.value) {
    message.warning('不能添加自己为好友')
    return
  }
  submitting.value = true
  try {
    const requestId = await friendStore.applyFriendRequest({
      toUserId: targetUser.value.id,
      applyContent: applyContent.value.trim() || undefined,
      displayName: displayName.value.trim() || undefined,
      addSource: addSource.value
    })
    // silent åˆ†æ”¯ï¼ˆå·²æ˜¯å•向好友被静默重启):主动 fetchFriendInfo å…¥åº“,不依赖 WS FRIEND_ADD æŽ¨é€ï¼Œé¿å…ä¸¢æŽ¨æ—¶åˆ—表看不到
    if (requestId === null) {
      await friendStore.fetchFriendInfo(targetUser.value.id)
    }
    message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友')
    visible.value = false
  } catch {
    // ä¸šåŠ¡é”™è¯¯ï¼ˆå·²æ˜¯å¥½å‹ / è¢«å¯¹æ–¹æ‹‰é»‘ / ç”¨æˆ·è¢«ç¦ç”¨ ç­‰ï¼‰ï¼šå…¨å±€æ‹¦æˆªå™¨å·²å¼¹é”™è¯¯æç¤ºï¼Œæœ¬åœ°å…³å¼¹çª—避免脏状态停留
    visible.value = false
  } finally {
    submitting.value = false
  }
}
</script>
<template>
  <!--
    æ·»åŠ å¥½å‹å¯¹è¯æ¡†ï¼ˆåŒå±‚æµç¨‹ï¼‰
    - ç¬¬ä¸€å±‚ search:按昵称搜索用户列表
    - ç¬¬äºŒå±‚ apply:选中用户后展开「申请添加朋友」表单(申请理由 + å¤‡æ³¨ï¼‰
  -->
  <Modal
    v-model:open="visible"
    :title="dialogTitle"
    width="480px"
    :mask-closable="false"
    :footer="step === 'apply' ? undefined : null"
  >
    <!-- ç¬¬ä¸€å±‚:搜索 + ç”¨æˆ·åˆ—表 -->
    <template v-if="step === 'search'">
      <Input
        v-model:value="keyword"
        placeholder="输入昵称回车搜索(最多展示 20 æ¡ï¼‰"
        allow-clear
        @keyup.enter="handleSearch"
      >
        <template #suffix>
          <Icon icon="ant-design:search-outlined" class="cursor-pointer" @click="handleSearch" />
        </template>
      </Input>
      <Spin :spinning="loading" wrapper-class-name="w-full">
        <div class="h-[400px] mt-2.5">
          <div
            v-if="visibleUsers.length === 0"
            class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
          >
            {{ searched ? '没有搜到用户' : '输入关键字后回车开始搜索' }}
          </div>
          <div
            v-for="user in visibleUsers"
            :key="user.id"
            class="flex gap-3 items-center px-2 py-2.5 border-b border-b-solid border-[var(--ant-color-border-secondary)]"
          >
            <UserAvatar
              :id="user.id"
              :url="user.avatar"
              :name="user.nickname"
              :size="42"
              :clickable="false"
            />
            <div class="flex-1 min-w-0 overflow-hidden">
              <!-- æ˜µç§° + æ€§åˆ«å›¾æ ‡ -->
              <div
                class="flex items-center gap-1 text-sm font-semibold text-[var(--ant-color-text)]"
              >
                <span class="truncate">{{ user.nickname }}</span>
                <Icon
                  v-if="getGenderIcon(user.sex)"
                  :icon="getGenderIcon(user.sex)"
                  :size="14"
                  :color="getGenderColor(user.sex)"
                  class="flex-shrink-0"
                />
              </div>
              <!-- éƒ¨é—¨ -->
              <div
                v-if="user.deptName"
                class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]"
              >
                {{ user.deptName }}
              </div>
            </div>
            <!-- å·²æ˜¯å¥½å‹æ˜¾ç¤ºã€Œå·²æ·»åŠ ã€ï¼›å¦åˆ™æ˜¾ç¤ºã€Œæ·»åŠ ã€ï¼ˆç‚¹å‡»è¿›å…¥ apply æ­¥éª¤ï¼‰ -->
            <Button
              v-if="!friendStore.isActiveFriend(user.id)"
              type="primary"
              size="small"
              @click="enterApply(user)"
            >
              æ·»åŠ 
            </Button>
            <Button v-else size="small" disabled>已添加</Button>
          </div>
        </div>
      </Spin>
    </template>
    <!-- ç¬¬äºŒå±‚:申请表单(对齐微信「申请添加朋友」) -->
    <template v-if="step === 'apply' && targetUser">
      <!-- é€‰ä¸­çš„用户卡片 -->
      <div
        class="flex gap-3 items-center px-2 py-3 mb-4 rounded-md bg-[var(--ant-color-fill-secondary)]"
      >
        <UserAvatar
          :id="targetUser.id"
          :url="targetUser.avatar"
          :name="targetUser.nickname"
          :size="42"
          :clickable="false"
        />
        <div class="flex-1 min-w-0 overflow-hidden">
          <div class="text-sm font-semibold text-[var(--ant-color-text)] truncate">
            {{ targetUser.nickname }}
          </div>
          <div
            v-if="targetUser.deptName"
            class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]"
          >
            {{ targetUser.deptName }}
          </div>
        </div>
      </div>
      <div class="text-13px text-[var(--ant-color-text-secondary)] mb-1.5">发送添加朋友申请</div>
      <Input.TextArea
        v-model:value="applyContent"
        :rows="3"
        :maxlength="255"
        show-count
        placeholder="请填写申请理由"
      />
      <div class="text-13px text-[var(--ant-color-text-secondary)] mt-3 mb-1.5">备注</div>
      <Input
        v-model:value="displayName"
        :maxlength="16"
        placeholder="给对方起个备注(仅自己可见,可不填)"
      />
    </template>
    <!-- ä»…在 apply æ­¥éª¤æ˜¾ç¤º footer æ“ä½œæŒ‰é’®ï¼ˆslot å¿…须是 el-dialog ç›´æŽ¥å­èŠ‚ç‚¹ï¼‰ -->
    <template v-if="step === 'apply'" #footer>
      <!-- é¢„填模式无搜索步骤,「取消」直接关闭弹窗 -->
      <Button @click="presetMode ? (visible = false) : backToSearch()">取消</Button>
      <Button type="primary" :loading="submitting" @click="handleSubmitApply"> ç¡®å®š </Button>
    </template>
  </Modal>
</template>
src/views/im/home/components/friend/friend-item.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { useImUiStore } from '../../store/uiStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendItem' })
const props = withDefaults(
  defineProps<{
    active?: boolean
    friend: FriendLite
    menu?: boolean // æ˜¯å¦å¯ç”¨å³é”®èœå•;在选择器弹窗里一般关闭
  }>(),
  {
    active: false,
    menu: true
  }
)
const emit = defineEmits<{
  chat: [friend: FriendLite]
  click: [friend: FriendLite]
  delete: [friend: FriendLite]
}>()
const uiStore = useImUiStore()
/** å³é”®èœå•:发送消息 / åˆ é™¤å¥½å‹ */
function handleContextMenu(event: MouseEvent) {
  if (!props.menu) {
    return
  }
  uiStore.openContextMenu(
    { x: event.clientX, y: event.clientY },
    [
      { key: 'chat', name: '发送消息' },
      { key: 'delete', name: '删除好友' }
    ],
    (item) => {
      if (item.key === 'chat') emit('chat', props.friend)
      else if (item.key === 'delete') emit('delete', props.friend)
    }
  )
}
</script>
<template>
  <!--
    å¥½å‹å•行项
    - å¤´åƒ + æ˜µç§°
    - é€‰ä¸­æ€ active
    - å³é”®èœå•(发消息 / åˆ é™¤å¥½å‹ï¼‰ç”±å…¨å±€ ContextMenu æ‰¿æŽ¥
  -->
  <div
    class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
    :class="{ '!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': active }"
    @click="$emit('click', friend)"
    @contextmenu.prevent="handleContextMenu"
  >
    <!-- prefix slot:放在头像前,给选择类弹窗的 checkbox / åœ†ç‚¹ç”¨ï¼Œä¸ä¼ åˆ™ä¸æ¸²æŸ“ -->
    <slot name="prefix"></slot>
    <!-- å¤´åƒ -->
    <UserAvatar
      :id="friend.id"
      :url="friend.avatar"
      :name="friend.nickname"
      :size="42"
      :clickable="false"
    />
    <!-- å•行展示 displayName ä¼˜å…ˆï¼›æ˜µç§°ä»…在好友详情面板展示,列表里不重复 -->
    <div class="flex flex-1 min-w-0">
      <div class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
        {{ friend.displayName || friend.nickname }}
      </div>
    </div>
    <slot></slot>
  </div>
</template>
src/views/im/home/components/friend/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2 @@
export { default as FriendAddDialog } from './friend-add-dialog.vue';
export { default as FriendItem } from './friend-item.vue';
src/views/im/home/components/group/group-admin-set-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,122 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { ref } from 'vue'
import { Button, message, Modal } from 'ant-design-vue'
import { addGroupAdmin, removeGroupAdmin } from '#/api/im/group'
import { GROUP_ADMIN_MAX_COUNT } from '#/views/im/utils/config'
import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupAdminSetDialog' })
const emit = defineEmits<{
  /** ç®¡ç†å‘˜å˜æ›´æˆåŠŸï¼›çˆ¶ä¾§é€šå¸¸ç”¨æ¥ reload ç¾¤æ•°æ® */
  reload: []
}>()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
const currentAdminIds = ref<number[]>([]) // å½“前管理员 userId åˆ—表:默认勾选 + æäº¤æ—¶ diff
const hideIds = ref<number[]>([])
const maxSize = ref(GROUP_ADMIN_MAX_COUNT)
const selectedIds = ref<number[]>([])
defineExpose({
  /** æ‰“开设置管理员弹窗:reset â†’ çŒå‚ â†’ visible=true */
  open(opts: {
    /** å½“前管理员 userId åˆ—表(默认勾选) */
    currentAdminIds: number[]
    groupId: number
    /** éšè— userId(群主) */
    hideIds?: number[]
    /** å·²é€‰æ•°ä¸Šé™ï¼›ä¸ä¼ èµ° GROUP_ADMIN_MAX_COUNT */
    maxSize?: number
    members: GroupMemberLite[]
  }) {
    groupId.value = opts.groupId
    members.value = opts.members
    currentAdminIds.value = [...opts.currentAdminIds]
    hideIds.value = opts.hideIds ? [...opts.hideIds] : []
    maxSize.value = opts.maxSize ?? GROUP_ADMIN_MAX_COUNT
    selectedIds.value = [...opts.currentAdminIds]
    submitting.value = false
    visible.value = true
  }
})
/** è·Ÿå½“前管理员列表做差集,分别拿到要新增 / æ’¤é”€çš„ userId */
async function handleOk() {
  if (!groupId.value) {
    return
  }
  const previousIds = currentAdminIds.value
  const previousIdSet = new Set(previousIds)
  const nextIds = selectedIds.value
  const nextIdSet = new Set(nextIds)
  const addedIds = nextIds.filter((id) => !previousIdSet.has(id))
  const removedIds = previousIds.filter((id) => !nextIdSet.has(id))
  if (addedIds.length === 0 && removedIds.length === 0) {
    visible.value = false
    return
  }
  submitting.value = true
  try {
    if (addedIds.length > 0) {
      await addGroupAdmin({ id: groupId.value, userIds: addedIds })
    }
    if (removedIds.length > 0) {
      await removeGroupAdmin({ id: groupId.value, userIds: removedIds })
    }
    message.success(`已更新群管理员(新增 ${addedIds.length} ä½ï¼Œæ’¤é”€ ${removedIds.length} ä½ï¼‰`)
    emit('reload')
    visible.value = false
  } finally {
    submitting.value = false
  }
}
</script>
<template>
  <!--
    è®¾ç½®ç¾¤ç®¡ç†å‘˜ï¼šä¸€ä¸ªå¼¹çª—合并增 / åˆ ï¼Œæäº¤æ—¶è·Ÿå½“前管理员列表 diff
    - dialog å£³æœ¬ç»„件持有;选择 UI å§”托 GroupMemberPickerPanel(grid å½¢æ€å¯¹é½å½“前视觉)
    - ç¾¤ä¸»ä»Žå€™é€‰é‡Œéšè—ï¼ˆä¸èƒ½è®¾ä¸ºç®¡ç†å‘˜ï¼‰
    - å¯¹å¤–接口:ref + open({ groupId, members, currentAdminIds, hideIds, maxSize }) + emit reload()
  -->
  <Modal
    v-model:open="visible"
    title="设置群管理员"
    width="700px"
    :mask-closable="false"
    class="im-picker-dialog"
  >
    <div class="h-[480px]">
      <GroupMemberPickerPanel
        v-model:selected-ids="selectedIds"
        :members="members"
        :hide-ids="hideIds"
        :max-size="maxSize"
        selected-display="grid"
      />
    </div>
    <template #footer>
      <Button @click="visible = false">取消</Button>
      <Button type="primary" :loading="submitting" @click="handleOk">确定</Button>
    </template>
  </Modal>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep ç©¿é€ el-dialog å†…部类;复用 picker å…¬å…± mixin */
.im-picker-dialog {
  @include picker.styles;
}
</style>
src/views/im/home/components/group/group-avatar.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,131 @@
<script lang="ts" setup>
import type { GroupMember } from '../../types'
import { computed, ref, watch } from 'vue'
import {
  buildGroupAvatar,
  getCachedGroupAvatar,
  setCachedGroupAvatar
} from '../../../utils/group'
import { getMemberDisplayName } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupAvatar' })
const props = withDefaults(
  defineProps<{
    clickable?: boolean // æ˜¯å¦å¯ç‚¹å‡»ï¼ˆé»˜è®¤ false,列表里仅展示)
    groupId: number // ç¾¤ç¼–号;用于查 store æ‹¿æˆå‘˜å¤´åƒ
    name?: string // ç¾¤åï¼›è‰²å¡å…œåº•文字
    previewable?: boolean // æ˜¯å¦ç‚¹å¤´åƒç›´æŽ¥æ”¾å¤§é¢„览(群详情大头像位用)
    previewZIndex?: number // é¢„览层 z-index
    radius?: string // åœ†è§’,CSS é•¿åº¦
    size?: number // å°ºå¯¸ï¼ˆpx),正方形
    url?: string // æœåŠ¡ç«¯å·²è®¾ç½®çš„ç¾¤å¤´åƒ URL;非空则直接用,不拼图
  }>(),
  {
    clickable: false,
    name: '',
    previewable: false,
    previewZIndex: 2000,
    radius: '15%',
    size: 42,
    url: ''
  }
)
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const mergedUrl = ref('')
// ç«žæ€ä¿æŠ¤ï¼šä¸¢å¼ƒè¿‡æœŸ await ç»“æžœ
let mergeToken = 0
/** æŒ‰å®¹å™¨ size Ã— DPR ç®— canvas å®žé™…像素,避免 2x / 3x retina å±æ‹¼å›¾ç³Šï¼›DPR å°é¡¶ 3 é˜²æ­¢è¶…高分辨率画布过大 */
function getTargetSize(size: number): number {
  const dpr = Math.min(window.devicePixelRatio || 1, 3)
  return Math.max(Math.round(size * dpr), 64)
}
/** store é‡Œæ•´ç¾¤æˆå‘˜æ˜¯å¦ã€Œå®Œæ•´åŠ è½½ã€è¿‡ï¼›åªåœ¨ä¸º true æ—¶æ‰æ‹¼å›¾ï¼Œé¿å…åˆ—表场景批量发接口 */
const loadedMembers = computed<GroupMember[] | null>(() => {
  const g = groupStore.getGroup(props.groupId)
  if (!g?.membersLoaded || !g.members) {
    return null
  }
  return g.members
})
/** å‰ 9 ä¸ªæˆå‘˜çš„æ‹¼å›¾å…¥å‚ï¼›name èµ° getMemberDisplayName å£å¾„(好友备注 > ç¾¤æ˜µç§° > çœŸå®žæ˜µç§°ï¼‰ */
const memberItems = computed(() => {
  const members = loadedMembers.value
  if (!members) {
    return []
  }
  return members.slice(0, 9).map((m) => ({
    avatar: m.avatar || '',
    name: getMemberDisplayName(m, friendStore.getFriend(m.userId))
  }))
})
/** æˆå‘˜å¿«ç…§ç­¾åï¼šæ‹¼ (avatar, name) å­—段,原地修改任一字段都会让 watch é‡ç®— */
const memberSignature = computed(() =>
  memberItems.value.map((it) => `${it.avatar}#${it.name}`).join('|')
)
/** èµ° buildGroupAvatar æ‹¼å›¾å¹¶å†™å›ž mergedUrl;mergeToken æ ¡éªŒé¿å…è€ await è¦†ç›–新结果 */
async function applyMerge(key: string, targetSize: number): Promise<void> {
  const myToken = ++mergeToken
  const cached = getCachedGroupAvatar(key)
  if (cached) {
    mergedUrl.value = cached
    return
  }
  const dataUrl = await buildGroupAvatar(memberItems.value, { targetSize })
  if (myToken !== mergeToken) {
    return
  }
  if (dataUrl) {
    setCachedGroupAvatar(key, dataUrl)
  }
  mergedUrl.value = dataUrl
}
watch(
  () => [props.url, props.groupId, props.size, memberSignature.value] as const,
  ([url, groupId, size, signature]) => {
    if (url) {
      mergedUrl.value = ''
      return
    }
    if (!signature) {
      mergeToken++
      mergedUrl.value = ''
      groupStore.loadGroupMemberList(groupId)
      return
    }
    const targetSize = getTargetSize(size)
    const key = `${groupId}:${targetSize}:${signature}`
    applyMerge(key, targetSize)
  },
  { immediate: true }
)
/** æœ€ç»ˆå±•示 url:服务端 url ä¼˜å…ˆ â†’ æ‹¼å›¾ â†’ ç©ºå­—符串(让 UserAvatar èµ°è‰²å¡ï¼‰ */
const finalUrl = computed(() => props.url || mergedUrl.value)
</script>
<template>
  <!-- url éžç©ºèµ°åŽŸå›¾ï¼›url ç©ºæ—¶å–前 9 ä¸ªæˆå‘˜å¤´åƒæ‹¼ä¹å®«æ ¼ dataURL,成员未在 store ç¼“存时走色卡兜底 -->
  <UserAvatar
    :url="finalUrl"
    :name="name"
    :size="size"
    :radius="radius"
    :clickable="clickable"
    :previewable="previewable"
    :preview-z-index="previewZIndex"
  />
</template>
src/views/im/home/components/group/group-create-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,144 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { computed, ref } from 'vue'
import { Button, message, Modal } from 'ant-design-vue'
import { createGroup } from '#/api/im/group'
import { buildDefaultGroupName } from '../../../utils/group'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupCreateDialog' })
const emit = defineEmits<{
  /** åˆ›å»ºæˆåŠŸï¼Œæºå¸¦æ–°ç¾¤ç¼–å·ï¼›çˆ¶ä¾§é€šå¸¸ç”¨æ¥è·³è½¬åˆ°æ–°ç¾¤ä¼šè¯ */
  created: [groupId: number]
}>()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const visible = ref(false)
const submitting = ref(false)
const lockedIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
defineExpose({
  /** æ‰“开发起群聊弹窗:reset â†’ çŒå‚ â†’ visible=true */
  open(opts?: { lockedIds?: number[] }) {
    lockedIds.value = opts?.lockedIds ? [...opts.lockedIds] : []
    selectedIds.value = []
    submitting.value = false
    visible.value = true
  }
})
/** å…¨é‡å¥½å‹ï¼šç›´æŽ¥å¤ç”¨ friendStore Lite è§†å›¾ï¼ˆå¸¦æ‹¼éŸ³å­—段供分桶用) */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** å®ŒæˆæŒ‰é’®å¯ç‚¹ï¼šè‡³å°‘有 1 ä¸ªéž locked å‹¾é€‰ï¼ˆlocked æ˜¯å…¥å£é”å®šé¡¹ï¼Œä¸ç®—"用户主动选择") */
const canSubmit = computed(() => selectedIds.value.length > 0)
/** æ‹¿åˆ°æ‰€æœ‰è¦è¿›ç¾¤çš„好友(locked + selected);建群默认群名按这批人生成 */
function resolveMembersToInvite(): FriendLite[] {
  const seen = new Set<number>()
  const result: FriendLite[] = []
  const byId = new Map(friends.value.map((f) => [f.id, f]))
  for (const id of lockedIds.value) {
    if (seen.has(id)) {
      continue
    }
    const friend = byId.get(id)
    if (friend) {
      seen.add(id)
      result.push(friend)
    }
  }
  for (const id of selectedIds.value) {
    if (seen.has(id)) {
      continue
    }
    const friend = byId.get(id)
    if (friend) {
      seen.add(id)
      result.push(friend)
    }
  }
  return result
}
/** åˆ›å»ºç¾¤èŠï¼šå»ºç¾¤ï¼ˆåŒæ—¶é‚€è¯·åˆå§‹æˆå‘˜ï¼‰â†’ upsert groupStore â†’ emit created è®©çˆ¶é¡µè·³è½¬æ–°ä¼šè¯ */
async function handleOk() {
  const members = resolveMembersToInvite()
  if (members.length === 0) {
    return
  }
  submitting.value = true
  try {
    const memberUserIds = members.map((m) => m.id)
    const name = buildDefaultGroupName(members)
    const group = await createGroup({ name, memberUserIds, joinApproval: false })
    if (!group?.id) {
      throw new Error('创建群失败:未返回群编号')
    }
    // ç›´æŽ¥ upsert è¿› groupStore,省一次 fetchGroupList â€”— æœåŠ¡ç«¯è¿”å›ž VO å·²ç»å¤Ÿå»ºä¼šè¯äº†
    groupStore.upsertGroup({
      id: group.id,
      name: group.name,
      avatar: group.avatar,
      notice: group.notice,
      ownerUserId: group.ownerUserId
    })
    message.success('群聊创建成功')
    emit('created', group.id)
    visible.value = false
  } finally {
    submitting.value = false
  }
}
</script>
<template>
  <!--
    å‘起群聊:选好友 â†’ é»˜è®¤æŒ‰æ‰€é€‰æˆå‘˜åç”Ÿæˆç¾¤å â†’ createGroup
    - dialog å£³æœ¬ç»„件持有;选择 UI å§”托 FriendPickerPanel
    - lockedIds ç”±è°ƒç”¨æ–¹é€šè¿‡ open({ lockedIds }) ä¼ å…¥ï¼›ç§èŠä¾§ +建群锁定对方
    - ä¸å†è¦æ±‚先输入群名 / ä¸å†å±•示「进群审批」开关,对齐微信 PC
    - å¯¹å¤–接口:ref + open({ lockedIds }) + emit created(groupId)
  -->
  <Modal
    v-model:open="visible"
    title="发起群聊"
    width="720px"
    :mask-closable="false"
    class="im-picker-dialog"
  >
    <div class="h-[480px]">
      <FriendPickerPanel
        v-model:selected-ids="selectedIds"
        :friends="friends"
        :locked-ids="lockedIds"
      />
    </div>
    <template #footer>
      <Button @click="visible = false">取消</Button>
      <Button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
        å®Œæˆ
      </Button>
    </template>
  </Modal>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep ç©¿é€ el-dialog å†…部类;复用 picker å…¬å…± mixin */
.im-picker-dialog {
  @include picker.styles;
}
</style>
src/views/im/home/components/group/group-info-card.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { GroupLite } from '../../types'
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { prompt } from '#/packages/effects/common-ui/src'
import { Input, message } from 'ant-design-vue'
import { applyJoinGroup } from '#/api/im/group/request'
import { ImConversationType, ImGroupAddSource } from '../../../utils/constants'
import { getGroupDisplayName } from '../../../utils/user'
import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore'
import GroupInfo from './group-info.vue'
defineOptions({ name: 'ImGroupInfoCard' })
const uiStore = useImUiStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const router = useRouter()
const card = computed(() => uiStore.groupInfoCard)
/** å…³é—­æµ®å±‚ */
function handleClose() {
  uiStore.closeGroupInfoCard()
}
/** Esc å…³é—­ */
function handleKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && card.value.show) {
    handleClose()
  }
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
/** è¿›å…¥ç¾¤èŠï¼šå–本地最新群信息(含 silent / ç¾¤å¤‡æ³¨ï¼‰ï¼Œæ–°å»ºæˆ–激活会话 + è·³è·¯ç”± */
function handleChat(group: GroupLite) {
  const cached = groupStore.getGroup(group.id)
  // cached å‘½ä¸­èµ° getGroupDisplayName è®©ç¾¤å¤‡æ³¨ä¼˜å…ˆï¼ˆä¸Ž contact / ä¼šè¯åˆ—表的展示名一致);缺 cached æ—¶å›žè½ showGroupName / åŽŸç¾¤å
  const displayName = cached
    ? getGroupDisplayName(cached)
    : group.showGroupName || group.name || ''
  // æ‰“开或新建会话
  conversationStore.openConversation(
    group.id,
    ImConversationType.GROUP,
    displayName,
    cached?.avatar || group.showImage || '',
    { silent: !!cached?.silent }
  )
  // å¦‚果不在会话页,先跳过去(如果在了,MessagePanel ä¼šè‡ªå·±æ„ŸçŸ¥ä¼šè¯å˜åŒ–刷新)
  if (router.currentRoute.value.name !== 'ImHomeConversation') {
    router.push({ name: 'ImHomeConversation' })
  }
  handleClose()
}
/** åŠ å…¥ç¾¤èŠï¼šå…ˆå…³æµ®å±‚ï¼ˆé¿å…ä¸Ž prompt çš„ mask äº’相遮挡)→ å¼¹ç”³è¯·ç†ç”±ï¼ˆå¯é€‰ï¼‰â†’ applyJoinGroup */
async function handleApply(group: GroupLite) {
  handleClose()
  let applyContent: string
  try {
    const result = await prompt<string>({
      cancelText: '取消',
      component: Input,
      componentProps: {
        allowClear: true,
        placeholder: '请填写验证消息(可选)'
      },
      content: '',
      defaultValue: '',
      confirmText: '发送申请',
      modelPropName: 'value',
      title: `申请加入「${group.name || ''}」`
    })
    applyContent = (result || '').trim()
  } catch {
    return
  }
  await applyJoinGroup({
    groupId: group.id,
    applyContent: applyContent || undefined,
    addSource: ImGroupAddSource.SHARE_LINK
  })
  message.success('加群申请已发送')
}
</script>
<template>
  <!--
    ç¾¤ä¿¡æ¯æµ®å±‚(与 UserInfoCard å¯¹ä½ï¼‰
    - ä»…承担「浮层定位 + å…³é—­é€»è¾‘(点遮罩 / Esc)」,群信息视觉走 <GroupInfo>,与通讯录详情共用一份组件
    - è§¦å‘:useImUiStore.openGroupInfoCardAtEvent(group, e)
    - GroupInfo å†…部按 groupStore ç¼“存推导 member / stranger,浮层只负责接 chat / apply äº‹ä»¶åšä¸šåŠ¡
  -->
  <teleport to="body">
    <div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose">
      <div
        class="fixed w-80 p-4 bg-[var(--ant-color-bg-elevated)] rounded-md shadow-xl"
        :style="{ left: `${card.position.x }px`, top: `${card.position.y }px` }"
        @click.stop
      >
        <GroupInfo v-if="card.group" :group="card.group" @chat="handleChat" @apply="handleApply" />
      </div>
    </div>
  </teleport>
</template>
src/views/im/home/components/group/group-info.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,154 @@
<script lang="ts" setup>
import type { Friend, GroupLite, GroupMember } from '../../types'
import type { GroupMemberLite } from './group-member.vue'
import { computed, ref, watch } from 'vue'
import { CommonStatusEnum } from '#/packages/constants/src'
import { Button } from 'ant-design-vue'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { getMemberDisplayName, isGroupQuit } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import GroupAvatar from './group-avatar.vue'
import GroupMemberGrid from './group-member-grid.vue'
defineOptions({ name: 'ImGroupInfo' })
const props = defineProps<{
  group: GroupLite
}>()
const emit = defineEmits<{
  /** stranger ç‚¹ã€ŒåŠ å…¥ç¾¤èŠã€ï¼›çˆ¶çº§è´Ÿè´£å¼¹ç”³è¯·ç†ç”± + è°ƒ applyJoinGroup */
  apply: [group: GroupLite]
  /** member ç‚¹ã€Œè¿›å…¥ç¾¤èŠã€ï¼›çˆ¶çº§è´Ÿè´£åˆ‡ä¼šè¯ + å…³æµ®å±‚ */
  chat: [group: GroupLite]
}>()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const members = ref<GroupMemberLite[]>([])
/**
 * æ˜¯å¦å·²åŠ ç¾¤ï¼šåŸºäºŽ"自己确实在成员列表里"判断
 * - ç¼“存未命中:直接 false(陌生群)
 * - å‘½ä¸­ä¸” members å·²æ‹‰ï¼šç²¾å‡†æŸ¥ self.userId åœ¨ä¸åœ¨
 * - å‘½ä¸­ä½† members æœªæ‹‰ï¼šfetchGroupList æŽ¥å£è¯­ä¹‰å³ã€Œæˆ‘加入的群」,命中视为 member(拉成员后会自动收敛)
 */
const isMember = computed(() => {
  if (!props.group?.id) {
    return false
  }
  const cached = groupStore.getGroup(props.group.id)
  if (!cached) {
    return false
  }
  // åŽ†å²é€€ç¾¤ç¾¤ï¼šç›´æŽ¥åˆ¤ false,避免成员未加载时误显示「进入群聊」
  if (isGroupQuit(cached)) {
    return false
  }
  if (cached.membersLoaded && cached.members) {
    const myId = getCurrentUserId()
    return cached.members.some((m) => m.userId === myId && m.status === CommonStatusEnum.ENABLE)
  }
  return true
})
/** åŽ†å²é€€ç¾¤ç¾¤ï¼šåªè¯»ï¼ŒåŠ¨ä½œåŒºä¸¤ä¸ªæŒ‰é’®éƒ½ä¸æ¸²æŸ“ï¼ˆæ—¢ä¸ã€Œè¿›å…¥ç¾¤èŠã€ä¹Ÿä¸ã€ŒåŠ å…¥ç¾¤èŠã€ï¼‰ */
const isQuitGroup = computed(() => {
  const id = props.group?.id
  return id != null && isGroupQuit(groupStore.getGroup(id))
})
/** æ˜¯å¦æœªåŠ ç¾¤ï¼šæœ‰ id、非成员、且非历史退群群;只有真·陌生人才给「加入群聊」 */
const isStranger = computed(() => !!props.group?.id && !isMember.value && !isQuitGroup.value)
/** æˆå‘˜æ•°æ–‡æ¡ˆï¼šmember ä¼˜å…ˆç”¨æœ¬åœ°æ‹‰åˆ°çš„列表长度,stranger ç”¨ props.group.memberCount å¡ç‰‡å¿«ç…§ */
const memberCountText = computed(() => {
  const count = isMember.value
    ? props.group.memberCount || members.value.length
    : props.group.memberCount
  return count ? `${count} ä½æˆå‘˜` : ''
})
/** member åˆ‡ç¾¤ / é¦–挂:拉成员;竞态用 group.id æ¯”对丢弃陈旧响应避免上一条群成员错位 */
watch(
  () => [props.group?.id, isMember.value] as const,
  async ([id, member]) => {
    members.value = []
    if (!id || !member) {
      return
    }
    const list = await groupStore.fetchGroupMemberList(id, true)
    if (props.group?.id !== id) {
      return
    }
    members.value = list.map((m) => convertGroupMemberLite(m, friendStore.getFriend(m.userId)))
  },
  { immediate: true }
)
/** ç¾¤æˆå‘˜ â†’ åˆ—表项 */
function convertGroupMemberLite(member: GroupMember, friend: Friend | undefined): GroupMemberLite {
  return {
    userId: member.userId,
    showName: getMemberDisplayName(member, friend),
    nickname: member.nickname,
    avatar: member.avatar,
    status: member.status,
    role: member.role
  }
}
</script>
<template>
  <!--
    ç¾¤ä¿¡æ¯å†…容组件(与 UserInfo å¯¹ä½ï¼‰
    - å¤´åƒ + ç¾¤å + æˆå‘˜æ•° + æˆå‘˜å®«æ ¼ + åŠ¨ä½œåŒºï¼Œçº¯å±•ç¤º + æŠ›äº‹ä»¶ï¼Œä¸šåŠ¡ç”±çˆ¶çº§æ‰¿æŽ¥
    - relation èµ° groupStore ç¼“存推导:命中 = member(已加群),否则 = stranger(未加群),无 id = readonly
    - æˆå‘˜å®«æ ¼ä»… member æ—¶æ‹‰å–(陌生群拉不到,所有信息走 props.group å¡ç‰‡å¿«ç…§ï¼‰
  -->
  <div class="flex justify-center">
    <div class="w-full max-w-[320px] flex flex-col gap-3 items-center">
      <GroupAvatar
        :group-id="group.id"
        :url="group.showImage || group.showImageThumb"
        :name="group.showGroupName || group.name"
        :size="72"
        :previewable="isMember"
      />
      <div
        class="w-full text-lg font-semibold leading-snug text-[var(--ant-color-text)] truncate text-center"
      >
        {{ group.showGroupName || group.name }}
      </div>
      <div v-if="memberCountText" class="text-13px text-[var(--ant-color-text-secondary)]">
        {{ memberCountText }}
      </div>
      <!-- æˆå‘˜å®«æ ¼ï¼šä»… member æ¸²æŸ“(陌生群拉不到成员) -->
      <div
        v-if="isMember && members.length > 0"
        class="flex flex-wrap gap-2 justify-center w-full pt-2"
      >
        <GroupMemberGrid
          v-for="member in members"
          :key="member.userId"
          :member="member"
          :group-name="group.name"
        />
      </div>
      <!-- åŠ¨ä½œåŒºï¼šmember è¿›å…¥ç¾¤èŠ / stranger åŠ å…¥ç¾¤èŠ / readonly ä¸æ¸²æŸ“ -->
      <div v-if="isMember" class="mt-4">
        <Button type="primary" @click="emit('chat', group)">进入群聊</Button>
      </div>
      <div v-else-if="isStranger" class="mt-4">
        <Button type="primary" @click="emit('apply', group)">加入群聊</Button>
      </div>
    </div>
  </div>
</template>
src/views/im/home/components/group/group-item.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { GroupLite } from '../../types'
import GroupAvatar from './group-avatar.vue'
defineOptions({ name: 'ImGroupItem' })
defineProps<{
  active?: boolean
  group: GroupLite
}>()
defineEmits<{
  click: [group: GroupLite]
}>()
</script>
<template>
  <!--
    ç¾¤å•行项
    - å¤´åƒ + ç¾¤å
    - é€‰ä¸­æ€ active
  -->
  <div
    class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
    :class="{ '!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': active }"
    @click="$emit('click', group)"
  >
    <GroupAvatar
      :group-id="group.id"
      :url="group.showImage || group.showImageThumb"
      :name="group.showGroupName || group.name"
      :size="42"
    />
    <div class="flex flex-1 min-w-0">
      <!-- å•行展示群名;成员数仅在群详情面板展示,列表里不重复 -->
      <div class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
        {{ group.showGroupName || group.name }}
      </div>
    </div>
    <slot></slot>
  </div>
</template>
src/views/im/home/components/group/group-member-add-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,167 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { computed, ref } from 'vue'
import { CommonStatusEnum } from '#/packages/constants/src'
import { Button, message, Modal } from 'ant-design-vue'
import { inviteGroupMember } from '#/api/im/group/member'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { GROUP_MAX_MEMBER } from '#/views/im/utils/config'
import { ImGroupMemberRole } from '#/views/im/utils/constants'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupMemberAddDialog' })
const emit = defineEmits<{
  /** é‚€è¯·æˆåŠŸï¼Œæºå¸¦è¢«é‚€è¯·çš„å¥½å‹ id åˆ—表;父侧通常用来 reload ç¾¤æˆå‘˜ */
  reload: [friendIds: number[]]
}>()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const selectedIds = ref<number[]>([])
defineExpose({
  /** æ‰“开添加群成员弹窗:reset â†’ çŒå‚ â†’ visible=true */
  open(opts: { groupId: number }) {
    groupId.value = opts.groupId
    selectedIds.value = []
    submitting.value = false
    visible.value = true
  }
})
/** å½“前群成员列表:从 groupStore çŽ°å–ï¼Œé¿å…éš groupId å˜åŒ–时父侧 prop æ›´æ–°å»¶è¿Ÿ */
const members = computed<GroupMemberLite[]>(() => {
  const group = groupStore.getGroup(groupId.value)
  return (group?.members || []).map((member) => ({
    userId: member.userId,
    nickname: member.nickname,
    showName: member.displayUserName || member.nickname,
    avatar: member.avatar,
    status: member.status,
    role: member.role
  }))
})
/** å…¨é‡å¥½å‹ï¼šç›´æŽ¥å¤ç”¨ friendStore Lite è§†å›¾ */
const friends = computed(() => friendStore.getActiveFriendLiteList)
/** å·²åœ¨ç¾¤é‡Œçš„好友 id:传给 Panel çš„ disabledIds ç½®ç° + ä¸è®¡å…¥å·²é€‰ */
const disabledIds = computed<number[]>(() =>
  members.value
    .filter((member) => member.status !== CommonStatusEnum.DISABLE)
    .map((member) => member.userId)
)
/** æ˜¯å¦èµ°å®¡æ‰¹åˆ†æ”¯ï¼šç¾¤å¼€å¯ joinApproval + å½“前用户是普通成员(群主 / ç®¡ç†å‘˜é‚€è¯·ç›´è¿›ï¼‰ */
const willGoApproval = computed(() => {
  const group = groupStore.getGroup(groupId.value)
  if (!group?.joinApproval) {
    return false
  }
  const myId = getCurrentUserId()
  if (!myId) {
    return false
  }
  // ç¾¤ä¸»ç›´åˆ¤ï¼Œé¿å¼€ members å¼‚步加载的窗口;admin ä»ä¾èµ– members
  if (group.ownerUserId === myId) {
    return false
  }
  // members æœªåˆ°ä½æ—¶æ— æ³•判定 admin,保守按非审批处理,宁可漏报「等待审批」也不误报给真实管理员
  const myRole = members.value.find((member) => member.userId === myId)?.role
  if (myRole == null) {
    return false
  }
  return myRole !== ImGroupMemberRole.ADMIN
})
/** å½“前群已启用成员数(DISABLE å³é€€ç¾¤ / è¢«è¸¢ä¸è®¡å…¥ï¼‰ï¼Œç”¨äºŽä¸Šé™åˆ¤å®š */
const activeMemberCount = computed(
  () => members.value.filter((member) => member.status !== CommonStatusEnum.DISABLE).length
)
/** é‚€è¯·åŽç¾¤æ€»äººæ•°è‹¥è¶… GROUP_MAX_MEMBER,前端先拦;activeMemberCount + selectedIds.length å³é‚€è¯·åŽçš„æˆå‘˜æ•° */
const willExceedLimit = computed(
  () => activeMemberCount.value + selectedIds.value.length > GROUP_MAX_MEMBER
)
/** æ·»åŠ æŒ‰é’®å¯ç‚¹ï¼šè‡³å°‘æœ‰ 1 ä¸ªæ–°é‚€è¯·çš„好友 + ä¸è¶…群人数上限 */
const canSubmit = computed(() => selectedIds.value.length > 0 && !willExceedLimit.value)
/** é‚€è¯·å…¥ç¾¤ï¼šè°ƒ /im/group/invite,成功后 emit reload è®©çˆ¶ä¾§åˆ·æ–°ç¾¤æˆå‘˜ */
async function handleOk() {
  if (!groupId.value) {
    return
  }
  const memberUserIds = [...selectedIds.value]
  if (memberUserIds.length === 0) {
    return
  }
  // ç¾¤äººæ•°ä¸Šé™å†—余防御:与 canSubmit é‡å¤åˆ¤ä¸€æ¬¡ï¼Œé˜²æ­¢çŠ¶æ€åœ¨ await é—´éš™å˜åŒ–或调用方绕过按钮直接调
  if (activeMemberCount.value + memberUserIds.length > GROUP_MAX_MEMBER) {
    message.warning(`群成员上限为 ${GROUP_MAX_MEMBER} äºº`)
    return
  }
  submitting.value = true
  try {
    await inviteGroupMember({ groupId: groupId.value, memberUserIds })
    // å®¡æ‰¹åˆ†æ”¯ï¼šåŽç«¯ä»…落审批记录,未入群
    message.success(willGoApproval.value ? '邀请已发起,等待群主 / ç®¡ç†å‘˜å®¡æ‰¹' : '邀请成功')
    emit('reload', memberUserIds)
    visible.value = false
  } finally {
    submitting.value = false
  }
}
</script>
<template>
  <!--
    æ·»åŠ ç¾¤æˆå‘˜ï¼šé€‰å¥½å‹é‚€è¯·å…¥ç¾¤ï¼ˆå·²åœ¨ç¾¤æˆå‘˜ç½®ç°ã€ä¸è®¡å…¥å·²é€‰ï¼‰
    - dialog å£³æœ¬ç»„件持有;选择 UI å§”托 FriendPickerPanel
    - å·²åœ¨ç¾¤æˆå‘˜é€šè¿‡ disabledIds ä¼ å…¥ï¼Œä¸å†èµ° checked+disabled çš„"已勾选灰态"
    - å¯¹å¤–接口:ref + open({ groupId }) + emit reload(friendIds)
  -->
  <Modal
    v-model:open="visible"
    title="添加群成员"
    width="720px"
    :mask-closable="false"
    class="im-picker-dialog"
  >
    <div class="h-[480px]">
      <FriendPickerPanel
        v-model:selected-ids="selectedIds"
        :friends="friends"
        :disabled-ids="disabledIds"
      />
    </div>
    <template #footer>
      <Button @click="visible = false">取消</Button>
      <Button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
        æ·»åŠ 
      </Button>
    </template>
  </Modal>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep ç©¿é€ el-dialog å†…部类;复用 picker å…¬å…± mixin */
.im-picker-dialog {
  @include picker.styles;
}
</style>
src/views/im/home/components/group/group-member-grid.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { ImFriendAddSource } from '../../../utils/constants'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMemberGrid' })
withDefaults(
  defineProps<{
    clickable?: boolean // å¤´åƒç‚¹å‡»æ˜¯å¦å¼¹ UserInfoCard;选择器宫格里需要保持关闭,避免和勾选交互冲突
    groupName?: string // ç¾¤åï¼šåŠ å¥½å‹æ—¶æ‹¼ã€Œæˆ‘æ˜¯ 'XX ç¾¤' çš„ YY」话术,落库 add_source=GROUP
    member: GroupMemberLite
    size?: number // å¤´åƒåƒç´ å¤§å°ï¼›é»˜è®¤ 38(兼容选择器右侧已选区),群信息抽屉传 50 å¯¹é½å¾®ä¿¡ PC
  }>(),
  {
    clickable: false,
    size: 38,
    groupName: ''
  }
)
</script>
<template>
  <!--
    ç¾¤æˆå‘˜å®«æ ¼å•å…ƒ
    - å®«æ ¼å±•示的最小单位:头像在上、名字在下;列宽 = size + 16,自适应 size ç•™å‘¼å¸ç©ºé—´
    - è¢« GroupMemberPickerPanel å³ä¾§å·²é€‰åŒºï¼ˆgrid å½¢æ€ï¼‰ã€ConversationGroupSide ç¾¤æˆå‘˜åŒºå¾ªçŽ¯ä½¿ç”¨
  -->
  <div
    class="relative flex flex-col items-center px-0.5 py-1"
    :style="{ width: `${size! + 16}px` }"
  >
    <UserAvatar
      :id="member.userId"
      :url="member.avatar"
      :name="member.nickname"
      :size="size"
      :clickable="clickable"
      :add-source="ImFriendAddSource.GROUP"
      :add-source-extra="groupName"
    />
    <div
      class="w-full mt-1 overflow-hidden text-12px leading-[18px] text-center truncate text-[var(--ant-color-text)]"
    >
      {{ member.showName }}
    </div>
    <slot></slot>
  </div>
</template>
src/views/im/home/components/group/group-member-item.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { computed } from 'vue'
import { DICT_TYPE } from '#/packages/constants/src'
import { getDictLabel } from '#/packages/effects/hooks/src'
import { ImGroupMemberRole } from '../../../utils/constants'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMemberItem' })
const props = withDefaults(
  defineProps<{
    active?: boolean
    height?: number
    member: GroupMemberLite
  }>(),
  {
    height: 50,
    active: false
  }
)
defineEmits<{
  click: [member: GroupMemberLite]
}>()
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
/** è§’色标签文案:普通成员不显示,其余取 im_group_member_role å­—å…¸ label */
const roleLabel = computed(() => {
  if (props.member.role == null || props.member.role === ImGroupMemberRole.NORMAL) {
    return ''
  }
  return getDictLabel(DICT_TYPE.IM_GROUP_MEMBER_ROLE, props.member.role)
})
/** è§’色标签样式:群主用主色;管理员用次要色 */
const roleLabelClass = computed(() => {
  if (props.member.role === ImGroupMemberRole.OWNER) {
    return 'text-[var(--ant-color-primary)] bg-[var(--ant-color-primary-bg)]'
  }
  return 'text-[var(--ant-color-info)] bg-[var(--ant-color-fill)]'
})
</script>
<template>
  <!--
    ç¾¤æˆå‘˜è¡Œå½¢æ€
    - æ¨ªæŽ’、带 hover æ€ï¼›slot æ”¾ checkbox / æ“ä½œæŒ‰é’®ç­‰
    - ä¸Ž GroupMember çš„差别:带 hover æ€ + slot æ‰©å±•点,适合 selector / admin åˆ—表
  -->
  <div
    class="relative flex gap-2.5 items-center mx-px px-4 box-border whitespace-nowrap rounded cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
    :class="{ '!bg-[#e1eaf7] dark:!bg-[var(--ant-color-primary-bg-hover)]': active }"
    :style="{ height: `${height }px` }"
    @click="$emit('click', member)"
  >
    <UserAvatar
      :id="member.userId"
      :url="member.avatar"
      :name="member.nickname"
      :size="avatarSize"
      :clickable="false"
    />
    <div
      class="flex-1 h-full pl-1 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
      :style="{ lineHeight: `${height }px` }"
    >
      {{ member.showName }}
    </div>
    <!-- è§’色标签:群主 / ç®¡ç†å‘˜ï¼›æ™®é€šæˆå‘˜ä¸æ˜¾ç¤ºã€‚仅在传入 member.role æ—¶ç”Ÿæ•ˆ -->
    <span
      v-if="roleLabel"
      class="px-1.5 py-px rounded text-xs whitespace-nowrap"
      :class="roleLabelClass"
    >
      {{ roleLabel }}
    </span>
    <slot></slot>
  </div>
</template>
src/views/im/home/components/group/group-member-remove-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { ref } from 'vue'
import { Button, message, Modal } from 'ant-design-vue'
import { removeGroupMember } from '#/api/im/group/member'
import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupMemberRemoveDialog' })
const emit = defineEmits<{
  /** ç§»å‡ºæˆåŠŸï¼›çˆ¶ä¾§é€šå¸¸ç”¨æ¥ reload ç¾¤æ•°æ® */
  reload: []
}>()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
const hideIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
defineExpose({
  /** æ‰“开移除群成员弹窗:reset â†’ çŒå‚ â†’ visible=true */
  open(opts: {
    groupId: number
    /** éšè— userId:群主始终隐藏;管理员视角额外隐藏其它管理员 */
    hideIds?: number[]
    members: GroupMemberLite[]
  }) {
    groupId.value = opts.groupId
    members.value = opts.members
    hideIds.value = opts.hideIds ? [...opts.hideIds] : []
    selectedIds.value = []
    submitting.value = false
    visible.value = true
  }
})
/** ä¸€æ¬¡æ€§æ‰¹é‡è¸¢äººï¼šé€‰ä¸­æˆå‘˜ userId æ•°ç»„传给后端,比循环调 N æ¬¡æŽ¥å£çœå¾€è¿” */
async function handleOk() {
  if (!groupId.value || selectedIds.value.length === 0) {
    return
  }
  submitting.value = true
  try {
    const memberUserIds = [...selectedIds.value]
    await removeGroupMember({ groupId: groupId.value, memberUserIds })
    message.success(`已移除 ${memberUserIds.length} ä½æˆå‘˜`)
    emit('reload')
    visible.value = false
  } finally {
    submitting.value = false
  }
}
</script>
<template>
  <!--
    ç§»é™¤ç¾¤æˆå‘˜ï¼šé€‰æˆå‘˜ â†’ removeGroupMember æ‰¹é‡è¸¢äºº
    - dialog å£³æœ¬ç»„件持有;选择 UI å§”托 GroupMemberPickerPanel
    - ç¾¤ä¸» / ç®¡ç†å‘˜è§†è§’的不可移除成员通过 hideIds éšè—ï¼Œç”±è°ƒç”¨æ–¹ä¼ å…¥
    - å¯¹å¤–接口:ref + open({ groupId, members, hideIds }) + emit reload()
  -->
  <Modal
    v-model:open="visible"
    title="移出群成员"
    width="700px"
    :mask-closable="false"
    class="im-picker-dialog"
  >
    <div class="h-[480px]">
      <GroupMemberPickerPanel
        v-model:selected-ids="selectedIds"
        :members="members"
        :hide-ids="hideIds"
        :max-size="50"
      />
    </div>
    <template #footer>
      <Button @click="visible = false">取消</Button>
      <Button
        type="primary"
        :loading="submitting"
        :disabled="selectedIds.length === 0"
        @click="handleOk"
      >
        ç§»å‡º
      </Button>
    </template>
  </Modal>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep ç©¿é€ el-dialog å†…部类;复用 picker å…¬å…± mixin */
.im-picker-dialog {
  @include picker.styles;
}
</style>
src/views/im/home/components/group/group-member.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { ImFriendAddSource } from '../../../utils/constants'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMember' })
const props = withDefaults(
  defineProps<{
    active?: boolean // é€‰ä¸­æ€ï¼ˆ@候选键盘高亮等)
    clickable?: boolean // å¤´åƒç‚¹å‡»æ˜¯å¦å¼¹ UserInfoCard;@候选场景通常禁用(避免嵌套交互)
    groupName?: string // ç¾¤åï¼šåŠ å¥½å‹æ—¶æ‹¼ã€Œæˆ‘æ˜¯ 'XX ç¾¤' çš„ YY」话术,落库 add_source=GROUP
    height?: number // è¡Œé«˜ï¼ˆpx),影响头像大小
    member: GroupMemberLite
  }>(),
  {
    height: 50,
    active: false,
    clickable: false,
    groupName: ''
  }
)
/** ç¾¤æˆå‘˜ç»“构(跨多处使用,放这里做窄接口;独立于 types/index.ts) */
export interface GroupMemberLite {
  userId: number // ç”¨æˆ·ç¼–号;特殊值见 IM_AT_ALL_USER_ID(@ å…¨ä½“成员)
  nickname: string // çœŸå®žæ˜µç§°ï¼šæ°¸è¿œæ˜¯ç”¨æˆ·çš„ nickname,专给 UserAvatar è‰²å¡ç”¨ï¼Œä¿è¯åŒä¸€ä¸ªäººè‰²å¡é¦–字母在所有界面一致
  showName: string // å±•示昵称:好友备注 > ç”¨æˆ·ç¾¤å¤‡æ³¨ï¼ˆdisplayUserName) > çœŸå®žæ˜µç§°ï¼ˆnickname),给"显示给用户看"的位置用(行内文字、@候选标签等)
  avatar?: string
  status?: number
  role?: number // æˆå‘˜è§’色,仅在群信息抽屉等需要展示角色标签的场景透传;@候选 / å·²è¯»åˆ—表等场景可不传
}
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
</script>
<template>
  <!--
    ç¾¤æˆå‘˜å•行
    è·¨å­åŸŸå¤ç”¨ï¼š@候选 (MentionPicker) / å·²è¯»åˆ—表 (MessageReadStatus) / ç¾¤æˆå‘˜å®«æ ¼ (ConversationGroupSide)
  -->
  <div
    class="relative flex items-center px-[5px] box-border whitespace-nowrap"
    :class="{ 'bg-[#e1eaf7] dark:bg-[var(--ant-color-primary-bg)]': active }"
    :style="{ height: `${height }px` }"
  >
    <UserAvatar
      :size="avatarSize"
      :name="member.nickname"
      :url="member.avatar"
      :clickable="clickable"
      :id="member.userId"
      :add-source="ImFriendAddSource.GROUP"
      :add-source-extra="groupName"
    />
    <div
      class="flex-1 h-full pl-2.5 overflow-hidden text-sm text-left truncate text-[var(--ant-color-text)]"
      :style="{ lineHeight: `${height }px` }"
    >
      {{ member.showName }}
    </div>
  </div>
</template>
src/views/im/home/components/group/group-mute-member-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,97 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { Button, message, Modal } from 'ant-design-vue'
import { muteMember } from '#/api/im/group'
defineOptions({ name: 'ImGroupMuteMemberDialog' })
const emit = defineEmits<{
  success: []
}>()
const visible = ref(false)
const loading = ref(false)
const groupId = ref(0)
const userId = ref(0)
const memberName = ref('')
const selected = ref(600) // é»˜è®¤ 10 åˆ†é’Ÿ
const presets = [
  { label: '10 åˆ†é’Ÿ', value: 600 },
  { label: '1 å°æ—¶', value: 3600 },
  { label: '12 å°æ—¶', value: 43_200 },
  { label: '1 å¤©', value: 86_400 },
  { label: '7 å¤©', value: 604_800 },
  { label: '30 å¤©', value: 2_592_000 },
  { label: '永久', value: 0 }
]
/** æ‰“开弹窗 */
function open(gid: number, uid: number, name: string) {
  groupId.value = gid
  userId.value = uid
  memberName.value = name
  selected.value = 600
  visible.value = true
}
/** ç¡®è®¤ç¦è¨€ */
async function handleConfirm() {
  loading.value = true
  try {
    await muteMember({
      id: groupId.value,
      userId: userId.value,
      mutedSeconds: selected.value
    })
    message.success('禁言成功')
    visible.value = false
    emit('success')
  } finally {
    loading.value = false
  }
}
defineExpose({ open })
</script>
<template>
  <!-- ç¦è¨€æ—¶é•¿é€‰æ‹©å¼¹çª— -->
  <Modal v-model:open="visible" title="设置禁言" width="560px" :mask-closable="false">
    <div class="flex flex-col gap-4">
      <!-- æˆå‘˜ä¿¡æ¯å¡ï¼šå’Œ FriendAddDialog çš„ user å¡ä¿æŒä¸€è‡´çš„æµ…色背景 -->
      <div
        class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-[var(--ant-color-fill-secondary)]"
      >
        <span class="text-13px text-[var(--ant-color-text-secondary)]">禁言成员</span>
        <span
          class="text-sm font-medium text-[var(--ant-color-text)] truncate"
        >
          {{ memberName }}
        </span>
      </div>
      <!-- ç¦è¨€æ—¶é•¿é€‰é¡¹ï¼šç”¨ el-button å¹³é“ºï¼Œé€‰ä¸­èµ° primary,靠 gap-2 ç•™é—´è· -->
      <div>
        <div class="mb-2 text-13px text-[var(--ant-color-text-secondary)]">禁言时长</div>
        <div class="grid grid-cols-3 gap-2">
          <Button
            v-for="opt in presets"
            :key="opt.value"
            :type="selected === opt.value ? 'primary' : undefined"
            class="!ml-0 w-full"
            @click="selected = opt.value"
          >
            {{ opt.label }}
          </Button>
        </div>
      </div>
    </div>
    <template #footer>
      <Button @click="visible = false">取消</Button>
      <Button type="primary" :loading="loading" @click="handleConfirm">确定</Button>
    </template>
  </Modal>
</template>
src/views/im/home/components/group/group-owner-transfer-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,126 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { computed, ref } from 'vue'
import { confirm } from '#/packages/effects/common-ui/src'
import { Button, message, Modal } from 'ant-design-vue'
import { transferGroupOwner } from '#/api/im/group'
import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupOwnerTransferDialog' })
const emit = defineEmits<{
  /** è½¬è®©æˆåŠŸï¼›çˆ¶ä¾§é€šå¸¸ç”¨æ¥ reload ç¾¤æ•°æ® */
  reload: []
}>()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
const hideIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
defineExpose({
  /** æ‰“开转让群主弹窗:reset â†’ çŒå‚ â†’ visible=true */
  open(opts: {
    groupId: number
    /** éšè— userId:当前用户(不能转给自己) */
    hideIds?: number[]
    members: GroupMemberLite[]
  }) {
    groupId.value = opts.groupId
    members.value = opts.members
    hideIds.value = opts.hideIds ? [...opts.hideIds] : []
    selectedIds.value = []
    submitting.value = false
    visible.value = true
  }
})
/** é€‰ä¸­çš„æ–°ç¾¤ä¸»å¯¹è±¡ï¼ˆå–数组首项) */
const newOwner = computed<GroupMemberLite | undefined>(() => {
  if (selectedIds.value.length === 0) {
    return undefined
  }
  return members.value.find((member) => member.userId === selectedIds.value[0])
})
/** äºŒæ¬¡ç¡®è®¤è½¬è®©ï¼šè½¬è®©åŽæ—§ç¾¤ä¸»é™ä¸ºæ™®é€šæˆå‘˜ï¼Œæ— æ³•撤销 */
async function handleOk() {
  if (!groupId.value || !newOwner.value) {
    return
  }
  try {
    await confirm(
      `确定将群主转让给 ${newOwner.value.showName}?转让后你将变为普通成员,无法撤销。`,
      '确认转让群主'
    )
  } catch {
    return
  }
  submitting.value = true
  try {
    await transferGroupOwner({
      id: groupId.value,
      newOwnerUserId: newOwner.value.userId
    })
    message.success('群主转让成功')
    emit('reload')
    visible.value = false
  } finally {
    submitting.value = false
  }
}
</script>
<template>
  <!--
    è½¬è®©ç¾¤ä¸»ï¼šé€‰ 1 ä½æ–°ç¾¤ä¸» â†’ äºŒæ¬¡ç¡®è®¤ â†’ transferGroupOwner
    - dialog å£³æœ¬ç»„件持有;选择 UI å§”托 GroupMemberPickerPanel
    - å½“前用户从候选里隐藏(不能转给自己)
    - maxSize=1 é™å®šå•选
    - å¯¹å¤–接口:ref + open({ groupId, members, hideIds }) + emit reload()
  -->
  <Modal
    v-model:open="visible"
    title="选择新群主"
    width="700px"
    :mask-closable="false"
    class="im-picker-dialog"
  >
    <div class="h-[480px]">
      <GroupMemberPickerPanel
        v-model:selected-ids="selectedIds"
        :members="members"
        :hide-ids="hideIds"
        :max-size="1"
      />
    </div>
    <template #footer>
      <Button @click="visible = false">取消</Button>
      <Button
        type="primary"
        :loading="submitting"
        :disabled="selectedIds.length === 0"
        @click="handleOk"
      >
        ç¡®å®š
      </Button>
    </template>
  </Modal>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep ç©¿é€ el-dialog å†…部类;复用 picker å…¬å…± mixin */
.im-picker-dialog {
  @include picker.styles;
}
</style>
src/views/im/home/components/group/group-request-list-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,377 @@
<script lang="ts" setup>
import type { ImGroupRequestApi } from '#/api/im/group/request'
import { computed, ref, watch } from 'vue'
import { prompt } from '#/packages/effects/common-ui/src'
import { Empty, Input, message, Modal, Spin } from 'ant-design-vue'
import { getGroupRequestListByGroupId } from '#/api/im/group/request'
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
import { useGroupRequestStore } from '../../store/groupRequestStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupRequestListDialog' })
const groupRequestStore = useGroupRequestStore()
const visible = ref(false)
const groupId = ref<number | undefined>() // å½“前展示的群编号;undefined æ—¶èµ°å…¨å±€æœªå¤„理列表(store.unhandledList)
const loading = ref(false)
const groupList = ref<ImGroupRequestApi.GroupRequestRespVO[]>([])
const actingId = ref<null | number>(null)
defineExpose({
  /** æ‰“开进群申请弹窗:reset â†’ çŒå‚ â†’ visible=true;不传 groupId èµ°å…¨å±€æœªå¤„理列表 */
  open(opts?: { groupId?: number }) {
    groupId.value = opts?.groupId
    actingId.value = null
    visible.value = true
  }
})
/** æ•°æ®æºï¼šå•群模式用 fetch å›žæ¥çš„ groupList;全局模式直接读 store.unhandledList,处理后 store è‡ªåЍ reactive åŒæ­¥ */
const list = computed<ImGroupRequestApi.GroupRequestRespVO[]>(() =>
  groupId.value ? groupList.value : groupRequestStore.unhandledList
)
/** é¡¶éƒ¨å¡ç‰‡ï¼šæœ€æ–°ä¸€æ¡ï¼›ç©ºæ•°ç»„时为 null */
const latest = computed(() => list.value[0] || null)
/** åŽ†å²åˆ—è¡¨ï¼šé™¤æœ€æ–°ä¸€æ¡å¤–çš„å…¶ä½™ */
const histories = computed(() => list.value.slice(1))
/** æ‰“å¼€ dialog æ—¶æ‹‰æ•°æ®ï¼šå•群拉 API;全局直接读 store;关闭时清掉单群缓存 */
watch(
  [visible, groupId],
  ([isVisible, currentGroupId]) => {
    if (isVisible && currentGroupId) {
      void fetchList(currentGroupId)
    } else if (!isVisible) {
      groupList.value = []
    }
  },
  { immediate: true }
)
/**
 * å•群模式下订阅 store ä¸­å½’属本群的未处理列表变化:远端事件(WS 1503 æ–°ç”³è¯· / å…¶ä»–管理员处理)触发时 refetch
 * æ‹¿æœ€æ–° handleResult;本端 agree / refuse æœŸé—´ actingId é”ä½ï¼Œè·³è¿‡æœ¬ç«¯åŠ¨ä½œå¼•å‘çš„ store å˜åŒ–避免冗余 RTT
 *
 * key ä¸èƒ½åª join id:复用旧记录时同一 requestId çš„ applyContent / inviterUserId ä¼šåˆ·æ–°ä½† id ä¸å˜ï¼Œå¿…须把内容字段也纳入触发
 */
watch(
  () =>
    groupId.value && visible.value
      ? groupRequestStore.unhandledList
          .filter((request) => request.groupId === groupId.value)
          .map(
            (request) =>
              `${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`
          )
          .join(',')
      : null,
  (current, previous) => {
    if (current === null || previous === undefined || current === previous) {
      return
    }
    if (actingId.value !== null) {
      return
    }
    if (groupId.value) {
      void fetchList(groupId.value)
    }
  }
)
let fetchSeq = 0 // å•调递增请求序号;同群也会因为 WS 1503 æŽ¨é€è§¦å‘额外 fetch,乱序返回时旧响应不能覆盖新数据
async function fetchList(targetGroupId: number) {
  const seq = ++fetchSeq
  loading.value = true
  try {
    const data = (await getGroupRequestListByGroupId(targetGroupId)) || []
    // æœŸé—´åˆ‡ç¾¤ / å…³å¼¹çª— / åˆè§¦å‘æ›´æ–° fetch:丢响应
    if (seq !== fetchSeq || !visible.value || groupId.value !== targetGroupId) {
      return
    }
    groupList.value = data
  } finally {
    // æ—§è¯·æ±‚ finally å‘½ä¸­æ—¶æ–°è¯·æ±‚仍在跑,跳过避免提前关 loading
    if (seq === fetchSeq) {
      loading.value = false
    }
  }
}
/** åŒæ„ï¼šèµ° store åŒæ­¥å…¨å±€æœªå¤„理列表 + æœ¬åœ°æ›´æ–° handleResult è®©æŒ‰é’®å˜ç° */
async function handleAgree(item: ImGroupRequestApi.GroupRequestRespVO) {
  if (actingId.value !== null) return
  actingId.value = item.id
  try {
    await groupRequestStore.agreeGroupRequest(item.id)
    updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED)
    message.success('已同意')
  } finally {
    actingId.value = null
  }
}
/** æ‹’绝:弹理由输入框;为空则不带 handleContent */
async function handleRefuse(item: ImGroupRequestApi.GroupRequestRespVO) {
  if (actingId.value !== null) return
  let handleContent: string
  try {
    const result = await prompt<string>({
      component: Input,
      componentProps: {
        allowClear: true,
        placeholder: '请输入拒绝理由(可选)'
      },
      content: '',
      modelPropName: 'value',
      title: '拒绝申请'
    })
    handleContent = result || ''
  } catch {
    return
  }
  actingId.value = item.id
  try {
    await groupRequestStore.refuseGroupRequest(item.id, handleContent || undefined)
    updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED)
    message.success('已拒绝')
  } finally {
    actingId.value = null
  }
}
/** å•群模式下处理后更新 groupList é‡Œçš„ handleResult,按钮转「已同意 / å·²æ‹’绝」灰态;全局模式 store ç›´æŽ¥ç§»é™¤è¯¥é¡¹æ— éœ€æ›´æ–° */
function updateLocalResult(id: number, handleResult: number) {
  const target = groupList.value.find((r) => r.id === id)
  if (target) {
    target.handleResult = handleResult
  }
}
</script>
<template>
  <!--
    ç¾¤ã€Œè¿›ç¾¤ç”³è¯·ã€åˆ—表对话框
    - ä»…群主 / ç®¡ç†å‘˜å…¥å£å¯è¾¾ï¼›å±•示当前群下全部申请(含已处理)
    - é¡¶éƒ¨æœ€æ–°ä¸€æ¡å¡ç‰‡åŒ–突出(带申请理由),其余按 id å€’序紧凑列表
    - åŒæ„ / æ‹’绝走 groupRequestStore çš„ action,处理后本地更新 handleResult è®©æŒ‰é’®è½¬ç°æ€
  -->
  <Modal
    v-model:open="visible"
    title="进群申请"
    width="560px"
    :footer="null"
    :mask-closable="false"
    class="im-group-request-list__dialog"
  >
    <Spin :spinning="loading" wrapper-class-name="w-full">
      <div class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1">
        <!-- ç©ºæ€ -->
        <Empty v-if="!loading && list.length === 0" description="暂无进群申请" />
      <!-- é¡¶éƒ¨å¡ç‰‡ï¼šæœ€æ–°ä¸€æ¡ -->
        <div
          v-if="latest"
          class="flex flex-col gap-2.5 p-3.5 rounded-[10px] border border-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
        >
        <div class="flex items-center gap-3">
          <UserAvatar
            :url="latest.userAvatar"
            :name="latest.userNickname"
            :size="44"
            :clickable="false"
          />
          <div class="flex-1 min-w-0">
            <div
              class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]"
            >
              {{ latest.userNickname || `用户 ${latest.userId}` }}
            </div>
            <div
              class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
            >
              <template v-if="latest.inviterUserId">
                é€šè¿‡
                <span class="text-[var(--ant-color-primary)]">
                  {{ latest.inviterNickname || `用户 ${latest.inviterUserId}` }}
                </span>
                çš„邀请进群
              </template>
              <template v-else>申请加入</template>
            </div>
          </div>
          <span
            v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
            class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
          >
            å·²åŒæ„
          </span>
          <span
            v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED"
            class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
          >
            å·²æ‹’绝
          </span>
          <div v-else class="flex gap-1.5 flex-shrink-0">
            <button
              class="im-group-request-list__btn im-group-request-list__btn--primary"
              :disabled="actingId === latest.id"
              @click="handleAgree(latest)"
            >
              ç¡®è®¤
            </button>
            <button
              class="im-group-request-list__btn im-group-request-list__btn--ghost"
              :disabled="actingId === latest.id"
              @click="handleRefuse(latest)"
            >
              æ‹’绝
            </button>
          </div>
        </div>
        <!-- ç”³è¯·ç†ç”±ï¼šé‚€è¯·åœºæ™¯æ˜¾ç¤ºé‚€è¯·äºº + ç•™è¨€ï¼›ä¸»åŠ¨ç”³è¯·æ˜¾ç¤ºç”³è¯·äºº + ç•™è¨€ -->
        <div
          v-if="latest.applyContent"
          class="px-3 py-2 rounded-md text-[13px] leading-[1.5] break-all bg-[var(--ant-color-fill-secondary)] text-[var(--ant-color-text)]"
        >
          <span class="text-[var(--ant-color-primary)]">
            {{
              latest.inviterUserId
                ? latest.inviterNickname || `用户 ${latest.inviterUserId}`
                : latest.userNickname || `用户 ${latest.userId}`
            }}:
          </span>
          {{ latest.applyContent }}
        </div>
      </div>
      <!-- åˆ†å‰²çº¿ï¼šä»…在有更早申请时出现 -->
        <div
          v-if="histories.length > 0"
          class="flex items-center justify-center mt-1.5 -mb-0.5 text-12px text-[var(--ant-color-text-placeholder)]"
        >
          <span>以下为更早的申请</span>
        </div>
        <!-- åŽ†å²ç”³è¯·åˆ—è¡¨ -->
        <div
          v-for="item in histories"
          :key="item.id"
          class="flex flex-col gap-2.5 px-3.5 py-2.5 rounded-[10px] border border-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
        >
        <div class="flex items-center gap-3">
          <UserAvatar
            :url="item.userAvatar"
            :name="item.userNickname"
            :size="40"
            :clickable="false"
          />
          <div class="flex-1 min-w-0">
            <div
              class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]"
            >
              {{ item.userNickname || `用户 ${item.userId}` }}
            </div>
            <div
              class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
            >
              <template v-if="item.inviterUserId">
                é€šè¿‡
                <span class="text-[var(--ant-color-primary)]">
                  {{ item.inviterNickname || `用户 ${item.inviterUserId}` }}
                </span>
                çš„邀请进群
              </template>
              <template v-else>申请加入</template>
            </div>
          </div>
          <span
            v-if="item.handleResult === ImGroupRequestHandleResult.AGREED"
            class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
          >
            å·²åŒæ„
          </span>
          <span
            v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED"
            class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
          >
            å·²æ‹’绝
          </span>
          <div v-else class="flex gap-1.5 flex-shrink-0">
            <button
              class="im-group-request-list__btn im-group-request-list__btn--primary"
              :disabled="actingId === item.id"
              @click="handleAgree(item)"
            >
              ç¡®è®¤
            </button>
            <button
              class="im-group-request-list__btn im-group-request-list__btn--ghost"
              :disabled="actingId === item.id"
              @click="handleRefuse(item)"
            >
              æ‹’绝
            </button>
          </div>
        </div>
        </div>
      </div>
    </Spin>
  </Modal>
</template>
<style scoped>
/* è‡ªç»˜æŒ‰é’®ï¼šè´´è¿‘微信小药丸样式;与 :disabled、:hover:not(:disabled) ç­‰ä¼ªç±»å åŠ  modifier ç±»çš„组合选择器写在 class é‡Œæˆæœ¬é«˜ï¼Œç•™ SCSS */
.im-group-request-list__btn {
  flex-shrink: 0;
  min-width: 56px;
  height: 28px;
  padding: 0 12px;
  font-size: 13px;
  border-radius: 4px;
  cursor: pointer;
  border: 1px solid transparent;
  transition:
    background-color 0.15s,
    border-color 0.15s,
    color 0.15s;
}
.im-group-request-list__btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
.im-group-request-list__btn--primary {
  color: #fff;
  background-color: var(--ant-color-primary);
  border-color: var(--ant-color-primary);
}
.im-group-request-list__btn--primary:hover:not(:disabled) {
  background-color: var(--ant-color-primary-hover);
  border-color: var(--ant-color-primary-hover);
}
.im-group-request-list__btn--ghost {
  color: var(--ant-color-text);
  background-color: var(--ant-color-bg-container);
  border-color: var(--ant-color-border);
}
.im-group-request-list__btn--ghost:hover:not(:disabled) {
  color: var(--ant-color-primary);
  border-color: var(--ant-color-primary);
}
</style>
<style>
/* el-dialog å†…部 body é€šè¿‡ teleport æ¸²æŸ“到 body,scoped é€‰ä¸åˆ°ï¼Œç•™éž scoped å…¨å±€è¦†ç›– */
.im-group-request-list__dialog .el-dialog__body {
  padding: 12px 20px 8px;
  background-color: var(--ant-color-fill-secondary);
}
</style>
src/views/im/home/components/group/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
export { default as GroupAdminSetDialog } from './group-admin-set-dialog.vue';
export { default as GroupAvatar } from './group-avatar.vue';
export { default as GroupCreateDialog } from './group-create-dialog.vue';
export { default as GroupInfoCard } from './group-info-card.vue';
export { default as GroupInfo } from './group-info.vue';
export { default as GroupItem } from './group-item.vue';
export { default as GroupMemberAddDialog } from './group-member-add-dialog.vue';
export { default as GroupMemberGrid } from './group-member-grid.vue';
export { default as GroupMemberItem } from './group-member-item.vue';
export { default as GroupMemberRemoveDialog } from './group-member-remove-dialog.vue';
export { default as GroupMember } from './group-member.vue';
export type { GroupMemberLite } from './group-member.vue';
export { default as GroupMuteMemberDialog } from './group-mute-member-dialog.vue';
export { default as GroupOwnerTransferDialog } from './group-owner-transfer-dialog.vue';
export { default as GroupRequestListDialog } from './group-request-list-dialog.vue';
src/views/im/home/components/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,4 @@
export { default as ContextMenu } from './context-menu.vue';
export { default as PagedScroller } from './paged-scroller.vue';
export { default as ResizableAside } from './resizable-aside.vue';
export { default as ToolBar } from './tool-bar.vue';
src/views/im/home/components/paged-scroller.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,109 @@
<script lang="ts" setup generic="T">
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
defineOptions({ name: 'ImPagedScroller' })
const props = withDefaults(
  defineProps<{
    itemKey?: string // ä¸šåŠ¡ id å­—段名(如 'userId' / 'id');不传 / å­—段值非 string|number æ—¶å›žé€€ idx
    items: T[] // å…¨é‡æ•°æ®
    pageSize?: number // æ¯é¡µæ¸²æŸ“条数
    threshold?: number // è·åº•多少 px è§¦å‘下一页
  }>(),
  {
    itemKey: undefined,
    pageSize: 30,
    threshold: 30
  }
)
/** è§£æžæ¯æ¡ item çš„ :key:caller ä¼  itemKey åˆ™æŒ‰å­—段取,无效 / ç¼ºå¤±å›žé€€ç´¢å¼•,避免传错字段时全表 undefined key */
function resolveItemKey(item: T, idx: number): number | string {
  if (!props.itemKey || item == null || typeof item !== 'object') {
    return idx
  }
  const value = (item as Record<string, unknown>)[props.itemKey]
  return typeof value === 'string' || typeof value === 'number' ? value : idx
}
const scrollbarRef = useTemplateRef<HTMLDivElement>('scrollbarRef')
const page = ref(1)
const displayItems = computed(() => {
  const limit = Math.min(page.value * props.pageSize, props.items.length)
  return props.items.slice(0, limit)
})
const allLoaded = computed(() => displayItems.value.length >= props.items.length)
/** ä»…当超过一页时才显示「已到底部」,避免短列表也出现这条提示 */
const showFooter = computed(() => allLoaded.value && props.items.length > props.pageSize)
let wrapEl: HTMLElement | null = null
onMounted(() => {
  wrapEl = scrollbarRef.value
  wrapEl?.addEventListener('scroll', onScroll)
})
onBeforeUnmount(() => {
  wrapEl?.removeEventListener('scroll', onScroll)
})
/** åˆ‡æ¢æ•°æ®æºï¼ˆå¦‚切会话)时重置分页:避免新列表沿用旧 page,首屏出现空段 */
watch(
  () => props.items,
  () => {
    page.value = 1
  }
)
/** æ»šåˆ°è·åº• threshold å†…时自增 page,扩出下一段切片 */
function onScroll(e: Event) {
  const el = e.target as HTMLElement
  if (el.scrollTop + el.clientHeight < el.scrollHeight - props.threshold) {
    return
  }
  if (allLoaded.value) {
    return
  }
  page.value++
}
defineExpose({
  /** æ‰‹åŠ¨æ»šåˆ°é¡¶éƒ¨ */
  scrollTop: () => {
    if (wrapEl) {
      wrapEl.scrollTop = 0
    }
  },
  /** æ‰‹åŠ¨æ»šåˆ°åº•éƒ¨ */
  scrollBottom: () => {
    if (wrapEl) {
      wrapEl.scrollTop = wrapEl.scrollHeight
    }
  }
})
</script>
<template>
  <!--
    åˆ†é¡µå¢žé‡æ»šåЍ容噍
    - æ»šåˆ°åº•部自动 page++,直到全部渲染完
    - é€šè¿‡ slot æš´éœ²æ¯ä¸€é¡¹ï¼Œè®©è°ƒç”¨æ–¹è‡ªå·±å†³å®šæ¸²æŸ“
  -->
  <div ref="scrollbarRef" class="w-full h-full overflow-y-auto">
    <slot
      v-for="(item, idx) in displayItems"
      :item="item"
      :index="idx"
      :key="resolveItemKey(item, idx)"
    ></slot>
    <div
      v-if="showFooter"
      class="py-3 text-xs text-center text-[var(--ant-color-text-secondary)]"
    >
      å·²åˆ°åº•部
    </div>
  </div>
</template>
src/views/im/home/components/picker/conversation-picker-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,370 @@
<script lang="ts" setup>
import type { Conversation } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Input, message } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { GroupAvatar } from '../group'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImConversationPickerPanel' })
const props = withDefaults(
  defineProps<{
    /** å…¨é‡ä¼šè¯åˆ—表 */
    conversations: Conversation[]
    /** éšè— key:从候选 / å·²é€‰ / æœ€è¿‘转发里都剔除(不能转发回自己、推荐名片自身的会话等) */
    hideKeys?: string[]
    /** å·²é€‰æ•°ä¸Šé™ï¼›ä¸ä¼ æˆ– <=0 æ—¶ä¸é™ */
    maxSize?: number
    /** æœ€è¿‘转发会话 key åˆ—表;展示在左栏顶部横向头像区 */
    recentForwardConversationKeys?: string[]
    /** å·²é€‰ä¼šè¯ key(v-model);key ç”± getConversationKey ç”Ÿæˆ */
    selectedKeys: string[]
    /** æ˜¯å¦å±•示「创建聊天」入口 */
    showCreateChat?: boolean
  }>(),
  {
    recentForwardConversationKeys: () => [],
    hideKeys: () => [],
    maxSize: 0,
    showCreateChat: false
  }
)
const emit = defineEmits<{
  createChat: []
  /** ç”¨æˆ·åœ¨ã€Œæœ€è¿‘转发」段进入移除模式后点 Ã—;业务壳收到后调 conversationStore.removeRecentForwardConversationKey è½ç›˜ */
  removeRecent: [key: string]
  'update:selectedKeys': [value: string[]]
}>()
const keyword = ref('')
const recentRemoveMode = ref(false) // ã€Œæœ€è¿‘转发」段是否处于移除模式:true æ—¶å¤´åƒå³ä¸Šè§’变 Ã— ä¸å†åˆ‡å‹¾é€‰
/** å…¨é‡ä¼šè¯çš„ key→Conversation æ˜ å°„,已选 / æœ€è¿‘转发反查共用,避免每次 O(N) æ‰« */
const byKey = computed(() => {
  const map = new Map<string, Conversation>()
  for (const conversation of props.conversations) {
    map.set(getConversationKey(conversation), conversation)
  }
  return map
})
/** éšè—é›†åˆï¼šæ¯æ¬¡è¿‡æ»¤å¤ç”¨ */
const hideSet = computed(() => new Set(props.hideKeys))
/** å·²é€‰é›†åˆï¼šåœ†å½¢æŒ‡ç¤ºå™¨ isSelected èµ° set å¿«æŸ¥ */
const selectedSet = computed(() => new Set(props.selectedKeys))
/** å€™é€‰ä¼šè¯ï¼šå‰”除 hideKeys */
const candidateConversations = computed(() =>
  props.conversations.filter((c) => !hideSet.value.has(getConversationKey(c)))
)
/** å·¦æ å±•示列表:在候选基础上按 keyword è¿‡æ»¤ */
const shownConversations = computed(() =>
  filterConversationsByKeyword(candidateConversations.value, keyword.value)
)
/** æœ€è¿‘转发的会话对象列表:从 recentForwardConversationKeys åæŸ¥ï¼›å‰”除 hide / ä¸å­˜åœ¨çš„ key */
const recentForwardConversations = computed(() =>
  props.recentForwardConversationKeys
    .map((key) => byKey.value.get(key))
    .filter((c): c is Conversation => c != null && !hideSet.value.has(getConversationKey(c)))
)
/** æ˜¯å¦å±•示「最近转发」段:keyword ä¸ºç©º + æœ‰æ•°æ®æ—¶æ‰å±•示,搜索时让位 */
const showRecentSection = computed(
  () => !keyword.value.trim() && recentForwardConversations.value.length > 0
)
/** å·²é€‰ä¼šè¯åˆ—表:按 selectedKeys æ•°ç»„顺序(即点击顺序)反查;过滤 hideSet é¿å…çˆ¶ç»„件动态隐藏的会话仍在右侧渲染 / æäº¤ */
const selectedConversations = computed(() =>
  props.selectedKeys
    .map((key) => byKey.value.get(key))
    .filter(
      (conversation): conversation is Conversation =>
        conversation != null && !hideSet.value.has(getConversationKey(conversation))
    )
)
/** å³æ æ ‡é¢˜æ–‡æ¡ˆï¼šå•选「发送给」、多选「分别发送给」 */
const sendTitle = computed(() => (props.selectedKeys.length > 1 ? '分别发送给' : '发送给'))
/** æ˜¯å¦å·²é€‰ä¸­ï¼šå·¦æ åœ†å½¢æŒ‡ç¤ºå™¨ / æœ€è¿‘转发头像角标共用 */
function isSelected(conversation: Conversation): boolean {
  return selectedSet.value.has(getConversationKey(conversation))
}
/** ã€Œæœ€è¿‘转发」头像点击:移除模式下不切勾选(移除由 Ã— è§’标处理) */
function handleRecentTileClick(conversation: Conversation) {
  if (recentRemoveMode.value) {
    return
  }
  handleToggle(conversation)
}
/** åˆ‡æ¢é€‰ä¸­æ€ï¼šå·¦æ  row / æœ€è¿‘转发头像 / å³æ  Ã— ç§»é™¤éƒ½èµ°è¿™é‡Œ */
function handleToggle(conversation: Conversation) {
  const key = getConversationKey(conversation)
  const next = [...props.selectedKeys]
  const index = next.indexOf(key)
  if (index === -1) {
    // çˆ¶ç»„件标记隐藏的会话即便有路径触达也不应入选
    if (hideSet.value.has(key)) {
      return
    }
    if (props.maxSize > 0 && next.length >= props.maxSize) {
      message.error(`最多选择 ${props.maxSize} ä¸ªä¼šè¯`)
      return
    }
    next.push(key)
  } else {
    next.splice(index, 1)
  }
  emit('update:selectedKeys', next)
}
</script>
<template>
  <!--
    ä¼šè¯é€‰æ‹©é¢æ¿ï¼šç”¨äºŽæŽ¨èåç‰‡ / è½¬å‘消息等"选已有会话"场景
    - å·¦ï¼šæœç´¢ + æœ€è¿‘转发横向头像 + åˆ›å»ºèŠå¤©å…¥å£ + æœ€è¿‘聊天列表(圆形勾选)
    - å³ï¼šå·²é€‰æ•°æ ‡é¢˜ + å·²é€‰ä¼šè¯åˆ—表(按点击顺序)+ footer slot
    - Panel ä¸å¸¦ el-dialog å£³ï¼›dialog ç”±ä¸šåŠ¡å£³æŒæœ‰
    - footer slot æ¸²æŸ“在右栏已选列表下方,业务壳放预览卡 / ç•™è¨€ / æäº¤æŒ‰é’®
  -->
  <div class="flex h-full">
    <!-- å·¦æ  -->
    <div
      class="flex flex-col w-[280px] border-r border-r-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]"
    >
      <!-- æœç´¢æ¡† -->
      <div class="flex-shrink-0 px-3 py-2">
        <Input v-model:value="keyword" placeholder="搜索" allow-clear>
          <template #prefix>
            <Icon icon="ant-design:search-outlined" />
          </template>
        </Input>
      </div>
      <div class="flex-1">
        <!-- æœ€è¿‘转发横向头像区:keyword ä¸ºç©º + æœ‰æœ€è¿‘转发数据时展示 -->
        <template v-if="showRecentSection">
          <div class="flex justify-between items-center pl-3 pr-2 pb-1.5">
            <span class="text-13px text-[var(--ant-color-text-secondary)]">最近转发</span>
            <span
              class="px-1 cursor-pointer text-13px text-[var(--ant-color-primary)] hover:opacity-80"
              @click="recentRemoveMode = !recentRemoveMode"
            >
              {{ recentRemoveMode ? '完成' : '移除' }}
            </span>
          </div>
          <div
            class="flex gap-2 pl-3 pr-2 pt-1 pb-2 overflow-x-auto im-conversation-picker__recent"
          >
            <div
              v-for="conversation in recentForwardConversations"
              :key="getConversationKey(conversation)"
              class="flex flex-col flex-shrink-0 gap-1 items-center"
              :class="{ 'cursor-pointer': !recentRemoveMode }"
              @click="handleRecentTileClick(conversation)"
            >
              <div class="relative">
                <GroupAvatar
                  v-if="conversation.type === ImConversationType.GROUP"
                  :group-id="conversation.targetId"
                  :url="conversation.avatar"
                  :name="conversation.name"
                  :size="36"
                />
                <UserAvatar
                  v-else
                  :url="conversation.avatar"
                  :name="conversation.name"
                  :size="36"
                  :clickable="false"
                />
                <!-- ç§»é™¤æ¨¡å¼ï¼šå³ä¸Šè§’ Ã— åœ†è§’标,点击把这条 key ä»Ž recentForwardConversationKeys åˆ æŽ‰ -->
                <span
                  v-if="recentRemoveMode"
                  class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full cursor-pointer bg-[var(--ant-color-fill-dark)] text-[var(--ant-color-text)]"
                  @click.stop="emit('removeRecent', getConversationKey(conversation))"
                >
                  <Icon icon="ant-design:close-outlined" :size="10" />
                </span>
                <!-- éžç§»é™¤æ¨¡å¼ï¼šå³ä¸Šè§’圆形勾选指示器;未选灰空心圈、选中绿底白对勾 -->
                <span
                  v-else
                  class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full transition-colors"
                  :class="
                    isSelected(conversation)
                      ? 'bg-[#07c160] border border-solid border-[#07c160]'
                      : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
                  "
                >
                  <Icon
                    v-if="isSelected(conversation)"
                    icon="ant-design:check-outlined"
                    :size="10"
                    color="#fff"
                  />
                </span>
              </div>
              <span
                class="overflow-hidden max-w-[48px] text-12px truncate text-[var(--ant-color-text)]"
              >
                {{ conversation.name }}
              </span>
            </div>
          </div>
        </template>
        <!-- åˆ›å»ºèŠå¤©å…¥å£ï¼škeyword ä¸ºç©º + showCreateChat=true æ—¶å±•示 -->
        <div
          v-if="showCreateChat && !keyword.trim()"
          class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--ant-color-fill)]"
          @click="emit('createChat')"
        >
          <span
            class="flex flex-shrink-0 justify-center items-center w-8 h-8 rounded-full bg-[var(--ant-color-fill)] text-[var(--ant-color-text-secondary)]"
          >
            <Icon icon="ant-design:plus-outlined" :size="16" />
          </span>
          <span class="text-sm text-[var(--ant-color-text)]">创建聊天</span>
        </div>
        <!-- æœ€è¿‘聊天分组标题 -->
        <div class="px-3 pb-1.5 text-13px text-[var(--ant-color-text-secondary)]">最近聊天</div>
        <!-- ä¼šè¯åˆ—表 -->
        <div
          v-for="conversation in shownConversations"
          :key="getConversationKey(conversation)"
          class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--ant-color-fill)]"
          @click="handleToggle(conversation)"
        >
          <!-- åœ†å½¢å‹¾é€‰æŒ‡ç¤ºå™¨ï¼šæœªé€‰ç°è‰²ç©ºå¿ƒåœ†ï¼Œé€‰ä¸­å®žå¿ƒå¾®ä¿¡ç»¿ + ç™½å¯¹å‹¾ -->
          <span
            class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
            :class="
              isSelected(conversation)
                ? 'bg-[#07c160] border border-solid border-[#07c160]'
                : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
            "
          >
            <Icon
              v-if="isSelected(conversation)"
              icon="ant-design:check-outlined"
              :size="12"
              color="#fff"
            />
          </span>
          <GroupAvatar
            v-if="conversation.type === ImConversationType.GROUP"
            :group-id="conversation.targetId"
            :url="conversation.avatar"
            :name="conversation.name"
            :size="32"
          />
          <UserAvatar
            v-else
            :url="conversation.avatar"
            :name="conversation.name"
            :size="32"
            :clickable="false"
          />
          <span
            class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
          >
            {{ conversation.name }}
          </span>
        </div>
        <!-- ç©ºæ€ -->
        <div
          v-if="shownConversations.length === 0"
          class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
        >
          {{ keyword ? '没有满足条件的会话' : '暂无会话' }}
        </div>
      </div>
    </div>
    <!-- å³æ  -->
    <div class="flex flex-col flex-1 min-w-0">
      <!-- æ ‡é¢˜ï¼šé€‰ 0/1「发送给」、多个「分别发送给」(与微信文案一致) -->
      <div
        class="flex-shrink-0 px-4 py-3 border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]"
      >
        {{ sendTitle }}
      </div>
      <!-- å·²é€‰é¢„览:按 selectedKeys æ•°ç»„顺序(点击顺序)展示 -->
      <div class="flex-1">
        <div
          v-for="conversation in selectedConversations"
          :key="getConversationKey(conversation)"
          class="flex gap-2.5 items-center px-4 py-2"
        >
          <GroupAvatar
            v-if="conversation.type === ImConversationType.GROUP"
            :group-id="conversation.targetId"
            :url="conversation.avatar"
            :name="conversation.name"
            :size="32"
          />
          <UserAvatar
            v-else
            :url="conversation.avatar"
            :name="conversation.name"
            :size="32"
            :clickable="false"
          />
          <span
            class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
          >
            {{ conversation.name }}
          </span>
          <Icon
            icon="ant-design:close-outlined"
            :size="14"
            class="flex-shrink-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
            @click="handleToggle(conversation)"
          />
        </div>
        <div
          v-if="selectedConversations.length === 0"
          class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
        >
          ä»Žå·¦ä¾§é€‰æ‹©å¥½å‹æˆ–群聊
        </div>
      </div>
      <!-- ä¸šåŠ¡å£³å¡žé¢„è§ˆå¡ / ç•™è¨€ / æäº¤æŒ‰é’®çš„位置 -->
      <div
        v-if="$slots.footer"
        class="flex-shrink-0 border-t border-t-solid border-[var(--ant-color-border-secondary)]"
      >
        <slot name="footer"></slot>
      </div>
    </div>
  </div>
</template>
<style scoped>
/* æ¨ªå‘滚动条做窄一点避免占视觉;走 ::-webkit-scrollbar æµè§ˆå™¨ä¼ªå…ƒç´  */
.im-conversation-picker__recent::-webkit-scrollbar {
  height: 4px;
}
.im-conversation-picker__recent::-webkit-scrollbar-thumb {
  background-color: var(--ant-color-border);
  border-radius: 2px;
}
</style>
src/views/im/home/components/picker/friend-picker-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,245 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Input, message } from 'ant-design-vue'
import { PagedScroller } from '..'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
import { useSelectedItems } from '../../composables/useSelectedItems'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendPickerPanel' })
const props = withDefaults(
  defineProps<{
    /** ç¦ç”¨ id:列表里展示置灰、不可勾选、不计入已选数(典型:邀请入群时已在群成员) */
    disabledIds?: number[]
    /** å…¨é‡å¥½å‹åˆ—表 */
    friends: FriendLite[]
    /** éšè— id:不展示(hide > locked > disabled) */
    hideIds?: number[]
    /** é”å®š id:默认勾选、不可取消、计入已选数(典型:私聊侧 +建群锁定对方) */
    lockedIds?: number[]
    /** å·²é€‰æ•°ä¸Šé™ï¼›ä¸ä¼ æˆ– <=0 æ—¶ä¸é™ */
    maxSize?: number
    /** å·²é€‰å¥½å‹ id(v-model);按数组顺序即为点击顺序 */
    selectedIds: number[]
  }>(),
  {
    lockedIds: () => [],
    disabledIds: () => [],
    hideIds: () => [],
    maxSize: 0
  }
)
const emit = defineEmits<{
  'update:selectedIds': [value: number[]]
}>()
const keyword = ref('')
/** id â†’ friend æ˜ å°„,已选反查 / ä¸‰æ€åˆ¤å®šå…±ç”¨ï¼Œé¿å…æ¯æ¬¡ O(N) æ‰« */
const byId = computed(() => {
  const map = new Map<number, FriendLite>()
  for (const friend of props.friends) {
    map.set(friend.id, friend)
  }
  return map
})
/** ä¸‰æ€ id é›†åˆï¼šæ¯æ¬¡è¿‡æ»¤å¤ç”¨ */
const hideSet = computed(() => new Set(props.hideIds))
const lockedSet = computed(() => new Set(props.lockedIds))
const disabledSet = computed(() => new Set(props.disabledIds))
const selectedSet = computed(() => new Set(props.selectedIds))
/** å€™é€‰å¥½å‹ï¼šå‰”除 hideIds(hide ä¼˜å…ˆçº§æœ€é«˜ï¼‰ */
const candidates = computed(() =>
  props.friends.filter((friend) => !hideSet.value.has(friend.id))
)
/** å§”托 useFriendBuckets:搜索规则复用,左侧列表按滚动分页渲染 */
const { filtered } = useFriendBuckets(candidates, keyword)
/** å·²é€‰æ•° + å·²é€‰å¥½å‹åˆ—表:三态优先级 + é¡ºåºæ‹¼æŽ¥ç”± useSelectedItems ç»Ÿä¸€æ‰¿æ‹… */
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
  () => props.selectedIds,
  () => props.lockedIds,
  () => props.disabledIds,
  () => props.hideIds,
  byId
)
/** æ˜¯å¦è¢«é”å®š */
function isLocked(friend: FriendLite): boolean {
  return lockedSet.value.has(friend.id)
}
/** æ˜¯å¦è¢«ç¦ç”¨ï¼šlocked / hide å·²è¢«å‰ç½®è¿‡æ»¤ï¼Œå‰©ä¸‹çš„æ‰ç®— disabled */
function isDisabled(friend: FriendLite): boolean {
  return !lockedSet.value.has(friend.id) && disabledSet.value.has(friend.id)
}
/** æ˜¯å¦é€‰ä¸­ï¼šlocked è§†ä¸ºæ°¸è¿œé€‰ä¸­ */
function isSelected(friend: FriendLite): boolean {
  return selectedSet.value.has(friend.id)
}
/** åœ†å½¢å‹¾é€‰æŒ‡ç¤ºå™¨çš„ class:选中 / é”å®šèµ°ç»¿åº•,禁用灰底,未选空心圆 */
function getCheckClass(friend: FriendLite): string {
  if (isLocked(friend) || isSelected(friend)) {
    return 'bg-[#07c160] border border-solid border-[#07c160]'
  }
  if (isDisabled(friend)) {
    return 'bg-[var(--ant-color-fill)] border border-solid border-[var(--ant-color-border)]'
  }
  return 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
}
/** åˆ‡æ¢é€‰ä¸­æ€ï¼šlocked / disabled ä¸å“åº”;右栏 Ã— ç§»é™¤ / è¡Œ click éƒ½èµ°è¿™é‡Œ */
function handleToggle(friend: FriendLite) {
  if (isLocked(friend) || isDisabled(friend)) {
    return
  }
  const next = [...props.selectedIds]
  const index = next.indexOf(friend.id)
  if (index === -1) {
    if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
      message.error(`最多选择 ${props.maxSize} ä½å¥½å‹`)
      return
    }
    next.push(friend.id)
  } else {
    next.splice(index, 1)
  }
  emit('update:selectedIds', next)
}
</script>
<template>
  <!--
    å¥½å‹é€‰æ‹©é¢æ¿ï¼šç”¨äºŽã€Œæ–°å»ºç¾¤èŠ / é‚€è¯·å¥½å‹ / æŽ¨èæ—¶åˆ›å»ºèŠå¤©ã€ç­‰å¥½å‹é€‰æ‹©åœºæ™¯
    - å·¦ï¼šæœç´¢ + å¥½å‹åˆ—表(圆形勾选)
    - å³ï¼šå·²é€‰æ•°æ ‡é¢˜ + å·²é€‰å¥½å‹åˆ—表(按点击顺序)
    - Panel ä¸å¸¦ el-dialog å£³ï¼›dialog ç”±ä¸šåŠ¡å£³æŒæœ‰
    - ä¸‰æ€è¯­ä¹‰ï¼šhide > locked > disabled(详见 contract)
  -->
  <div class="flex h-full">
    <!-- å·¦æ  -->
    <div
      class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]"
    >
      <!-- æœç´¢æ¡† -->
      <div class="flex-shrink-0 px-3 py-2">
        <Input v-model:value="keyword" placeholder="搜索好友" allow-clear>
          <template #prefix>
            <Icon icon="ant-design:search-outlined" />
          </template>
        </Input>
      </div>
      <div class="flex-1 min-h-0">
        <PagedScroller v-if="filtered.length > 0" :items="filtered" :page-size="30" item-key="id">
          <template #default="{ item }">
            <div
              :key="(item as FriendLite).id"
              class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--ant-color-fill)]"
              :class="{
                'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(item as FriendLite)
              }"
              @click="handleToggle(item as FriendLite)"
            >
              <!-- åœ†å½¢å‹¾é€‰æŒ‡ç¤ºå™¨ï¼šæœªé€‰ç°è‰²ç©ºå¿ƒåœ†ï¼Œé€‰ä¸­å®žå¿ƒå¾®ä¿¡ç»¿ + ç™½å¯¹å‹¾ï¼›é”å®š / ç¦ç”¨èµ°ç°åº• -->
              <span
                class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
                :class="getCheckClass(item as FriendLite)"
              >
                <Icon
                  v-if="isSelected(item as FriendLite) || isLocked(item as FriendLite)"
                  icon="ant-design:check-outlined"
                  :size="12"
                  color="#fff"
                />
              </span>
              <UserAvatar
                :id="(item as FriendLite).id"
                :url="(item as FriendLite).avatar"
                :name="(item as FriendLite).nickname"
                :size="36"
                :clickable="false"
              />
              <!-- è¡Œå†…名字:备注优先,列表里不重复展示昵称 -->
              <span
                class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
              >
                {{ (item as FriendLite).displayName || (item as FriendLite).nickname }}
              </span>
            </div>
          </template>
        </PagedScroller>
        <div v-else class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]">
          {{ keyword ? '没有匹配的好友' : '暂无好友' }}
        </div>
      </div>
    </div>
    <!-- å³æ  -->
    <div class="flex flex-col flex-1 min-w-0">
      <!-- æ ‡é¢˜ï¼šå·²é€‰æ•°ï¼›é«˜åº¦å¯¹é½å·¦ä¾§ input default(32px),保证两侧第一项起点同水平 -->
      <div
        class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]"
      >
        å·²é€‰æ‹© {{ selectedCount }} ä¸ªå¥½å‹
      </div>
      <!-- å·²é€‰é¢„览:按 selectedIds + lockedIds æ•°ç»„顺序(点击顺序)展示 -->
      <div class="flex-1">
        <div
          v-for="friend in selectedFriends"
          :key="friend.id"
          class="flex gap-2.5 items-center px-4 py-2"
        >
          <UserAvatar
            :id="friend.id"
            :url="friend.avatar"
            :name="friend.nickname"
            :size="36"
            :clickable="false"
          />
          <span
            class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
          >
            {{ friend.displayName || friend.nickname }}
          </span>
          <!-- é”å®šé¡¹ä¸æ¸²æŸ“ Ã—,避免误以为可移除 -->
          <Icon
            v-if="!isLocked(friend)"
            icon="ant-design:close-outlined"
            :size="14"
            class="flex-shrink-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
            @click="handleToggle(friend)"
          />
        </div>
        <div
          v-if="selectedFriends.length === 0"
          class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
        >
          è¯·ä»Žå·¦ä¾§é€‰æ‹©å¥½å‹
        </div>
      </div>
      <!-- ä¸šåŠ¡å£³å¡žé¢å¤–å†…å®¹çš„ä½ç½®ï¼›FriendPickerPanel ä¸»æµåœºæ™¯ä¸éœ€è¦ footer -->
      <div
        v-if="$slots.footer"
        class="flex-shrink-0 border-t border-t-solid border-[var(--ant-color-border-secondary)]"
      >
        <slot name="footer"></slot>
      </div>
    </div>
  </div>
</template>
src/views/im/home/components/picker/group-member-picker-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,242 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../group'
import { computed, ref } from 'vue'
import { CommonStatusEnum } from '#/packages/constants/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Input, message } from 'ant-design-vue'
import { PagedScroller } from '..'
import { useSelectedItems } from '../../composables/useSelectedItems'
import { GroupMemberGrid, GroupMemberItem } from '../group'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMemberPickerPanel' })
const props = withDefaults(
  defineProps<{
    /** ç¦ç”¨ userId:列表里展示置灰、不可勾选、不计入已选数 */
    disabledIds?: number[]
    /** éšè— userId:不展示(hide > locked > disabled) */
    hideIds?: number[]
    /** é”å®š userId:默认勾选、不可取消、计入已选数 */
    lockedIds?: number[]
    /** å·²é€‰æ•°ä¸Šé™ï¼›ä¸ä¼ æˆ– <=0 æ—¶ä¸é™ */
    maxSize?: number
    /** ç¾¤æˆå‘˜åˆ—表 */
    members: GroupMemberLite[]
    /** å·²é€‰åŒºå±•示形态:默认 list å¯¹é½å¾®ä¿¡æ–°è§†è§‰ */
    selectedDisplay?: 'grid' | 'list'
    /** å·²é€‰ userId(v-model);按数组顺序即为点击顺序 */
    selectedIds: number[]
  }>(),
  {
    lockedIds: () => [],
    disabledIds: () => [],
    hideIds: () => [],
    maxSize: 0,
    selectedDisplay: 'list'
  }
)
const emit = defineEmits<{
  'update:selectedIds': [value: number[]]
}>()
const keyword = ref('')
/** userId â†’ member æ˜ å°„,已选反查 / ä¸‰æ€åˆ¤å®šå…±ç”¨ */
const byId = computed(() => {
  const map = new Map<number, GroupMemberLite>()
  for (const member of props.members) {
    map.set(member.userId, member)
  }
  return map
})
const hideSet = computed(() => new Set(props.hideIds))
const lockedSet = computed(() => new Set(props.lockedIds))
const disabledSet = computed(() => new Set(props.disabledIds))
const selectedSet = computed(() => new Set(props.selectedIds))
/** å½“前展示的成员:剔除 hideIds、剔除已退群(DISABLE)、按关键字大小写无关过滤 */
const shownMembers = computed(() => {
  const keywordLower = keyword.value.trim().toLowerCase()
  return props.members.filter(
    (member) =>
      !hideSet.value.has(member.userId) &&
      member.status !== CommonStatusEnum.DISABLE &&
      (!keywordLower || member.showName.toLowerCase().includes(keywordLower))
  )
})
/** å·²é€‰æ•° + å·²é€‰æˆå‘˜åˆ—表:三态优先级 + é¡ºåºæ‹¼æŽ¥ç”± useSelectedItems ç»Ÿä¸€æ‰¿æ‹… */
const { selectedCount, selectedItems: selectedMembers } = useSelectedItems<GroupMemberLite>(
  () => props.selectedIds,
  () => props.lockedIds,
  () => props.disabledIds,
  () => props.hideIds,
  byId
)
/** æ˜¯å¦è¢«é”å®š */
function isLocked(member: GroupMemberLite): boolean {
  return lockedSet.value.has(member.userId)
}
/** æ˜¯å¦è¢«ç¦ç”¨ï¼šlocked / hide å·²è¢«å‰ç½®è¿‡æ»¤ï¼Œå‰©ä¸‹çš„æ‰ç®— disabled */
function isDisabled(member: GroupMemberLite): boolean {
  return !lockedSet.value.has(member.userId) && disabledSet.value.has(member.userId)
}
/** æ˜¯å¦é€‰ä¸­ï¼šlocked è§†ä¸ºæ°¸è¿œé€‰ä¸­ */
function isSelected(member: GroupMemberLite): boolean {
  return selectedSet.value.has(member.userId)
}
/** åœ†å½¢å‹¾é€‰æŒ‡ç¤ºå™¨çš„ class */
function getCheckClass(member: GroupMemberLite): string {
  if (isLocked(member) || isSelected(member)) {
    return 'bg-[#07c160] border border-solid border-[#07c160]'
  }
  if (isDisabled(member)) {
    return 'bg-[var(--ant-color-fill)] border border-solid border-[var(--ant-color-border)]'
  }
  return 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
}
/** åˆ‡æ¢é€‰ä¸­æ€ï¼šlocked / disabled ä¸å“åº”;右栏 Ã— / è¡Œ click éƒ½èµ°è¿™é‡Œ */
function handleToggle(member: GroupMemberLite) {
  if (isLocked(member) || isDisabled(member)) {
    return
  }
  const next = [...props.selectedIds]
  const index = next.indexOf(member.userId)
  if (index === -1) {
    if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
      message.error(`最多选择 ${props.maxSize} ä½æˆå‘˜`)
      return
    }
    next.push(member.userId)
  } else {
    next.splice(index, 1)
  }
  emit('update:selectedIds', next)
}
</script>
<template>
  <!--
    ç¾¤æˆå‘˜é€‰æ‹©é¢æ¿ï¼šç”¨äºŽã€Œç§»é™¤æˆå‘˜ / è®¾ç½®ç®¡ç†å‘˜ / è½¬è®©ç¾¤ä¸» / ç¦è¨€æˆå‘˜ã€ç­‰åœºæ™¯
    - å·¦ï¼šæœç´¢ + ç¾¤æˆå‘˜åˆ—表(圆形勾选)
    - å³ï¼šå·²é€‰æ•°æ ‡é¢˜ + å·²é€‰æˆå‘˜ï¼ˆlist / grid ä¸¤ç§å½¢æ€å¯é…ï¼Œé»˜è®¤ list å¯¹é½å¾®ä¿¡æ–°è§†è§‰ï¼‰
    - Panel ä¸å¸¦ el-dialog å£³ï¼›dialog ç”±ä¸šåŠ¡å£³æŒæœ‰
    - ä¸‰æ€è¯­ä¹‰ï¼šhide > locked > disabled
  -->
  <div class="flex h-full">
    <!-- å·¦æ  -->
    <div
      class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]"
    >
      <!-- æœç´¢æ¡† -->
      <div class="flex-shrink-0 px-3 py-2">
        <Input v-model:value="keyword" placeholder="搜索成员" allow-clear>
          <template #prefix>
            <Icon icon="ant-design:search-outlined" />
          </template>
        </Input>
      </div>
      <div class="flex-1 min-h-0">
        <PagedScroller :items="shownMembers" :page-size="30" item-key="userId">
          <template #default="{ item }">
            <GroupMemberItem
              :member="item as GroupMemberLite"
              :height="46"
              @click="handleToggle(item as GroupMemberLite)"
            >
              <span
                class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
                :class="getCheckClass(item as GroupMemberLite)"
              >
                <Icon
                  v-if="isSelected(item as GroupMemberLite) || isLocked(item as GroupMemberLite)"
                  icon="ant-design:check-outlined"
                  :size="12"
                  color="#fff"
                />
              </span>
            </GroupMemberItem>
          </template>
        </PagedScroller>
      </div>
    </div>
    <!-- å³æ  -->
    <div class="flex flex-col flex-1 min-w-0">
      <!-- æ ‡é¢˜ï¼šå·²é€‰æ•°ï¼›é«˜åº¦å¯¹é½å·¦ä¾§ input default(32px) -->
      <div
        class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]"
      >
        å·²é€‰æ‹© {{ selectedCount }} ä½æˆå‘˜
      </div>
      <div class="flex-1">
        <!-- list å½¢æ€ï¼šçºµå‘行,每行带 Ã— ç§»é™¤ï¼ˆlocked ä¸æ¸²æŸ“ Ã—) -->
        <template v-if="selectedDisplay === 'list'">
          <div
            v-for="member in selectedMembers"
            :key="member.userId"
            class="flex gap-2.5 items-center px-4 py-2"
          >
            <UserAvatar
              :id="member.userId"
              :url="member.avatar"
              :name="member.nickname"
              :size="36"
              :clickable="false"
            />
            <span
              class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
            >
              {{ member.showName }}
            </span>
            <Icon
              v-if="!isLocked(member)"
              icon="ant-design:close-outlined"
              :size="14"
              class="flex-shrink-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
              @click="handleToggle(member)"
            />
          </div>
        </template>
        <!-- grid å½¢æ€ï¼šå®«æ ¼é¢„览;非 locked æˆå‘˜å³ä¸Šè§’叠加 Ã— ç§»é™¤ï¼ˆlocked ä¸æ¸²æŸ“) -->
        <div v-else class="flex flex-wrap p-2.5">
          <GroupMemberGrid
            v-for="member in selectedMembers"
            :key="member.userId"
            :member="member"
          >
            <Icon
              v-if="!isLocked(member)"
              icon="ant-design:close-circle-filled"
              :size="16"
              class="absolute top-0 right-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
              @click="handleToggle(member)"
            />
          </GroupMemberGrid>
        </div>
        <div
          v-if="selectedMembers.length === 0"
          class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
        >
          è¯·ä»Žå·¦ä¾§é€‰æ‹©æˆå‘˜
        </div>
      </div>
    </div>
  </div>
</template>
src/views/im/home/components/picker/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
export { default as ConversationPickerPanel } from './conversation-picker-panel.vue';
export { default as FriendPickerPanel } from './friend-picker-panel.vue';
export { default as GroupMemberPickerPanel } from './group-member-picker-panel.vue';
src/views/im/home/components/picker/picker-dialog.scss
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
// IM é€‰æ‹©ç±»å¼¹çª—的公共样式 mixin
// æ¯ä¸ªä¸šåŠ¡å£³åœ¨è‡ªå·±çš„ <style scoped lang="scss"> å†… @use + @include å³å¯ï¼Œé¿å…å…¨å±€æ±¡æŸ“
@mixin styles {
  :deep(.el-dialog__body) {
    padding: 0;
    border-top: 1px solid var(--ant-color-border-secondary);
  }
  :deep(.el-dialog__header) {
    margin-right: 0;
    padding-bottom: 16px;
  }
}
src/views/im/home/components/resizable-aside.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,111 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
defineOptions({ name: 'ImResizableAside' })
const props = withDefaults(
  defineProps<{
    defaultWidth?: number // é»˜è®¤å®½åº¦
    maxWidth?: number // æœ€å¤§å®½åº¦
    minWidth?: number // æœ€å°å®½åº¦
    storageKey: string // localStorage å­˜å‚¨ key,必填;调用方传 StorageKeys.localStorage.asideWidth
  }>(),
  {
    defaultWidth: 260,
    minWidth: 200,
    maxWidth: 500
  }
)
const asideWidth = ref<number>(props.defaultWidth)
const isResizing = ref(false)
let startX = 0
let startWidth = 0
onMounted(() => {
  const saved = localStorage.getItem(props.storageKey)
  if (saved) {
    const w = Number.parseInt(saved, 10)
    if (!Number.isNaN(w)) {
      asideWidth.value = clamp(w)
    }
  }
  document.addEventListener('mousemove', handleResize)
  document.addEventListener('mouseup', stopResize)
})
onBeforeUnmount(() => {
  // æ‹–拽中卸载:复用 stopResize å¤ä½ body cursor/userSelect å¹¶å†™å›žå½“前宽度,避免全局状态泄漏
  if (isResizing.value) {
    stopResize()
  }
  document.removeEventListener('mousemove', handleResize)
  document.removeEventListener('mouseup', stopResize)
})
/** æŠŠå®½åº¦å¤¹åˆ° [minWidth, maxWidth] åŒºé—´ï¼Œæ¢å¤ / æ‹–拽路径都走它兜底 */
function clamp(w: number) {
  return Math.max(props.minWidth, Math.min(props.maxWidth, w))
}
/** æŒ‰ä¸‹æ‹–拽手柄:记录起始位置 + é”å®š body cursor/userSelect,避免拖拽中误选文本 */
function startResize(e: MouseEvent) {
  isResizing.value = true
  startX = e.clientX
  startWidth = asideWidth.value
  document.body.style.cursor = 'col-resize'
  document.body.style.userSelect = 'none'
  e.preventDefault()
}
/** æ‹–拽中:按鼠标位移计算新宽度并 clamp åˆ°å…è®¸åŒºé—´ */
function handleResize(e: MouseEvent) {
  if (!isResizing.value) {
    return
  }
  const deltaX = e.clientX - startX
  asideWidth.value = clamp(startWidth + deltaX)
}
/** æ¾å¼€é¼ æ ‡ï¼šè§£é” body å…¨å±€æ€å¹¶æŠŠå½“前宽度写入 localStorage æŒä¹…化 */
function stopResize() {
  if (!isResizing.value) {
    return
  }
  isResizing.value = false
  document.body.style.cursor = ''
  document.body.style.userSelect = ''
  localStorage.setItem(props.storageKey, String(asideWidth.value))
}
</script>
<template>
  <!--
    å¯æ‹–拽宽度的左侧 Aside
    - ä½¿ç”¨ localStorage è®°ä½ç”¨æˆ·ä¸Šæ¬¡è°ƒæ•´çš„宽度(storageKey å¿…填)
    - æ‹–拽区在右边缘,鼠标变 col-resize
  -->
  <aside
    class="relative flex flex-col shrink-0 bg-[var(--ant-color-fill-secondary)] border-r border-r-solid border-[var(--im-border-color-lighter)] shadow-[2px_0_8px_rgba(0,0,0,0.05)]"
    :style="{ width: `${asideWidth }px` }"
  >
    <slot></slot>
    <div
      class="im-resizable-aside__handle absolute top-0 right--0.75 z-10 flex items-center justify-center w-1.5 h-full cursor-col-resize transition-colors"
      :class="{ 'is-resizing': isResizing }"
      title="拖拽调整宽度"
      @mousedown="startResize"
    >
      <div class="im-resizable-aside__line w-0.5 h-full rounded-0.5 bg-transparent transition-all"></div>
    </div>
  </aside>
</template>
<style scoped>
/* hover / æ‹–拽中 æŠŠå†…部 line åŠ ç²—å˜æ·±ï¼Œæç¤ºæ‰‹æŸ„å¯æ‹–ï¼›çŠ¶æ€åœ¨çˆ¶ handle ä¸Š â†’ é€šè¿‡åŽä»£é€‰æ‹©å™¨è”动子 line */
.im-resizable-aside__handle:hover .im-resizable-aside__line,
.im-resizable-aside__handle.is-resizing .im-resizable-aside__line {
  width: 3px;
  background-color: var(--im-resize-line-color);
}
</style>
src/views/im/home/components/rtc/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
export { default as RtcCallContainer } from './rtc-call-container.vue';
export { default as RtcCallIncoming } from './rtc-call-incoming.vue';
export { default as RtcCallInviting } from './rtc-call-inviting.vue';
export { default as RtcCallMemberPickerDialog } from './rtc-call-member-picker-dialog.vue';
export { default as RtcCallParticipantTile } from './rtc-call-participant-tile.vue';
export type { CallParticipantVM } from './rtc-call-participant-tile.vue';
export { default as RtcCallRunning } from './rtc-call-running.vue';
export { default as RtcGroupCallBanner } from './rtc-group-call-banner.vue';
src/views/im/home/components/rtc/rtc-call-container.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,488 @@
<script lang="ts" setup>
import type { CallParticipantVM } from './rtc-call-participant-tile.vue'
import { computed, ref, watch } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { message } from 'ant-design-vue'
import { Track } from 'livekit-client'
import {
  acceptCall,
  cancelCall,
  inviteCall,
  leaveCall,
  noAnswerCallCheck,
  rejectCall
} from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '#/views/im/utils/config'
import {
  ImConversationType,
  ImRtcCallMediaType,
  ImRtcCallStage
} from '#/views/im/utils/constants'
import { getSenderAvatar, getSenderDisplayName } from '#/views/im/utils/user'
import { useLiveKitRoom } from '../../composables/useLiveKitRoom'
import { useRtcStore } from '../../store/rtcStore'
import RtcCallIncoming from './rtc-call-incoming.vue'
import RtcCallInviting from './rtc-call-inviting.vue'
import RtcCallMemberPickerDialog from './rtc-call-member-picker-dialog.vue'
import RtcCallRunning from './rtc-call-running.vue'
defineOptions({ name: 'ImRtcCallContainer' })
const rtcStore = useRtcStore()
const lk = useLiveKitRoom()
const memberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
const connecting = ref(false)
const accepting = ref(false)
const rejecting = ref(false)
const cancelling = ref(false)
const hangingUp = ref(false)
// ==================== è§†å›¾æ¨¡åž‹ ====================
/** å½“前是否视频通话 */
const isVideo = computed(() => {
  const t =
    rtcStore.call?.mediaType ||
    rtcStore.incomingPayload?.mediaType ||
    ImRtcCallMediaType.VOICE
  return t === ImRtcCallMediaType.VIDEO
})
/** å½“前是否群通话;决定浮动窗大小 */
const isGroup = computed(
  () =>
    (rtcStore.call?.conversationType ??
      rtcStore.incomingPayload?.conversationType) === ImConversationType.GROUP
)
/** åˆå§‹æ‘„像头是否打开;群通话默认全部关闭,进入后用户主动开 */
const initialCamera = computed(() => {
  if (rtcStore.call?.conversationType === ImConversationType.GROUP) {
    return false
  }
  return isVideo.value
})
/** æœ¬ç«¯è§†é¢‘流;优先 ScreenShare(屏共时也铺底),无则 Camera;显式订阅 screenShareEnabled / cameraEnabled è§¦å‘重算 */
const localStream = computed<MediaStream | null>(() => {
  // è§¦æ‘¸å“åº”式依赖,确保切屏共享 / æ‘„像头后 computed é‡æ–°æ±‚值(pickStream å†…部用普通 Map ç¼“存,自身不响应)
  void lk.screenShareEnabled.value
  void lk.cameraEnabled.value
  const lp = lk.localParticipant.value
  if (!lp) {
    return null
  }
  return lk.pickStream(lp, Track.Source.ScreenShare) || lk.pickStream(lp, Track.Source.Camera)
})
/** è¿œç«¯è§†é¢‘流(仅 1v1 ç”¨ï¼‰ï¼›ä¼˜å…ˆ ScreenShare,无则取 Camera */
const remoteVideoStream = computed<MediaStream | null>(() => {
  if (isGroup.value) {
    return null
  }
  for (const rp of lk.remoteParticipants.value) {
    const screen = lk.pickStream(rp, Track.Source.ScreenShare)
    if (screen) {
      return screen
    }
    const camera = lk.pickStream(rp, Track.Source.Camera)
    if (camera) {
      return camera
    }
  }
  return null
})
/** è¿œç«¯éŸ³é¢‘流(仅 1v1 ç”¨ï¼‰ */
const remoteAudioStream = computed<MediaStream | null>(() => {
  if (isGroup.value) {
    return null
  }
  for (const rp of lk.remoteParticipants.value) {
    const stream = lk.pickStream(rp, Track.Source.Microphone)
    if (stream) {
      return stream
    }
  }
  return null
})
/** ç¾¤é€šè¯ç½‘格用:自己 + è¿œç«¯åœ¨æˆ¿ + å¾…加入成员;昵称 / å¤´åƒèµ° user.ts helper è‡ªåŠ¨å¤„ç† self / ç¾¤æˆå‘˜ / å¥½å‹ / å…œåº• */
const participants = computed<CallParticipantVM[]>(() => {
  const call = rtcStore.call
  if (!call) {
    return []
  }
  const conversationType = call.conversationType
  const targetId = call.groupId ?? 0
  const myId = getCurrentUserId()
  const result: CallParticipantVM[] = [{
    userId: myId,
    nickname: getSenderDisplayName(myId, conversationType, targetId),
    avatar: getSenderAvatar(myId, conversationType, targetId) || undefined,
    isLocal: true,
    videoStream: localStream.value
  }]
  // å·²åŠ å…¥çš„è¿œç«¯ï¼šå®žé™…æŽ¨æµï¼›å±å¹•å…±äº«åœ¨ç½‘æ ¼é‡Œç‹¬å è¯¥æˆå‘˜çš„æ ¼å­ï¼Œæ— åˆ™é™çº§ Camera
  const joined = new Set<number>()
  for (const rp of lk.remoteParticipants.value) {
    const userId = Number(rp.identity)
    if (Number.isNaN(userId)) {
      continue
    }
    joined.add(userId)
    result.push({
      userId,
      nickname: getSenderDisplayName(userId, conversationType, targetId),
      avatar: getSenderAvatar(userId, conversationType, targetId) || undefined,
      isLocal: false,
      videoStream:
        lk.pickStream(rp, Track.Source.ScreenShare) || lk.pickStream(rp, Track.Source.Camera),
      audioStream: lk.pickStream(rp, Track.Source.Microphone)
    })
  }
  // ç¾¤é€šè¯ï¼šæœªåŠ å…¥çš„è¢«é‚€è¯·äººä½œä¸º pending å ä½ï¼›å·²é€€å‡º / å·²æ‹’绝的人不渲染
  if (conversationType === ImConversationType.GROUP) {
    const inviteeIds = call.inviteeIds || []
    for (const userId of inviteeIds) {
      if (userId === myId || joined.has(userId) || rtcStore.isUserLeft(userId)) {
        continue
      }
      result.push({
        userId,
        nickname: getSenderDisplayName(userId, ImConversationType.GROUP, targetId),
        avatar: getSenderAvatar(userId, ImConversationType.GROUP, targetId) || undefined,
        isLocal: false,
        pending: true
      })
    }
  }
  return result
})
// ==================== LiveKit è¿žæŽ¥ ====================
/** è¿žå…¥ LiveKit æˆ¿é—´å¹¶æ³¨å†Œç¦»å¼€å›žè°ƒï¼›INVITING ä¸»å«é¢„连和被叫 accept åŽè¿žå…¥å…±ç”¨ */
async function connectLiveKit(livekitUrl: string, token: string) {
  // å¹‚等:lk.connect å†…部进入后就把 room.value èµ‹å€¼ï¼›éžç©ºè¡¨ç¤ºå·²ç»åœ¨è¿žæŽ¥æˆ–已连接;stage å¤šæ¬¡åˆ‡æ¢æ—¶é‡å¤è§¦å‘也跳过
  if (lk.room.value || connecting.value) {
    return
  }
  connecting.value = true
  try {
    // å…ˆæ³¨å†Œå›žè°ƒï¼Œå† connect;信令握手过程会即时推送已在房参与者,业务 handler å¿…须先就绪
    lk.onDisconnected(() => handlePeerDisconnected())
    lk.onParticipantConnected(maybeEnterRunning)
    lk.onParticipantDisconnected((userId) => rtcStore.markUserLeft(userId))
    await lk.connect(livekitUrl, token, { audio: true, video: initialCamera.value })
    // å…œåº•:connect æœŸé—´è‹¥å·²æœ‰è¿œç«¯åœ¨æˆ¿ï¼Œäº‹ä»¶å¯èƒ½åœ¨ handler æ³¨å†Œå‰å·²è§¦å‘,主动切到 RUNNING
    if (lk.remoteParticipants.value.length > 0) {
      maybeEnterRunning()
    }
  } finally {
    connecting.value = false
  }
}
/** ä¸»å«ç«¯ï¼šä»Ž INVITING åˆ‡åˆ° RUNNING;其它阶段不处理 */
function maybeEnterRunning() {
  if (rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call) {
    rtcStore.enterRunning(rtcStore.call)
  }
}
watch(
  () => rtcStore.stage,
  async (stage) => {
    if (
      stage === ImRtcCallStage.INVITING &&
      rtcStore.call?.token &&
      rtcStore.call?.livekitUrl
    ) {
      try {
        await connectLiveKit(rtcStore.call.livekitUrl, rtcStore.call.token)
      } catch (error) {
        console.error('[Call] connect å¤±è´¥', { room: rtcStore.call?.room }, error)
        message.error('通话连接失败')
        await handleCancel()
      }
    }
    if (stage === ImRtcCallStage.IDLE) {
      await lk.disconnect()
    }
  }
)
/** è¢«å«ç«¯ accept åŽä¼šæ‹¿åˆ° token;这里监听 stage + token å˜åŒ–触发连接 */
watch(
  () => [rtcStore.stage, rtcStore.call?.token],
  async ([stage, token], [prevStage]) => {
    if (
      stage === ImRtcCallStage.RUNNING &&
      prevStage !== ImRtcCallStage.RUNNING &&
      token &&
      !lk.isConnected.value &&
      rtcStore.call?.livekitUrl
    ) {
      try {
        await connectLiveKit(rtcStore.call.livekitUrl, token as string)
      } catch (error) {
        console.error('[Call] accept connect å¤±è´¥', { room: rtcStore.call?.room }, error)
        message.error('通话连接失败')
        // åŽç«¯ accept å·²å†™ JOINED;前端连接失败需调 leave å›žæ»šï¼Œé¿å…åŽç«¯è®°å½•残留忙线
        if (rtcStore.call?.room) {
          leaveCall(rtcStore.call.room).catch(() => undefined)
        }
        rtcStore.reset()
      }
    }
  }
)
// ==================== é€šè¯ç”Ÿå‘½å‘¨æœŸ ====================
/** ä¸»å«å–消邀请 */
async function handleCancel() {
  if (cancelling.value) {
    return
  }
  cancelling.value = true
  const room = rtcStore.call?.room
  try {
    if (room) {
      await cancelCall(room)
    }
    await lk.disconnect()
    rtcStore.reset()
  } finally {
    cancelling.value = false
  }
}
/** è¢«å«æ‹’绝来电 */
async function handleReject() {
  if (rejecting.value) {
    return
  }
  rejecting.value = true
  const payload = rtcStore.incomingPayload
  try {
    if (payload?.room) {
      await rejectCall(payload.room)
      // æœ¬ç«¯å…ˆè¡Œä»Žèƒ¶å›Šæ¡ç§»é™¤è‡ªå·±ï¼Œå…ç­‰åŽç«¯ RTC_CALL(REJECTED) æŽ¨å›žï¼›ç§èŠåœºæ™¯ store å†…部 no-op
      rtcStore.applyParticipantRejected({
        room: payload.room,
        conversationType: payload.conversationType,
        groupId: payload.groupId,
        operatorUserId: getCurrentUserId()
      })
    }
    rtcStore.reset()
  } finally {
    rejecting.value = false
  }
}
/** è¢«å«æŽ¥å¬æ¥ç”µ */
async function handleAccept() {
  if (accepting.value) {
    return
  }
  const payload = rtcStore.incomingPayload
  if (!payload) return
  accepting.value = true
  try {
    const data = await acceptCall(payload.room)
    rtcStore.enterRunning(data)
  } finally {
    accepting.value = false
  }
}
/** é€šè¯ä¸­æŒ‚æ–­ */
async function handleHangup() {
  if (hangingUp.value) {
    return
  }
  hangingUp.value = true
  const call = rtcStore.call
  try {
    if (call?.room) {
      await leaveCall(call.room)
      // æœ¬ç«¯å…ˆè¡Œä»Žèƒ¶å›Šæ¡ç§»é™¤è‡ªå·±ï¼Œå…ç­‰åŽç«¯ RTC_PARTICIPANT_DISCONNECTED æŽ¨å›žï¼›ç§èŠåœºæ™¯ store å†…部 no-op,整通话由 END å…³æŽ‰
      rtcStore.applyParticipantDisconnected({
        room: call.room,
        userId: getCurrentUserId(),
        conversationType: call.conversationType,
        groupId: call.groupId
      })
    }
    await lk.disconnect()
    rtcStore.reset()
  } finally {
    hangingUp.value = false
  }
}
/** LiveKit Room å¼‚常断开;多见于网络中断 */
function handlePeerDisconnected() {
  if (!rtcStore.isActive) {
    return
  }
  const room = rtcStore.call?.room
  // ç»™ RTC_CALL_END WebSocket æŽ¨é€ä¸€ä¸ªå°çª—口;私聊超时 / ä¸»åŠ¨æŒ‚æ–­ç­‰åœºæ™¯ä¸‹ï¼ŒåŽç«¯ endSession ä¼šå…ˆæŽ¨ RTC_CALL_END,
  // è®©å‰ç«¯æŒ‰ä¸šåŠ¡è¯­ä¹‰ï¼ˆ"对方未接听" / "已取消" ç­‰ï¼‰reset,避免错把业务断开 toast æˆã€Œé€šè¯å·²æ–­å¼€ã€
  setTimeout(() => {
    if (!rtcStore.isActive) {
      return
    }
    // ä¸ŠæŠ¥ç¦»å¼€æˆ¿é—´
    if (room) {
      leaveCall(room).catch(() => undefined)
    }
    // æ¸…理本地通话状态
    message.warning('通话已断开')
    rtcStore.reset()
  }, 100)
}
// ==================== æŒ¯é“ƒè¶…时兜底 ====================
/** é€šè¯å­˜æ´»æœŸé—´ï¼ˆINVITING / INCOMING / RUNNING)周期性触发后端扫该 room çš„è¶…æ—¶ INVITING;保持 timer æ˜¯ä¸ºäº† inviteCall è¿½åŠ æ–°äººåŽä¹Ÿèƒ½è¦†ç›–ï¼›é˜ˆå€¼ç”±åŽç«¯é…ç½®å†³å®šï¼Œå‰ç«¯åªè´Ÿè´£ trigger */
const { resume: resumeNoAnswerTimer, pause: pauseNoAnswerTimer } = useIntervalFn(
  triggerNoAnswerCallCheck, RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS, { immediate: false }
)
watch(
  () => rtcStore.isActive,
  (active) => (active ? resumeNoAnswerTimer() : pauseNoAnswerTimer()),
  { immediate: true }
)
/** æœ¬åœ°ä»æœ‰ pending æ‰è°ƒï¼›INVITING / RUNNING å– call、INCOMING å– incomingPayload;接口静默错误 fire-and-forget */
function triggerNoAnswerCallCheck() {
  const source = rtcStore.call ?? rtcStore.incomingPayload
  if (!source?.room || !source.inviteeIds?.length) {
    return
  }
  noAnswerCallCheck(source.room).catch(() => undefined)
}
// ==================== è®¾å¤‡æŽ§åˆ¶ ====================
async function toggleMic() {
  await lk.setMicEnabled(!lk.micEnabled.value)
}
async function toggleCamera() {
  await lk.setCameraEnabled(!lk.cameraEnabled.value)
}
function toggleSpeaker() {
  lk.setSpeakerEnabled(!lk.speakerEnabled.value)
}
/** åˆ‡å±å¹•共享;浏览器弹原生「选择共享内容」对话框,用户取消时会抛错,UI ä¸å¼¹æç¤º */
async function handleScreenShare() {
  const enabled = !lk.screenShareEnabled.value
  try {
    await lk.setScreenShareEnabled(enabled)
  } catch (error: any) {
    // ç”¨æˆ·å–消选择,不当作错误;其它异常打日志
    if (error?.name !== 'NotAllowedError' && error?.message !== 'permission denied') {
      console.warn('[Call] screenShare åˆ‡æ¢å¤±è´¥', { enabled }, error)
    }
  }
}
// ==================== æ·»åŠ æˆå‘˜ ====================
/** æ‰“开「添加成员」弹窗;占位群通话 + æŽ¥é€šä¸­çŠ¶æ€æ‰å…è®¸ */
function openAddMember() {
  const call = rtcStore.call
  if (!call?.groupId) {
    return
  }
  memberPickerRef.value?.open({
    groupId: call.groupId,
    mode: 'add',
    excludeUserIds: participants.value.map((p) => p.userId)
  })
}
/** picker é€‰å®Œæˆå‘˜ï¼›èµ° invite è¿½åŠ é‚€è¯·æŽ¥å£ï¼ŒåŽç«¯æŽ¨ RTC_INVITE ç»™æ–°æˆå‘˜ */
async function handleAddMemberSuccess(userIds: number[]) {
  const call = rtcStore.call
  if (!call?.room || userIds.length === 0) {
    return
  }
  await inviteCall({ room: call.room, inviteeIds: userIds })
  // åŒæ­¥æœ¬åœ° inviteeIds,让新成员立即作为 pending å ä½å‡ºçŽ°åœ¨ç½‘æ ¼é‡Œ
  rtcStore.appendInvitees(userIds)
  message.success('已发送邀请')
}
</script>
<template>
  <!-- é€šè¯é˜¶æ®µå¯¹åº”弹窗;INVITING / INCOMING / RUNNING ä¸‰é€‰ä¸€äº’æ–¥ -->
  <template v-if="rtcStore.isActive">
    <!-- ä¸»å«ç«¯ï¼šç­‰å¾…对方接听 -->
    <RtcCallInviting
      v-if="rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call"
      :peer-nickname="rtcStore.peerNickname"
      :peer-avatar="rtcStore.peerAvatar"
      :is-group="isGroup"
      :is-video="isVideo"
      :mic-enabled="lk.micEnabled.value"
      :camera-enabled="lk.cameraEnabled.value"
      :speaker-enabled="lk.speakerEnabled.value"
      :local-stream="localStream"
      @cancel="handleCancel"
      @toggle-mic="toggleMic"
      @toggle-camera="toggleCamera"
      @toggle-speaker="toggleSpeaker"
    />
    <!-- è¢«å«ç«¯ï¼šæ¥ç”µå“é“ƒ -->
    <RtcCallIncoming
      v-else-if="rtcStore.stage === ImRtcCallStage.INCOMING"
      :payload="rtcStore.incomingPayload"
      :is-group="isGroup"
      :accepting="accepting"
      :rejecting="rejecting"
      @accept="handleAccept"
      @reject="handleReject"
    />
    <!-- é€šè¯è¿›è¡Œä¸­ï¼š1v1 è§†é¢‘ / è¯­éŸ³ + ç¾¤é€šè¯å®«æ ¼ -->
    <RtcCallRunning
      v-else-if="rtcStore.stage === ImRtcCallStage.RUNNING && rtcStore.call"
      :is-group="isGroup"
      :is-video="isVideo"
      :mic-enabled="lk.micEnabled.value"
      :camera-enabled="lk.cameraEnabled.value"
      :speaker-enabled="lk.speakerEnabled.value"
      :screen-share-enabled="lk.screenShareEnabled.value"
      :reconnecting="lk.reconnecting.value"
      :started-at="rtcStore.startedAt"
      :participants="participants"
      :peer-nickname="rtcStore.peerNickname"
      :peer-avatar="rtcStore.peerAvatar"
      :local-stream="localStream"
      :remote-video-stream="remoteVideoStream"
      :remote-audio-stream="remoteAudioStream"
      :hanging-up="hangingUp"
      @hangup="handleHangup"
      @toggle-mic="toggleMic"
      @toggle-camera="toggleCamera"
      @toggle-speaker="toggleSpeaker"
      @toggle-screen-share="handleScreenShare"
      @add-member="openAddMember"
    />
  </template>
  <!-- é€šè¯ä¸­ã€Œæ·»åŠ æˆå‘˜ã€é€‰äººå¼¹çª—ï¼›æŒ‚åœ¨ isActive å¤–,避免 stage åˆ‡æ¢çž¬é—´å¼¹çª—被卸载 -->
  <RtcCallMemberPickerDialog ref="memberPickerRef" @success="handleAddMemberSuccess" />
</template>
src/views/im/home/components/rtc/rtc-call-incoming.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
<script lang="ts" setup>
import type { ImRtcCallNotification } from '../../store/rtcStore'
import { computed } from 'vue'
import { DICT_TYPE } from '#/packages/constants/src'
import { getDictLabel } from '#/packages/effects/hooks/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { UserAvatar } from '../user'
const props = defineProps<{
  accepting?: boolean
  isGroup?: boolean
  payload: ImRtcCallNotification | null
  rejecting?: boolean
}>()
defineEmits<{ accept: []; reject: [] }>()
/** æ¥ç”µæç¤ºæ–‡æ¡ˆï¼›åŒºåˆ†è¯­éŸ³ / è§†é¢‘ */
const tipText = computed(() => {
  if (!props.payload) return ''
  return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话`
})
/** æŽ¥å¬æŒ‰é’®ç¦ç”¨æ€ */
const acceptDisabled = computed(() => !!props.accepting || !!props.rejecting)
/** æ‹’绝按钮禁用态 */
const rejectDisabled = computed(() => !!props.rejecting || !!props.accepting)
// ç¾¤é€šè¯æˆå‘˜ï¼›ç¼“存为空时用 INVITE è½½è·é‡Œçš„主叫兜底,避免空白
const callMembers = useGroupCallMembers(
  computed(() => (props.isGroup ? props.payload?.groupId : undefined)),
  computed(() => props.payload?.inviterUserId)
)
</script>
<template>
  <!--
    è¢«å«ç«¯æ¥ç”µå°æ¡ï¼›å‚考微信,右上角固定悬浮;
    ç¾¤èŠï¼šå·¦å¤´åƒ + ä¸­ï¼ˆé‚€è¯·äºº + æ–‡æ¡ˆä¸€è¡Œ / é€šè¯æˆå‘˜è¡Œï¼‰+ å³ä¸‹è§’拒绝 / æŽ¥å¬ï¼›
    ç§èŠï¼šå·¦å¤´åƒ + ä¸­ï¼ˆé‚€è¯·äºº + æ–‡æ¡ˆä¸¤è¡Œï¼‰+ å³ä¸‹è§’拒绝 / æŽ¥å¬
  -->
  <div
    class="fixed top-5 right-5 rounded-2xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.4)] z-[9999] flex gap-3 items-end p-3 text-white bg-[#2a2a2c]"
    :class="isGroup ? 'w-[360px]' : 'w-[340px]'"
  >
    <!-- å·¦ï¼šé‚€è¯·è€…头像 -->
    <UserAvatar
      :url="payload?.inviterAvatar"
      :name="payload?.inviterNickname"
      :size="48"
      radius="8px"
      :clickable="false"
      class="self-start"
    />
    <!-- ä¸­ï¼šç¾¤èŠå•行(邀请人 + æ–‡æ¡ˆï¼‰+ é€šè¯æˆå‘˜ï¼›ç§èŠä¸¤è¡Œï¼ˆå / æ–‡æ¡ˆï¼‰ -->
    <div class="flex flex-col flex-1 gap-1 self-start min-w-0">
      <!-- å + æ–‡æ¡ˆï¼šç¾¤å•行内联,私聊上下两行 -->
      <div v-if="isGroup" class="text-sm truncate">
        <span class="font-medium">{{ payload?.inviterNickname || '对方' }}</span>
        <span class="ml-1 text-white/60">{{ tipText }}</span>
      </div>
      <template v-else>
        <div class="text-sm font-medium truncate">{{ payload?.inviterNickname || '对方' }}</div>
        <div class="text-13px text-white/60 truncate">{{ tipText }}</div>
      </template>
      <!-- ç¾¤é€šè¯æˆå‘˜è¡Œï¼›ç§èŠæ— ï¼›æŽ¥å…¥ä¸­çš„人半透明展示 -->
      <template v-if="isGroup && callMembers.length > 0">
        <div class="mt-1 text-xs text-white/45">通话成员</div>
        <div class="flex flex-wrap gap-1">
          <UserAvatar
            v-for="member in callMembers"
            :key="member.userId"
            :url="member.avatar"
            :name="member.nickname"
            :size="22"
            radius="4px"
            :clickable="false"
            :class="{ 'opacity-50': member.pending }"
            :title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
          />
        </div>
      </template>
    </div>
    <!-- å³ä¸‹è§’:拒绝 / æŽ¥å¬ -->
    <div class="flex flex-shrink-0 gap-2 items-center">
      <button
        class="flex flex-shrink-0 justify-center items-center w-10 h-10 text-white rounded-full transition-opacity bg-[#f04a4a] hover:opacity-90"
        :class="{ 'opacity-60 cursor-not-allowed': rejectDisabled }"
        :disabled="rejectDisabled"
        @click="$emit('reject')"
      >
        <Icon icon="ant-design:phone-outlined" :size="18" class="rotate-[135deg]" />
      </button>
      <button
        class="flex flex-shrink-0 justify-center items-center w-10 h-10 text-white rounded-full transition-opacity bg-[#2ec27e] hover:opacity-90"
        :class="{ 'opacity-60 cursor-not-allowed': acceptDisabled }"
        :disabled="acceptDisabled"
        @click="$emit('accept')"
      >
        <Icon icon="ant-design:phone-outlined" :size="18" />
      </button>
    </div>
  </div>
</template>
src/views/im/home/components/rtc/rtc-call-inviting.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
<script lang="ts" setup>
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import { UserAvatar } from '../user'
const props = defineProps<{
  cameraEnabled: boolean
  isGroup?: boolean
  isVideo: boolean
  localStream?: MediaStream | null // æœ¬åœ°è§†é¢‘流;视频呼叫预览铺底
  micEnabled: boolean
  peerAvatar?: string
  peerNickname?: string
  speakerEnabled: boolean
}>()
defineEmits<{
  cancel: []
  toggleCamera: []
  toggleMic: []
  toggleSpeaker: []
}>()
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
</script>
<template>
  <!-- ä¸»å«ç­‰å¾…对方接听的悬浮窗;1v1 ç§èŠ 320×540;群通话切大窗 720×560 -->
  <div
    class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[1000] flex flex-col text-white bg-gradient-to-b from-[#2a2a2c] to-[#1a1a1c]"
    :class="isGroup ? 'w-[720px] h-[560px]' : 'w-[320px] h-[540px]'"
  >
    <div class="flex relative flex-1 justify-center items-center">
      <!-- è§†é¢‘呼叫:自己摄像头预览铺底,对方头像悬浮顶部 -->
      <video
        v-if="isVideo && localStream"
        :ref="setLocalVideoRef"
        class="absolute inset-0 object-cover w-full h-full scale-x-[-1]"
        autoplay
        muted
        playsinline
      ></video>
      <div
        class="flex relative z-[1] flex-col gap-4 items-center"
        :class="{ 'self-start mt-16': isVideo }"
      >
        <UserAvatar
          :url="peerAvatar"
          :name="peerNickname"
          :size="96"
          radius="8px"
          :clickable="false"
        />
        <div class="text-[17px] font-medium">{{ peerNickname || '对方' }}</div>
        <div class="text-13px text-white/60">等待对方接受邀请……</div>
      </div>
    </div>
    <!-- åº•部操作区:麦克风 / å–消 / (摄像头 | æ‰¬å£°å™¨) -->
    <div class="flex flex-shrink-0 gap-4 justify-around items-center pt-4 px-5 pb-5">
      <div
        class="flex flex-col gap-2 items-center cursor-pointer select-none"
        @click="$emit('toggleMic')"
      >
        <!-- Iconify é‡Œ mic æœ‰é™éŸ³å˜ä½“ï¼›speaker / camera æ²¡æœ‰åŒæºé™éŸ³å˜ä½“,off æ€å€Ÿ tabler:*-off è¡¨è¾¾æ–œçº¿ -->
        <span
          class="flex justify-center items-center w-12 h-12 rounded-full"
          :class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
        >
          <Icon
            :icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'"
            :size="22"
          />
        </span>
        <span class="text-xs text-white/70 whitespace-nowrap">
          {{ micEnabled ? '麦克风已开' : '麦克风已关' }}
        </span>
      </div>
      <div
        class="flex flex-col gap-2 items-center cursor-pointer select-none"
        @click="$emit('cancel')"
      >
        <span
          class="flex justify-center items-center w-12 h-12 text-white rounded-full bg-[#f04a4a]"
        >
          <Icon icon="ant-design:phone-outlined" :size="22" class="rotate-[135deg]" />
        </span>
        <span class="text-xs text-white/70 whitespace-nowrap">取消</span>
      </div>
      <div
        v-if="isVideo"
        class="flex flex-col gap-2 items-center cursor-pointer select-none"
        @click="$emit('toggleCamera')"
      >
        <span
          class="flex justify-center items-center w-12 h-12 rounded-full"
          :class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
        >
          <Icon
            :icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'"
            :size="22"
          />
        </span>
        <span class="text-xs text-white/70 whitespace-nowrap">
          {{ cameraEnabled ? '摄像头已开' : '摄像头已关' }}
        </span>
      </div>
      <div
        v-else
        class="flex flex-col gap-2 items-center cursor-pointer select-none"
        @click="$emit('toggleSpeaker')"
      >
        <span
          class="flex justify-center items-center w-12 h-12 rounded-full"
          :class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
        >
          <Icon
            :icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'"
            :size="22"
          />
        </span>
        <span class="text-xs text-white/70 whitespace-nowrap">
          {{ speakerEnabled ? '扬声器已开' : '扬声器已关' }}
        </span>
      </div>
    </div>
  </div>
</template>
src/views/im/home/components/rtc/rtc-call-member-picker-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,109 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../group'
import { computed, ref } from 'vue'
import { Button, Modal } from 'ant-design-vue'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupStore } from '../../store/groupStore'
import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImRtcCallMemberPickerDialog' })
const emit = defineEmits<{
  /** é€‰å®Œç‚¹å®Œæˆï¼›æºå¸¦é€‰ä¸­çš„ userId åˆ—表 */
  success: [selectedIds: number[]]
}>()
type PickerMode = 'add' | 'invite'
const groupStore = useGroupStore()
const visible = ref(false) // å¼¹çª—显隐
const groupId = ref(0) // å½“前群编号;open æ—¶ç”±è°ƒç”¨æ–¹ä¼ å…¥
const mode = ref<PickerMode>('invite') // å¼¹çª—用途;invite=发起群通话选邀请人 / add=通话中追加成员
const excludeUserIds = ref<number[]>([]) // ç½®ç°çš„ userId åˆ—表;add åœºæ™¯æŠŠå·²åœ¨é€šè¯å†…的人禁用
const selectedIds = ref<number[]>([]) // å½“前选中的 userId åˆ—表;GroupMemberPickerPanel v-model ç»‘过来
/** æ ‡é¢˜ï¼›æŒ‰ç”¨é€”切换 */
const title = computed(() => (mode.value === 'add' ? '添加成员' : '选择成员'))
/** ç¾¤æˆå‘˜åˆ—表;从 groupStore çŽ°å–ï¼Œmap æˆ GroupMemberLite */
const members = computed<GroupMemberLite[]>(() => {
  const group = groupStore.getGroup(groupId.value)
  return (group?.members || []).map((member) => ({
    userId: member.userId,
    nickname: member.nickname,
    showName: member.displayUserName || member.nickname,
    avatar: member.avatar,
    status: member.status,
    role: member.role
  }))
})
/** è‡ªå·±ä¸å‡ºçŽ°åœ¨é€‰é¡¹é‡Œ */
const hideIds = computed<number[]>(() => {
  const myId = getCurrentUserId()
  return myId ? [myId] : []
})
/** å·²åœ¨é€šè¯å†…的成员置灰 */
const disabledIds = computed<number[]>(() => excludeUserIds.value)
/** æ˜¯å¦å¯æäº¤ï¼šè‡³å°‘选 1 ä¸ª */
const canSubmit = computed(() => selectedIds.value.length > 0)
/** æ‰“开弹窗;excludeUserIds ç”¨äºŽã€Œæ·»åŠ æˆå‘˜ã€æ—¶æŠŠå·²åœ¨é€šè¯å†…çš„äººç½®ç° */
function open(opts: { excludeUserIds?: number[]; groupId: number; mode?: PickerMode; }) {
  groupId.value = opts.groupId
  mode.value = opts.mode || 'invite'
  excludeUserIds.value = opts.excludeUserIds || []
  selectedIds.value = []
  visible.value = true
}
defineExpose({ open }) // æä¾› open æ–¹æ³•,用于打开弹窗
/** ç‚¹å®Œæˆï¼šemit é€‰ä¸­ ID åˆ—表给父级 + å…³é—­å¼¹çª—;提交按钮 disabled å·²ä¿è¯ selectedIds éžç©º */
function handleOk() {
  emit('success', [...selectedIds.value])
  visible.value = false
}
</script>
<template>
  <!--
    é€šè¯æˆå‘˜é€‰æ‹©å¼¹çª—:发起群通话时选邀请人 / é€šè¯ä¸­æ·»åŠ æˆå‘˜ï¼›
    é€‰æ‹© UI å§”托 GroupMemberPickerPanel;自己 hide、已在通话内的成员 disabled
  -->
  <Modal
    v-model:open="visible"
    :title="title"
    width="720px"
    :mask-closable="false"
    class="im-picker-dialog"
  >
    <div class="h-[480px]">
      <GroupMemberPickerPanel
        v-model:selected-ids="selectedIds"
        :members="members"
        :disabled-ids="disabledIds"
        :hide-ids="hideIds"
      />
    </div>
    <template #footer>
      <Button @click="visible = false">取消</Button>
      <Button type="primary" :disabled="!canSubmit" @click="handleOk">完成</Button>
    </template>
  </Modal>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep ç©¿é€ el-dialog å†…部类;复用 picker å…¬å…± mixin */
.im-picker-dialog {
  @include picker.styles;
}
</style>
src/views/im/home/components/rtc/rtc-call-participant-tile.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
<script lang="ts" setup>
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import { UserAvatar } from '../user'
export interface CallParticipantVM {
  userId: number
  nickname: string
  avatar?: string
  isLocal: boolean
  videoStream?: MediaStream | null
  audioStream?: MediaStream | null
  /** ç­‰å¾…加入;UI æ˜¾ç¤ºä¸‰ç‚¹åŠ¨ç”» */
  pending?: boolean
}
const props = defineProps<{
  participant: CallParticipantVM
  /** æ‰¬å£°å™¨å¼€å…³ï¼›ä¸º false æ—¶é™éŸ³è¯¥æ ¼å­çš„远端音频 */
  speakerEnabled: boolean
}>()
const setVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
</script>
<template>
  <!--
    ç¾¤é€šè¯å®«æ ¼é‡Œçš„单个参与者格子;
    ä¼˜å…ˆæ¸²æŸ“远端视频(有 videoStream æ—¶é“ºæ»¡ï¼‰ï¼Œæ— è§†é¢‘降级渲染头像 + åå­—胶囊;
    pending=true æ—¶å åŠ ã€ŒæŽ¥å…¥ä¸­ã€ä¸‰ç‚¹åŠ¨ç”»å ä½ï¼Œç”¨äºŽè¢«é‚€è¯·äººæœªæŽ¥é€šå‰çš„å ä½æ¸²æŸ“ï¼›
    è¿œç«¯éŸ³é¢‘通过隐藏 audio æ’­æ”¾ï¼ˆæœ¬ç«¯ isLocal ä¸æ¸²æŸ“避免回声)
  -->
  <div
    class="flex relative overflow-hidden justify-center items-center w-full h-full rounded-md bg-[#2a2a2c]"
  >
    <!-- è§†é¢‘可用:渲染 video;否则渲染头像或默认占位 -->
    <video
      v-if="participant.videoStream"
      :ref="setVideoRef"
      class="object-cover w-full h-full"
      autoplay
      playsinline
      :muted="participant.isLocal"
    ></video>
    <div v-else class="flex justify-center items-center w-full h-full">
      <UserAvatar
        :url="participant.avatar"
        :name="participant.nickname"
        :size="64"
        radius="50%"
        :clickable="false"
      />
    </div>
    <!-- è¿œç«¯éŸ³é¢‘;通过 audio å…ƒç´ æ’­æ”¾ï¼Œæœ¬ç«¯é™éŸ³é¿å…å›žå£°ï¼›æ‰¬å£°å™¨å…³é—­æ—¶æ•´ä½“静音 -->
    <audio
      v-if="participant.audioStream && !participant.isLocal"
      :ref="setAudioRef"
      autoplay
      :muted="!speakerEnabled"
    ></audio>
    <!-- å·¦ä¸‹è§’名字胶囊 -->
    <div
      class="flex absolute bottom-3 left-3 gap-1.5 items-center py-[3px] pr-2.5 pl-[3px] text-13px text-white rounded-full bg-black/45 max-w-[calc(100%-60px)]"
    >
      <UserAvatar
        :url="participant.avatar"
        :name="participant.nickname"
        :size="22"
        radius="50%"
        :clickable="false"
      />
      <span class="truncate min-w-0">{{ participant.nickname }}</span>
    </div>
    <!-- ç­‰å¾…对方加入:三点动画 +「接入中」文案;位置在格子中心下方 60px -->
    <div
      v-if="participant.pending"
      class="flex absolute left-1/2 flex-col gap-1.5 items-center -translate-x-1/2 -translate-y-1/2 top-[calc(50%+60px)]"
    >
      <div class="flex gap-1.5">
        <span
          v-for="i in 3"
          :key="i"
          class="tile-dot w-1.5 h-1.5 rounded-full bg-white/60"
          :style="{ animationDelay: `${(i - 1) * 0.2}s` }"
        ></span>
      </div>
      <span class="text-xs text-white/70">接入中</span>
    </div>
  </div>
</template>
<style scoped>
/* ä¸‰ç‚¹æ·¡å…¥æ·¡å‡ºåŠ¨ç”»ï¼›@keyframes å¿…é¡» CSS å®šä¹‰ */
.tile-dot {
  animation: tile-dot 1.4s infinite ease-in-out both;
}
@keyframes tile-dot {
  0%,
  80%,
  100% {
    opacity: 0.3;
  }
  40% {
    opacity: 1;
  }
}
</style>
src/views/im/home/components/rtc/rtc-call-running.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,291 @@
<script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { formatCallDuration } from '#/views/im/utils/time'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import { UserAvatar } from '../user'
import RtcCallParticipantTile, { type CallParticipantVM } from './rtc-call-participant-tile.vue'
const props = defineProps<{
  cameraEnabled: boolean
  hangingUp?: boolean
  /** æ˜¯å¦ç¾¤é€šè¯ï¼›å†³å®šç½‘æ ¼ / å•点布局 + æµ®çª—大小 */
  isGroup: boolean
  isVideo: boolean
  localStream?: MediaStream | null
  micEnabled: boolean
  /** ç½‘格视图用:所有参与者(含自己) */
  participants: CallParticipantVM[]
  peerAvatar?: string
  /** 1v1 è§†å›¾ç”¨ */
  peerNickname?: string
  /** æ˜¯å¦å¤„于网络重连中;显示顶部黄色提示条 */
  reconnecting?: boolean
  remoteAudioStream?: MediaStream | null
  remoteVideoStream?: MediaStream | null
  /** æ˜¯å¦æ­£åœ¨å±å¹•共享;按钮高亮 + æ–‡æ¡ˆåˆ‡æ¢ */
  screenShareEnabled?: boolean
  /** æ‰¬å£°å™¨å¼€å…³ï¼›true æ—¶æ­£å¸¸æ’­æ”¾è¿œç«¯éŸ³é¢‘,false æ—¶æ‰€æœ‰è¿œç«¯ audio å…ƒç´ é™éŸ³ */
  speakerEnabled: boolean
  startedAt: number
}>()
defineEmits<{
  addMember: []
  hangup: []
  toggleCamera: []
  toggleMic: []
  toggleScreenShare: []
  toggleSpeaker: []
}>()
/** ç½‘格列数;按人数自适应;返回 UnoCSS class å­—面量让 JIT æ‰«æå™¨é™æ€è¯†åˆ« */
const gridColsClass = computed(() => {
  const n = props.participants.length
  if (n <= 1) return 'grid-cols-1'
  if (n <= 4) return 'grid-cols-2'
  return 'grid-cols-3'
})
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const setRemoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
const setRemoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
/** 1v1 è§†é¢‘:是否有远端视频流 */
const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream)
const now = ref(Date.now()) // é€šè¯æ—¶é•¿ï¼›ä»… 1v1 è¯­éŸ³è§†å›¾éœ€è¦å±•示,其它视图不启 tick
let tick = 0
watch(
  () => props.isGroup || props.isVideo,
  (suppressTick) => {
    if (suppressTick) {
      if (tick) {
        clearInterval(tick)
        tick = 0
      }
      return
    }
    now.value = Date.now()
    tick = window.setInterval(() => {
      now.value = Date.now()
    }, 1000)
  },
  { immediate: true }
)
onUnmounted(() => {
  if (tick) {
    clearInterval(tick)
  }
})
/** é€šè¯æ—¶é•¿ MM:SS / HH:MM:SS */
const formattedDuration = computed(() =>
  formatCallDuration(Math.floor((now.value - props.startedAt) / 1000))
)
</script>
<template>
  <!-- é€šè¯è¿›è¡Œä¸­çš„æ‚¬æµ®çª—ï¼›1v1 ç§èŠ 320×540;群通话切大窗 720×560 -->
  <div
    class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[1000] flex flex-col text-white bg-[#1a1a1c]"
    :class="isGroup ? 'w-[720px] h-[560px]' : 'w-[320px] h-[540px]'"
  >
    <!-- é‡è¿žä¸­æ¨ªå¹…;网络抖动时显示,直到 Reconnected äº‹ä»¶æ¸…除 -->
    <div
      v-if="reconnecting"
      class="inline-flex absolute top-3 left-1/2 z-10 gap-2 items-center px-3.5 py-1.5 text-13px text-[#ffd45e] rounded-full -translate-x-1/2 bg-[rgba(255,196,0,0.18)]"
    >
      <span class="reconnect-dot w-2 h-2 rounded-full bg-[#ffd45e]"></span>
      ç½‘络不佳,正在重连……
    </div>
    <div class="flex relative flex-1 justify-center items-center">
      <!-- ç¾¤é€šè¯ï¼šç½‘格布局,列数随人数自适应 -->
      <div
        v-if="isGroup"
        class="grid gap-1 p-1 w-full h-full content-stretch"
        :class="gridColsClass"
      >
        <RtcCallParticipantTile
          v-for="participant in participants"
          :key="participant.userId"
          :participant="participant"
          :speaker-enabled="speakerEnabled"
        />
      </div>
      <!-- 1v1 è§†é¢‘:远端铺底 + æœ¬åœ°ç¼©ç•¥ -->
      <template v-else-if="isVideo">
        <div v-show="hasRemoteVideo" class="absolute inset-0">
          <video
            :ref="setRemoteVideoRef"
            class="object-cover w-full h-full"
            autoplay
            playsinline
          ></video>
        </div>
        <div v-if="!hasRemoteVideo" class="flex z-[1] flex-col gap-4 items-center">
          <UserAvatar
            :url="peerAvatar"
            :name="peerNickname"
            :size="96"
            radius="8px"
            :clickable="false"
          />
          <div class="text-[17px] font-medium">{{ peerNickname }}</div>
          <div class="text-13px text-white/60">等待对方开启摄像头……</div>
        </div>
        <div
          v-if="localStream"
          class="absolute top-4 right-4 z-[2] overflow-hidden w-30 rounded-lg aspect-[9/16] bg-[#333]"
        >
          <video
            :ref="setLocalVideoRef"
            class="object-cover w-full h-full scale-x-[-1]"
            autoplay
            muted
            playsinline
          ></video>
        </div>
      </template>
      <!-- 1v1 è¯­éŸ³ï¼šå¤´åƒ + æ—¶é•¿ -->
      <template v-else>
        <div class="flex z-[1] flex-col gap-4 items-center">
          <UserAvatar
            :url="peerAvatar"
            :name="peerNickname"
            :size="96"
            radius="8px"
            :clickable="false"
          />
          <div class="text-[17px] font-medium">{{ peerNickname }}</div>
          <div class="text-13px text-white/60">{{ formattedDuration }}</div>
        </div>
      </template>
      <audio
        v-if="!isGroup && remoteAudioStream"
        :ref="setRemoteAudioRef"
        autoplay
        :muted="!speakerEnabled"
      ></audio>
    </div>
    <!-- åº•部操作区:麦克风 / æ‰¬å£°å™¨ / æ‘„像头 / (群聊:共享屏幕 / æ·»åŠ æˆå‘˜) / æŒ‚æ–­ -->
    <div
      class="flex flex-shrink-0 gap-3 justify-around items-center pt-4 px-4 pb-5 bg-black/20"
    >
      <div
        class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
        @click="$emit('toggleMic')"
      >
        <span
          class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
          :class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
        >
          <Icon
            :icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'"
            :size="22"
          />
        </span>
        <span class="text-xs text-white/70 whitespace-nowrap">
          {{ micEnabled ? '麦克风已开' : '麦克风已关' }}
        </span>
      </div>
      <div
        class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
        @click="$emit('toggleSpeaker')"
      >
        <span
          class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
          :class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
        >
          <Icon
            :icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'"
            :size="22"
          />
        </span>
        <span class="text-xs text-white/70 whitespace-nowrap">
          {{ speakerEnabled ? '扬声器已开' : '扬声器已关' }}
        </span>
      </div>
      <!-- æ‘„像头按钮:私聊视频固定显示;群聊不论起始 mediaType éƒ½æ˜¾ç¤ºï¼Œè®©ç”¨æˆ·æŒ‰éœ€å¼€ -->
      <div
        v-if="isVideo || isGroup"
        class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
        @click="$emit('toggleCamera')"
      >
        <span
          class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
          :class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
        >
          <Icon
            :icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'"
            :size="22"
          />
        </span>
        <span class="text-xs text-white/70 whitespace-nowrap">
          {{ cameraEnabled ? '摄像头已开' : '摄像头已关' }}
        </span>
      </div>
      <!-- ç¾¤é€šè¯æ‰æœ‰ï¼šå…±äº«å±å¹• / æ·»åŠ æˆå‘˜ï¼›å…±äº«ä¸­æŒ‰é’®é«˜äº® + æ–‡æ¡ˆæ¢æˆã€Œåœæ­¢å…±äº«ã€ -->
      <template v-if="isGroup">
        <div
          class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
          @click="$emit('toggleScreenShare')"
        >
          <span
            class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
            :class="screenShareEnabled ? 'bg-[#07c160] text-white' : 'bg-white/15 text-white'"
          >
            <Icon
              :icon="screenShareEnabled ? 'ant-design:laptop-outlined' : 'tabler:device-laptop-off'"
              :size="22"
            />
          </span>
          <span class="text-xs text-white/70 whitespace-nowrap">
            {{ screenShareEnabled ? '停止共享' : '共享屏幕' }}
          </span>
        </div>
        <div
          class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
          @click="$emit('addMember')"
        >
          <span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-white/15">
            <Icon icon="ant-design:plus-outlined" :size="22" />
          </span>
          <span class="text-xs text-white/70 whitespace-nowrap">添加成员</span>
        </div>
      </template>
      <div
        class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
        :class="{ 'opacity-60 pointer-events-none': hangingUp }"
        @click="$emit('hangup')"
      >
        <span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-[#f04a4a]">
          <Icon icon="ant-design:phone-outlined" :size="22" class="rotate-[135deg]" />
        </span>
        <span class="text-xs text-white/70 whitespace-nowrap">挂断</span>
      </div>
    </div>
  </div>
</template>
<style scoped>
/* é‡è¿žå°ç‚¹æ·¡å…¥æ·¡å‡ºï¼›@keyframes å¿…é¡» CSS å®šä¹‰ */
.reconnect-dot {
  animation: reconnect-pulse 1s ease-in-out infinite;
}
@keyframes reconnect-pulse {
  0%,
  100% {
    opacity: 0.3;
  }
  50% {
    opacity: 1;
  }
}
</style>
src/views/im/home/components/rtc/rtc-group-call-banner.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,205 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { DICT_TYPE } from '#/packages/constants/src'
import { getDictLabel } from '#/packages/effects/hooks/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { message, Popover } from 'ant-design-vue'
import { getActiveCall, joinCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useGroupStore } from '../../store/groupStore'
import { useRtcStore } from '../../store/rtcStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImRtcGroupCallBanner' })
const props = defineProps<{
  groupId: number
}>()
const rtcStore = useRtcStore()
const groupStore = useGroupStore()
const popoverVisible = ref(false)
/** å½“前群的活跃通话;rtcStore ç»´æŠ¤ï¼Œå‚与者加入 / ç¦»å¼€é€šçŸ¥å¢žåˆ  joinedUserIds,通话结束移除 */
const activeCall = computed(() => rtcStore.getGroupCall(props.groupId))
/** èƒ¶å›Šæ¡æ–‡æ¡ˆï¼›æœ‰æˆå‘˜ï¼ˆå·²åŠ å…¥ + æŽ¥å…¥ä¸­ï¼‰åˆ™å¸¦äººæ•°ï¼Œåˆå§‹ 0 äººæ—¶åªæ˜¾ç¤ºåª’体类型 */
const pillText = computed(() => {
  const media = getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, activeCall.value?.mediaType)
  const count = memberList.value.length
  return count > 0 ? `正在${media}通话(${count} äººï¼‰` : `正在${media}通话`
})
/**
 * åˆ‡åˆ°ç¾¤ / é€šè¯ room å˜åŒ–时拉一次最新参与者列表;
 * ä¸¤ä¸ªè§¦å‘场景:1)用户切群,本端可能没有该群通话的最新缓存;2)参与者通知首次填充后只含本次加入者,缺历史加入者;
 * ç”¨ [groupId, room] åŒæºç›‘听 + å·²å¡«å……守卫,避免切群 / é¦–次填充触发的双次重复拉取
 */
watch(
  () =>
    [
      props.groupId,
      activeCall.value?.room,
      groupStore.isGroupActiveCallExpired(props.groupId)
    ] as const,
  async ([groupId, room], oldValues) => {
    if (!groupId) {
      return
    }
    if (!activeCall.value) {
      if (!groupStore.isGroupActiveCallExpired(groupId)) {
        return
      }
      try {
        const data = await getActiveCall(groupId)
        if (data) {
          rtcStore.setGroupCall(data, true)
        } else {
          rtcStore.removeGroupCall(groupId)
        }
      } catch (error) {
        console.warn('[GroupCallBanner] getActiveCall å¤±è´¥', { groupId }, error)
      }
      return
    }
    // å†³ç­–是否需要拉取:补齐本地已有通话;没有本地通话时按群缓存过期状态懒探测一次
    const groupChanged = !oldValues || oldValues[0] !== groupId
    const roomChanged = oldValues && oldValues[1] !== room
    const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
    const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
    if (
      !activeCallExpired &&
      (rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
        (!groupChanged && !roomChanged && participantsLoaded))
    ) {
      return
    }
    // æ‹‰æœ€æ–°å‚与者写回 store;接口返回空 â†’ è¯¥ç¾¤å·²æ— æ´»è·ƒé€šè¯ï¼Œç§»é™¤æœ¬åœ°ç¼“å­˜
    try {
      const data = await getActiveCall(groupId)
      if (data) {
        rtcStore.setGroupCall(data, true)
      } else {
        rtcStore.removeGroupCall(groupId)
      }
    } catch (error) {
      console.warn('[GroupCallBanner] getActiveCall å¤±è´¥', { groupId }, error)
    }
  },
  { immediate: true }
)
/** åœ¨é€šè¯ä¸­çš„æˆå‘˜ï¼ˆå·²åŠ å…¥ï¼‰+ æŽ¥å…¥ä¸­çš„æˆå‘˜ï¼ˆå·²é‚€è¯·æœªæŽ¥é€šï¼‰ */
const memberList = useGroupCallMembers(computed(() => props.groupId))
/** æœ¬ç«¯æ˜¯å¦æ­£åœ¨è¯¥æˆ¿é—´é€šè¯ï¼ˆå¤„于 INVITING / RUNNING) */
const isInThisCall = computed(
  () => rtcStore.isActive && rtcStore.call?.room === activeCall.value?.room
)
/**
 * æœåŠ¡ç«¯æ˜¯å¦è®°å½•æˆ‘å·²åŠ å…¥ï¼›åˆ·æ–°åŽ LiveKit è¿žæŽ¥å·²æ–­ä½† webhook è¿˜æ²¡æŠŠ status æ ‡ä¸º LEFT æ—¶ä»ä¸º true;
 * ç”¨äºŽæŠŠæŒ‰é’®æ–‡æ¡ˆåˆ‡åˆ°ã€Œé‡æ–°åŠ å…¥ã€ï¼Œä½†ä¸ disable æŒ‰é’®
 */
const serverSaysJoined = computed(() => {
  const myId = getCurrentUserId()
  return activeCall.value?.joinedUserIds?.includes(myId) ?? false
})
/** åŠ å…¥æŒ‰é’®ç¦ç”¨ï¼šä»…åœ¨æœ¬ç«¯å®žé™…æŒæœ‰ LiveKit è¿žæŽ¥æ—¶ç¦ç”¨ */
const joinDisabled = computed(() => isInThisCall.value)
/** åŠ å…¥æŒ‰é’®æ–‡æ¡ˆï¼›æœ¬ç«¯è¿žç€ â†’ å·²åœ¨é€šè¯ä¸­ï¼›æœåŠ¡ç«¯è¿˜æ®‹ç•™æˆ‘ä½†æœ¬ç«¯æ–­äº† â†’ é‡æ–°åŠ å…¥ï¼›å…¶å®ƒ â†’ åŠ å…¥ */
const joinLabel = computed(() => {
  if (isInThisCall.value) return '已在通话中'
  if (serverSaysJoined.value) return '重新加入'
  return '加入'
})
/** ä¸»åŠ¨åŠ å…¥ï¼šè°ƒ invite å‘½ä¸­å·²æœ‰ call æ‹¿ token;rtcStore æŒ‰ status è‡ªåŠ¨è¿› RUNNING */
async function handleJoin() {
  const call = activeCall.value
  if (!call || joinDisabled.value) {
    return
  }
  if (rtcStore.isActive) {
    message.warning('您正在通话中')
    return
  }
  popoverVisible.value = false
  const data = await joinCall(call.room)
  rtcStore.startInviting(data)
}
</script>
<template>
  <!-- ä»…当该群有活跃通话时显示;点击胶囊条展开 popover çœ‹åœ¨é€šè¯æˆå‘˜ + åŠ å…¥ -->
  <div v-if="activeCall" class="flex-shrink-0 px-4 pb-2 bg-[var(--ant-color-fill-secondary)]">
    <Popover
      v-model:open="popoverVisible"
      placement="bottomLeft"
      :overlay-style="{ width: '280px' }"
      trigger="click"
    >
      <!-- èƒ¶å›Šæ¡æœ¬ä½“:电话图标 + æ–‡æ¡ˆï¼ˆå«äººæ•°ï¼‰+ å³ç®­å¤´ -->
      <div
        class="inline-flex gap-2 items-center px-2.5 py-1 text-13px rounded-full cursor-pointer select-none transition-colors duration-150 bg-[var(--ant-color-success-bg)] text-[var(--ant-color-text)] hover:bg-[var(--ant-color-success-bg-hover)]"
      >
        <span
          class="inline-flex flex-shrink-0 justify-center items-center w-[18px] h-[18px] text-white rounded-full bg-[#07c160]"
        >
          <Icon icon="ant-design:phone-filled" :size="14" />
        </span>
        <span class="font-medium">{{ pillText }}</span>
        <Icon
          icon="ant-design:right-outlined"
          :size="12"
          class="text-[var(--ant-color-text-secondary)]"
        />
      </div>
      <!-- å±•开面板:通话成员(含接入中)头像横排 + åŠ å…¥æŒ‰é’® -->
      <template #content>
        <div class="flex flex-col gap-4 items-center pt-2 pb-1">
          <div class="flex flex-wrap gap-1.5 justify-center max-w-[240px]">
            <UserAvatar
              v-for="member in memberList"
              :key="member.userId"
              :url="member.avatar"
              :name="member.nickname"
              :size="40"
              radius="6px"
              :clickable="false"
              :class="{ 'opacity-50': member.pending }"
              :title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
            />
            <!-- é¦–次填充时房内可能暂时 0 äººï¼›åŠ å…¥åŽç”± ParticipantConnected äº‹ä»¶è¿½åŠ  -->
            <div
              v-if="memberList.length === 0"
              class="p-3 text-13px text-[var(--ant-color-text-secondary)]"
            >
              æš‚无成员在通话
            </div>
          </div>
          <!-- æœ¬ç«¯åœ¨é€šè¯å†…时置灰「已在通话中」;服务端残留我但本端连接断了显示「重新加入」(刷新页面后场景) -->
          <button
            class="w-[200px] h-9 text-sm font-medium rounded-lg cursor-pointer border-none bg-[#f1f1f3] text-[#1a1a1c] transition-colors duration-150 disabled:cursor-not-allowed disabled:text-[#999] hover:[&:not(:disabled)]:bg-[#e7e7ea]"
            :disabled="joinDisabled"
            @click="handleJoin"
          >
            {{ joinLabel }}
          </button>
        </div>
      </template>
    </Popover>
  </div>
</template>
src/views/im/home/components/tool-bar.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useUserStore } from '#/packages/stores/src'
import { Badge } from 'ant-design-vue'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { useImUiStore } from '../store/uiStore'
import { UserAvatar } from './user'
defineOptions({ name: 'ImToolBar' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
/** æ¶ˆæ¯ Tab çš„红点:所有非免打扰会话的未读总和 */
const totalUnread = computed(() => conversationStore.getTotalUnreadCount)
/** é€šè®¯å½• Tab çš„红点:未处理好友申请数(接收方=我) */
const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount)
const tabs = [
  { name: 'ImHomeConversation', icon: 'ep:chat-round' },
  { name: 'ImHomeContact', icon: 'mingcute:contacts-line' }
] // ä¸¤ä¸ªä¸» Tab;用路由 name è€Œéž path,避免前缀 / åµŒå¥—调整后失效
// å½“前路由是否命中 Tab:直接比对 route.name
const isActive = (name: string) => route.name === name
// åˆ‡æ¢ Tab:当前已选中时,消息 Tab è§¦å‘"滚动到下一个未读"(对齐微信 PC),其它 Tab æ— åŠ¨ä½œ
const goTab = (name: string) => {
  if (route.name === name) {
    if (name === 'ImHomeConversation') {
      uiStore.requestNextUnreadJump()
    }
    return
  }
  router.push({ name })
}
// è·³è½¬ä¸ªäººä¸­å¿ƒï¼ˆè·¯ç”± name=Profile)
const goProfile = () => router.push({ name: 'Profile' })
</script>
<template>
  <!--
    ToolBar:IM å·¦ä¾§å·¥å…·æ 
    å¸ƒå±€ï¼šé¡¶éƒ¨å¤´åƒ â†’ ä¸­é—´ä¸‰ Tab(消息/好友/群聊)→ åº•部设置
  -->
  <div
    class="flex flex-col items-center w-14 pt-4 pb-3 gap-2 flex-shrink-0 select-none bg-[#2b2b2b]"
  >
    <!-- é¡¶éƒ¨ç”¨æˆ·å¤´åƒï¼Œç‚¹å‡»è·³ä¸ªäººä¸­å¿ƒï¼›èµ° UserAvatar ç»Ÿä¸€é¦–å­— / å“ˆå¸Œé…è‰²è§„则 -->
    <div class="mb-2 cursor-pointer" @click="goProfile">
      <UserAvatar
        :url="userStore.userInfo?.avatar"
        :name="userStore.userInfo?.nickname"
        :size="36"
        :clickable="false"
      />
    </div>
    <!-- ä¸­é—´ä¸‰ Tab -->
    <div class="flex flex-col items-center gap-2 flex-1 w-full">
      <div
        v-for="item in tabs"
        :key="item.name"
        class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10"
        :class="{ 'bg-white/15 text-white': isActive(item.name) }"
        @click="goTab(item.name)"
      >
        <Badge
          v-if="item.name === 'ImHomeConversation' && totalUnread > 0"
          :count="totalUnread"
          :max="99"
          class="tool-bar__badge"
        >
          <Icon :icon="item.icon" class="tool-bar__icon" />
        </Badge>
        <Badge
          v-else-if="item.name === 'ImHomeContact' && unhandledRequestCount > 0"
          :count="unhandledRequestCount"
          :max="99"
          class="tool-bar__badge"
        >
          <Icon :icon="item.icon" class="tool-bar__icon" />
        </Badge>
        <Icon v-else :icon="item.icon" class="tool-bar__icon" />
      </div>
    </div>
    <!-- åº•部设置按钮:点击跳个人中心 -->
    <div class="flex flex-col items-center gap-2 w-full">
      <div
        class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10"
        @click="goProfile"
      >
        <Icon icon="ant-design:setting-outlined" class="tool-bar__icon" />
      </div>
    </div>
  </div>
</template>
<style scoped>
.tool-bar__icon {
  width: 22px;
  height: 22px;
}
/* è°ƒæ•´ AntD Badge å³ä¸Šè§’红点位置 */
.tool-bar__badge :deep(.ant-badge-count) {
  top: 4px;
  right: 8px;
  border: none;
}
</style>
src/views/im/home/components/user/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
export { default as RecommendCardDialog } from './recommend-card-dialog.vue';
export { default as UserAvatar } from './user-avatar.vue';
export { default as UserInfoCard } from './user-info-card.vue';
export { default as UserInfo } from './user-info.vue';
export type { UserInfoRelation } from './user-info.vue';
src/views/im/home/components/user/recommend-card-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,322 @@
<script lang="ts" setup>
import type { Conversation, FriendLite } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Button, Input, message, Modal } from 'ant-design-vue'
import { createGroup } from '#/api/im/group'
import { CardBubble } from '#/views/im/home/components/card'
import { ImContentType, ImConversationType, isGroupConversation } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { type CardTarget, serializeMessage } from '../../../utils/message'
import { getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import { useMessageSender } from '../../composables/useMessageSender'
import { FacePicker } from '../../pages/conversation/components/input'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { ConversationPickerPanel } from '../picker'
import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImRecommendCardDialog' })
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const { sendRaw, send } = useMessageSender()
const visible = ref(false)
const target = ref<CardTarget | null>(null)
const view = ref<'contact' | 'conversation'>('conversation') // å½“前视图:默认会话选择,「创建聊天」入口切到好友选择
const selectedKeys = ref<string[]>([])
const selectedFriendIds = ref<number[]>([])
const leaveMessage = ref('')
const sending = ref(false)
const emojiVisible = ref(false) // è¡¨æƒ…面板显隐:右侧 smile icon åˆ‡æ¢
defineExpose({
  /** æ‰“开推荐弹窗:reset â†’ çŒå‚ â†’ visible=true */
  open(opts: { target: CardTarget }) {
    target.value = opts.target
    view.value = 'conversation'
    selectedKeys.value = []
    selectedFriendIds.value = []
    leaveMessage.value = ''
    emojiVisible.value = false
    sending.value = false
    visible.value = true
  }
})
/** å¼¹çª—标题:会话视图按 target ç±»åž‹åˆ†æ–‡æ¡ˆï¼›å¥½å‹è§†å›¾å›ºå®šä¸ºã€Œé€‰æ‹©å¥½å‹ã€ */
const headerTitle = computed(() => {
  if (view.value === 'contact') {
    return '选择好友'
  }
  return isGroupConversation(target.value?.targetType) ? '把这个群推荐给朋友' : '把他推荐给朋友'
})
/** å€™é€‰ä¼šè¯ï¼šä»Ž store æ‹¿æŽ’序后的列表(hide ç”± Panel æŽ¥ hideKeys è¿‡æ»¤ï¼‰ï¼›åŽ†å²é€€ç¾¤ç¾¤ä¸å¯è¢«æŽ¨èé€‰ä¸­ï¼ˆé€‰äº†åŽç«¯ä¹Ÿä¼šæ‹’ï¼‰ */
const candidateConversations = computed<Conversation[]>(() =>
  conversationStore.getSortedConversationList.filter(
    (conversation) =>
      !(
        conversation.type === ImConversationType.GROUP &&
        isGroupQuit(groupStore.getGroup(conversation.targetId))
      )
  )
)
/** éšè— key:不能把名片推回名片本身的会话(用户名片避免自推、群名片避免推回该群) */
const hideKeys = computed<string[]>(() => {
  const t = target.value
  if (!t) {
    return []
  }
  return [getConversationKey({ type: t.targetType, targetId: t.targetId })]
})
/** å¥½å‹è§†å›¾å€™é€‰åˆ—表:直接复用 friendStore Lite è§†å›¾ */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** æŠŠé€‰ä¸­çš„ emoji æ‹¼åˆ°ç•™è¨€æœ«å°¾ï¼›FacePicker è‡ªèº«è´Ÿè´£å…³é—­é¢æ¿ */
function handleEmojiSelect(emoji: string) {
  leaveMessage.value = `${leaveMessage.value}${emoji}`
}
/** åˆ‡åˆ°å¥½å‹è§†å›¾ï¼šæ¸…掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
  view.value = 'contact'
  leaveMessage.value = ''
  emojiVisible.value = false
}
/**
 * ç¡®è®¤å‘送(会话视图):每个选中会话先发 CARD,CARD æˆåŠŸåŽæ‰å‘ç•™è¨€ï¼ˆä¿è¯ã€Œå…ˆçœ‹åˆ°åç‰‡ã€çš„é¡ºåºæ„å›¾ï¼‰
 *
 * æ–‡æ¡ˆèšåˆï¼šå…¨éƒ¨æˆåŠŸã€Œå·²è½¬å‘ã€ã€å…¨éƒ¨å¤±è´¥ã€Œè½¬å‘å¤±è´¥ï¼šA、B」、部分失败「已转发,但 X、Y å¤±è´¥ã€ï¼›
 * å¤±è´¥çš„æ¶ˆæ¯ä»¥ FAILED çŠ¶æ€ç•™åœ¨å¯¹åº”ä¼šè¯æ°”æ³¡é‡Œï¼Œå¯å³é”®é‡è¯•
 */
async function handleSend() {
  const card = target.value
  if (!card?.targetId || selectedKeys.value.length === 0) {
    return
  }
  const byKey = new Map(candidateConversations.value.map((c) => [getConversationKey(c), c]))
  const targets = selectedKeys.value
    .map((key) => byKey.get(key))
    .filter((c): c is Conversation => c != null)
  if (targets.length === 0) {
    return
  }
  const cardContent = serializeMessage({ ...card })
  const leaveText = leaveMessage.value.trim()
  sending.value = true
  try {
    const tasks = targets.map(async (conversation) => {
      const cardOk = await sendRaw(ImContentType.CARD, cardContent, { conversation })
      if (!cardOk) {
        return { conversation, ok: false }
      }
      const ok = leaveText ? await send(leaveText, { conversation }) : true
      return { conversation, ok }
    })
    const results = await Promise.all(tasks)
    const failedNames = results.filter((r) => !r.ok).map((r) => r.conversation.name || '未命名会话')
    // æŠŠå‘½ä¸­çš„目标推到最近转发列表(部分失败也推:用户的"意图"已表达)
    conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c)))
    if (failedNames.length === 0) {
      message.success('已转发')
    } else if (failedNames.length === targets.length) {
      message.error(`转发失败:${failedNames.join('、')}`)
    } else {
      message.warning(`已转发,但 ${failedNames.join('、')} å¤±è´¥`)
    }
    visible.value = false
  } finally {
    sending.value = false
  }
}
/**
 * å¥½å‹è§†å›¾å‘送:先建群(同时邀请所选好友)→ ç»™æ–°ç¾¤å‘名片 â†’ å‘留言 â†’ å…³å¼¹çª—
 *
 * è·Ÿä¼šè¯è§†å›¾çš„差别:先要 createGroup æ‹¿åˆ° groupId,之后构造一个 GROUP ç±»åž‹çš„ conversation å¯¹è±¡ç»™ sendRaw ç”¨
 * ï¼ˆsendRaw å†…部会自动 insertMessage æŠŠæ–°ç¾¤ç™»è®°è¿› store,最近转发列表也能正常推)
 */
async function handleCreateGroupAndSend() {
  const card = target.value
  if (!card?.targetId || selectedFriendIds.value.length === 0) {
    return
  }
  const byId = new Map(friends.value.map((f) => [f.id, f]))
  const members = selectedFriendIds.value
    .map((id) => byId.get(id))
    .filter((f): f is FriendLite => f != null)
  if (members.length === 0) {
    return
  }
  sending.value = true
  try {
    const memberUserIds = members.map((m) => m.id)
    const name = buildDefaultGroupName(members)
    const group = await createGroup({ name, memberUserIds, joinApproval: false })
    if (!group?.id) {
      throw new Error('创建群失败:未返回群编号')
    }
    // upsert è¿› groupStore,省一次 fetchGroupList
    groupStore.upsertGroup({
      id: group.id,
      name: group.name,
      avatar: group.avatar,
      notice: group.notice,
      ownerUserId: group.ownerUserId
    })
    // ç»™æ–°ç¾¤æž„造一个临时 conversation å¯¹è±¡ç»™ sendRaw ç”¨ï¼›sendRaw å†…部会自动 insertMessage ç™»è®°
    const newConversation: Conversation = {
      type: ImConversationType.GROUP,
      targetId: group.id,
      name: getGroupDisplayName(group) || name,
      avatar: group.avatar || '',
      unreadCount: 0,
      lastContent: '',
      lastSendTime: 0
    }
    const cardOk = await sendRaw(ImContentType.CARD, serializeMessage({ ...card }), {
      conversation: newConversation
    })
    if (!cardOk) {
      message.warning('群已创建,但名片发送失败,请稍后在群里重试')
      visible.value = false
      return
    }
    const leaveText = leaveMessage.value.trim()
    if (leaveText) {
      await send(leaveText, { conversation: newConversation })
    }
    conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
    message.success('已创建群聊并发送')
    visible.value = false
  } finally {
    sending.value = false
  }
}
</script>
<template>
  <!--
    æŠŠåç‰‡æŽ¨èç»™æœ‹å‹ï¼ˆç”¨æˆ· / ç¾¤é€šç”¨ï¼‰
    - dialog å£³ç”±æœ¬ç»„件持有;选择 UI å§”托 ConversationPickerPanel / FriendPickerPanel
    - view='conversation':选已有会话发名片(默认视图)
    - view='contact':从「创建聊天」入口进入,选好友建群再发名片,业务壳层切视图,两个 Panel äº’不知道对方
    - é€‰ 1 èµ°ã€Œå‘送」、多个走「分别发送(n)」文案,与微信一致
    - å¤±è´¥çš„æ¶ˆæ¯ä»¥ FAILED çŠ¶æ€ç•™åœ¨å¯¹åº”ä¼šè¯æ°”æ³¡é‡Œï¼Œä¾›å³é”®é‡è¯•
    - å¯¹å¤–接口:ref + open({ target }),不再走 v-model
  -->
  <Modal
    v-model:open="visible"
    width="720px"
    :mask-closable="false"
    :footer="view === 'conversation' ? null : undefined"
    class="im-picker-dialog im-recommend-dialog"
  >
    <template #header>
      <div class="flex gap-2 items-center">
        <Icon
          v-if="view === 'contact'"
          icon="ant-design:arrow-left-outlined"
          :size="16"
          class="cursor-pointer text-[var(--ant-color-text-secondary)] transition-colors duration-150 hover:text-[var(--ant-color-primary)]"
          @click="view = 'conversation'"
        />
        <span class="text-base text-[var(--ant-color-text)]">
          {{ headerTitle }}
        </span>
      </div>
    </template>
    <div class="h-[480px]">
      <!-- ä¼šè¯è§†å›¾ï¼šé€‰å·²æœ‰ä¼šè¯å‘送 -->
      <ConversationPickerPanel
        v-if="view === 'conversation'"
        v-model:selected-keys="selectedKeys"
        :conversations="candidateConversations"
        :recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys"
        :hide-keys="hideKeys"
        :show-create-chat="true"
        @create-chat="handleSwitchToContact"
        @remove-recent="conversationStore.removeRecentForwardConversationKey"
      >
        <template #footer>
          <div class="flex flex-col gap-3 px-4 py-3">
            <!-- åç‰‡é¢„览卡 -->
            <CardBubble v-if="target" :card="target" />
            <!-- ç•™è¨€ï¼ˆå•行):右侧表情按钮触发 FacePicker;选中 emoji æ‹¼åˆ°æœ«å°¾ -->
            <div class="relative">
              <Input v-model:value="leaveMessage" :maxlength="100" placeholder="给朋友留言">
                <template #suffix>
                  <Icon
                    icon="ant-design:smile-outlined"
                    :size="18"
                    class="cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-primary)]"
                    @click.stop="emojiVisible = !emojiVisible"
                  />
                </template>
              </Input>
              <FacePicker
                v-model:visible="emojiVisible"
                mode="emoji"
                class="bottom-full right-0 mb-2"
                @select-emoji="handleEmojiSelect"
              />
            </div>
            <!-- æ“ä½œæŒ‰é’®ï¼šé€‰ 0/1 æ˜¾ç¤ºã€Œå‘送」、多个显示「分别发送(n)」 -->
            <div class="flex gap-2 justify-end">
              <Button @click="visible = false">取消</Button>
              <Button
                type="primary"
                :loading="sending"
                :disabled="selectedKeys.length === 0"
                @click="handleSend"
              >
                {{ selectedKeys.length > 1 ? `分别发送(${selectedKeys.length})` : '发送' }}
              </Button>
            </div>
          </div>
        </template>
      </ConversationPickerPanel>
      <!-- å¥½å‹è§†å›¾ï¼šé€‰å¥½å‹å»ºç¾¤åŽå‘送 -->
      <FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
    </div>
    <!-- å¥½å‹è§†å›¾çš„ dialog footer:建群并发送 -->
    <template v-if="view === 'contact'" #footer>
      <Button @click="visible = false">取消</Button>
      <Button
        type="primary"
        :loading="sending"
        :disabled="selectedFriendIds.length === 0"
        @click="handleCreateGroupAndSend"
      >
        åˆ›å»ºç¾¤èŠå¹¶å‘送
      </Button>
    </template>
  </Modal>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep ç©¿é€ el-dialog å†…部类;复用 picker å…¬å…± mixin */
.im-picker-dialog {
  @include picker.styles;
}
</style>
src/views/im/home/components/user/user-avatar.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,134 @@
<script lang="ts" setup>
import type { User } from '../../types'
import { computed } from 'vue'
import { Image } from 'ant-design-vue'
import { ImFriendAddSource } from '../../../utils/constants'
import { getAvatarBgColor, getAvatarText } from '../../../utils/user'
import { useImUiStore } from '../../store/uiStore'
defineOptions({ name: 'ImUserAvatar', inheritAttrs: false })
const props = withDefaults(
  defineProps<{
    addSource?: number // åŠ å¥½å‹æ¥æºï¼›ç‚¹å¤´åƒå¼¹ UserInfoCard æ—¶é€ä¼ ç»™ FriendAddDialog(默认 1=搜索)
    addSourceExtra?: string // åŠ å¥½å‹æ¥æºé™„åŠ ï¼šaddSource=2(群聊)时传群名,话术拼为「我是 XX ç¾¤çš„ YY」
    clickable?: boolean // æ˜¯å¦ç‚¹å‡»å¼¹å‡º UserInfoCard;默认 true
    id?: number | string // ç”¨æˆ· id;传了才能点开名片
    name?: string // ç”¨æˆ·åï¼ˆè‰²å¡æ–‡å­— + åç‰‡å¤‡ç”¨ï¼‰
    previewable?: boolean // æ˜¯å¦ç‚¹å¤´åƒç›´æŽ¥æ”¾å¤§é¢„览;开启后忽略 clickable,不再弹名片
    previewZIndex?: number // é¢„览层 z-index;放在高 z-index å¼¹å±‚(如 UserInfoCard)里时需手动抬高
    radius?: string // åœ†è§’,支持 CSS é•¿åº¦ï¼›é»˜è®¤ 15% æ–¹å—小圆角(参考微信)
    size?: number // å°ºå¯¸ï¼ˆpx),正方形;优先于 width/height
    url?: string // å¤´åƒå›¾ç‰‡ URL;为空时走色卡文字
    user?: User // é¢å¤–的用户信息,传了点击就不用现拉接口(弹名片用)
  }>(),
  {
    addSourceExtra: undefined,
    clickable: true,
    id: undefined,
    name: undefined,
    previewable: false,
    previewZIndex: 2000,
    radius: '15%',
    size: 42,
    url: undefined,
    user: undefined,
    addSource: ImFriendAddSource.SEARCH
  }
)
const uiStore = useImUiStore()
const imgStyle = computed(() => ({
  width: `${props.size}px`,
  height: `${props.size}px`,
  borderRadius: props.radius
}))
const textStyle = computed(() => ({
  width: `${props.size}px`,
  height: `${props.size}px`,
  fontSize: `${Math.floor(props.size * (avatarText.value.length > 1 ? 0.34 : 0.42))}px`,
  background: textColor.value,
  borderRadius: props.radius
}))
/** è‰²å¡é¦–字:中文取 1 ä¸ªå­—、英文 / æ‹‰ä¸å–前 2 ä¸ªå­—母 */
const avatarText = computed(() => getAvatarText(props.name))
/** è‰²å¡åº•色:按昵称 charCode å“ˆå¸Œå–调色板色 */
const textColor = computed(() => getAvatarBgColor(props.name))
/** å¤´åƒç‚¹å‡»ï¼špreviewable èµ° el-image é¢„览不弹名片;否则按 user / id ä»»ä¸€å…¥å‚打开名片 */
function handleClick(e: MouseEvent) {
  if (props.previewable) {
    return
  }
  if (!props.clickable) {
    return
  }
  // æƒ…况一:有预传 user ä¿¡æ¯ï¼šå°±ç›´æŽ¥ç”¨ï¼Œçœä¸€æ¬¡æŽ¥å£
  if (props.user) {
    uiStore.openUserInfoCardAtEvent(props.user, e, props.addSource, props.addSourceExtra)
    return
  }
  // æƒ…况二:无预传 user ä¿¡æ¯ï¼šæ‰“开名片,传最小必要信息(id + æ˜µç§° + å¤´åƒï¼‰ï¼Œä½ç½®åœ¨é¼ æ ‡å³ä¾§
  const numId = Number(props.id)
  if (!numId || numId <= 0) {
    return
  }
  uiStore.openUserInfoCardAtEvent(
    {
      id: numId,
      nickname: props.name,
      avatar: props.url
    },
    e,
    props.addSource,
    props.addSourceExtra
  )
}
</script>
<template>
  <!--
    é€šç”¨ç”¨æˆ·å¤´åƒç»„ä»¶
    - æœ‰ url æ—¶å±•示图片;无 url æ—¶å±•示色卡 + é¦–字母/首字
    - ç‚¹å‡»é»˜è®¤è§¦å‘ UserInfoCard(clickable)
    - previewable=true æ—¶æ”¹ä¸ºç‚¹å¤´åƒç›´æŽ¥æ”¾å¤§é¢„览(用于名片 / è¯¦æƒ…页等大头像位)
  -->
  <div
    class="relative inline-flex"
    :style="{ cursor: clickable && !previewable ? 'pointer' : 'default' }"
    v-bind="$attrs"
    @click="handleClick"
  >
    <Image
      v-if="url && previewable"
      class="block overflow-hidden"
      :src="url"
      :preview="{ src: url, zIndex: previewZIndex }"
      :style="imgStyle"
    />
    <img
      v-else-if="url"
      class="block overflow-hidden object-cover"
      :src="url"
      :style="imgStyle"
      loading="lazy"
      :alt="name || 'avatar'"
    />
    <div
      v-else
      class="flex items-center justify-center text-white font-medium select-none"
      :style="textStyle"
    >
      {{ avatarText }}
    </div>
    <!-- å…è®¸å¤–部插入装饰,如群聊角标 -->
    <slot></slot>
  </div>
</template>
src/views/im/home/components/user/user-info-card.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,121 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType } from '../../../utils/constants'
import { getFriendDisplayName } from '../../../utils/user'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useImUiStore } from '../../store/uiStore'
import UserInfo, { type UserInfoRelation } from './user-info.vue'
defineOptions({ name: 'ImUserInfoCard' })
const uiStore = useImUiStore()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const router = useRouter()
const card = computed(() => uiStore.userInfoCard)
const user = computed(() => card.value.user)
const isSelf = computed(() => {
  const myId = getCurrentUserId()
  return !!user.value?.id && user.value.id === myId
})
const isActiveFriend = computed(() => {
  if (!user.value?.id || isSelf.value) {
    return false
  }
  return friendStore.isActiveFriend(user.value.id)
})
const relation = computed<UserInfoRelation>(() => {
  if (!user.value) {
    return 'readonly'
  }
  if (isSelf.value) {
    return 'self'
  }
  if (isActiveFriend.value) {
    return 'friend'
  }
  return 'stranger'
})
const remark = computed(() => {
  if (!isActiveFriend.value || !user.value?.id) {
    return undefined
  }
  return friendStore.getFriend(user.value.id)?.displayName || ''
})
/** å…³é—­åç‰‡ï¼šç‚¹å‡»é®ç½© / Esc / UserInfo æŠ›ä¸Šæ¥çš„删除成功事件 */
function handleClose() {
  uiStore.closeUserInfoCard()
}
/** é”®ç›˜äº‹ä»¶ï¼šEsc é”®å…³é—­åç‰‡ */
function handleKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && card.value.show) {
    handleClose()
  }
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
/** å‘消息:UserInfo æŠ›ä¸Šæ¥çš„"发消息",由本组件接力做 openConversation + è·³æ¶ˆæ¯ Tab + å…³æµ®å±‚ */
function handleSendMessage() {
  if (!user.value) {
    return
  }
  // å– friendStore é‡Œçš„æœ€æ–°å¤‡æ³¨ / å…æ‰“扰,避免新建会话用过期数据
  const friend = friendStore.getFriend(user.value.id)
  const conversationName = friend
    ? getFriendDisplayName(friend)
    : user.value.nickname || ''
  conversationStore.openConversation(
    user.value.id,
    ImConversationType.PRIVATE,
    conversationName,
    user.value.avatar || '',
    { silent: !!friend?.silent }
  )
  // è·³è½¬ä¼šè¯ Tab(如果不在的话),并且不管当前路由是啥都先切到会话列表页
  if (router.currentRoute.value.name !== 'ImHomeConversation') {
    router.push({ name: 'ImHomeConversation' })
  }
  uiStore.closeUserInfoCard()
}
</script>
<template>
  <!--
    ç”¨æˆ·åç‰‡æµ®å±‚
    - ä»…承担"浮层定位 + å…³é—­é€»è¾‘(点遮罩 / Esc)",名片视觉走 <UserInfo>,与 contact è¯¦æƒ…共用一份组件
    - è§¦å‘:useImUiStore.openUserInfoCard(user, position);本组件订阅 store,全局只挂一份实例
    - å…³ç³»æ€ç”± isSelf / isActiveFriend æ´¾ç”Ÿ relation prop é€åˆ° UserInfo;删除 / åŠ å¥½å‹ / å¤‡æ³¨è½åº“都在 UserInfo å†…闭环
  -->
  <teleport to="body">
    <div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose">
      <div
        class="fixed w-80 p-4 bg-[var(--ant-color-bg-elevated)] rounded-md shadow-xl"
        :style="{ left: `${card.position.x }px`, top: `${card.position.y }px` }"
        @click.stop
      >
        <UserInfo
          :user="user"
          :display-name="remark"
          :relation="relation"
          :preview-z-index="10000"
          :add-source="card.addSource"
          :add-source-extra="card.addSourceExtra"
          @chat="handleSendMessage"
          @deleted="handleClose"
        />
      </div>
    </div>
  </teleport>
</template>
src/views/im/home/components/user/user-info.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,447 @@
<script lang="ts" setup>
import type { User } from '../../types'
import { computed, h, nextTick, ref, watch } from 'vue'
import { confirm } from '#/packages/effects/common-ui/src'
import { DICT_TYPE } from '#/packages/constants/src'
import { getDictLabel } from '#/packages/effects/hooks/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Button, Checkbox, Dropdown, Input, Menu, message } from 'ant-design-vue'
import { getSimpleUser, type SystemUserApi } from '#/api/system/user'
import { formatDate } from '#/views/im/utils/time'
import { ImFriendAddSource } from '../../../utils/constants'
import { toUserCardTarget } from '../../../utils/message'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { FriendAddDialog } from '../friend'
import RecommendCardDialog from './recommend-card-dialog.vue'
import UserAvatar from './user-avatar.vue'
defineOptions({ name: 'ImUserInfo' })
const props = withDefaults(
  defineProps<{
    /** åŠ å¥½å‹æ¥æºï¼›é»˜è®¤ SEARCH;参见 ImFriendAddSource */
    addSource?: number
    /** æ¥æºé™„带信息:addSource=GROUP(群聊)时传群名,用于「我是 XX ç¾¤çš„ YY」预填话术 */
    addSourceExtra?: string
    /** å¤‡æ³¨ï¼šä»… relation=friend æ—¶æœ‰æ•ˆï¼›ç©ºä¸² â†’ æ˜¾ç¤º"添加备注"占位 */
    displayName?: string
    /** UserAvatar é¢„览层 z-index;放在高 z-index æµ®å±‚(如 UserInfoCard)里需手动抬高 */
    previewZIndex?: number
    relation?: UserInfoRelation
    /** èµ·æ‰‹ç”¨æˆ·ï¼šè‡³å°‘要有 id;性别 / éƒ¨é—¨ç”±ç»„件按 id æ‡’拉合并补齐 */
    user: null | User
  }>(),
  {
    addSource: ImFriendAddSource.SEARCH,
    addSourceExtra: '',
    displayName: '',
    previewZIndex: 2000,
    relation: 'readonly'
  }
)
const emit = defineEmits<{
  /** ç”¨æˆ·ç‚¹"发消息":导航 / å…³æµ®å±‚等场景相关动作由父级承担(比如 UserInfoCard è¦ close) */
  chat: [user: User]
  /** åˆ é™¤è”系人成功后通知父级(confirm + è°ƒ store éƒ½åœ¨æœ¬ç»„件内做完):父级关浮层 / æ¸…选中等 */
  deleted: [user: User]
  /** å¤‡æ³¨è½åº“成功后通知父级,用于同步父级持有的本地 FriendLite / Friend å‰¯æœ¬ï¼ˆå¦‚ contact é¡µçš„ selection) */
  saved: [value: string]
}>()
/**
 * å…³ç³»æ€ï¼šå†³å®šå¤‡æ³¨è¡Œ / å³ä¸Š "..." èœå• / åº•部动作区的内容
 * - friend: å¤‡æ³¨ï¼ˆå¯ç¼–辑)+ "..." åˆ é™¤èœå• + 3 å›¾æ ‡åŠ¨ä½œ
 * - stranger: å•按钮"加为好友"
 * - self: å•按钮"不能和自己聊天" disabled
 * - readonly: ä¸æ¸²æŸ“备注 / èœå• / åŠ¨ä½œåŒºï¼Œä»…å±•ç¤ºå¤´éƒ¨ä¿¡æ¯
 */
export type UserInfoRelation = 'friend' | 'readonly' | 'self' | 'stranger'
const friendStore = useFriendStore()
const full = ref<null | User>(props.user) // èµ·æ‰‹ user + getSimpleUser åˆå¹¶åŽçš„完整对象(性别 / éƒ¨é—¨è¡¥é½ç”¨ï¼‰
/** ä¸»æ ‡é¢˜ï¼šå¤‡æ³¨ä¼˜å…ˆï¼ˆå¥½å‹åœºæ™¯ï¼‰ï¼Œå…¶æ¬¡åŽŸæ˜µç§° */
const headerName = computed(() => props.displayName || full.value?.nickname || '')
const deptText = computed(() => full.value?.deptName || '-')
const genderIcon = computed(() => getGenderIcon(full.value?.sex))
const genderColor = computed(() => getGenderColor(full.value?.sex))
/** å¥½å‹å…³ç³»è®°å½•:来源 / æ·»åŠ æ—¶é—´ / æ˜¯å¦æ‹‰é»‘从这里取(仅 friend æ€ä¸‹æ‰æœ‰æ„ä¹‰ï¼‰ */
const friendInfo = computed(() =>
  props.user?.id ? friendStore.getFriend(props.user.id) : undefined
)
/** æ˜¯å¦å·²æ‹‰é»‘:菜单项「加入黑名单 / ç§»å‡ºé»‘名单」按这个切换 */
const isBlocked = computed(() => !!friendInfo.value?.blocked)
const editingRemark = ref(false) // å¤‡æ³¨å†…联编辑:editingRemark æŽ§åˆ¶è¾“入态;user åˆ‡æ¢æ—¶ç”±ä¸‹é¢çš„ watch å¤ä½é¿å…è„æ€æ³„漏
const remarkInput = ref('')
const remarkInputRef = ref<null | { focus: () => void; select?: () => void }>(null)
/**
 * user.id å˜åŒ–的统一处理:
 * 1. èµ·æ‰‹ç”¨ prop å…œåº•首屏(full = props.user),再 getSimpleUser å‘½ä¸­åŽåˆå¹¶æ›¿æ¢
 * 2. é¡ºä¾¿å¤ä½å¤‡æ³¨ç¼–辑态,避免上一个用户的脏输入泄漏到下一个
 * 3. ç«žæ€ç”¨ id æ¯”对丢弃陈旧响应
 */
watch(
  () => props.user?.id,
  async (id) => {
    full.value = props.user
    editingRemark.value = false
    if (!id) {
      return
    }
    const data = (await getSimpleUser(id)) as User
    if (props.user?.id !== id) {
      return
    }
    full.value = { ...props.user, ...data }
  },
  { immediate: true }
)
/** å¤‡æ³¨è¡Œç‚¹å‡»ï¼šè¿›ç¼–辑态 + æŠŠå½“前备注灌进输入框,下一帧把焦点 / å…¨é€‰äº¤ç»™ el-input */
async function handleRowClick() {
  if (editingRemark.value) {
    return
  }
  remarkInput.value = props.displayName || ''
  editingRemark.value = true
  await nextTick()
  remarkInputRef.value?.focus()
  remarkInputRef.value?.select?.()
}
/**
 * ä¿å­˜å¤‡æ³¨ï¼š
 * 1. é‡å…¥ä¿æŠ¤â€”—blur + Enter åŒæ—¶è§¦å‘只走一次(编辑态先复位)
 * 2. æ— å˜åŒ–跳过后端调用 + ä¸æŠ› saved,避免父级误同步
 * 3. è½åº“成功 / å¤±è´¥éƒ½åœ¨ç»„件内自闭:成功抛 saved è®©çˆ¶çº§åŒæ­¥æœ¬åœ°å‰¯æœ¬ï¼Œå¤±è´¥ç•™ç¼–辑态外的展示值不动
 */
async function saveRemark() {
  if (!editingRemark.value) {
    return
  }
  const userId = props.user?.id
  if (!userId) {
    editingRemark.value = false
    return
  }
  const next = remarkInput.value.trim()
  editingRemark.value = false
  if (next === (props.displayName || '')) {
    return
  }
  await friendStore.setFriendDisplayName(userId, next)
  message.success('已更新备注')
  emit('saved', next)
}
function cancelEditRemark() {
  editingRemark.value = false
}
/** å‘消息:导航 / å…³æµ®å±‚这些"业务侧"动作交给父级,本组件只负责通知 */
function handleChat() {
  if (!props.user) {
    return
  }
  emit('chat', props.user)
}
/** å ä½æç¤ºï¼šè¯­éŸ³ / è§†é¢‘聊天能力尚未接入,先以"开发中"友好提示 */
function handleComingSoon(featureName: string) {
  message.info(`${featureName} åŠŸèƒ½å¼€å‘ä¸­`)
}
// ==================== æ·»åŠ å¥½å‹ / åˆ é™¤å¥½å‹ ====================
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // åŠ å¥½å‹å¼¹çª— ref:handleAddFriend è°ƒ open({ presetUser, addSource, addSourceExtra }) è§¦å‘
const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // æŽ¨èåç‰‡å¼¹çª— ref:handleRecommend è°ƒç”¨ open({ target }) æ‰“å¼€
/** æŠŠä»–推荐给朋友:弹 RecommendCardDialog é€‰ç›®æ ‡ä¼šè¯ */
function handleRecommend() {
  if (!props.user?.id) {
    return
  }
  const target = toUserCardTarget(full.value)
  if (!target) {
    return
  }
  recommendDialogRef.value?.open({ target })
}
/** åŠ ä¸ºå¥½å‹ï¼šå¼¹ FriendAddDialog(带预填用户),让用户填申请理由 + å¤‡æ³¨åŽå†å‘申请 */
function handleAddFriend() {
  if (!props.user?.id) {
    return
  }
  const presetUser: SystemUserApi.UserSimple = {
    id: props.user.id,
    nickname: props.user.nickname,
    avatar: props.user.avatar,
    sex: props.user.sex,
    deptId: props.user.deptId,
    deptName: props.user.deptName
  } as SystemUserApi.UserSimple
  friendAddDialogRef.value?.open({
    presetUser,
    addSource: props.addSource,
    addSourceExtra: props.addSourceExtra
  })
}
/** åŠ å…¥é»‘åå•ï¼šconfirm â†’ friendStore.blockFriend;后端 FRIEND_BLOCK æŽ¨åˆ°æ—¶ç”± dispatcher åŒæ­¥å¤šç«¯ */
async function handleBlock() {
  if (!props.user?.id) {
    return
  }
  const target = props.user
  try {
    await confirm(`确定将「${target.nickname || ''}」加入黑名单吗?`, '加入黑名单')
  } catch {
    return
  }
  await friendStore.blockFriend(target.id)
  message.success('已加入黑名单')
}
/** ç§»å‡ºé»‘名单:操作温和不弹 confirm;后端 FRIEND_UNBLOCK æŽ¨åˆ°æ—¶ç”± dispatcher åŒæ­¥å¤šç«¯ */
async function handleUnblock() {
  if (!props.user?.id) {
    return
  }
  await friendStore.unblockFriend(props.user.id)
  message.success('已移出黑名单')
}
/** åˆ é™¤è”系人:弹自定义确认(含「同时清空聊天记录」选项)→ friendStore.deleteFriend â†’ é€šçŸ¥çˆ¶çº§å…³æµ®å±‚ / æ¸…选中 */
async function handleDeleteFriend() {
  if (!props.user?.id) {
    return
  }
  const target = props.user
  const clearConversation = ref(true)
  try {
    await confirm({
      cancelText: '取消',
      confirmText: '删除',
      content: h('div', { class: 'flex flex-col gap-3 text-sm' }, [
        h('div', `确定删除好友「${target.nickname || ''}」?`),
        h(
          Checkbox,
          {
            checked: clearConversation.value,
            'onUpdate:checked': (value: boolean) => {
              clearConversation.value = value
            }
          },
          () => '同时清空聊天记录'
        )
      ]),
      icon: 'warning',
      title: '删除联系人'
    })
  } catch {
    return
  }
  await friendStore.deleteFriend(target.id, clearConversation.value)
  message.success('已删除好友')
  emit('deleted', target)
}
</script>
<template>
  <!--
    ç”¨æˆ·åç‰‡è‡ªåŒ…含组件(浮层 / è¡Œå†…通用)
    - ç”± UserInfoCard(浮层)和 contact/index.vue(行内)共用:UserInfoCard æŠŠå®ƒæ”¾è¿› teleport æµ®å±‚,contact ç›´æŽ¥ mount åˆ°å³æ 
    - å…³ç³»æ€ç”± relation prop å†³å®šï¼ˆfriend / stranger / self / readonly),对应右上 "..." èœå• + åº•部动作区两块都内化在本组件
    - å¤‡æ³¨ / åˆ é™¤è”系人 / åŠ ä¸ºå¥½å‹ç­‰ store æ“ä½œéƒ½åœ¨æœ¬ç»„件内闭环,父级仅监听 chat / deleted / saved ç­‰é€šçŸ¥åšå¯¼èˆª / å…³æµ®å±‚ / åŒæ­¥å‰¯æœ¬
  -->
  <div>
    <!-- é¡¶éƒ¨ï¼šå¤´åƒï¼ˆå·¦ï¼‰ + åå­— + æ€§åˆ«å›¾æ ‡ + æ˜µç§° / éƒ¨é—¨ï¼ˆå³ï¼‰ + å³ä¸Š "..." èœå•(仅 friend æ€ï¼‰ -->
    <div class="flex gap-3 items-start">
      <UserAvatar
        :id="full?.id"
        :url="full?.avatar"
        :name="full?.nickname"
        :size="56"
        :clickable="false"
        previewable
        :preview-z-index="previewZIndex"
      />
      <div class="flex-1 min-w-0">
        <div class="flex items-center gap-1.5">
          <span
            class="text-lg font-semibold leading-snug truncate text-[var(--ant-color-text)]"
          >
            {{ headerName }}
          </span>
          <!-- æ€§åˆ«å°å›¾æ ‡ï¼šç”·è“ã€å¥³ç²‰ï¼ŒæœªçŸ¥ / 0 ä¸å±•示,对齐微信留白做法 -->
          <Icon
            v-if="genderIcon"
            :icon="genderIcon"
            :size="16"
            :color="genderColor"
            class="flex-shrink-0"
          />
        </div>
        <div class="mt-2 space-y-1 text-13px text-[var(--ant-color-text-secondary)]">
          <!-- ä»…当备注已设时展示昵称副行;未设置时主标题就是 nickname,避免重复 -->
          <div v-if="displayName" class="truncate">昵称:{{ full?.nickname }}</div>
          <div class="truncate">部门:{{ deptText }}</div>
        </div>
      </div>
      <!-- å³ä¸Š "..." èœå•:仅 friend æ€å±•示;含「加入/移出黑名单」 + ã€Œåˆ é™¤è”系人」 -->
      <div v-if="relation === 'friend'" class="flex-shrink-0">
        <Dropdown :trigger="['click']" placement="bottomRight" overlay-class-name="im-user-info__more-menu">
          <div
            class="flex items-center justify-center w-7 h-7 rounded cursor-pointer hover:bg-[var(--ant-color-fill-secondary)]"
          >
            <Icon icon="ep:more-filled" :size="18" class="text-[var(--ant-color-text-secondary)]" />
          </div>
          <template #overlay>
            <Menu>
              <!-- æŠŠä»–推荐给朋友:以个人名片消息形式发到选中的会话 -->
              <Menu.Item @click="handleRecommend">把他推荐给朋友</Menu.Item>
              <!-- æ‹‰é»‘ / ç§»å‡ºé»‘名单:按 friendInfo.blocked åˆ‡æ¢æ–‡æ¡ˆ -->
              <Menu.Item v-if="!isBlocked" divided @click="handleBlock">
加入黑名单
</Menu.Item>
              <Menu.Item v-else divided @click="handleUnblock">移出黑名单</Menu.Item>
              <Menu.Item divided @click="handleDeleteFriend">
                <span class="text-[var(--ant-color-error)]">删除联系人</span>
              </Menu.Item>
            </Menu>
          </template>
        </Dropdown>
      </div>
    </div>
    <!-- å¤‡æ³¨è¡Œï¼šä»… friend æ€å±•示,未编辑时整行可点;编辑态用 el-input è¡Œå†…替换 value åˆ— -->
    <template v-if="relation === 'friend'">
      <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div>
      <div
        class="flex gap-5 items-center px-1.5 py-1.5 text-sm"
        :class="
          !editingRemark
            ? 'group cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill-tertiary)]'
            : ''
        "
        @click="handleRowClick"
      >
        <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">备注</span>
        <Input
          v-if="editingRemark"
          ref="remarkInputRef"
          v-model:value="remarkInput"
          size="small"
          :maxlength="20"
          placeholder="添加备注"
          class="flex-1"
          @click.stop
          @blur="saveRemark"
          @keyup.enter="saveRemark"
          @keyup.esc="cancelEditRemark"
        />
        <template v-else>
          <span
            class="flex-1 min-w-0 truncate"
            :class="
              displayName
                ? 'text-[var(--ant-color-text)]'
                : 'text-[var(--ant-color-text-placeholder)]'
            "
          >
            {{ displayName || '添加备注' }}
          </span>
          <!-- é»˜è®¤ opacity:0 å ä½é¿å… hover æ—¶å…¶å®ƒåˆ—位移;仅父行 hover æ—¶æµ®çް -->
          <Icon
            icon="ant-design:edit-outlined"
            :size="14"
            class="flex-shrink-0 text-[var(--ant-color-text-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
          />
        </template>
      </div>
      <!-- ==================== æ›´å¤šä¿¡æ¯ï¼šæ¥æº / æ·»åŠ æ—¶é—´ï¼Œå¯¹é½å¾®ä¿¡ã€Œæœ‹å‹èµ„æ–™ã€åˆ†å— ==================== -->
      <template v-if="friendInfo?.addSource || friendInfo?.addTime">
        <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div>
        <div v-if="friendInfo?.addSource" class="flex gap-5 items-center px-1.5 py-1.5 text-sm">
          <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">来源</span>
          <span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]">
            {{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, friendInfo.addSource) }}
          </span>
        </div>
        <div v-if="friendInfo?.addTime" class="flex gap-5 items-center px-1.5 py-1.5 text-sm">
          <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">添加时间</span>
          <span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]">
            {{ formatDate(new Date(friendInfo.addTime), 'YYYY-MM-DD') }}
          </span>
        </div>
      </template>
    </template>
    <!-- åŠ¨ä½œåŒºï¼šå¥½å‹ = 3 å›¾æ ‡ï¼›é™Œç”Ÿäºº = åŠ ä¸ºå¥½å‹æŒ‰é’®ï¼›è‡ªå·± = disabled;readonly ä¸æ¸²æŸ“ -->
    <template v-if="relation !== 'readonly'">
      <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div>
      <div v-if="relation === 'friend'" class="flex justify-around">
        <!-- ä¸‰è¿žå›¾æ ‡æŒ‰é’®ï¼šå›¾ä¸Šå­—下、主色作为可点暗示,hover é™ä¸é€æ˜Žåº¦åšåé¦ˆ -->
        <div
          class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75"
          @click="handleChat"
        >
          <Icon icon="ant-design:message-outlined" :size="22" />
          <span>发消息</span>
        </div>
        <div
          class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75"
          @click="handleComingSoon('语音聊天')"
        >
          <Icon icon="ant-design:phone-outlined" :size="22" />
          <span>语音聊天</span>
        </div>
        <div
          class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75"
          @click="handleComingSoon('视频聊天')"
        >
          <Icon icon="ant-design:video-camera-outlined" :size="22" />
          <span>视频聊天</span>
        </div>
      </div>
      <div v-else-if="relation === 'self'" class="flex justify-center">
        <Button type="primary" disabled>不能和自己聊天</Button>
      </div>
      <div v-else class="flex justify-center">
        <Button type="primary" @click="handleAddFriend">加为好友</Button>
      </div>
    </template>
    <!-- åŠ å¥½å‹å¼¹çª—ï¼šæºå¸¦é¢„å¡«ç”¨æˆ·è·³è¿‡æœç´¢æ­¥éª¤ï¼Œç›´æŽ¥è¿›ç”³è¯·è¡¨å•ï¼ˆç†ç”±æŒ‰ addSource åŒºåˆ†è¯æœ¯ï¼‰ -->
    <FriendAddDialog ref="friendAddDialogRef" />
    <!-- æŠŠä»–推荐给朋友弹窗:仅 friend æ€ä¸‹å‡ºçŽ°å…¥å£ -->
    <RecommendCardDialog ref="recommendDialogRef" />
  </div>
</template>
<style>
/*
  éž scoped:el-dropdown çš„下拉菜单走 teleport åˆ° body,scoped é€‰ä¸åˆ°ã€‚
  UserInfoCard æµ®å±‚用 z-9998,要把这块抬到更高(默认 --ant-z-index-popup-base ~2050 ä¼šè¢«é®ç½©åŽ‹ä½ï¼‰ã€‚
*/
.im-user-info__more-menu {
  z-index: 10000 !important;
}
</style>
src/views/im/home/composables/useFriendBuckets.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,99 @@
import type { FriendLite } from '../types'
import { computed, type ComputedRef, type Ref } from 'vue'
/** å­—母分桶结果:letter å– 'A'-'Z' æˆ–兜底 '#';list æ¡¶å†…按拼音 / åå­—自然序 */
export interface FriendBucket {
  letter: string
  list: FriendLite[]
}
/** å–分桶 / æŽ’序键:备注拼音优先 â†’ æ˜µç§°æ‹¼éŸ³ â†’ åå­—本身(兜底英文 / æ•°å­—) */
function getSortKey(friend: FriendLite): string {
  return (
    friend.displayNamePinyin ||
    friend.nicknamePinyin ||
    (friend.displayName || friend.nickname || '').toLowerCase()
  )
}
/** å–分桶字母:拼音首字母大写,非字母(纯符号 / æ•°å­— / ä¸­æ–‡ï¼‰å…œåº• '#' */
function getBucketLetter(friend: FriendLite): string {
  const first = getSortKey(friend).charAt(0)
  return /^[a-zA-Z]$/.test(first) ? first.toUpperCase() : '#'
}
/** æ‹¼éŸ³é¦–字母拼接:「lao zhang」→ 'lz',支持「输 lz æœè€å¼ ã€ */
function pinyinInitials(pinyin?: string): string {
  if (!pinyin) {
    return ''
  }
  return pinyin
    .split(' ')
    .map((word) => word.charAt(0))
    .join('')
}
/**
 * å¥½å‹åˆ—表的搜索 + å­—母分桶
 *
 * - æœç´¢åŒ¹é…ï¼šå¤‡æ³¨ / æ˜µç§° / å…¨æ‹¼ï¼ˆåŽ»ç©ºæ ¼ï¼‰/ é¦–字母任一命中
 * - åˆ†æ¡¶è§„则:A-Z + '#' å…œåº•,桶内按 getSortKey è‡ªç„¶åº
 *
 * é€šè®¯å½•页 FriendList ä¸Žé€‰æ‹©ç±»å¼¹çª— FriendPickerPanel å…±ç”¨ï¼Œé¿å…è§„则各自实现走偏
 */
export function useFriendBuckets(
  friends: ComputedRef<FriendLite[]> | Ref<FriendLite[]>,
  keyword: Ref<string>
): {
  buckets: ComputedRef<FriendBucket[]>
  filtered: ComputedRef<FriendLite[]>
} {
  const filtered = computed(() => {
    const keywordLower = keyword.value.trim().toLowerCase()
    if (!keywordLower) {
      return friends.value
    }
    return friends.value.filter((friend) => {
      const nicknamePinyin = friend.nicknamePinyin || ''
      const displayNamePinyin = friend.displayNamePinyin || ''
      // å…¨æ‹¼æœç´¢åŽ»æŽ‰ç©ºæ ¼ï¼Œè®©ã€Œlaozhang」也能命中「lao zhang」
      return (
        (friend.nickname || '').toLowerCase().includes(keywordLower) ||
        (friend.displayName || '').toLowerCase().includes(keywordLower) ||
        nicknamePinyin.replaceAll(/\s/g, '').includes(keywordLower) ||
        displayNamePinyin.replaceAll(/\s/g, '').includes(keywordLower) ||
        pinyinInitials(nicknamePinyin).includes(keywordLower) ||
        pinyinInitials(displayNamePinyin).includes(keywordLower)
      )
    })
  })
  const buckets = computed<FriendBucket[]>(() => {
    const map = new Map<string, FriendLite[]>()
    for (const friend of filtered.value) {
      const letter = getBucketLetter(friend)
      const bucket = map.get(letter) ?? []
      bucket.push(friend)
      map.set(letter, bucket)
    }
    const letters = [...map.keys()].toSorted((a, b) => {
      // '#' æ°¸è¿œæŽ’末尾,A-Z èµ° localeCompare
      if (a === '#') {
        return 1
      }
      if (b === '#') {
        return -1
      }
      return a.localeCompare(b)
    })
    return letters.map((letter) => ({
      letter,
      list: (map.get(letter) ?? []).toSorted((a, b) =>
        getSortKey(a).localeCompare(getSortKey(b))
      )
    }))
  })
  return { filtered, buckets }
}
src/views/im/home/composables/useGroupCallMembers.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
import { computed, type ComputedRef, type Ref } from 'vue'
import { ImConversationType } from '../../utils/constants'
import { getSenderAvatar, getSenderDisplayName } from '../../utils/user'
import { useRtcStore } from '../store/rtcStore'
/** ç¾¤é€šè¯æˆå‘˜è§†å›¾æ¨¡åž‹ï¼šå·²åŠ å…¥ + æŽ¥å…¥ä¸­ï¼›pending å¤´åƒ UI åŠé€æ˜Žï¼Œjoined ä¸é€æ˜Ž */
export interface GroupCallMember {
  userId: number
  nickname: string
  avatar?: string
  pending: boolean
}
/**
 * ç¾¤é€šè¯æˆå‘˜åˆ—表 computed:joined åœ¨å‰ï¼Œæœª joined çš„ invitee åœ¨åŽï¼›
 * ç¼“存为空(来电首屏渲染早于 syncGroupActiveCall å›žå†™ï¼‰æ—¶ç”¨ fallback ä¸»å«å…œåº•,避免空白
 *
 * @param groupId ç¾¤ç¼–号
 * @param fallbackInviterId å…œåº•主叫;缓存为空时填一个头像,标记为已加入而非 pending
 */
export function useGroupCallMembers(
  groupId: Ref<number | undefined>,
  fallbackInviterId?: Ref<number | undefined>
): ComputedRef<GroupCallMember[]> {
  const rtcStore = useRtcStore()
  return computed(() => {
    const gid = groupId.value
    if (!gid) {
      return []
    }
    const groupCall = rtcStore.getGroupCall(gid)
    const joinedIds = groupCall?.joinedUserIds ?? []
    const inviteeIds = groupCall?.inviteeIds ?? []
    const joinedSet = new Set(joinedIds)
    const orderedIds = [...joinedIds, ...inviteeIds.filter((id) => !joinedSet.has(id))]
    if (orderedIds.length > 0) {
      return orderedIds.map((userId) => toVM(userId, gid, !joinedSet.has(userId)))
    }
    const fallback = fallbackInviterId?.value
    return fallback ? [toVM(fallback, gid, false)] : []
  })
}
/** æŠŠ userId ç¿»è¯‘成视图模型,统一走 user.ts helper è§£æžæ˜µç§° / å¤´åƒ */
function toVM(userId: number, groupId: number, pending: boolean): GroupCallMember {
  return {
    userId,
    nickname: getSenderDisplayName(userId, ImConversationType.GROUP, groupId),
    avatar: getSenderAvatar(userId, ImConversationType.GROUP, groupId) || undefined,
    pending
  }
}
src/views/im/home/composables/useLiveKitRoom.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,331 @@
import { computed, ref, shallowRef } from 'vue'
import {
  ConnectionQuality,
  type LocalParticipant,
  type Participant,
  type RemoteParticipant,
  Room,
  RoomEvent,
  Track,
  VideoPresets
} from 'livekit-client'
type ParticipantEventHandler = (userId: number) => void
/** LiveKit Room è¿žæŽ¥ / è®¾å¤‡ / äº‹ä»¶çš„薄封装;UI ç»„件只关心响应式状态 */
export function useLiveKitRoom() {
  /** Room å®žä¾‹ï¼›æ¨¡å—内部状态,不对外暴露,避免调用方误写 */
  const _room = shallowRef<null | Room>(null)
  /** åªè¯» room å¼•用;调用方仅用于幂等判定 */
  const room = computed(() => _room.value)
  /** æœ¬åœ°å‚与者;连接成功后赋值 */
  const localParticipant = shallowRef<LocalParticipant | null>(null)
  /** è¿œç«¯å‚与者列表;ParticipantConnected / Disconnected æ—¶åˆ·æ–°ï¼›shallowRef é¿å… Vue æ·±åº¦ä»£ç† SDK class å†…部 */
  const remoteParticipants = shallowRef<RemoteParticipant[]>([])
  /** è¿žæŽ¥çŠ¶æ€ */
  const isConnected = ref(false)
  /** è¿žæŽ¥è´¨é‡ */
  const connectionQuality = ref<ConnectionQuality>(ConnectionQuality.Unknown)
  /** éº¦å…‹é£Žå¼€å…³ */
  const micEnabled = ref(true)
  /** æ‘„像头开关 */
  const cameraEnabled = ref(false)
  /** æ‰¬å£°å™¨å¼€å…³ï¼›æµè§ˆå™¨æ— ç³»ç»Ÿçº§ API,通过 audio å…ƒç´  muted å±žæ€§å®žçŽ°è¿œç«¯éŸ³é¢‘é™éŸ³ */
  const speakerEnabled = ref(true)
  /** å±å¹•共享开关 */
  const screenShareEnabled = ref(false)
  /** å½“前是否处于「重连中」;瞬断时 UI æ˜¾ç¤ºæç¤ºè€Œä¸å¼ºåˆ¶ç»“束通话 */
  const reconnecting = ref(false)
  /** è¿œç«¯æ–­å¼€è®¢é˜…者;通话结束时统一清空 */
  const disconnectedHandlers = new Set<() => void>()
  /** æˆ¿å†…某人加入订阅者;主叫端用于从 INVITING åˆ‡åˆ° RUNNING */
  const participantConnectedHandlers = new Set<ParticipantEventHandler>()
  /** æˆ¿å†…某人离开订阅者;用于把 userId æ ‡è®°ä¸ºã€Œå·²é€€å‡ºã€ä»Ž pending å ä½ä¸­ç§»é™¤ */
  const participantDisconnectedHandlers = new Set<ParticipantEventHandler>()
  /** åŒæ­¥è¿œç«¯å‚与者列表到响应式数组 */
  function syncRemotes(r: Room) {
    remoteParticipants.value = [...r.remoteParticipants.values()]
  }
  /** è¿žæŽ¥ LiveKit Server;audio / video æŽ§åˆ¶åˆå§‹é»˜è®¤å¼€å…³ */
  async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) {
    // æ–°è¿žæŽ¥å‰å…ˆæ–­å¼€æ—§ Room;保留本次注册的事件回调
    if (_room.value) {
      await disconnectRoom(false)
    }
    const r = new Room({
      // æŒ‰æ ¼å­å°ºå¯¸è‡ªåЍ选 simulcast å±‚
      adaptiveStream: true,
      // æœªè®¢é˜…的层动态停发,节省上行
      dynacast: true,
      // é‡‡é›†åˆ†è¾¨çއ 720p,确保大格子清晰
      videoCaptureDefaults: {
        resolution: VideoPresets.h720.resolution
      },
      // å‘布编码上限 1.5 Mbps / 30fps;保留默认 simulcast ä¸‰å±‚(180p / 360p / 720p)
      publishDefaults: {
        videoEncoding: {
          maxBitrate: 1_500_000,
          maxFramerate: 30,
          priority: 'high'
        },
        // å±å¹•共享码率 3 Mbps,文字界面清晰
        screenShareEncoding: {
          maxBitrate: 3_000_000,
          maxFramerate: 15,
          priority: 'medium'
        }
      }
    })
    _room.value = r
    r.on(RoomEvent.ParticipantConnected, (rp) => {
        syncRemotes(r)
        const userId = parseUserId(rp.identity)
        if (userId != null) {
          participantConnectedHandlers.forEach((cb) => cb(userId))
        }
      })
      .on(RoomEvent.ParticipantDisconnected, (rp) => {
        syncRemotes(r)
        // ç¦»å¼€çš„参与者缓存清掉,避免下次同 sid é‡è¿žå‘½ä¸­å¤±æ•ˆå¼•用
        for (const key of streamCache.keys()) {
          if (key.startsWith(`${rp.sid}:`)) {
            streamCache.delete(key)
          }
        }
        const userId = parseUserId(rp.identity)
        if (userId != null) {
          participantDisconnectedHandlers.forEach((cb) => cb(userId))
        }
      })
      .on(RoomEvent.TrackSubscribed, () => syncRemotes(r))
      .on(RoomEvent.TrackUnsubscribed, () => syncRemotes(r))
      // mute / unmute è®© pickStream çš„ isMuted çŸ­è·¯é‡ç®—,video å…ƒç´ èƒ½è§£ç»‘ srcObject è€Œä¸æ˜¯å¡æœ€åŽä¸€å¸§
      .on(RoomEvent.TrackMuted, () => syncRemotes(r))
      .on(RoomEvent.TrackUnmuted, () => syncRemotes(r))
      .on(RoomEvent.ConnectionQualityChanged, (quality) => {
        connectionQuality.value = quality
      })
      // çž¬æ–­ â†’ æ˜¾ç¤ºã€Œé‡è¿žä¸­ã€ï¼›ä¸å…³é€šè¯çª—,由 SDK å†…部重连机制恢复
      .on(RoomEvent.Reconnecting, () => {
        reconnecting.value = true
      })
      .on(RoomEvent.Reconnected, () => {
        reconnecting.value = false
      })
      // é‡è¿žå¤±è´¥ / ä¸»åŠ¨æ–­ / è¢«è¸¢æ—¶è§¦å‘清理
      .on(RoomEvent.Disconnected, () => {
        isConnected.value = false
        reconnecting.value = false
        disconnectedHandlers.forEach((cb) => cb())
      })
    // é¢„热 getUserMedia ä¸Ž WebSocket æ¡æ‰‹å¹¶è¡Œï¼Œçœ 100~300ms ä¸²è¡Œå»¶è¿Ÿï¼›
    // æ‹¿åˆ°çš„ stream ä»…用于触发权限弹窗 + è®¾å¤‡å°±ç»ªï¼Œæ¡æ‰‹å®ŒæˆåŽç”± LiveKit å†…部重新请求设备发布轨
    const warmup = prewarmMedia(opts)
    // å»ºç«‹ WebSocket ä¿¡ä»¤ + WebRTC åª’体通道;完成后 localParticipant å¯ç”¨ï¼Œå·²åœ¨æˆ¿å‚与者会通过 ParticipantConnected äº‹ä»¶æ‰¹é‡æŽ¨é€
    await r.connect(url, token)
    // æœŸé—´è¢«å¤–部 disconnect æ›¿æ¢ï¼›ä¸­æ­¢åŽç»­ publish,避免摄像头被重新启用
    if (_room.value !== r) {
      return
    }
    localParticipant.value = r.localParticipant
    isConnected.value = true
    // é¢„热结果不直接发布(避免 SDK ä¸Žå¤–部 track ç”Ÿå‘½å‘¨æœŸçº ç¼ ï¼‰ï¼Œä»…等待权限就绪后再走标准 setXxxEnabled
    await warmup
    if (_room.value !== r) {
      return
    }
    // éº¦å…‹é£Žä¸Žæ‘„像头权限相互独立,并行启用发布
    const inits: Promise<unknown>[] = []
    if (opts.audio) {
      inits.push(r.localParticipant.setMicrophoneEnabled(true))
    }
    if (opts.video) {
      inits.push(r.localParticipant.setCameraEnabled(true))
    }
    if (inits.length > 0) {
      await Promise.all(inits)
    }
    micEnabled.value = !!opts.audio
    cameraEnabled.value = !!opts.video
    // å…œåº•同步一次远端列表:r.connect æœŸé—´ ParticipantConnected äº‹ä»¶å¯èƒ½åœ¨ handler ç»‘定前触发被吞,导致首屏漏人
    syncRemotes(r)
  }
  /** æå‰è§¦å‘权限弹窗 + è®¾å¤‡å”¤èµ·ï¼Œä¸²è¡Œå»¶è¿Ÿåœ¨ r.connect æœŸé—´ä¸€èµ·è·‘;失败静默(连接后会再试一次) */
  async function prewarmMedia(opts: { audio?: boolean; video?: boolean }): Promise<void> {
    if (!opts.audio && !opts.video) {
      return
    }
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: !!opts.audio,
        video: !!opts.video
      })
      // æ‹¿åˆ°æƒé™å³å¯ï¼Œç«‹å³åœæŽ‰æ‰€æœ‰ track é‡Šæ”¾è®¾å¤‡ï¼›æ­£å¼å‘布走 SDK æµç¨‹é‡æ–°è¯·æ±‚
      stream.getTracks().forEach((t) => t.stop())
    } catch {
      // ç”¨æˆ·æ‹’绝 / è®¾å¤‡å ç”¨ç­‰å¼‚常,交给后续 setXxxEnabled å†æ¬¡å°è¯•报错
    }
  }
  /** åˆ‡éº¦å…‹é£Ž */
  async function setMicEnabled(enabled: boolean) {
    if (!_room.value) {
      return
    }
    await _room.value.localParticipant.setMicrophoneEnabled(enabled)
    micEnabled.value = enabled
  }
  /** åˆ‡æ‘„像头 */
  async function setCameraEnabled(enabled: boolean) {
    if (!_room.value) {
      return
    }
    await _room.value.localParticipant.setCameraEnabled(enabled)
    cameraEnabled.value = enabled
  }
  /** åˆ‡æ‰¬å£°å™¨ï¼›ä»…切响应式状态,实际静音由模板上 audio å…ƒç´  :muted ç»‘定生效 */
  function setSpeakerEnabled(enabled: boolean) {
    speakerEnabled.value = enabled
  }
  /**
   * åˆ‡å±å¹•共享;
   *
   * æµè§ˆå™¨ä¼šå¼¹åŽŸç”Ÿã€Œé€‰æ‹©å…±äº«å†…å®¹ã€å¯¹è¯æ¡†ï¼Œç”¨æˆ·åœ¨å¼¹çª—é‡Œç‚¹å–æ¶ˆæ—¶ setScreenShareEnabled ä¼šæŠ›é”™ï¼Œæ•获并把状态复位回 SDK çš„实际值
   */
  async function setScreenShareEnabled(enabled: boolean) {
    if (!_room.value) return
    try {
      await _room.value.localParticipant.setScreenShareEnabled(enabled)
      screenShareEnabled.value = enabled
    } catch (error) {
      // ç”¨æˆ·åœ¨æµè§ˆå™¨åŽŸç”Ÿå¯¹è¯æ¡†é‡Œå–æ¶ˆé€‰æ‹©ï¼Œä¸å½“ä½œé”™è¯¯
      screenShareEnabled.value = _room.value.localParticipant.isScreenShareEnabled
      throw error
    }
  }
  /** æ³¨å†Œã€Œè¿œç«¯è¿žæŽ¥å¼‚常断开」回调;返回反注册函数 */
  function onDisconnected(cb: () => void): () => void {
    disconnectedHandlers.add(cb)
    return () => disconnectedHandlers.delete(cb)
  }
  /** æ³¨å†Œã€Œæˆ¿å†…某人加入」回调;返回反注册函数 */
  function onParticipantConnected(cb: ParticipantEventHandler): () => void {
    participantConnectedHandlers.add(cb)
    return () => participantConnectedHandlers.delete(cb)
  }
  /** æ³¨å†Œã€Œæˆ¿å†…某人离开」回调;返回反注册函数 */
  function onParticipantDisconnected(cb: ParticipantEventHandler): () => void {
    participantDisconnectedHandlers.add(cb)
    return () => participantDisconnectedHandlers.delete(cb)
  }
  /** identity æ˜¯åŽç«¯ç­¾ token æ—¶å¡žçš„ userId å­—符串,转 number è¿”回;非数字(兼容性兜底)返回 null */
  function parseUserId(identity: string): null | number {
    const id = Number(identity)
    return Number.isNaN(id) ? null : id
  }
  /**
   * MediaStream ç¼“存;key ä¸º `${participantSid}:${source}`,value ä¸º `{ track, stream }`;
   * åŒä¸€æ¡ MediaStreamTrack å¤ç”¨åŒä¸€ä¸ª MediaStream,避免 <video>.srcObject åå¤é‡æŒ‚导致解码管线重建(视频闪烁);
   * track å¼•用切换(重新订阅 / åˆ‡æµï¼‰æ—¶æŒ‰éœ€æ–°å»ºå¹¶æ›¿æ¢
   */
  const streamCache = new Map<string, { stream: MediaStream; track: MediaStreamTrack; }>()
  /**
   * å–参与者指定来源的轨道并打包为 MediaStream;
   * å‚与者走 unknown æ˜¯å› ä¸ºå“åº”式系统会丢失 livekit-client çš„类签名,函数内手动 cast å›žå‚与者类型;
   * å‘½ä¸­ç¼“存返回同一 MediaStream å¼•用,下游 watch / srcObject æ— éœ€é‡æŒ‚ï¼›
   * è½¨é“被 mute(本端关摄像头 / è¿œç«¯å…³æ‘„像头)时返回 null,让 video å…ƒç´ è§£ç»‘ srcObject è€Œä¸æ˜¯å¡åœ¨æœ€åŽä¸€å¸§
   */
  function pickStream(participant: unknown, source: Track.Source): MediaStream | null {
    const p = participant as Participant
    const pub = p.getTrackPublication(source)
    if (!pub || pub.isMuted) {
      return null
    }
    const track = pub.track?.mediaStreamTrack
    if (!track) {
      return null
    }
    const key = `${p.sid}:${source}`
    const cached = streamCache.get(key)
    if (cached && cached.track === track) {
      return cached.stream
    }
    const stream = new MediaStream([track])
    streamCache.set(key, { track, stream })
    return stream
  }
  /** æ–­å¼€å½“前 Room;clearHandlers ä¸º true æ—¶åŒæ­¥æ¸…理外部注册的事件回调 */
  async function disconnectRoom(clearHandlers: boolean) {
    // æ¸…理通话结束后不再复用的订阅回调
    if (clearHandlers) {
      disconnectedHandlers.clear()
      participantConnectedHandlers.clear()
      participantDisconnectedHandlers.clear()
    }
    // æ¸…理音视频轨道缓存
    streamCache.clear()
    if (_room.value) {
      // å¸è½½ Room äº‹ä»¶å¹¶æ–­å¼€è¿žæŽ¥
      _room.value.removeAllListeners()
      await _room.value.disconnect()
      _room.value = null
    }
    // é‡ç½®è¿žæŽ¥å’Œè®¾å¤‡çŠ¶æ€
    localParticipant.value = null
    remoteParticipants.value = []
    isConnected.value = false
    reconnecting.value = false
    micEnabled.value = true
    cameraEnabled.value = false
    speakerEnabled.value = true
    screenShareEnabled.value = false
  }
  /** ä¸»åŠ¨æ–­å¼€ï¼›é€šè¯ç»“æŸç»Ÿä¸€è°ƒ */
  async function disconnect() {
    await disconnectRoom(true)
  }
  return {
    room,
    localParticipant,
    remoteParticipants,
    isConnected,
    connectionQuality,
    micEnabled,
    cameraEnabled,
    speakerEnabled,
    screenShareEnabled,
    reconnecting,
    connect,
    disconnect,
    setMicEnabled,
    setCameraEnabled,
    setSpeakerEnabled,
    setScreenShareEnabled,
    pickStream,
    onDisconnected,
    onParticipantConnected,
    onParticipantDisconnected
  }
}
export type ImLiveKitRoom = ReturnType<typeof useLiveKitRoom>
src/views/im/home/composables/useMediaStreamElement.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
import { type VNodeRef, watch } from 'vue'
/**
 * æŠŠå“åº”式 MediaStream æŒ‚到 `<video>` / `<audio>` å…ƒç´ çš„ srcObject ä¸Šï¼›
 * stream ä¸ºç©ºæ—¶æ¸…掉 srcObject,避免设备关闭后画面卡在最后一帧
 */
export function useMediaStreamElement<T extends HTMLMediaElement>(
  streamSource: () => MediaStream | null | undefined
): VNodeRef {
  let el: T | null = null
  let currentStream: MediaStream | null | undefined
  const syncStream = () => {
    if (el) {
      el.srcObject = currentStream || null
    }
  }
  watch(
    streamSource,
    (stream) => {
      currentStream = stream
      syncStream()
    },
    { flush: 'post', immediate: true }
  )
  return (value) => {
    if (value instanceof HTMLMediaElement) {
      el = value as T
      syncStream()
      return
    }
    if (el) {
      el.srcObject = null
    }
    el = null
  }
}
src/views/im/home/composables/useMediaUploader.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,408 @@
import type { Conversation, Message } from '../types'
import type { AxiosProgressEvent } from '#/api/infra/file'
import { isOpenableUrl } from '#/packages/utils/src'
import { message } from 'ant-design-vue'
import { uploadFile } from '#/api/infra/file'
import { getCurrentUserId } from '#/views/im/utils/auth'
import {
  MESSAGE_FILE_MAX_MB,
  MESSAGE_IMAGE_MAX_MB,
  MESSAGE_VIDEO_MAX_MB,
  MESSAGE_VOICE_MAX_MB
} from '../../utils/config'
import { ImContentType, ImMessageStatus } from '../../utils/constants'
import { getConversationKey } from '../../utils/conversation'
import {
  type AudioMessage,
  BLOB_URL_PREFIX,
  type FileMessage,
  generateClientMessageId,
  type ImageMessage,
  parseMessage,
  type QuoteMessage,
  serializeMessage,
  type VideoMessage,
  withQuotePayload
} from '../../utils/message'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
import { useMessageSender } from './useMessageSender'
import { useMuteOverlay } from './useMuteOverlay'
type UploadProgressEvent = Parameters<NonNullable<AxiosProgressEvent>>[0]
/** å•条媒体 payload è”合(覆盖 IMAGE / FILE / VOICE / VIDEO å››ç§ï¼‰ */
export type MediaPayload = AudioMessage | FileMessage | ImageMessage | VideoMessage
/**
 * åª’体特定的元数据上下文:首发 / é‡ä¼ å…±ç”¨å…¥å‚;不同 type å…³å¿ƒä¸åŒå­—段
 *
 * - voiceDuration:语音时长(秒),首发由 VoiceRecorder ç»™ï¼Œé‡ä¼ ä»Žæ—§ AudioMessage.duration å–
 * - videoProbe:视频元信息(首发由 probeVideoFile è§£å‡ºï¼Œé‡ä¼ ä»Žæ—§ VideoMessage ç›´æŽ¥æ‹·å­—段)
 * - videoCoverUrl:视频封面真实 URL;占位阶段不设(避免传 blob å½“ poster åœ¨éƒ¨åˆ†æµè§ˆå™¨é€€åŒ–),commit é˜¶æ®µç”± cover ä¸Šä¼ ç»“果填入;重传时从旧 VideoMessage.coverUrl å¤ç”¨ï¼Œæ—§å€¼è‹¥æ˜¯ blob ä¼šè¢«è·³è¿‡
 */
export interface MediaTypeContext {
  voiceDuration?: number
  videoProbe?: { duration?: number; height?: number; width?: number; }
  videoCoverUrl?: string
}
export interface MediaTypeHandler {
  /** ä¸­æ–‡åï¼Œä»…日志用(替代之前散落 9 å¤„çš„ kind å­—符串) */
  kind: string
  /** ç”± file + url + context ç”Ÿæˆ payload;占位时 url æ˜¯ blob URL,commit æ—¶æ˜¯çœŸå®ž url */
  build: (file: File, url: string, context: MediaTypeContext) => MediaPayload
  /** é‡ä¼ åœºæ™¯ï¼šä»Žæ—§ content æå– context(让重传不需要重做 probe / é‡å½•语音) */
  extractResendContext: (oldContent: string) => MediaTypeContext
}
/** åª’体类型注册表:image / file / voice / video å„自的 kind + é¦–发 / é‡ä¼ å…±ç”¨çš„ build / extract */
export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
  [ImContentType.IMAGE]: {
    kind: '图片',
    build: (_file, url) => ({ url }) as ImageMessage,
    extractResendContext: () => ({})
  },
  [ImContentType.FILE]: {
    kind: '文件',
    build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage,
    extractResendContext: () => ({})
  },
  [ImContentType.VOICE]: {
    kind: '语音',
    build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
    extractResendContext: (oldContent) => {
      const old = parseMessage<AudioMessage>(oldContent)
      return { voiceDuration: old?.duration ?? 0 }
    }
  },
  [ImContentType.VIDEO]: {
    kind: '视频',
    build: (file, url, context) =>
      ({
        url,
        coverUrl: context.videoCoverUrl,
        duration: context.videoProbe?.duration,
        width: context.videoProbe?.width,
        height: context.videoProbe?.height,
        size: file.size
      }) as VideoMessage,
    extractResendContext: (oldContent) => {
      const old = parseMessage<VideoMessage>(oldContent)
      // æ—§ coverUrl æ˜¯ blob è¯´æ˜Žä¸Šä¼ æœŸå¤±è´¥ï¼ˆcover æ²¡ä¼ æˆåŠŸï¼‰ï¼Œä¸å¤ç”¨ï¼›çœŸå®ž URL ç›´æŽ¥å¤ç”¨ï¼Œçœä¸€æ¬¡å°é¢ä¸Šä¼ 
      const reuseCover =
        old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined
      return {
        videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
        videoCoverUrl: reuseCover
      }
    }
  }
}
/** å•次媒体上传的入参(image / file / voice èµ° uploadAndSendMedia;video èµ°ä½Žå±‚ helper è‡ªè¡Œç»„装) */
export interface UploadAndSendMediaOptions {
  file: File
  /** å¯¹é½ ImContentType;mediaTypeHandlers å¿…须有对应项 */
  type: number
  /** åª’体特定的元数据(如语音时长 / è§†é¢‘元信息);不传按空对象处理 */
  context?: MediaTypeContext
  /** å¼•用消息(若有),写进 payload.quote */
  quote?: QuoteMessage
  /** é”å®šèµ·å§‹ä¼šè¯ï¼Œä¸Šä¼ æœŸé—´ä¼šè¯åˆ‡èµ°åˆ™æ”¾å¼ƒå‘送 */
  conversation: Conversation
  /** é‡è¯•已有占位消息时复用的客户端消息编号 */
  existingClientMessageId?: string
}
/**
 * åª’体上传 + å‘送 composable(image / file / voice / video å…±ç”¨åº•层 helper)
 *
 * ä¸Ž useMessageSender.sendRaw çš„「先发请求再 ack」不同,媒体链路必须「先占位再上传」:
 * 1. ç«‹å³ insertMessage å†™å…¥å ä½æ¶ˆæ¯ï¼ˆstatus=SENDING、content ç”¨ blob URL、_localFile å†…存留 File)
 * 2. uploadFile ä¸Šä¼ ï¼ŒonUploadProgress å›žè°ƒ patchMessage æ›´æ–° uploadProgress;UI å®žæ—¶æ˜¾ç¤ºè¿›åº¦æ¡
 * 3. ä¸Šä¼ æˆåŠŸåŽç”¨çœŸå®ž url é‡ç”Ÿ content,patchMessage æ›¿æ¢ï¼›æ—§ blob URL ç”± store è‡ªåЍ revoke
 * 4. èµ° sendRaw(existingClientMessageId) å¤ç”¨å ä½å‘送请求,避免重复插入两条
 *
 * ä»»æ„å¤±è´¥æŠŠæ¶ˆæ¯çŠ¶æ€ç½® FAILED;MessageItem ä¸Šç‚¹é‡è¯•再走一次本函数(_localFile è¿˜åœ¨å†…存就行)
 */
/** æŒ‰å†…容类型映射体积上限(MB);未识别类型返回 0 è¡¨ç¤ºä¸é™ */
function resolveMediaMaxMb(type: number): number {
  switch (type) {
    case ImContentType.FILE: {
      return MESSAGE_FILE_MAX_MB
    }
    case ImContentType.IMAGE: {
      return MESSAGE_IMAGE_MAX_MB
    }
    case ImContentType.VIDEO: {
      return MESSAGE_VIDEO_MAX_MB
    }
    case ImContentType.VOICE: {
      return MESSAGE_VOICE_MAX_MB
    }
    default: {
      return 0
    }
  }
}
/** æ ¡éªŒåª’体文件大小是否超过内容类型上限;超限触发 warn å¹¶è¿”回 false,调用方不应进入占位 / ä¸Šä¼ é“¾è·¯ */
export function ensureMediaSizeWithinLimit(
  file: File,
  type: number,
  warn: (text: string) => void
): boolean {
  const maxMb = resolveMediaMaxMb(type)
  if (maxMb && file.size > maxMb * 1024 * 1024) {
    warn(`文件大小超过上限 ${maxMb}MB,请压缩后再发`)
    return false
  }
  return true
}
export const useMediaUploader = () => {
  const conversationStore = useConversationStore()
  const messageStore = useMessageStore()
  const muteOverlay = useMuteOverlay()
  const { sendRaw } = useMessageSender()
  /**
   * ç«‹å³å†™å…¥åª’体占位消息(低层 helper;image/file/voice èµ° uploadAndSendMedia åŒ…装,video ç›´æŽ¥ç”¨æœ¬å‡½æ•°ï¼‰
   *
   * ç”¨ createObjectURL(file) ç”Ÿæˆä¸´æ—¶ blob URL å–‚ç»™ buildContent;占位 status=SENDING + uploadProgress=0;
   * file æŒ‚在 _localFile ä¸Šä¾›å¤±è´¥é‡è¯•时重走上传
   */
  const insertMediaPlaceholder = (opts: {
    buildContent: (blobUrl: string) => string
    conversation: Conversation
    existingClientMessageId?: string
    file: File
    type: number
  }): { blobUrl: string; clientMessageId: string; } => {
    const { conversation } = opts
    const blobUrl = URL.createObjectURL(opts.file)
    const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
    if (opts.existingClientMessageId) {
      messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
        content: opts.buildContent(blobUrl),
        status: ImMessageStatus.SENDING,
        uploadProgress: 0,
        _localFile: opts.file
      })
      return { clientMessageId, blobUrl }
    }
    const placeholder: Message = {
      clientMessageId,
      type: opts.type,
      content: opts.buildContent(blobUrl),
      status: ImMessageStatus.SENDING,
      sendTime: Date.now(),
      senderId: getCurrentUserId(),
      targetId: conversation.targetId,
      selfSend: true,
      uploadProgress: 0,
      _localFile: opts.file
    }
    void messageStore.insertMessage(
      {
        type: conversation.type,
        targetId: conversation.targetId,
        name: conversation.name || String(conversation.targetId),
        avatar: conversation.avatar || ''
      },
      placeholder
    ).catch(() => undefined)
    return { clientMessageId, blobUrl }
  }
  /**
   * æŠŠå ä½æ¶ˆæ¯ç½®ä¸º FAILED(上传失败 / ä¼šè¯åˆ‡èµ° / ç¦è¨€æœŸåˆ°ç‚¹ ç­‰åœºæ™¯ç»Ÿä¸€æ”¶å°¾ï¼‰
   *
   * åŒæ—¶æ¸…掉 uploadProgress â€”— å¤±è´¥åŽ MessageItem çš„ isUploading ä¸å†å‘½ä¸­ï¼Œè¿›åº¦é®ç½© / æ–‡ä»¶ç‚¹å‡»ç¦ç”¨ / è¯­éŸ³ loading æŠ‘制 éƒ½åŒæ­¥è§£é™¤ï¼›
   * _localFile ä¿ç•™ï¼Œè®©ç”¨æˆ·ç‚¹é‡è¯•可以走 uploadAndSendMedia é‡ä¼ 
   */
  const markMediaFailed = (
    conversationType: number,
    targetId: number,
    clientMessageId: string
  ): void => {
    messageStore.patchMessage(conversationType, targetId, clientMessageId, {
      status: ImMessageStatus.FAILED,
      uploadProgress: undefined
    })
  }
  /**
   * ç”Ÿæˆ axios `onUploadProgress` å›žè°ƒï¼šç”¨ closure ç¼“存上次百分比,未变化直接 return ä¸è¿› store
   *
   * XHR onProgress å¤§æ–‡ä»¶ä¸‹æ¯ç§’触发 10-50 æ¬¡ï¼Œä½† Math.round åŽç™¾åˆ†æ¯”有大量重复(一秒内可能十几次同一个数字);
   * åœ¨æºå¤´åŽ»é‡ï¼Œèƒ½çœæŽ‰ store çš„ find + Object.assign + Vue reactivity è§¦å‘链
   */
  const createUploadProgressHandler = (conversation: Conversation, clientMessageId: string) => {
    let lastPercent = -1
    return (event: UploadProgressEvent): void => {
      if (!event.total) {
        return
      }
      const percent = Math.round((event.loaded / event.total) * 100)
      if (percent === lastPercent) {
        return
      }
      lastPercent = percent
      messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
        uploadProgress: percent
      })
    }
  }
  /** å–媒体类型中文名(仅日志用);未注册 type é€€åŒ–为通用「媒体」 */
  const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? '媒体'
  /**
   * æŒ‰ type å– handler,缺则抛错(程序错误集中在这一处)
   *
   * è°ƒç”¨æ–¹æ‹¿åˆ°çš„æ˜¯ `MediaTypeHandler`(非 undefined),不再需要 `!` æ–­è¨€ã€‚
   * ä»…给「确定 type åœ¨è¡¨é‡Œã€çš„调用方用 â€”— image/file/voice/video å››ç±»å…¥å£ï¼›é€šç”¨ dispatcher ä»å¯ç”¨ `mediaTypeHandlers[type]?.` optional chain
   */
  const requireMediaHandler = (type: number): MediaTypeHandler => {
    const handler = mediaTypeHandlers[type]
    if (!handler) {
      throw new Error(`[IM] æœªæ³¨å†Œçš„媒体类型 ${type}`)
    }
    return handler
  }
  /**
   * ä¸Šä¼ å®ŒæˆåŽçš„æ”¶å£æ ¡éªŒï¼šä¼šè¯ä»æ˜¯å ä½æ—¶é”å®šçš„那个 + å½“前未被禁言;任一不满足 markMediaFailed + è¿”回 false
   *
   * image / file / voice / video é“¾è·¯éƒ½è¦åœ¨ã€Œæ‹¿åˆ°çœŸå®ž url åŽã€è°ƒ sendRaw ä¹‹å‰ã€è¿‡ä¸€éè¿™ä¸¤é“
   */
  const verifyMediaUploadStillAllowed = (
    conversation: Conversation,
    startKey: string,
    type: number,
    clientMessageId: string
  ): boolean => {
    const activeConversation = conversationStore.activeConversation
    if (!activeConversation || getConversationKey(activeConversation) !== startKey) {
      console.warn(`[IM] ${getMediaKind(type)}上传期间切换了会话,放弃发送`, { startKey })
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return false
    }
    if (muteOverlay.value) {
      console.warn(`[IM] ${getMediaKind(type)}上传期间被禁言,放弃发送`, { startKey })
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return false
    }
    return true
  }
  /**
   * å ä½å®ŒæˆåŽç”¨çœŸå®ž url æ›¿æ¢ content,再走 sendRaw å®Œæˆå‘送
   *
   * ä¸Šä¼ æˆåŠŸ â†’ patch content â†’ sendRaw å¤ç”¨ existingClientMessageId;store å†…部 revoke æ—§ blob URL
   */
  const commitMediaPlaceholder = async (opts: {
    clientMessageId: string
    conversation: Conversation
    realContent: string
    type: number
  }): Promise<void> => {
    messageStore.patchMessage(
      opts.conversation.type,
      opts.conversation.targetId,
      opts.clientMessageId,
      { content: opts.realContent }
    )
    // æ˜¾å¼ä¼  conversation è€Œéžä¾èµ– sendRaw å†…部取 active:
    // verifyMediaUploadStillAllowed ä¸Ž sendRaw ä¹‹é—´å­˜åœ¨å¾®ç§’窗口,期间用户切会话也能保证发到原会话
    await sendRaw(opts.type, opts.realContent, {
      existingClientMessageId: opts.clientMessageId,
      targetId: opts.conversation.targetId,
      conversation: opts.conversation
    })
  }
  /**
   * ä¸Šä¼ åª’体文件并发送消息(高层入口;image / file / voice ç”¨ï¼Œvideo èµ°ä½Žå±‚ helper è‡ªè¡Œç»„装)
   *
   * @returns å ä½æ¶ˆæ¯çš„ clientMessageId(调用方按需用于后续 patch / ç§»é™¤ï¼›ä¸Šä¼ å¤±è´¥æ—¶å ä½ä»ä¿ç•™ä¸º FAILED æ€ï¼‰
   */
  const uploadAndSendMedia = async (opts: UploadAndSendMediaOptions): Promise<string> => {
    const { conversation } = opts
    const handler = mediaTypeHandlers[opts.type]
    if (!handler) {
      console.warn('[IM] uploadAndSendMedia æ”¶åˆ°æœªæ³¨å†Œçš„媒体类型', { type: opts.type })
      return ''
    }
    // ä½“积上限拦截:大文件浏览器内截帧 / è§£ç å¯è‡´ OOM;超限直接 warning,不进入占位 / ä¸Šä¼ é“¾è·¯
    if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) {
      return ''
    }
    const startKey = getConversationKey(conversation)
    const context = opts.context ?? {}
    const buildContent = (url: string): string =>
      serializeMessage(withQuotePayload(handler.build(opts.file, url, context), opts.quote))
    // 1. ç«‹å³å ä½
    const { clientMessageId } = insertMediaPlaceholder({
      file: opts.file,
      type: opts.type,
      conversation,
      buildContent,
      existingClientMessageId: opts.existingClientMessageId
    })
    // 2. ä¸Šä¼ ï¼šè¿›åº¦å›žè°ƒ patch uploadProgress;失败保留 _localFile ä¾›é‡è¯•
    let url: string | undefined
    try {
      url = await uploadFile(
        { file: opts.file },
        createUploadProgressHandler(conversation, clientMessageId)
      )
    } catch (error) {
      console.error(`[IM] ${handler.kind}上传失败`, error)
    }
    if (!url) {
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return clientMessageId
    }
    if (!isOpenableUrl(url)) {
      console.warn(`[IM] ${handler.kind}上传返回了不支持打开的 URL`, { url })
      message.warning('上传返回的文件地址不支持打开')
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return clientMessageId
    }
    // 3. ä¸Šä¼ æœŸé—´ä¼šè¯åˆ‡æ¢ / ç”¨æˆ·ç™»å‡º / è¢«ç¦è¨€ï¼šä»»ä¸€æƒ…况都放弃发送,占位置 FAILED
    if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) {
      return clientMessageId
    }
    // 4. patch content + sendRaw æ”¶å°¾
    await commitMediaPlaceholder({
      type: opts.type,
      conversation,
      clientMessageId,
      realContent: buildContent(url)
    })
    return clientMessageId
  }
  return {
    uploadAndSendMedia,
    insertMediaPlaceholder,
    markMediaFailed,
    commitMediaPlaceholder,
    createUploadProgressHandler,
    verifyMediaUploadStillAllowed,
    getMediaKind,
    requireMediaHandler
  }
}
src/views/im/home/composables/useMessageMultiSelect.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import type { Message } from '../types'
import { computed, reactive } from 'vue'
/**
 * æ¶ˆæ¯å¤šé€‰æ¨¡å¼
 *
 * æ¨¡å—级单例 reactive state;MessagePanel å­æ ‘内多处共享:
 * - MessageItem æ¸²æŸ“勾选 + åˆ‡æ¢å‹¾é€‰
 * - MessageMultiSelectBar åº•栏读选中数
 * - MessageForwardDialog è½¬å‘成功后 exit
 * - MessagePanel watch activeConversation åˆ‡ä¼šè¯ exit
 */
const state = reactive({
  active: false,
  /** å·²é€‰ clientMessageId åˆ—表,按选中顺序保序 */
  selectedClientMessageIds: [] as string[]
})
/** å·²é€‰ clientMessageId é›†åˆï¼›MessageItem å¤§é‡ has æŸ¥è¯¢èµ°å®ƒï¼Œé¿å… array.includes O(N²) */
const selectedIdSet = computed(() => new Set(state.selectedClientMessageIds))
/** è¿›å…¥å¤šé€‰æ¨¡å¼ï¼Œå¯é™„带初始勾选项 */
function enter(initialMessage?: Message) {
  state.active = true
  state.selectedClientMessageIds = initialMessage ? [initialMessage.clientMessageId] : []
}
/** é€€å‡ºå¤šé€‰æ¨¡å¼ */
function exit() {
  state.active = false
  state.selectedClientMessageIds = []
}
/** åˆ‡æ¢æŸæ¡æ¶ˆæ¯çš„选中态 */
function toggle(message: Message) {
  const ids = state.selectedClientMessageIds
  const index = ids.indexOf(message.clientMessageId)
  if (index === -1) {
    ids.push(message.clientMessageId)
  } else {
    ids.splice(index, 1)
  }
}
export function useMessageMultiSelect() {
  return { state, selectedIdSet, enter, exit, toggle }
}
src/views/im/home/composables/useMessagePuller.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,464 @@
import type { Message } from '../types'
import type { ImChannelMessageApi } from '#/api/im/message/channel'
import type { ImGroupMessageApi } from '#/api/im/message/group'
import type { ImPrivateMessageApi } from '#/api/im/message/private'
import { watch } from 'vue'
import { pullChannelMessageList as apiPullChannelMessageList } from '#/api/im/message/channel'
import { pullGroupMessageList as apiPullGroupMessageList } from '#/api/im/message/group'
import { getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId, pullPrivateMessageList as apiPullPrivateMessageList } from '#/api/im/message/private'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { buildChannelConversationStub } from '../../utils/channel'
import {
  MESSAGE_GROUP_PULL_SIZE,
  MESSAGE_PRIVATE_PULL_SIZE,
  MESSAGE_PRIVATE_READ_ENABLED
} from '../../utils/config'
import {
  ImContentType,
  ImConversationType,
  ImMessageStatus,
  isFriendChatTip,
  isFriendNotification
} from '../../utils/constants'
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
import { runMinIdPull } from '../../utils/pull'
import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { useGroupRequestStore } from '../store/groupRequestStore'
import { useGroupStore } from '../store/groupStore'
import { type PulledMessage, useMessageStore } from '../store/messageStore'
import { useRtcStore } from '../store/rtcStore'
import { useImWebSocketStore } from '../store/websocketStore'
/** ä¸‰ç±»æ¶ˆæ¯ pull æŽ¥å£è¿”回的原始 VO è”合类型;runMinIdPull åªéœ€ id æŽ¨è¿›æ¸¸æ ‡ï¼Œå…·ä½“分发在 applyPage å†…按类型 cast */
type PulledRawMessage = ImChannelMessageApi.ChannelMessageRespVO | ImGroupMessageApi.GroupMessageRespVO | ImPrivateMessageApi.PrivateMessageRespVO
/**
 * æ¶ˆæ¯å¢žé‡æ‹‰å–:登录后分页拉取离线期间的新消息
 *
 * è®¾è®¡è¦ç‚¹ï¼š
 * 1. åŒæ—¶æ‹‰å–私聊 + ç¾¤èŠï¼Œä½¿ç”¨å„自的 `minId` æ¸¸æ ‡ï¼ˆprivateMessageMaxId / groupMessageMaxId)
 * 2. åŽç«¯ä¸€æ¬¡æœ€å¤šè¿”回 size æ¡ï¼›å‰ç«¯æŒ‰ minId æŒç»­ç¿»é¡µï¼Œç›´åˆ°æŽ¥å£è¿”回空列表为止
 * 3. æ‹‰å–期间 conversationStore.loading=true:
 *    - conversationStore è·³è¿‡æ‰¹é‡æŒä¹…化,避免频繁写入卡顿
 *    - websocketStore æŠŠæ–°æ¥çš„ WS æ™®é€šæ¶ˆæ¯ä¸¢è¿›ç¼“冲区,等循环结束后统一回放
 * 4. WebSocket é‡è¿žåŽä¼šå†è§¦å‘一次拉取,补齐断网期间错过的消息
 */
export const useMessagePuller = () => {
  const conversationStore = useConversationStore()
  const messageStore = useMessageStore()
  const wsStore = useImWebSocketStore()
  const friendStore = useFriendStore()
  const groupStore = useGroupStore()
  const groupRequestStore = useGroupRequestStore()
  const rtcStore = useRtcStore()
  const currentUserId = getCurrentUserId()
  /** åˆ¤æ–­è¯·æ±‚是否被主动取消 */
  const isAbortError = (e: unknown): boolean => {
    const error = e as { code?: string; message?: string; name?: string; }
    return (
      error?.name === 'CanceledError' ||
      error?.code === 'ERR_CANCELED' ||
      error?.message === 'canceled'
    )
  }
  /** ç§èŠä¼šè¯å½’属:自己发的算"发给 receiverId çš„会话",否则算"发送方的会话";curry currentUserId è¿›é—­åŒ…减少 3 å¤„调用方的样板 */
  const getPrivatePeerId = (message: ImPrivateMessageApi.PrivateMessageRespVO) =>
    getPrivateMessagePeerId(message, currentUserId)
  /** æœåŠ¡ç«¯ç§èŠæ¶ˆæ¯ -> æœ¬åœ° Message:targetId æ˜¯ä¼šè¯ä¸»é”®ï¼ˆå¯¹ç«¯ userId) */
  const convertPrivateMessage = (message: ImPrivateMessageApi.PrivateMessageRespVO): Message => {
    return {
      id: message.id,
      clientMessageId: message.clientMessageId || generateClientMessageId(),
      type: message.type,
      content: message.content,
      status: message.status,
      receiptStatus: message.receiptStatus,
      sendTime: new Date(message.sendTime).getTime(),
      senderId: message.senderId,
      targetId: getPrivatePeerId(message),
      selfSend: message.senderId === currentUserId
    }
  }
  /** æœåŠ¡ç«¯ç¾¤èŠæ¶ˆæ¯ -> æœ¬åœ° Message */
  const convertGroupMessage = (message: ImGroupMessageApi.GroupMessageRespVO): Message => {
    return {
      id: message.id,
      clientMessageId: message.clientMessageId || generateClientMessageId(),
      type: message.type,
      content: message.content,
      status: message.status,
      sendTime: new Date(message.sendTime).getTime(),
      senderId: message.senderId,
      targetId: message.groupId,
      selfSend: message.senderId === currentUserId,
      atUserIds: message.atUserIds || [],
      receiverUserIds: message.receiverUserIds || [],
      receiptStatus: message.receiptStatus,
      readCount: message.readCount
    }
  }
  /** æœåŠ¡ç«¯é¢‘é“æ¶ˆæ¯ -> æœ¬åœ° Message */
  const convertChannelMessage = (message: ImChannelMessageApi.ChannelMessageRespVO): Message => {
    return {
      id: message.id,
      clientMessageId: message.clientMessageId || generateClientMessageId(),
      type: message.type,
      content: message.content,
      status: ImMessageStatus.NORMAL, // é¢‘道无撤回,恒为正常
      receiptStatus: message.receiptStatus, // é¢‘道已读态:DONE å·²è¯» / PENDING æœªè¯»
      sendTime: new Date(message.sendTime).getTime(),
      senderId: 0, // ç³»ç»Ÿä¸‹å‘,无发送人
      targetId: message.channelId, // ä¼šè¯å½’属到频道编号
      selfSend: false,
      materialId: message.materialId // è¯¦æƒ…页拉富文本用
    }
  }
  /** é¢‘道:会话归属到 channelId;name / avatar æš‚用占位,将来接入 channelStore åŽå†å¡«çœŸå€¼ */
  const convertChannelConversation = (message: ImChannelMessageApi.ChannelMessageRespVO) =>
    buildChannelConversationStub(message.channelId)
  /** ç§èŠï¼šä¼šè¯å½’属到对端 userId */
  const convertPrivateConversation = (message: ImPrivateMessageApi.PrivateMessageRespVO) => {
    const targetId = getPrivatePeerId(message)
    const friend = friendStore.getFriend(targetId)
    return {
      type: ImConversationType.PRIVATE,
      targetId,
      name: friend ? getFriendDisplayName(friend) : String(targetId), // ä¼šè¯åˆ—表 / é¡¶éƒ¨æ ‡é¢˜å±•示:好友备注 > çœŸå®žæ˜µç§°
      avatar: friend?.avatar || '',
      silent: friend?.silent
    }
  }
  /** ç¾¤èŠï¼šä¼šè¯å½’属到 groupId */
  const convertGroupConversation = (message: ImGroupMessageApi.GroupMessageRespVO) => {
    const group = groupStore.getGroup(message.groupId)
    return {
      type: ImConversationType.GROUP,
      targetId: message.groupId,
      name: group ? getGroupDisplayName(group) : String(message.groupId),
      avatar: group?.avatar || '',
      silent: group?.silent
    }
  }
  /**
   * åˆ†ç±»åž‹æ‹‰å–离线消息:翻页 / minId æ¸¸æ ‡æŽ¨è¿› / ç©ºé¡µåœç”± runMinIdPull è´Ÿè´£ï¼Œè¿™é‡ŒåªåšæŽ¥å£åˆ†æ”¯ + é€æ¡ä¸šåŠ¡åˆ†å‘
   * ï¼ˆæ’¤å›ž / å¥½å‹é€šçŸ¥ / æ™®é€šæ¶ˆæ¯ï¼‰+ å…¥åº“。
   *
   * å–消语义两层守卫,经 isActive ä¼ å…¥ runMinIdPull,任一不等即丢弃本批不入库、停止翻页,避免旧 session å“åº”落到新 store:
   * 1. startEpoch:cancelPull() é€’增 pullEpoch;离开 IM / åˆ‡è´¦å·æ—¶è·³å‡º
   * 2. startUserId:每批 await åŽæ¯”对当前登录 userId;防御 logout / å¤š tab ä¸‹ç”¨æˆ·å·²åˆ‡ä½† cancelPull æœªè§¦å‘
   */
  const pullByType = async (
    conversationType: number,
    startMinId: number,
    startEpoch: number,
    startUserId: number,
    signal: AbortSignal
  ) => {
    // ç§èŠ / ç¾¤èŠ / é¢‘道各自一套接口;按 conversationType åˆ†æ”¯è°ƒåº¦ã€‚翻页机制(minId æ¸¸æ ‡ / ç©ºé¡µåˆ¤æ–­ / é˜²æ­»ç¿»ï¼‰äº¤ç»™ runMinIdPull
    const isPrivate = conversationType === ImConversationType.PRIVATE
    const isChannel = conversationType === ImConversationType.CHANNEL
    const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
    const isStillValid = () =>
      !signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId
    await runMinIdPull<PulledRawMessage>({
      initialMinId: startMinId,
      pageSize: size,
      isActive: isStillValid,
      fetchPage: ({ minId, size }) => {
        if (isPrivate) {
          return apiPullPrivateMessageList({ minId, size }, signal)
        }
        if (isChannel) {
          return apiPullChannelMessageList({ minId, size }, signal)
        }
        return apiPullGroupMessageList({ minId, size }, signal)
      },
      applyPage: async (list, nextMinId) => {
        const pulledMessages: PulledMessage[] = []
        // é€æ¡ dispatch:原消息走批量 insert;RECALL ä¿¡å·èµ°æ‰¹é‡ recall æŠŠåŒæ‰¹å†…å·² insert çš„原消息更新为撤回提示。
        // åŽç«¯æŒ‰ id å‡åºè¿”回,且信号 id ä¸€å®š > åŽŸæ¶ˆæ¯ id(先更新 status å†æ’信号),所以原消息一定先到、recallMessage æ‰¾å¾—到
        for (const raw of list) {
          if (isChannel) {
            const message = raw as ImChannelMessageApi.ChannelMessageRespVO
            pulledMessages.push({
              kind: 'insert',
              conversationInfo: convertChannelConversation(message),
              message: convertChannelMessage(message)
            })
            continue
          }
          if (isPrivate) {
            const message = raw as ImPrivateMessageApi.PrivateMessageRespVO
            // ç‰¹æ®Šï¼šæ’¤å›žæ¶ˆæ¯çš„处理
            if (message.type === ImContentType.RECALL) {
              pulledMessages.push({
                kind: 'recall',
                conversationType: ImConversationType.PRIVATE,
                targetId: getPrivatePeerId(message),
                recallSignalContent: message.content
              })
              continue
            }
            // ç‰¹æ®Šï¼šåŽ†å²å¥½å‹äº‹ä»¶åªè¿˜åŽŸèŠå¤©æ°”æ³¡ï¼›å¥½å‹ä¸»æ•°æ®ç”±å¥½å‹å¢žé‡è¡¥å¿åŒæ­¥
            // ä»… FRIEND_ADD / FRIEND_DELETE æ‰ä½œä¸ºä¼šè¯æ°”泡入消息列表
            if (isFriendNotification(message.type) && !isFriendChatTip(message.type)) {
              continue
            }
            // å…¶å®ƒæ¶ˆæ¯æ­£å¸¸å…¥ä¼šè¯æ¶ˆæ¯åˆ—表
            pulledMessages.push({
              kind: 'insert',
              conversationInfo: convertPrivateConversation(message),
              message: convertPrivateMessage(message)
            })
          } else {
            const message = raw as ImGroupMessageApi.GroupMessageRespVO
            // ç‰¹æ®Šï¼šæ’¤å›žæ¶ˆæ¯çš„处理
            if (message.type === ImContentType.RECALL) {
              pulledMessages.push({
                kind: 'recall',
                conversationType: ImConversationType.GROUP,
                targetId: message.groupId,
                recallSignalContent: message.content
              })
              continue
            }
            pulledMessages.push({
              kind: 'insert',
              conversationInfo: convertGroupConversation(message),
              message: convertGroupMessage(message)
            })
          }
        }
        // å…¥åº“ + æŽ¨è¿› messageMaxId;nextMinId ä¸ºç©ºï¼ˆæœ¬æ‰¹æ— æœ‰æ•ˆ id)时不推进游标,与旧逻辑一致
        await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
      }
    })
  }
  /** åŒä¸€æ—¶åˆ»åªå…è®¸ä¸€æ¬¡ pull:index.vue çš„æ‰‹åŠ¨è°ƒç”¨ä¸Žé‡è¿ž watch è§¦å‘可能并发,共用同一个 promise å³å¯åŽ»é‡ */
  let pullPromise: null | Promise<void> = null
  let pullAbortController: AbortController | null = null
  /**
   * é¦–次 pull æ˜¯å¦å·²å®Œæˆã€‚仅在置 true åŽï¼ŒisConnected watch æ‰ä¼šè§¦å‘ pull。
   * é˜²æ­¢ socket onopen æ¯” friendStore/groupStore é¢„拉先到达时,watcher æŠ¢è·‘造成消息插入早于会话元数据可见
   */
  let initialPulled = false
  /**
   * pull è½®æ¬¡è®¡æ•°ï¼›åˆ‡è´¦å· / ç¦»å¼€ IM æ—¶ cancelPull() é€’增,旧 pullByType å¾ªçŽ¯æŒ‰ epoch è‡ªæ£€åŽè·³å‡º
   * é¿å…æ—§ session çš„æŽ¥å£å“åº”在新 session è½åœ°ï¼Œé€ æˆè·¨è´¦å·æ¶ˆæ¯æ³„漏
   *
   * æ³¨æ„ï¼šæ™®é€šæ–­è¿žï¼ˆWS çŸ­æ–­ï¼‰ä¸å–消 pull——网络抖动 / æœåŠ¡ç«¯é‡å¯éƒ½å±žäºŽæœ¬è´¦å·æ­£å¸¸ç”Ÿå‘½å‘¨æœŸï¼Œ
   * å–消会导致首拉被中断后 initialPulled æ°¸è¿œåœåœ¨ false,后续重连 watcher ä¸å†è¡¥æ‹‰
   */
  let pullEpoch = 0
  /** æ˜¾å¼å–消:仅由 index.vue onUnmounted(离开 IM / åˆ‡è´¦å· / è·¯ç”±è·³å‡ºï¼‰è°ƒç”¨ */
  const cancelPull = () => {
    pullEpoch++
    pullAbortController?.abort()
    pullAbortController = null
    // æ—§ promise ä»åœ¨ finally é˜¶æ®µè·‘,但 epoch å®ˆå«å·²é˜»æ–­åŽç»­å‰¯ä½œç”¨ï¼›è¿™é‡Œç«‹åˆ»è®© pullPromise = null è®©æ–°ä¸€è½®å¯é‡å…¥
    pullPromise = null
    // åŒæ­¥ä¸¢å¼ƒ WS ç¼“冲帧;旧 pull å·²ä¸ä¼š flushBuffer,若不清下次进 IM ç¬¬ä¸€æ¬¡ pullOnce ä¼šæŠŠæ—§ session çš„帧回放进新 store
    wsStore.discardBuffer()
  }
  /**
   * çŠ¶æ€äº‹ä»¶è¡¥å¿ï¼šå¥½å‹ / å¥½å‹ç”³è¯·èµ°å¢žé‡ï¼›ç¾¤åˆ—表和群申请红点走快照刷新
   *
   * é¦–登主数据由 index.vue é©±åŠ¨ï¼Œé‡è¿žæ—¶å„ store å·²å°±ä½ï¼Œå¤šè·¯ allSettled å¹¶å‘互不影响,单路失败仅记日志。
   * ç¾¤æˆå‘˜ä¸åšå…¨å±€å¢žé‡åŒæ­¥ï¼Œé‡è¿žåªæ ‡è®°æœ¬åœ°ç¾¤æˆå‘˜ cache è¿‡æœŸï¼Œè¿›å…¥ç¾¤ä¼šè¯æˆ–成员列表时再按 groupId åˆ·æ–°ã€‚
   */
  const pullStateEvents = async (): Promise<void> => {
    // 1. æ¸…理连接级缓存
    messageStore.clearPrivateReadMaxIdCache()
    rtcStore.clearGroupCallCache()
    groupStore.markAllGroupActiveCallsExpired()
    groupStore.markAllGroupInfoExpired()
    groupStore.markAllGroupMembersExpired()
    // 2. å¹¶å‘补偿远端状态
    const results = await Promise.allSettled([
      friendStore.pullFriends(),
      friendStore.pullFriendRequests(),
      conversationStore.pullConversationReads(),
      groupStore.fetchGroupList(true),
      groupRequestStore.pullGroupRequests(),
      groupRequestStore.fetchUnhandledGroupRequestList()
    ])
    for (const result of results) {
      if (result.status === 'rejected') {
        console.warn('[IM] çŠ¶æ€äº‹ä»¶å¢žé‡è¡¥å¿å¤±è´¥', result.reason)
      }
    }
  }
  /** æ‰§è¡Œä¸€æ¬¡å…¨é‡å¢žé‡æ‹‰å–(重入安全:进行中再次调用复用同一个 promise) */
  const pullOnce = (): Promise<void> => {
    if (!currentUserId) {
      return Promise.resolve()
    }
    if (pullPromise) {
      return pullPromise
    }
    const startEpoch = pullEpoch
    // å¯åŠ¨æ—¶çš„ç”¨æˆ·å¿«ç…§ï¼›pullByType æ¯æ‰¹ await åŽæ¯”对当前登录用户,账号变了立刻丢弃
    const startUserId = currentUserId
    const abortController = new AbortController()
    pullAbortController = abortController
    // æœ¬è½® pull ä»å±žäºŽå½“前 session:epoch æœªæ¼‚ + ç”¨æˆ·æœªåˆ‡ï¼›ä»»ä½•动新 store çŠ¶æ€çš„å‰¯ä½œç”¨éƒ½è¦å…ˆè¿‡è¿™é“å…³
    const isCurrentPull = () =>
      !abortController.signal.aborted &&
      pullEpoch === startEpoch &&
      getCurrentUserId() === startUserId
    pullPromise = (async () => {
      try {
        // æ—§ puller åœ¨ cancelPull æœªè§¦å‘的异常路径上再进来时,先于任何副作用退出,避免污染新 session çš„ loading
        if (!isCurrentPull()) {
          return
        }
        conversationStore.loading = true
        let messagePullSucceeded = false
        try {
          // å¹¶å‘拉取私聊 + ç¾¤èŠ + é¢‘道消息,降低初始加载耗时
          await Promise.all([
            pullByType(
              ImConversationType.PRIVATE,
              messageStore.privateMessageMaxId,
              startEpoch,
              startUserId,
              abortController.signal
            ),
            pullByType(
              ImConversationType.GROUP,
              messageStore.groupMessageMaxId,
              startEpoch,
              startUserId,
              abortController.signal
            ),
            pullByType(
              ImConversationType.CHANNEL,
              messageStore.channelMessageMaxId,
              startEpoch,
              startUserId,
              abortController.signal
            )
          ])
          messagePullSucceeded = true
        } catch (error) {
          if (isAbortError(error)) {
            return
          }
          console.error('[IM] æ‹‰å–离线消息失败:', error)
        } finally {
          // ä»å±žæœ¬è½®æ‰å¤ä½ loading;旧轮被 cancel / åˆ‡è´¦å·æ—¶ç”±æ–°ä¸€è½®è‡ªç®¡ï¼Œé¿å…è¦†ç›–æ–° session çš„ true
          if (isCurrentPull()) {
            conversationStore.loading = false
          }
        }
        // å–消 / åˆ‡è´¦å·åŽè·³è¿‡ flushBuffer / æŽ’序 / å·²è¯»ä½ç½®è¡¥é½
        if (!isCurrentPull()) {
          return
        }
        if (!messagePullSucceeded) {
          return
        }
        // å›žæ”¾ WebSocket åœ¨ loading æœŸé—´æ”¶åˆ°çš„缓冲消息
        const buffered = wsStore.flushBuffer()
        const replayPersistPromises: Promise<void>[] = []
        for (const item of buffered) {
          if (item.conversationType === ImConversationType.PRIVATE) {
            replayPersistPromises.push(wsStore.handlePrivateMessage(item.payload))
          } else if (item.conversationType === ImConversationType.CHANNEL) {
            replayPersistPromises.push(wsStore.handleChannelMessage(item.payload))
          } else {
            replayPersistPromises.push(wsStore.handleGroupMessage(item.payload))
          }
        }
        await Promise.all(replayPersistPromises)
        // pull + replay éƒ½å®ŒæˆåŽå†æŽ’序,避免回放消息打乱顺序
        conversationStore.sortConversationList()
        // é‡è¿ž / å†·å¯åŠ¨åŽè¡¥é½å½“å‰æ¿€æ´»ç§èŠä¼šè¯çš„ã€Œå¯¹æ–¹å·²è¯»ä½ç½®ã€
        // ç¦»çº¿æœŸé—´é”™è¿‡çš„ RECEIPT æŽ¨é€ä¼šè¢«è¿™é‡Œè¡¥å›žï¼›å…¶ä»–私聊会话等用户点开时由 index.vue çš„ watch è§¦å‘
        // ç§èŠå·²è¯»å…³é—­æ—¶è·³è¿‡ï¼Œé¿å…æ‰“到已禁用接口触发错误日志
        const active = conversationStore.activeConversation
        if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
          try {
            const maxReadId = await apiGetPrivateMaxReadMessageId(
              active.targetId,
              abortController.signal
            )
            if (!isCurrentPull()) {
              return
            }
            messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
            if (maxReadId) {
              messageStore.applyMessageReadReceipt({
                conversationType: ImConversationType.PRIVATE,
                targetId: active.targetId,
                privateReadMaxId: maxReadId
              })
            }
          } catch (error) {
            if (isAbortError(error)) {
              return
            }
            console.warn('[IM] æ‹‰å–对方已读位置失败', error)
          }
        }
      } finally {
        // ä»å±žæœ¬è½®ï¼šæ­£å¸¸å®Œæˆé¦–拉;epoch ç­‰ä½† userId åˆ‡äº†ï¼šæ¸… pullPromise é˜²å¡æ­»ã€ä¸æ ‡é¦–拉;epoch æ¼‚:cancelPull å·²æ¸…,no-op
        if (isCurrentPull()) {
          pullPromise = null
          initialPulled = true
          if (pullAbortController === abortController) {
            pullAbortController = null
          }
        } else if (pullEpoch === startEpoch) {
          pullPromise = null
          if (pullAbortController === abortController) {
            pullAbortController = null
          }
        }
      }
    })()
    return pullPromise
  }
  /**
   * æ–­ç½‘期间 WS æ”¶ä¸åˆ°æŽ¨é€ï¼šé‡è¿žåŽæ—¢è¦æŒ‰ minId è¡¥é½æ¶ˆæ¯ï¼Œä¹Ÿè¦æŒ‰ update_time + id è¡¥é½å¥½å‹ / ç¾¤ / ç¾¤ç”³è¯·çŠ¶æ€ã€‚
   * é¦–次连接由 index.vue æ˜¾å¼é©±åŠ¨ï¼ˆpullOnce æ‹‰æ¶ˆæ¯ + å„ store é¦–拉),这里仅覆盖之后的重连。
   * é‡è¿žæ—¶ store å·²å°±ä½ï¼ŒpullStateEvents ä¸Ž pullOnce å¹¶å‘即可,无需「先就位再拉消息」的首登顺序约束。
   */
  watch(
    () => wsStore.isConnected,
    (isConnected) => {
      if (isConnected && initialPulled) {
        void pullOnce()
        void pullStateEvents()
      }
    }
  )
  return { pullOnce, cancelPull, convertPrivateMessage, convertGroupMessage }
}
src/views/im/home/composables/useMessageSender.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,337 @@
import type { Conversation, Message } from '../types'
import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'
import {
  readGroupMessages as apiReadGroupMessages,
  recallGroupMessage as apiRecallGroupMessage,
  sendGroupMessage as apiSendGroupMessage
} from '#/api/im/message/group'
import {
  getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId,
  readPrivateMessages as apiReadPrivateMessages,
  recallPrivateMessage as apiRecallPrivateMessage,
  sendPrivateMessage as apiSendPrivateMessage
} from '#/api/im/message/private'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { MESSAGE_GROUP_READ_ENABLED, MESSAGE_PRIVATE_READ_ENABLED } from '../../utils/config'
import { ImContentType, ImConversationType, ImMessageStatus } from '../../utils/constants'
import { getClientConversationId } from '../../utils/db'
import {
  generateClientMessageId,
  type QuoteMessage,
  serializeMessage,
  type TextMessage,
  withQuotePayload
} from '../../utils/message'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
/** éžæ–‡æœ¬æ¶ˆæ¯çš„æ‰©å±•选项(通用) */
interface SendExtOptions {
  atUserIds?: number[] // ç¾¤èŠ @ çš„用户编号列表
  receipt?: boolean // æ˜¯å¦éœ€è¦ç¾¤å›žæ‰§ï¼ˆé»˜è®¤ false)
  targetId?: number // è¦†ç›–默认的 targetId
  /**
   * æ˜¾å¼æŒ‡å®šç›®æ ‡ä¼šè¯ï¼ˆè½¬å‘ / åç‰‡æŽ¨èåœºæ™¯ï¼‰
   *
   * ä¸ä¼ æ—¶é»˜è®¤å– conversationStore.activeConversation;传入时按本值发送 + ä¹è§‚更新到对应会话,
   * ä¸è¦æ±‚该会话当前是激活状态(适合发给「非当前会话」的多个目标)
   */
  conversation?: Conversation
  /** è¢«å¼•用消息(可选):写进 content.quote ç”¨äºŽä¹è§‚渲染,服务端按 quote.messageId åæŸ¥é‡ç®—覆盖 */
  quote?: QuoteMessage
  /**
   * å¤ç”¨å·²å­˜åœ¨çš„æœ¬åœ°å ä½æ¶ˆæ¯ clientMessageId(媒体上传场景)
   *
   * åª’体上传链路在请求服务端前已经 insertMessage äº†å ä½ï¼ˆå¸¦ blob URL + è¿›åº¦æ¡ï¼‰ï¼Œ
   * è¿™é‡Œè·³è¿‡ buildLocalMessage / insertMessage,直接拿这个 id èµ° ackMessage æ”¶å°¾ï¼Œé¿å…é‡å¤æ’入两条
   */
  existingClientMessageId?: string
}
/**
 * æ¶ˆæ¯å‘送 / æ’¤å›ž / å·²è¯» ç»„合式逻辑
 *
 * è®¾è®¡è¦ç‚¹ï¼š
 * 1. ç§èŠ / ç¾¤èŠæŽ¥å£ç­¾åå¯¹ç§°ï¼ŒæŒ‰ conversation.type åˆ†æ”¯è°ƒåº¦ï¼Œå·®å¼‚在分支内部消化
 * 2. å‘送走「乐观更新」:先 insertMessage å†™å…¥ SENDING å ä½ï¼Œè¯·æ±‚成功 ackMessage æ›´æ–°ä¸º NORMAL,失败更新为 FAILED
 * 3. æ’¤å›žä¸åšä¹è§‚更新:服务端通过 WebSocket RECALL äº‹ä»¶å›žä¼ ï¼Œç”± websocketStore ç»Ÿä¸€æ›´æ–°çŠ¶æ€ï¼Œé¿å…ç½‘ç»œå¤±è´¥åŽä¸å¯å›žé€€
 * 4. å·²è¯»ä¸ŠæŠ¥ï¼šæœ¬ç«¯ç«‹åˆ»æ¸…未读数并记录本地读位置;接口失败仅记录日志
 */
export const useMessageSender = () => {
  const conversationStore = useConversationStore()
  const messageStore = useMessageStore()
  /** æž„造本地乐观消息对象 */
  const buildLocalMessage = (opts: {
    atUserIds?: number[]
    clientMessageId: string
    content: string
    targetId: number
    type: number
  }): Message => {
    return {
      clientMessageId: opts.clientMessageId,
      type: opts.type,
      content: opts.content,
      status: ImMessageStatus.SENDING,
      sendTime: Date.now(),
      senderId: getCurrentUserId(),
      targetId: opts.targetId,
      selfSend: true,
      atUserIds: opts.atUserIds
    }
  }
  /**
   * å‘送任意类型的消息(底层实现)
   * 1. æ–‡æœ¬ã€å›¾ç‰‡ã€æ–‡ä»¶ã€è¯­éŸ³ç­‰éƒ½èµ°è¿™é‡Œ
   * 2. type / content ç”±è°ƒç”¨æ–¹æž„造
   * 3. è¿”回值:成功 true / å¤±è´¥ false(失败时本地占位已标 FAILED);参数缺失等无法发送的场景也返 false
   *    è½¬å‘ / åç‰‡æŽ¨èç­‰åœºæ™¯æŒ‰è¿”回值决定是否继续后续动作(如有留言时仅在名片成功后再发留言)
   */
  const sendRaw = async (
    type: number,
    content: string,
    options?: SendExtOptions
  ): Promise<boolean> => {
    // 1. å‚数校验:优先用显式传入的 conversation(转发场景),否则取激活会话
    const conversation = options?.conversation ?? conversationStore.activeConversation
    if (!conversation) {
      return false
    }
    const realTarget = options?.targetId || conversation.targetId
    if (!realTarget) {
      return false
    }
    // 2. å‡†å¤‡ clientMessageId:媒体上传链路在 step 1 å·²ç» insertMessage å ä½ï¼Œè¿™é‡Œç›´æŽ¥å¤ç”¨ id;其余场景走默认乐观插入
    let clientMessageId: string
    if (options?.existingClientMessageId) {
      clientMessageId = options.existingClientMessageId
      // å ä½è‹¥å·²è¢«åˆ é™¤ï¼ˆä¸Šä¼ æœŸé—´ç”¨æˆ·å³é”®åˆ é™¤ / æ’¤å›ž / removeMessage ç­‰ï¼‰åˆ™æ”¾å¼ƒå‘送,
      // å¦åˆ™ sendRaw ä»ä¼šæŠŠæ¶ˆæ¯æŽ¨åˆ°æœåŠ¡ç«¯ï¼Œå¯¼è‡´"本地无气泡 / å¯¹æ–¹å´æ”¶åˆ°ä¸€æ¡"
      const stillExists = messageStore
        .getMessageList(conversation.type, realTarget)
        .some((message) => message.clientMessageId === clientMessageId && !message._ackMerging)
      if (!stillExists) {
        return false
      }
    } else {
      clientMessageId = generateClientMessageId()
      const message = buildLocalMessage({
        clientMessageId,
        content,
        targetId: realTarget,
        type,
        atUserIds: options?.atUserIds
      })
      const conversationInfo = {
        type: conversation.type,
        targetId: realTarget,
        name: conversation.name || String(realTarget),
        avatar: conversation.avatar || ''
      }
      void messageStore.insertMessage(conversationInfo, message).catch(() => undefined)
    }
    // 3. å‘送请求:按会话类型分发到不同接口;成功后 ackMessage æ›´æ–°ä¸º NORMAL,失败更新为 FAILED
    try {
      if (conversation.type === ImConversationType.PRIVATE) {
        const data = await apiSendPrivateMessage({
          clientMessageId,
          receiverId: realTarget,
          type,
          content
        })
        void messageStore
          .ackMessage(conversation.type, realTarget, clientMessageId, {
            id: data.id,
            sendTime: new Date(data.sendTime).getTime(),
            status: data.status,
            receiptStatus: data.receiptStatus,
            content: data.content
          })
          .catch(() => undefined)
      } else if (conversation.type === ImConversationType.GROUP) {
        const data = await apiSendGroupMessage({
          clientMessageId,
          groupId: realTarget,
          type,
          content,
          atUserIds: options?.atUserIds,
          receipt: options?.receipt
        })
        void messageStore
          .ackMessage(conversation.type, realTarget, clientMessageId, {
            id: data.id,
            sendTime: new Date(data.sendTime).getTime(),
            status: data.status,
            receiptStatus: data.receiptStatus,
            readCount: data.readCount,
            content: data.content
          })
          .catch(() => undefined)
      }
      return true
    } catch (error) {
      console.error('[IM] æ¶ˆæ¯å‘送失败', { type, realTarget, clientMessageId }, error)
      void messageStore
        .ackMessage(conversation.type, realTarget, clientMessageId, {
          status: ImMessageStatus.FAILED
        })
        .catch(() => undefined)
      return false
    }
  }
  /**
   * å‘送文本消息(最常用的快捷入口):message-input.vue æ–‡æœ¬å›žè½¦èµ°è¿™é‡Œ
   * è¿”回值:成功 true / å¤±è´¥ false / ç©ºæ–‡æœ¬ false(与 sendRaw å¯¹é½ï¼Œè½¬å‘场景按返回值判断)
   */
  const send = async (text: string, options?: SendExtOptions): Promise<boolean> => {
    if (!text.trim()) {
      return false
    }
    const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote)
    return sendRaw(ImContentType.TEXT, serializeMessage(payload), options)
  }
  /**
   * æ’¤å›žæŸæ¡æ¶ˆæ¯
   * 1. æœåŠ¡ç«¯ä¼šé€šè¿‡ WebSocket RECALL äº‹ä»¶å›žä¼ ï¼Œæœ¬ç«¯ UI ç”± websocketStore ç»Ÿä¸€æ›´æ–°
   * 2. æ­¤å¤„不做乐观撤回,避免网络失败后状态不可回退
   */
  const recall = async (message: Message) => {
    // å‚数校验:本地占位消息不能撤回
    if (!message.id) {
      return
    }
    const conversation = conversationStore.activeConversation
    if (!conversation) {
      return
    }
    // ç§èŠ / ç¾¤èŠæŽ¥å£ç­¾åä¸€è‡´ï¼ŒæŒ‰ä¼šè¯ç±»åž‹åˆ†å‘
    const isPrivate = conversation.type === ImConversationType.PRIVATE
    try {
      await (isPrivate ? apiRecallPrivateMessage(message.id) : apiRecallGroupMessage(message.id))
    } catch (error) {
      console.error('[IM] æ’¤å›žå¤±è´¥', { messageId: message.id, type: conversation.type }, error)
    }
  }
  /**
   * è§¦å‘当前会话的已读上报(切会话 / è¿›å…¥é¡µé¢æ—¶è°ƒç”¨ï¼‰
   * 1. æœ¬ç«¯ç«‹åˆ»æ¸…未读数并推进读位置
   * 2. å·²è¯»ä½ç½®å–已加载消息和会话末条消息的最大服务端 id
   */
  const readActive = async () => {
    const conversation = conversationStore.activeConversation
    if (!conversation) {
      return
    }
    let loadedMaxMessageId = 0
    for (const message of messageStore.getMessages(
      getClientConversationId(conversation.type, conversation.targetId)
    )) {
      if (message.id && message.id > loadedMaxMessageId) {
        loadedMaxMessageId = message.id
      }
    }
    const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
    const readReported = conversationStore.isReportedReadPositionCovered(
      conversation.type,
      conversation.targetId,
      maxMessageId
    )
    if (readReported) {
      conversationStore.markConversationRead(conversation.type, conversation.targetId)
      return
    }
    const isPrivate = conversation.type === ImConversationType.PRIVATE
    const isGroup = conversation.type === ImConversationType.GROUP
    const isChannel = conversation.type === ImConversationType.CHANNEL
    // æœ¬åœ°æ ‡è®°å·²è¯»ï¼šæœªè¯»æ•°æ¸…零(UI ç«‹åˆ»å“åº”)
    conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
    if (!maxMessageId) {
      return
    }
    // æŽ¥å£è°ƒç”¨ï¼šæŒ‰ä¼šè¯ç±»åž‹åˆ†å‘,并按对应已读开关控制
    if (!isPrivate && !isGroup && !isChannel) {
      return
    }
    if (isPrivate && !MESSAGE_PRIVATE_READ_ENABLED) {
      return
    }
    if (isGroup && !MESSAGE_GROUP_READ_ENABLED) {
      return
    }
    try {
      if (isPrivate) {
        await apiReadPrivateMessages(conversation.targetId, maxMessageId)
      } else if (isGroup) {
        await apiReadGroupMessages(conversation.targetId, maxMessageId)
      } else {
        await apiReadChannelMessages(conversation.targetId, maxMessageId)
      }
      conversationStore.markConversationReadReported(
        conversation.type,
        conversation.targetId,
        maxMessageId
      )
    } catch (error) {
      console.error(
        '[IM] æ ‡è®°å·²è¯»å¤±è´¥',
        { type: conversation.type, targetId: conversation.targetId, maxMessageId },
        error
      )
    }
  }
  /**
   * æ‹‰å–「对方已读到我哪条消息」并补齐本地状态
   *
   * 1. å¼¥è¡¥ç¦»çº¿ / å¤šç«¯æœŸé—´é”™è¿‡çš„ RECEIPT æŽ¨é€ï¼šè¿›å…¥ç§èŠä¼šè¯æˆ–断线重连后调一次,
   *    æŠŠå¯¹æ–¹ maxReadId åŒæ­¥åˆ°æœ¬åœ°æ¶ˆæ¯ status,避免对方明明读了、本端却仍显示未读
   * 2. ä»…私聊使用:群聊已读位置在每条消息的 readCount / receiptStatus å­—段,离线拉取自带回
   */
  const syncPrivateReadStatus = async (peerId: number) => {
    if (!peerId) {
      return
    }
    // ç§èŠå·²è¯»å…³é—­ï¼šè·³è¿‡å¯¹æ–¹å·²è¯»ä½ç½®åŒæ­¥ï¼Œé¿å…æ— è°“接口调用
    if (!MESSAGE_PRIVATE_READ_ENABLED) {
      return
    }
    const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
    if (cachedMaxReadId !== undefined) {
      if (cachedMaxReadId > 0) {
        messageStore.applyMessageReadReceipt({
          conversationType: ImConversationType.PRIVATE,
          targetId: peerId,
          privateReadMaxId: cachedMaxReadId
        })
      }
      return
    }
    try {
      // æ‹‰å–对方已读到的最大消息 id
      const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
      messageStore.updatePrivateReadMaxId(peerId, maxReadId)
      if (!maxReadId) {
        return
      }
      // applyMessageReadReceipt å†…部把 â‰¤ maxReadId çš„æœ¬ç«¯æ¶ˆæ¯å›žæ‰§æ›´æ–°ä¸º DONE
      messageStore.applyMessageReadReceipt({
        conversationType: ImConversationType.PRIVATE,
        targetId: peerId,
        privateReadMaxId: maxReadId
      })
    } catch (error) {
      console.warn('[IM] æ‹‰å–对方已读位置失败', { peerId }, error)
    }
  }
  return { send, sendRaw, recall, readActive, syncPrivateReadStatus }
}
src/views/im/home/composables/useMuteOverlay.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
import { computed, type ComputedRef, onScopeDispose, ref } from 'vue'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImGroupMemberRole } from '../../utils/constants'
import { isGroupQuit } from '../../utils/user'
import { useConversationStore } from '../store/conversationStore'
import { useGroupStore } from '../store/groupStore'
export type MuteOverlayInfo = { icon: string; text: string; }
/**
 * æ¨¡å—级共享 now tick + è®¢é˜…计数:
 * MessageItem v-for æ—¶æ¯æ¡æ¶ˆæ¯éƒ½ä¼š useMuteOverlay(),单实例自起 timer ä¼šå˜æˆå‡ ç™¾ä¸ª 30s å®šæ—¶å™¨ï¼›
 * æ”¹æˆæ¨¡å—级共享后所有订阅者共用一份 setInterval,订阅数清零时也清掉 timer,避免内存与时钟漂移
 */
const sharedNow = ref(Date.now())
let sharedTickTimer: null | number = null
let subscriberCount = 0
function subscribeNowTick(): void {
  subscriberCount++
  if (!sharedTickTimer) {
    sharedTickTimer = window.setInterval(() => {
      sharedNow.value = Date.now()
    }, 30_000)
  }
}
function unsubscribeNowTick(): void {
  subscriberCount--
  if (subscriberCount <= 0) {
    subscriberCount = 0
    if (sharedTickTimer) {
      window.clearInterval(sharedTickTimer)
      sharedTickTimer = null
    }
  }
}
/**
 * å½“前激活会话的禁言 / å°ç¦çŠ¶æ€ï¼›éžç¾¤èŠæˆ–æ— ç¦è¨€æ—¶è¿”å›ž null
 *
 * ä¼˜å…ˆçº§ï¼šå°ç¦ > å…¨ç¾¤ç¦è¨€ï¼ˆç¾¤ä¸» / ç®¡ç†å‘˜è±å…ï¼‰> æˆå‘˜ç¦è¨€
 *
 * MessageInput é¡¶éƒ¨ overlay / handleResend é‡è¯• / uploadAndSendMedia ä¸Šä¼ å®Œå‰éƒ½å…±ç”¨ä¸€ä»½ï¼Œ
 * é¿å…ã€Œè¾“入框拦了但重试绕过」「上传期间被禁言但 sendRaw ä»å‘出去」之类的不一致
 */
export function useMuteOverlay(): ComputedRef<MuteOverlayInfo | null> {
  const conversationStore = useConversationStore()
  const groupStore = useGroupStore()
  // è®¢é˜…模块级 tick;scope é”€æ¯æ—¶åè®¢é˜…,最后一个订阅者退场后 timer ä¹Ÿè·Ÿç€æ¸…
  subscribeNowTick()
  onScopeDispose(unsubscribeNowTick)
  return computed(() => {
    const conversation = conversationStore.activeConversation
    if (!conversation || conversation.type !== ImConversationType.GROUP) {
      return null
    }
    const group = groupStore.getGroup(conversation.targetId)
    if (!group) {
      return null
    }
    // åŽ†å²é€€ç¾¤ç¾¤ï¼šå·²é€€ç¾¤åªèƒ½æŸ¥çœ‹åŽ†å²ï¼Œç¦æ­¢å‘é€ï¼ˆæ–‡æœ¬ / å›¾ç‰‡ / æ–‡ä»¶ / è¯­éŸ³ / é‡è¯•共用这一层拦截)
    if (isGroupQuit(group)) {
      return { text: '你已退出群聊,仅可查看历史消息', icon: 'ant-design:logout-outlined' }
    }
    const myId = getCurrentUserId()
    // ç¾¤å°ç¦ï¼šç®¡ç†åŽå°æ“ä½œï¼Œæ‰€æœ‰äººä¸å¯å‘送
    if (group.banned) {
      return { text: '该群已被管理员封禁,无法发送消息', icon: 'ant-design:stop-outlined' }
    }
    // å…¨ç¾¤ç¦è¨€ï¼šç¾¤ä¸»èµ° ownerUserId æ¯”较直接豁免;其它人需要成员列表加载完才能区分管理员 vs æ™®é€šæˆå‘˜
    // - åŠ è½½å®Œ + æˆ‘是管理员 â†’ è±å…
    // - åŠ è½½å®Œ + æˆ‘不是管理员(含已退群)→ æ‹¦
    // - åŠ è½½æœªå®Œ â†’ ä¸æ˜¾ç¤º overlay,后端兜底拒绝普通成员;避免误拦管理员
    if (group.mutedAll && myId !== group.ownerUserId && group.membersLoaded) {
      const myMember = group.members?.find((m) => m.userId === myId)
      if (myMember?.role !== ImGroupMemberRole.ADMIN) {
        return { text: '全群禁言中,暂时无法发送消息', icon: 'ant-design:audio-muted-outlined' }
      }
    }
    // æˆå‘˜ç¦è¨€ï¼šmuteEndTime åœ¨æœªæ¥æ‰ç®—;用响应式 sharedNow æ¯”对,到期后下一个 tick å°±è®© overlay æ¶ˆå¤±
    const myMember = group.members?.find((m) => m.userId === myId)
    if (myMember?.muteEndTime) {
      const endTime = new Date(myMember.muteEndTime)
      if (endTime.getTime() > sharedNow.value) {
        const pad = (n: number) => n.toString().padStart(2, '0')
        const timeStr =
          `${pad(endTime.getMonth() + 1)}-${pad(endTime.getDate())} ` +
          `${pad(endTime.getHours())}:${pad(endTime.getMinutes())}`
        return {
          text: `您已被禁言,解除时间:${timeStr}`,
          icon: 'ant-design:audio-muted-outlined'
        }
      }
    }
    return null
  })
}
src/views/im/home/composables/useSelectedItems.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
import { computed, type ComputedRef, type Ref } from 'vue'
/**
 * ä¸‰æ€é€‰æ‹©é¢æ¿çš„「已选数 + å·²é€‰é¡¹åˆ—表」派生
 *
 * - ä¸‰æ€ä¼˜å…ˆçº§ï¼ˆä¸Ž dialog-picker-contract å¯¹é½ï¼‰ï¼šhide > locked > disabled
 *   - hide:永远剔除,不计数 / ä¸è¿›å·²é€‰åˆ—表
 *   - locked:默认勾选 + è®¡æ•° + ä¼˜å…ˆæŽ’在前面;即使被外部塞进 disabledIds ä¹Ÿèƒœå‡º
 *   - disabled:仅过滤 selectedIds,不计数 / ä¸è¿›å·²é€‰åˆ—表
 * - é¡ºåºï¼šlockedIds åœ¨å‰ï¼ŒselectedIds ç´§éšï¼›æŒ‰æ•°ç»„顺序即为「点击顺序」
 *
 * FriendPickerPanel / GroupMemberPickerPanel å…±ç”¨ï¼Œé¿å…ä¸¤ä¾§ 25 è¡ŒåŒæž„ computed å„写一份;
 * Panel å†…部的 isLocked / isDisabled / isSelected ç­‰æ¨¡æ¿åˆ¤å®šå‡½æ•°ä»å„自维护,本 composable åªæ‰¿æ‹…派生量
 */
export function useSelectedItems<T>(
  selectedIds: () => readonly number[],
  lockedIds: () => readonly number[],
  disabledIds: () => readonly number[],
  hideIds: () => readonly number[],
  byId: ComputedRef<Map<number, T>> | Ref<Map<number, T>>
): {
  selectedCount: ComputedRef<number>
  selectedItems: ComputedRef<T[]>
} {
  const hideSet = computed(() => new Set(hideIds()))
  const disabledSet = computed(() => new Set(disabledIds()))
  const selectedCount = computed(() => {
    const merged = new Set<number>()
    for (const id of selectedIds()) {
      if (hideSet.value.has(id) || disabledSet.value.has(id)) {
        continue
      }
      merged.add(id)
    }
    // locked ä»…被 hide è¿‡æ»¤ï¼›å¥‘约里 locked èƒœè¿‡ disabled,确保锁定项始终计入
    for (const id of lockedIds()) {
      if (hideSet.value.has(id)) {
        continue
      }
      merged.add(id)
    }
    return merged.size
  })
  const selectedItems = computed(() => {
    const seen = new Set<number>()
    const result: T[] = []
    // locked åœ¨å‰ï¼›ä»…被 hide è¿‡æ»¤
    for (const id of lockedIds()) {
      if (seen.has(id) || hideSet.value.has(id)) {
        continue
      }
      const item = byId.value.get(id)
      if (item) {
        seen.add(id)
        result.push(item)
      }
    }
    // selectedIds ç´§éšï¼›é¢å¤–过滤 disabled
    for (const id of selectedIds()) {
      if (seen.has(id) || disabledSet.value.has(id) || hideSet.value.has(id)) {
        continue
      }
      const item = byId.value.get(id)
      if (item) {
        seen.add(id)
        result.push(item)
      }
    }
    return result
  })
  return { selectedCount, selectedItems }
}
src/views/im/home/composables/useVoicePlayer.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
import { ref } from 'vue'
/**
 * è¯­éŸ³æ’­æ”¾å…¨å±€äº’æ–¥
 *
 * æ¨¡å—级单例:同一时刻最多只有一条语音在播;新点的语音停掉旧的
 * ä¸‰å¤„入口共享同一播放态:
 * - MessageBubble:气泡点击 toggle å½“前条
 * - MessagePanel:切会话 stop()
 * - MessageHistory / MessageMergeDetailDialog:关闭时 stop()
 *
 * key ç”± MessageBubble åœ¨ setup é‡Œ Symbol() ç”Ÿæˆå®žä¾‹çº§å”¯ä¸€èº«ä»½ï¼›ä¸åŒæ¥æºï¼ˆä¸»é¢æ¿ / åŽ†å²æŠ½å±‰ /
 * åˆå¹¶è¯¦æƒ…)的同一条语音 url åœ¨ä¸åŒæ°”泡里也是不同 key,避免一处卸载误停另一处仍可见的播放。
 */
export type VoiceKey = symbol
interface VoiceTask {
  key: VoiceKey
  url: string
  audio: HTMLAudioElement
}
const currentTask = ref<null | VoiceTask>(null)
/**
 * æ˜¾å¼åœæ­¢
 *
 * - ä¸ä¼  key:强停(切会话 / å…³å¼¹çª—用)
 * - ä¼  key:仅当当前 task æ˜¯è¯¥ key æ—¶æ‰åœï¼ˆæ°”泡卸载兜底用,避免误停别人)
 */
function stop(key?: VoiceKey) {
  const task = currentTask.value
  if (!task) {
    return
  }
  if (key !== undefined && task.key !== key) {
    return
  }
  task.audio.pause()
  // removeAttribute('src') + load() æ˜¯ W3C æŽ¨èçš„释放姿势:不会触发空 src åŠ è½½å¯¼è‡´çš„ error äº‹ä»¶ï¼Œ
  // ä¹Ÿèƒ½è®©æµè§ˆå™¨ç«‹å³é‡Šæ”¾åº•层 decoder buffer,比 audio.src = '' æ›´å¹²å‡€
  task.audio.removeAttribute('src')
  task.audio.load()
  currentTask.value = null
}
/**
 * åˆ‡æ¢æ’­æ”¾
 *
 * - åŒ key å†ç‚¹ï¼šåœæŽ‰
 * - åˆ‡åˆ°æ–° key:停旧起新
 */
function play(key: VoiceKey, url: string) {
  if (!url) {
    return
  }
  if (currentTask.value?.key === key) {
    stop(key)
    return
  }
  stop()
  const audio = new Audio(url)
  const task: VoiceTask = { key, url, audio }
  /** æ’­æ”¾ç»“束 / å¼‚常清栈;只清当前任务,避免被后续新任务的回调误清 */
  const finalize = () => {
    if (currentTask.value === task) {
      currentTask.value = null
    }
  }
  audio.addEventListener('ended', finalize, { once: true })
  audio.addEventListener('error', finalize, { once: true })
  currentTask.value = task
  audio.play().catch(finalize)
}
export function useVoicePlayer() {
  /** æŒ‡å®š key æ˜¯å¦æ­£åœ¨æ’­æ”¾ */
  function isPlaying(key: VoiceKey): boolean {
    return currentTask.value?.key === key
  }
  return { isPlaying, play, stop }
}
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>
src/views/im/home/pages/contact/friend-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { ref, toRef } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { FriendItem } from '../../components/friend'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
defineOptions({ name: 'ImContactFriendList' })
const props = defineProps<{
  activeId?: number
  friends: FriendLite[]
  keyword: string
}>()
const emit = defineEmits<{
  chat: [friend: FriendLite]
  delete: [friend: FriendLite]
  select: [friend: FriendLite]
}>()
const expanded = ref(true)
const { filtered, buckets } = useFriendBuckets(toRef(props, 'friends'), toRef(props, 'keyword'))
</script>
<template>
  <!--
    é€šè®¯å½• - å¥½å‹åˆ†ç»„
    - è‡ªæ²»ï¼šæŠ˜å çŠ¶æ€ + å…³é”®å­—过滤 + å­—母分桶 æœ¬ç»„件内闭环
    - å­—母分桶 / æ‹¼éŸ³æœç´¢å§”托 useFriendBuckets,与选择类弹窗 FriendPickerPanel å…±ç”¨ä¸€ä»½è§„则
    - é€‰ä¸­æ€ç”±çˆ¶çº§ activeId é€ä¼ ï¼›chat / delete é€ä¼ åˆ°çˆ¶çº§èµ° store æ”¹é€ 
  -->
  <div>
    <!-- æŠ˜å åˆ†ç»„头:字号对齐微信 PC(15px),hover æµ…底色反馈 -->
    <div
      class="flex gap-2 items-center px-3.5 py-2.5 cursor-pointer select-none text-15px text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]"
      @click="expanded = !expanded"
    >
      <Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
      <span class="flex-1">好友</span>
      <span class="text-sm text-[var(--ant-color-text-secondary)]">{{ filtered.length }}</span>
    </div>
    <div v-show="expanded">
      <template v-for="bucket in buckets" :key="bucket.letter">
        <!-- å­—母分桶 header:浅底 + å°å­—号,作为好友列表内部分隔 -->
        <div
          class="pt-1 pb-0.5 px-3.5 text-12px text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-tertiary)]"
        >
          {{ bucket.letter }}
        </div>
        <FriendItem
          v-for="friend in bucket.list"
          :key="friend.id"
          :friend="friend"
          :active="activeId === friend.id"
          @click="emit('select', friend)"
          @chat="emit('chat', $event)"
          @delete="emit('delete', $event)"
        />
      </template>
      <div
        v-if="filtered.length === 0"
        class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]"
      >
        {{ keyword ? '没有匹配的好友' : '暂无好友' }}
      </div>
    </div>
  </div>
</template>
src/views/im/home/pages/contact/friend-request-detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,229 @@
<script lang="ts" setup>
import type { FriendRequest, User } from '../../types'
import { computed, ref } from 'vue'
import { prompt } from '#/packages/effects/common-ui/src'
import { DICT_TYPE } from '#/packages/constants/src'
import { getDictLabel } from '#/packages/effects/hooks/src'
import { Button, Input, message } from 'ant-design-vue'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImFriendRequestHandleResult } from '../../../utils/constants'
import { UserAvatar } from '../../components/user'
import { UserInfo } from '../../components/user'
import { useFriendStore } from '../../store/friendStore'
defineOptions({ name: 'ImContactFriendRequestDetail' })
const props = defineProps<{
  request: FriendRequest
}>()
const emit = defineEmits<{
  chat: [peerUserId: number]
}>()
const friendStore = useFriendStore()
/** å½“前登录用户编号;用 computed åŒ…一层,切账号后随 wsCache é‡å–,避免顶层求值在 keep-alive å®žä¾‹é‡ŒæŒæœ‰æ—§ id */
const currentUserId = computed(() => getCurrentUserId())
/** æ˜¯ä¸æ˜¯æˆ‘发起的(fromUserId === currentUserId) */
const iSentIt = computed(() => props.request.fromUserId === currentUserId.value)
/** æ˜¯å¦ã€Œå·²æ‹’绝」态:模板里多处用到,computed ä¸€æ¬¡çœå¾—到处写枚举比对 */
const refused = computed(
  () => props.request.handleResult === ImFriendRequestHandleResult.REFUSED
)
/** æ˜¯å¦ã€Œå·²é€šè¿‡ã€æ€ï¼šè½¬èµ° UserInfo å¥½å‹è¯¦æƒ…入口 */
const agreed = computed(
  () => props.request.handleResult === ImFriendRequestHandleResult.AGREED
)
/** å¯¹ç«¯çš„用户编号 / æ˜µç§° / å¤´åƒ */
const peerUserId = computed(() =>
  iSentIt.value ? props.request.toUserId : props.request.fromUserId
)
const peerNickname = computed(() =>
  iSentIt.value
    ? props.request.toNickname || String(props.request.toUserId)
    : props.request.fromNickname || String(props.request.fromUserId)
)
const peerAvatar = computed(() =>
  iSentIt.value ? props.request.toAvatar : props.request.fromAvatar
)
/** é€ç»™ UserInfo çš„æœ€å°ç”¨æˆ·ä¿¡æ¯ï¼›UserInfo å†…部会按 id è°ƒ getSimpleUser è¡¥é½æ€§åˆ« / éƒ¨é—¨ */
const peerUser = computed<User>(() => ({
  id: peerUserId.value,
  nickname: peerNickname.value,
  avatar: peerAvatar.value
}))
// å„自的 loading ç”¨äºŽæŒ‰é’® spinner æ˜¾ç¤ºï¼›processing æ˜¯è·¨æŒ‰é’®äº’斥锁,避免同意 / æ‹’绝并发提交同一申请
const agreeing = ref(false)
const refusing = ref(false)
const processing = ref(false)
/** åŒæ„ç”³è¯·ï¼šäº’斥锁 + çŠ¶æ€äºŒæ¬¡æ ¡éªŒï¼Œé¿å…å¹¶å‘ / æœåŠ¡ç«¯å·²å¤„ç†åŽå†æ¬¡æäº¤ */
async function handleAgree() {
  if (processing.value) {
    return
  }
  if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
    return
  }
  processing.value = true
  agreeing.value = true
  try {
    await friendStore.agreeFriendRequest(props.request.id)
    message.success('已同意好友申请')
  } finally {
    agreeing.value = false
    processing.value = false
  }
}
/** æ‹’绝申请:弹 prompt æ”¶é›†å¯é€‰æ‹’绝理由(点取消则中止),随后调 store è½åº“ + æç¤º */
async function handleRefuse() {
  if (processing.value) {
    return
  }
  if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
    return
  }
  // 1. å¼¹ prompt æ”¶é›†æ‹’绝理由(最多 255 å­—);用户点「取消」会 reject,中止后续流程
  let handleContent: string | undefined
  try {
    const result = await prompt<string>({
      beforeClose(scope) {
        if (!scope.isConfirm) {
          return
        }
        if ((scope.value || '').length > 255) {
          message.error('最多 255 ä¸ªå­—符')
          return false
        }
      },
      cancelText: '取消',
      component: Input.TextArea,
      componentProps: {
        allowClear: true,
        maxlength: 255,
        placeholder: '不填则不告知对方原因',
        rows: 3
      },
      content: '',
      defaultValue: '',
      confirmText: '拒绝',
      modelPropName: 'value',
      title: '拒绝好友申请'
    })
    handleContent = result || undefined
  } catch {
    return
  }
  // 2. prompt æœŸé—´çŠ¶æ€å¯èƒ½è¢«è·¨ç«¯æ”¹æˆ AGREED / REFUSED,再校验一次避免重复提交
  if (processing.value) {
    return
  }
  if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
    return
  }
  processing.value = true
  refusing.value = true
  try {
    await friendStore.refuseFriendRequest(props.request.id, handleContent)
    message.success('已拒绝好友申请')
  } finally {
    refusing.value = false
    processing.value = false
  }
}
</script>
<template>
  <!--
    æ–°çš„æœ‹å‹è¯¦æƒ…面板
    - å·²é€šè¿‡ â†’ ç›´æŽ¥èµ° UserInfo å¥½å‹è¯¦æƒ…,跟通讯录里点开好友的体验完全一致
    - æœªå¤„理 / å·²æ‹’绝 â†’ ç”³è¯·æ€é¢æ¿ï¼šå¤´åƒ + ç”³è¯·/拒绝对话气泡 + æ¥æº + æ“ä½œæŒ‰é’®
  -->
  <div v-if="agreed" class="flex justify-center pt-12 px-6">
    <div class="w-full max-w-[320px]">
      <UserInfo
        :user="peerUser"
        :display-name="friendStore.getFriend(peerUserId)?.displayName || ''"
        relation="friend"
        @chat="emit('chat', peerUserId)"
      />
    </div>
  </div>
  <div v-else class="flex flex-col items-center px-6 pt-12">
    <UserAvatar
      :id="peerUserId"
      :url="peerAvatar"
      :name="peerNickname"
      :size="64"
      :clickable="false"
    />
    <div class="mt-3 text-base font-semibold text-[var(--ant-color-text)]">
      {{ peerNickname }}
    </div>
    <!-- ç”³è¯·ç†ç”±å—:仿微信灰底气泡,按申请方身份前缀;长文本 break-words æŠ˜è¡Œ -->
    <div
      v-if="request.applyContent"
      class="w-full max-w-[420px] mt-6 px-3.5 py-3 rounded-md bg-[var(--ant-color-fill-secondary)] text-13px text-[var(--ant-color-text)] break-words"
    >
      <span class="text-[var(--ant-color-text-secondary)]">
        {{ iSentIt ? '我' : peerNickname }}:
      </span>
      <span class="ml-1">{{ request.applyContent }}</span>
    </div>
    <!-- æ¥æºè¡Œï¼šlabel å·¦ã€value å³ï¼Œå¯¹é½å¾®ä¿¡ã€Œæ¥æºã€è¡Œ -->
    <div
      v-if="request.addSource"
      class="w-full max-w-[420px] mt-3 flex items-center text-13px text-[var(--ant-color-text-secondary)]"
    >
      <span class="w-16 flex-shrink-0 whitespace-nowrap">来源</span>
      <span class="text-[var(--ant-color-text)]">
        {{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, request.addSource) }}
      </span>
    </div>
    <!-- æ‹’绝理由:label å·¦ã€value å³ï¼›é•¿æ–‡æœ¬ break-words è‡ªåŠ¨æŠ˜è¡Œ -->
    <div
      v-if="refused && request.handleContent"
      class="w-full max-w-[420px] mt-3 flex items-start text-13px text-[var(--ant-color-text-secondary)]"
    >
      <span class="w-16 flex-shrink-0 whitespace-nowrap">拒绝理由</span>
      <span class="flex-1 min-w-0 break-words text-[var(--ant-color-text)]">
        {{ request.handleContent }}
      </span>
    </div>
    <!-- æ“ä½œæŒ‰é’® -->
    <div class="w-full max-w-[420px] mt-8 flex justify-center">
      <!-- æˆ‘发起 + ç­‰å¾…中:禁用「等待对方验证」 -->
      <Button
        v-if="iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED"
        disabled
      >
        ç­‰å¾…对方验证
      </Button>
      <!-- åˆ«äººåŠ æˆ‘ + ç­‰å¾…中:同意 / æ‹’绝 -->
      <template v-if="!iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED">
        <Button @click="handleRefuse" :loading="refusing" :disabled="processing">拒绝</Button>
        <Button type="primary" @click="handleAgree" :loading="agreeing" :disabled="processing">
          åŒæ„
        </Button>
      </template>
      <!-- å·²æ‹’绝:占位禁用按钮 -->
      <Button v-if="refused" disabled>已拒绝</Button>
    </div>
  </div>
</template>
src/views/im/home/pages/contact/friend-request-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,138 @@
<script lang="ts" setup>
import type { FriendRequest } from '../../types'
import { computed, ref } from 'vue'
import { DICT_TYPE } from '#/packages/constants/src'
import { getDictLabel } from '#/packages/effects/hooks/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Badge } from 'ant-design-vue'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { UserAvatar } from '../../components/user'
import { useFriendStore } from '../../store/friendStore'
defineOptions({ name: 'ImContactFriendRequestList' })
const props = defineProps<{
  activeId?: number
  requests: FriendRequest[]
}>()
const emit = defineEmits<{
  select: [request: FriendRequest]
}>()
const friendStore = useFriendStore()
const expanded = ref(true)
/** å½“前登录用户编号;用 computed åŒ…一层,切账号后随 wsCache é‡å–,避免顶层求值在 keep-alive å®žä¾‹é‡ŒæŒæœ‰æ—§ id */
const currentUserId = computed(() => getCurrentUserId())
/** åˆ—表项展示对端:fromUserId == æˆ‘ â†’ å¯¹ç«¯ = toUser;否则对端 = fromUser */
function getPeer(request: FriendRequest) {
  const sentByMe = request.fromUserId === currentUserId.value
  return {
    id: sentByMe ? request.toUserId : request.fromUserId,
    nickname: sentByMe
      ? request.toNickname || String(request.toUserId)
      : request.fromNickname || String(request.fromUserId),
    avatar: sentByMe ? request.toAvatar : request.fromAvatar
  }
}
/** åˆ—表项预先附 peer å­—段,模板里直接 {{ peer.xxx }} ä¸€æ¬¡æˆåž‹ï¼Œçœ 3 æ¬¡ helper è°ƒç”¨ */
const enrichedRequests = computed(() =>
  props.requests.map((request) => ({ request, peer: getPeer(request) }))
)
const loadingMore = ref(false) // ç‚¹å‡»ã€ŒåŠ è½½æ›´å¤šã€æ‹‰ä¸‹ä¸€é¡µï¼›store å†…部按 maxId æ¸¸æ ‡åˆ†é¡µ + pending åŽ»é‡
async function handleLoadMore() {
  if (loadingMore.value) {
    return
  }
  loadingMore.value = true
  try {
    await friendStore.loadMoreFriendRequestList()
  } finally {
    loadingMore.value = false
  }
}
</script>
<template>
  <!--
    é€šè®¯å½• - æ–°çš„æœ‹å‹åˆ†ç»„
    - è‡ªæ²»ï¼šæŠ˜å çŠ¶æ€ç”±ç»„ä»¶å†… ref ç®¡ç†ï¼Œé»˜è®¤å±•å¼€
    - åˆ—表项展示:头像 + æ˜µç§° + ç”³è¯·ç†ç”± + çŠ¶æ€æ ‡ç­¾ï¼ˆç­‰å¾…éªŒè¯ / å·²æ·»åŠ  / å·²æ‹’绝)
    - é€‰ä¸­æ€ç”±çˆ¶çº§ activeId é€ä¼ ï¼›ç‚¹å‡»è§¦å‘ select äº‹ä»¶
  -->
  <div>
    <!-- æŠ˜å åˆ†ç»„头 -->
    <div
      class="flex gap-2 items-center px-3.5 py-2.5 text-15px text-[var(--ant-color-text)] cursor-pointer select-none hover:bg-[var(--ant-color-fill-secondary)]"
      @click="expanded = !expanded"
    >
      <Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
      <span class="flex-1">新的朋友</span>
      <!-- çº¢ç‚¹ï¼šæœªå¤„理且别人加我的(统一走 store getter,避免本地 computed è·Ÿ store åŒå£å¾„) -->
      <Badge
        v-if="friendStore.getUnhandledRequestCount > 0"
        :count="friendStore.getUnhandledRequestCount"
        :max="99"
        class="mr-2"
      />
      <span class="text-sm text-[var(--ant-color-text-secondary)]">{{ requests.length }}</span>
    </div>
    <div v-show="expanded">
      <div
        v-for="{ request, peer } in enrichedRequests"
        :key="request.id"
        class="flex gap-3 items-start px-3.5 py-2.5 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill-secondary)]"
        :class="{
          'bg-[var(--ant-color-fill)]': activeId === request.id
        }"
        @click="emit('select', request)"
      >
        <UserAvatar
          :id="peer.id"
          :url="peer.avatar"
          :name="peer.nickname"
          :size="36"
          :clickable="false"
        />
        <div class="flex-1 min-w-0 overflow-hidden">
          <div class="flex justify-between gap-2 items-center">
            <span class="flex-1 text-sm font-medium truncate text-[var(--ant-color-text)]">
              {{ peer.nickname }}
            </span>
            <span class="flex-shrink-0 text-12px text-[var(--ant-color-text-secondary)]">
              {{ getDictLabel(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, request.handleResult) }}
            </span>
          </div>
          <div
            v-if="request.applyContent"
            class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]"
          >
            {{ request.applyContent }}
          </div>
        </div>
      </div>
      <div
        v-if="requests.length === 0"
        class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]"
      >
        æš‚无新的朋友
      </div>
      <!-- åŠ è½½æ›´å¤šï¼šæŒ‰æœ¬åœ°æœ€æ—§ requestId æ¸¸æ ‡åˆ†é¡µæ‹‰ä¸‹ä¸€æ‰¹ï¼›hasMore=false ä¸å±•示 -->
      <div
        v-else-if="friendStore.hasMoreFriendRequests"
        class="py-2 text-12px text-center cursor-pointer text-[var(--ant-color-text-secondary)] hover:bg-[var(--ant-color-fill-secondary)]"
        @click="handleLoadMore"
      >
        {{ loadingMore ? '加载中…' : '加载更多' }}
      </div>
    </div>
  </div>
</template>
src/views/im/home/pages/contact/group-detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
<script lang="ts" setup>
import type { GroupLite } from '../../types'
import { GroupInfo } from '../../components/group'
defineOptions({ name: 'ImContactGroupDetail' })
defineProps<{
  group: GroupLite
}>()
const emit = defineEmits<{
  chat: [group: GroupLite]
}>()
</script>
<template>
  <!--
    é€šè®¯å½• - ç¾¤èŠè¯¦æƒ…
    - å†…容走共享 <GroupInfo>,与 GroupInfoCard æµ®å±‚用同一份组件
    - é€šè®¯å½•里都是已加群(GroupInfo å†…部按 groupStore æŽ¨å¯¼ï¼‰ï¼›chat æŠ›ä¸ŠåŽ»ç”±çˆ¶çº§æ‰¿æŽ¥
  -->
  <div class="pt-12 px-6">
    <GroupInfo :group="group" @chat="emit('chat', $event)" />
  </div>
</template>
src/views/im/home/pages/contact/group-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
<script lang="ts" setup>
import type { GroupLite } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { GroupItem } from '../../components/group'
defineOptions({ name: 'ImContactGroupList' })
const props = defineProps<{
  activeId?: number
  groups: GroupLite[]
  keyword: string
}>()
const emit = defineEmits<{ select: [group: GroupLite] }>()
const expanded = ref(true)
const filtered = computed(() => {
  const keywordLower = props.keyword.trim().toLowerCase()
  if (!keywordLower) {
    return props.groups
  }
  return props.groups.filter((group) =>
    (group.showGroupName || group.name || '').toLowerCase().includes(keywordLower)
  )
})
</script>
<template>
  <!--
    é€šè®¯å½• - ç¾¤èŠåˆ†ç»„
    - è‡ªæ²»ï¼šæŠ˜å çŠ¶æ€ + å…³é”®å­—过滤本组件内闭环
    - é€‰ä¸­æ€ç”±çˆ¶çº§ activeId é€ä¼ ï¼Œé¿å…å­ç»„件持有 selection çŸ¥è¯†
  -->
  <div>
    <!-- æŠ˜å åˆ†ç»„头:字号对齐微信 PC(15px),hover æµ…底色反馈 -->
    <div
      class="flex gap-2 items-center px-3.5 py-2.5 text-15px text-[var(--ant-color-text)] cursor-pointer select-none hover:bg-[var(--ant-color-fill-secondary)]"
      @click="expanded = !expanded"
    >
      <Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
      <span class="flex-1">群聊</span>
      <span class="text-sm text-[var(--ant-color-text-secondary)]">{{ filtered.length }}</span>
    </div>
    <div v-show="expanded">
      <GroupItem
        v-for="group in filtered"
        :key="group.id"
        :group="group"
        :active="activeId === group.id"
        @click="emit('select', group)"
      />
      <div
        v-if="filtered.length === 0"
        class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]"
      >
        {{ keyword ? '没有匹配的群聊' : '暂无群聊' }}
      </div>
    </div>
  </div>
</template>
src/views/im/home/pages/contact/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
<script lang="ts" setup>
import type { FriendLite, FriendRequest, Group, GroupLite, User } from '../../types'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { confirm } from '#/packages/effects/common-ui/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Input, message } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants'
import { StorageKeys } from '../../../utils/db'
import { getFriendDisplayName, getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import { ResizableAside } from '../../components'
import { UserInfo } from '../../components/user'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import FriendList from './friend-list.vue'
import FriendRequestDetail from './friend-request-detail.vue'
import FriendRequestList from './friend-request-list.vue'
import GroupDetail from './group-detail.vue'
import GroupList from './group-list.vue'
defineOptions({ name: 'ImContactPage' })
const router = useRouter()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
/** ç”¨ type åˆ¤åˆ«é€‰ä¸­æ˜¯å¥½å‹ / ç¾¤èŠ / å¥½å‹ç”³è¯· */
type Selection =
  | { friend: FriendLite; type: 'friend'; }
  | { group: GroupLite; type: 'group'; }
  | { request: FriendRequest; type: 'request'; }
const selection = ref<null | Selection>(null)
const keyword = ref('')
/** é€‰ä¸­ç”³è¯·è¯¦æƒ…:详情用 store é‡Œçš„æœ€æ–°å‰¯æœ¬ï¼ˆåŒæ„ / æ‹’绝后状态会变) */
const currentRequest = computed<FriendRequest>(() => {
  const req = selection.value?.type === 'request' ? selection.value.request : null
  if (!req) {
    return {} as FriendRequest
  }
  return friendStore.getFriendRequest(req.id) || req
})
/** æˆ‘相关的申请列表(用 friendStore é‡Œçš„实时副本,便于通知到达后自动刷新) */
const friendRequests = computed<FriendRequest[]>(() => friendStore.friendRequests)
/** å¥½å‹åˆ—表的展示快照:附带后端算好的拼音,给 FriendList åšå­—母分桶 / æ‹¼éŸ³æœç´¢ */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
const groups = computed<GroupLite[]>(() =>
  // é€šè®¯å½•只展示当前仍在群的;已退群历史群只留在 store é‡Œä¾›æ¶ˆæ¯å±•示群名 / å¤´åƒï¼Œä¸è¿›é€šè®¯å½•
  groupStore.groups
    .filter((group: Group) => !isGroupQuit(group))
    .map((group: Group) => ({
      id: group.id,
      name: group.name,
      showGroupName: getGroupDisplayName(group), // ä¼˜å…ˆç”¨ç¾¤å¤‡æ³¨ groupRemark,没设置时回落到原群名;避免点"进入群聊"时把已同步的备注会话名刷回原名
      showImage: group.avatar,
      showImageThumb: group.avatar,
      memberCount: group.memberCount
    }))
)
/**
 * store åˆ—表变化时同步 selection æŒçš„对象副本:对端推送 / è·¨ç«¯åŠ¨ä½œæ”¹ store åŽï¼Œå³ä¾§è¯¦æƒ…能跟上:
 * - å‘½ä¸­åˆ™æ›¿æ¢ä¸ºæœ€æ–°å¼•用,资料 / å¤‡æ³¨ / ç”³è¯·çŠ¶æ€å˜æ›´ç«‹åˆ»åæ˜ 
 * - æ‰¾ä¸åˆ°ï¼ˆåˆ é™¤ / æ‹’绝 / å·²é€šè¿‡ç­‰è®©è®°å½•消失)则置 null æ”¶èµ·è¯¦æƒ…
 */
watch(
  friends,
  (list) => {
    const selected = selection.value
    if (selected?.type !== 'friend') {
      return
    }
    const fresh = list.find((friend) => friend.id === selected.friend.id)
    if (!fresh) {
      selection.value = null
    } else if (fresh !== selected.friend) {
      selection.value = { type: 'friend', friend: fresh }
    }
  },
  { deep: true }
)
watch(
  groups,
  (list) => {
    const selected = selection.value
    if (selected?.type !== 'group') {
      return
    }
    const fresh = list.find((group) => group.id === selected.group.id)
    if (!fresh) {
      selection.value = null
    } else if (fresh !== selected.group) {
      selection.value = { type: 'group', group: fresh }
    }
  },
  { deep: true }
)
watch(
  friendRequests,
  (list) => {
    const selected = selection.value
    if (selected?.type !== 'request') {
      return
    }
    const fresh = list.find((request) => request.id === selected.request.id)
    if (!fresh) {
      selection.value = null
    } else if (fresh !== selected.request) {
      selection.value = { type: 'request', request: fresh }
    }
  },
  { deep: true }
)
const friendUser = computed<null | User>(() => {
  if (selection.value?.type !== 'friend') {
    return null
  }
  const friend = selection.value.friend
  return {
    id: friend.id,
    nickname: friend.nickname,
    avatar: friend.avatar
  }
})
onMounted(async () => {
  await Promise.all([
    friendStore.fetchFriendList(),
    friendStore.fetchFriendRequestList(),
    groupStore.fetchGroupList()
  ])
})
/** é€‰ä¸­å¥½å‹ â†’ åˆ‡åˆ°å¥½å‹è¯¦æƒ… */
function handleSelectFriend(friend: FriendLite) {
  selection.value = { type: 'friend', friend }
}
/** é€‰ä¸­ç¾¤èŠ â†’ åˆ‡åˆ°ç¾¤è¯¦æƒ… */
function handleSelectGroup(group: GroupLite) {
  selection.value = { type: 'group', group }
}
/** é€‰ä¸­å¥½å‹ç”³è¯· â†’ åˆ‡åˆ°ã€Œæ–°çš„æœ‹å‹ã€è¯¦æƒ… */
function handleSelectRequest(request: FriendRequest) {
  selection.value = { type: 'request', request }
}
/** ç”³è¯·è¯¦æƒ…里点「发消息」:直接进与对端的私聊会话 */
function handleChatPeer(peerUserId: number) {
  const friend = friendStore.getFriend(peerUserId)
  const conversationName = friend ? getFriendDisplayName(friend) : String(peerUserId)
  conversationStore.openConversation(
    peerUserId,
    ImConversationType.PRIVATE,
    conversationName,
    friend?.avatar || '',
    { silent: !!friend?.silent }
  )
  router.push({ name: 'ImHomeConversation' })
}
/** è¿›å…¥ä¸Žè¯¥å¥½å‹çš„私聊会话 */
function handleChatFriend(friend: FriendLite) {
  // ä»Ž friendStore åŒæ­¥å¤‡æ³¨ + å…æ‰“扰,避免新建会话用过期数据
  const entry = friendStore.getFriend(friend.id)
  const conversationName = entry ? getFriendDisplayName(entry) : friend.nickname
  conversationStore.openConversation(
    friend.id,
    ImConversationType.PRIVATE,
    conversationName,
    friend.avatar || '',
    { silent: !!entry?.silent }
  )
  router.push({ name: 'ImHomeConversation' })
}
/** è¿›å…¥è¯¥ç¾¤çš„群聊会话 */
function handleChatGroup(group: GroupLite) {
  const entry = groupStore.getGroup(group.id)
  conversationStore.openConversation(
    group.id,
    ImConversationType.GROUP,
    group.showGroupName || group.name || '',
    group.showImage || group.showImageThumb || '',
    { silent: !!entry?.silent }
  )
  router.push({ name: 'ImHomeConversation' })
}
/** åˆ é™¤å¥½å‹ï¼šäºŒæ¬¡ç¡®è®¤ â†’ store è½åº“ â†’ æ¸…空当前选中 */
async function handleDeleteFriend(friend: FriendLite) {
  try {
    await confirm(`确定删除好友「${friend.nickname}」吗?`, '删除联系人')
    // friendStore.deleteFriend å†…部已经级联清理对应私聊会话
    await friendStore.deleteFriend(friend.id)
    if (selection.value?.type === 'friend' && selection.value.friend.id === friend.id) {
      selection.value = null
    }
    message.success('已删除好友')
  } catch {}
}
/** å¤‡æ³¨å·²ä¿å­˜ï¼šUserInfo å†…部已经走完 friendStore è½åº“ + æç¤ºï¼Œæœ¬ä¾§åªè´Ÿè´£åŒæ­¥ selection æŒçš„æ—§ FriendLite å‰¯æœ¬ */
function onRemarkSaved(displayName: string) {
  if (selection.value?.type === 'friend') {
    selection.value.friend.displayName = displayName
  }
}
</script>
<template>
  <!--
    é€šè®¯å½• Tab æ•´é¡µï¼ˆå‚考微信 PC é€šè®¯å½•)
    - å·¦ï¼šæœç´¢ + GroupList / FriendList ä¸¤ä¸ªæŠ˜å åˆ†ç»„(好友按字母分桶)
    - å³ï¼šé€‰ä¸­é¡¹çš„详情,好友走共享 <UserInfo>(relation=friend),群聊走 GroupDetail
    - æœ¬é¡µä»…做:选中分发 + æ•°æ®æºè½¬æ¢ + è·¨ç»„件事件落 store
  -->
  <div class="flex flex-1 h-full min-w-0 bg-[var(--ant-color-bg-container)]">
    <ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth">
      <!-- é¡¶éƒ¨ï¼šä»…搜索框;h-14 ä¸Žæ¶ˆæ¯ Tab é¡¶éƒ¨å¯¹é½ï¼Œé¿å…åˆ‡æ¢æ—¶æœç´¢æ¡†ä¸Šä¸‹æŠ–动 -->
      <div
        class="flex flex-shrink-0 items-center h-14 px-4 border-b border-b-solid border-[var(--im-border-color-lighter)]"
      >
        <Input v-model:value="keyword" placeholder="搜索" allow-clear class="flex-1">
          <template #prefix>
            <Icon icon="ant-design:search-outlined" />
          </template>
        </Input>
      </div>
      <!-- åˆ—表主体:拆 FriendRequestList / GroupList / FriendList ä¸‰ä¸ªå­ç»„件,各自管理折叠 + è¿‡æ»¤ï¼›æœ¬é¡µåªé€ä¼ é€‰ä¸­æ€ -->
      <!-- overflow-y-auto:联系人多时本列表可滚动(父 ResizableAside ä¸æä¾›æ»šåŠ¨ï¼‰ï¼Œå¯¹é½ Vue3 çš„ el-scrollbar -->
      <div class="flex-1 overflow-y-auto">
        <FriendRequestList
          :requests="friendRequests"
          :active-id="selection?.type === 'request' ? selection.request.id : undefined"
          @select="handleSelectRequest"
        />
        <GroupList
          :groups="groups"
          :keyword="keyword"
          :active-id="selection?.type === 'group' ? selection.group.id : undefined"
          @select="handleSelectGroup"
        />
        <FriendList
          :friends="friends"
          :keyword="keyword"
          :active-id="selection?.type === 'friend' ? selection.friend.id : undefined"
          @select="handleSelectFriend"
          @chat="handleChatFriend"
          @delete="handleDeleteFriend"
        />
      </div>
    </ResizableAside>
    <!-- å³ä¾§è¯¦æƒ…区 -->
    <div class="flex-1 min-w-0">
      <!-- ç©ºæ€ï¼šå ä½å›¾æ ‡ + æç¤ºæ–‡æ¡ˆ -->
      <div
        v-if="!selection"
        class="flex flex-col items-center justify-center h-full gap-3 text-[var(--ant-color-text-secondary)]"
      >
        <Icon
          icon="ant-design:contacts-outlined"
          :size="64"
          class="text-[var(--ant-color-text-placeholder)]"
        />
        <span class="text-sm">在左侧选择好友或群聊查看详情</span>
      </div>
      <!-- å¥½å‹è¯¦æƒ… -->
      <div v-else-if="selection.type === 'friend'" class="flex justify-center pt-12 px-6">
        <div class="w-full max-w-[320px]">
          <UserInfo
            :user="friendUser"
            :display-name="selection.friend.displayName || ''"
            relation="friend"
            @chat="handleChatFriend(selection.friend)"
            @deleted="selection = null"
            @saved="onRemarkSaved"
          />
        </div>
      </div>
      <!-- ç¾¤è¯¦æƒ… -->
      <GroupDetail
        v-else-if="selection.type === 'group'"
        :group="selection.group"
        @chat="handleChatGroup"
      />
      <!-- æ–°çš„æœ‹å‹ - ç”³è¯·è¯¦æƒ… -->
      <FriendRequestDetail
        v-else-if="selection.type === 'request'"
        :request="currentRequest"
        @chat="handleChatPeer"
      />
    </div>
  </div>
</template>
src/views/im/home/pages/conversation/components/conversation/conversation-group-side.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,832 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group'
import type { Conversation, GroupLite } from '../../../../types'
import { computed, ref, watch } from 'vue'
import { confirm } from '#/packages/effects/common-ui/src'
import { CommonStatusEnum } from '#/packages/constants/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Button, Drawer, Input, message, Popover, Switch } from 'ant-design-vue'
import {
  dissolveGroup,
  muteAll,
  updateGroup
} from '#/api/im/group'
import { quitGroup, updateGroupMember } from '#/api/im/group/member'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants'
import { toGroupCardTarget } from '#/views/im/utils/message'
import { isGroupQuit } from '#/views/im/utils/user'
import { GroupAdminSetDialog } from '../../../../components/group'
import { GroupMemberAddDialog } from '../../../../components/group'
import { GroupMemberGrid } from '../../../../components/group'
import { GroupMemberRemoveDialog } from '../../../../components/group'
import { GroupOwnerTransferDialog } from '../../../../components/group'
import { GroupRequestListDialog } from '../../../../components/group'
import { RecommendCardDialog } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
defineOptions({ name: 'ImConversationGroupSide' })
// å¤§ç¾¤é»˜è®¤åªå±•示前 N ä¸ªæˆå‘˜ï¼ˆ4×4 å®«æ ¼ âˆ’ 2 ä¸ªç“¦ç‰‡é¢„留给"添加 / ç§»å‡º"按钮)
const props = withDefaults(
  defineProps<{
    conversation?: Conversation | null // å½“前会话:用于读 / åˆ‡å…æ‰“扰、置顶状态
    group?: GroupLite & { groupRemark?: string; notice?: string; remarkNickName?: string; } // å½“前群信息(可空:无激活群会话时)
    members?: GroupMemberLite[]
    modelValue?: boolean // æŠ½å±‰æ˜¯å¦æ‰“开(v-model)
  }>(),
  {
    conversation: null,
    group: undefined,
    modelValue: false,
    members: () => []
  }
)
const emit = defineEmits<{
  openHistory: [] // ç‚¹å‡» "查找聊天内容" è¡Œ â†’ çˆ¶ç»„件打开 MessageHistory å¼¹çª—
  reload: [friendIds?: number[]] // é‚€è¯· / ç§»é™¤ / ä¿®æ”¹ç¾¤èµ„料后,父组件重新拉群数据;邀请新成员场景透出 friendIds è®©ä¸Šå±‚可选精准刷新
  'update:modelValue': [value: boolean]
}>()
const MEMBER_PREVIEW_COUNT = 14
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const visible = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v)
})
// ==================== è§’色 / æˆå‘˜å±•示 ====================
const searchText = ref('')
const showAllMembers = ref(false)
const inviteDialogRef = ref<InstanceType<typeof GroupMemberAddDialog>>() // é‚€è¯·å¥½å‹å…¥ç¾¤å¼¹çª— ref:handleOpenInvite è°ƒ open({ groupId }) æ‰“å¼€
/** æ‰“开邀请好友入群弹窗 */
function handleOpenInvite() {
  if (!props.group?.id) {
    return
  }
  inviteDialogRef.value?.open({ groupId: props.group.id })
}
const myId = computed(() => getCurrentUserId())
/** åŽ†å²é€€ç¾¤ç¾¤ï¼šç¦æ‰€æœ‰ç¾¤æ“ä½œå…¥å£ï¼ˆé‚€è¯· / ç§»å‡º / æ”¹èµ„æ–™ / ç¦è¨€ / å®¡æ‰¹ / é€€å‡ºç­‰ï¼‰ï¼Œåªä¿ç•™å±•示;props.group æ˜¯ GroupLite æ—  joinStatus,回 store å–全量 */
const isQuitGroup = computed(() => {
  const id = props.group?.id
  return id != null && isGroupQuit(groupStore.getGroup(id))
})
const isOwner = computed(
  () => !isQuitGroup.value && props.group != null && props.group.ownerId === myId.value
)
/** å½“前用户在群里的角色(来自 props.members çš„ me è¡Œï¼‰ï¼›ç”¨äºŽåˆ¤å®šæ˜¯å¦å¯ç§»å‡ºä»–人 */
const myRole = computed(() => props.members.find((m) => m.userId === myId.value)?.role)
/** ç¾¤ä¸»æˆ–管理员:在抽屉里有 "移出群成员" å…¥å£ï¼›åŽ†å²é€€ç¾¤ç¾¤ä¸€å¾‹è§†ä¸ºæ— æƒé™ */
const isOwnerOrAdmin = computed(
  () =>
    !isQuitGroup.value &&
    (myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN)
)
/** æŽ’除已退群成员 + å…³é”®å­—过滤;按角色排序:群主→管理员→普通成员(同角色按 userId ç¨³å®šï¼‰ */
const visibleMembers = computed(() => {
  return props.members
    .filter(
      (member) =>
        member.status !== CommonStatusEnum.DISABLE &&
        (member.showName || '').includes(searchText.value)
    )
    .toSorted((a, b) => {
      const roleA = a.role ?? ImGroupMemberRole.NORMAL
      const roleB = b.role ?? ImGroupMemberRole.NORMAL
      return roleA === roleB ? a.userId - b.userId : roleA - roleB
    })
})
/** æŠ˜å è§„则:搜索 / å·²å±•å¼€ æ—¶ä¸æŠ˜å ï¼Œå…¶ä½™åªå–前 N ä¸ª */
const moreMembersHidden = computed(
  () =>
    !searchText.value && !showAllMembers.value && visibleMembers.value.length > MEMBER_PREVIEW_COUNT
)
const displayMembers = computed(() =>
  moreMembersHidden.value
    ? visibleMembers.value.slice(0, MEMBER_PREVIEW_COUNT)
    : visibleMembers.value
)
// æŠ½å±‰å…³é—­æ—¶æ¸…掉成员区临时态(搜索关键字、查看更多展开)
watch(visible, (v) => {
  if (!v) {
    searchText.value = ''
    showAllMembers.value = false
  }
})
// ==================== ç¾¤ä¿¡æ¯ç¼–辑 ====================
const namePopoverVisible = ref(false)
const noticePopoverVisible = ref(false)
const remarkPopoverVisible = ref(false)
const groupRemarkPopoverVisible = ref(false)
const editName = ref('')
const editNotice = ref('')
const editRemark = ref('')
const editGroupRemark = ref('')
// popover å¼¹å‡ºæ—¶æŠŠå½“前值灌进编辑态,避免上次未保存的脏值
watch(namePopoverVisible, (v) => {
  if (v) editName.value = props.group?.name || ''
})
watch(noticePopoverVisible, (v) => {
  if (v) editNotice.value = props.group?.notice || ''
})
watch(remarkPopoverVisible, (v) => {
  if (v) editRemark.value = props.group?.remarkNickName || ''
})
watch(groupRemarkPopoverVisible, (v) => {
  if (v) editGroupRemark.value = props.group?.groupRemark || ''
})
// æŠ½å±‰å…³é—­æ—¶æ¸…掉所有 popover,避免下次打开仍弹着
watch(visible, (v) => {
  if (!v) {
    namePopoverVisible.value = false
    noticePopoverVisible.value = false
    remarkPopoverVisible.value = false
    groupRemarkPopoverVisible.value = false
  }
})
/** ç¾¤ä¸»ï¼šä¿å­˜ç¾¤åï¼ˆèµ° /im/group/update);trim åŽç©ºå­—符串拒提交,与 saveGroupRemark è¡Œä¸ºå¯¹é½ */
async function saveName() {
  if (!props.group) {
    return
  }
  const trimmed = editName.value.trim()
  if (!trimmed) {
    message.warning('群名称不能为空')
    return
  }
  await updateGroup({ id: props.group.id, name: trimmed })
  namePopoverVisible.value = false
  message.success('保存成功')
  emit('reload')
}
/** ç¾¤ä¸»ï¼šä¿å­˜ç¾¤å…¬å‘Š */
async function saveNotice() {
  if (!props.group) {
    return
  }
  await updateGroup({ id: props.group.id, notice: editNotice.value })
  noticePopoverVisible.value = false
  message.success('保存成功')
  emit('reload')
}
/** ç¾¤ä¸»ï¼šåˆ‡æ¢ã€Œè¿›ç¾¤å®¡æ‰¹ã€å¼€å…³ï¼›å¼€å¯åŽæ‰€æœ‰ã€Œç”³è¯·ã€ã€Œé‚€è¯·ã€è·¯å¾„都需群主 / ç®¡ç†å‘˜åŒæ„ */
async function handleJoinApprovalChange(value: boolean | number | string) {
  if (!props.group) {
    return
  }
  await updateGroup({ id: props.group.id, joinApproval: !!value })
  message.success('保存成功')
  emit('reload')
}
/** ä»»ä½•成员:保存群备注(仅自己可见,会替换会话列表 / é¡¶éƒ¨ç¾¤åå±•示) */
async function saveGroupRemark() {
  if (!props.group) {
    return
  }
  await updateGroupMember({
    groupId: props.group.id,
    groupRemark: editGroupRemark.value.trim()
  })
  groupRemarkPopoverVisible.value = false
  message.success('保存成功')
  emit('reload')
}
/** ä»»ä½•成员:保存自己在群里的昵称(走 /im/group-member/update) */
async function saveRemark() {
  if (!props.group) {
    return
  }
  await updateGroupMember({
    groupId: props.group.id,
    displayUserName: editRemark.value
  })
  remarkPopoverVisible.value = false
  message.success('保存成功')
  emit('reload')
}
// ==================== å¼€å…³åˆ‡æ¢ ====================
/**
 * æ¶ˆæ¯å…æ‰“扰:本地 conversationStore ç«‹å³åˆ‡ï¼›åŽç«¯ /silent å¼‚步同步,失败回滚本地
 *
 * ä¸Ž ConversationItem å³é”®èœå•çš„"消息免打扰"语义一致;区别仅在 UI å…¥å£
 */
function onMutedChange(value: boolean | number | string) {
  if (!props.conversation) {
    return
  }
  const next = !!value
  const { type, targetId } = props.conversation
  conversationStore.setConversationSilent(type, targetId, next)
  groupStore.setGroupSilent(targetId, next).catch((error) => {
    console.error('[IM ConversationGroupSide] setGroupSilent å¤±è´¥', { targetId }, error)
    conversationStore.setConversationSilent(type, targetId, !next)
  })
}
/** ç½®é¡¶èŠå¤©ï¼šçº¯æœ¬åœ° conversationStore æŽ’序态(无后端字段) */
function onTopChange(value: boolean | number | string) {
  if (!props.conversation) {
    return
  }
  conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value)
}
// ==================== å…¨ç¾¤ç¦è¨€ ====================
/** å½“前群是否全群禁言 */
const currentMutedAll = computed(() => {
  if (!props.group) {
    return false
  }
  return groupStore.getGroup(props.group.id)?.mutedAll ?? false
})
/** å…¨ç¾¤ç¦è¨€å¼€å…³åˆ‡æ¢ */
async function onMuteAllChange(value: boolean | number | string) {
  if (!props.group) {
    return
  }
  const newValue = !!value
  await muteAll({ id: props.group.id, mutedAll: newValue })
  message.success(newValue ? '已开启全群禁言' : '已关闭全群禁言')
  emit('reload')
}
// ==================== è¿›ç¾¤å®¡æ‰¹ ====================
const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>() // è¿›ç¾¤ç”³è¯·åˆ—表弹窗 ref:handleOpenRequestList è°ƒ open({ groupId }) è§¦å‘
/** æ‰“开当前群的进群申请列表 */
function handleOpenRequestList() {
  if (!props.group?.id) {
    return
  }
  requestListDialogRef.value?.open({ groupId: props.group.id })
}
// ==================== åˆ†äº«ç¾¤åç‰‡ ====================
const recommendCardDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // åˆ†äº«ç¾¤åç‰‡å¼¹çª— ref:handleShareGroupCard è°ƒç”¨ open({ target }) æ‰“å¼€
/** åˆ†äº«ç¾¤åç‰‡ï¼šæŠŠå½“前群作为名片消息推荐给其他会话 */
function handleShareGroupCard() {
  const target = toGroupCardTarget(props.group)
  if (!target) {
    return
  }
  recommendCardDialogRef.value?.open({ target })
}
// ==================== é€€å‡ºç¾¤èŠ ====================
/** é€€å‡ºç¾¤èŠï¼ˆæ™®é€šæˆå‘˜å…¥å£ï¼›ç¾¤ä¸»é€€å‡ºèµ°"解散群"是另一条路径,这里不处理) */
async function handleQuit() {
  if (!props.group) {
    return
  }
  // äºŒæ¬¡ç¡®è®¤
  try {
    await confirm('退出群聊后将不再接收群里的消息,确认退出吗?', '确认退出')
  } catch {
    return
  }
  const groupId = props.group.id
  await quitGroup(groupId)
  // æœ¬åœ°ç«‹å³å“åº”:先把 self.member ç½® DISABLE(让 GroupInfo ç­‰ isMember æ”¶æ•›ï¼‰ï¼Œå†æ¸…会话 + ç¾¤ store
  if (myId.value) {
    groupStore.updateGroupMemberStatus(groupId, myId.value, CommonStatusEnum.DISABLE)
  }
  conversationStore.removeConversation(ImConversationType.GROUP, groupId)
  groupStore.removeGroup(groupId)
  message.success('已退出群聊')
  visible.value = false
}
/** è§£æ•£ç¾¤èŠï¼ˆä»…群主入口) */
async function handleDissolve() {
  if (!props.group) {
    return
  }
  try {
    await confirm('解散后所有成员将被移出,且无法恢复,确认解散吗?', '确认解散')
  } catch {
    return
  }
  const groupId = props.group.id
  await dissolveGroup(groupId)
  conversationStore.removeConversation(ImConversationType.GROUP, groupId)
  groupStore.removeGroup(groupId)
  message.success('群聊已解散')
  visible.value = false
}
// ==================== ç¾¤ä¸»æ“ä½œ ====================
// ç§»é™¤ç¾¤æˆå‘˜ï¼ˆç¾¤ä¸» / ç®¡ç†å‘˜å¯è§ï¼‰+ è®¾ç½®ç¾¤ç®¡ç†å‘˜ï¼ˆä»…群主)+ ç¾¤ä¸»ç®¡ç†æƒè½¬è®©ï¼ˆä»…群主)
const removeDialogRef = ref<InstanceType<typeof GroupMemberRemoveDialog>>() // ç§»é™¤ç¾¤æˆå‘˜å¼¹çª— ref
const adminSetDialogRef = ref<InstanceType<typeof GroupAdminSetDialog>>() // è®¾ç½®ç¾¤ç®¡ç†å‘˜å¼¹çª— ref
const ownerTransferDialogRef = ref<InstanceType<typeof GroupOwnerTransferDialog>>() // è½¬è®©ç¾¤ä¸»å¼¹çª— ref
// ---------- ç§»é™¤ç¾¤æˆå‘˜ ----------
/** æ‰“开移除群成员弹窗:始终隐藏群主;管理员视角额外隐藏其它管理员(管理员不能移出管理员) */
function handleOpenRemove() {
  if (!props.group?.id) {
    return
  }
  const hideIds: number[] = []
  if (props.group.ownerId) {
    hideIds.push(props.group.ownerId)
  }
  if (myRole.value === ImGroupMemberRole.ADMIN) {
    props.members
      .filter((m) => m.role === ImGroupMemberRole.ADMIN)
      .forEach((m) => hideIds.push(m.userId))
  }
  removeDialogRef.value?.open({
    groupId: props.group.id,
    members: props.members,
    hideIds
  })
}
// ---------- è®¾ç½®ç¾¤ç®¡ç†å‘˜ ----------
/** æ‰“开设置群管理员弹窗:当前管理员默认勾选;群主从候选里隐藏 */
function handleOpenAdminSet() {
  if (!props.group?.id) {
    return
  }
  // è¿‡æ»¤å·²é€€ç¾¤æˆå‘˜ï¼Œé¿å… maxSize åé¢è¢«éšè—æˆå‘˜å ç”¨å¯¼è‡´æ— æ³•新增管理员
  const currentAdminIds = props.members
    .filter(
      (member) =>
        member.role === ImGroupMemberRole.ADMIN && member.status !== CommonStatusEnum.DISABLE
    )
    .map((member) => member.userId)
  const hideIds = props.group.ownerId ? [props.group.ownerId] : []
  adminSetDialogRef.value?.open({
    groupId: props.group.id,
    members: props.members,
    currentAdminIds,
    hideIds
  })
}
// ---------- ç¾¤ä¸»ç®¡ç†æƒè½¬è®© ----------
/** æ‰“开转让群主弹窗:当前用户从候选里隐藏(不能转给自己) */
function handleOpenTransferOwner() {
  if (!props.group?.id) {
    return
  }
  ownerTransferDialogRef.value?.open({
    groupId: props.group.id,
    members: props.members,
    hideIds: [myId.value]
  })
}
</script>
<template>
  <!-- èŠå¤©é¢æ¿å³ä¾§ç¾¤ä¿¡æ¯æŠ½å±‰ï¼šæˆå‘˜å®«æ ¼ + ç¾¤ä¿¡æ¯ + å¼€å…³ + é€€å‡ºç¾¤èŠï¼Œæ•´ä½“对齐微信 PC -->
  <Drawer
    v-model:open="visible"
    :closable="false"
    placement="right"
    width="380px"
    root-class-name="im-conversation-group-side__modal"
  >
    <div v-if="group" class="flex flex-col h-full bg-[var(--ant-color-bg-container)]">
      <!-- ä¸Šéƒ¨ï¼šå¯æ»šåŠ¨å†…å®¹åŒº -->
      <div class="flex-1 overflow-y-auto bg-[var(--ant-color-fill-secondary)]">
        <!-- ==================== ç¾¤æˆå‘˜åŒº ==================== -->
        <div class="px-4 pt-4 pb-[10px] bg-[var(--ant-color-bg-container)]">
          <Input v-model:value="searchText" placeholder="搜索群成员" allow-clear>
            <template #prefix>
              <Icon
                icon="ant-design:search-outlined"
                class="text-[var(--ant-color-text-placeholder)]"
              />
            </template>
          </Input>
          <div class="flex flex-wrap gap-x-1 gap-y-[14px] mt-[14px]">
            <GroupMemberGrid
              v-for="member in displayMembers"
              :key="member.userId"
              :member="member"
              :size="50"
              clickable
              :group-name="group.name"
            />
            <!-- æ·»åŠ ï¼ˆä»»ä½•æˆå‘˜éƒ½èƒ½é‚€è¯·ï¼›åŽ†å²é€€ç¾¤ç¾¤éšè—ï¼‰ -->
            <div
              v-if="!isQuitGroup"
              class="im-conversation-group-side__tile-wrap flex flex-col items-center w-[66px] cursor-pointer"
              title="邀请好友入群"
              @click="handleOpenInvite"
            >
              <div class="im-conversation-group-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200">
                <Icon icon="ant-design:plus-outlined" />
              </div>
              <div class="mt-1.5 text-12px leading-[1.5] text-[var(--ant-color-text)] text-center">添加</div>
            </div>
            <!-- ç§»å‡ºï¼ˆç¾¤ä¸»æˆ–管理员;管理员只能移出普通成员,由后端校验) -->
            <div
              v-if="isOwnerOrAdmin"
              class="im-conversation-group-side__tile-wrap flex flex-col items-center w-[66px] cursor-pointer"
              title="移出群成员"
              @click="handleOpenRemove"
            >
              <div class="im-conversation-group-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200">
                <Icon icon="ant-design:minus-outlined" />
              </div>
              <div class="mt-1.5 text-12px leading-[1.5] text-[var(--ant-color-text)] text-center">移出</div>
            </div>
          </div>
          <!-- å¤§ç¾¤æŠ˜å ï¼šé»˜è®¤åªå±•示前 N ä¸ªï¼Œç‚¹ "查看更多" å…¨å±•开(搜索时不折叠) -->
          <div
            v-if="moreMembersHidden"
            class="flex items-center justify-center gap-1 mt-[10px] pt-1.5 pb-0.5 text-12px text-[var(--ant-color-text-secondary)] cursor-pointer transition-colors duration-150 hover:text-[var(--ant-color-primary)]"
            @click="showAllMembers = true"
          >
            æŸ¥çœ‹æ›´å¤š
            <Icon icon="ant-design:down-outlined" :size="10" />
          </div>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- ==================== ç¾¤ä¿¡æ¯ ==================== -->
        <!-- label åœ¨ä¸Šã€value åœ¨ä¸‹ï¼Œçºµå‘堆叠(对齐微信 PC è®¾è®¡ï¼‰ï¼›åªæœ‰ "群公告" å› ä¸ºå†…容长加 > chevron -->
        <div class="bg-[var(--ant-color-bg-container)]">
          <!-- ç¾¤èŠåç§°ï¼ˆç¾¤ä¸»å¯æ”¹ï¼‰ -->
          <Popover
            v-if="isOwner"
            v-model:open="namePopoverVisible"
            trigger="click"
            placement="leftTop"
            :overlay-style="{ width: '280px' }"
          >
            <div
              class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            >
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群聊名称</span>
              <span class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] truncate">{{ group.name }}</span>
            </div>
            <template #content>
              <div class="flex flex-col gap-2">
                <Input v-model:value="editName" :maxlength="20" placeholder="请输入群聊名称" />
                <div class="flex justify-end gap-2">
                  <Button size="small" @click="namePopoverVisible = false">取消</Button>
                  <Button size="small" type="primary" @click="saveName">保存</Button>
                </div>
              </div>
            </template>
          </Popover>
          <div
            v-else
            class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 transition-colors duration-150"
          >
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群聊名称</span>
            <span class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] truncate">{{ group.name }}</span>
          </div>
          <!-- ç¾¤å…¬å‘Šï¼ˆç¾¤ä¸»å¯æ”¹ï¼‰ï¼šå†…容可能很长,加 > chevron è¡¨ç¤ºå¯å±•开编辑 -->
          <Popover
            v-if="isOwner"
            v-model:open="noticePopoverVisible"
            trigger="click"
            placement="leftTop"
            :overlay-style="{ width: '320px' }"
          >
            <div
              class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            >
              <div class="flex items-center justify-between gap-2">
                <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群公告</span>
                <Icon
                  icon="ant-design:right-outlined"
                  :size="11"
                  class="text-[var(--ant-color-text-placeholder)]"
                />
              </div>
              <span
                v-if="group.notice"
                class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] line-clamp-2"
              >
                {{ group.notice }}
              </span>
              <span v-else class="text-13px text-[var(--ant-color-text-placeholder)] leading-[1.6]">未设置</span>
            </div>
            <template #content>
              <div class="flex flex-col gap-2">
                <Input.TextArea
                  v-model:value="editNotice"
                  :rows="4"
                  :maxlength="1024"
                  show-count
                  placeholder="请输入群公告"
                />
                <div class="flex justify-end gap-2">
                  <Button size="small" @click="noticePopoverVisible = false">取消</Button>
                  <Button size="small" type="primary" @click="saveNotice">保存</Button>
                </div>
              </div>
            </template>
          </Popover>
          <div
            v-else
            class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 transition-colors duration-150"
          >
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群公告</span>
            <span
              v-if="group.notice"
              class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] line-clamp-2"
            >
              {{ group.notice }}
            </span>
            <span v-else class="text-13px text-[var(--ant-color-text-placeholder)] leading-[1.6]">未设置</span>
          </div>
          <!-- å¤‡æ³¨ï¼ˆä»…自己可见;保存后会替换会话列表 / é¡¶éƒ¨ç¾¤åå±•示);历史退群群隐藏:改备注走 updateGroupMember,已退群会被后端拒 -->
          <Popover
            v-if="!isQuitGroup"
            v-model:open="groupRemarkPopoverVisible"
            trigger="click"
            placement="leftTop"
            :overlay-style="{ width: '280px' }"
          >
            <div
              class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            >
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">备注</span>
              <span
                v-if="group.groupRemark"
                class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] line-clamp-2"
              >
                {{ group.groupRemark }}
              </span>
              <span v-else class="text-13px text-[var(--ant-color-text-placeholder)] leading-[1.6]">
                ç¾¤èŠçš„备注仅自己可见
              </span>
            </div>
            <template #content>
              <div class="flex flex-col gap-2">
                <Input.TextArea
                  v-model:value="editGroupRemark"
                  :rows="3"
                  :maxlength="64"
                  show-count
                  placeholder="仅自己可见"
                />
                <div class="flex justify-end gap-2">
                  <Button size="small" @click="groupRemarkPopoverVisible = false">取消</Button>
                  <Button size="small" type="primary" @click="saveGroupRemark">保存</Button>
                </div>
              </div>
            </template>
          </Popover>
          <!-- æˆ‘在本群的昵称(任何成员都能改自己的);历史退群群隐藏:走 updateGroupMember,已退群会被后端拒 -->
          <Popover
            v-if="!isQuitGroup"
            v-model:open="remarkPopoverVisible"
            trigger="click"
            placement="leftTop"
            :overlay-style="{ width: '280px' }"
          >
            <div
              class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            >
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">我在本群的昵称</span>
              <span
                v-if="group.remarkNickName"
                class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] truncate"
              >
                {{ group.remarkNickName }}
              </span>
              <span v-else class="text-13px text-[var(--ant-color-text-placeholder)] leading-[1.6]">点击设置</span>
            </div>
            <template #content>
              <div class="flex flex-col gap-2">
                <Input v-model:value="editRemark" :maxlength="20" placeholder="请输入本群昵称" />
                <div class="flex justify-end gap-2">
                  <Button size="small" @click="remarkPopoverVisible = false">取消</Button>
                  <Button size="small" type="primary" @click="saveRemark">保存</Button>
                </div>
              </div>
            </template>
          </Popover>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- ==================== æŸ¥æ‰¾èŠå¤©å†…容 ==================== -->
        <!-- ç‚¹å‡» â†’ çˆ¶ç»„件打开 MessageHistory å¼¹çª— -->
        <div class="flex-shrink-0 h-[10px]"></div>
        <div class="bg-[var(--ant-color-bg-container)]">
          <div
            class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            @click="emit('openHistory')"
          >
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">查找聊天内容</span>
            <Icon
              icon="ant-design:right-outlined"
              :size="11"
              class="text-[var(--ant-color-text-placeholder)]"
            />
          </div>
          <!-- åˆ†äº«ç¾¤åç‰‡ï¼šå¼¹ RecommendCardDialog,把当前群作为名片消息推荐给其他会话 -->
          <div
            v-if="group"
            class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            @click="handleShareGroupCard"
          >
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">分享群名片</span>
            <Icon
              icon="ant-design:right-outlined"
              :size="11"
              class="text-[var(--ant-color-text-placeholder)]"
            />
          </div>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- ==================== å¼€å…³é¡¹ ==================== -->
        <div class="bg-[var(--ant-color-bg-container)]">
          <div class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">消息免打扰</span>
            <Switch :checked="!!conversation?.silent" @change="onMutedChange" />
          </div>
          <div class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">置顶聊天</span>
            <Switch :checked="!!conversation?.top" @change="onTopChange" />
          </div>
          <!-- å…¨ç¾¤ç¦è¨€ï¼šä»…群主或管理员可操作 -->
          <div v-if="isOwnerOrAdmin" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">全群禁言</span>
            <Switch :checked="!!currentMutedAll" @change="onMuteAllChange" />
          </div>
        </div>
        <!-- ==================== è¿›ç¾¤å®¡æ‰¹ ==================== -->
        <!-- å•独一段:群主开关 + ç´§è·Ÿã€Œ- è¿›ç¾¤ç”³è¯·ã€å­é¡¹ï¼›ä¸Žå¾®ä¿¡ç¾¤ç®¡ç†å¸ƒå±€å¯¹é½ -->
        <template v-if="isOwner || (isOwnerOrAdmin && !!group.joinApproval)">
          <div class="flex-shrink-0 h-[10px]"></div>
          <div class="bg-[var(--ant-color-bg-container)]">
            <!-- è¿›ç¾¤å®¡æ‰¹ï¼šä»…群主可操作;开启后普通成员的「申请」「邀请」路径都需群主 / ç®¡ç†å‘˜åŒæ„ï¼›ç¾¤ä¸» / ç®¡ç†å‘˜é‚€è¯·ç›´è¿› -->
            <div v-if="isOwner" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">进群需要群主 / ç¾¤ç®¡ç†ç¡®è®¤</span>
              <Switch :checked="!!group.joinApproval" @change="handleJoinApprovalChange" />
            </div>
            <!-- è¿›ç¾¤ç”³è¯·å­é¡¹ï¼šä»…当开启审批 + å½“前用户是 owner / admin æ—¶å‡ºçŽ°ï¼›ç‚¹å‡»è¿›åˆ—è¡¨ dialog -->
            <div
              v-if="isOwnerOrAdmin && !!group.joinApproval"
              class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
              @click="handleOpenRequestList"
            >
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">- è¿›ç¾¤ç”³è¯·</span>
              <Icon
                icon="ant-design:right-outlined"
                :size="11"
                class="text-[var(--ant-color-text-placeholder)]"
              />
            </div>
          </div>
        </template>
        <!-- ==================== ç¾¤ä¸»æ“ä½œ ==================== -->
        <!-- ä»…群主可见,含管理员设置 + ç¾¤ä¸»ç®¡ç†æƒè½¬è®© -->
        <template v-if="isOwner">
          <div class="flex-shrink-0 h-[10px]"></div>
          <div class="bg-[var(--ant-color-bg-container)]">
            <div
              class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
              @click="handleOpenAdminSet"
            >
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群管理员</span>
              <Icon
                icon="ant-design:right-outlined"
                :size="11"
                class="text-[var(--ant-color-text-placeholder)]"
              />
            </div>
            <div
              class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
              @click="handleOpenTransferOwner"
            >
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群主管理权转让</span>
              <Icon
                icon="ant-design:right-outlined"
                :size="11"
                class="text-[var(--ant-color-text-placeholder)]"
              />
            </div>
          </div>
        </template>
      </div>
      <!-- ==================== åº•部:退出 / è§£æ•£ç¾¤èŠï¼ˆåŽ†å²é€€ç¾¤ç¾¤éšè—ï¼Œå·²é€€ç¾¤æ— éœ€å†é€€ï¼‰ ==================== -->
      <div
        v-if="!isQuitGroup"
        class="flex-shrink-0 px-4 pt-[14px] pb-[18px] bg-[var(--ant-color-bg-container)] border-t border-t-solid border-[var(--im-border-color-lighter)]"
      >
        <!-- ç¾¤ä¸»ï¼šè§£æ•£ç¾¤èŠ -->
        <Button
          v-if="isOwner"
          class="w-full !h-9 text-14px"
          danger
          @click="handleDissolve"
        >
          è§£æ•£ç¾¤èŠ
        </Button>
        <!-- éžç¾¤ä¸»ï¼šé€€å‡ºç¾¤èŠ -->
        <Button
          v-else
          class="w-full !h-9 text-14px"
          danger
          @click="handleQuit"
        >
          é€€å‡ºç¾¤èŠ
        </Button>
      </div>
    </div>
    <!-- ==================== å­å¯¹è¯æ¡† ==================== -->
    <!-- é‚€è¯·æ–°æˆå‘˜ / é€‰æˆå‘˜ç§»é™¤ -->
    <GroupMemberAddDialog ref="inviteDialogRef" @reload="(ids) => $emit('reload', ids)" />
    <GroupMemberRemoveDialog ref="removeDialogRef" @reload="$emit('reload')" />
    <!-- ç¾¤ä¸»æ“ä½œï¼šç®¡ç†å‘˜è®¾ç½®ï¼ˆä¸€ä¸ªå¼¹çª—合并增 / åˆ ï¼Œæäº¤æ—¶ diff)+ ç¾¤ä¸»ç®¡ç†æƒè½¬è®© -->
    <GroupAdminSetDialog ref="adminSetDialogRef" @reload="$emit('reload')" />
    <GroupOwnerTransferDialog ref="ownerTransferDialogRef" @reload="$emit('reload')" />
    <!-- è¿›ç¾¤ç”³è¯·åˆ—表(仅当开启审批 + å½“前用户是 owner / admin æ—¶å…¥å£å¯è§ï¼‰ -->
    <GroupRequestListDialog ref="requestListDialogRef" />
    <!-- åˆ†äº«ç¾¤åç‰‡ï¼šæŠŠå½“前群作为名片消息推荐给其他会话 -->
    <RecommendCardDialog ref="recommendCardDialogRef" />
  </Drawer>
</template>
<style scoped>
/* ã€Œæ·»åŠ  / ç§»å‡ºã€ç“¦ç‰‡ï¼šhover æ—¶è”动内部 icon-tile èµ°ä¸»è‰²ï¼Œè·¨å­å…ƒç´ çš„ hover è”动无法用单元素工具类表达 */
.im-conversation-group-side__tile-wrap:hover .im-conversation-group-side__icon-tile {
  color: var(--ant-color-primary);
  border-color: var(--ant-color-primary);
  background-color: var(--ant-color-primary-bg);
}
/* :deep ç©¿é€ Icon å†…部 svg; el-icon å…¨å±€ color åœ¨æš—色模式下被主题盖过,锁 fill åˆ°å½“前色 */
.im-conversation-group-side__icon-tile :deep(svg) {
  fill: currentColor !important;
}
/* ç›¸é‚»ä¿¡æ¯è¡ŒåŠ åˆ†éš”çº¿ï¼› ç›¸é‚»å…„弟选择器无法用工具类表达 */
.im-conversation-group-side__row + .im-conversation-group-side__row {
  border-top: 1px solid var(--im-border-color-lighter);
}
</style>
<!-- AntD Drawer è¢«ä¼ é€å‡ºå½“前 scoped è¾¹ç•Œï¼Œè¿™é‡Œé  root-class-name åŽ‹æŽ‰é»˜è®¤ padding -->
<style>
.im-conversation-group-side__modal .ant-drawer-body {
  padding: 0;
}
</style>
src/views/im/home/pages/conversation/components/conversation/conversation-item.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,330 @@
<script lang="ts" setup>
import type { Conversation } from '../../../../types'
import { computed } from 'vue'
import { confirm } from '#/packages/effects/common-ui/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Tag } from 'ant-design-vue'
import { buildRecallTip } from '#/views/im/utils/conversation'
import { formatConversationTime } from '#/views/im/utils/time'
import { getSenderDisplayName } from '#/views/im/utils/user'
import { ImContentType, ImConversationType, isNormalMessage } from '../../../../../utils/constants'
import { GroupAvatar } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useImUiStore } from '../../../../store/uiStore'
defineOptions({ name: 'ImConversationItem' })
// å‘¨ä¸­æ–‡åï¼ˆdayjs çš„ day() è¿”回 0-6,0=周日);项目没全局装 dayjs/locale/zh-cn,本地映射避免引副作用
const props = defineProps<{
  conversation: Conversation
}>()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const uiStore = useImUiStore()
const isActive = computed(
  () =>
    conversationStore.activeConversation?.targetId === props.conversation.targetId &&
    conversationStore.activeConversation?.type === props.conversation.type
)
/**
 * å½“前会话的草稿快照:存在时列表显示 [草稿] å‰ç¼€ + æ–‡æœ¬ï¼Œç›–掉 sender å‰ç¼€å’Œ @我 çº¢å­—
 * å½“前打开的会话不展示草稿;输入内容已经在编辑器里可见,列表再显示 [草稿] åè€Œå†—ä½™
 */
const draft = computed(() => {
  if (isActive.value) {
    return undefined
  }
  return conversationStore.getConversationDraft(props.conversation)
})
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
/** æœ€åŽä¸€æ¡æ¶ˆæ¯å‘送者的展示名:实时算 + å¿«ç…§ fallback(getSenderDisplayName ç®—不出时兜底) */
const lastSenderDisplayName = computed(() => {
  const senderId = props.conversation.lastSenderId
  if (!senderId) {
    return ''
  }
  return getSenderDisplayName(
    senderId,
    props.conversation.type,
    props.conversation.targetId,
    props.conversation.lastSenderDisplayName
  )
})
/** ç¾¤èŠ + æœ‰æœ€åŽå‘送者 + æœ€åŽä¸€æ¡æ˜¯æ™®é€šæ¶ˆæ¯æ—¶ï¼Œæ˜¾ç¤ºå‘送者前缀(FRIEND_* / GROUP_* / RECALL / è‰ç¨¿æ€ä¸å¸¦å‰ç¼€ï¼‰ */
const showSendName = computed(() => {
  if (draft.value) {
    return false
  }
  if (!isGroup.value) {
    return false
  }
  if (!props.conversation.lastSenderId) {
    return false
  }
  const lastType = props.conversation.lastMessageType
  return lastType != null && isNormalMessage(lastType)
})
/** åˆ—表展示文案:草稿优先(对齐微信 PC:有草稿时盖掉最后一条预览)→ æ’¤å›žå®žæ—¶ç®— â†’ lastContent å…œåº• */
const lastContentDisplay = computed(() => {
  if (draft.value) {
    return draft.value.plain
  }
  if (
    props.conversation.lastMessageType === ImContentType.RECALL &&
    props.conversation.lastSenderId != null
  ) {
    return buildRecallTip(
      props.conversation.lastSenderId,
      !!props.conversation.lastSelfSend,
      props.conversation.type,
      props.conversation.targetId,
      props.conversation.lastSenderDisplayName
    )
  }
  return props.conversation.lastContent
})
/** ä¼šè¯åˆ—表 "[草稿]" / "@ æˆ‘" / "@ å…¨ä½“成员" çº¢å­—提示;草稿优先(对齐微信 PC) */
const atText = computed(() => {
  if (draft.value) {
    return '[草稿]'
  }
  if (props.conversation.atMe) {
    return '[有人@我]'
  }
  if (props.conversation.atAll) {
    return '[@全体成员]'
  }
  return ''
})
/** å…æ‰“扰会话未读条数文案 */
const mutedUnreadText = computed(() => {
  if (!props.conversation.silent || props.conversation.unreadCount <= 0) {
    return ''
  }
  const count = props.conversation.unreadCount > 99 ? '99+' : props.conversation.unreadCount
  return `[${count}条]`
})
/** ç¾¤èŠæœªå¤„理加群申请红字前缀;store å·²ç»æŒ‰ã€Œæˆ‘管理的群」过滤过,count > 0 å³å¯æ˜¾ç¤º */
const requestText = computed(() => {
  if (!isGroup.value) {
    return ''
  }
  const count = groupRequestStore.getUnhandledGroupRequestCountMap.get(props.conversation.targetId) ?? 0
  return count > 0 ? `[${count}条进群申请]` : ''
})
/** ç‚¹å‡»åˆ‡ä¼šè¯ */
function handleClick() {
  conversationStore.setActiveConversation(props.conversation)
}
/** åˆ‡æ¢ç½®é¡¶ */
function handleTop() {
  conversationStore.setConversationTop(
    props.conversation.type,
    props.conversation.targetId,
    !props.conversation.top
  )
}
/** åˆ‡æ¢å…æ‰“扰:乐观 UI(先本地切换,菜单立即关;后端失败回滚 conversation çŠ¶æ€ï¼‰ */
function handleMuted() {
  const next = !props.conversation.silent
  const { type, targetId } = props.conversation
  conversationStore.setConversationSilent(type, targetId, next)
  const sync =
    type === ImConversationType.PRIVATE
      ? friendStore.setFriendSilent(targetId, next)
      : groupStore.setGroupSilent(targetId, next)
  sync.catch((error) => {
    console.error('[IM] åˆ‡æ¢å…æ‰“扰失败', error)
    conversationStore.setConversationSilent(type, targetId, !next)
  })
}
/** åˆ é™¤ä¼šè¯ï¼šäºŒæ¬¡ç¡®è®¤åŽè½¯åˆ  */
async function handleDelete() {
  try {
    await confirm(`确定删除与「${props.conversation.name}」的会话吗?`, '删除会话')
    conversationStore.removeConversation(props.conversation.type, props.conversation.targetId)
  } catch {}
}
/** å³é”®èœå•:置顶 / å…æ‰“扰 / åˆ é™¤ */
function handleContextMenu(e: MouseEvent) {
  uiStore.openContextMenu(
    { x: e.clientX, y: e.clientY },
    [
      { key: 'TOP', name: props.conversation.top ? '取消置顶' : '置顶' },
      { key: 'MUTED', name: props.conversation.silent ? '允许消息通知' : '消息免打扰' },
      { key: 'DELETE', name: '删除', divided: true, danger: true }
    ],
    (item) => {
      switch (item.key) {
      case 'DELETE': {
        void handleDelete()
      break;
      }
      case 'MUTED': {
        handleMuted()
      break;
      }
      case 'TOP': {
        handleTop()
      break;
      }
      // No default
      }
    }
  )
}
</script>
<template>
  <div
    class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
    :class="{ '!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': isActive }"
    :data-conversation-key="`${conversation.type}-${conversation.targetId}`"
    @click="handleClick"
    @contextmenu.prevent="handleContextMenu"
  >
    <!-- å¤´åƒ + æœªè¯»æç¤º -->
    <div class="relative">
      <GroupAvatar
        v-if="isGroup"
        :group-id="conversation.targetId"
        :url="conversation.avatar"
        :name="conversation.name"
        :size="40"
      />
      <UserAvatar
        v-else
        :url="conversation.avatar"
        :name="conversation.name"
        :size="40"
        :clickable="false"
      />
      <!-- æ•°å­—徽标:非免打扰且有未读时显示具体条数 -->
      <span
        v-show="!conversation.silent && conversation.unreadCount > 0"
        class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1.5 text-11px leading-[18px] text-white text-center bg-[#f56c6c] border border-solid border-white dark:border-[var(--ant-color-bg-container)] rounded-full box-border whitespace-nowrap"
      >
        {{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }}
      </span>
      <!-- å°çº¢ç‚¹ï¼šå…æ‰“扰且有未读时提示存在新消息 -->
      <span
        v-show="conversation.silent && conversation.unreadCount > 0"
        class="absolute -top-1 -right-1 w-2.5 h-2.5 bg-[#f56c6c] border border-solid border-white dark:border-[var(--ant-color-bg-container)] rounded-full box-border"
      ></span>
    </div>
    <div class="flex-1 min-w-0">
      <div class="flex items-center justify-between">
        <span class="flex flex-1 items-center gap-1 min-w-0">
          <span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
            {{ conversation.name }}
          </span>
          <Tag
            v-if="isGroup"
            type="primary"
            size="small"
            class="conversation-item__tag flex-shrink-0 !h-[18px] !px-1 !leading-4"
          >
            ç¾¤
          </Tag>
        </span>
        <span class="flex-shrink-0 ml-1 text-12px text-[var(--ant-color-text-secondary)]">
          {{ formatConversationTime(conversation.lastSendTime) }}
        </span>
      </div>
      <div class="flex items-center mt-1 leading-5">
        <!-- è¿›ç¾¤ç”³è¯·çº¢å­—前缀:群主 / ç®¡ç†å‘˜çœ‹åˆ°è‡ªå·±ç®¡ç†çš„群下还有未处理申请时显示 -->
        <span
          v-if="requestText"
          class="conversation-item__prefix flex-shrink overflow-hidden text-12px text-[#c70b0b] truncate whitespace-nowrap"
        >
          {{ requestText }}
        </span>
        <!-- @红字提示:atMe ä¼˜å…ˆäºŽ atAll -->
        <span
          v-if="atText"
          class="conversation-item__prefix flex-shrink overflow-hidden text-12px text-[#c70b0b] truncate whitespace-nowrap"
        >
          {{ atText }}
        </span>
        <!-- å…æ‰“扰未读条数 -->
        <span
          v-if="mutedUnreadText"
          class="flex-shrink-0 mr-1 text-12px text-[var(--ant-color-text-secondary)] whitespace-nowrap"
        >
          {{ mutedUnreadText }}
        </span>
        <!-- ç¾¤èŠæœ€åŽä¸€æ¡å‘送者前缀:按 lastSenderId + å½“前会话上下文实时算名字 -->
        <span
          v-if="showSendName"
          class="conversation-item__sender flex-shrink overflow-hidden text-12px text-[var(--ant-color-text-secondary)] truncate whitespace-nowrap"
        >
          {{ lastSenderDisplayName }}:&nbsp;
        </span>
        <span
          class="flex-1 min-w-0 overflow-hidden text-12px truncate text-[var(--ant-color-text-secondary)]"
        >
          {{ lastContentDisplay }}
        </span>
        <!-- å…æ‰“扰图标 -->
        <Icon
          v-if="conversation.silent"
          icon="mdi:bell-off-outline"
          :size="14"
          class="conversation-item__silent flex-shrink-0 ml-1 text-[var(--ant-color-text-disabled)]"
          title="消息免打扰"
        />
      </div>
    </div>
  </div>
</template>
<style scoped>
/* æ¶ˆæŽ‰ el-tag åˆ‡ä¼šè¯æ—¶ active åº•色变化的渐变(看起来像闪烁); :deep ç©¿é€ el-tag è‡ªèº«æ ·å¼ */
.conversation-item__tag {
  transition: none !important;
}
/* el-icon çš„全局 color:var(--color) åœ¨æš—色模式下会渲染成白色,这里用 :deep + !important é”å®š */
.conversation-item__silent :deep(svg) {
  fill: currentColor !important;
}
.conversation-item__prefix {
  max-width: 45%;
}
.conversation-item__sender {
  max-width: 50%;
}
</style>
src/views/im/home/pages/conversation/components/conversation/conversation-private-side.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,285 @@
<script lang="ts" setup>
import type { Conversation, Friend } from '../../../../types'
import { computed, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Button, Drawer, Input, message, Popover, Spin, Switch } from 'ant-design-vue'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useFriendStore } from '#/views/im/home/store/friendStore'
import { useGroupStore } from '#/views/im/home/store/groupStore'
import { ImConversationType } from '#/views/im/utils/constants'
import { getFriendDisplayName, getGroupDisplayName } from '#/views/im/utils/user'
import { GroupCreateDialog } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
defineOptions({ name: 'ImConversationPrivateSide' })
const props = withDefaults(
  defineProps<{
    conversation?: Conversation | null // å½“前会话(取置顶 / å…æ‰“扰态)
    friend?: Friend // å¯¹æ–¹å¥½å‹ä¿¡æ¯ï¼ˆå–头像 / æ˜µç§°ï¼‰
    modelValue?: boolean // æŠ½å±‰å¼€å…³ï¼ˆv-model)
  }>(),
  {
    conversation: null,
    friend: undefined,
    modelValue: false
  }
)
const emit = defineEmits<{
  openHistory: [] // ç‚¹å‡» "查找聊天内容" è¡Œ â†’ çˆ¶ç»„件打开 MessageHistory å¼¹çª—
  'update:modelValue': [value: boolean]
}>()
const visible = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
/** tile æ ‡ç­¾ / åŽç»­èŠå¤©ç•Œé¢ç”¨çš„展示名:备注优先 */
const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : ''))
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // å‘起群聊弹窗 ref:handleOpenCreateGroup è°ƒ open({ lockedIds }) é”å®šå¯¹æ–¹
/** æ‰“开发起群聊弹窗:把对方默认勾上且不可取消,对应微信"基于私聊发起群聊" */
function handleOpenCreateGroup() {
  const lockedIds = props.friend ? [props.friend.friendUserId] : []
  createGroupDialogRef.value?.open({ lockedIds })
}
const displayNamePopoverVisible = ref(false)
const editDisplayName = ref('')
// popover å¼¹å‡ºæ—¶æŠŠå½“前备注灌进编辑态,避免上次未保存的脏值
watch(displayNamePopoverVisible, (open) => {
  if (open) {
    editDisplayName.value = props.friend?.displayName || ''
  }
})
// æŠ½å±‰å…³é—­æ—¶æŠŠè¿˜æ²¡æ”¶æŽ‰çš„ popover ä¸€å¹¶æ¸…掉,避免下次打开闪一下
watch(visible, (open) => {
  if (!open) {
    displayNamePopoverVisible.value = false
  }
})
/** å¤‡æ³¨ popover ç‚¹å‡»ä¿å­˜ */
async function handleSaveDisplayName() {
  if (!props.friend) {
    return
  }
  await friendStore.setFriendDisplayName(props.friend.friendUserId, editDisplayName.value)
  displayNamePopoverVisible.value = false
  message.success('保存成功')
}
/**
 * åˆ‡å…æ‰“扰:乐观双写 conversationStore + friendStore;后端失败回滚 conversation çŠ¶æ€ï¼Œä¿æŒä¸Ž ConversationItem.handleMuted ä¸€è‡´
 */
function handleMutedChange(value: boolean | number | string) {
  if (!props.conversation) {
    return
  }
  const next = !!value
  const { type, targetId } = props.conversation
  conversationStore.setConversationSilent(type, targetId, next)
  if (type !== ImConversationType.PRIVATE) {
    return
  }
  friendStore.setFriendSilent(targetId, next).catch((error) => {
    console.error('[IM ConversationPrivateSide] åˆ‡æ¢å…æ‰“扰失败', { targetId }, error)
    conversationStore.setConversationSilent(type, targetId, !next)
  })
}
/** åˆ‡ç½®é¡¶ï¼šçº¯æœ¬åœ° conversationStore æŽ’序态(无后端字段) */
function handleTopChange(value: boolean | number | string) {
  if (!props.conversation) {
    return
  }
  conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value)
}
/** ç¾¤åˆ›å»ºæˆåŠŸï¼šè·³åˆ°æ–°ç¾¤ä¼šè¯ + å…³æŽ‰æœ¬ä¾§æŠ½å±‰ï¼Œè®©ç”¨æˆ·ä¸“注新群 */
function handleGroupCreated(groupId: number) {
  const group = groupStore.getGroup(groupId)
  if (!group) {
    return
  }
  conversationStore.openConversation(
    groupId,
    ImConversationType.GROUP,
    getGroupDisplayName(group),
    group.avatar || '',
    { silent: !!group.silent }
  )
  visible.value = false
}
</script>
<template>
  <!--
    ç§èŠä¾§è¾¹æŠ½å±‰
    - æ•´ä½“结构对齐 ConversationGroupSide:宫格 + ä¿¡æ¯è¡Œ + å¼€å…³
    - é¡¶éƒ¨å¥½å‹å®«æ ¼ + "+" tile:点 + è°ƒèµ· GroupCreateDialog å¹¶é”å®šå¯¹æ–¹ï¼Œå¯¹é½å¾®ä¿¡"基于私聊发起群聊"
    - "清空聊天记录"按钮在 WeChat é‡Œæœ‰ï¼Œä½†ç›®å‰åŽç«¯æ²¡å»ºæ¶ˆæ¯æ¸…空能力,先不加避免做半吊子
  -->
  <Drawer
    v-model:open="visible"
    :closable="false"
    placement="right"
    width="340px"
    root-class-name="im-conversation-private-side__modal"
  >
    <!-- friend ç¼ºå¤±åœºæ™¯ï¼šé™Œç”Ÿäººä¼šè¯åˆšæ‰“å¼€ / å¥½å‹æ•°æ®è¿˜æ²¡è¡¥æ‹‰åˆ°ï¼›ç©ºç™½ä¼šè®©ç”¨æˆ·ä»¥ä¸ºæŠ½å±‰åäº†ï¼Œç»™ä¸ªåŠ è½½å ä½ -->
    <div
      v-if="!friend"
      class="flex flex-col items-center justify-center h-full text-13px text-[var(--ant-color-text-placeholder)] bg-[var(--ant-color-bg-container)]"
    >
      <Spin tip="加载中..." />
    </div>
    <div v-else class="flex flex-col h-full bg-[var(--ant-color-bg-container)]">
      <div class="flex-1 overflow-y-auto bg-[var(--ant-color-fill-secondary)]">
        <!-- å¥½å‹å®«æ ¼ï¼šåŽŸ tile + "+" tile,对齐 GroupSide è§†è§‰ï¼Œè®©ä¸¤ç§æŠ½å±‰çœ‹èµ·æ¥æ˜¯ä¸€å®¶çš„ -->
        <div class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--ant-color-bg-container)]">
          <div class="flex flex-col items-center w-[66px]">
            <UserAvatar
              :id="friend.friendUserId"
              :url="friend.avatar"
              :name="friend.nickname"
              :size="50"
            />
            <div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">
              {{ displayName }}
            </div>
          </div>
          <!-- + tile:点击调起 GroupCreateDialog,把对方 id ä½œä¸º lockedIds ä¼ å…¥ -->
          <div
            class="im-conversation-private-side__tile-wrap-clickable flex flex-col items-center w-[66px] cursor-pointer"
            title="发起群聊"
            @click="handleOpenCreateGroup"
          >
            <div class="im-conversation-private-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200">
              <Icon icon="ant-design:plus-outlined" />
            </div>
            <div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">添加</div>
          </div>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- å¤‡æ³¨ï¼ˆä»…自己可见):点击弹 popover ç¼–辑,保存后立即刷新本抽屉 + ä¼šè¯åˆ—表展示名 -->
        <div class="bg-[var(--ant-color-bg-container)]">
          <Popover
            v-model:open="displayNamePopoverVisible"
            trigger="click"
            placement="leftTop"
            :overlay-style="{ width: '280px' }"
          >
            <div
              class="im-conversation-private-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            >
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">备注</span>
              <span
                v-if="friend.displayName"
                class="text-13px leading-[1.6] text-[var(--ant-color-text)] break-all line-clamp-2"
              >
                {{ friend.displayName }}
              </span>
              <span v-else class="text-13px leading-[1.6] text-[var(--ant-color-text-placeholder)]">
                å¥½å‹å¤‡æ³¨ä»…自己可见
              </span>
            </div>
            <template #content>
              <div class="flex flex-col gap-2">
                <Input
                  v-model:value="editDisplayName"
                  :maxlength="16"
                  show-count
                  placeholder="请输入备注名"
                />
                <div class="flex justify-end gap-2">
                  <Button size="small" @click="displayNamePopoverVisible = false">取消</Button>
                  <Button size="small" type="primary" @click="handleSaveDisplayName">
                    ä¿å­˜
                  </Button>
                </div>
              </div>
            </template>
          </Popover>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- æŸ¥æ‰¾èŠå¤©å†…容 -->
        <div class="bg-[var(--ant-color-bg-container)]">
          <div
            class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            @click="emit('openHistory')"
          >
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">查找聊天内容</span>
            <Icon
              icon="ant-design:right-outlined"
              :size="11"
              class="text-[var(--ant-color-text-placeholder)]"
            />
          </div>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- å¼€å…³é¡¹ -->
        <div class="bg-[var(--ant-color-bg-container)]">
          <div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">消息免打扰</span>
            <Switch :checked="!!conversation?.silent" @change="handleMutedChange" />
          </div>
          <div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">置顶聊天</span>
            <Switch :checked="!!conversation?.top" @change="handleTopChange" />
          </div>
        </div>
      </div>
    </div>
    <!-- å­å¯¹è¯æ¡†ï¼šå‘起群聊(锁定对方为已选) -->
    <GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
  </Drawer>
</template>
<style scoped>
/* ã€Œ+」 tile: hover æ—¶è”动内部 icon-tile èµ°ä¸»è‰²ï¼› è·¨å­å…ƒç´ çš„ hover è”动无法用单元素工具类表达 */
.im-conversation-private-side__tile-wrap-clickable:hover .im-conversation-private-side__icon-tile {
  color: var(--ant-color-primary);
  border-color: var(--ant-color-primary);
  background-color: var(--ant-color-primary-bg);
}
/* :deep ç©¿é€ Icon å†…部 svg; el-icon å…¨å±€ color åœ¨æš—色模式下被主题盖过,锁 fill åˆ°å½“前色 */
.im-conversation-private-side__icon-tile :deep(svg) {
  fill: currentColor !important;
}
/* ç›¸é‚»ä¿¡æ¯è¡ŒåŠ åˆ†éš”çº¿ï¼› ç›¸é‚»å…„弟选择器无法用工具类表达 */
.im-conversation-private-side__row + .im-conversation-private-side__row {
  border-top: 1px solid var(--im-border-color-lighter);
}
</style>
<!-- åŒ GroupSide:antd Drawer ä¼ é€åˆ° body åŽ scoped data-v è½ä¸åˆ° body ä¸Šï¼Œé  root-class-name ä½œç¥–先选择器写全局规则压掉默认 padding -->
<style>
.im-conversation-private-side__modal .ant-drawer-body {
  padding: 0;
}
</style>
src/views/im/home/pages/conversation/components/conversation/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
export { default as ConversationGroupSide } from './conversation-group-side.vue';
export { default as ConversationItem } from './conversation-item.vue';
export { default as ConversationPrivateSide } from './conversation-private-side.vue';
src/views/im/home/pages/conversation/components/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,3 @@
export * from './conversation';
export * from './input';
export * from './message';
src/views/im/home/pages/conversation/components/input/face-picker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,353 @@
<script lang="ts" setup>
import type { ImFacePackApi } from '#/api/im/face/pack'
import type { ImFaceUserItemApi } from '#/api/im/face/userItem'
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { message, Tooltip } from 'ant-design-vue'
import { uploadFile } from '#/api/infra/file'
import { useFaceStore } from '#/views/im/home/store/faceStore'
import { IM_EMOJI_LIST } from '#/views/im/utils/emoji'
import { probeImageSize } from '#/views/im/utils/image'
defineOptions({ name: 'ImFacePicker' })
const props = withDefaults(
  defineProps<{
    /** full:emoji + ä¸ªäººè¡¨æƒ… + ç³»ç»ŸåŒ…(聊天主输入用);emoji:仅 emoji(留言 / è¯„论场景) */
    mode?: FacePickerMode
    visible: boolean
  }>(),
  { mode: 'full' }
)
const emit = defineEmits<{
  /** é€‰ä¸­ Unicode emoji(如 ðŸ˜€ï¼‰ï¼Œè°ƒç”¨æ–¹åº”插入到输入框走 TEXT é€šé“ */
  selectEmoji: [emoji: string]
  /** é€‰ä¸­è¡¨æƒ…贴图,调用方应走 FACE æ¶ˆæ¯å‘送 */
  selectFace: [face: { height: number; name?: string; url: string; width: number; }]
  'update:visible': [value: boolean]
}>()
/** é¢æ¿æ¨¡å¼ */
type FacePickerMode = 'emoji' | 'full'
const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef')
const faceStore = useFaceStore()
// tab æ ‡è¯†å¸¸é‡ï¼›pack:N ç±»ç”¨ packTabKey() æ‹¼å‡ºï¼Œé¿å…æ•£è½å­—符串字面量
const FACE_TAB = {
  EMOJI: 'emoji',
  MINE: 'mine'
} as const
const packTabKey = (packId: number) => `pack:${packId}`
const activeTab = ref<string>(FACE_TAB.EMOJI) // å½“前激活的 tab
/** æ˜¯å¦å®Œæ•´æ¨¡å¼ï¼ˆå«ä¸ªäºº / ç³»ç»ŸåŒ… tab) */
const isFullMode = computed(() => props.mode === 'full')
const uploading = ref(false) // ä¸Šä¼ ä¸­æ ‡è®°ï¼Œé¿å…è¿žç»­ç‚¹å‡»è§¦å‘并发上传
/** é€‰ emoji å­—符:插到输入框;选完不关面板,方便用户连发多个 */
function handleSelectEmoji(emoji: string) {
  emit('selectEmoji', emoji)
}
/** é€‰ä¸ªäººè¡¨æƒ…:直接发;点完关面板,对齐微信 */
function handleSelectFaceUserItem(item: ImFaceUserItemApi.FaceUserItem) {
  emit('selectFace', { url: item.url, width: item.width, height: item.height, name: item.name })
  emit('update:visible', false)
}
/** é€‰ç³»ç»Ÿè¡¨æƒ…包内表情:直接发;点完关面板 */
function handleSelectPackItem(item: ImFacePackApi.FacePackUserItem) {
  emit('selectFace', { url: item.url, width: item.width, height: item.height, name: item.name })
  emit('update:visible', false)
}
/** é•¿æŒ‰ / å³é”®åˆ é™¤ä¸ªäººè¡¨æƒ… */
async function handleDeleteUserItem(item: ImFaceUserItemApi.FaceUserItem) {
  if (!confirm('确认删除该表情?')) {
    return
  }
  await faceStore.removeFaceUserItem(item.id)
}
/** ç‚¹ + è§¦å‘文件选择 */
function onUploadClick() {
  uploadInputRef.value?.click()
}
/** æ–‡ä»¶é€‰å®Œå³ä¸Šä¼ ï¼ŒæˆåŠŸåŽå†™å…¥ faceStore ä¸ªäººè¡¨æƒ…列表 */
async function onUploadPicked(e: Event) {
  const input = e.target as HTMLInputElement
  const file = input.files?.[0]
  input.value = ''
  if (!file) {
    return
  }
  uploading.value = true
  let size: { height: number; width: number; }
  try {
    size = await probeImageSize(file)
  } catch (error) {
    console.warn('[IM] è§£æžä¸ªäººè¡¨æƒ…失败', error)
    message.error('图片解析失败')
    uploading.value = false
    return
  }
  try {
    const url = await uploadFile({ file })
    if (!url) {
      message.error('上传失败')
      return
    }
    const payload = { url, width: size.width, height: size.height }
    await faceStore.addFaceUserItem(payload)
  } finally {
    uploading.value = false
  }
}
/** é¢æ¿å±•开时拉数据 + æŒ‚全局 click;mode='emoji' æ—¶ä¸æ‹‰ç³»ç»ŸåŒ… / ä¸ªäººè¡¨æƒ… */
watch(
  () => props.visible,
  (visible) => {
    if (visible) {
      document.addEventListener('click', handleDocumentClick)
      if (isFullMode.value) {
        // ç³»ç»ŸåŒ…通常已被 home onMounted é¢„拉过;ensureXxx å†…部 promise ç¼“存避免重复请求
        void faceStore.ensureFacePackList()
        void faceStore.ensureFaceUserItemList()
      }
    } else {
      document.removeEventListener('click', handleDocumentClick)
    }
  },
  { immediate: true }
)
/** ç‚¹å‡»é¢æ¿å¤–部关闭 */
function handleDocumentClick(e: MouseEvent) {
  if (!props.visible || !rootRef.value) {
    return
  }
  if (!rootRef.value.contains(e.target as Node)) {
    emit('update:visible', false)
  }
}
onUnmounted(() => {
  document.removeEventListener('click', handleDocumentClick)
})
</script>
<template>
  <!--
    è¡¨æƒ…面板(多 tab):emoji / ä¸ªäººè¡¨æƒ… / N ä¸ªç³»ç»Ÿè¡¨æƒ…包
    - å¯¹é½å¾®ä¿¡ PC:底部 tab æ åˆ‡æ¢é¢æ¿å†…容;emoji ä¿æŒ Unicode(仍由 TEXT é€šé“发送)
    - ä¸ªäººè¡¨æƒ… / ç³»ç»Ÿè¡¨æƒ…èµ° FACE å†…容类型,通过 selectFace äº‹ä»¶ç”±è°ƒç”¨æ–¹èµ° sendRaw å‘送
    - mode='emoji' æ—¶åªæ˜¾ç¤º emoji tab + éšè—åº•部 tab æ ï¼Œç»™ç•™è¨€ / è¯„论这类只发文本的场景用
    - å®šä½ç”±è°ƒç”¨æ–¹å†³å®šï¼ˆé€šå¸¸æµ®åœ¨è¡¨æƒ…按钮上方)
  -->
  <div
    v-if="visible"
    ref="rootRef"
    class="im-popover-arrow absolute z-100 flex flex-col w-[380px] rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
    @click.stop
  >
    <!-- ä¸»å†…容区:高度固定 + å„ tab ç”¨ v-show åˆ‡æ¢ï¼Œé¿å…æ¯æ¬¡åˆ‡ tab é‡å»º scrollbar é€ æˆæ»šåŠ¨ä½ç½®ä¸¢å¤± -->
    <div class="relative h-[300px] overflow-hidden">
      <!-- emoji ç½‘æ ¼ -->
      <div v-show="activeTab === FACE_TAB.EMOJI" style="height: 300px; overflow-y: auto">
        <div class="grid grid-cols-10 gap-0.5 p-2">
          <button
            v-for="emoji in IM_EMOJI_LIST"
            :key="emoji"
            class="p-1 text-xl leading-none bg-transparent border-none rounded cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
            type="button"
            @click="handleSelectEmoji(emoji)"
          >
            {{ emoji }}
          </button>
        </div>
      </div>
      <!-- ä¸ªäººè¡¨æƒ…:5 åˆ—方格,无名字标签;末尾「+」上传 -->
      <div v-if="isFullMode" v-show="activeTab === FACE_TAB.MINE" style="height: 300px; overflow-y: auto">
        <div class="grid grid-cols-5 gap-2 p-3">
          <!-- ä¸Šä¼ å…¥å£å›ºå®šæ”¾ç¬¬ä¸€æ ¼ï¼›dashed border ä¸Žè¡¨æƒ…格子区分视觉语义,对齐 el-upload è§‚感 -->
          <button
            class="aspect-square flex items-center justify-center rounded-md cursor-pointer transition-colors border border-dashed border-[var(--ant-color-border)] bg-transparent text-[var(--ant-color-text-placeholder)] hover:border-[var(--ant-color-primary)] hover:text-[var(--ant-color-primary)] disabled:cursor-not-allowed disabled:text-[var(--ant-color-text-disabled)]"
            type="button"
            :disabled="uploading"
            :title="uploading ? '上传中…' : '上传图片到个人表情'"
            @click="onUploadClick"
          >
            <Icon
              :icon="uploading ? 'eos-icons:bubble-loading' : 'ant-design:plus-outlined'"
              :size="22"
            />
          </button>
          <div
            v-for="item in faceStore.faceUserItems"
            :key="item.id"
            class="im-face-grid-cell group relative aspect-square flex items-center justify-center rounded-md bg-[var(--ant-color-fill-tertiary)] cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
            :title="item.name || '点击发送 / å³é”®åˆ é™¤'"
            @click="handleSelectFaceUserItem(item)"
            @contextmenu.prevent="handleDeleteUserItem(item)"
          >
            <img
              :src="item.url"
              :alt="item.name || '表情'"
              class="max-w-full max-h-full object-contain"
              draggable="false"
            />
          </div>
          <div
            v-if="faceStore.faceUserItems.length === 0"
            class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--ant-color-text-placeholder)]"
          >
            è¿˜æ²¡æœ‰ä¸ªäººè¡¨æƒ…,点 + ä¸Šä¼  / åœ¨èŠå¤©é‡Œé•¿æŒ‰æ¶ˆæ¯æ·»åŠ 
          </div>
        </div>
      </div>
      <!-- ç³»ç»Ÿè¡¨æƒ…包:5 åˆ—方格 + ä¸‹æ–¹è¡¨æƒ…名(无 name æ—¶ä¸æ˜¾ç¤ºæ ‡ç­¾ï¼Œä¿æŒé«˜åº¦ä¸€è‡´ï¼‰ -->
      <template v-if="isFullMode">
        <div
          v-for="pack in faceStore.facePacks"
          v-show="activeTab === packTabKey(pack.id)"
          :key="pack.id"
          style="height: 300px; overflow-y: auto"
        >
          <div class="grid grid-cols-5 gap-2 p-3">
            <div
              v-for="item in pack.items"
              :key="item.id"
              class="im-face-grid-cell flex flex-col items-center gap-1 cursor-pointer rounded-md p-1 transition-colors hover:bg-[var(--ant-color-fill)]"
              :title="item.name || ''"
              @click="handleSelectPackItem(item)"
            >
              <div
                class="aspect-square w-full flex items-center justify-center bg-[var(--ant-color-fill-tertiary)] rounded"
              >
                <img
                  :src="item.url"
                  :alt="item.name || '表情'"
                  class="max-w-full max-h-full object-contain"
                  draggable="false"
                />
              </div>
              <div
                class="h-4 text-xs text-[var(--ant-color-text-secondary)] truncate w-full text-center leading-4"
              >
                {{ item.name || '' }}
              </div>
            </div>
            <div
              v-if="pack.items.length === 0"
              class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--ant-color-text-placeholder)]"
            >
              è¯¥è¡¨æƒ…包暂无表情
            </div>
          </div>
        </div>
      </template>
    </div>
    <!-- åº•部 tab æ ï¼š[ emoji / ä¸ªäºº / ç³»ç»ŸåŒ… 1..N ];mode='emoji' æ—¶éšè— -->
    <div
      v-if="isFullMode"
      class="flex flex-shrink-0 items-center gap-1 px-2 py-1.5 border-t border-t-solid border-[var(--ant-color-border-secondary)]"
    >
      <Tooltip title="Emoji è¡¨æƒ…" placement="top" :mouse-enter-delay="300">
        <button
          class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
          :class="
            activeTab === FACE_TAB.EMOJI
              ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]'
              : 'text-[var(--ant-color-text)]'
          "
          type="button"
          @click="activeTab = FACE_TAB.EMOJI"
        >
          <Icon icon="ant-design:smile-outlined" :size="18" />
        </button>
      </Tooltip>
      <Tooltip title="个人表情" placement="top" :mouse-enter-delay="300">
        <button
          class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
          :class="
            activeTab === FACE_TAB.MINE
              ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]'
              : 'text-[var(--ant-color-text)]'
          "
          type="button"
          @click="activeTab = FACE_TAB.MINE"
        >
          <Icon icon="ant-design:heart-outlined" :size="18" />
        </button>
      </Tooltip>
      <Tooltip
        v-for="pack in faceStore.facePacks"
        :key="pack.id"
        :title="pack.name"
        placement="top"
        :mouse-enter-delay="300"
      >
        <button
          class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
          :class="
            activeTab === packTabKey(pack.id)
              ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]'
              : 'text-[var(--ant-color-text)]'
          "
          type="button"
          @click="activeTab = packTabKey(pack.id)"
        >
          <img
            v-if="pack.icon"
            :src="pack.icon"
            :alt="pack.name"
            class="w-[18px] h-[18px] object-contain"
            draggable="false"
          />
          <Icon v-else icon="ant-design:appstore-outlined" :size="18" />
        </button>
      </Tooltip>
    </div>
    <!-- éšè—çš„æ–‡ä»¶é€‰æ‹©å™¨ï¼ˆç‚¹ + æ—¶è§¦å‘) -->
    <input
      v-if="isFullMode"
      ref="uploadInputRef"
      type="file"
      accept="image/*"
      hidden
      @change="onUploadPicked"
    />
  </div>
</template>
<style scoped>
/* åº•部小三角:指向触发图标,仿微信 PC æ°”泡指针;left åç§»å¯¹åº”表情按钮(工具栏 1st icon) */
.im-popover-arrow::after {
  content: '';
  position: absolute;
  top: calc(100% - 1px);
  left: 10px;
  border-style: solid;
  border-width: 6px 6px 0 6px;
  border-color: var(--ant-color-bg-container) transparent transparent transparent;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
}
</style>
src/views/im/home/pages/conversation/components/input/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
export { default as FacePicker } from './face-picker.vue';
export { default as MentionPicker } from './mention-picker.vue';
export { default as MessageInput } from './message-input.vue';
export { default as MessageMultiSelectBar } from './message-multi-select-bar.vue';
export { default as VoiceRecorder } from './voice-recorder.vue';
src/views/im/home/pages/conversation/components/input/mention-picker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,207 @@
<script lang="ts" setup>
import { computed, ref, useTemplateRef, watch } from 'vue'
import { CommonStatusEnum } from '#/packages/constants/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '#/views/im/utils/constants'
import { GroupMember, type GroupMemberLite } from '../../../../components/group'
defineOptions({ name: 'ImMentionPicker' })
const props = withDefaults(
  defineProps<{
    canAtAll?: boolean // å½“前用户是否能 @ å…¨å‘˜ï¼ˆç¾¤ä¸» / ç®¡ç†å‘˜ï¼‰ï¼Œçˆ¶ç»„件按角色算好传入
    members: GroupMemberLite[] // å½“前群的成员列表
    // æµ®å±‚位置:x æ¨ªåæ ‡ + top / bottom äºŒé€‰ä¸€ï¼ˆbottom é”šå®šæ—¶ picker ä¸‹æ²¿è´´ @ ä¸Šæ–¹ï¼‰
    position?: { bottom?: number; top?: number; x: number; }
    searchText?: string // @ åŽè¾“入的过滤文本
    visible: boolean // æ˜¯å¦æ˜¾ç¤º
  }>(),
  {
    searchText: '',
    position: () => ({ x: 0, bottom: 0 })
  }
)
const emit = defineEmits<{
  select: [member: GroupMemberLite]
  'update:visible': [value: boolean]
}>()
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef')
const activeIdx = ref(0)
/** å½“前登录用户 id(成员列表过滤掉自己) */
const selfUserId = computed(() => getCurrentUserId())
/**
 * è™šæ‹Ÿ"所有人"项:群主 / ç®¡ç†å‘˜ï¼ˆcanAtAll=true)+ å…³é”®å­—命中"所有人"前缀时存在
 *
 * MessageInput èµ° token data-id æ”¶é›† atUserIds,不依赖文案字符串;
 * è¿™é‡Œçš„ userId / æ–‡æ¡ˆéƒ½ä»Ž im/utils/constants å–,避免散落
 */
const allItem = computed<GroupMemberLite | null>(() => {
  if (!props.canAtAll) {
    return null
  }
  if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) {
    return null
  }
  // @所有人 æ˜¯ä¸ªä¼ªæˆå‘˜ï¼Œnickname ç»™ IM_AT_ALL_NICKNAME è®©å¤´åƒ :name è¡Œä¸ºå¯¹é½æ™®é€šæˆå‘˜
  return {
    userId: IM_AT_ALL_USER_ID,
    showName: IM_AT_ALL_NICKNAME,
    nickname: IM_AT_ALL_NICKNAME
  }
})
/** çœŸæˆå‘˜ï¼šè¿‡æ»¤è‡ªå·± / é€€ç¾¤ / ä¸åŒ¹é…å…³é”®å­—;不截断数量,浮层 max-height + el-scrollbar æ’‘滚动 */
const memberItems = computed<GroupMemberLite[]>(() =>
  props.members.filter(
    (member) =>
      member.userId !== selfUserId.value &&
      member.status !== CommonStatusEnum.DISABLE &&
      !!member.showName &&
      member.showName.startsWith(props.searchText)
  )
)
/** é”®ç›˜å¯¼èˆªä¸Ž pickActive èµ°çš„æ‰å¹³åˆ—表,allItem åœ¨å‰ã€memberItems åœ¨åŽ */
const showMembers = computed<GroupMemberLite[]>(() => {
  return allItem.value ? [allItem.value, ...memberItems.value] : memberItems.value
})
/** å€™é€‰åˆ—表变化(用户输入关键词在过滤)→ é‡ç½®é«˜äº®åˆ°é¦–项 + æ»šå›žé¡¶ */
watch(showMembers, (list) => {
  activeIdx.value = list.length > 0 ? 0 : -1
  scrollToTop()
})
/** æµ®å±‚重新打开 â†’ é‡ç½®é«˜äº® + æ»šå›žé¡¶ï¼ˆé¿å…ä¸Šæ¬¡çš„中间状态残留) */
watch(
  () => props.visible,
  (v) => {
    if (v) {
      activeIdx.value = showMembers.value.length > 0 ? 0 : -1
      scrollToTop()
    }
  }
)
/** el-scrollbar æ²¡æš´éœ² scrollTo,直接拿内部 wrap è°ƒ scrollTop */
function scrollToTop() {
  const scrollWrap = scrollRef.value
  if (scrollWrap) {
    scrollWrap.scrollTop = 0
  }
}
/** é”®ç›˜ä¸Šä¸‹å¯¼èˆªæ—¶æŠŠé«˜äº®é¡¹æ»šåˆ°å¯è§†åŒºï¼šè¶…出底边下推、超出顶边上拉,否则不动 */
function scrollToActive() {
  const scrollWrap = scrollRef.value
  if (!scrollWrap) {
    return
  }
  const itemHeight = 40
  const activeOffsetTop = activeIdx.value * itemHeight
  if (activeOffsetTop + itemHeight > scrollWrap.scrollTop + scrollWrap.clientHeight) {
    scrollWrap.scrollTop = activeOffsetTop + itemHeight - scrollWrap.clientHeight
  } else if (activeOffsetTop < scrollWrap.scrollTop) {
    scrollWrap.scrollTop = activeOffsetTop
  }
}
/** é€‰ä¸­ä¸€é¡¹ï¼šemit ç»™ MessageInput è½ token,同时关掉浮层 */
function handleSelect(member: GroupMemberLite) {
  emit('select', member)
  emit('update:visible', false)
}
// æš´éœ²ç»™çˆ¶ç»„件的键盘导航方法
defineExpose({
  moveUp() {
    if (activeIdx.value > 0) {
      activeIdx.value--
      scrollToActive()
    }
  },
  moveDown() {
    if (activeIdx.value < showMembers.value.length - 1) {
      activeIdx.value++
      scrollToActive()
    }
  },
  pickActive() {
    const member = showMembers.value[activeIdx.value]
    if (activeIdx.value >= 0 && member) {
      handleSelect(member)
    }
  },
  hasCandidates: () => showMembers.value.length > 0
})
</script>
<template>
  <!--
    @ æˆå‘˜é€‰æ‹©æµ®å±‚(对齐微信 PC:所有人在顶 + ç¾¤æˆå‘˜åˆ†ç»„ + åº•部三角指针)
    - çˆ¶ç»„件通过 v-model:visible æŽ§åˆ¶æ˜¾éšï¼ŒsearchText è¿‡æ»¤
    - çˆ¶ç»„件通过 ref è°ƒ moveUp / moveDown / pickActive å®žçŽ°é”®ç›˜ä¸Šä¸‹ + Enter é€‰ä¸­
    - @select å‘射被选中的成员
  -->
  <div
    v-show="visible && showMembers.length > 0"
    class="message-input__mention-picker !fixed z-100 w-50 rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
    :style="{
      left: `${position.x }px`,
      top: position.top != null ? `${position.top }px` : 'auto',
      bottom: position.bottom != null ? `${position.bottom }px` : 'auto'
    }"
  >
    <div ref="scrollRef" style="max-height: 300px; overflow-y: auto">
      <!-- æ‰€æœ‰äººï¼šè™šæ‹Ÿé¡¹ï¼Œä»…群主可选;蓝色方块 + ç¾¤ä½“图标,跟下面的成员头像区分 -->
      <div
        v-if="allItem"
        class="flex items-center gap-2.5 px-[5px] h-10 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
        :class="{ 'bg-[#e1eaf7] dark:bg-[var(--ant-color-primary-bg)]': activeIdx === 0 }"
        @click.stop="handleSelect(allItem)"
      >
        <div
          class="flex items-center justify-center w-[30px] h-[30px] rounded text-white bg-[var(--ant-color-primary)] flex-shrink-0"
        >
          <Icon icon="ep:user-filled" :size="18" />
        </div>
        <span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
          {{ allItem.showName }}
        </span>
      </div>
      <!-- ç¾¤æˆå‘˜ section header:只有"所有人 + çœŸæˆå‘˜"两边都在时才出现,避免单一来源时显得多余 -->
      <div
        v-if="allItem && memberItems.length > 0"
        class="px-2 pt-2 pb-1 text-12px text-[var(--ant-color-text-secondary)]"
      >
        ç¾¤æˆå‘˜
      </div>
      <!-- çœŸæˆå‘˜è¡Œ -->
      <GroupMember
        v-for="(member, idx) in memberItems"
        :key="member.userId"
        :member="member"
        :height="40"
        :active="activeIdx === (allItem ? idx + 1 : idx)"
        :clickable="false"
        @click.stop="handleSelect(member)"
      />
    </div>
    <!-- ä¸‰è§’指针:picker åœ¨ @ ä¸‹æ–¹ï¼ˆposition.top é”šå®šï¼‰â†’ ç®­å¤´æœä¸Šè´´é¡¶ï¼›picker åœ¨ @ ä¸Šæ–¹ï¼ˆposition.bottom é”šå®šï¼‰â†’ ç®­å¤´æœä¸‹è´´åº• -->
    <div
      class="absolute left-4 w-3 h-3 rotate-45 bg-[var(--ant-color-bg-container)]"
      :class="position.top != null ? '-top-1.5' : '-bottom-1.5'"
    ></div>
  </div>
</template>
src/views/im/home/pages/conversation/components/input/message-input.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1216 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group'
import type { Conversation } from '#/views/im/home/types'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { isOpenableUrl } from '#/packages/utils/src'
import { Button, DropdownButton, Menu, message, Tooltip } from 'ant-design-vue'
import { uploadFile } from '#/api/infra/file'
import {
  ensureMediaSizeWithinLimit,
  useMediaUploader
} from '#/views/im/home/composables/useMediaUploader'
import { useMessageSender } from '#/views/im/home/composables/useMessageSender'
import { useMuteOverlay } from '#/views/im/home/composables/useMuteOverlay'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useFriendStore } from '#/views/im/home/store/friendStore'
import { useGroupStore } from '#/views/im/home/store/groupStore'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '#/views/im/utils/config'
import { ImContentType, ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants'
import { getConversationKey } from '#/views/im/utils/conversation'
import {
  type FaceMessage,
  type QuoteMessage,
  serializeMessage,
  withQuotePayload
} from '#/views/im/utils/message'
import { getMemberDisplayName } from '#/views/im/utils/user'
import { ReplyPreview } from '../message'
import FacePicker from './face-picker.vue'
import MentionPicker from './mention-picker.vue'
import VoiceRecorder from './voice-recorder.vue'
defineOptions({ name: 'ImMessageInput' })
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const { send, sendRaw } = useMessageSender()
const {
  uploadAndSendMedia,
  insertMediaPlaceholder,
  markMediaFailed,
  commitMediaPlaceholder,
  createUploadProgressHandler,
  verifyMediaUploadStillAllowed,
  requireMediaHandler
} = useMediaUploader()
const muteOverlay = useMuteOverlay() // ç¦è¨€ / å°ç¦è¦†ç›–层
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const videoInputRef = useTemplateRef<HTMLInputElement>('videoInputRef')
const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef')
// ==================== æ–‡æœ¬ / å‘送 ====================
const canSend = ref(false) // editor æ˜¯å¦æœ‰å¯å‘送内容;contenteditable æ²¡ v-model,靠 input äº‹ä»¶ä¸»åŠ¨åŒæ­¥
/** ç»´æŠ¤ canSend + data-empty(撑起 placeholder);不写草稿,restoreDraftToEditor å¤ç”¨é¿å…å›žæµ */
function applyEditorUiState(editor: HTMLDivElement) {
  const raw = editor.textContent || ''
  // canSend æŒ‰ trim åŽåˆ¤æ–­ï¼ˆç©ºæ ¼ / æ¢è¡Œä¸ç®—可发送内容);禁言态直接禁用
  canSend.value = !!raw.trim() && !!conversationStore.activeConversation && !muteOverlay.value
  // data-empty æŒ‰åŽŸå§‹å†…å®¹åˆ¤æ–­ï¼šç”¨æˆ·æ•²ä¸€ä¸ªç©ºæ ¼ä¹Ÿè¦è®© placeholder éšè—ï¼Œé¿å…è§†è§‰å åŠ 
  // ç”¨å±žæ€§"存在 / ç¼ºå¤±"而非 'true'/'false' å­—符串:CSS [data-empty]::before å‘½ä¸­å³å¯ï¼Œ
  // æ¯” [data-empty='true'] ç›´è§‚;浏览器删空后留 <br> â†’ :empty ä¸å‘½ä¸­ï¼Œæ‰€ä»¥å¿…é¡» JS ç»´æŠ¤
  if (raw) {
    delete editor.dataset.empty
  } else {
    editor.dataset.empty = ''
  }
}
/** ç”¨æˆ·ç¼–辑入口的统一收尾:UI çŠ¶æ€åŒæ­¥ + è‰ç¨¿å†™å›ž store(列表立即出 [草稿] å‰ç¼€ï¼‰ */
function syncEditorState() {
  const editor = editorRef.value
  if (!editor) {
    return
  }
  applyEditorUiState(editor)
  syncDraftToStore(editor)
}
/** ç¦è¨€çŠ¶æ€å˜åŒ–æ—¶åŒæ­¥å‘é€æŒ‰é’® */
watch(muteOverlay, () => {
  const editor = editorRef.value
  if (editor) {
    applyEditorUiState(editor)
  }
})
/** æŠŠ editor å½“前内容写到会话草稿;plain ç”± collectFromEditor æ‹¿ï¼Œä¸Žå‘送时同源避免列表与实发不一致 */
function syncDraftToStore(editor: HTMLDivElement) {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  // collectFromEditor å·² trim,plain ä¸ºç©ºæ—¶ store å†…部按 clearConversationDraft å¤„理
  // reply é€ä¼ å½“前快照:setConversationDraft æ˜¯æ•´å¯¹è±¡æ›¿æ¢ï¼Œä¸è¯»æ—§ reply ä¼šè®©ç”¨æˆ·æ¯æ•²ä¸€ä¸ªé”®å°±æŠŠå¼•用条擦掉
  const { text } = collectFromEditor(editor)
  const existing = conversationStore.getConversationDraft(conversation)
  conversationStore.setConversationDraft(conversation, {
    html: editor.innerHTML,
    plain: text,
    reply: existing?.reply
  })
}
/** åˆ‡ä¼šè¯æ—¶æŠŠ store é‡Œçš„草稿还原到 editor;只更 UI ä¸å›žå†™è‰ç¨¿ï¼Œé¿å… store→editor→store å›žæµ */
function restoreDraftToEditor() {
  const editor = editorRef.value
  if (!editor) {
    return
  }
  const conversation = conversationStore.activeConversation
  const draft = conversation ? conversationStore.getConversationDraft(conversation) : undefined
  editor.innerHTML = draft?.html || ''
  applyEditorUiState(editor)
  // æŠŠå…‰æ ‡ç§»åˆ°æœ«å°¾ï¼Œè®©ç”¨æˆ·æŽ¥ç€è¾“入;空内容直接 focus å³å¯
  if (draft?.html) {
    placeCaretAtEnd(editor)
  }
}
/** æŠŠå…‰æ ‡æ”¾åˆ° contenteditable å…ƒç´ çš„æœ«å°¾â€”—切回有草稿的会话时光标自然落在尾部,对齐微信 */
function placeCaretAtEnd(el: HTMLElement) {
  const range = document.createRange()
  range.selectNodeContents(el)
  range.collapse(false)
  const sel = window.getSelection()
  if (!sel) {
    return
  }
  sel.removeAllRanges()
  sel.addRange(range)
}
/**
 * èµ° DOM æŠŠ editor å†…容拼回 text + atUserIds
 *
 * èŠ‚ç‚¹åˆ†æ”¯ï¼š
 * 1. text èŠ‚ç‚¹ï¼šç›´æŽ¥æ‹¼ textContent;过程中滤掉 ZWSP(token é¦–位锚点用,不进发送内容)
 * 2. br:拼 \n(Shift+Enter èµ° execCommand('insertLineBreak') äº§ç‰©ï¼‰
 * 3. span[data-id]:拼 token æ˜¾ç¤ºæ–‡æœ¬ + æŠŠ dataset.id æ”¶åˆ° atUserIds(不递归 span å†…部)
 * 4. div:浏览器在 contenteditable é‡Œé»˜è®¤æ¢è¡Œå®¹å™¨ï¼Œå‰ç½® \n åŽé€’归子节点
 * 5. å…¶ä»–元素:透传,递归子节点
 *
 * atUserIds èµ° Set åŽ»é‡ï¼šç”¨æˆ· @ å¼ ä¸‰ä¸¤æ¬¡æ—¶ atUserIds åªå‡ºçŽ°ä¸€æ¬¡ï¼›trim æœ«å°¾ç©ºç™½
 */
function collectFromEditor(root: HTMLElement): { atUserIds: number[]; text: string; } {
  const userIds: number[] = []
  let text = ''
  function walk(node: Node) {
    if (node.nodeType === Node.TEXT_NODE) {
      text += (node.textContent || '').replaceAll('​', '')
      return
    }
    if (node.nodeType !== Node.ELEMENT_NODE) {
      return
    }
    const el = node as HTMLElement
    const tag = el.tagName.toLowerCase()
    if (tag === 'br') {
      text += '\n'
      return
    }
    if (tag === 'span' && el.dataset.id) {
      text += el.textContent || ''
      const id = Number(el.dataset.id)
      if (!Number.isNaN(id)) {
        userIds.push(id)
      }
      return
    }
    if (tag === 'div') {
      if (text && !text.endsWith('\n')) {
        text += '\n'
      }
      el.childNodes.forEach((child) => walk(child))
      return
    }
    el.childNodes.forEach((child) => walk(child))
  }
  // ç›´æŽ¥ä»Ž root.childNodes å¼€å§‹ï¼Œé¿å…æŠŠ root æœ¬èº«ä¹Ÿå½“元素处理(虽然目前没有特殊样式,但以防未来改动)
  root.childNodes.forEach((child) => walk(child))
  return {
    text: text.trim(),
    atUserIds: [...new Set(userIds)]
  }
}
/**
 * å‘送:从 DOM æ”¶ text + atUserIds â†’ æ¸…空编辑器 â†’ èµ° useMessageSender.send
 *
 * 1. é˜²å¾¡ï¼šcanSend æ˜¯ false(trim åŽç©º / æ²¡æ¿€æ´»ä¼šè¯ï¼‰æˆ– editor æ²¡ mount â†’ ç›´æŽ¥ return
 * 2. æ”¶é›†ï¼šDOM walk æ‹¿åˆ°è¦å‘送的文本 + atUserIds
 * 3. äºŒæ¬¡é˜²å¾¡ï¼šcollectFromEditor èµ° trim,可能比 syncEditorState æ›´ä¸¥æ ¼ï¼ˆä¾‹å¦‚å…¨ ZWSP),仍空就 return
 * 4. æ¸…空 + åŒæ­¥çŠ¶æ€ï¼šå…ˆæ¸… innerHTML å† syncEditorState è®© placeholder / canSend ä¸€èµ·å›žå½’
 *    ï¼ˆé¡ºåºå¾ˆé‡è¦ï¼šå…ˆæ¸…后 sync,否则 sync çœ‹åˆ°æ—§å†…容会误判)
   * 5. ä¸Šé€ï¼šatUserIds éžç©ºæ‰ä¼ ï¼Œé¿å…å‘空数组;quote ç”± clearConversationDraft å‰æŠ“取,确保引用条立即消失
 */
async function handleSend(options?: { receipt?: boolean }) {
  const editor = editorRef.value
  if (!canSend.value || !editor || muteOverlay.value) {
    return
  }
  const { text, atUserIds } = collectFromEditor(editor)
  if (!text) {
    return
  }
  // 1. æŠ“ quote åŽæ¸…空 editor + å½“前会话草稿(包含 reply);syncEditorState åŽ / reply éƒ½ä¸ºç©º
  //    store å†…部会自动清理,但显式 clearConversationDraft èƒ½ç«‹å³åŒæ­¥ã€ä¸ä¾èµ– debounce æ—¶åºï¼Œåˆ—表上的 [草稿] ç«‹å³æ¶ˆå¤±
  const replyQuote = replyTarget.value
  editor.innerHTML = ''
  if (conversationStore.activeConversation) {
    conversationStore.clearConversationDraft(conversationStore.activeConversation)
  }
  syncEditorState()
  // 2. å‘送
  await send(text, {
    atUserIds: atUserIds.length > 0 ? atUserIds : undefined,
    receipt: options?.receipt,
    quote: replyQuote
  })
}
/** å‘送按钮下拉菜单操作 */
function handleSendMenuClick({ key }: { key: number | string }) {
  if (key === 'receipt') {
    handleSend({ receipt: true })
  }
}
// ==================== é€‰åŒº / æ’å…¥ ====================
/**
 * ä¸Šæ¬¡è½åœ¨ editor å†…çš„ selection(焦点被表情面板等夺走时用来回到原插入点)
 *
 * ç›‘听 document.selectionchange æ¯” editor.@blur æ›´å¯é ï¼šblur æ—¶ selection å·²ç»ç§»èµ°
 */
let savedRange: null | Range = null
/**
 * èµ° native execCommand,保留浏览器原生 undo æ ˆ
 *
 * execCommand åœ¨ lib.dom é‡Œè¢«æ ‡ @deprecated(IDE æ˜¾ç¤ºåˆ é™¤çº¿ï¼‰ï¼Œä½† 'insertText' /
 * 'insertLineBreak' æ²¡æœ‰ç­‰ä»·çš„ W3C æ ‡å‡†æ›¿ä»£â€”—Range/Selection è‡ªå·±æ‹¼ DOM ä¼šè®© Ctrl+Z å¤±æ•ˆã€‚
 * é›†ä¸­åˆ°è¿™é‡Œè°ƒç”¨ï¼Œè°ƒç”¨ç‚¹ä¸å¿…散落 ts-expect-error;不引入 eslint disable(项目当前
 * @typescript-eslint v7 æ²¡æœ‰ no-deprecated è§„则,加了也无效,反而让 lint æŠ¥"规则不存在")
 */
function nativeExec(command: 'insertLineBreak' | 'insertText', value?: string) {
  document.execCommand(command, false, value)
}
/**
 * document selectionchange ç›‘听:把落在 editor å†…çš„ selection ç¼“存到 savedRange,
 * insertText åœ¨ç„¦ç‚¹è¢«å·èµ°åŽç”¨å®ƒæŠŠå…‰æ ‡æ¢å¤åˆ°åŽŸæ’å…¥ç‚¹
 */
function onSelectionChange() {
  const editor = editorRef.value
  const sel = window.getSelection()
  if (!editor || !sel || sel.rangeCount === 0) {
    return
  }
  const range = sel.getRangeAt(0)
  if (editor.contains(range.startContainer)) {
    savedRange = range.cloneRange()
  }
}
/**
 * ç‚¹å‡» editor / picker å¤–部时关掉浮层,避免输入 @keyword åŽç”¨æˆ·ç‚¹åˆ«å¤„浮层不消失
 *
 * ç”¨ mousedown è€Œéž click:click åœ¨æŸäº›æµè§ˆå™¨é‡Œ picker å…ƒç´ æ¶ˆå¤±åŽå›žä¸åˆ°åŽŸ target,会被吞掉
 */
function onDocMousedown(e: MouseEvent) {
  if (!mentionVisible.value) {
    return
  }
  const target = e.target as Node | null
  if (!target) {
    return
  }
  if (editorRef.value?.contains(target)) {
    return
  }
  // picker æ˜¯ fixed å®šä½çš„兄弟节点,不在 editor å­æ ‘里;用类名定位
  const pickerEl = document.querySelector('.message-input__mention-picker')
  if (pickerEl?.contains(target)) {
    return
  }
  closeMention()
}
onMounted(() => {
  document.addEventListener('selectionchange', onSelectionChange)
  document.addEventListener('mousedown', onDocMousedown)
  restoreDraftToEditor()
})
onBeforeUnmount(() => {
  document.removeEventListener('selectionchange', onSelectionChange)
  document.removeEventListener('mousedown', onDocMousedown)
})
/**
 * åˆ‡ä¼šè¯æ—¶è¿˜åŽŸå¯¹æ–¹çš„è‰ç¨¿åˆ° editor
 *
 * åŒæ­¥å…³ @ / è¡¨æƒ… / è¯­éŸ³æµ®å±‚并清 savedRange:
 * - mentionRange / savedRange æ—§å¼•用还指向上一会话的 DOM èŠ‚ç‚¹ï¼Œä¸æ¸…ä¸‹æ¬¡æ’ token ä¼šè½é”™ä½ç½®
 * - è¯­éŸ³å½•制弹窗保留时,录完触发的 onVoiceSend ä¼šè¯»å½“前 activeConversation,把语音发到新会话
 */
watch(
  () =>
    conversationStore.activeConversation
      ? getConversationKey(conversationStore.activeConversation)
      : null,
  () => {
    closeMention()
    emojiVisible.value = false
    voiceVisible.value = false
    savedRange = null
    restoreDraftToEditor()
  }
)
/**
 * æŠŠå­—符串插入光标处(emoji é¢æ¿ç­‰åœºæ™¯è°ƒç”¨ï¼‰
 *
 * 1. editor æ²¡æŒ‚直接返回
 * 2. ç„¦ç‚¹å›žåˆ° editor + æŠŠ savedRange æ¢å¤æˆå½“前 selection(emoji é¢æ¿å·ç„¦ç‚¹åŽè¿˜èƒ½å›žåŽŸä½ï¼‰
 * 3. nativeExec æ’文本,保留浏览器原生 undo æ ˆ
 * 4. åŒæ­¥ canSend / placeholder
 */
function insertText(str: string) {
  const editor = editorRef.value
  if (!editor) {
    return
  }
  editor.focus()
  if (savedRange) {
    const sel = window.getSelection()
    if (sel) {
      sel.removeAllRanges()
      sel.addRange(savedRange)
    }
  }
  // 1. nativeExec æ’文本,保留浏览器原生 undo æ ˆ
  nativeExec('insertText', str)
  // 2. åŒæ­¥ canSend / placeholder
  syncEditorState()
}
/**
 * ç²˜è´´å¤„理
 *
 * 1. ä¼˜å…ˆæ‰« clipboardData.items æ‰¾æ–‡ä»¶ç±»åž‹æ¡ç›®ï¼ˆæˆªå›¾ã€æ‹–入的图片 / æ–‡ä»¶ç­‰ï¼‰
 *    - image/* â†’ èµ° IMAGE ä¸Šä¼ å‘送
 *    - å…¶å®ƒ file â†’ èµ° FILE ä¸Šä¼ å‘送
 *    - ä¸€æ¬¡ç²˜è´´åªå¤„理第一个文件,避免一次粘贴发出多条消息
 * 2. æ²¡æ–‡ä»¶å†èµ° text:剥掉外部样式 / è„šæœ¬ï¼Œé¿å…å¤–ç«™ inline style æ±¡æŸ“ editor
 *    ï¼ˆcontenteditable é»˜è®¤ç²˜è´´ä¼šå¸¦ HTML,所以模板上 @paste.prevent æ‹¦æˆªï¼‰
 */
function onPaste(e: ClipboardEvent) {
  // 1. ä¼˜å…ˆæ‰« clipboardData.items æ‰¾æ–‡ä»¶ç±»åž‹æ¡ç›®ï¼ˆæˆªå›¾ã€æ‹–入的图片 / æ–‡ä»¶ç­‰ï¼‰
  const items = e.clipboardData?.items
  if (items?.length) {
    for (const item of items) {
      if (!item) {
        continue
      }
      if (item.kind !== 'file') {
        continue
      }
      const file = item.getAsFile()
      if (!file) {
        continue
      }
      if (item.type.startsWith('image/')) {
        void uploadAndSendImage(file)
      } else {
        void uploadAndSendFile(file)
      }
      return
    }
  }
  // 2. æ²¡æ–‡ä»¶å†èµ° text:剥掉外部样式 / è„šæœ¬ï¼Œé¿å…å¤–ç«™ inline style æ±¡æŸ“ editor
  const text = e.clipboardData?.getData('text/plain') || ''
  if (text) {
    nativeExec('insertText', text)
    // @paste.prevent é˜»æ–­äº†æµè§ˆå™¨é»˜è®¤ input äº‹ä»¶ï¼Œéœ€æ‰‹åŠ¨åŒæ­¥è‰ç¨¿ / canSend,与 insertText() è·¯å¾„一致
    syncEditorState()
  }
}
/** ç¼–辑器内容变化的统一入口:先同步 canSend / placeholder,再判 @ æµ®å±‚是否要展开 */
function onInput() {
  syncEditorState()
  detectAtMention()
}
// ==================== å¼•用 / å›žå¤ ====================
/** å½“前会话的「正在回复」对象,从会话草稿派生 */
const replyTarget = computed<QuoteMessage | undefined>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return undefined
  }
  return conversationStore.getConversationDraft(conversation)?.reply
})
/** æ¸…掉当前 reply ä½†ä¿ç•™æ­£æ–‡è‰ç¨¿ï¼šç‚¹ Ã— å…³é—­ / å‘送即将进行时调 */
function clearConversationReplyDraft() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  conversationStore.clearConversationReplyDraft(conversation)
}
/** å–走当前 reply å¿«ç…§ï¼ˆæŠ“一次清一次),媒体上传链路在动手前统一调它拿 quote */
function consumeReply(): QuoteMessage | undefined {
  const quote = replyTarget.value
  if (quote) {
    clearConversationReplyDraft()
  }
  return quote
}
// ==================== è¡¨æƒ… ====================
const emojiVisible = ref(false)
/** åˆ‡æ¢è¡¨æƒ…面板;打开时互斥关掉语音面板 */
function toggleEmoji() {
  emojiVisible.value = !emojiVisible.value
  if (emojiVisible.value) {
    voiceVisible.value = false
  }
}
/** é€‰ä¸­è¡¨æƒ…贴图:拼 FaceMessage payload ç›´æŽ¥èµ° sendRaw å‘送(quote å¤ç”¨å½“前 reply å¿«ç…§ï¼‰ */
async function onSelectFace(face: { height: number; name?: string; url: string; width: number; }) {
  if (muteOverlay.value) {
    return
  }
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  const replyQuote = consumeReply()
  const payload = withQuotePayload<FaceMessage>(
    { url: face.url, width: face.width, height: face.height, name: face.name },
    replyQuote
  )
  await sendRaw(ImContentType.FACE, serializeMessage(payload), { conversation })
}
// ==================== @ æˆå‘˜é€‰æ‹©ï¼ˆç¾¤èŠï¼‰ ====================
const isGroup = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
/** ä»Ž groupStore è¯»å½“前激活群的成员(切会话时由 MessagePanel é¢„拉) */
const groupMembers = computed<GroupMemberLite[]>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return []
  }
  const group = groupStore.getGroup(conversation.targetId)
  return (group?.members || []).map((member) => {
    const friend = friendStore.getFriend(member.userId)
    return {
      userId: member.userId,
      showName: getMemberDisplayName(member, friend),
      nickname: member.nickname,
      avatar: member.avatar,
      status: member.status
    }
  })
})
/** å½“前用户是否能 @ å…¨å‘˜ï¼›ç¾¤ä¸» + ç®¡ç†å‘˜éƒ½å…è®¸ï¼ˆå¯¹é½å¾®ä¿¡ PC:admin ä¹Ÿèƒ½ @ æ‰€æœ‰äººï¼‰ */
const canAtAll = computed<boolean>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return false
  }
  const group = groupStore.getGroup(conversation.targetId)
  if (!group) {
    return false
  }
  const myId = getCurrentUserId()
  if (!myId) {
    return false
  }
  if (group.ownerUserId === myId) {
    return true
  }
  return group.members?.find((member) => member.userId === myId)?.role === ImGroupMemberRole.ADMIN
})
const mentionVisible = ref(false)
const mentionSearchText = ref('')
/** æµ®å±‚定位:x æ˜¯å·¦è¾¹è·ï¼›top / bottom äºŒé€‰ä¸€â€”— bottom é”šå®šï¼ˆpicker ä¸‹æ²¿è´´ @)是默认,
 *  ä¸Šæ–¹æ”¾ä¸ä¸‹æ—¶é€€åŒ–为 top é”šå®šï¼ˆpicker ä¸Šæ²¿è´´ @ ä¸‹æ–¹ï¼‰ */
const mentionPosition = ref<{ bottom?: number; top?: number; x: number; }>({ x: 0, bottom: 0 })
/** MentionPicker çš„容器宽度(与组件里的 w-50 å¯¹é½ï¼‰ï¼Œç”¨äºŽè§†å£å³æ²¿å›žå¼¹ï¼›
 *  é«˜åº¦ä¸å†ç”¨å¸¸é‡ç®—位置——bottom é”šå®šåŽ picker å†…容多寡都不影响下沿位置,自然贴 @ */
const MENTION_WIDTH = 200
const MENTION_MIN_FIT_ABOVE = 120 // ä¸Šæ–¹å‰©ä½™ç©ºé—´è‡³å°‘这么多才放上方,否则翻到下方(避免 picker è¢«è§†å£é¡¶ / é¡¶éƒ¨ chat header åˆ‡æŽ‰ï¼‰
/** å½“前 @ å…³é”®è¯åœ¨ editor é‡Œçš„范围;onMentionSelect ç”¨å®ƒå®šä½åˆ é™¤ + æ’å…¥ token */
let mentionRange: null | Range = null
/** å…³é—­æµ®å±‚ + æ¸…掉 range,避免上次残留的 range è¢«ä¸‹ä¸€æ¬¡ onMentionSelect è¯¯ç”¨ */
function closeMention() {
  mentionVisible.value = false
  mentionRange = null
}
/** åœ¨å…‰æ ‡å½“前文本节点里向前找 @keyword,命中则展开浮层 */
function detectAtMention() {
  if (!isGroup.value) {
    closeMention()
    return
  }
  const sel = window.getSelection()
  if (!sel || sel.rangeCount === 0) {
    closeMention()
    return
  }
  const range = sel.getRangeAt(0)
  if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) {
    closeMention()
    return
  }
  const node = range.startContainer
  const offset = range.startOffset
  const before = (node.textContent || '').slice(0, offset)
  // ç›´æŽ¥æ‰¾æœ€è¿‘çš„ @:不限制前置字符,对齐微信"中文紧贴 @ ä¹Ÿèƒ½è”想"(你好@张三、,@张三 éƒ½è§¦å‘);
  // ä»£ä»·æ˜¯ email-like "test@example.com" ä¹Ÿä¼šè§¦å‘,但聊天输入里粘 email çš„æ¦‚率低,
  // ä¸”用户按 Esc å³å¯å…³æµ®å±‚;兜底由 [^\s@] ä¿è¯ä¸€æ—¦è¾“入空格 / ç¬¬äºŒä¸ª @ å°±åœä¸‹
  const match = before.match(/@([^\s@]*)$/)
  if (!match) {
    closeMention()
    return
  }
  const keyword = match[1] ?? ''
  const atOffset = offset - keyword.length - 1
  mentionRange = document.createRange()
  mentionRange.setStart(node, atOffset)
  mentionRange.setEnd(node, offset)
  mentionSearchText.value = keyword
  // é”šå®šåœ¨ @ ç¬¦å·æœ¬èº«ï¼Œè€Œéžå½“前 caret——否则用户每多敲一个字浮层就跟着右移("飘")
  positionMention(node, atOffset)
  mentionVisible.value = true
}
/**
 * æµ®å±‚位置:默认 bottom é”šå®šï¼ˆpicker ä¸‹æ²¿è´´ @ ä¸Šæ–¹ 8px),上方不够才翻成 top é”šå®š
 *
 * 1. è®¡ç®— @ å­—符屏幕坐标 rect
 * 2. æ¨ªå‘:picker å·¦è¾¹å¯¹é½ @,越过视口右沿则左推;至少留 8px ç•™ç™½
 * 3. çºµå‘:上方剩余 â‰¥ MENTION_MIN_FIT_ABOVE èµ° bottom é”šå®šï¼ˆä¸ä¾èµ– picker å®žé™…高度,
 *    æ— è®º 1 é¡¹è¿˜æ˜¯ N é¡¹ picker ä¸‹æ²¿éƒ½è´´ @);不够则翻到 @ ä¸‹æ–¹èµ° top é”šå®š
 */
function positionMention(node: Node, atOffset: number) {
  // 1. è®¡ç®— @ å­—符屏幕坐标 rect
  const anchor = document.createRange()
  anchor.setStart(node, atOffset)
  anchor.collapse(true)
  const rect = anchor.getBoundingClientRect()
  // 2. æ¨ªå‘:picker å·¦è¾¹å¯¹é½ @,越过视口右沿则左推;至少留 8px ç•™ç™½
  const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
  // 3. çºµå‘:上方剩余 â‰¥ MENTION_MIN_FIT_ABOVE èµ° bottom é”šå®š
  mentionPosition.value = rect.top >= MENTION_MIN_FIT_ABOVE ? { x: left, bottom: window.innerHeight - rect.top + 8 } : { x: left, top: rect.bottom + 8 };
}
/** editor å†…部滚动时同步浮层位置(多行 + è§¦å‘滚动条场景) */
function onEditorScroll() {
  if (!mentionVisible.value || !mentionRange) {
    return
  }
  positionMention(mentionRange.startContainer, mentionRange.startOffset)
}
function onMentionSelect(member: GroupMemberLite) {
  const editor = editorRef.value
  if (!editor || !mentionRange) {
    return
  }
  // åˆ  @keyword,插入 contenteditable=false çš„ token:
  // åˆ é™¤æ—¶æ•´æ®µæ¶ˆé™¤ + ä¸ä¼šè¢«å…‰æ ‡æ‹†ç©¿ï¼›data-id æ˜¯åŽç»­ collectFromEditor æ”¶ atUserIds çš„钩子
  // token æ–‡æœ¬å›ºå®šèµ°çœŸå®žæ˜µç§°ï¼šç¾¤é‡Œæ‰€æœ‰æˆå‘˜çœ‹åˆ°çš„字面量一致,避免我侧的好友备注 / ç¾¤æ˜µç§°æ±¡æŸ“发送文本
  mentionRange.deleteContents()
  const span = document.createElement('span')
  span.className = 'mention-token'
  span.dataset.id = String(member.userId)
  span.contentEditable = 'false'
  span.textContent = `@${member.nickname || member.showName}`
  mentionRange.insertNode(span)
  // token åœ¨ editor é¦–位时,contenteditable=false è¾¹ç¼˜ä¼šè®©å…‰æ ‡æ— æ³•挪到 token å‰
  // è¡¥ä¸€ä¸ªé›¶å®½ç©ºæ ¼å½“锚点;DOM walk æ—¶ä¼šè¢«æ»¤æŽ‰ï¼Œä¸è¿›å…¥å‘送内容
  const prev = span.previousSibling
  if (!prev || (prev.nodeType === Node.TEXT_NODE && !prev.textContent)) {
    span.parentNode?.insertBefore(document.createTextNode('\u200B'), span)
  }
  // åœ¨ token åŽè¡¥ä¸€ä¸ª NBSP,让光标可以继续输入;NBSP æ¯”普通空格更稳,避免被浏览器折叠
  const space = document.createTextNode('\u00A0')
  span.parentNode?.insertBefore(space, span.nextSibling)
  // å…‰æ ‡ç§»åˆ° NBSP ä¹‹åŽ
  const sel = window.getSelection()
  if (sel) {
    const newRange = document.createRange()
    newRange.setStartAfter(space)
    newRange.collapse(true)
    sel.removeAllRanges()
    sel.addRange(newRange)
  }
  closeMention()
  editor.focus()
  syncEditorState()
}
/**
 * é”®ç›˜äº‹ä»¶åˆ†å‘
 *
 * 1. mention æµ®å±‚打开时
 *    1.1 â†‘/↓ ç§»åŠ¨é«˜äº®
 *    1.2 Enter æœ‰å€™é€‰ â†’ é€‰ä¸­ï¼›æ— å€™é€‰ fall through åˆ°ä¸‹é¢çš„发送分支,避免按 Enter æ²¡ååº”
 *    1.3 Esc å…³æµ®å±‚
 * 2. Shift+Enter æ¢è¡Œï¼šå¼ºåˆ¶èµ° br(浏览器默认会插 div,DOM walk æ‹¼æŽ¥æ›´å¤æ‚)
 * 3. æ™®é€š Enter å‘送(IME composition æœŸé—´ä¸è§¦å‘,避免选词被误发)
 */
function onKeydown(e: KeyboardEvent) {
  // 1. mention æµ®å±‚打开时
  if (mentionVisible.value) {
    if (e.key === 'ArrowUp') {
      e.preventDefault()
      mentionRef.value?.moveUp()
      return
    }
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      mentionRef.value?.moveDown()
      return
    }
    if (e.key === 'Enter' && !e.isComposing && mentionRef.value?.hasCandidates()) {
        e.preventDefault()
        mentionRef.value?.pickActive()
        return
      }
    if (e.key === 'Escape') {
      closeMention()
      return
    }
  }
  // 2. Shift+Enter æ¢è¡Œï¼šå¼ºåˆ¶èµ° br(浏览器默认会插 div,DOM walk æ‹¼æŽ¥æ›´å¤æ‚)
  if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
    e.preventDefault()
    nativeExec('insertLineBreak')
    syncEditorState()
    return
  }
  // 3. æ™®é€š Enter å‘送(IME composition æœŸé—´ä¸è§¦å‘,避免选词被误发)
  if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
    e.preventDefault()
    handleSend()
  }
}
// ==================== å›¾ç‰‡ / æ–‡ä»¶ / è¯­éŸ³ ä¸Šä¼  ====================
/** ä¸Šä¼ å‰çš„统一拦截:禁言态 / æ— æ¿€æ´»ä¼šè¯ç›´æŽ¥æ”¾å¼ƒï¼›è¿”回当前 conversation ä¸ŽæŠ“èµ°çš„ quote */
function prepareMediaUpload(): undefined | { conversation: Conversation; quote?: QuoteMessage } {
  if (muteOverlay.value) {
    return undefined
  }
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return undefined
  }
  return { conversation, quote: consumeReply() }
}
/** ä¸Šä¼ å¹¶å‘送 IMAGE æ¶ˆæ¯ï¼ˆå ä½ + è¿›åº¦ + çœŸå®ž url ack ç”± useMediaUploader å¤„理) */
async function uploadAndSendImage(file: File) {
  const context = prepareMediaUpload()
  if (!context) {
    return
  }
  await uploadAndSendMedia({
    file,
    type: ImContentType.IMAGE,
    quote: context.quote,
    conversation: context.conversation
  })
}
/** ä¸Šä¼ å¹¶å‘送 FILE æ¶ˆæ¯ï¼›payload ç”± mediaTypeHandlers[FILE] è‡ªåŠ¨æ‹¼ url + name + size */
async function uploadAndSendFile(file: File) {
  // æ–‡ä»¶åå–最后一个 "." ä¹‹åŽçš„部分;无后缀 / ç©ºå­—符串都按"未知"放行
  const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
  if (extension && DANGEROUS_FILE_EXTENSIONS.includes(extension)) {
    message.warning(`不允许发送 .${extension} ç±»åž‹æ–‡ä»¶`)
    return
  }
  const context = prepareMediaUpload()
  if (!context) {
    return
  }
  await uploadAndSendMedia({
    file,
    type: ImContentType.FILE,
    quote: context.quote,
    conversation: context.conversation
  })
}
/** å›¾ç‰‡é€‰å®Œå³ä¸Šä¼  + å‘送 IMAGE æ¶ˆæ¯ï¼ˆä¸æ”¾å…¥ editor,由 useMediaUploader æŽ¥ç®¡å ä½ / è¿›åº¦ / ack) */
async function onImagePicked(e: Event) {
  const input = e.target as HTMLInputElement
  const file = input.files?.[0]
  input.value = ''
  if (file) {
    await uploadAndSendImage(file)
  }
}
/** æ–‡ä»¶é€‰å®Œå³ä¸Šä¼  + å‘送 FILE æ¶ˆæ¯ï¼ˆæºå¸¦åŽŸå§‹ name / size å…ƒæ•°æ®ï¼‰ */
async function onFilePicked(e: Event) {
  const input = e.target as HTMLInputElement
  const file = input.files?.[0]
  input.value = ''
  if (file) {
    await uploadAndSendFile(file)
  }
}
// ==================== è¯­éŸ³ ====================
const voiceVisible = ref(false)
/** æ‰“开语音录制面板;互斥关掉表情面板 */
function openVoice() {
  voiceVisible.value = true
  emojiVisible.value = false
}
/** å‘送录音消息 */
async function onVoiceSend(payload: {
  blob: Blob
  duration: number
  extension: string
  mimeType: string
}) {
  const context = prepareMediaUpload()
  if (!context) {
    return
  }
  const file = new File([payload.blob], `voice-${Date.now()}.${payload.extension}`, {
    type: payload.mimeType || payload.blob.type
  })
  await uploadAndSendMedia({
    file,
    type: ImContentType.VOICE,
    quote: context.quote,
    conversation: context.conversation,
    context: { voiceDuration: payload.duration }
  })
}
// ==================== è§†é¢‘ ====================
type VideoProbe = {
  cover?: Blob
  duration?: number
  height?: number
  width?: number
}
const VIDEO_COVER_MAX_DIM = 720 // å°é¢æœ€é•¿è¾¹ cap:聊天列表里的视频封面没必要原视频分辨率,4K åŽŸå°ºå¯¸ jpeg 1-3MB å¤ªæµªè´¹
/**
 * åŠ è½½è§†é¢‘æœ¬åœ°é¢„è§ˆï¼Œä¸€æ¬¡æ€§æ‹¿åˆ° metadata(duration / å®½é«˜ï¼‰+ é¦–帧封面 blob
 *
 * ä¸€ä¸ª video å…ƒç´ ä¸²ä¸¤ä»¶äº‹æ˜¯ä¸ºäº†é¿å…é‡å¤ decode:metadata è§£å®ŒåŽç›´æŽ¥ seek é¦–帧再截图。
 * æˆªå›¾å¤±è´¥ä¸æŠ›å¼‚常,只让 cover ç¼ºå¤±ï¼Œä¿è¯ä¸»æµç¨‹ä»èƒ½ä¸Šä¼ è§†é¢‘本体。
 *
 * finally é‡Œæ˜¾å¼æ–­å¼•用是因为:仅 revokeObjectURL ä¸è¶³ä»¥è®© video decoder ç«‹å³é‡Šæ”¾ï¼Œ
 * éƒ¨åˆ†æµè§ˆå™¨ç‰ˆæœ¬ä¸Š 4K è§†é¢‘解码 buffer å¯æ»žç•™æ•°å MB å‡ ç§’到十几秒,连发几条会累计放大。
 */
async function probeVideoFile(file: File): Promise<VideoProbe> {
  // 1. å‡†å¤‡ç¦»å± video
  // 1.1 muted + preload=metadata:只下载文件头,不预加载整条流
  const objectUrl = URL.createObjectURL(file)
  const video = document.createElement('video')
  video.preload = 'metadata'
  video.muted = true
  video.src = objectUrl
  try {
    // 1.2 ç­‰ metadata åŠ è½½ï¼šè§£å‡º duration / å®½é«˜æ‰æœ‰ seek + æˆªå›¾çš„依据
    await new Promise<void>((resolve, reject) => {
      video.addEventListener('loadedmetadata', () => resolve(), { once: true })
      video.addEventListener('error', () => reject(new Error('video metadata load error')), {
        once: true
      })
    })
    // 1.3 æŠ½å…ƒä¿¡æ¯ï¼šduration å¶æœ‰ NaN(极少数损坏文件),软处理为 undefined
    const meta = {
      duration: Number.isFinite(video.duration) ? Math.round(video.duration) : undefined,
      width: video.videoWidth || undefined,
      height: video.videoHeight || undefined
    }
    // 2. æˆªé¦–帧封面(独立 try:失败仅降级 cover ä¸ºç©ºï¼Œä¸å½±å“ meta)
    let cover: Blob | undefined
    try {
      // 2.1 ç®— seek æ—¶é—´ï¼š0.1s é¿å¼€å¸¸è§çš„纯黑首帧;时长 < 0.2s çš„æžçŸ­è§†é¢‘退化为 0
      const seekTo = video.duration > 0.2 ? 0.1 : 0
      // 2.2 seek + 3s è¶…时:currentTime è®¾ä¸ºå½“前值(譬如已经是 0 çš„æžçŸ­è§†é¢‘)
      //     éƒ¨åˆ†æµè§ˆå™¨ä¸è§¦å‘ onseeked,promise ä¼šä¸€ç›´ pending å¡æ­»æ•´æ¡é“¾è·¯
      await new Promise<void>((resolve, reject) => {
        const timer = setTimeout(() => reject(new Error('video seek timeout')), 3000)
        video.addEventListener('seeked', () => {
          clearTimeout(timer)
          resolve()
        }, { once: true })
        video.addEventListener(
          'error',
          () => {
            clearTimeout(timer)
            reject(new Error('video seek error'))
          },
          { once: true }
        )
        video.currentTime = seekTo
      })
      // 2.3 ç¦»å± canvas ç­‰æ¯”缩放:长边 cap 720(VIDEO_COVER_MAX_DIM)
      const canvas = document.createElement('canvas')
      const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight))
      canvas.width = Math.round(video.videoWidth * ratio)
      canvas.height = Math.round(video.videoHeight * ratio)
      const context = canvas.getContext('2d')
      if (context && canvas.width && canvas.height) {
        // 2.4 å½“前帧绘到 canvas â†’ toBlob æ‹¿ jpeg;0.8 è´¨é‡æ˜¯èŠå¤©å°é¢å¸¸ç”¨ç”œç‚¹
        context.drawImage(video, 0, 0, canvas.width, canvas.height)
        cover =
          (await new Promise<Blob | null>((resolve) =>
            canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
          )) ?? undefined
        // 2.5 æå‰é‡Šæ”¾ canvas backing store(4K åŽŸå°ºå¯¸ 33MB),别等 GC
        canvas.width = 0
        canvas.height = 0
      }
    } catch (error) {
      console.warn('[IM] è§†é¢‘封面截取失败', error)
    }
    return { ...meta, cover }
  } finally {
    // 3. æ˜¾å¼é‡Šæ”¾ video èµ„源
    // 3.1 revoke æœ¬åœ° objectUrl
    URL.revokeObjectURL(objectUrl)
    // 3.2 è§¦å‘ unload,让 decoder buffer ç«‹å³é‡Šæ”¾
    video.removeAttribute('src')
    video.load()
  }
}
/**
 * ä¸Šä¼ å¹¶å‘送 VIDEO æ¶ˆæ¯
 *
 * 1. ç«‹å³å ä½ï¼šblob URL æ—¢å½“ video src ä¹Ÿå½“ poster,浏览器会拿首帧绘制 cover;status=SENDING + è¿›åº¦æ¡
 * 2. probe ä¸Žè§†é¢‘上传同步起跑;封面上传等 probe å‡º cover åŽä¸Žè§†é¢‘上传竞速
 *    ï¼ˆprobe è§£ç  + å°é¢ä¸Šä¼ é€šå¸¸è¢«è§†é¢‘上传时长完全遮蔽,体感节省几百 ms èµ·æ­¥ï¼‰
 * 3. è§†é¢‘本体上传必须成功,拿不到 url å°±æŠŠå ä½ç½® FAILED;封面是锦上添花,失败仅日志
 * 4. è§†é¢‘链路耗时长,上传期间用户切会话则放弃发送(避免落到错误会话里);切走再切回来不算变化(key ä»ç›¸ç­‰ï¼‰
 */
async function uploadAndSendVideo(file: File) {
  if (!ensureMediaSizeWithinLimit(file, ImContentType.VIDEO, message.warning)) {
    return
  }
  const context = prepareMediaUpload()
  if (!context) {
    return
  }
  const { conversation } = context
  const replyQuote = context.quote
  const startKey = getConversationKey(conversation)
  // 1. ç«‹å³å ä½ï¼šurl èµ° blob è®© <video src> æ‹‰é¦–字节渲染;coverUrl ä¸è®¾ blob
  //    ï¼ˆ<video poster> æœŸå¾…图片资源,传 video blob åœ¨éƒ¨åˆ†æµè§ˆå™¨ä¼šé€€åŒ–成黑底,不是稳定行为)
  //    cover ç­‰ probe å¼‚步出真实 URL åŽç”± commit é˜¶æ®µä¸€èµ· patch;_localFile ç•™ file ä¾›å¤±è´¥é‡è¯•
  //    payload æ‹¼è£…èµ° mediaTypeHandlers[VIDEO].build ä¸Ž commit é˜¶æ®µå…±äº«åŒä¸€ä»½é€»è¾‘
  const videoHandler = requireMediaHandler(ImContentType.VIDEO)
  const buildPlaceholderContent = (blobUrl: string): string =>
    serializeMessage(withQuotePayload(videoHandler.build(file, blobUrl, {}), replyQuote))
  const { clientMessageId } = insertMediaPlaceholder({
    file,
    type: ImContentType.VIDEO,
    conversation,
    buildContent: buildPlaceholderContent
  })
  // 2. ä¸‰è·¯å¹¶è¡Œèµ·è·‘(probe ä¸Žä¸¤æ¡ä¸Šä¼ æ— ä¾èµ–,封面上传等 probe å‡º cover åŽç«‹å³æŽ¥åŠ›ï¼‰
  // 2.1 è§†é¢‘本体上传:async IIFE åŒ…一层让 await æ˜¾å¼å¯è§ï¼ˆlint ä¸å†è¯¯åˆ¤ floating promise),
  //     å¤±è´¥å…œåº•为 url=undefined,由 step 3 æ‹¿ä¸åˆ° url æ—¶æ”¶å°¾
  const videoUploadPromise: Promise<string | undefined> = (async () => {
    try {
      return await uploadFile(
        { file },
        createUploadProgressHandler(conversation, clientMessageId)
      )
    } catch (error) {
      console.warn('[IM] è§†é¢‘本体上传失败', error)
      return undefined
    }
  })()
  // 2.2 probe æ‹¿å…ƒä¿¡æ¯ + å°é¢ blob:解码失败降级为空 probe,不阻断视频上传
  const probePromise = probeVideoFile(file).catch((error): VideoProbe => {
    console.warn('[IM] è§†é¢‘元信息加载失败,降级为仅 url + size', error)
    return {}
  })
  // 2.3 å°é¢ä¸Šä¼ ï¼šç­‰ probe.cover å‡ºæ¥åŽæŽ¥åŠ›èµ·è·‘ï¼Œä¸Žè§†é¢‘ä¸Šä¼ ç«žé€Ÿï¼›å¤±è´¥é™çº§ coverUrl ä¸ºç©º
  const coverUploadPromise = probePromise.then(async (probe) => {
    if (!probe.cover) {
      return { probe, coverUrl: undefined as string | undefined }
    }
    try {
      const coverUrl = await uploadFile({
        file: new File([probe.cover], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' })
      })
      return { probe, coverUrl }
    } catch (error) {
      console.warn('[IM] è§†é¢‘封面上传失败', error)
      return { probe, coverUrl: undefined as string | undefined }
    }
  })
  // 3. æ”¶å£æ ¡éªŒ
  // 3.1 ç­‰ä¸¤æ¡ä¸Šä¼ é“¾è·¯æ±‡åˆ
  const [videoRes, { probe, coverUrl }] = await Promise.all([
    videoUploadPromise,
    coverUploadPromise
  ])
  // 3.2 è§†é¢‘本体没 url:占位置 FAILED,让用户决定重试 / åˆ é™¤ï¼ˆ_localFile åœ¨å†…存里)
  const url = videoRes
  if (!url) {
    markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
    return
  }
  if (!isOpenableUrl(url)) {
    console.warn('[IM] è§†é¢‘上传返回了不支持打开的 URL', { url })
    message.warning('上传返回的视频地址不支持打开')
    markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
    return
  }
  const safeCoverUrl = coverUrl && isOpenableUrl(coverUrl) ? coverUrl : undefined
  if (coverUrl && !safeCoverUrl) {
    console.warn('[IM] è§†é¢‘封面上传返回了不支持打开的 URL', { coverUrl })
  }
  // 3.3 ä¸Šä¼ åŽä¼šè¯æ ¡éªŒ + muteOverlay å¤æŸ¥ï¼ˆä¸Ž useMediaUploader.uploadAndSendMedia åŒä¸€é“)
  if (
    !verifyMediaUploadStillAllowed(conversation, startKey, ImContentType.VIDEO, clientMessageId)
  ) {
    return
  }
  // 4. æ‹¼çœŸå®ž VideoMessage payload,patch è¿›å ä½ + èµ° sendRaw å¤ç”¨å ä½å‘送
  const realContent = serializeMessage(
    withQuotePayload(
      videoHandler.build(file, url, {
        videoProbe: { duration: probe.duration, width: probe.width, height: probe.height },
        videoCoverUrl: safeCoverUrl
      }),
      replyQuote
    )
  )
  await commitMediaPlaceholder({
    type: ImContentType.VIDEO,
    conversation,
    clientMessageId,
    realContent
  })
}
/** è§†é¢‘选完即上传 + å‘送 VIDEO æ¶ˆæ¯ï¼ˆä¸æ”¾å…¥ editor,独立链路:probe + åŒä¸Šä¼ ï¼Œæœ€ç»ˆèµ° commitMediaPlaceholder æ”¶å°¾ï¼‰ */
async function onVideoPicked(e: Event) {
  const input = e.target as HTMLInputElement
  const file = input.files?.[0]
  input.value = ''
  if (file) {
    await uploadAndSendVideo(file)
  }
}
</script>
<template>
  <!--
    å¤–层底色与消息流(bg-color-page)保持一致,让"消息 â†’ è¾“å…¥"无色差过渡;
    padding ç»™å†…层白卡片呼吸空间,卡片自带边框就够区分输入区,不再需要一条 border-t
  -->
  <div class="relative bg-[var(--ant-color-bg-layout)] px-3 pt-2 pb-3">
    <!-- ç¦è¨€ / å°ç¦è¦†ç›–层:优先级 å°ç¦ > å…¨ç¾¤ç¦è¨€ > æˆå‘˜ç¦è¨€ -->
    <div
      v-if="muteOverlay"
      class="message-input__mute-overlay absolute top-2 right-3 bottom-3 left-3 z-20 flex items-center justify-center gap-2 rounded-lg border border-solid text-sm"
      :class="
        muteOverlay.icon === 'ant-design:stop-outlined'
          ? 'message-input__mute-overlay--error'
          : 'message-input__mute-overlay--warning'
      "
    >
      <Icon :icon="muteOverlay.icon" :size="18" />
      <span>{{ muteOverlay.text }}</span>
    </div>
    <!-- å†…层白色圆角卡片 = editor + å·¥å…·æ ï¼›border + rounded æ¨¡æ‹Ÿå¾®ä¿¡ã€Œè¾“入框」边界 -->
    <div
      class="relative flex flex-col bg-[var(--ant-color-bg-container)] rounded-lg border border-solid border-[var(--im-border-color-lighter)]"
    >
      <!--
        è¾“入区在上:contenteditable div(取代 textarea,对齐微信 PC:输入区在上,操作在下)
        - è®© @ æµ®å±‚能拿到真实光标 rect(textarea æ‹¿ä¸åˆ°ï¼‰
        - è®© @ æˆå‘˜ä»¥ <span data-id> token èŠ‚ç‚¹å­˜åœ¨ï¼Œåˆ  token å³åˆ  id,避免 stale atUserIds
        - placeholder é€šè¿‡ data-empty + ::before æ¨¡æ‹Ÿï¼ˆcontenteditable æ²¡æœ‰åŽŸç”Ÿ placeholder)
      -->
      <div
        ref="editorRef"
        class="message-input__editor relative min-h-[120px] max-h-[200px] overflow-y-auto py-3.5 px-4 text-sm leading-normal outline-none whitespace-pre-wrap break-words"
        contenteditable="true"
        data-placeholder="按 Enter å‘送,Shift+Enter æ¢è¡Œ"
        data-empty=""
        role="textbox"
        @keydown="onKeydown"
        @input="onInput"
        @scroll.passive="onEditorScroll"
        @paste.prevent="onPaste"
      ></div>
      <!-- å¼•用预览条 -->
      <ReplyPreview
        v-if="replyTarget"
        :quote="replyTarget"
        closable
        class="mx-3 mb-1.5"
        @close="clearConversationReplyDraft"
      />
      <!--
        åº•部工具栏:左侧操作图标 + å³ä¾§å‘送按钮(对齐微信 PC:操作图标统一放底部)
        - relative ç»™ FacePicker æä¾› absolute é”šç‚¹ï¼Œpicker ç”¨ bottom-full å‘上弹出
        - å›¾æ ‡ç»Ÿä¸€ 30×30 ç‚¹å‡»åŒºï¼ˆ18px icon + p-1.5),gap-1 è®©é—´è·è´´åˆå¾®ä¿¡è§‚感
        - border-t åœ¨ç¼–辑区与工具栏之间画一条与 card è¾¹æ¡†åŒè‰²çš„细线
      -->
      <div
        class="relative flex items-center justify-between gap-2 px-3 py-2 border-t border-t-solid border-[var(--im-border-color-lighter)]"
      >
        <div class="flex items-center gap-1">
          <!--
            æ‰€æœ‰ icon ç»Ÿä¸€èµ° Iconify(ant-design outlined ç³»åˆ—):
            - è§†è§‰é£Žæ ¼æ›´æŽ¥è¿‘微信 PC(线性、圆角,比 Element Plus å†…置的更轻量)
            - ç¬‘脸 / å›¾ç‰‡ / æ–‡ä»¶å¤¹ / éº¦å…‹é£Ž åŒæºï¼Œé¿å…ä¸€ä¸ªèµ° ep ä¸€ä¸ªèµ° antd è§†è§‰å‰²è£‚
            - å¤–层 span å¤ç”¨ .message-input__tool çš„ padding / hover æ ·å¼ï¼Œscoped CSS çš„ :deep(svg) ä»èƒ½å‘½ä¸­
          -->
          <Tooltip title="表情" placement="top">
            <span
              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
              @click.stop="toggleEmoji"
            >
              <Icon icon="ant-design:smile-outlined" :size="18" />
            </span>
          </Tooltip>
          <Tooltip title="发送图片" placement="top">
            <span
              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
              @click="imageInputRef?.click()"
            >
              <Icon icon="ant-design:picture-outlined" :size="18" />
            </span>
          </Tooltip>
          <Tooltip title="发送文件" placement="top">
            <span
              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
              @click="fileInputRef?.click()"
            >
              <Icon icon="ant-design:folder-outlined" :size="18" />
            </span>
          </Tooltip>
          <Tooltip title="语音消息" placement="top">
            <span
              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
              @click.stop="openVoice"
            >
              <Icon icon="ant-design:audio-outlined" :size="18" />
            </span>
          </Tooltip>
          <Tooltip title="发送视频" placement="top">
            <span
              class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]"
              @click="videoInputRef?.click()"
            >
              <Icon icon="ant-design:video-camera-outlined" :size="18" />
            </span>
          </Tooltip>
        </div>
        <!-- ç¾¤èŠ + ç¾¤å·²è¯»å¼€å¯ï¼šå‘送按钮 + â–¼ ä¸‹æ‹‰èœå•(点主按钮普通发送 / ç‚¹ â–¼ é€‰ã€Œå‘送回执消息」),对齐微信 PC -->
        <!-- æ³¨æ„ï¼šant-design-vue çš„分裂按钮是 DropdownButton(非 Dropdown çš„ split-button å±žæ€§ï¼Œé‚£æ˜¯ Element Plus å†™æ³•) -->
        <DropdownButton
          v-if="isGroup && MESSAGE_GROUP_READ_ENABLED"
          type="primary"
          :disabled="!canSend"
          @click="handleSend()"
        >
          å‘ é€
          <template #overlay>
            <Menu @click="handleSendMenuClick">
              <Menu.Item key="receipt">发送回执消息</Menu.Item>
            </Menu>
          </template>
        </DropdownButton>
        <!-- ç§èŠæˆ–群已读关闭:普通发送按钮(无群回执入口) -->
        <Button v-else type="primary" :disabled="!canSend" @click="handleSend()">
          å‘ é€
        </Button>
        <!-- è¡¨æƒ…面板:bottom-full è®© picker ä¸‹æ²¿è´´å·¥å…·æ é¡¶éƒ¨ï¼Œå‘上弹出(对齐工具栏左侧首图标) -->
        <FacePicker
          v-model:visible="emojiVisible"
          class="bottom-full left-3 mb-2"
          @select-emoji="insertText"
          @select-face="onSelectFace"
        />
        <!-- è¯­éŸ³å½•制面板:与表情面板同处工具栏,bottom-full å‘上弹出,避免离触发的麦克风图标过远 -->
        <VoiceRecorder v-model="voiceVisible" class="bottom-full left-3 mb-2" @send="onVoiceSend" />
      </div>
    </div>
    <!-- @ é€‰æ‹©æµ®å±‚:群聊才启用 -->
    <MentionPicker
      ref="mentionRef"
      v-model:visible="mentionVisible"
      :position="mentionPosition"
      :members="groupMembers"
      :search-text="mentionSearchText"
      :can-at-all="canAtAll"
      @select="onMentionSelect"
    />
    <!-- éšè—çš„æ–‡ä»¶é€‰æ‹©å™¨ -->
    <input ref="imageInputRef" type="file" accept="image/*" hidden @change="onImagePicked" />
    <input ref="fileInputRef" type="file" hidden @change="onFilePicked" />
    <input ref="videoInputRef" type="file" accept="video/*" hidden @change="onVideoPicked" />
  </div>
</template>
<style scoped>
/* el-icon å…¨å±€è§„则 .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em} ä¼˜å…ˆçº§æ›´é«˜ï¼Œ
   ç”¨å­—面选择器 + !important é”æ­»ï¼›é¢œè‰²å– Element Plus ä¸»é¢˜å˜é‡ï¼Œæš—色自动切到浅灰 */
.message-input__tool,
.message-input__tool:deep(svg) {
  font-size: 18px !important;
  color: var(--ant-color-text) !important;
  fill: currentColor !important;
}
.message-input__tool:hover,
.message-input__tool:hover:deep(svg) {
  color: var(--ant-color-primary) !important;
}
.message-input__mute-overlay--warning {
  color: #d48806;
  background: #fff7e6;
  border-color: #ffd591;
}
.message-input__mute-overlay--error {
  color: #cf1322;
  background: #fff1f0;
  border-color: #ffa39e;
}
/* æ³¨æ„ï¼šå¿…须把 .dark ä¸Žç›®æ ‡ç±»æ•´ä½“放进 :global(),否则 Vue scoped ç¼–译会把
   `:global(.dark) .xxx` é”™è¯¯å¡Œç¼©æˆè£¸ `.dark`,导致这些颜色被直接刷到 <html> ä¸Š
   ï¼ˆæš—色下整页发红/发暗黄)。BEM ç±»åå”¯ä¸€ï¼Œæ•´ä½“ global ä¸ä¼šè¯¯ä¼¤å…¶å®ƒå…ƒç´ ã€‚ */
:global(.dark .message-input__mute-overlay--warning) {
  color: #ffd666;
  background: rgb(77 56 21 / 88%);
  border-color: #ad6800;
}
:global(.dark .message-input__mute-overlay--error) {
  color: #ff7875;
  background: rgb(91 33 33 / 88%);
  border-color: #a8071a;
}
/* ç”¨ data-empty è€Œéž :empty:浏览器在删空后会留下 <br>,:empty ä¸å‘½ä¸­ï¼›data-empty ç”± syncEditorState ç»´æŠ¤ */
.message-input__editor[data-empty]::before {
  content: attr(data-placeholder);
  color: var(--ant-color-text-placeholder);
  pointer-events: none;
  position: absolute;
}
/* @ token èµ°ä¸»è‰²é«˜äº®ï¼›contenteditable=false è®© backspace æ•´æ®µåˆ è€Œä¸æ˜¯é€å­—符 */
.message-input__editor :deep(.mention-token) {
  color: var(--ant-color-primary);
}
</style>
src/views/im/home/pages/conversation/components/input/message-multi-select-bar.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,153 @@
<script lang="ts" setup>
import type { Message } from '#/views/im/home/types'
import { computed, inject } from 'vue'
import { confirm } from '#/packages/effects/common-ui/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useMessageStore } from '#/views/im/home/store/messageStore'
import { ImForwardMode, isNormalMessage } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys'
defineOptions({ name: 'ImMessageMultiSelectBar' })
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY)
const multiSelect = useMessageMultiSelect()
/** é€‰ä¸­æ¡æ•° */
const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length)
/** å½“前会话内已选消息 */
function getSelectedMessages(): Message[] {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return []
  }
  const ids = multiSelect.selectedIdSet.value
  return messageStore
    .getMessages(getClientConversationId(conversation.type, conversation.targetId))
    .filter((message) => ids.has(message.clientMessageId) && isNormalMessage(message.type))
}
/** é€æ¡è½¬å‘:开 ForwardDialog å•条模式 */
function handleForwardOneByOne() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  const messages = getSelectedMessages()
  if (messages.length === 0) {
    return
  }
  openForwardDialog?.({
    mode: ImForwardMode.SINGLE,
    messages,
    sourceConversation: conversation
  })
}
/** åˆå¹¶è½¬å‘:开 ForwardDialog åˆå¹¶æ¨¡å¼ */
function handleForwardMerged() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  const messages = getSelectedMessages()
  if (messages.length === 0) {
    return
  }
  openForwardDialog?.({
    mode: ImForwardMode.MERGE,
    messages,
    sourceConversation: conversation
  })
}
/** åˆ é™¤ï¼šå¼¹ç¡®è®¤æ¡† â†’ æœ¬åœ°æ‰¹é‡ç§»é™¤ï¼ˆä¸åŒæ­¥åŽç«¯ï¼Œå¯¹é½å¾®ä¿¡ã€Œåˆ é™¤ã€è¯­ä¹‰ï¼‰ */
async function handleDelete() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  const messages = getSelectedMessages()
  if (messages.length === 0) {
    return
  }
  try {
    await confirm(`确认删除选中的 ${messages.length} æ¡æ¶ˆæ¯ï¼Ÿ`, {
      cancelText: '取消',
      confirmText: '确定',
      icon: 'warning',
      title: '删除确认'
    })
  } catch {
    return
  }
  for (const m of messages) {
    messageStore.removeMessage(conversation.type, conversation.targetId, {
      id: m.id,
      clientMessageId: m.clientMessageId
    })
  }
  multiSelect.exit()
}
/** å–消多选 */
function handleCancel() {
  multiSelect.exit()
}
</script>
<template>
  <div
    v-if="multiSelect.state.active"
    class="flex items-center justify-center gap-12 px-5 w-full h-full border-t border-t-solid bg-[var(--ant-color-bg-container)] border-[var(--im-border-color-lighter)]"
  >
    <span
      class="absolute left-5 top-1/2 -translate-y-1/2 text-12px text-[var(--ant-color-text-secondary)]"
    >
      å·²é€‰ {{ selectedCount }} æ¡
    </span>
    <button
      class="inline-flex flex-col items-center gap-1 px-3 py-1 text-12px rounded-md border-0 bg-transparent cursor-pointer transition-colors text-[var(--ant-color-text)] hover:text-[var(--ant-color-primary)] hover:bg-[var(--ant-color-fill)] disabled:text-[var(--ant-color-text-disabled)] disabled:cursor-not-allowed disabled:bg-transparent"
      :disabled="selectedCount === 0"
      @click="handleForwardOneByOne"
    >
      <Icon icon="ant-design:share-alt-outlined" :size="22" />
      <span>逐条转发</span>
    </button>
    <button
      class="inline-flex flex-col items-center gap-1 px-3 py-1 text-12px rounded-md border-0 bg-transparent cursor-pointer transition-colors text-[var(--ant-color-text)] hover:text-[var(--ant-color-primary)] hover:bg-[var(--ant-color-fill)] disabled:text-[var(--ant-color-text-disabled)] disabled:cursor-not-allowed disabled:bg-transparent"
      :disabled="selectedCount === 0"
      @click="handleForwardMerged"
    >
      <Icon icon="tabler:arrow-merge" :size="22" />
      <span>合并转发</span>
    </button>
    <button
      class="inline-flex flex-col items-center gap-1 px-3 py-1 text-12px rounded-md border-0 bg-transparent cursor-pointer transition-colors text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill)] hover:text-[var(--ant-color-error)] disabled:text-[var(--ant-color-text-disabled)] disabled:cursor-not-allowed disabled:bg-transparent"
      :disabled="selectedCount === 0"
      @click="handleDelete"
    >
      <Icon icon="ant-design:delete-outlined" :size="22" />
      <span>删除</span>
    </button>
    <button
      class="absolute right-5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-7 h-7 rounded-full border-0 bg-transparent cursor-pointer transition-colors text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill)]"
      @click="handleCancel"
    >
      <Icon icon="ant-design:close-outlined" :size="20" />
    </button>
  </div>
</template>
src/views/im/home/pages/conversation/components/input/voice-recorder.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,340 @@
<script lang="ts" setup>
import {
  computed,
  onBeforeUnmount,
  onMounted,
  onUnmounted,
  ref,
  useTemplateRef,
  watch
} from 'vue'
import { Button, message } from 'ant-design-vue'
import { formatSeconds } from '#/views/im/utils/time'
defineOptions({ name: 'ImVoiceRecorder' })
const props = withDefaults(
  defineProps<{
    maxDuration?: number // æœ€é•¿å½•制秒数
    modelValue: boolean // æ˜¯å¦æ˜¾ç¤º
  }>(),
  {
    maxDuration: 60
  }
)
const emit = defineEmits<{
  send: [payload: { blob: Blob; duration: number; extension: string; mimeType: string }] // å½•制完成数据
  'update:modelValue': [value: boolean]
}>()
const VOICE_MIME_TYPE_OPTIONS = [
  { extension: 'webm', mimeType: 'audio/webm;codecs=opus' },
  { extension: 'webm', mimeType: 'audio/webm' },
  { extension: 'm4a', mimeType: 'audio/mp4' },
  { extension: 'ogg', mimeType: 'audio/ogg;codecs=opus' },
  { extension: 'ogg', mimeType: 'audio/ogg' }
]
const visible = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v)
})
const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
/** å½•制状态 */
type Status = 'idle' | 'preview' | 'recording'
const status = ref<Status>('idle')
const duration = ref(0)
const previewUrl = ref('')
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let mediaStream: MediaStream | null = null
let timer: null | ReturnType<typeof setInterval> = null
let recordedBlob: Blob | null = null
let discarding = false
let recordingMimeType = ''
let recordingExtension = ''
let recordedMimeType = ''
let recordedExtension = ''
/** è®¡æ—¶å™¨æ–‡æ¡ˆ */
const timerText = computed(() => formatSeconds(duration.value))
/** ç›‘听面板显示状态 */
watch(visible, (v) => {
  if (v) {
    document.addEventListener('click', handleDocumentClick)
  } else {
    document.removeEventListener('click', handleDocumentClick)
    resetAll()
  }
})
/** å¤„理外部点击 */
function handleDocumentClick(e: MouseEvent) {
  if (!props.modelValue || !rootRef.value) {
    return
  }
  if (rootRef.value.contains(e.target as Node)) {
    return
  }
  if (status.value !== 'idle') {
    return
  }
  visible.value = false
}
/** èŽ·å–æ”¯æŒçš„å½•éŸ³æ ¼å¼ */
function getSupportedVoiceMimeType() {
  if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) {
    return undefined
  }
  return VOICE_MIME_TYPE_OPTIONS.find((item) => MediaRecorder.isTypeSupported(item.mimeType))
}
/** èŽ·å–å½•éŸ³æ–‡ä»¶åŽç¼€ */
function getVoiceExtension(mimeType: string) {
  const normalizedMimeType = mimeType.split(';')[0]
  if (normalizedMimeType === 'audio/mp4') {
    return 'm4a'
  }
  if (normalizedMimeType === 'audio/ogg') {
    return 'ogg'
  }
  return 'webm'
}
/** åˆ›å»ºå½•音器 */
function createVoiceRecorder(stream: MediaStream) {
  const supportedMimeType = getSupportedVoiceMimeType()
  const recorder = supportedMimeType
    ? new MediaRecorder(stream, { mimeType: supportedMimeType.mimeType })
    : new MediaRecorder(stream)
  recordingMimeType = recorder.mimeType || supportedMimeType?.mimeType || ''
  recordingExtension = supportedMimeType?.extension || (recordingMimeType ? getVoiceExtension(recordingMimeType) : '')
  return recorder
}
/** å¼€å§‹å½•制 */
async function startRecord() {
  if (typeof MediaRecorder === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
    message.error('当前浏览器不支持录音(需要 HTTPS æˆ– localhost)')
    return
  }
  try {
    mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
  } catch {
    message.error('无法获取麦克风权限')
    return
  }
  audioChunks = []
  discarding = false
  try {
    mediaRecorder = createVoiceRecorder(mediaStream)
  } catch {
    cleanupStream()
    message.error('当前浏览器不支持录音格式')
    return
  }
  mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => {
    if (event.data.size > 0) {
      audioChunks.push(event.data)
    }
  })
  mediaRecorder.addEventListener('stop', () => {
    if (discarding) {
      return
    }
    recordedMimeType = recordingMimeType || mediaRecorder?.mimeType || audioChunks[0]?.type || 'audio/webm'
    recordedExtension = recordingExtension || getVoiceExtension(recordedMimeType)
    recordedBlob = new Blob(audioChunks, { type: recordedMimeType })
    previewUrl.value = URL.createObjectURL(recordedBlob)
    status.value = 'preview'
  })
  mediaRecorder.start()
  status.value = 'recording'
  duration.value = 0
  timer = setInterval(() => {
    duration.value++
    if (duration.value >= props.maxDuration) {
      stopRecord()
    }
  }, 1000)
}
/** åœæ­¢å½•制 */
function stopRecord() {
  if (duration.value < 1) {
    message.warning('录音时间太短')
    resetAll()
    return
  }
  if (mediaRecorder && mediaRecorder.state !== 'inactive') {
    mediaRecorder.stop()
  }
  cleanupStream()
  if (timer) {
    clearInterval(timer)
    timer = null
  }
}
/** é‡æ–°å½•制 */
function restart() {
  clearPreview()
  duration.value = 0
  status.value = 'idle'
}
/** å‘送录音 */
function handleSend() {
  if (!recordedBlob) {
    return
  }
  emit('send', {
    blob: recordedBlob,
    duration: duration.value,
    extension: recordedExtension,
    mimeType: recordedMimeType || recordedBlob.type
  })
  visible.value = false
}
/** å–消录制 */
function handleCancel() {
  visible.value = false
}
/** é‡ç½®å½•制资源 */
function resetAll() {
  discarding = true
  if (mediaRecorder && mediaRecorder.state !== 'inactive') {
    mediaRecorder.stop()
  }
  cleanupStream()
  if (timer) {
    clearInterval(timer)
    timer = null
  }
  audioChunks = []
  duration.value = 0
  status.value = 'idle'
  recordingMimeType = ''
  recordingExtension = ''
  recordedMimeType = ''
  recordedExtension = ''
  clearPreview()
}
/** é‡Šæ”¾é¢„览音频 */
function clearPreview() {
  recordedBlob = null
  if (previewUrl.value) {
    URL.revokeObjectURL(previewUrl.value)
    previewUrl.value = ''
  }
}
/** å…³é—­éº¦å…‹é£Žé‡‡é›† */
function cleanupStream() {
  mediaStream?.getTracks().forEach((t) => t.stop())
  mediaStream = null
}
onMounted(() => {
  if (props.modelValue) {
    document.addEventListener('click', handleDocumentClick)
  }
})
onBeforeUnmount(resetAll)
onUnmounted(() => {
  document.removeEventListener('click', handleDocumentClick)
})
</script>
<template>
  <!-- è¯­éŸ³å½•制面板 -->
  <div
    v-if="visible"
    ref="rootRef"
    class="im-popover-arrow absolute z-100 w-80 p-4 rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
    @click.stop
  >
    <div class="flex flex-col items-center gap-3">
      <!-- å½•制时长 -->
      <div class="text-[28px] font-medium tabular-nums text-[var(--ant-color-text)]">
        {{ timerText }}
      </div>
      <!-- çŠ¶æ€æ–‡æ¡ˆ -->
      <div class="text-13px text-[var(--ant-color-text-secondary)]">
        <span v-if="status === 'idle'">点击下方按钮开始录制</span>
        <span v-else-if="status === 'recording'">录制中,最长 {{ maxDuration }} ç§’</span>
        <span v-else>录制完成,可试听后发送</span>
      </div>
      <!-- å½•制状态 -->
      <div
        v-if="status !== 'preview'"
        class="w-12 h-12 rounded-full bg-[var(--ant-color-border)]"
        :class="{ 'im-voice-recorder__pulse bg-[#f56c6c]': status === 'recording' }"
      ></div>
      <audio v-else :src="previewUrl" controls class="w-full"></audio>
    </div>
    <!-- æ“ä½œæŒ‰é’® -->
    <div class="flex justify-end gap-2 mt-3">
      <template v-if="status === 'idle'">
        <Button size="small" @click="handleCancel">取消</Button>
        <Button size="small" type="primary" @click="startRecord">开始录制</Button>
      </template>
      <template v-else-if="status === 'recording'">
        <Button size="small" @click="handleCancel">取消</Button>
        <Button size="small" type="primary" @click="stopRecord">停止录制</Button>
      </template>
      <template v-else>
        <Button size="small" @click="handleCancel">取消</Button>
        <Button size="small" @click="restart">重新录制</Button>
        <Button size="small" type="primary" @click="handleSend">发送</Button>
      </template>
    </div>
  </div>
</template>
<style scoped>
/* åº•部小三角 */
.im-popover-arrow::after {
  content: '';
  position: absolute;
  top: calc(100% - 1px);
  left: 110px;
  border-style: solid;
  border-width: 6px 6px 0 6px;
  border-color: var(--ant-color-bg-container) transparent transparent transparent;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
}
/* å½•音中的脉冲动画 */
.im-voice-recorder__pulse {
  animation: im-voice-pulse 1s infinite;
}
@keyframes im-voice-pulse {
  0% {
    box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.6);
  }
  70% {
    box-shadow: 0 0 0 20px rgba(245, 108, 108, 0);
  }
  100% {
    box-shadow: 0 0 0 0 rgba(245, 108, 108, 0);
  }
}
</style>
src/views/im/home/pages/conversation/components/message/forward/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2 @@
export { default as MessageForwardDialog } from './message-forward-dialog.vue';
export { default as MessageMergeDetailDialog } from './message-merge-detail-dialog.vue';
src/views/im/home/pages/conversation/components/message/forward/keys.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
import type { InjectionKey } from 'vue'
import type { Conversation, Message } from '#/views/im/home/types'
import type { ImForwardModeValue } from '#/views/im/utils/constants'
/** æ‰“开转发弹窗 */
export type OpenForwardDialog = (opts: {
  messages: Message[]
  mode: ImForwardModeValue
  sourceConversation: Conversation
}) => void
/** æ‰“开合并消息详情弹窗 */
export type OpenMergeDetailDialog = (content: string) => void
/** é‡æ‹¨ RTC é€šè¯ï¼›ç‚¹ç§èŠ RTC_CALL_END æ°”泡触发 */
export type RtcRedial = (mediaType: number) => void
/** MessagePanel é€šè¿‡ provide æš´éœ²ç»™å­æ ‘ */
export const IM_FORWARD_DIALOG_KEY: InjectionKey<OpenForwardDialog> = Symbol('IM_FORWARD_DIALOG')
export const IM_MERGE_DETAIL_DIALOG_KEY: InjectionKey<OpenMergeDetailDialog> = Symbol(
  'IM_MERGE_DETAIL_DIALOG'
)
export const IM_RTC_REDIAL_KEY: InjectionKey<RtcRedial> = Symbol('IM_RTC_REDIAL')
src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,446 @@
<script lang="ts" setup>
import type { Conversation, FriendLite, Message } from '#/views/im/home/types'
import { computed, reactive, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Button, Input, message, Modal } from 'ant-design-vue'
import { createGroup } from '#/api/im/group'
import { ConversationPickerPanel } from '#/views/im/home/components/picker'
import { FriendPickerPanel } from '#/views/im/home/components/picker'
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect'
import { useMessageSender } from '#/views/im/home/composables/useMessageSender'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useFriendStore } from '#/views/im/home/store/friendStore'
import { useGroupStore } from '#/views/im/home/store/groupStore'
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config'
import {
  ImContentType,
  ImConversationType,
  ImForwardMode,
  type ImForwardModeValue
} from '#/views/im/utils/constants'
import { getConversationKey, summarizeMessageContent } from '#/views/im/utils/conversation'
import { buildDefaultGroupName } from '#/views/im/utils/group'
import {
  buildMergeMessagePayload,
  removeQuotePayload,
  serializeMessage
} from '#/views/im/utils/message'
import { getGroupDisplayName, isGroupQuit } from '#/views/im/utils/user'
import { FacePicker } from '../../input'
defineOptions({ name: 'ImMessageForwardDialog' })
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const { sendRaw, send } = useMessageSender()
const multiSelect = useMessageMultiSelect()
const state = reactive({
  mode: ImForwardMode.SINGLE as ImForwardModeValue,
  messages: [] as Message[],
  sourceConversation: null as Conversation | null
})
const visible = ref(false)
const view = ref<'contact' | 'conversation'>('conversation') // å½“前视图:默认会话选择,「创建聊天」入口切到好友选择
const selectedKeys = ref<string[]>([])
const selectedFriendIds = ref<number[]>([])
const leaveMessage = ref('')
const sending = ref(false)
const emojiVisible = ref(false) // emoji picker æ˜¾éšï¼šå³ä¾§ç¬‘脸按钮切换
defineExpose({
  /** æ‰“开转发弹窗:reset â†’ çŒå‚ â†’ visible=true */
  open(opts: { messages: Message[]; mode: ImForwardModeValue; sourceConversation: Conversation }) {
    state.mode = opts.mode
    state.messages = opts.messages
    state.sourceConversation = opts.sourceConversation
    view.value = 'conversation'
    selectedKeys.value = []
    selectedFriendIds.value = []
    leaveMessage.value = ''
    emojiVisible.value = false
    sending.value = false
    visible.value = true
  }
})
/** å¼¹çª—标题:会话视图按 mode åŒºåˆ†ã€Œé€æ¡ / åˆå¹¶è½¬å‘」;好友视图固定为「选择好友」 */
const headerTitle = computed(() => {
  if (view.value === 'contact') {
    return '选择好友'
  }
  return state.mode === ImForwardMode.MERGE ? '合并转发' : '逐条转发'
})
/** ç¡®è®¤æŒ‰é’®æ–‡æ¡ˆï¼šå•选「发送」、多选「分别发送(n)」 */
const confirmButtonText = computed(() =>
  selectedKeys.value.length > 1 ? `分别发送(${selectedKeys.value.length})` : '发送'
)
/** å€™é€‰ä¼šè¯ï¼šä»Ž store æ‹¿æŽ’序后的列表(转发回原会话也允许,与微信一致);公众号 / é¢‘道单向消息不接受转发,从候选里剔除 */
const candidateConversations = computed<Conversation[]>(() =>
  conversationStore.getSortedConversationList.filter(
    (conversation) =>
      conversation.type !== ImConversationType.CHANNEL &&
      // åŽ†å²é€€ç¾¤ç¾¤ä¸å¯è¢«è½¬å‘é€‰ä¸­ï¼ˆé€‰äº†åŽç«¯ä¹Ÿä¼šæ‹’ï¼‰
      !(
        conversation.type === ImConversationType.GROUP &&
        isGroupQuit(groupStore.getGroup(conversation.targetId))
      )
  )
)
/** å¥½å‹è§†å›¾å€™é€‰åˆ—表:直接复用 friendStore Lite è§†å›¾ */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** åˆ‡åˆ°å¥½å‹è§†å›¾ï¼šæ¸…掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
  view.value = 'contact'
  leaveMessage.value = ''
  emojiVisible.value = false
}
/** é€‰ä¸­ emoji:拼到留言末尾;FacePicker è‡ªèº«è´Ÿè´£å…³é—­é¢æ¿ */
function handleEmojiSelect(emoji: string) {
  leaveMessage.value = `${leaveMessage.value}${emoji}`
}
/** åˆå¹¶ payload + åºåˆ—化 content;merge æ¨¡å¼ä¸‹ä¸€æ¬¡æž„造,预览 / å‘送共用 */
const mergeBundle = computed(() => {
  if (
    state.mode !== ImForwardMode.MERGE ||
    !state.sourceConversation ||
    state.messages.length === 0
  ) {
    return null
  }
  const payload = buildMergeMessagePayload(state.messages, state.sourceConversation)
  return { payload, content: serializeMessage(payload) }
})
/** åˆå¹¶æ¨¡å¼é¢„览:从 messages å‰ N æ¡æ´¾ç”Ÿã€Œ{昵称}:{摘要}」 */
const mergePreview = computed(() => {
  const payload = mergeBundle.value?.payload
  if (!payload) {
    return null
  }
  const lines = payload.messages
    .slice(0, MESSAGE_MERGE_PREVIEW_LINES)
    .map((item) => `${item.senderNickname}:${summarizeMessageContent(item)}`)
  return { title: payload.title, lines }
})
/** é€æ¡æ¨¡å¼é¢„览:取前 N æ¡æ‘˜è¦ */
const singlePreviewLines = computed(() =>
  state.messages.slice(0, MESSAGE_MERGE_PREVIEW_LINES).map((m) => summarizeMessageContent(m))
)
/** å¾…发送的逐条消息:剥离 quote ä¸€æ¬¡ï¼Œå‘送多目标时复用 */
const cleanedSinglePayloads = computed(() =>
  state.messages.map((m) => ({ type: m.type, content: removeQuotePayload(m.content) }))
)
/**
 * ç»™å•个目标发送转发消息:merge ä¸€æ¬¡ sendRaw、single æŒ‰æ—¶é—´åºé€æ¡ sendRaw
 *
 * ä»»ä¸€åŽŸæ¶ˆæ¯å¤±è´¥å³åœæ­¢åŽç»­ï¼Œè¿”å›ž false è®©ä¸Šå±‚跳过留言
 */
async function forwardToTarget(target: Conversation): Promise<boolean> {
  if (state.mode === ImForwardMode.MERGE) {
    const content = mergeBundle.value?.content
    if (!content) {
      return false
    }
    return sendRaw(ImContentType.MERGE, content, { conversation: target })
  }
  for (const payload of cleanedSinglePayloads.value) {
    const ok = await sendRaw(payload.type, payload.content, { conversation: target })
    if (!ok) {
      return false
    }
  }
  return true
}
/**
 * ç¡®è®¤å‘送:单条模式逐条循环;合并模式发一条 MergeMessage
 *
 * æ–‡æ¡ˆèšåˆï¼šå…¨éƒ¨æˆåŠŸã€Œå·²è½¬å‘ã€ã€å…¨éƒ¨å¤±è´¥ã€Œè½¬å‘å¤±è´¥ï¼šA、B」、部分失败「已转发,但 X、Y å¤±è´¥ã€ï¼›
 * ç•™è¨€åªåœ¨è¯¥ç›®æ ‡çš„转发消息全部成功后才追加,避免错序
 */
async function handleSend() {
  if (selectedKeys.value.length === 0) {
    return
  }
  if (state.messages.length === 0) {
    message.warning('没有可转发的消息')
    return
  }
  // åæŸ¥å·²é€‰ conversation å¯¹è±¡ï¼ˆæŒ‰ selectedKeys æ•°ç»„顺序,即点击顺序)
  const candidates = candidateConversations.value
  const byKey = new Map(candidates.map((c) => [getConversationKey(c), c]))
  const targets = selectedKeys.value
    .map((key) => byKey.get(key))
    .filter((c): c is Conversation => c != null)
  if (targets.length === 0) {
    return
  }
  const leaveText = leaveMessage.value.trim()
  sending.value = true
  try {
    const tasks = targets.map(async (target) => {
      const forwardOk = await forwardToTarget(target)
      if (!forwardOk) {
        return { target, ok: false }
      }
      const ok = leaveText ? await send(leaveText, { conversation: target }) : true
      return { target, ok }
    })
    const results = await Promise.all(tasks)
    const failedNames = results.filter((r) => !r.ok).map((r) => r.target.name || '未命名会话')
    // å‘½ä¸­çš„目标统一推到最近转发列表(部分失败也推:用户的"意图"已表达)
    conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c)))
    if (failedNames.length === 0) {
      message.success('已转发')
    } else if (failedNames.length === targets.length) {
      message.error(`转发失败:${failedNames.join('、')}`)
    } else {
      message.warning(`已转发,但 ${failedNames.join('、')} å¤±è´¥`)
    }
    if (multiSelect.state.active) {
      multiSelect.exit()
    }
    visible.value = false
  } finally {
    sending.value = false
  }
}
/**
 * å¥½å‹è§†å›¾å‘送:先建群(同时邀请所选好友)→ ç»™æ–°ç¾¤å¤ç”¨ forwardToTarget è½¬å‘ â†’ å‘留言 â†’ å…³å¼¹çª—
 *
 * è·Ÿä¼šè¯è§†å›¾çš„差别:先要 createGroup æ‹¿åˆ° groupId,之后构造 GROUP ä¸´æ—¶ conversation å–‚给已有的 forwardToTarget
 * ï¼ˆsendRaw å†…部会自动 insertMessage æŠŠæ–°ç¾¤ç™»è®°è¿› store,最近转发列表也能正常推)
 */
async function handleCreateGroupAndSend() {
  if (selectedFriendIds.value.length === 0) {
    return
  }
  if (state.messages.length === 0) {
    message.warning('没有可转发的消息')
    return
  }
  const byId = new Map(friends.value.map((f) => [f.id, f]))
  const members = selectedFriendIds.value
    .map((id) => byId.get(id))
    .filter((f): f is FriendLite => f != null)
  if (members.length === 0) {
    return
  }
  sending.value = true
  try {
    const memberUserIds = members.map((m) => m.id)
    const name = buildDefaultGroupName(members)
    const group = await createGroup({ name, memberUserIds, joinApproval: false })
    if (!group?.id) {
      throw new Error('创建群失败:未返回群编号')
    }
    // upsert è¿› groupStore,省一次 fetchGroupList
    groupStore.upsertGroup({
      id: group.id,
      name: group.name,
      avatar: group.avatar,
      notice: group.notice,
      ownerUserId: group.ownerUserId
    })
    // ç»™æ–°ç¾¤æž„造一个临时 conversation å¯¹è±¡ç»™ forwardToTarget ç”¨ï¼›sendRaw å†…部会自动 insertMessage ç™»è®°
    const newConversation: Conversation = {
      type: ImConversationType.GROUP,
      targetId: group.id,
      name: getGroupDisplayName(group) || name,
      avatar: group.avatar || '',
      unreadCount: 0,
      lastContent: '',
      lastSendTime: 0
    }
    const forwardOk = await forwardToTarget(newConversation)
    if (forwardOk) {
      const leaveText = leaveMessage.value.trim()
      if (leaveText) {
        await send(leaveText, { conversation: newConversation })
      }
      conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
      message.success('已创建群聊并转发')
    } else {
      message.warning('群已创建,但消息转发失败,请稍后在群里重试')
    }
    // ç»Ÿä¸€é€€å¤šé€‰ + å…³å¼¹çª—:成功 / å¤±è´¥éƒ½è¦é€€æºä¼šè¯çš„多选态,避免遗留
    if (multiSelect.state.active) {
      multiSelect.exit()
    }
    visible.value = false
  } finally {
    sending.value = false
  }
}
</script>
<template>
  <!--
    è½¬å‘消息(逐条 / åˆå¹¶ï¼‰ï¼šé€‰ç›®æ ‡ä¼šè¯ + ç•™è¨€åŽæ‰¹é‡å‘送
    - dialog å£³æœ¬ç»„件持有;选择 UI å§”托 ConversationPickerPanel / FriendPickerPanel
    - view='conversation':选已有会话发送(默认视图)
    - view='contact':从「创建聊天」入口进入,选好友建群再转发,业务壳层切视图
    - footer slot å¡žé¢„览卡(合并 / é€æ¡ä¸åŒè§†è§‰ï¼‰+ ç•™è¨€ + æäº¤æŒ‰é’®
    - å¯¹å¤–接口沿用:ref + open({ mode, messages, sourceConversation })
  -->
  <Modal
    v-model:open="visible"
    width="720px"
    :mask-closable="false"
    :footer="view === 'conversation' ? null : undefined"
    class="im-picker-dialog im-forward-dialog"
  >
    <template #header>
      <div class="flex gap-2 items-center">
        <Icon
          v-if="view === 'contact'"
          icon="ant-design:arrow-left-outlined"
          :size="16"
          class="cursor-pointer im-forward-dialog__back"
          @click="view = 'conversation'"
        />
        <span class="text-base text-[var(--ant-color-text)]">
          {{ headerTitle }}
        </span>
      </div>
    </template>
    <div class="h-[480px]">
      <!-- ä¼šè¯è§†å›¾ï¼šé€‰å·²æœ‰ä¼šè¯è½¬å‘ -->
      <ConversationPickerPanel
        v-if="view === 'conversation'"
        v-model:selected-keys="selectedKeys"
        :conversations="candidateConversations"
        :recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys"
        :show-create-chat="true"
        @create-chat="handleSwitchToContact"
        @remove-recent="conversationStore.removeRecentForwardConversationKey"
      >
        <template #footer>
          <div class="flex flex-col gap-3 px-4 py-3">
            <!-- åˆå¹¶æ¨¡å¼é¢„览:「[聊天记录] æ ‡é¢˜ + æ‘˜è¦åˆ—表」预览卡 -->
            <div
              v-if="state.mode === ImForwardMode.MERGE && mergePreview"
              class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border-secondary)]"
            >
              <div
                class="px-3 py-2 text-sm font-medium truncate text-[var(--ant-color-text)]"
              >
                {{ mergePreview.title }}
              </div>
              <div class="flex flex-col px-3 pb-2 gap-0.5">
                <div
                  v-for="(line, idx) in mergePreview.lines"
                  :key="idx"
                  class="text-12px text-[var(--ant-color-text-secondary)] truncate"
                >
                  {{ line }}
                </div>
              </div>
              <div
                class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]"
              >
                èŠå¤©è®°å½•
              </div>
            </div>
            <!-- é€æ¡æ¨¡å¼é¢„览:消息数 + é¦–条摘要 -->
            <div
              v-else-if="state.mode === ImForwardMode.SINGLE && singlePreviewLines.length > 0"
              class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border-secondary)]"
            >
              <div class="flex flex-col px-3 py-2 gap-0.5">
                <div
                  v-for="(line, idx) in singlePreviewLines"
                  :key="idx"
                  class="text-13px text-[var(--ant-color-text)] truncate"
                >
                  {{ line }}
                </div>
              </div>
              <div
                class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]"
              >
                å…± {{ state.messages.length }} æ¡æ¶ˆæ¯
              </div>
            </div>
            <!-- ç•™è¨€ï¼ˆå•行):右侧表情按钮触发 FacePicker;选中 emoji æ‹¼åˆ°æœ«å°¾ -->
            <div class="relative">
              <Input v-model:value="leaveMessage" :maxlength="100" placeholder="给朋友留言">
                <template #suffix>
                  <Icon
                    icon="ant-design:smile-outlined"
                    :size="18"
                    class="cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-primary)]"
                    @click.stop="emojiVisible = !emojiVisible"
                  />
                </template>
              </Input>
              <FacePicker
                v-model:visible="emojiVisible"
                mode="emoji"
                class="bottom-full right-0 mb-2"
                @select-emoji="handleEmojiSelect"
              />
            </div>
            <div class="flex gap-2 justify-end">
              <Button @click="visible = false">取消</Button>
              <Button
                type="primary"
                :loading="sending"
                :disabled="selectedKeys.length === 0"
                @click="handleSend"
              >
                {{ confirmButtonText }}
              </Button>
            </div>
          </div>
        </template>
      </ConversationPickerPanel>
      <!-- å¥½å‹è§†å›¾ï¼šé€‰å¥½å‹å»ºç¾¤åŽè½¬å‘ -->
      <FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
    </div>
    <!-- å¥½å‹è§†å›¾çš„ dialog footer:建群并转发 -->
    <template v-if="view === 'contact'" #footer>
      <Button @click="visible = false">取消</Button>
      <Button
        type="primary"
        :loading="sending"
        :disabled="selectedFriendIds.length === 0"
        @click="handleCreateGroupAndSend"
      >
        åˆ›å»ºç¾¤èŠå¹¶å‘送
      </Button>
    </template>
  </Modal>
</template>
<style scoped lang="scss">
/* å¤ç”¨é€‰æ‹©ç±»å¼¹çª—的公共 mixin; å†…部是 :deep ç©¿é€ el-dialog å†…部 header / body çš„æ ·å¼ï¼Œæ— æ³•替换为工具类 */
@use '#/views/im/home/components/picker/picker-dialog' as picker;
.im-picker-dialog {
  @include picker.styles;
}
</style>
src/views/im/home/pages/conversation/components/message/forward/message-merge-detail-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Modal } from 'ant-design-vue'
import { UserAvatar } from '#/views/im/home/components/user'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import { type MergeMessage, parseMessage } from '#/views/im/utils/message'
import { formatMergeItemTime } from '#/views/im/utils/time'
import { MessageBubble } from '..'
defineOptions({ name: 'ImMessageMergeDetailDialog' })
const voicePlayer = useVoicePlayer()
const visible = ref(false)
const stack = ref<MergeMessage[]>([]) // åµŒå¥—层级栈,存 parsed payload é¿å…åˆ‡å±‚重 parse
defineExpose({
  /** æ‰“开详情弹窗,传入顶层合并消息 content */
  open(content: string) {
    const payload = parseMessage<MergeMessage>(content)
    stack.value = payload ? [payload] : []
    visible.value = true
  }
})
/** å½“前层 payload */
const currentPayload = computed<MergeMessage | null>(
  () => stack.value[stack.value.length - 1] ?? null
)
/** åµŒå¥—合并气泡点击:解析 content åŽåŽ‹æ ˆè¿›å…¥ä¸‹ä¸€å±‚ */
function handleNestedOpen(content: string) {
  const payload = parseMessage<MergeMessage>(content)
  if (payload) {
    stack.value.push(payload)
  }
}
/** é¡¶éƒ¨è¿”回箭头点击:弹出栈顶回到上一层 */
function handleBack() {
  if (stack.value.length > 1) {
    stack.value.pop()
  }
}
/** å¼¹çª—关闭:清栈 + åœè¯­éŸ³ï¼Œä¸‹æ¬¡æ‰“开从顶层重新开始 */
function handleClose() {
  stack.value = []
  voicePlayer.stop()
}
</script>
<template>
  <Modal
    v-model:open="visible"
    width="560px"
    :footer="null"
    :mask-closable="false"
    class="im-merge-detail-dialog"
    :closable="true"
    @close="handleClose"
  >
    <template #header>
      <div class="flex items-center gap-2">
        <Icon
          v-if="stack.length > 1"
          icon="ant-design:left-outlined"
          class="cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-text)]"
          @click="handleBack"
        />
        <span class="text-base font-medium truncate">{{ currentPayload?.title || '聊天记录' }}</span>
      </div>
    </template>
    <div v-if="currentPayload" class="flex flex-col h-[480px]">
      <div
        class="px-4 py-2 text-12px text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
      >
        ä»¥ä¸‹æ˜¯ {{ currentPayload.messages.length }} æ¡æ¶ˆæ¯
      </div>
      <!-- overflow-y-auto:合并消息多时在定高弹窗内可滚动,对齐 Vue3 çš„ el-scrollbar -->
      <div class="flex-1 overflow-y-auto">
        <div
          v-for="(item, idx) in currentPayload.messages"
          :key="idx"
          class="flex gap-3 items-start px-4 py-3 border-b border-b-solid border-[var(--ant-color-border-secondary)]"
        >
          <UserAvatar
            :url="item.senderAvatar"
            :name="item.senderNickname"
            :size="36"
            :clickable="false"
          />
          <div class="flex-1 min-w-0">
            <div class="flex gap-2 items-baseline">
              <span class="text-13px font-medium text-[var(--ant-color-text)] truncate">
                {{ item.senderNickname }}
              </span>
              <span class="text-12px text-[var(--ant-color-text-secondary)] flex-shrink-0">
                {{ formatMergeItemTime(item.sendTime) }}
              </span>
            </div>
            <!-- å¤ç”¨ä¸»èŠå¤©æ°”泡渲染;MERGE åµŒå¥—点击 push å½“前层 -->
            <div class="mt-1.5">
              <MessageBubble
                :type="item.type"
                :content="item.content"
                @open-merge="handleNestedOpen($event)"
              />
            </div>
          </div>
        </div>
      </div>
    </div>
  </Modal>
</template>
<style scoped>
/* :deep ç©¿é€ el-dialog å†…部 body,去掉默认内边距,让滚动区贴边 */
.im-merge-detail-dialog :deep(.el-dialog__body) {
  padding: 0;
}
</style>
src/views/im/home/pages/conversation/components/message/group-pinned-message.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,207 @@
<script lang="ts" setup>
import type { Message } from '../../../../types'
import { computed, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Button, message } from 'ant-design-vue'
import { unpinGroupMessage as apiUnpinGroupMessage } from '#/api/im/group'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants'
import { resolveConversationLastContent } from '#/views/im/utils/conversation'
import { getSenderDisplayName, isGroupQuit } from '#/views/im/utils/user'
import { useGroupStore } from '../../../../store/groupStore'
defineOptions({ name: 'ImGroupPinnedMessage' })
const props = defineProps<{
  /** å½“前群编号(自行从 groupStore æ‹¿å®Œæ•´ Group,跟随响应式) */
  groupId: number
}>()
const emit = defineEmits<{
  /** ç‚¹å‡»ç½®é¡¶æ¶ˆæ¯ â†’ çˆ¶çº§ MessagePanel æ»šåŠ¨å®šä½åˆ°åŽŸæ¶ˆæ¯ä½ç½® */
  locate: [messageId: number]
}>()
const groupStore = useGroupStore()
/** å½“前群(含 pinnedMessages) */
const group = computed(() => groupStore.getGroup(props.groupId))
const expanded = ref(false)
const removingId = ref<null | number>(null)
// åˆ‡ç¾¤æ—¶é‡ç½®å±•å¼€ / ç§»é™¤ä¸­çŠ¶æ€ï¼šæœ¬åœ° ref ä¸è·Ÿéš groupId,否则上一群"展开"或"移除中"会带到新群
watch(
  () => props.groupId,
  () => {
    expanded.value = false
    removingId.value = null
  }
)
/** å½“前群置顶消息列表(直接走 group.value,跟随响应式) */
const pinnedMessages = computed<Message[]>(() => group.value?.pinnedMessages ?? [])
/** é¡¶éƒ¨èƒ¶å›Šå±•示的最新一条(即列表最后一条,pin é¡ºåºè¿½åŠ ï¼‰ */
const latest = computed<Message | null>(
  () => pinnedMessages.value[pinnedMessages.value.length - 1] ?? null
)
/** å½“前用户是否群主 / ç®¡ç†å‘˜ï¼ˆå†³å®šæ˜¯å¦æ˜¾ç¤ºã€Œç§»é™¤ã€å…¥å£ï¼‰ */
const canManage = computed(() => {
  // åŽ†å²é€€ç¾¤ç¾¤ï¼šæœ¬åœ°ç¼“å­˜æ®‹ç•™æ—¶ä¹Ÿä¸ç»™ã€Œç§»é™¤ã€å…¥å£
  if (isGroupQuit(group.value)) {
    return false
  }
  const myId = getCurrentUserId()
  const role = group.value?.members?.find((m) => m.userId === myId)?.role
  return role === ImGroupMemberRole.OWNER || role === ImGroupMemberRole.ADMIN
})
/** é¡¶éƒ¨èƒ¶å›Šç‚¹å‡»ï¼šå•条直接跳转原消息位置;多条切换展开 / æŠ˜å  */
function handleTopClick() {
  if (!latest.value) {
    return
  }
  if (pinnedMessages.value.length === 1) {
    handleLocate(latest.value)
    return
  }
  expanded.value = !expanded.value
}
/** ç‚¹å‡»ç½®é¡¶æ¶ˆæ¯è¡Œ â†’ è§¦å‘跳转 + æ”¶èµ·å¼¹å‡ºå±‚ */
function handleLocate(message: Message) {
  if (!message.id) {
    return
  }
  emit('locate', message.id)
  expanded.value = false
}
/** ç½®é¡¶æ¶ˆæ¯å‘送人显示名 */
function getSenderName(message: Message): string {
  return group.value
    ? getSenderDisplayName(message.senderId, ImConversationType.GROUP, group.value.id)
    : ''
}
/** ç½®é¡¶æ¶ˆæ¯é¢„览文本:复用会话最后一条摘要逻辑([图片] / [文件] / æ–‡æœ¬ç­‰ï¼‰ */
function getPreview(message: Message): string {
  return group.value
    ? resolveConversationLastContent(message, ImConversationType.GROUP, group.value.id)
    : ''
}
/** ç§»é™¤ç½®é¡¶ï¼šè°ƒåŽç«¯ API,loading æœŸé—´ç¦æ­¢é‡å¤ç‚¹ï¼›åŽç«¯å¹¿æ’­ GROUP_MESSAGE_UNPIN ç”± dispatcher è‡ªåŠ¨åŒæ­¥æœ¬åœ° */
async function handleRemove(pinnedMessage: Message) {
  if (!group.value || !pinnedMessage.id || removingId.value !== null) {
    return
  }
  removingId.value = pinnedMessage.id
  try {
    await apiUnpinGroupMessage({ id: group.value.id, messageId: pinnedMessage.id })
    message.success('已取消置顶')
  } finally {
    removingId.value = null
  }
}
</script>
<template>
  <!-- ç¾¤èŠç½®é¡¶æ¶ˆæ¯ï¼šä»…群聊 + æœ‰ç½®é¡¶æ—¶æ˜¾ç¤ºï¼Œæ‚¬æŒ‚在群聊头部下方左上角;不占整行(对齐微信 PC) -->
  <div
    v-if="latest"
    class="im-group-pinned-message relative flex flex-shrink-0 flex-col items-start px-4 pt-1.5 pb-2 bg-[var(--ant-color-fill-secondary)]"
  >
    <!-- é¡¶éƒ¨èƒ¶å›Šï¼šå•条点击跳转;多条折叠点击展开;多条展开点击折叠 -->
    <div
      class="flex items-center gap-1.5 w-[360px] px-3 py-1.5 rounded-[10px] text-13px text-[var(--ant-color-text)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_2px_rgba(0,0,0,0.04)] cursor-pointer hover:bg-[var(--ant-color-fill-tertiary)]"
      @click="handleTopClick"
    >
      <Icon
        icon="ant-design:pushpin-outlined"
        :size="14"
        class="flex-shrink-0 text-[var(--ant-color-warning)]"
      />
      <span class="flex-shrink-0 text-[var(--ant-color-text-secondary)]">{{ getSenderName(latest) }}:</span>
      <span class="flex-1 min-w-0 truncate">{{ getPreview(latest) }}</span>
      <!-- å•条:移除按钮;多条折叠:共 N æ¡ï¼›å¤šæ¡å±•开:收起箭头 -->
      <Button
        v-if="pinnedMessages.length === 1 && canManage"
        type="link"
        size="small"
        :loading="removingId === latest.id"
        class="flex-shrink-0 !h-auto !p-0 text-13px"
        @click.stop="handleRemove(latest)"
      >
        ç§»é™¤
      </Button>
      <template v-else-if="pinnedMessages.length > 1">
        <span class="flex-shrink-0 text-[var(--ant-color-text-secondary)] text-12px">
          å…± {{ pinnedMessages.length }} æ¡
        </span>
        <Icon
          :icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
          :size="11"
          class="flex-shrink-0 text-[var(--ant-color-text-placeholder)]"
        />
      </template>
    </div>
    <!-- å¤šæ¡å±•开:浅色面板包裹完整列表,每条独立胶囊;点击跳转到对应消息位置 -->
    <div
      v-if="pinnedMessages.length > 1 && expanded"
      class="im-group-pinned-message__list absolute top-full left-1.5 z-10 flex flex-col gap-2.5 w-[380px] p-3 rounded-xl bg-[var(--ant-color-bg-container)] shadow-[0_6px_16px_rgba(0,0,0,0.12)]"
      style="margin-top: -1px"
    >
      <div
        v-for="msg in pinnedMessages"
        :key="msg.id"
        class="flex items-center gap-1.5 w-full px-3 py-1.5 rounded-[10px] text-13px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-secondary)] cursor-pointer hover:bg-[var(--ant-color-bg-container)]"
        @click="handleLocate(msg)"
      >
        <Icon
          icon="ant-design:pushpin-outlined"
          :size="14"
          class="flex-shrink-0 text-[var(--ant-color-warning)]"
        />
        <span class="flex-shrink-0 text-[var(--ant-color-text-secondary)]">
          {{ getSenderName(msg) }}:
        </span>
        <span class="flex-1 min-w-0 truncate">{{ getPreview(msg) }}</span>
        <Button
          v-if="canManage"
          type="link"
          size="small"
          :loading="removingId === msg.id"
          class="flex-shrink-0 !h-auto !p-0 text-13px"
          @click.stop="handleRemove(msg)"
        >
          ç§»é™¤
        </Button>
      </div>
    </div>
  </div>
</template>
<style scoped>
/* å¼¹å‡ºå±‚朝上的三角箭头;走 ::before + 4 è¾¹ border é…è‰²ç”»ï¼Œé¢œè‰²è·Ÿå¼¹å‡ºå±‚ background ä¸€è‡´ */
.im-group-pinned-message__list::before {
  content: '';
  position: absolute;
  top: -8px;
  left: 184px;
  width: 0;
  height: 0;
  border-left: 8px solid transparent;
  border-right: 8px solid transparent;
  border-bottom: 8px solid var(--ant-color-bg-container);
  filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04));
}
</style>
src/views/im/home/pages/conversation/components/message/group-request-pending.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImGroupMemberRole } from '#/views/im/utils/constants'
import { GroupRequestListDialog } from '../../../../components/group'
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
import { useGroupStore } from '../../../../store/groupStore'
defineOptions({ name: 'ImGroupRequestPending' })
const props = defineProps<{
  groupId: number
}>()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>() // ç”³è¯·åˆ—表弹窗 ref:handleOpen è°ƒ open({ groupId }) è§¦å‘
/** æ‰“开当前群的进群申请列表 */
function handleOpen() {
  requestListDialogRef.value?.open({ groupId: props.groupId })
}
/** å½“前群(含 ownerUserId / members) */
const group = computed(() => groupStore.getGroup(props.groupId))
/** å½“前用户在群里的角色;优先用 group.members,懒加载未到时回退到 ownerUserId ç›´åˆ¤ */
const myRole = computed(() => {
  const myId = getCurrentUserId()
  if (group.value?.ownerUserId === myId) {
    return ImGroupMemberRole.OWNER
  }
  return group.value?.members?.find((m) => m.userId === myId)?.role
})
/** ä»…群主 / ç®¡ç†å‘˜å¯è§ */
const canManage = computed(
  () => myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN
)
/** å½“前群未处理申请数;从 store æ´¾ç”Ÿ */
const pendingCount = computed(() => groupRequestStore.getUnhandledGroupRequestCount(props.groupId))
</script>
<template>
  <!--
    ç¾¤é¡¶éƒ¨ã€Œå¾…处理加群申请」横幅
    - ä»…当登录用户是该群 owner / admin ä¸”该群下未处理申请数 > 0 æ—¶æ˜¾ç¤º
    - count ä»Ž groupRequestStore æ´¾ç”Ÿï¼ˆå…¨å±€å­˜ï¼‰ï¼›æœ¬ç«¯å¤„理 / WS é€šçŸ¥åˆ°è¾¾åŽ store è‡ªåŠ¨æ›´æ–°
    - ç‚¹å‡»æ¨ªå¹…打开 GroupRequestListDialog(含历史已处理记录),不再就地展开
  -->
  <div
    v-if="canManage && pendingCount > 0"
    class="flex flex-shrink-0 flex-col items-start px-4 pt-1.5 pb-2 bg-[var(--ant-color-fill-secondary)]"
  >
    <div
      class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-13px text-[var(--ant-color-text)] bg-[var(--ant-color-bg-container)] cursor-pointer shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-colors hover:bg-[var(--ant-color-fill-tertiary)]"
      @click="handleOpen"
    >
      <Icon
        icon="ant-design:user-add-outlined"
        :size="14"
        class="im-group-request-pending__icon flex-shrink-0 text-[var(--ant-color-success)]"
      />
      <span class="flex-1 min-w-0 truncate"> æ–°è¿›ç¾¤ç”³è¯·ï¼ˆ{{ pendingCount }}) </span>
      <Icon
        icon="ant-design:right-outlined"
        :size="11"
        class="flex-shrink-0 text-[var(--ant-color-text-placeholder)]"
      />
    </div>
    <!-- ç”³è¯·åˆ—表 dialog:复用同一组件,避免群管理面板与会话顶部各写一份 -->
    <GroupRequestListDialog ref="requestListDialogRef" />
  </div>
</template>
<style scoped>
/* :deep ç©¿é€ Icon å­ç»„ä»¶ DOM;强制 svg èµ° currentColor åº”对暗色模式 el-icon å…¨å±€è‰²è¦†ç›– */
.im-group-request-pending__icon :deep(svg) {
  fill: currentColor !important;
}
</style>
src/views/im/home/pages/conversation/components/message/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
export { default as GroupPinnedMessage } from './group-pinned-message.vue';
export { default as GroupRequestPending } from './group-request-pending.vue';
export { default as MaterialBubble } from './material-bubble.vue';
export { default as MessageBubble } from './message-bubble.vue';
export { default as MessageHistory } from './message-history.vue';
export { default as MessageItem } from './message-item.vue';
export { default as MessagePanel } from './message-panel.vue';
export { default as MessageReadStatus } from './message-read-status.vue';
export { default as ReplyPreview } from './reply-preview.vue';
export { default as TipSegments } from './tip-segments.vue';
src/views/im/home/pages/conversation/components/message/material-bubble.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,170 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { openSafeUrl } from '#/packages/utils/src'
import { Modal, Spin } from 'ant-design-vue'
import { getChannelMaterial } from '#/api/im/channel/material'
import { useChannelStore } from '#/views/im/home/store/channelStore'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { ImConversationType } from '#/views/im/utils/constants'
import { type MaterialMessage, parseMessage } from '#/views/im/utils/message'
const props = defineProps<{
  content: string
}>()
const conversationStore = useConversationStore()
const channelStore = useChannelStore()
/** å½“前是否在公众号 / é¢‘道会话里:决定走大卡片还是紧凑转发卡片 */
const isChannelView = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** ååºåˆ—化 content JSON ä¸º payload å¯¹è±¡ */
const payload = computed<MaterialMessage>(
  () => parseMessage<MaterialMessage>(props.content) ?? {}
)
/** æ¥æºé¢‘道;紧凑卡底部渲染头像 + åç§° */
const sourceChannel = computed(() =>
  payload.value.channelId ? channelStore.getChannel(payload.value.channelId) : undefined
)
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailHtml = ref('')
// ç‚¹å‡»è¡Œä¸ºï¼šurl éžç©ºè·³å¤–链;为空则按 payload.materialId æ‹‰å¯Œæ–‡æœ¬æ­£æ–‡ï¼Œå…¨å± dialog æ¸²æŸ“
const onClick = async () => {
  if (payload.value.url) {
    openSafeUrl(payload.value.url)
    return
  }
  if (!payload.value.materialId) {
    return
  }
  detailVisible.value = true
  detailLoading.value = true
  detailHtml.value = ''
  try {
    const material = await getChannelMaterial(payload.value.materialId)
    detailHtml.value = material?.content ?? ''
  } catch (error) {
    console.error('[Material] æ‹‰å–正文失败', error)
  } finally {
    detailLoading.value = false
  }
}
</script>
<template>
  <!-- å…¬ä¼—号会话内大卡片:对齐微信公众号单图文卡(封面 9:5 + ä¸‹æ–¹ç™½åº•加粗标题条) -->
  <div
    v-if="isChannelView"
    class="material-card cursor-pointer w-full overflow-hidden rounded-lg bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]"
    @click="onClick"
  >
    <img v-if="payload.coverUrl" class="block w-full h-[200px] object-cover" :src="payload.coverUrl" />
    <div class="px-3.5 py-3 text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2">
      {{ payload.title || '(无标题)' }}
    </div>
  </div>
  <!-- ç§èŠ / ç¾¤èŠé‡Œè¢«è½¬å‘的素材紧凑卡片:标题 + æ‘˜è¦åœ¨å·¦ã€å°å°é¢åœ¨å³ã€åº•部频道头像 + åç§°ï¼ˆå¯¹é½å¾®ä¿¡å…¬ä¼—号转发卡) -->
  <div
    v-else
    class="material-card cursor-pointer flex flex-col w-[260px] px-3.5 pt-3 pb-2.5 rounded-lg bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]"
    @click="onClick"
  >
    <div class="flex gap-2.5 items-start">
      <div class="flex flex-1 flex-col gap-1.5 min-w-0">
        <div class="text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2 break-all">
          {{ payload.title || '(无标题)' }}
        </div>
        <div
          v-if="payload.summary"
          class="text-12px leading-[1.5] text-[var(--ant-color-text-secondary)] line-clamp-2 break-all"
        >
          {{ payload.summary }}
        </div>
      </div>
      <img
        v-if="payload.coverUrl"
        class="flex-shrink-0 w-[60px] h-[60px] object-cover rounded bg-[var(--ant-color-fill-secondary)]"
        :src="payload.coverUrl"
      />
    </div>
    <div class="flex items-center gap-1.5 mt-2.5 pt-2 border-t border-t-solid border-[var(--im-border-color-lighter)] text-12px text-[var(--ant-color-text-secondary)]">
      <img
        v-if="sourceChannel?.avatar"
        class="w-4 h-4 rounded-full object-cover flex-shrink-0"
        :src="sourceChannel.avatar"
      />
      <Icon v-else icon="ep:promotion" :size="14" />
      <span class="truncate">{{ sourceChannel?.name || '频道消息' }}</span>
    </div>
  </div>
  <!-- å¯Œæ–‡æœ¬è¯¦æƒ…:全屏弹窗按需挂载,关闭后释放富文本 DOM -->
  <Modal
    v-model:open="detailVisible"
    :title="payload.title || '详情'"
    :footer="null"
    width="100vw"
    wrap-class-name="im-material-detail-modal"
    destroy-on-close
  >
    <Spin :spinning="detailLoading" wrapper-class-name="w-full">
      <div class="material-detail-body max-w-[720px] mx-auto px-5 pt-6 pb-20 min-h-[60vh]">
        <div class="text-[22px] font-600 leading-[1.4] text-[var(--ant-color-text)] mb-5">
          {{ payload.title || '' }}
        </div>
        <div
          v-if="detailHtml"
          class="article-content text-15px leading-[1.75] text-[var(--ant-color-text)]"
          v-dompurify-html="detailHtml"
        ></div>
        <div
          v-else-if="!detailLoading"
          class="text-center text-[var(--ant-color-text-secondary)] mt-20"
        >
          æš‚无正文
        </div>
      </div>
    </Spin>
  </Modal>
</template>
<style scoped lang="scss">
/* hover é˜´å½± + transition:合写一处,写法更紧凑 */
.material-card {
  transition: box-shadow 0.15s ease;
  &:hover {
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  }
}
/* :deep ç©¿é€ v-dompurify-html æ¸²æŸ“的内嵌 DOM;统一控制富文本里的 img / p / hN æŽ’版 */
.article-content {
  :deep(img) {
    max-width: 100%;
    height: auto;
  }
  :deep(p) {
    margin: 12px 0;
  }
  :deep(h1),
  :deep(h2),
  :deep(h3) {
    margin: 20px 0 12px;
    font-weight: 600;
  }
}
</style>
src/views/im/home/pages/conversation/components/message/message-bubble.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,414 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { formatFileSize, openSafeUrl } from '#/packages/utils/src'
import { Image } from 'ant-design-vue'
import { CardBubble } from '#/views/im/home/components/card'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config'
import { ImContentType } from '#/views/im/utils/constants'
import { summarizeMessageContent } from '#/views/im/utils/conversation'
import {
  type AudioMessage,
  type CardMessage,
  type FaceMessage,
  type FileMessage,
  getFileIconInfo,
  type ImageMessage,
  type MentionCandidate,
  type MergeMessage,
  parseMessage,
  parseTextSegments,
  type TextMessage,
  type VideoMessage
} from '#/views/im/utils/message'
import { formatSeconds } from '#/views/im/utils/time'
import MaterialBubble from './material-bubble.vue'
import TipSegments from './tip-segments.vue'
defineOptions({ name: 'ImMessageBubble' })
const props = defineProps<{
  /** æ¶ˆæ¯ content(JSON å­—符串) */
  content: string
  /** TEXT æ°”泡的 @ mention å€™é€‰åå­—;不传则文本里的 @xxx é€€åŒ–为普通文本 */
  mentions?: MentionCandidate[]
  /** æ˜¯å¦è‡ªå·±å‘送,影响气泡配色(绿底 vs ç°åº•) */
  selfSend?: boolean
  /** å†…容类型,对齐 ImContentType */
  type: number
  /** åª’体上传进度(0-100);非 null å³è§†ä¸ºä¸Šä¼ ä¸­ï¼Œæ¸²æŸ“遮罩 / è¿›åº¦æ¡ */
  uploadProgress?: null | number
}>()
const emit = defineEmits<{
  /** åç‰‡ç‚¹å‡»ï¼šè°ƒç”¨æ–¹å†³å®šå¼¹å¡ç‰‡ / è·³ç¾¤ç­‰è¡Œä¸º */
  clickCard: [card: CardMessage, e: MouseEvent]
  /** åˆå¹¶æ¶ˆæ¯æ°”泡点击:调用方决定开 dialog æˆ–栈内 push */
  openMerge: [content: string]
}>()
/** å„ type åˆ¤å®š */
const isText = computed(() => props.type === ImContentType.TEXT)
const isImage = computed(() => props.type === ImContentType.IMAGE)
const isFile = computed(() => props.type === ImContentType.FILE)
const isVoice = computed(() => props.type === ImContentType.VOICE)
const isVideo = computed(() => props.type === ImContentType.VIDEO)
const isFace = computed(() => props.type === ImContentType.FACE)
const isCard = computed(() => props.type === ImContentType.CARD)
const isMerge = computed(() => props.type === ImContentType.MERGE)
const isMaterial = computed(() => props.type === ImContentType.MATERIAL)
/** åª’体上传中:uploadProgress éž null å³è§†ä¸ºä¸Šä¼ ä¸­ */
const isUploading = computed(() => props.uploadProgress != null)
const uploadProgress = computed(() => props.uploadProgress ?? 0)
const uploadProgressText = computed(() => `${uploadProgress.value}%`)
/**
 * å•一 parse å…¥å£ï¼šcontent ä¸€å˜åª parse ä¸€æ¬¡ï¼ŒæŒ‰ type åˆ†å‘到下面 7 ä¸ª payload
 *
 * å„类型 payload å…±ç”¨åŒä¸€æ£µ JSON æ ‘,避免 7 ä¸ª computed å„自重 parse åŒä¸€ä»½ content
 */
const parsedContent = computed<unknown>(() => parseMessage(props.content))
const textPayload = computed(() =>
  isText.value ? (parsedContent.value as null | TextMessage) : null
)
/** æ–‡æœ¬æ°”泡 segment æ•°ç»„:mention é«˜äº® + URL è‡ªåŠ¨è¯†åˆ« + æ™®é€šæ–‡æœ¬ä¸‰æ®µæ‹¼æŽ¥ */
const textSegments = computed(() => {
  const content = textPayload.value?.content
  if (!content) {
    return []
  }
  return parseTextSegments(content, props.mentions || [])
})
const imagePayload = computed(() =>
  isImage.value ? (parsedContent.value as ImageMessage | null) : null
)
const filePayload = computed(() =>
  isFile.value ? (parsedContent.value as FileMessage | null) : null
)
const voicePayload = computed(() =>
  isVoice.value ? (parsedContent.value as AudioMessage | null) : null
)
const videoPayload = computed(() =>
  isVideo.value ? (parsedContent.value as null | VideoMessage) : null
)
const cardPayload = computed(() =>
  isCard.value ? (parsedContent.value as CardMessage | null) : null
)
const mergePayload = computed(() =>
  isMerge.value ? (parsedContent.value as MergeMessage | null) : null
)
/** åˆå¹¶æ¶ˆæ¯å†…嵌前 N æ¡æ´¾ç”Ÿã€Œ{昵称}:{摘要}」 */
const mergePreviewLines = computed(() => {
  if (!mergePayload.value) {
    return []
  }
  return mergePayload.value.messages
    .slice(0, MESSAGE_MERGE_PREVIEW_LINES)
    .map((item) => `${item.senderNickname}:${summarizeMessageContent(item)}`)
})
const FACE_DIMENSION_MAX = 2048 // è¡¨æƒ… payload:非法宽高派生成 undefined,让 <img> èµ° CSS max-w / max-h å…œåº•
const facePayload = computed(() => {
  if (!isFace.value) {
    return null
  }
  const raw = parsedContent.value as FaceMessage | null
  if (!raw) {
    return null
  }
  const sanitize = (v: number | undefined) =>
    v && v > 0 && v <= FACE_DIMENSION_MAX ? v : undefined
  return { ...raw, width: sanitize(raw.width), height: sanitize(raw.height) }
})
/** æ–‡ä»¶å›¾æ ‡ + é…è‰²ï¼šæŒ‰æ‰©å±•名分发 */
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
/** æ–‡æœ¬ / æ–‡ä»¶ / è¯­éŸ³æ°”泡的整体 class(含 selfSend é…è‰² + ::before ä¸‰è§’çš„ side class) */
function bubbleClass(variant: 'file' | 'text' | 'voice'): string[] {
  const isSelf = props.selfSend
  const side = isSelf ? 'message-bubble--self' : 'message-bubble--other'
  switch (variant) {
    case 'file': {
      return [
        side,
        'message-bubble--file',
        isSelf
          ? 'bg-[#95ec69] border-[var(--ant-color-border-secondary)]'
          : 'bg-[var(--ant-color-bg-container)] border-[var(--ant-color-border-secondary)] hover:border-[#409eff]'
      ]
    }
    case 'text': {
      return [
        side,
        'message-bubble--text',
        isSelf
          ? 'text-black bg-[#95ec69]'
          : 'text-[var(--ant-color-text)]'
      ]
    }
    case 'voice': {
      return [side, 'message-bubble--voice', isSelf ? 'bg-[#95ec69]' : '']
    }
  }
}
/** æ–‡ä»¶ç‚¹å‡» â†’ æ–°çª—口下载;上传中跳过 */
function handleFileClick() {
  if (isUploading.value || !filePayload.value?.url) {
    return
  }
  openSafeUrl(filePayload.value.url)
}
const voicePlayer = useVoicePlayer() // è¯­éŸ³ç‚¹å‡»ï¼šæ‰˜ç®¡ç»™ useVoicePlayer å…¨å±€äº’斥播放,新点的语音会停掉旧的
/**
 * å®žä¾‹çº§å”¯ä¸€æ’­æ”¾ key:每个 MessageBubble å®žä¾‹ç‹¬ç«‹ä¸€ä»½
 *
 * ä¸ç”¨ url å½“ key æ˜¯ä¸ºäº†é¿å…ã€Œä¸»é¢æ¿ / åŽ†å²æŠ½å±‰ / åˆå¹¶è¯¦æƒ…同一条语音」共享身份:那样三处气泡会
 * åŒæ—¶æ˜¾ç¤ºæ’­æ”¾æ€ï¼Œä¸”任何一处卸载都会 stop æŽ‰åˆ«å¤„仍可见的播放
 */
const voiceKey = Symbol('im-message-bubble-voice')
const voicePlaying = computed(() => voicePlayer.isPlaying(voiceKey))
function handleVoiceClick() {
  const url = voicePayload.value?.url
  if (!url) {
    return
  }
  voicePlayer.play(voiceKey, url)
}
/** æ°”泡卸载兜底:传 key è®© stop è‡ªå·±åˆ¤åˆ«ã€Œæ˜¯ä¸æ˜¯æˆ‘」,不会误伤别人的播放 */
onBeforeUnmount(() => {
  voicePlayer.stop(voiceKey)
})
</script>
<template>
  <!-- æ–‡æœ¬ï¼šæŒ‰ segment æ¸²æŸ“,mention é«˜äº®å¯ç‚¹å‡»ã€URL è‡ªåŠ¨è¯†åˆ«æˆå¯ç‚¹å‡»é“¾æŽ¥ -->
  <div
    v-if="isText && textPayload"
    class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
    :class="bubbleClass('text')"
  >
    <TipSegments :segments="textSegments" />
  </div>
  <!-- å›¾ç‰‡ï¼šel-image å†…置预览;上传中半透明遮罩 -->
  <div v-else-if="isImage && imagePayload" class="relative inline-block">
    <Image
      class="max-w-[220px] rounded cursor-zoom-in"
      :src="imagePayload.thumbnailUrl || imagePayload.url"
      :preview="isUploading ? false : { src: imagePayload.url }"
    />
    <div
      v-if="isUploading"
      class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none"
    >
      {{ uploadProgressText }}
    </div>
  </div>
  <!-- æ–‡ä»¶ï¼šæ–‡ä»¶å + å¤§å°é å·¦ã€å½©è‰²å¤§å›¾æ ‡è´´å³ï¼›ä¸Šä¼ ä¸­æ’一条进度条 -->
  <div
    v-else-if="isFile && filePayload"
    class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border border-solid rounded transition-colors"
    :class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']"
    @click="handleFileClick"
  >
    <div class="flex-1 min-w-0">
      <div class="overflow-hidden text-sm font-medium truncate text-[var(--ant-color-text)]">
        {{ filePayload.name }}
      </div>
      <div class="mt-1 text-12px text-[var(--ant-color-text-secondary)]">
        {{ formatFileSize(filePayload.size) }}
      </div>
      <div v-if="isUploading" class="flex gap-2 items-center mt-1.5">
        <div class="overflow-hidden flex-1 h-1 rounded bg-[var(--ant-color-fill-dark)]">
          <div
            class="h-full bg-[var(--ant-color-primary)] transition-[width] duration-150"
            :style="{ width: `${uploadProgress }%` }"
          ></div>
        </div>
        <span class="text-11px text-[var(--ant-color-text-secondary)] tabular-nums">
          {{ uploadProgressText }}
        </span>
      </div>
    </div>
    <Icon :icon="fileIconInfo.icon" :color="fileIconInfo.color" :size="40" class="flex-shrink-0" />
  </div>
  <!-- è¯­éŸ³ -->
  <div
    v-else-if="isVoice && voicePayload"
    class="relative flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer"
    :class="bubbleClass('voice')"
    @click="handleVoiceClick"
  >
    <Icon
      icon="ant-design:audio-outlined"
      :size="18"
      class="message-bubble__voice-icon"
      :class="{ 'im-voice-playing': voicePlaying }"
    />
    <span class="text-13px text-[var(--ant-color-text)]">
      {{ formatSeconds(voicePayload.duration) }}
    </span>
  </div>
  <!-- è§†é¢‘:原生 controls å†…嵌播放,poster èµ°åŽç«¯å°é¢ï¼›ä¸Šä¼ ä¸­åŠé€æ˜Žé®ç½© -->
  <div v-else-if="isVideo && videoPayload?.url" class="relative inline-block">
    <video
      class="max-w-[280px] max-h-[320px] rounded bg-black"
      :src="videoPayload.url"
      :poster="videoPayload.coverUrl"
      :controls="!isUploading"
      preload="metadata"
    ></video>
    <div
      v-if="isUploading"
      class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none"
    >
      {{ uploadProgressText }}
    </div>
  </div>
  <!-- è§†é¢‘但 payload è§£æžå¤±è´¥ / æ²¡ url:降级展示 -->
  <div
    v-else-if="isVideo"
    class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]"
  >
    [视频消息]
  </div>
  <!-- è¡¨æƒ…贴图:裸 <img> ä¸å¥—气泡 -->
  <div v-else-if="isFace && facePayload" class="inline-block">
    <img
      :src="facePayload.url"
      :alt="facePayload.name || '表情'"
      :title="facePayload.name || ''"
      :width="facePayload.width"
      :height="facePayload.height"
      class="block max-w-[160px] max-h-[160px] object-contain"
      draggable="false"
    />
  </div>
  <!-- åç‰‡ -->
  <CardBubble
    v-else-if="isCard && cardPayload"
    :card="cardPayload"
    clickable
    @click="(e: MouseEvent) => emit('clickCard', cardPayload!, e)"
  />
  <!-- åˆå¹¶è½¬å‘气泡:title + æ‘˜è¦é¢„览 + åº•部「聊天记录」标签 -->
  <div
    v-else-if="isMerge && mergePayload"
    class="flex flex-col w-[260px] rounded-md overflow-hidden cursor-pointer bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border)] hover:border-[#409eff]"
    @click="emit('openMerge', content)"
  >
    <div class="px-3 py-2 text-sm font-medium text-[var(--ant-color-text)] truncate">
      {{ mergePayload.title }}
    </div>
    <div class="flex flex-col gap-0.5 px-3 pb-2">
      <div
        v-for="(line, idx) in mergePreviewLines"
        :key="idx"
        class="text-12px text-[var(--ant-color-text-secondary)] truncate"
      >
        {{ line }}
      </div>
    </div>
    <div
      class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]"
    >
      èŠå¤©è®°å½•
    </div>
  </div>
  <!-- åˆå¹¶æ¶ˆæ¯è§£æžå¤±è´¥å…œåº• -->
  <div
    v-else-if="isMerge"
    class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]"
  >
    [聊天记录]
  </div>
  <!-- é¢‘道素材:图文卡片,点击拉富文本 / è·³å¤–链 -->
  <MaterialBubble v-else-if="isMaterial" :content="props.content" />
  <!-- æœªçŸ¥ç±»åž‹é™çº§ -->
  <div
    v-else
    class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]"
  >
    [不支持的内容类型]
  </div>
</template>
<style scoped>
/* æ°”泡尾巴小三角指向对应头像(对方在左、自己在右);走 ::before + 4 è¾¹ border é…è‰²ç”»ï¼šé€æ˜Ž 3 è¾¹ + å®žè‰² 1 è¾¹ï¼Œ
   é¢œè‰²ä¸Žæ°”泡背景对应,留 1px è§†è§‰åƒè¿›åŽ»ï¼Œçœä¸€å¼ å›¾ç‰‡ */
.message-bubble--other::before,
.message-bubble--self::before {
  content: '';
  position: absolute;
  top: 12px;
  width: 0;
  height: 0;
  border-style: solid;
}
.message-bubble--other {
  --im-message-bubble-other-bg: #f5f5f5;
}
.message-bubble--other.message-bubble--text,
.message-bubble--other.message-bubble--voice {
  background-color: var(--im-message-bubble-other-bg);
}
.message-bubble--other::before {
  left: -5px;
  border-width: 5px 6px 5px 0;
  border-color: transparent var(--im-message-bubble-other-bg) transparent transparent;
}
.message-bubble--self::before {
  right: -5px;
  border-width: 5px 0 5px 6px;
  border-color: transparent transparent transparent #95ec69;
}
/* æ•´ä½“放进 :global(),避免 Vue scoped æŠŠ `:global(.dark) .xxx` å¡Œç¼©æˆè£¸ `.dark` è€ŒæŠŠå˜é‡åˆ·åˆ° <html> */
:global(.dark .message-bubble--other) {
  --im-message-bubble-other-bg: rgb(255 255 255 / 12%);
}
/* :deep ç©¿é€ scoped å­ç»„ä»¶ DOM;el-icon åœ¨æš—色模式下全局 color è¢« .el-icon{color:var(--color)} å¹²æ‰°ï¼ŒæŠŠ voice å›¾æ ‡ fill é”æ­» */
.message-bubble__voice-icon :deep(svg) {
  fill: #606266 !important;
}
.message-bubble__voice-icon.im-voice-playing :deep(svg) {
  fill: #409eff !important;
}
/* @keyframes éœ€è¦ SCSS å£°æ˜Žï¼›æ’­æ”¾ä¸­çš„脉冲动画 */
.im-voice-playing {
  animation: im-voice-icon-pulse 0.8s infinite;
}
@keyframes im-voice-icon-pulse {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.15);
  }
}
</style>
src/views/im/home/pages/conversation/components/message/message-history.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,807 @@
<script lang="ts" setup>
import type { Message } from '#/views/im/home/types'
import { computed, inject, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useUserStore } from '#/packages/stores/src'
import { Button, Calendar, Input, Modal, Popover, Tag } from 'ant-design-vue'
import dayjs from 'dayjs'
import { getGroupMessageList as apiGetGroupMessageList } from '#/api/im/message/group'
import { getPrivateMessageList as apiGetPrivateMessageList } from '#/api/im/message/private'
import { useMessagePuller } from '#/views/im/home/composables/useMessagePuller'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import {
  ImContentType,
  ImConversationType,
  isFriendChatTip,
  isGroupNotification
} from '#/views/im/utils/constants'
import {
  buildFacePreviewText,
  buildRecallTip,
  buildRecallTipSegments,
  getConversationKey
} from '#/views/im/utils/conversation'
import { getClientConversationId } from '#/views/im/utils/db'
import {
  resolveFriendNotificationSegments,
  resolveFriendNotificationText,
  resolveGroupNotificationSegments
} from '#/views/im/utils/message'
import {
  type CardMessage,
  type FaceMessage,
  type FileMessage,
  getCardLabelInfo,
  type MergeMessage,
  parseMessage,
  type TextMessage
} from '#/views/im/utils/message'
import { formatHistoryTime } from '#/views/im/utils/time'
import {
  getMemberDisplayName,
  getSenderDisplayName,
  getSenderRealNickname
} from '#/views/im/utils/user'
import { GroupMember, type GroupMemberLite } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore'
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
import MessageBubble from './message-bubble.vue'
import TipSegments from './tip-segments.vue'
defineOptions({ name: 'ImMessageHistory' })
const emit = defineEmits<{
  // åŽ†å²æ¶ˆæ¯è¡Œä¸Šçš„"定位"按钮:通知父组件 MessagePanel æ»šåˆ°å¯¹åº”消息位置 + å…³æŽ‰è‡ªå·±
  locate: [messageId: number]
}>()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
const voicePlayer = useVoicePlayer()
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller()
const visible = ref(false)
defineExpose({
  /** æ‰“开历史消息抽屉 */
  open() {
    visible.value = true
  }
})
const conversation = computed(() => conversationStore.activeConversation)
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
const allMessages = computed<Message[]>(() =>
  conversation.value
    ? messageStore.getMessages(
        getClientConversationId(conversation.value.type, conversation.value.targetId)
      )
    : []
)
/** å•条消息的发送人显示名:渲染时按 conversation ä¸Šä¸‹æ–‡èµ° WeChat ä¼˜å…ˆçº§å®žæ—¶ç®— */
function senderDisplayNameOf(message: Message): string {
  return getSenderDisplayName(
    message.senderId,
    conversation.value?.type ?? 0,
    conversation.value?.targetId ?? 0
  )
}
/** ç¾¤å¹¿æ’­äº‹ä»¶ segments çš„æˆå‘˜åè§£æžå™¨ï¼›æŒ‰å½“前会话 targetId èµ° getSenderDisplayName */
function resolveGroupMemberName(message: Message): (userId: number) => string {
  return (id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
}
/** å•条消息的发送人真实昵称:给 UserAvatar è‰²å¡ / alt ç”¨ï¼Œæ°¸è¿œæ˜¯ nickname ä¸æŽºå¤‡æ³¨ */
function senderRealNicknameOf(message: Message): string {
  return getSenderRealNickname(
    message.senderId,
    conversation.value?.type ?? 0,
    conversation.value?.targetId ?? 0
  )
}
/** å•条撤回消息的 tip æ–‡æ¡ˆï¼šbuildRecallTip å†…部按 conversation ä¸Šä¸‹æ–‡å®žæ—¶ç®— sender å */
function recallTipOf(message: Message): string {
  return buildRecallTip(
    message.senderId,
    message.selfSend,
    conversation.value?.type ?? 0,
    conversation.value?.targetId ?? 0
  )
}
/** å•条撤回消息的 tip segments:sender åæ®µæŒ‚可点击 mention */
function recallTipSegmentsOf(message: Message) {
  return buildRecallTipSegments(
    message.senderId,
    message.selfSend,
    conversation.value?.type ?? 0,
    conversation.value?.targetId ?? 0
  )
}
// ==================== æ ‡é¢˜ ====================
/**
 * å¼¹çª—标题:参考微信 PC
 * - ç§èŠï¼šä¸Ž"X"的聊天记录
 * - ç¾¤èŠï¼š"X ç¾¤"的聊天记录(N)
 */
const title = computed(() => {
  if (!conversation.value) {
    return '聊天记录'
  }
  const name = conversation.value.name
  if (isGroup.value) {
    return `"${name}"的聊天记录(${allMessages.value.length})`
  }
  return `与"${name}"的聊天记录`
})
// ==================== æœç´¢ + ç­›é€‰ chip ====================
/** å½“前激活的筛选条件 â€”— å• chip æ¨¡å¼ï¼ˆåŒæ—¶åª 1 ä¸ªï¼‰ï¼Œç‚¹å‡» tab è½æ–°å€¼ï¼ŒÃ— æ¸…空 */
type ActiveFilter =
  | { day: string; kind: 'date'; } // YYYY-MM-DD
  | { kind: 'file' }
  | { kind: 'image' }
  | { kind: 'member'; nickname: string; userId: number; }
  | { kind: 'voice' }
const keyword = ref('')
const activeFilter = ref<ActiveFilter | null>(null)
/** chip æ–‡æ¡ˆï¼šæ—¥æœŸ / ç¾¤æˆå‘˜ å¤šå¸¦å€¼ï¼Œå…¶ä»–直接 tab å */
const filterChipLabel = computed(() => {
  if (!activeFilter.value) {
    return ''
  }
  switch (activeFilter.value.kind) {
    case 'date': {
      return `日期:${activeFilter.value.day}`
    }
    case 'file': {
      return '文件'
    }
    case 'image': {
      return '图片'
    }
    case 'member': {
      return `@${activeFilter.value.nickname}`
    }
    case 'voice': {
      return '语音'
    }
    default: {
      return ''
    }
  }
})
/** ç‚¹ tab è½ç­›é€‰ï¼›åŒ kind é‡å¤ç‚¹å‡» â†’ å½“ toggle å…³æŽ‰ï¼ˆé¿å…è¿·æƒ‘) */
function setFilter(filter: ActiveFilter) {
  if (
    activeFilter.value?.kind === filter.kind &&
    filter.kind !== 'date' &&
    filter.kind !== 'member'
  ) {
    activeFilter.value = null
    return
  }
  activeFilter.value = filter
}
/** chip Ã— å…³é—­ / é‡ç½® */
function clearFilter() {
  activeFilter.value = null
}
// ==================== æ—¥æœŸ popover ====================
const datePopoverVisible = ref(false)
const datePickerValue = ref(dayjs())
/** æ—¥æœŸ popover ç¡®å®šï¼šæŠŠ Date â†’ YYYY-MM-DD è½åˆ° activeFilter,关 popover */
function onDateConfirm() {
  if (!datePickerValue.value) {
    datePopoverVisible.value = false
    return
  }
  const day = datePickerValue.value.format('YYYY-MM-DD')
  activeFilter.value = { kind: 'date', day }
  datePopoverVisible.value = false
}
// ==================== ç¾¤æˆå‘˜ popover ====================
const memberPopoverVisible = ref(false)
const memberSearchKeyword = ref('')
/** ç¾¤æˆå‘˜ picker åˆ—表:从 groupStore æ‹‰ + é€‚配 GroupMemberLite + å…³é”®å­—过滤 */
const filteredMembersForPicker = computed<GroupMemberLite[]>(() => {
  if (!isGroup.value || !conversation.value) {
    return []
  }
  const group = groupStore.getGroup(conversation.value.targetId)
  const all = (group?.members || []).map((member) => {
    const friend = friendStore.getFriend(member.userId)
    return {
      userId: member.userId,
      showName: getMemberDisplayName(member, friend),
      nickname: member.nickname,
      avatar: member.avatar,
      status: member.status
    }
  })
  const trimmedKeyword = memberSearchKeyword.value.trim()
  if (!trimmedKeyword) {
    return all
  }
  return all.filter((member) => member.showName.includes(trimmedKeyword))
})
/** ç¾¤æˆå‘˜ picker é€‰æ‹©ï¼šè½ activeFilter + å…³ popover + æ¸…搜索词 */
function onMemberSelect(member: GroupMemberLite) {
  activeFilter.value = {
    kind: 'member',
    userId: member.userId,
    nickname: member.showName
  }
  memberPopoverVisible.value = false
  memberSearchKeyword.value = ''
}
// ==================== åˆ—表过滤 ====================
/** activeFilter å‘½ä¸­ï¼šé»˜è®¤æ— ç­›é€‰æ—¶å…¨éƒ¨å‘½ä¸­ */
function matchesActiveFilter(message: Message): boolean {
  if (!activeFilter.value) {
    return true
  }
  switch (activeFilter.value.kind) {
    case 'date': {
      return dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day
    }
    case 'file': {
      return message.type === ImContentType.FILE
    }
    case 'image': {
      return message.type === ImContentType.IMAGE
    }
    case 'member': {
      return message.senderId === activeFilter.value.userId
    }
    case 'voice': {
      return message.type === ImContentType.VOICE
    }
    default: {
      return true
    }
  }
}
/**
 * å½“前列表:先 activeFilter è¿‡æ»¤ã€å† keyword æ¨¡ç³Šå‘½ä¸­ã€æœ€åŽ reverse(最新在前)
 *
 * å…³é”®å­—命中走 textSnippetOf â€”— æ–‡æœ¬æ‹¿åŽŸæ–‡ã€åª’ä½“æ‹¿"[图片]"等占位词、文件拿文件名
 */
const currentList = computed<Message[]>(() => {
  const trimmedKeyword = keyword.value.trim()
  let list = allMessages.value.filter((message) => matchesActiveFilter(message))
  if (trimmedKeyword) {
    list = list.filter((message) => textSnippetOf(message).includes(trimmedKeyword))
  }
  return list.toReversed()
})
// ==================== åŠ è½½æ›´æ—©æ¶ˆæ¯ ====================
const HISTORY_PAGE_SIZE = 50
const loadingMore = ref(false)
const hasMore = ref(true)
/**
 * åŠ è½½æ›´æ—©æ¶ˆæ¯ï¼šæ‹¿å½“å‰æœ€æ—©ä¸€æ¡ id ä½œ maxId(不含),调 list æŽ¥å£æ‹‰ä¸€é¡µ + convert + prepend
 *
 * - æœªå¯¹æŽ¥ list æŽ¥å£çš„ type / keyword / sender è¿‡æ»¤å‚数:后端只支持 maxId + limit æ¸¸æ ‡åˆ†é¡µï¼Œ
 *   tab ç­›é€‰åœ¨å‰ç«¯åšï¼ˆæ•°æ®æ¥å›žåˆ°æœ¬åœ°åŽè¿‡æ»¤ï¼‰
 * - æœ¬åœ°å ä½è·³è¿‡ï¼šåŽç«¯æ²¡æ³•按 messageId æŸ¥
 * - è¿”回数量 < limit è§†ä¸ºåˆ°é¡¶
 */
async function loadEarlier() {
  // é‡å…¥ / åˆ°é¡¶ / æ— ä¼šè¯ æ—©é€€ï¼šé¿å…é‡å¤è¯·æ±‚或在 conversation åˆ‡æ¢é—´éš™è§¦å‘
  if (loadingMore.value || !hasMore.value || !conversation.value) {
    return
  }
  // ä»… PRIVATE / GROUP èµ°åˆ†é¡µæŽ¥å£ï¼›CHANNEL å•向广播、没有 list æŽ¥å£ï¼Œè½åˆ° else ä¼šè¯¯è°ƒç§èŠæŽ¥å£ï¼ˆreceiverId ä¼  channelId)
  const requestedType = conversation.value.type
  if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) {
    return
  }
  // å¿«ç…§å½“前会话主键:await æœŸé—´ç”¨æˆ·åˆ‡èµ° / å…³é—­é¢æ¿æ—¶ä¸¢å¼ƒå“åº”,避免旧会话历史被 prepend åˆ°æ–°ä¼šè¯é€ æˆä¸²å·
  const requestedKey = getConversationKey(conversation.value)
  const requestedTargetId = conversation.value.targetId
  const requestedIsGroup = requestedType === ImConversationType.GROUP
  loadingMore.value = true
  try {
    // ç®— maxId(不含,作为后端游标):取当前会话本地缓存里最早一条服务端 id;
    // æœ¬åœ°ä¹è§‚占位消息没有服务端 id,要剔除
    // å…¨æ˜¯å ä½ / åˆ—表为空时 reduce ä¸æ›´æ–°åˆå€¼ï¼ˆPOSITIVE_INFINITY),转成 undefined â†’ åŽç«¯ä»Žæœ€æ–°æ‹‰
    let earliestId = Number.POSITIVE_INFINITY
    for (const message of allMessages.value) {
      if (message.id && message.id > 0) {
        earliestId = Math.min(earliestId, message.id)
      }
    }
    const maxId = Number.isFinite(earliestId) ? earliestId : undefined
    // è°ƒåŽç«¯ list æŽ¥å£ï¼šç§èŠ / ç¾¤èŠæŽ¥å£ç­¾åä¸åŒï¼Œåˆ†æ”¯è°ƒåº¦ï¼›è¿”回结果用 useMessagePuller
    // æš´éœ²çš„ convert å‡½æ•°è½¬æˆæœ¬åœ° Message(与 puller åŒä¸€ä»½å­—段映射,避免分歧)
    let earlier: Message[] = []
    let pageLength = 0
    if (requestedIsGroup) {
      const list = await apiGetGroupMessageList({
        groupId: requestedTargetId,
        maxId,
        limit: HISTORY_PAGE_SIZE
      })
      earlier = (list || []).map((message) => convertGroupMessage(message))
      pageLength = list?.length ?? 0
    } else {
      const list = await apiGetPrivateMessageList({
        receiverId: requestedTargetId,
        maxId,
        limit: HISTORY_PAGE_SIZE
      })
      earlier = (list || []).map((message) => convertPrivateMessage(message))
      pageLength = list?.length ?? 0
    }
    // await æœŸé—´ active å¯èƒ½è¢«å¤–部置 null / æ¢ä¸»é”®ï¼šç›´æŽ¥ä¸¢å¼ƒå“åº”;不更新 hasMore(旧会话到顶不代表新会话到顶)也不 prepend
    if (!conversation.value || getConversationKey(conversation.value) !== requestedKey) {
      return
    }
    // è¿”回数量 < limit è§†ä¸ºåˆ°é¡¶ â€”— å…³é—­"加载更早"按钮,避免后续点击空跑接口
    if (pageLength < HISTORY_PAGE_SIZE) {
      hasMore.value = false
    }
    // åˆå¹¶åˆ° messageStore:prependMessageList å†…部去重 + å‡åºåˆå¹¶ + è½ IndexedDB;
    // ä¸»èŠå¤©é¢æ¿çš„ messages æ˜¯åŒä¸€ä»½å¼•用,老消息也会一起出现在主面板里(符合预期)
    messageStore.prependMessageList(requestedType, requestedTargetId, earlier)
  } finally {
    loadingMore.value = false
  }
}
// ==================== å¼¹çª—打开 reset ====================
/** å¼¹çª—打开时把上次的 chip / æœç´¢ / åŠ è½½çŠ¶æ€éƒ½æ¸…å¹²å‡€ï¼Œé¿å…ä¸Šæ¬¡çš„çŠ¶æ€æ®‹ç•™è¿·æƒ‘ */
function onDialogOpen() {
  activeFilter.value = null
  keyword.value = ''
  hasMore.value = true
  datePopoverVisible.value = false
  memberPopoverVisible.value = false
  memberSearchKeyword.value = ''
  datePickerValue.value = dayjs()
  // æœ¬åœ°æ— æ¶ˆæ¯æ—¶ç«‹å³æ‹‰ä¸€æ¬¡ï¼ˆmaxId=undefined ä»Žæœ€æ–°å¼€å§‹ï¼‰ï¼Œé¿å…æ–°è®¾å¤‡ / ç¼“存清后弹窗只显示"暂无消息"
  if (allMessages.value.length === 0 && hasMore.value && conversation.value) {
    void loadEarlier()
  }
}
/** æŠ½å±‰å…³é—­æ—¶å¤ä½ + åœè¯­éŸ³ */
watch(visible, (value) => {
  if (!value) {
    activeFilter.value = null
    keyword.value = ''
    voicePlayer.stop()
  }
})
/**
 * æŠ½å±‰å¼€ç€æ—¶å¤–部切了 active conversation:dialog çš„ title / åˆ—表 / isGroup å…¨éƒ¨è·Ÿç€æ–° conversation èµ°ï¼Œ
 * è¿™é‡ŒæŠŠåˆ†é¡µæ€ä¸€å¹¶é‡ç½®ï¼›å¦åˆ™æ—§ä¼šè¯æ®‹ç•™çš„ loadingMore=true / hasMore=false ä¼šè®©æ–°ä¼šè¯"加载更早"按钮失效
 */
watch(conversation, () => {
  loadingMore.value = false
  hasMore.value = true
})
// ==================== helper ====================
/** å–头像 url:自己用 userStore,群里查 groupStore æˆå‘˜ï¼Œç§èŠç”¨ conversation.avatar */
function getAvatar(message: Message): string {
  if (message.selfSend) {
    return userStore.userInfo?.avatar || ''
  }
  if (!conversation.value) {
    return ''
  }
  if (isGroup.value) {
    const group = groupStore.getGroup(conversation.value.targetId)
    return group?.members?.find((member) => member.userId === message.senderId)?.avatar || ''
  }
  return conversation.value.avatar || ''
}
/** å…³é”®å­—命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string {
  if (isFriendChatTip(message.type)) {
    return resolveFriendNotificationText(message)
  }
  switch (message.type) {
    case ImContentType.CARD: {
      const card = parseMessage<CardMessage>(message.content)
      return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}`
    }
    case ImContentType.FACE: {
      return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
    }
    case ImContentType.FILE: {
      return parseMessage<FileMessage>(message.content)?.name ?? '[文件]'
    }
    case ImContentType.IMAGE: {
      return '[图片]'
    }
    case ImContentType.MERGE: {
      const merge = parseMessage<MergeMessage>(message.content)
      return merge?.title ?? '[聊天记录]'
    }
    case ImContentType.RECALL: {
      return recallTipOf(message)
    }
    case ImContentType.TEXT: {
      return parseMessage<TextMessage>(message.content)?.content ?? ''
    }
    case ImContentType.VIDEO: {
      return '[视频]'
    }
    case ImContentType.VOICE: {
      return '[语音]'
    }
    default: {
      return ''
    }
  }
}
/**
 * å®šä½åˆ°èŠå¤©ä½ç½®ï¼šemit ç»™ MessagePanel èµ° scrollIntoView + çŸ­æš‚高亮,再关掉自己
 * messageId === 0(本地占位消息)跳过——还没拿到真实 id,DOM ä¸Šæ²¡æ³• querySelector
 */
function locateMessage(messageId: number) {
  if (!messageId) {
    return
  }
  emit('locate', messageId)
  visible.value = false
}
</script>
<template>
  <!--
    åŽ†å²æ¶ˆæ¯å¼¹çª—ï¼ˆå¯¹é½å¾®ä¿¡ PC)
    - å±…中 dialog,title "与「X」聊天记录" / "「X ç¾¤ã€èŠå¤©è®°å½•(N)"
    - é»˜è®¤æ— ç­›é€‰ï¼Œå…¨é‡å€’序展示;点击 tab è½ç­›é€‰ â†’ æœç´¢æ¡†å·¦ä¾§å‡º chip Ã— å¯æ¸…
    - æ–‡ä»¶ / å›¾ç‰‡ / é“¾æŽ¥ï¼šç›´æŽ¥è½ç­›é€‰ï¼›æ—¥æœŸ / ç¾¤æˆå‘˜ï¼šå¼¹ popover äºŒæ¬¡é€‰æ‹©å†è½
    - "加载更早消息":列表底部按钮,点击调 /im/message/{private,group}/list æ‹¿ä¸€é¡µ + prepend
  -->
  <Modal
    v-model:open="visible"
    :title="title"
    width="640px"
    :footer="null"
    class="im-message-history__dialog"
    @after-open-change="(open: boolean) => open && onDialogOpen()"
  >
    <div class="flex flex-col gap-3 h-[520px]">
      <!-- æœç´¢åŒºï¼šactiveFilter å­˜åœ¨æ—¶å·¦ä¾§å‡º chip Ã— å¯æ¸…,否则纯搜索框 -->
      <div
        class="im-message-history__searchbar flex items-center gap-2 px-3 py-1.5 rounded-md bg-[var(--ant-color-fill-secondary)]"
      >
        <Tag
          v-if="activeFilter"
          closable
          size="small"
          class="im-message-history__chip flex-shrink-0"
          @close="clearFilter"
        >
          {{ filterChipLabel }}
        </Tag>
        <Icon
          v-else
          icon="ant-design:search-outlined"
          class="text-[var(--ant-color-text-secondary)]"
        />
        <input
          v-model="keyword"
          type="text"
          placeholder="搜索"
          class="im-message-history__search-input flex-1 min-w-0 bg-transparent border-none outline-none text-sm text-[var(--ant-color-text)]"
        />
      </div>
      <!-- Tab è¡Œï¼šæ–‡ä»¶ / å›¾ç‰‡ / è¯­éŸ³ / æ—¥æœŸ(popover) / ç¾¤æˆå‘˜(popover, ä»…群聊);底部一条分割线把 tab åŒºè·Ÿæ¶ˆæ¯åˆ—表分开,对齐微信观感 -->
      <div
        class="flex gap-5 px-2 pb-2 text-sm flex-shrink-0 text-[#1989fa] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
      >
        <span
          class="im-message-history__tab cursor-pointer"
          :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'file' }"
          @click="setFilter({ kind: 'file' })"
        >
          æ–‡ä»¶
        </span>
        <span
          class="im-message-history__tab cursor-pointer"
          :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'image' }"
          @click="setFilter({ kind: 'image' })"
        >
          å›¾ç‰‡
        </span>
        <span
          class="im-message-history__tab cursor-pointer"
          :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'voice' }"
          @click="setFilter({ kind: 'voice' })"
        >
          è¯­éŸ³
        </span>
        <!-- æ—¥æœŸï¼šel-popover åŒ… el-calendar,确认后才落筛选 -->
        <Popover
          v-model:open="datePopoverVisible"
          trigger="click"
          placement="bottom"
          :overlay-style="{ width: '320px' }"
        >
          <span
            class="im-message-history__tab cursor-pointer"
            :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'date' }"
          >
            æ—¥æœŸ
          </span>
          <template #content>
            <div class="im-message-history__date-panel">
              <div class="px-2 pt-1 pb-2 text-13px font-medium text-[var(--ant-color-text)]">
                é€‰æ‹©å‘送日期
              </div>
              <Calendar
                v-model:value="datePickerValue"
                :fullscreen="false"
                class="im-message-history__calendar"
              />
              <div class="flex gap-2 justify-end px-2 pt-2">
                <Button size="small" @click="datePopoverVisible = false">取消</Button>
                <Button size="small" type="primary" @click="onDateConfirm">确定</Button>
              </div>
            </div>
          </template>
        </Popover>
        <!-- ç¾¤æˆå‘˜ï¼šä»…群聊;popover å†…自带搜索 + GroupMember åˆ—表 -->
        <Popover
          v-if="isGroup"
          v-model:open="memberPopoverVisible"
          trigger="click"
          placement="bottom"
          :overlay-style="{ width: '320px' }"
        >
          <span
            class="im-message-history__tab cursor-pointer"
            :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'member' }"
          >
            ç¾¤æˆå‘˜
          </span>
          <template #content>
            <div>
              <Input v-model:value="memberSearchKeyword" placeholder="搜索群成员" size="small">
                <template #prefix>
                  <Icon icon="ant-design:search-outlined" />
                </template>
              </Input>
              <div class="max-h-[360px] overflow-y-auto mt-2">
                <GroupMember
                  v-for="member in filteredMembersForPicker"
                  :key="member.userId"
                  :member="member"
                  :height="44"
                  :clickable="false"
                  class="cursor-pointer hover:bg-[var(--ant-color-fill)]"
                  @click="onMemberSelect(member)"
                />
                <div
                  v-if="filteredMembersForPicker.length === 0"
                  class="py-6 text-12px text-center text-[var(--ant-color-text-disabled)]"
                >
                  æ²¡æœ‰åŒ¹é…çš„æˆå‘˜
                </div>
              </div>
            </div>
          </template>
        </Popover>
      </div>
      <!-- æ¶ˆæ¯åˆ—表 -->
      <div class="flex-1 overflow-y-auto">
        <template v-for="message in currentList" :key="message.id || message.clientMessageId">
          <!-- å¥½å‹ä¼šè¯äº‹ä»¶ï¼ˆFRIEND_ADD / FRIEND_DELETE):居中灰色,不挂头像 / sender,
               è·Ÿä¸»èŠå¤©é¢æ¿é‡Œ MessageItem çš„æ¸²æŸ“语义对齐 -->
          <div
            v-if="isFriendChatTip(message.type)"
            class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
          >
            <TipSegments :segments="resolveFriendNotificationSegments(message)" />
          </div>
          <!-- ç¾¤å¹¿æ’­äº‹ä»¶ï¼šè·Ÿå¥½å‹äº‹ä»¶åŒç°è‰²æ ·å¼ï¼Œmention æ®µæŒ‚点击弹 UserInfoCard -->
          <div
            v-else-if="isGroupNotification(message.type)"
            class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
          >
            <TipSegments
              :segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))"
            />
          </div>
          <!-- æ™®é€šæ¶ˆæ¯è¡Œ -->
          <div
            v-else
            class="im-message-history__row flex gap-3 items-start px-1 py-3 border-b border-b-solid border-[var(--ant-color-border-secondary)]"
          >
            <UserAvatar
              :url="getAvatar(message)"
              :name="senderRealNicknameOf(message)"
              :size="36"
              :clickable="false"
            />
            <div class="flex-1 min-w-0">
              <div
                class="flex justify-between items-start text-12px text-[var(--ant-color-text-secondary)]"
              >
                <span class="font-medium text-[var(--ant-color-text)]">
                  {{ senderDisplayNameOf(message) }}
                </span>
                <span class="im-message-history__meta relative flex-shrink-0">
                  <span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span>
                  <!-- å®šä½åˆ°èŠå¤©ä½ç½®ï¼šabsolute æµ®åœ¨æ—¶é—´ä¸‹æ–¹ï¼Œè¡Œ hover æ‰æ˜¾ç¤ºï¼Œ
                       ä¸å‚与右侧栏 flex æŽ’版(避免隐藏时占位让"我"和内容之间留空);
                       ä»…有真实 id çš„æ¶ˆæ¯æ‰æ”¯æŒï¼ˆæœ¬åœ°å ä½æ¶ˆæ¯ä¸è¡Œï¼‰ -->
                  <span
                    v-if="message.id && message.id > 0"
                    class="im-message-history__locate"
                    @click="locateMessage(message.id!)"
                  >
                    å®šä½åˆ°èŠå¤©ä½ç½®
                  </span>
                </span>
              </div>
              <div class="mt-1.5">
                <!-- æ’¤å›žï¼šå•独走灰色 tip,sender åæ®µå¯ç‚¹å‡» -->
                <div
                  v-if="message.type === ImContentType.RECALL"
                  class="text-sm italic text-[var(--ant-color-text-secondary)]"
                >
                  <TipSegments :segments="recallTipSegmentsOf(message)" />
                </div>
                <!-- å…¶å®ƒç±»åž‹èµ° MessageBubble å¤ç”¨ä¸»èŠå¤©æ°”泡 -->
                <MessageBubble
                  v-else
                  :type="message.type"
                  :content="message.content"
                  @open-merge="openMergeDetail?.($event)"
                />
              </div>
            </div>
          </div>
        </template>
        <div
          v-if="currentList.length === 0"
          class="py-10 text-center text-13px text-[var(--ant-color-text-disabled)]"
        >
          {{ keyword || activeFilter ? '没有匹配的消息' : '暂无消息' }}
        </div>
        <!-- åŠ è½½æ›´æ—©æ¶ˆæ¯ï¼šåˆ—è¡¨åº•éƒ¨ trigger(reverse åŽæœ€æ—©çš„在最下,按钮放底部更自然);
             filter å‘½ä¸­ 0 æ¡æ—¶ä»ä¿ç•™ â€”— åŠ è½½æ›´æ—©å¯èƒ½å¸¦å›žåŒ¹é…å†…å®¹ -->
        <div
          v-if="hasMore && allMessages.length > 0"
          class="py-3 text-center border-t border-t-solid border-[var(--ant-color-border-secondary)]"
        >
          <Button :loading="loadingMore" type="link" @click="loadEarlier">
            åŠ è½½æ›´æ—©æ¶ˆæ¯
          </Button>
        </div>
        <!-- "没有更早" åªåœ¨å·²æœ‰åŒ¹é…é¡¹æ—¶éœ²å‡ºï¼Œé¿å…å’Œä¸Šé¢"没有匹配的消息"空态文案重叠 -->
        <div
          v-else-if="!hasMore && currentList.length > 0"
          class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]"
        >
          æ²¡æœ‰æ›´æ—©çš„æ¶ˆæ¯äº†
        </div>
      </div>
    </div>
  </Modal>
</template>
<style scoped>
/* :deep ç©¿é€ el-tag å­ç»„ä»¶ DOM;搜索区 chip ç¦æŽ‰ hover é¢œè‰²è¿‡æ¸¡ / Ã— å›¾æ ‡åŠ¨æ•ˆï¼Œé¿å…åœ¨æœç´¢åŒºé‡Œæœ‰æŠ–åŠ¨æ„Ÿ */
.im-message-history__chip,
.im-message-history__chip :deep(.el-tag__close) {
  transition: none !important;
  animation: none !important;
}
/* ã€Œå®šä½åˆ°èŠå¤©ä½ç½®ã€æŒ‰é’®ï¼šçˆ¶è¡Œ hover æ‰æ˜¾ç¤ºï¼Œèµ° .parent:hover .child è·¨å…ƒç´ çŠ¶æ€è”åŠ¨ */
.im-message-history__locate {
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 4px;
  display: none;
  white-space: nowrap;
  color: #1989fa;
  cursor: pointer;
  transition: color 0.15s;
}
.im-message-history__row:hover .im-message-history__locate {
  display: block;
}
.im-message-history__locate:hover {
  color: #146fc7;
}
/* ::after ä¼ªå…ƒç´ ç”»æ¿€æ´»ä¸‹åˆ’线;tab é»˜è®¤è“è‰²è¡Œå†…文字,激活态加下划线 */
.im-message-history__tab {
  position: relative;
  padding: 4px 2px;
  color: #1989fa;
  transition: color 0.15s;
}
.im-message-history__tab:hover {
  color: #2f81d4;
}
.im-message-history__tab--active::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: #1989fa;
  border-radius: 1px;
}
/* :deep ç©¿é€ antd Calendar(fullscreen=false å¡ç‰‡æ¨¡å¼ï¼‰å­ DOM,压一压塞进 320 popover */
.im-message-history__calendar :deep(.ant-picker-calendar-header) {
  padding: 4px 8px;
}
.im-message-history__calendar :deep(.ant-picker-content) {
  font-size: 12px;
}
.im-message-history__calendar :deep(.ant-picker-cell) {
  padding: 1px 0;
}
.im-message-history__calendar :deep(.ant-picker-cell .ant-picker-calendar-date) {
  height: 28px;
  margin: 0 2px;
  padding: 2px 4px 0;
}
</style>
src/views/im/home/pages/conversation/components/message/message-item.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1055 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group'
import type { Message } from '../../../../types'
import { computed, inject } from 'vue'
import { confirm } from '#/packages/effects/common-ui/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useUserStore } from '#/packages/stores/src'
import { useClipboard } from '@vueuse/core'
import { message as antdMessage, Tag } from 'ant-design-vue'
import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '#/api/im/group'
import { removeGroupMember } from '#/api/im/group/member'
import { getCurrentUserId } from '#/views/im/utils/auth'
import {
  MESSAGE_GROUP_READ_ENABLED,
  MESSAGE_PRIVATE_READ_ENABLED,
  MESSAGE_RECALL_WINDOW_MS,
  MESSAGE_TIME_TIP_GAP_MS
} from '#/views/im/utils/config'
import {
  ImContentType,
  ImConversationType,
  ImForwardMode,
  ImFriendAddSource,
  ImGroupMemberRole,
  ImMessageReceiptStatus,
  ImMessageStatus,
  isFriendChatTip,
  isGroupNotification,
  isMediaMessageType,
  isNormalMessage,
  isRtcCallTip
} from '#/views/im/utils/constants'
import { buildRecallTipSegments } from '#/views/im/utils/conversation'
import {
  buildQuoteFromMessage,
  type CardMessage,
  extractAddableFace,
  getQuoteFromMessage,
  type MentionCandidate,
  parseMessage,
  type TextMessage
} from '#/views/im/utils/message'
import {
  parseRtcCallPayload,
  resolveFriendNotificationSegments,
  resolveGroupNotificationSegments,
  resolveRtcCallPrivateBubbleText,
  resolveRtcCallTipSegments
} from '#/views/im/utils/message'
import { formatTimeTip } from '#/views/im/utils/time'
import {
  getMemberDisplayName,
  getMentionCandidates,
  getSenderDisplayName,
  getSenderRealNickname,
  isGroupQuit
} from '#/views/im/utils/user'
import { UserAvatar } from '../../../../components/user'
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
import { useMessageSender } from '../../../../composables/useMessageSender'
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFaceStore } from '../../../../store/faceStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore'
import { useImUiStore } from '../../../../store/uiStore'
import {
  IM_FORWARD_DIALOG_KEY,
  IM_MERGE_DETAIL_DIALOG_KEY,
  IM_RTC_REDIAL_KEY
} from './forward/keys'
import MessageBubble from './message-bubble.vue'
import MessageReadStatus from './message-read-status.vue'
import ReplyPreview from './reply-preview.vue'
import TipSegments from './tip-segments.vue'
defineOptions({ name: 'ImMessageItem' })
const props = defineProps<{
  message: Message
  /** åˆ—表中的上一条消息,用于判断是否要在当前消息上方渲染时间分隔条;不传按列表第一条处理 */
  prevMessage?: Message
}>()
const emit = defineEmits<{
  /** å¼•用块点击 â†’ MessagePanel æ»šå®šä½ + é«˜äº® */
  locate: [messageId: number]
  /** ç¦è¨€ï¼šéœ€è¦çˆ¶ç»„件打开时长选择弹窗 */
  mute: [groupId: number, userId: number, displayName: string]
  /** æ•°æ®å˜æ›´åŽåˆ·æ–°ç¾¤ä¿¡æ¯ */
  reload: []
}>()
// ==================== Stores / Hooks ====================
const userStore = useUserStore()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const faceStore = useFaceStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
const { uploadAndSendMedia } = useMediaUploader()
const muteOverlay = useMuteOverlay()
// legacy:true å…¼å®¹ HTTP çŽ¯å¢ƒï¼Œæ²¡æœ‰ navigator.clipboard æ—¶é™çº§åˆ° execCommand
const { copy: copyToClipboard } = useClipboard({ legacy: true })
// ==================== å†…容类型判断 ====================
/** æ˜¯å¦åœ¨å½“前消息上方渲染时间分隔条:列表第一条 / è·ä¸Šä¸€æ¡è¶…过阈值;缺 sendTime ä¸æ¸²æŸ“;频道素材每条都显示 */
const shouldShowTimeTip = computed(() => {
  if (!props.message.sendTime) {
    return false
  }
  if (isMaterial.value) {
    return true
  }
  if (!props.prevMessage?.sendTime) {
    return true
  }
  return props.message.sendTime - props.prevMessage.sendTime > MESSAGE_TIME_TIP_GAP_MS
})
/** ä»… MessageItem è‡ªèº«ä»è¦ç”¨åˆ°çš„ type åˆ¤å®šï¼ˆå…¶å®ƒåˆ†æ”¯å·²ä¸‹æ²‰åˆ° MessageBubble) */
const isVoice = computed(() => props.message.type === ImContentType.VOICE)
const isMerge = computed(() => props.message.type === ImContentType.MERGE)
/**
 * é¢‘道素材在「频道会话」内仿微信公众号样式(居中 + æ— å¤´åƒï¼‰ï¼›
 * ç§èŠ / ç¾¤èŠé‡Œè¢«è½¬å‘过来的素材按 selfSend èµ°æ ‡å‡†æ°”泡布局(自己右、对方左、带头像)
 */
const isMaterial = computed(
  () =>
    props.message.type === ImContentType.MATERIAL &&
    conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** å½“前是否在公众号 / é¢‘道会话内:限制右键菜单只展示转发 / åˆ é™¤ */
const isChannelConversation = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
// ==================== äº‹ä»¶æ¶ˆæ¯ï¼ˆæ’¤å›ž / å¥½å‹ / ç¾¤å¹¿æ’­ï¼‰ ====================
// è¿™ä¸‰ç±»ä¸èµ°æ™®é€šæ°”泡,渲染成居中灰色 tip;判断 + æ–‡æ¡ˆé…å¯¹æ”¾ä¸€èµ·ï¼Œæ–°å¢žç¬¬å››ç±»äº‹ä»¶åªéœ€åœ¨æœ¬å—改完
/** æ˜¯å¦å·²æ’¤å›žï¼špull / WS ä¸¤è·¯éƒ½ä¼šè°ƒ recallMessage æŠŠåŽŸæ¶ˆæ¯æ›´æ–°ä¸º type=RECALL,渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImContentType.RECALL)
/** æ’¤å›žæç¤º segments:依赖 activeConversation å®žæ—¶ç®— sender å */
const recallTipSegments = computed(() => {
  const conversation = conversationStore.activeConversation
  return buildRecallTipSegments(
    props.message.senderId,
    props.message.selfSend,
    conversation?.type ?? 0,
    conversation?.targetId ?? 0
  )
})
/** æ˜¯å¦ä¼šè¯å†…好友事件气泡(FRIEND_ADD / FRIEND_DELETE) */
const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type))
/** å¥½å‹äº‹ä»¶ segments */
const friendChatTipSegments = computed(() => resolveFriendNotificationSegments(props.message))
/** æ˜¯å¦ç¾¤å¹¿æ’­äº‹ä»¶ï¼ˆGROUP_CREATE..GROUP_BANNED æ®µä½ï¼ŒæŽ’除 GROUP_MEMBER_SETTING_UPDATE ä¸ªäººä¿¡å·ï¼‰ */
const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type))
/** ç¾¤å¹¿æ’­äº‹ä»¶ segments */
const groupNotificationSegments = computed(() =>
  resolveGroupNotificationSegments(props.message, (id: number) =>
    getSenderDisplayName(id, ImConversationType.GROUP, props.message.targetId ?? 0)
  )
)
/** ç§èŠ RTC_CALL_END èµ°ã€Œå‡†æ°”泡」(左右分布 + ç”µè¯å›¾æ ‡ + æ–‡æ¡ˆï¼‰ï¼›éžç§èŠåœºæ™¯ä¸º null */
const rtcCallEndPrivatePayload = computed(() => {
  if (props.message.type !== ImContentType.RTC_CALL_END) {
    return null
  }
  const payload = parseRtcCallPayload(props.message.content)
  return payload?.conversationType === ImConversationType.PRIVATE ? payload : null
})
/** æ˜¯å¦ç§èŠé€šè¯æ°”泡 */
const isRtcCallPrivateBubbleMessage = computed(() => rtcCallEndPrivatePayload.value !== null)
/** ç§èŠé€šè¯æ°”泡文案(按 operatorUserId æ˜¯å¦å½“前用户区分;对齐微信两端不同视角) */
const rtcCallPrivateBubbleText = computed(() =>
  resolveRtcCallPrivateBubbleText(rtcCallEndPrivatePayload.value)
)
/** æ˜¯å¦ä¼šè¯å†…群通话事件居中 tip */
const isRtcCallTipMessage = computed(() => {
  if (!isRtcCallTip(props.message.type)) {
    return false
  }
  const payload = parseRtcCallPayload(props.message.content)
  if (payload?.conversationType !== ImConversationType.GROUP) {
    return false
  }
  return !isRtcCallPrivateBubbleMessage.value
})
/** é€šè¯äº‹ä»¶ segments;仅群聊 tip ç”¨ */
const rtcCallTipSegments = computed(() => resolveRtcCallTipSegments(props.message))
// ==================== æ¶ˆæ¯å†…容解析 / payload ====================
/** å¼•用对象:气泡内嵌入展示;非引用消息返回 null,模板 v-if ä¸æ¸²æŸ“ */
const quote = computed(() => getQuoteFromMessage(props.message.content))
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY) // MessagePanel æ³¨å…¥çš„弹窗触发函数
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
const redialRtcCall = inject(IM_RTC_REDIAL_KEY)
/** ç§èŠ RTC_CALL_END æ°”泡点击:用同款 mediaType é‡æ‹¨ */
function handleRtcCallBubbleClick() {
  const mediaType = rtcCallEndPrivatePayload.value?.mediaType
  if (mediaType == null) {
    return
  }
  redialRtcCall?.(mediaType)
}
const multiSelect = useMessageMultiSelect() // å¤šé€‰æ¨¡å¼ï¼šæ¨¡å—级单例 composable
/** åˆå¹¶æ¶ˆæ¯æ°”泡点击:打开详情弹窗(嵌套合并由弹窗内部 push æ ˆï¼‰ */
function handleMergeOpen(content: string) {
  openMergeDetail?.(content)
}
/** æ–‡æœ¬æ°”泡 @ mention å€™é€‰åå­—:仅群消息有效,按 atUserIds åæŸ¥ç¾¤æˆå‘˜çœŸå®žæ˜µç§°ï¼›éž TEXT ä¸èµ° store è¯»ï¼Œè®© getMentionCandidates ç›´æŽ¥è¿”回稳定空数组 */
const textMentions = computed<MentionCandidate[]>(() => {
  if (props.message.type !== ImContentType.TEXT) {
    return getMentionCandidates(undefined, null)
  }
  return getMentionCandidates(props.message.atUserIds, conversationStore.activeConversation)
})
/** åç‰‡ç‚¹å‡»ï¼šç”¨æˆ·åç‰‡å¼¹ UserInfoCard,群名片弹 GroupInfoCard;其它 targetType(含改包脏数据)忽略 */
function handleCardClick(card: CardMessage, e: MouseEvent) {
  if (!card?.targetId) {
    return
  }
  if (card.targetType === ImConversationType.PRIVATE) {
    uiStore.openUserInfoCardAtEvent(
      { id: card.targetId, nickname: card.name, avatar: card.avatar },
      e,
      ImFriendAddSource.CARD
    )
    return
  }
  if (card.targetType === ImConversationType.GROUP) {
    uiStore.openGroupInfoCardAtEvent(
      {
        id: card.targetId,
        name: card.name,
        showImage: card.avatar,
        memberCount: card.memberCount
      },
      e
    )
  }
}
/** åª’体上传中:MessageBubble è‡ªæ¸²æŸ“遮罩 / è¿›åº¦æ¡ï¼›å¤–层只用作 showSendingLoading åˆ¤å®š */
const isUploading = computed(() => props.message.uploadProgress != null)
/**
 * æ˜¯å¦åœ¨æ°”泡尾部显示「发送中」loading è½¬åœˆ
 *
 * å›¾ç‰‡ / è§†é¢‘ / æ–‡ä»¶æ°”泡内嵌已有进度反馈(遮罩 / è¿›åº¦æ¡ï¼‰ï¼Œå¤–层 loading ä¸å†å åŠ ï¼›
 * è¯­éŸ³æ°”泡只有麦克风 + æ—¶é•¿ï¼Œæ— å†…嵌进度,必须保留外层 loading è®©ç”¨æˆ·æ„ŸçŸ¥æ­£åœ¨å‘送
 */
const showSendingLoading = computed(
  () => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value)
)
// ==================== å‘送人 / å·²è¯» / @ ====================
/** ç¾¤èŠ + å¯¹æ–¹æ¶ˆæ¯ æ—¶ï¼Œåœ¨æ°”泡上方显示发送者昵称 */
const showSenderName = computed(() => {
  if (props.message.selfSend) {
    return false
  }
  return conversationStore.activeConversation?.type === ImConversationType.GROUP
})
/** å‘送者头像;私聊的 conversation.avatar å°±æ˜¯å¯¹æ–¹å¤´åƒï¼ˆopenConversation å…¥å‚约定) */
const senderAvatar = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || props.message.selfSend) {
    return ''
  }
  if (conversation.type === ImConversationType.GROUP) {
    const group = groupStore.getGroup(conversation.targetId)
    return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
  }
  return conversation.avatar || ''
})
/** å¤´åƒè‰²å¡ fallback æ–‡æœ¬ï¼šæ°¸è¿œæ˜¯çœŸå®žæ˜µç§°ï¼Œä¸æŽºå¤‡æ³¨ */
const senderRealNickname = computed(() => {
  const conversation = conversationStore.activeConversation
  return getSenderRealNickname(
    props.message.senderId,
    conversation?.type ?? 0,
    conversation?.targetId ?? 0
  )
})
/** æ°”泡上方发送人显示名(仅群聊对方消息显示):好友备注 > ç¾¤å¤‡æ³¨ > çœŸå®žæ˜µç§° */
const senderDisplayName = computed(() => {
  const conversation = conversationStore.activeConversation
  return getSenderDisplayName(
    props.message.senderId,
    conversation?.type ?? 0,
    conversation?.targetId ?? 0
  )
})
/** ç§èŠã€Œå·²è¯» / æœªè¯»ã€æ€ï¼ˆä»…对自己发送的私聊消息展示;私聊已读全局关闭时不再展示) */
const privateReadLabel = computed(() => {
  if (!MESSAGE_PRIVATE_READ_ENABLED) {
    return ''
  }
  if (!props.message.selfSend) {
    return ''
  }
  if (conversationStore.activeConversation?.type !== ImConversationType.PRIVATE) {
    return ''
  }
  if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) {
    return '已读'
  }
  if (props.message.receiptStatus === ImMessageReceiptStatus.PENDING) {
    return '未读'
  }
  return ''
})
/** æ˜¯å¦éœ€è¦æ˜¾ç¤ºç¾¤å›žæ‰§ popover:自己发的群消息且后端开启了回执(NO_RECEIPT è¡¨ç¤ºå‘送时未要求回执,不渲染;群已读全局关闭时统一不展示) */
const showGroupReadStatus = computed(() => {
  if (!MESSAGE_GROUP_READ_ENABLED) {
    return false
  }
  if (!props.message.selfSend) {
    return false
  }
  if (conversationStore.activeConversation?.type !== ImConversationType.GROUP) {
    return false
  }
  const status = props.message.receiptStatus
  if (status === undefined || status === null) {
    return false
  }
  return status !== ImMessageReceiptStatus.NO_RECEIPT
})
/** å½“前群成员(供 MessageReadStatus è®¡ç®—未读名单;未加载完时兜底空数组不渲染) */
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return []
  }
  const group = groupStore.getGroup(conversation.targetId)
  return (group?.members || []).map((member) => {
    const friend = friendStore.getFriend(member.userId)
    return {
      userId: member.userId,
      showName: getMemberDisplayName(member, friend),
      nickname: member.nickname,
      avatar: member.avatar,
      status: member.status
    }
  })
})
/** æ˜¯å¦ @我(群消息展示小徽标) */
const isAtMe = computed(() => {
  const myId = getCurrentUserId()
  if (!myId) {
    return false
  }
  return (props.message.atUserIds || []).includes(myId)
})
// ==================== å³é”®èœå• / æ“ä½œ ====================
// å³é”®èœå• key å¸¸é‡ï¼›push ç«¯å’Œåˆ†å‘端从同一处取,typo ç¼–译期就能抓
const MENU_KEYS = {
  COPY: 'COPY',
  REPLY: 'REPLY',
  FORWARD: 'FORWARD',
  MULTI_SELECT: 'MULTI_SELECT',
  PIN: 'PIN',
  ADD_TO_FACE: 'ADD_TO_FACE',
  MUTE: 'MUTE',
  UNMUTE: 'UNMUTE',
  KICK: 'KICK',
  RECALL: 'RECALL',
  DELETE: 'DELETE'
} as const
type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]
/**
 * å³é”®èœå•项:
 * - å¼•用:已落库 + æœªæ’¤å›žçš„æ¶ˆæ¯å¯å¼•用,引用块写入会话草稿
 * - æ’¤å›ž / åˆ é™¤ï¼šäº’斥;自己发送 + å·²è½åº“ + æœªæ’¤å›ž + 2 åˆ†é’Ÿå†…显示「撤回」(推服务器),其它显示「删除」(仅本地清)
 *
 * å¥½å‹äº‹ä»¶æ°”泡态不弹菜单
 */
async function handleContextMenu(e: MouseEvent) {
  if (isFriendChatTipMessage.value) {
    return
  }
  // å¤šé€‰æ¨¡å¼ä¸‹ä¸å¼¹èœå•:右键点击就当切换勾选,与单击行为一致
  if (isInMultiSelect.value) {
    if (canForward.value) {
      multiSelect.toggle(props.message)
    }
    return
  }
  type MenuItem = {
    danger?: boolean
    disabled?: boolean
    divided?: boolean
    icon?: string
    key: MenuKey
    name: string
  }
  // é¢‘道(公众号)会话单向消息:只保留转发 + æœ¬åœ°åˆ é™¤ï¼›ä¸æ”¯æŒå¤åˆ¶ / å¼•用 / å¤šé€‰ / æ’¤å›ž / ç¾¤ç®¡ç†æ“ä½œ
  if (isChannelConversation.value) {
    const channelItems: MenuItem[] = []
    if (canForward.value) {
      channelItems.push({
        key: MENU_KEYS.FORWARD,
        name: '转发',
        icon: 'ant-design:share-alt-outlined'
      })
    }
    channelItems.push({
      key: MENU_KEYS.DELETE,
      name: '删除',
      icon: 'ant-design:delete-outlined',
      divided: true,
      danger: true
    })
    uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, channelItems, async (item) => {
      if (item.key === MENU_KEYS.FORWARD) {
        handleForward()
      } else if (item.key === MENU_KEYS.DELETE) {
        handleDelete()
      }
    })
    return
  }
  const items: MenuItem[] = []
  // ã€Œå¤åˆ¶ã€ï¼šä»…文本消息支持;放在第一项,对齐微信桌面右键习惯
  if (props.message.type === ImContentType.TEXT) {
    items.push({
      key: MENU_KEYS.COPY,
      name: '复制',
      icon: 'ant-design:copy-outlined'
    })
  }
  // ã€Œå¼•用」:已落库 + æœªæ’¤å›ž + éžåˆå¹¶è½¬å‘ï¼›MERGE å†…嵌快照在引用预览里无法降级展示
  if (!!props.message.id && !isRecall.value && !isMerge.value) {
    items.push({
      key: MENU_KEYS.REPLY,
      name: '引用',
      icon: 'bxs:quote-alt-left'
    })
  }
  // ã€Œè½¬å‘」「多选」:已落库 + æ™®é€šæ¶ˆæ¯ + æœªæ’¤å›žï¼›è§¦å‘ ForwardDialog / è¿›å…¥å¤šé€‰æ¨¡å¼
  if (canForward.value) {
    items.push({
      key: MENU_KEYS.FORWARD,
      name: '转发',
      icon: 'ant-design:share-alt-outlined'
    }, {
      key: MENU_KEYS.MULTI_SELECT,
      name: '多选',
      icon: 'ant-design:check-square-outlined'
    })
  }
  // ã€Œç½®é¡¶ã€ï¼šä»…群聊 + æ™®é€šæ¶ˆæ¯ + å·²è½åº“ + æœªæ’¤å›ž + ç¾¤ä¸»æˆ–管理员;已置顶不再展示,由置顶面板的「移除」入口承接
  // ä¸æœ¬åœ°é¢„判上限,让后端校验,超限时通过 error toast åé¦ˆ
  if (canPin.value) {
    items.push({
      key: MENU_KEYS.PIN,
      name: '置顶',
      icon: 'ant-design:pushpin-outlined'
    })
  }
  // ã€Œæ·»åŠ åˆ°è¡¨æƒ…ã€ï¼šFACE / IMAGE æ¶ˆæ¯ + å·²è½åº“ + æœªæ’¤å›žï¼›å†™å…¥ä¸ªäººè¡¨æƒ…包,对照微信「添加到表情」
  if (canAddToFace.value) {
    items.push({
      key: MENU_KEYS.ADD_TO_FACE,
      name: '添加到表情',
      icon: 'ant-design:smile-outlined'
    })
  }
  // ã€Œç¦è¨€ / è§£ç¦ / ç§»é™¤ã€ï¼šç¾¤èŠ + éžè‡ªå·±æ¶ˆæ¯ + æˆ‘是群主或管理员
  if (currentGroup.value && !props.message.selfSend && canManageSender.value) {
    const senderMember = currentGroup.value.members?.find(
      (m) => m.userId === props.message.senderId
    )
    const isMuted = senderMember?.muteEndTime && new Date(senderMember.muteEndTime) > new Date()
    if (isMuted) {
      items.push({
        key: MENU_KEYS.UNMUTE,
        name: '解除禁言',
        icon: 'ant-design:audio-outlined',
        divided: true
      })
    } else {
      items.push({
        key: MENU_KEYS.MUTE,
        name: '禁言',
        icon: 'ant-design:audio-muted-outlined',
        divided: true
      })
    }
    items.push({
      key: MENU_KEYS.KICK,
      name: '移除',
      icon: 'ant-design:user-delete-outlined',
      danger: true
    })
  }
  // ã€Œæ’¤å›ž / åˆ é™¤ã€äºŒé€‰ä¸€ï¼š
  // - è‡ªå·±å‘送 + å·²è½åº“ + æœªæ’¤å›ž + åœ¨æ’¤å›žçª—口内 â†’ æ’¤å›žï¼ˆæŽ¨æœåŠ¡å™¨æŠŠæ¶ˆæ¯æ€ç½® RECALL)
  // - å…¶å®ƒï¼ˆå¯¹æ–¹æ¶ˆæ¯ / å·²æ’¤å›ž / è¶…出撤回窗口)→ åˆ é™¤ï¼ˆä»…本地清,不动后端)
  // divided æŠŠè¿™ä¸€é¡¹å’Œä¸Šé¢çš„「引用」隔开,danger æ˜¾çº¢å¯¹é½å¾®ä¿¡
  const canRecall =
    props.message.selfSend &&
    !!props.message.id &&
    !isRecall.value &&
    Date.now() - props.message.sendTime <= MESSAGE_RECALL_WINDOW_MS
  if (canRecall) {
    items.push({
      key: MENU_KEYS.RECALL,
      name: '撤回',
      icon: 'ant-design:undo-outlined',
      divided: true,
      danger: true
    })
  } else {
    items.push({
      key: MENU_KEYS.DELETE,
      name: '删除',
      icon: 'ant-design:delete-outlined',
      divided: true,
      danger: true
    })
  }
  // æŠŠèœå•渲染交给全局 uiStore(单例,避免每条消息都挂一份菜单 DOM)
  const menuHandlers: Record<MenuKey, () => Promise<void> | void> = {
    [MENU_KEYS.COPY]: handleCopy,
    [MENU_KEYS.REPLY]: handleReply,
    [MENU_KEYS.FORWARD]: handleForward,
    [MENU_KEYS.MULTI_SELECT]: handleEnterMultiSelect,
    [MENU_KEYS.PIN]: handlePin,
    [MENU_KEYS.ADD_TO_FACE]: handleAddToFace,
    [MENU_KEYS.MUTE]: handleMute,
    [MENU_KEYS.UNMUTE]: handleUnmute,
    [MENU_KEYS.KICK]: handleKick,
    [MENU_KEYS.RECALL]: handleRecall,
    [MENU_KEYS.DELETE]: handleDelete
  }
  uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
    await menuHandlers[item.key as MenuKey]?.()
  })
}
/** æ˜¯å¦å¯ã€Œæ·»åŠ åˆ°è¡¨æƒ…ã€ï¼šFACE / IMAGE æ¶ˆæ¯ + å·²è½åº“ + æœªæ’¤å›žï¼ˆGIF / é™å›¾éƒ½å…è®¸ï¼‰ */
const canAddToFace = computed(() => {
  if (isRecall.value || !props.message.id) {
    return false
  }
  return extractAddableFace(props.message) !== null
})
/** æ·»åŠ åˆ°ä¸ªäººè¡¨æƒ…ï¼šä»Ž message æŠ½ url + å°ºå¯¸ + name å†™å…¥ä¸ªäººè¡¨æƒ…库 */
async function handleAddToFace() {
  const payload = extractAddableFace(props.message)
  if (!payload) {
    return
  }
  const data = await faceStore.addFaceUserItem(payload)
  if (data) {
    antdMessage.success('已添加到表情')
  }
}
/** å½“前激活会话对应的群(私聊场景为 undefined) */
const currentGroup = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return undefined
  }
  return groupStore.getGroup(conversation.targetId)
})
/** å½“前用户在该群里的角色;私聊或非群成员 â†’ undefined */
const myGroupRole = computed(() => {
  const myId = getCurrentUserId()
  return currentGroup.value?.members?.find((m) => m.userId === myId)?.role
})
/** æ˜¯å¦å¯ç®¡ç†è¯¥æ¶ˆæ¯å‘送人:我的角色高于目标角色(群主 > ç®¡ç†å‘˜ > æ™®é€šæˆå‘˜ï¼‰ï¼›ç›®æ ‡è§’色未知时不展示 */
const canManageSender = computed(() => {
  // åŽ†å²é€€ç¾¤ç¾¤ï¼šæœ¬åœ°å¯èƒ½æ®‹ç•™æˆå‘˜ç¼“å­˜ï¼Œæ˜¾å¼æŽ’é™¤ï¼Œé¿å…å³é”®ä»å‡ºã€Œç¦è¨€ / ç§»é™¤ã€
  if (!currentGroup.value || isGroupQuit(currentGroup.value) || !myGroupRole.value) {
    return false
  }
  const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId)
  // æˆå‘˜ç¼“存不完整时不展示管理操作,等成员数据补齐后再判断
  if (!senderMember?.role) {
    return false
  }
  // è§’色数值:1=群主 2=管理员 3=普通成员;数值越小权限越高
  return myGroupRole.value < senderMember.role
})
/** æ˜¯å¦å…è®¸è½¬å‘ / å¤šé€‰ï¼šæ™®é€šæ¶ˆæ¯ + å·²è½åº“ + æœªæ’¤å›žï¼›æœ¬åœ°å ä½ / æ’¤å›ž / äº‹ä»¶æ¶ˆæ¯ä¸€å¾‹ä¸å¯ */
const canForward = computed(
  () => isNormalMessage(props.message.type) && !!props.message.id && !isRecall.value
)
/** å¤šé€‰æ¨¡å¼æ˜¯å¦æ¿€æ´»ï¼›åˆ‡æ¢ä¼šè¯ç”± index.vue ä¸»åЍ exitMultiSelect */
const isInMultiSelect = computed(() => multiSelect.state.active)
/** å½“前消息是否已勾选 */
const isMessageChecked = computed(() => {
  if (!isInMultiSelect.value) {
    return false
  }
  return multiSelect.selectedIdSet.value.has(props.message.clientMessageId)
})
/** å¤šé€‰æ¨¡å¼ä¸‹ç‚¹å‡»æ°”泡:可转发的消息切换勾选;事件 / æ’¤å›ž / å ä½ç­‰ä¸å“åº”(直接吃掉点击避免触发图片预览等) */
function handleMultiSelectClick(e: MouseEvent) {
  if (!isInMultiSelect.value) {
    return
  }
  e.preventDefault()
  e.stopPropagation()
  if (!canForward.value) {
    return
  }
  multiSelect.toggle(props.message)
}
/** æ˜¯å¦å…è®¸ç½®é¡¶ï¼ˆå·²ç½®é¡¶æ¶ˆæ¯ä¸å†å±•示菜单项,由置顶面板的「移除」入口承接):群聊 + æ™®é€šæ¶ˆæ¯ + å·²è½åº“ + æœªæ’¤å›ž + ç¾¤ä¸»æˆ–管理员 + æœªç½®é¡¶ */
const canPin = computed(
  () =>
    !!currentGroup.value &&
    !isGroupQuit(currentGroup.value) &&
    isNormalMessage(props.message.type) &&
    !!props.message.id &&
    !isRecall.value &&
    (myGroupRole.value === ImGroupMemberRole.OWNER ||
      myGroupRole.value === ImGroupMemberRole.ADMIN) &&
    !currentGroup.value.pinnedMessages?.some((m) => m.id === props.message.id)
)
/** ç½®é¡¶æ¶ˆæ¯ï¼šäºŒæ¬¡ç¡®è®¤ â†’ è°ƒåŽç«¯ pin-message;后端广播 GROUP_MESSAGE_PIN,本端 dispatcher æ‹‰æœ€æ–° pinnedMessages */
async function handlePin() {
  const group = currentGroup.value
  if (!group || !props.message.id) {
    return
  }
  try {
    await confirm('将在当前群成员的聊天中置顶', '置顶消息')
    await apiPinGroupMessage({ id: group.id, messageId: props.message.id })
    antdMessage.success('已置顶')
  } catch {}
}
/** å¤åˆ¶æ–‡æœ¬æ¶ˆæ¯ï¼šè§£å‡º content å­—段写入剪贴板,提示「内容已复制到剪贴板」 */
async function handleCopy() {
  const text = parseMessage<TextMessage>(props.message.content)?.content
  if (!text) {
    return
  }
  await copyToClipboard(text)
  antdMessage.success('内容已复制到剪贴板')
}
/** è¿›å…¥å¼•用模式:把当前消息构造成 QuoteMessage å†™å…¥ä¼šè¯è‰ç¨¿ */
function handleReply() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  conversationStore.setConversationReplyDraft(conversation, buildQuoteFromMessage(props.message))
}
/** è½¬å‘当前消息:打开 ForwardDialog(单条模式;mode=single å³åŽŸæ ·è½¬ï¼‰ */
function handleForward() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  openForwardDialog?.({
    mode: ImForwardMode.SINGLE,
    messages: [props.message],
    sourceConversation: conversation
  })
}
/** è¿›å…¥å¤šé€‰æ¨¡å¼ï¼Œåˆå§‹å‹¾é€‰å½“前消息 */
function handleEnterMultiSelect() {
  multiSelect.enter(props.message)
}
/**
 * æ’¤å›žæ¶ˆæ¯ï¼šå¼¹ç¡®è®¤æ¡† â†’ è°ƒ useMessageSender.recall â†’ åŽç«¯é€šè¿‡ WS RECALL äº‹ä»¶æŽ¨å›žï¼Œ
 * websocketStore æŠŠå¯¹åº” message çš„ type æ”¹æˆ RECALL,UI è‡ªåŠ¨åˆ‡åˆ°"XX æ’¤å›žäº†ä¸€æ¡æ¶ˆæ¯"
 *
 * ä¸åšä¹è§‚撤回:失败 / è¶…æ—¶ / åŽç«¯æ‹’绝时本端状态可能与服务端漂移,统一让 WS å›žæŽ¨æœ€ç¨³
 */
async function handleRecall() {
  try {
    await confirm('确定要撤回这条消息吗?', '撤回消息')
    await recall(props.message)
  } catch {}
}
/**
 * å¤±è´¥æ¶ˆæ¯ç‚¹å‡»é‡è¯•
 *
 * - åª’体消息(image / file / voice / video):_localFile åœ¨å†…存就重走 uploadAndSendMedia(重新上传 + å ä½ + è¿›åº¦ï¼‰
 * - æ–‡æœ¬æ¶ˆæ¯ï¼šå¤ç”¨åŽŸ clientMessageId + status å›žæ»šåˆ° SENDING,走 existingClientMessageId è·¯å¾„让服务端按 cmid å¹‚ç­‰
 *
 * åª’体类型若 _localFile å·²ä¸¢ï¼ˆç†è®ºä¸Š IDB æ¢å¤é˜¶æ®µå°±è¢« drop,进不到这里;保险起见仍走文本兜底)则按文本路径重发,
 * åŽç«¯æ‹’绝失效 blob URL æ—¶å†æ¬¡ FAILED,用户可右键删除
 *
 * ä¸è¿˜åŽŸåŽŸ receipt:群回执是发送时的扩展选项、不会持久化到 message,强行猜测可能与原意不符;
 * é»˜è®¤æŒ‰"无回执"重发,绝大多数场景符合预期,要回执就重新发一次更直观
 */
async function handleResend() {
  if (props.message.status !== ImMessageStatus.FAILED) {
    return
  }
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  // ç¦è¨€ / å°ç¦æ—¶æ‹¦æˆªï¼šé¿å…é‡è¯•绕过 MessageInput çš„ muteOverlay åˆèµ°ä¸€æ¬¡ sendRaw è®©åŽç«¯æ‹’
  if (muteOverlay.value) {
    return
  }
  const message = props.message
  const file = message._localFile
  // åª’体类型 + _localFile åœ¨ â†’ é‡èµ° uploadAndSendMedia;type åˆ†å‘ + æ—§å…ƒæ•°æ®å¤ç”¨ç»Ÿä¸€åœ¨ mediaTypeHandlers è¡¨é‡Œ
  if (isMediaMessageType(message.type) && file) {
    const handler = mediaTypeHandlers[message.type]
    if (handler) {
      const oldQuote = getQuoteFromMessage(message.content) ?? undefined
      const context = handler.extractResendContext(message.content)
      await uploadAndSendMedia({
        file,
        type: message.type,
        quote: oldQuote,
        conversation,
        context,
        existingClientMessageId: message.clientMessageId
      })
      return
    }
  }
  // æ–‡æœ¬ç±»åž‹ / åª’体类型但 _localFile å·²ä¸¢ï¼šæŠŠ FAILED å ä½å›žæ»šåˆ° SENDING,复用 clientMessageId è®©æœåŠ¡ç«¯æŒ‰ cmid å¹‚等去重
  messageStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, {
    status: ImMessageStatus.SENDING
  })
  await sendRaw(message.type, message.content, {
    atUserIds: message.atUserIds,
    existingClientMessageId: message.clientMessageId
  })
}
/**
 * åˆ é™¤æ¶ˆæ¯ï¼šæœ¬åœ°è½¯åˆ ï¼Œä»…从 messageStore ç§»é™¤ï¼Œä¸è°ƒåŽç«¯
 * åŒºåˆ«äºŽ"撤回":服务端没动,多端登录时其它客户端 / ç¾¤é‡Œå…¶ä»–人依然能看到这条
 */
/** ç¦è¨€ï¼šemit ç»™çˆ¶ç»„件打开时长选择弹窗(避免 MessageItem è¿‡é‡ï¼‰ */
function handleMute() {
  const group = currentGroup.value
  if (!group) {
    return
  }
  const name = senderDisplayName.value || '该成员'
  emit('mute', group.id, props.message.senderId, name)
}
/** è§£é™¤ç¦è¨€ */
async function handleUnmute() {
  const group = currentGroup.value
  if (!group) {
    return
  }
  try {
    await confirm('确定解除该成员的禁言吗?', '解除禁言')
    await cancelMuteMember({ id: group.id, userId: props.message.senderId })
    antdMessage.success('已解除禁言')
    emit('reload')
  } catch {}
}
/** ç§»é™¤ç¾¤æˆå‘˜ */
async function handleKick() {
  const group = currentGroup.value
  if (!group) {
    return
  }
  const name = senderDisplayName.value || '该成员'
  try {
    await confirm(`确定将「${name}」移出群聊吗?`, '移除成员')
    await removeGroupMember({ groupId: group.id, memberUserIds: [props.message.senderId] })
    antdMessage.success('已移除')
    emit('reload')
  } catch {}
}
function handleDelete() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  messageStore.removeMessage(conversation.type, conversation.targetId, {
    id: props.message.id,
    clientMessageId: props.message.clientMessageId
  })
}
</script>
<template>
  <!-- æ—¶é—´åˆ†éš”æ¡ï¼šåˆ—表第一条 / è·ä¸Šä¸€æ¡è¶…过阈值时居中显示灰色时间 -->
  <div
    v-if="shouldShowTimeTip"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-disabled)]"
  >
    {{ formatTimeTip(message.sendTime) }}
  </div>
  <!-- å¥½å‹ä¼šè¯äº‹ä»¶ï¼ˆFRIEND_ADD / FRIEND_DELETE):跟群广播事件同灰色样式,文案固定 -->
  <div
    v-if="isFriendChatTipMessage"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]"
  >
    <TipSegments :segments="friendChatTipSegments" />
  </div>
  <!-- ç¾¤å¹¿æ’­äº‹ä»¶ï¼šè·Ÿå¥½å‹äº‹ä»¶åŒç°è‰²æ ·å¼ï¼Œmention æ®µæŒ‚点击弹 UserInfoCard -->
  <div
    v-else-if="isGroupNotificationMessage"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]"
  >
    <TipSegments :segments="groupNotificationSegments" />
  </div>
  <!-- ç¾¤é€šè¯äº‹ä»¶ï¼ˆRTC_CALL_START / RTC_CALL_END):居中灰色 tip ä¸¤æ®µå¼ -->
  <div
    v-else-if="isRtcCallTipMessage"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]"
  >
    <TipSegments :segments="rtcCallTipSegments" />
  </div>
  <!-- ç§èŠé€šè¯ç»“束(RTC_CALL_END):仿微信「准气泡」;按 selfSend å·¦å³åˆ†å¸ƒï¼›ç”µè¯å›¾æ ‡ + æ–‡æ¡ˆ -->
  <div v-else-if="isRtcCallPrivateBubbleMessage" class="flex gap-2 items-start px-4 py-2">
    <div
      class="flex flex-1 min-w-0 gap-2 items-start"
      :class="{ 'flex-row-reverse': message.selfSend }"
    >
      <UserAvatar
        :id="message.selfSend ? getCurrentUserId() : message.senderId"
        :name="senderRealNickname"
        :url="message.selfSend ? userStore.userInfo?.avatar : senderAvatar"
        :size="36"
      />
      <div
        class="flex gap-2 items-center px-3.5 py-2 text-sm rounded-lg cursor-pointer"
        :class="
          message.selfSend
            ? 'text-black bg-[#95ec69]'
            : 'text-[var(--ant-color-text)] bg-[var(--ant-color-fill-secondary)]'
        "
        @click="handleRtcCallBubbleClick"
      >
        <Icon icon="ant-design:phone-outlined" :size="16" class="rotate-[135deg] flex-shrink-0" />
        <span class="whitespace-nowrap">{{ rtcCallPrivateBubbleText }}</span>
      </div>
    </div>
  </div>
  <!-- æ’¤å›žæ¶ˆæ¯ï¼šæ•´è¡Œç°è‰² tip,sender åæ®µå¯ç‚¹å‡» -->
  <div
    v-else-if="isRecall"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]"
  >
    <TipSegments :segments="recallTipSegments" />
  </div>
  <!-- æ™®é€šæ¶ˆæ¯æ°”泡 -->
  <div
    v-else
    class="flex gap-2 items-start px-4 py-2"
    :class="{ 'cursor-pointer': isInMultiSelect }"
    @contextmenu.prevent="handleContextMenu"
    @click.capture="handleMultiSelectClick"
  >
    <!-- å¤šé€‰æŒ‡ç¤ºï¼šæ°¸è¿œåœ¨æ•´è¡Œæœ€å·¦ä¾§ï¼ˆè‡ªå·± / å¯¹æ–¹æ¶ˆæ¯ä¸€è‡´ï¼‰ï¼Œä¸å‚与 selfSend reverse -->
    <span
      v-if="isInMultiSelect"
      class="flex flex-shrink-0 items-center justify-center mt-3 w-5 h-5 rounded-full transition-colors"
      :class="
        isMessageChecked
          ? 'bg-[#07c160]'
          : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
      "
    >
      <Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" />
    </span>
    <!-- æ¶ˆæ¯è¡Œï¼šå¤´åƒ + æ°”泡内部按 selfSend reverse;频道素材仿公众号样式居中且不显示头像 -->
    <div
      class="flex flex-1 min-w-0 gap-2 items-start"
      :class="{ 'flex-row-reverse': message.selfSend, 'justify-center': isMaterial }"
    >
      <!-- å¤´åƒï¼šç‚¹å‡»å¼¹ UserInfoCard ç”± UserAvatar å†…部承接;频道素材消息不显示头像 -->
      <UserAvatar
        v-if="!isMaterial"
        :id="message.selfSend ? getCurrentUserId() : message.senderId"
        :name="senderRealNickname"
        :url="message.selfSend ? userStore.userInfo?.avatar : senderAvatar"
        :size="36"
      />
      <div
        class="flex flex-col gap-0.5"
        :class="[
          message.selfSend ? 'items-end' : '',
          // å…¬ä¼—号会话内素材:固定 360 å®½ï¼ˆå¯¹é½å¾®ä¿¡å…¬ä¼—号卡片);其它(含私聊 / ç¾¤èŠè½¬å‘的素材):70% ä¸Šé™ï¼Œæ°”泡自己撑宽度
          isMaterial ? 'w-[360px]' : 'max-w-[70%]'
        ]"
      >
        <!-- ç¾¤èŠå¯¹æ–¹æ¶ˆæ¯ï¼šæ°”泡上方显示发送者昵称 -->
        <div
          v-if="showSenderName"
          class="mb-0.5 text-12px text-[var(--ant-color-text-secondary)] leading-tight"
        >
          {{ senderDisplayName }}
        </div>
        <div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }">
          <!-- æ¶ˆæ¯å†…容:按 type èµ° 9 ç±»æ°”泡,统一由 MessageBubble æ¸²æŸ“ -->
          <MessageBubble
            :type="message.type"
            :content="message.content"
            :self-send="message.selfSend"
            :upload-progress="message.uploadProgress"
            :mentions="textMentions"
            @click-card="handleCardClick"
            @open-merge="handleMergeOpen"
          />
          <!-- çŠ¶æ€åŒºï¼šè‡ªå·±æ¶ˆæ¯å±•ç¤ºå‘é€çŠ¶æ€ + å·²è¯»/群回执;对方消息 + @自己时展示 @徽标 -->
          <div class="flex gap-1.5 items-center text-base">
            <template v-if="message.selfSend">
              <Icon
                v-if="showSendingLoading"
                icon="ant-design:loading-outlined"
                class="im-loading-spin"
              />
              <Icon
                v-else-if="message.status === ImMessageStatus.FAILED"
                icon="ant-design:warning-filled"
                color="#f56c6c"
                class="cursor-pointer"
                title="发送失败,点击重试"
                @click="handleResend"
              />
              <!-- å·²è¯»æ€ï¼ˆç§èŠï¼‰ -->
              <span
                v-else-if="privateReadLabel"
                class="text-12px whitespace-nowrap"
                :class="
                  message.receiptStatus === ImMessageReceiptStatus.DONE
                    ? 'text-[#409eff]'
                    : 'text-[var(--ant-color-text-secondary)]'
                "
              >
                {{ privateReadLabel }}
              </span>
              <!-- ç¾¤å›žæ‰§ï¼šç‚¹å‡»å¼¹ popover å±•示已读 / æœªè¯»æˆå‘˜åˆ—表 -->
              <MessageReadStatus
                v-else-if="showGroupReadStatus"
                :message="message"
                :group-id="conversationStore.activeConversation?.targetId || 0"
                :group-members="groupMembersForReadStatus"
                class="text-12px whitespace-nowrap text-[var(--ant-color-text-secondary)]"
              />
            </template>
            <!-- ç¾¤ @ æˆ‘提示 -->
            <Tag
              v-if="!message.selfSend && isAtMe"
              danger
              size="small"
              class="ml-1"
            >
              @我
            </Tag>
          </div>
        </div>
        <!-- å¼•用块:气泡下方,selfSend æ—¶ç«–线在右侧 -->
        <ReplyPreview
          v-if="quote"
          :quote="quote"
          clickable
          :mirrored="message.selfSend"
          class="max-w-[280px]"
          @locate="emit('locate', $event)"
        />
      </div>
    </div>
  </div>
</template>
<style scoped>
/* @keyframes éœ€è¦ SCSS å£°æ˜Žï¼›SENDING çŠ¶æ€çš„è½¬åœˆåŠ¨ç”» */
.im-loading-spin {
  animation: im-loading-spin 1s linear infinite;
}
@keyframes im-loading-spin {
  to {
    transform: rotate(360deg);
  }
}
</style>
src/views/im/home/pages/conversation/components/message/message-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,805 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group'
import type { GroupLite } from '../../../../types'
import { computed, nextTick, provide, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { message, Popover, Tooltip } from 'ant-design-vue'
import { createCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImRtcCallMediaType, ImRtcCallStatus } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import { resolveCallEndReasonText } from '#/views/im/utils/message'
import { getGroupDisplayName, getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user'
import { GroupMuteMemberDialog } from '../../../../components/group'
import {
  RtcCallMemberPickerDialog,
  RtcGroupCallBanner
} from '../../../../components/rtc'
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
import { useVoicePlayer } from '../../../../composables/useVoicePlayer'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore'
import { useRtcStore } from '../../../../store/rtcStore'
import { useImUiStore } from '../../../../store/uiStore'
import { ConversationGroupSide } from '../conversation'
import { ConversationPrivateSide } from '../conversation'
import { MessageInput } from '../input'
import { MessageMultiSelectBar } from '../input'
import { MessageForwardDialog } from './forward'
import { MessageMergeDetailDialog } from './forward'
import {
  IM_FORWARD_DIALOG_KEY,
  IM_MERGE_DETAIL_DIALOG_KEY,
  IM_RTC_REDIAL_KEY
} from './forward/keys'
import GroupPinnedMessage from './group-pinned-message.vue'
import GroupRequestPending from './group-request-pending.vue'
import MessageHistory from './message-history.vue'
import MessageItem from './message-item.vue'
defineOptions({ name: 'ImMessagePanel' })
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
const groupStore = useGroupStore()
const rtcStore = useRtcStore()
const listRef = ref<HTMLElement>()
// ==================== è½¬å‘ / åˆå¹¶æ¶ˆæ¯è¯¦æƒ…:本地 dialog æµ®å±‚ ====================
// MessageItem / MessageMultiSelectBar / MessageHistory é€šè¿‡ inject è§¦å‘;不挂全局 store
const forwardDialogRef = ref<InstanceType<typeof MessageForwardDialog>>()
const mergeDetailDialogRef = ref<InstanceType<typeof MessageMergeDetailDialog>>()
provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts))
provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) => mergeDetailDialogRef.value?.open(content))
provide(IM_RTC_REDIAL_KEY, (mediaType: number) => {
  if (isPrivate.value) {
    void startPrivateCall(mediaType)
  }
}) // ç§èŠ RTC_CALL_END æ°”泡点击重拨;MessageItem æ³¨å…¥åŽè°ƒç”¨
// ==================== å¤šé€‰æ¨¡å¼ ====================
// æ¨¡å—级单例 state(composable);本组件仅做切会话退出 + template æ˜¾éšåˆ¤å®š
const multiSelect = useMessageMultiSelect()
const voicePlayer = useVoicePlayer()
/** åˆ‡ä¼šè¯é€€å‡ºå¤šé€‰ + åœè¯­éŸ³ï¼›é¿å…ä¸Šä¸€ä¼šè¯çš„勾选 / æ’­æ”¾æ€æ³„漏到新会话(type+targetId ä¸€èµ·ç›‘听,私聊与群聊 id åŒå·æ—¶ä¹Ÿèƒ½è§¦å‘) */
watch(
  () => [
    conversationStore.activeConversation?.type,
    conversationStore.activeConversation?.targetId
  ],
  () => {
    multiSelect.exit()
    voicePlayer.stop()
  }
)
const messages = computed(() => {
  const conversation = conversationStore.activeConversation
  return conversation
    ? messageStore.getMessages(getClientConversationId(conversation.type, conversation.targetId))
    : []
})
const isGroup = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
const isPrivate = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.PRIVATE
)
const isChannel = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** å½“前激活会话是否历史退群群:禁群通话、隐藏群申请横幅等操作入口;聊天历史、群名头像照常展示 */
const isQuitGroup = computed(() => {
  const conversation = conversationStore.activeConversation
  return (
    conversation?.type === ImConversationType.GROUP &&
    isGroupQuit(groupStore.getGroup(conversation.targetId))
  )
})
/** ç§èŠä¼šè¯ä¸”对端不是有效好友(本端 friend è®°å½•缺失或 DISABLE);单边删除语义下「被对方删除」不触发本端横幅 */
const showNotFriendBanner = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
    return false
  }
  return !friendStore.isActiveFriend(conversation.targetId)
})
/** ç‚¹å‡»ã€Œå¯¹æ–¹è¿˜ä¸æ˜¯ä½ çš„æœ‹å‹ã€èƒ¶å›Šï¼šæ‰“å¼€ UserInfoCard,引导用户重新添加 */
function handleNotFriendClick(event: MouseEvent) {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
  uiStore.openUserInfoCard(
    {
      id: conversation.targetId,
      nickname: conversation.name,
      avatar: conversation.avatar
    },
    { x: rect.left, y: rect.bottom + 4 }
  )
}
/**
 * ç¾¤èŠ header æ˜¾ç¤ºçš„人数:优先 groupStore.memberCount(无需等成员列表),无值再回退 members.length
 *
 * ä¹‹æ‰€ä»¥ä¸ç›´æŽ¥ç”¨ groupMembers.value.length:成员列表是按需懒加载的,刚切到群时未加载完,
 * è€Œ groupInfo.memberCount è·Ÿç¾¤ä¿¡æ¯ä¸€èµ·æ¥ï¼Œèƒ½æ›´æ—©æ˜¾ç¤ºäººæ•°é¿å…"先空再蹦"
 */
const headerMemberCount = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return 0
  }
  const group = groupStore.getGroup(conversation.targetId)
  return group?.memberCount ?? group?.members?.length ?? 0
})
/** é¡¶éƒ¨å‰¯æ ‡é¢˜ï¼šä»…当群备注 â‰  åŽŸç¾¤åæ—¶æ˜¾ç¤ºåŽŸç¾¤åï¼ˆå¯¹é½å¾®ä¿¡ PC åŒè¡Œ header) */
const headerSubtitle = computed(() => {
  const remark = groupInfo.value?.groupRemark
  const name = groupInfo.value?.name
  return remark && name && remark !== name ? name : ''
})
const BOTTOM_THRESHOLD = 80 // "是否停留在底部"的阈值:距离底部 < 80px è§†ä¸ºåº•部
const showJumpToBottom = ref(false) // å½“前是否已不在底部(显示"回到底部"按钮)
const newMessageCount = ref(0) // ä¸åœ¨åº•部期间累计的新消息数
/**
 * å½“前激活的群详情:优先 groupStore(带详细字段),未加载完时用 activeConversation å…œåº•
 *
 * groupStore æ˜¯æŒ‰éœ€æ‡’加载的,初次进群时 ensureGroupData è§¦å‘后才会有完整数据;
 * å…œåº•字段(name / avatar)保证 header ä¸ä¼š"闪空",notice / ownerId / memberCount
 * å¿…须等 store å°±ä½æ‰æœ‰å€¼ï¼ˆè¿™äº›å­—段在 conversation é‡Œæ²¡æœ‰ï¼‰
 */
const groupInfo = computed<
  | (GroupLite & {
      groupRemark?: string
      notice?: string
      ownerId?: number
      remarkNickName?: string
    })
  | undefined
>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return undefined
  }
  const group = groupStore.getGroup(conversation.targetId)
  const selfMember = group?.members?.find((member) => member.userId === getCurrentUserId())
  const showGroupName = group ? getGroupDisplayName(group) : conversation.name
  return {
    id: conversation.targetId,
    name: group?.name || conversation.name,
    showGroupName,
    showImage: group?.avatar || conversation.avatar,
    notice: group?.notice,
    remarkNickName: selfMember?.displayUserName,
    groupRemark: group?.groupRemark,
    ownerId: group?.ownerUserId,
    memberCount: group?.memberCount,
    joinApproval: group?.joinApproval
  }
})
/** ç¾¤æˆå‘˜åˆ—表:直接取 groupStore ç¼“存,map æˆ GroupMemberLite ç»™ä¸‹æ¸¸æ¶ˆè´¹ï¼ˆ@-mention / é‚€è¯·ç­‰ï¼‰ */
const groupMembers = computed<GroupMemberLite[]>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return []
  }
  const group = groupStore.getGroup(conversation.targetId)
  return (group?.members || []).map((member) => {
    // æ˜¾ç¤ºåèµ°ã€Œå¥½å‹å¤‡æ³¨ > ç¾¤å¤‡æ³¨ > çœŸå®žæ˜µç§°ã€ä¸‰çº§ï¼›å¤´åƒèµ° nickname ä¿ç¨³å®š
    const friend = friendStore.getFriend(member.userId)
    return {
      userId: member.userId,
      showName: getMemberDisplayName(member, friend),
      nickname: member.nickname,
      avatar: member.avatar,
      status: member.status,
      role: member.role
    }
  })
})
/** åˆ‡æ¢åˆ°ç¾¤ä¼šè¯æ—¶åŒæ­¥ç¾¤ä¿¡æ¯ + æˆå‘˜ */
async function ensureGroupData(groupId: number) {
  // è¿œç¨‹æ‹‰ç¾¤ä¿¡æ¯ï¼ˆç¾¤å / å…¬å‘Š / ç¾¤ä¸»ç­‰å…ƒæ•°æ®ï¼‰
  await groupStore.fetchGroupInfo(groupId).catch((error) => {
    console.warn('[IM MessagePanel] fetchGroupInfo å¤±è´¥', { groupId }, error)
  })
  if (isGroupQuit(groupStore.getGroup(groupId))) {
    return
  }
  // å…ˆä»Ž IDB åŒæ­¥åŠ è½½ç¾¤æˆå‘˜ï¼Œè®©é¦–å¸§ç«‹å³å‡ºæˆå‘˜å / å¤´åƒ
  await groupStore.loadGroupMemberList(groupId).catch((error) => {
    console.warn('[IM MessagePanel] loadGroupMemberList å¤±è´¥', { groupId }, error)
    return null
  })
  const group = groupStore.getGroup(groupId)
  if (!group?.membersLoaded || group.membersExpired) {
    groupStore.fetchGroupMemberList(groupId, true).catch((error) => {
      console.warn('[IM MessagePanel] fetchGroupMemberList å¤±è´¥', { groupId }, error)
    })
  }
}
/** ç¾¤ä¿¡æ¯æŠ½å±‰é‡Œç‚¹"刷新":强拉一次最新群元数据 + ç¾¤æˆå‘˜ */
function reloadGroupData() {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return
  }
  groupStore.fetchGroupInfo(conversation.targetId, true)
  groupStore.fetchGroupMemberList(conversation.targetId, true)
}
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>() // åŽ†å²æ¶ˆæ¯æŠ½å±‰ ref:「聊天历史」icon / æŠ½å±‰ã€ŒæŸ¥æ‰¾èŠå¤©å†…容」入口都调 open() è§¦å‘
const sideVisible = ref(false) // ä¿¡æ¯æŠ½å±‰å¼€å…³ï¼šç¾¤èŠ / ç§èŠå…±ç”¨ä¸€ä¸ª ref
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()
const callMemberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
const pendingMediaType = ref<null | number>(null) // ç¾¤é€šè¯å‘起:成员选择弹窗打开期间临时持有的 mediaType
/** æ¶ˆæ¯å³é”®èœå•「禁言」→ æ‰“开时长选择弹窗 */
function handleMuteMember(groupId: number, userId: number, displayName: string) {
  muteMemberDialogRef.value?.open(groupId, userId, displayName)
}
/** ä¿¡æ¯æŠ½å±‰çš„ toggle:跟 header ä¸Š 3 ç‚¹å›¾æ ‡æŒ‰é’®å…±ç”¨ */
function toggleSide() {
  sideVisible.value = !sideVisible.value
}
const callPopoverVisible = ref(false) // ç§èŠé€šè¯å…¥å£ï¼špopover è§¦å‘;点 è¯­éŸ³ / è§†é¢‘ ç›´æŽ¥å‘èµ·
const callInviting = ref(false) // é€šè¯å‘起中
async function startPrivateCall(mediaType: number) {
  callPopoverVisible.value = false
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  await doInvite({
    conversationType: ImConversationType.PRIVATE,
    mediaType,
    inviteeIds: [conversation.targetId]
  })
}
/** ç¾¤é€šè¯å…¥å£ï¼šé»˜è®¤è¯­éŸ³ç›´æŽ¥å¼¹é€‰äººï¼›ä¸Žå¾®ä¿¡ç¾¤é€šè¯ä¸€è‡´ï¼Œè¿›é€šè¯åŽç”¨æˆ·æŒ‰éœ€å¼€æ‘„像头 */
function handleGroupCall() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  pendingMediaType.value = ImRtcCallMediaType.VOICE
  callMemberPickerRef.value?.open({ groupId: conversation.targetId, mode: 'invite' })
}
/** é€‰äººå¼¹çª—确认;带选中 ID å‘起群通话 */
async function onCallMemberPicked(selectedIds: number[]) {
  const conversation = conversationStore.activeConversation
  const mediaType = pendingMediaType.value
  pendingMediaType.value = null
  if (!conversation || mediaType == null || selectedIds.length === 0) {
    return
  }
  await doInvite({
    conversationType: ImConversationType.GROUP,
    mediaType,
    groupId: conversation.targetId,
    inviteeIds: selectedIds
  })
}
/** å®žé™…è°ƒ create æŽ¥å£ï¼›ç»Ÿä¸€å¤„理成功 / ENDED(如忙线立即结束) */
async function doInvite(reqVO: {
  conversationType: number
  groupId?: number
  inviteeIds: number[]
  mediaType: number
}) {
  if (callInviting.value) {
    return
  }
  if (rtcStore.isActive) {
    message.warning('当前已有通话')
    return
  }
  callInviting.value = true
  try {
    const data = await createCall(reqVO)
    // åŽç«¯å·² INSERT + ç«‹å³ end(如忙线):toast æç¤ºï¼Œä¸è¿› INVITING é˜¶æ®µï¼›chat tip ç”± RTC_CALL_END æŽ¨é€å†™å…¥æ¶ˆæ¯æµ
    if (data.status === ImRtcCallStatus.ENDED) {
      message.warning(resolveCallEndReasonText(data.endReason))
      return
    }
    // æ­£å¸¸è¿›å…¥ INVITING é˜¶æ®µï¼šèµ° store é€»è¾‘发起通话,后续状态更新 / æ¶ˆæ¯æµæ›´æ–°ç”± RTC æ¨¡å—监听推送处理
    rtcStore.startInviting(data)
  } finally {
    callInviting.value = false
  }
}
/** å½“前私聊对应的好友(抽屉头部展示用) */
const privateFriend = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
    return undefined
  }
  return friendStore.getFriend(conversation.targetId)
})
/** è®¡ç®—距离底部的像素 */
function distanceFromBottom(): number {
  const el = listRef.value
  if (!el) {
    return 0
  }
  return el.scrollHeight - el.scrollTop - el.clientHeight
}
/**
 * æ¶ˆæ¯åˆ—表滚动事件:刷新"是否在底部"状态
 * - åœ¨åº•部:隐藏"回到底部"浮窗 + æ¸…掉"未读新消息"计数
 * - ä¸åœ¨åº•部:显示"回到底部"浮窗,新消息会累计到 newMessageCount
 */
function handleScroll() {
  const dist = distanceFromBottom()
  const atBottom = dist <= BOTTOM_THRESHOLD
  showJumpToBottom.value = !atBottom
  if (atBottom) {
    newMessageCount.value = 0
  }
}
/**
 * æ»šåˆ°åº•部:切会话 / æ”¶åˆ°æ–°æ¶ˆæ¯ï¼ˆä¸”当前在底部)/ ç”¨æˆ·ä¸»åŠ¨ç‚¹ã€Œå›žåˆ°åº•éƒ¨ã€ éƒ½èµ°è¿™é‡Œ
 *
 * smooth=true èµ°å¹³æ»‘动画,适合用户主动点击;初始 / è‡ªåŠ¨æ»šåŠ¨ç”¨ auto,避免用户感知到动画拖拽
 */
async function scrollToBottom(smooth = false) {
  // 1. æ»šåˆ°å½“前 scrollHeight çš„底部(图片 / è§†é¢‘还在加载时只是大致到底)
  // 1.1 ç­‰ v-for æŠŠæ–°æ¶ˆæ¯çœŸæ­£æ¸²æŸ“è¿› DOM åŽå†ç®— scrollHeight,否则差最后一条的位置
  await nextTick()
  if (!listRef.value) {
    return
  }
  // 1.2 è§¦å‘滚动;smooth ä»… user ä¸»åŠ¨ç‚¹ã€Œå›žåˆ°åº•éƒ¨ã€ç”¨ï¼Œåˆå§‹ / è‡ªåŠ¨æ»šèµ° auto é¿å…åŠ¨ç”»æ‹–æ‹½æ„Ÿ
  listRef.value.scrollTo({
    top: listRef.value.scrollHeight,
    behavior: smooth ? 'smooth' : 'auto'
  })
  newMessageCount.value = 0
  showJumpToBottom.value = false
  // 1.3 è®°ä¸‹ã€ŒæœŸæœ›åœä¸‹çš„ scrollTop」;图片 / è§†é¢‘加载完底部会上移,scrollTop æ²¡åŠ¨å°±è¯´æ˜Žç”¨æˆ·æ²¡æ‰‹åŠ¨æ»šèµ°
  //     ä¸èƒ½ç”¨ distanceFromBottom åˆ¤æ–­ï¼šåº•部上移会让 distance å˜å¤§ï¼Œè¢«è¯¯åˆ¤ä¸ºã€Œç”¨æˆ·æ»šèµ°äº†ã€ç›´æŽ¥æ”¾å¼ƒè¡¥æ»š
  const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight
  // 2. ç­‰åª’体加载完后补滚到真实底部
  // 2.1 ç­‰å®¹å™¨å†…未加载完的图片 / è§†é¢‘元数据;加载完后 scrollHeight ä¼šå¢žé•¿åˆ°çœŸå®žåº•部
  await waitMediaSettled()
  if (!listRef.value) {
    return
  }
  // 2.2 ä»…在用户没手动滚走时(scrollTop ä»è´´è¿‘ expectedScrollTop)才补滚,避免等待期间用户上翻被打断
  if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) {
    return
  }
  // 2.3 è¡¥æ»šåˆ°æ–°åº•部
  listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' })
}
/**
 * ç­‰å¾…容器内未加载完的图片 / è§†é¢‘;最多等 2s é˜²æ­¢è¶…大资源把整个滚动跟进卡住
 *
 * ä»…关心元数据(loadedmetadata / img.complete),不等真正解码,因为尺寸够算 scrollHeight å°±è¡Œ
 */
function waitMediaSettled(): Promise<void> {
  // 1. æ”¶é›†å®¹å™¨å†…未加载完的图片 / è§†é¢‘
  if (!listRef.value) {
    return Promise.resolve()
  }
  // 1.1 ä¸€æ¬¡æ‰« img + video,按 element ç±»åž‹åˆ†åˆ«çœ‹ã€Œcomplete / readyState」过滤 pending
  const pendingMedia = [...listRef.value.querySelectorAll<HTMLImageElement | HTMLVideoElement>('img, video')].filter((el) => (el instanceof HTMLImageElement ? !el.complete : el.readyState < 1))
  // 1.2 æ²¡æœ‰ pending ç›´æŽ¥è¿”回,省掉 Promise.race / setTimeout é—­åŒ…构造
  if (pendingMedia.length === 0) {
    return Promise.resolve()
  }
  // 2. ç­‰æ‰€æœ‰ pending èµ„源 load / error,最长 2s å…œåº•
  // 2.1 æ¯ä¸ª element éƒ½ç›‘听对应 loadedEvent + error,任一触发即 resolve(不让单条失败卡 race)
  const loadAll = Promise.all(
    pendingMedia.map(
      (el) =>
        new Promise<void>((resolve) => {
          const loadedEvent = el instanceof HTMLImageElement ? 'load' : 'loadedmetadata'
          el.addEventListener(loadedEvent, () => resolve(), { once: true })
          el.addEventListener('error', () => resolve(), { once: true })
        })
    )
  ).then(() => undefined)
  // 2.2 2s è¶…时兜底,防止超大资源 / ç½‘络挂起把整个滚动跟进永久卡住
  const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000))
  return Promise.race([loadAll, timeout])
}
/**
 * å®šä½åˆ°èŠå¤©ä½ç½®ï¼šMessageHistory è¡Œä¸Š"定位"按钮 / æ°”泡内引用块点击触发
 *
 * 1. å…ˆå…³æŽ‰åŽ†å²å¼¹çª—ï¼ˆé¿å… scroll æ—¶é®æŒ¡ + dialog å…³é—­åŽè®©èŠå¤©é¢æ¿æ‹¿å›žç„¦ç‚¹ï¼‰
 * 2. nextTick ç­‰å¼¹çª— leave åŠ¨ç”» / åˆ—表渲染稳定后再查 DOM
 * 3. æŒ‰ data-message-id æ‰¾ wrapper,scrollIntoView({ block: center }) è®©æ¶ˆæ¯è½åˆ°è§†å£ä¸­éƒ¨
 * 4. åŠ  --highlight class çŸ­æš‚高亮,提示用户"就是这条"
 * 5. æ‰¾ä¸åˆ° wrapper(原消息已分页出去)时弹 warning æç¤º,与微信"消息已不在窗口"观感一致
 */
async function handleLocate(messageId: number) {
  if (!messageId) {
    return
  }
  await nextTick()
  if (!listRef.value) {
    return
  }
  const target = listRef.value.querySelector<HTMLElement>(`[data-message-id="${messageId}"]`)
  if (!target) {
    message.warning('原消息不在视野')
    return
  }
  target.scrollIntoView({ behavior: 'smooth', block: 'center' })
  target.classList.add('message-panel__message-anchor--highlight')
  setTimeout(() => {
    target.classList.remove('message-panel__message-anchor--highlight')
  }, 1600)
}
/**
 * æ¶ˆæ¯æ•°é‡å˜åŒ–时的滚动跟进策略
 * - å½“前在底部 â†’ è‡ªåŠ¨æ»šåˆ°æ–°åº•éƒ¨ï¼ˆç”¨æˆ·åœ¨"实时跟读",体验上跟微信一致)
 * - ä¸åœ¨åº•部 â†’ ç´¯è®¡ newMessageCount,让 sticky æµ®çª—显示"X æ¡æ–°æ¶ˆæ¯",让用户主动点
 */
watch(
  () => messages.value.length,
  (newLen, oldLen) => {
    // ä»…处理新增(delta > 0);删除 / æ’¤å›žè®© length å‡å°‘时不动滚动状态
    const delta = (newLen || 0) - (oldLen || 0)
    if (delta <= 0) {
      return
    }
    // ç”¨ BOTTOM_THRESHOLD(80px)做容差:用户稍微往上翻几行就视作"不在底部",
    // å¦åˆ™ä¸€ç›´ auto-scroll ä¼šæŠŠäººæ­£åœ¨è¯»çš„内容顶走,体验糟糕
    const dist = distanceFromBottom()
    if (dist <= BOTTOM_THRESHOLD) {
      scrollToBottom()
    } else {
      newMessageCount.value += delta
      showJumpToBottom.value = true
    }
  }
)
/**
 * åˆ‡æ¢ä¼šè¯ï¼šæ¸…空"在不在底部"相关状态、强制滚到底部、群会话预拉资料
 *
 * type+targetId ä¸€èµ·ç›‘听:私聊与群聊 id åŒå·æ—¶åˆ‡æ¢ä¹Ÿèƒ½è§¦å‘ï¼›immediate:true è®©é¦–次进入页面就能初始化
 */
watch(
  () => [
    conversationStore.activeConversation?.type,
    conversationStore.activeConversation?.targetId
  ],
  ([type, targetId]) => {
    // åˆ‡ä¼šè¯æ—¶ä¸Šä¸€ä¼šè¯çš„「未读累计 + æµ®çª—显示」必须清掉,否则会带到新会话里看起来很突兀
    newMessageCount.value = 0
    showJumpToBottom.value = false
    // æŠ½å±‰é‡Œå±•示的群信息 / å¥½å‹ä¿¡æ¯å±žäºŽä¸Šä¸€ä¼šè¯ï¼Œåˆ‡ä¼šè¯æ—¶ç»Ÿä¸€å…³æŽ‰
    sideVisible.value = false
    scrollToBottom()
    // ä»…群聊预拉详情 / æˆå‘˜ï¼ˆç§èŠå¯¹ç«¯åœ¨é¦–屏 fetchFriendList æ—¶å°±æ‹‰äº†ï¼‰
    if (targetId && type === ImConversationType.GROUP) {
      ensureGroupData(targetId)
    }
  },
  { immediate: true }
)
</script>
<template>
  <div class="flex flex-1 flex-col min-w-0 bg-[var(--ant-color-fill-secondary)]">
    <template v-if="conversationStore.activeConversation">
      <!-- é¡¶éƒ¨ header:第一行群名 + å³ä¾§å›¾æ ‡ï¼Œç¬¬äºŒè¡ŒåµŒå…¥ç½®é¡¶æ°”泡(仅群聊 + æœ‰ç½®é¡¶ï¼‰ -->
      <div
        class="flex flex-shrink-0 flex-col bg-[var(--ant-color-fill-secondary)] border-b border-b-solid border-[var(--im-border-color-lighter)]"
      >
        <div class="flex items-center justify-between h-14 px-5">
          <span class="flex flex-col min-w-0">
            <span class="flex items-baseline gap-1.5 min-w-0">
              <span
                class="overflow-hidden text-base font-medium truncate text-[var(--ant-color-text)]"
              >
                {{ conversationStore.activeConversation?.name || '' }}
              </span>
              <span
                v-if="isGroup && headerMemberCount > 0"
                class="flex-shrink-0 text-sm text-[var(--ant-color-text-secondary)]"
              >
                ({{ headerMemberCount }})
              </span>
            </span>
            <!-- å‰¯æ ‡é¢˜ï¼šå¤‡æ³¨ â‰  ç¾¤åæ—¶å±•示原群名,提示用户当前看到的主名是自己设的备注 -->
            <span
              v-if="headerSubtitle"
              class="overflow-hidden text-xs truncate text-[var(--ant-color-text-secondary)]"
            >
              {{ headerSubtitle }}
            </span>
          </span>
          <div class="flex gap-2 items-center">
            <!-- èŠå¤©åŽ†å² -->
            <Tooltip title="聊天历史" placement="bottom">
              <Icon
                icon="ep:chat-dot-round"
                class="message-panel__header-icon cursor-pointer"
                @click="historyDialogRef?.open()"
              />
            </Tooltip>
            <!-- é€šè¯å…¥å£ï¼šç§èŠå¼¹ã€Œè¯­éŸ³ / è§†é¢‘」popover;群聊直接进选人弹窗 -->
            <Popover
              v-if="isPrivate"
              v-model:open="callPopoverVisible"
              placement="bottomRight"
              :overlay-style="{ width: '140px' }"
              trigger="click"
              overlay-class-name="message-panel__call-popover"
            >
              <Icon
                icon="ant-design:phone-outlined"
                class="message-panel__header-icon cursor-pointer"
              />
              <template #content>
                <div class="flex flex-col gap-0.5">
                  <div
                    class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]"
                    @click="startPrivateCall(ImRtcCallMediaType.VOICE)"
                  >
                    <Icon icon="ant-design:phone-outlined" :size="16" />
                    <span>语音通话</span>
                  </div>
                  <div
                    class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]"
                    @click="startPrivateCall(ImRtcCallMediaType.VIDEO)"
                  >
                    <Icon icon="ant-design:video-camera-outlined" :size="16" />
                    <span>视频通话</span>
                  </div>
                </div>
              </template>
            </Popover>
            <Tooltip v-else-if="!isQuitGroup" title="通话" placement="bottom">
              <Icon
                icon="ant-design:phone-outlined"
                class="message-panel__header-icon cursor-pointer"
                @click="handleGroupCall"
              />
            </Tooltip>
            <!-- ä¿¡æ¯æŠ½å±‰å…¥å£ï¼šç¾¤èŠæœ‰ï¼Œç§èŠä»…在对方是好友时显示(非好友会话抽屉内容为空,没意义) -->
            <Tooltip
              v-if="isGroup || (isPrivate && !showNotFriendBanner)"
              :title="isGroup ? '群聊信息' : '聊天信息'"
              placement="bottom"
            >
              <Icon
                icon="ant-design:ellipsis-outlined"
                class="message-panel__header-icon cursor-pointer"
                @click="toggleSide"
              />
            </Tooltip>
          </div>
        </div>
        <!-- ç¾¤é€šè¯èƒ¶å›Šæ¡ï¼šä»…群聊 + è¯¥ç¾¤æœ‰æ´»è·ƒé€šè¯æ—¶æ˜¾ç¤ºï¼›ç‚¹å‡»å±•开看成员 + åŠ å…¥æŒ‰é’® -->
        <RtcGroupCallBanner
          v-if="isGroup && !isQuitGroup && conversationStore.activeConversation"
          :group-id="conversationStore.activeConversation.targetId"
        />
        <!-- ç¾¤ç½®é¡¶æ¶ˆæ¯ï¼šç¬¬äºŒè¡ŒåµŒå…¥ header;仅群聊 + æœ‰ç½®é¡¶æ—¶æ˜¾ç¤º -->
        <GroupPinnedMessage
          v-if="isGroup && conversationStore.activeConversation"
          :group-id="conversationStore.activeConversation.targetId"
          @locate="handleLocate"
        />
        <!-- ç¾¤é¡¶éƒ¨ã€Œå¾…处理加群申请」横幅:仅群聊 + owner / admin + count > 0 æ—¶æ˜¾ç¤º -->
        <GroupRequestPending
          v-if="isGroup && !isQuitGroup && conversationStore.activeConversation"
          :group-id="conversationStore.activeConversation.targetId"
        />
        <!-- ç§èŠï¼šå¯¹æ–¹ä¸å†æ˜¯æœ‰æ•ˆå¥½å‹ï¼ˆæˆ‘删了对方 / ä»ŽæœªåŠ è¿‡ï¼›å•è¾¹è®¾è®¡ä¸‹ã€Œè¢«å¯¹æ–¹åˆ é™¤ã€æœ¬ç«¯ friendStore ä¸æ›´æ–°æ•…不会触发);胶囊嵌在 header å†…(跟群置顶同级),点击弹 UserInfoCard -->
        <div
          v-if="showNotFriendBanner"
          class="flex flex-shrink-0 items-start px-4 pb-2 bg-[var(--ant-color-fill-secondary)]"
        >
          <div
            class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-13px cursor-pointer text-[var(--ant-color-text)] bg-[var(--ant-color-warning-bg)] transition-colors hover:bg-[var(--ant-color-warning-bg-hover)]"
            @click="handleNotFriendClick"
          >
            <span
              class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--ant-color-warning)] flex-shrink-0"
            >
              <Icon icon="ant-design:user-outlined" :size="11" />
            </span>
            <span>对方还不是你的朋友</span>
            <Icon icon="ep:arrow-right" :size="12" class="text-[var(--ant-color-text-secondary)]" />
          </div>
        </div>
      </div>
      <!-- ä¸­é—´ï¼šæ¶ˆæ¯åˆ—表 -->
      <div
        ref="listRef"
        class="relative flex-1 py-2 overflow-y-auto bg-[var(--ant-color-bg-layout)]"
        @scroll="handleScroll"
      >
        <div
          v-if="messages.length === 0"
          class="flex items-center justify-center h-full text-sm text-[var(--ant-color-text-secondary)]"
        >
          æš‚无消息
        </div>
        <!-- data-message-id ç»™ MessageHistory "定位到聊天位置" ç”¨ï¼šçˆ¶çº§é€šè¿‡ querySelector
             æ‰¾åˆ°è¿™å±‚ wrapper,scrollIntoView + åŠ é«˜äº® class;本地占位消息跳过 -->
        <div
          v-for="(msg, index) in messages"
          :key="msg.id || msg.clientMessageId"
          :data-message-id="msg.id || ''"
          class="message-panel__message-anchor"
        >
          <MessageItem
            :message="msg"
            :prev-message="messages[index - 1]"
            @locate="handleLocate"
            @mute="handleMuteMember"
            @reload="reloadGroupData"
          />
        </div>
        <!-- å›žåˆ°åº•部浮动按钮(滚动不在底部时显示) -->
        <transition name="message-panel__jump-fade">
          <div
            v-if="showJumpToBottom"
            class="message-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--ant-color-bg-elevated)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]"
            @click="scrollToBottom(true)"
          >
            <Icon icon="ant-design:down-outlined" :size="14" />
            <span v-if="newMessageCount > 0" class="font-medium">
              {{ newMessageCount > 99 ? '99+' : newMessageCount }} æ¡æ–°æ¶ˆæ¯
            </span>
            <span v-else>回到底部</span>
          </div>
        </transition>
      </div>
      <!-- åº•部:输入框(频道单向消息无需输入框);多选模式底栏作为浮层盖在上面,保持下方输入框尺寸不变 -->
      <div v-if="!isChannel" class="relative">
        <MessageInput />
        <MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
      </div>
      <!-- å³ä¾§ä¿¡æ¯æŠ½å±‰ï¼šç¾¤èŠ / ç§èŠå„自一份 -->
      <ConversationGroupSide
        v-if="isGroup"
        v-model="sideVisible"
        :group="groupInfo"
        :conversation="conversationStore.activeConversation"
        :members="groupMembers"
        @reload="reloadGroupData"
        @open-history="historyDialogRef?.open()"
      />
      <ConversationPrivateSide
        v-else
        v-model="sideVisible"
        :conversation="conversationStore.activeConversation"
        :friend="privateFriend"
        @open-history="historyDialogRef?.open()"
      />
      <!-- åŽ†å²æ¶ˆæ¯æŠ½å±‰ -->
      <MessageHistory ref="historyDialogRef" @locate="handleLocate" />
      <!-- è½¬å‘弹窗 / åˆå¹¶æ¶ˆæ¯è¯¦æƒ…:在 MessagePanel å­æ ‘内挂载,子组件通过 inject è§¦å‘ -->
      <MessageForwardDialog ref="forwardDialogRef" />
      <MessageMergeDetailDialog ref="mergeDetailDialogRef" />
      <!-- ç¦è¨€æ—¶é•¿é€‰æ‹©å¼¹çª— -->
      <GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
      <!-- ç¾¤é€šè¯æˆå‘˜é€‰æ‹©å¼¹çª— -->
      <RtcCallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" />
    </template>
    <div
      v-else
      class="flex items-center justify-center h-full text-sm text-[var(--ant-color-text-secondary)]"
    >
      <span>选择一个会话开始聊天</span>
    </div>
  </div>
</template>
<style scoped>
/* :deep ç©¿é€ el-icon å­ç»„ä»¶ svg;el-icon å…¨å±€è§„则 .el-icon{color:var(--color,inherit)} ä¼˜å…ˆçº§æ›´é«˜ï¼Œ
   ç”¨ :deep + !important é”è‰²ï¼›é¢œè‰²å– Element Plus ä¸»é¢˜å˜é‡ï¼Œæš—色模式自动切到更亮的灰 */
.message-panel__header-icon,
.message-panel__header-icon :deep(svg) {
  width: 20px;
  height: 20px;
  color: var(--ant-color-text) !important;
  fill: currentColor !important;
  transition: color 0.15s;
}
.message-panel__header-icon {
  box-sizing: content-box;
  padding: 4px;
}
.message-panel__header-icon:hover,
.message-panel__header-icon:hover :deep(svg) {
  color: var(--ant-color-primary) !important;
}
/* sticky + translate å±…中:fit-content å®½åº¦ä¸ä¼šæ’‘满,transform æ°´å¹³ -50% åç§»ï¼›åŒæ—¶ transition opacity å’Œ transform ä¸¤ä¸ªå±žæ€§ */
.message-panel__jump-bottom {
  transform: translateX(-50%);
  transition:
    opacity 0.2s,
    transform 0.2s;
}
/* JS åˆ‡æ¢ highlight class + background-color transition è”动;MessageHistory「定位」跳过来时短暂高亮 1.6s */
.message-panel__message-anchor {
  transition: background-color 0.6s ease;
}
.message-panel__message-anchor--highlight {
  background-color: var(--ant-color-warning-bg);
}
/* Vue <transition> é’©å­ç±»åå›ºå®šï¼Œå¿…é¡» SCSS å£°æ˜Žï¼›å›žåˆ°åº•部按钮淡入淡出 */
.message-panel__jump-fade-enter-active,
.message-panel__jump-fade-leave-active {
  transition:
    opacity 0.2s,
    transform 0.2s;
}
.message-panel__jump-fade-enter-from,
.message-panel__jump-fade-leave-to {
  opacity: 0;
  transform: translate(-50%, 20px);
}
</style>
<style>
/* el-popover å…¨å±€æ ·å¼ï¼šç´§è´´èœå•的小 padding;非 scoped æ‰èƒ½å‘½ä¸­ popper-class */
.message-panel__call-popover.el-popover.el-popper {
  min-width: auto;
  padding: 6px;
}
</style>
src/views/im/home/pages/conversation/components/message/message-read-status.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
<script lang="ts" setup>
import type { Message } from '../../../../types'
import { computed, ref } from 'vue'
import { CommonStatusEnum } from '#/packages/constants/src'
import { Popover, TabPane, Tabs } from 'ant-design-vue'
import { getGroupReadUsers as apiGetGroupReadUsers } from '#/api/im/message/group'
import { ImConversationType, ImMessageReceiptStatus } from '../../../../../utils/constants'
import { PagedScroller } from '../../../../components'
import { GroupMember, type GroupMemberLite } from '../../../../components/group'
import { useMessageStore } from '../../../../store/messageStore'
defineOptions({ name: 'ImMessageReadStatus' })
const props = defineProps<{
  // å½“前群编号;供 loadReadUsers ä½œä¸º /im/message/group/get-read-users çš„入参
  groupId: number
  // å½“前群所有成员(外部 MessagePanel.groupMembers ä¼ å…¥ï¼›æ²¡æœ‰å°±ä¼ ç©ºæ•°ç»„,未读列表会变空但不报错)
  groupMembers: GroupMemberLite[]
  message: Message
}>()
const messageStore = useMessageStore()
// popover å¼€å…³ï¼šshow æ—¶æ‹‰å·²è¯»åå•,关闭后保留 readUserIds ç¼“存(重开同一条消息不再请求)
const popVisible = ref(false)
const activeTab = ref<'read' | 'unread'>('read')
// æœåŠ¡ç«¯è¿”å›žçš„"已读这条消息的 userId åˆ—表",未读靠 visibleMembers å‡åŽ»è¿™ä»½å¾—åˆ°
const readUserIds = ref<number[]>([])
/**
 * æ ‡ç­¾æ–‡æ¡ˆï¼š
 * - receiptStatus === DONE:服务端确认全员已读 â†’ ç›´æŽ¥æ˜¾ç¤º"全部已读",readCount ä¸å†ä½¿ç”¨
 * - readCount > 0:显示"N äººå·²è¯»"
 * - å…¶ä»–(readCount = 0 æˆ– undefined,且未到 DONE):显示"未读"
 */
const label = computed(() => {
  if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) {
    return '全部已读'
  }
  const readCount = props.message.readCount || 0
  return readCount > 0 ? `${readCount} äººå·²è¯»` : '未读'
})
/**
 * è¿™æ¡æ¶ˆæ¯"应该被谁看到"的可见成员集合(已读 / æœªè¯»ä¸¤ä¸ª tab å…±ç”¨åŸºåº•)
 *
 * ä¸‰å±‚过滤:
 * 1. å®šå‘群消息:receiverUserIds éžç©ºæ—¶ï¼Œåªç®—名单内的成员。少了这层,未在投递范围里的人
 *    ä¼šè¢«é”™ç®—成"未读"(getGroupReadUsers åŽç«¯å·²æŒ‰å¯è§æ€§è¿‡æ»¤ï¼Œå‰ç«¯å¿…须配套)
 * 2. å‘送者本人:自己发的消息算不算"自己已读"语义模糊,索性两个 tab éƒ½ä¸å±•示
 * 3. å·²é€€ç¾¤ï¼ˆstatus === DISABLE):他读没读已经不关心,UI ä¸å±•示
 */
const visibleMembers = computed<GroupMemberLite[]>(() => {
  const receiverUserIds = props.message.receiverUserIds
  const isDirected = !!receiverUserIds && receiverUserIds.length > 0
  return props.groupMembers.filter(
    (member) =>
      member.status !== CommonStatusEnum.DISABLE &&
      member.userId !== props.message.senderId &&
      (!isDirected || receiverUserIds.includes(member.userId))
  )
})
/** å·²è¯» = å¯è§æˆå‘˜ âˆ© readUserIds */
const readMembers = computed(() =>
  visibleMembers.value.filter((member) => readUserIds.value.includes(member.userId))
)
/** æœªè¯» = å¯è§æˆå‘˜ âˆ’ readUserIds */
const unreadMembers = computed(() =>
  visibleMembers.value.filter((member) => !readUserIds.value.includes(member.userId))
)
/**
 * æ‹‰å–已读用户 id åˆ—表
 *
 * è§¦å‘:el-popover @show(按需懒加载,避免每条群消息都预拉)
 * è·³è¿‡ï¼šæœ¬åœ°å ä½æ¶ˆæ¯ï¼ˆid = 0,还没拿到服务端 id),后端没法按 messageId æŸ¥
 * å¤±è´¥ï¼šä»…控制台告警,readUserIds ä¿æŒç©ºæ•°ç»„ â†’ label èµ° readCount å…œåº•,不阻塞 UI
 *
 * æ‹‰åˆ°åå•后顺手把 readCount / receiptStatus å›žå†™åˆ° messageStore,让 popover å¤–面的
 * label ä¹Ÿè·Ÿç€èµ°æœ€æ–°æ•°ï¼šç¦»çº¿ / æ¼æ”¶ RECEIPT äº‹ä»¶æ—¶æœ¬åœ° readCount ä¼šåæ—§ï¼Œå¼¹å±‚里看到"已读 5"
 * ä½†å¤–面仍是"未读"或旧人数;这里以服务端返回为准矫正回去
 */
async function loadReadUsers() {
  if (!props.message.id) {
    return
  }
  try {
    const userIds = await apiGetGroupReadUsers({
      groupId: props.groupId,
      messageId: props.message.id
    })
    readUserIds.value = userIds || []
    const readCount = readUserIds.value.length
    // å…¨å¯è§æˆå‘˜éƒ½å·²è¯» â†’ æ›´æ–°ä¸º DONE,让外面 label ç›´æŽ¥å‘½ä¸­ã€Œå…¨éƒ¨å·²è¯»ã€åˆ†æ”¯ï¼›
    // å¦åˆ™åªæ›´æ–° readCount,receiptStatus ç»´æŒä¸å˜ï¼ˆPENDING)
    const allRead = readCount > 0 && readCount >= visibleMembers.value.length
    messageStore.applyMessageReadReceipt({
      conversationType: ImConversationType.GROUP,
      targetId: props.groupId,
      groupMessageId: props.message.id,
      readCount,
      receiptStatus: allRead ? ImMessageReceiptStatus.DONE : undefined
    })
  } catch (error) {
    console.error('[IM] æ‹‰å–群已读列表失败:', error)
  }
}
</script>
<template>
  <!--
    ç¾¤æ¶ˆæ¯å·²è¯»çŠ¶æ€
    - æ ‡ç­¾å½¢æ€ï¼šå±•示「N å·²è¯»ã€æˆ–「全部已读」;点击弹出 popover ç”¨ tab åˆ—出已读 / æœªè¯»æˆå‘˜
    - æ¸²æŸ“条件:仅在群聊、自己发送、receiptStatus !== NO_RECEIPT æ—¶æŒ‚出(由 MessageItem æŠŠå…³ï¼‰
    - å·²è¯»åå•按需拉:popover è§¦å‘ show æ‰æ‰“ /im/message/group/get-read-users,避免每条群消息都预拉
    - æœªè¯»åå• = å½“前群成员 âˆ’ è‡ªå·± âˆ’ å·²é€€ç¾¤ âˆ’ å·²è¯»é›†åˆï¼ˆå‰ç«¯èšåˆï¼ŒåŽç«¯åªå›žå·²è¯» userId)
  -->
  <Popover
    v-model:open="popVisible"
    placement="left"
    trigger="click"
    :overlay-style="{ width: '320px' }"
    @open-change="(open) => open && loadReadUsers()"
  >
    <span
      class="text-12px whitespace-nowrap cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[#409eff]"
    >
      {{ label }}
    </span>
    <template #content>
      <Tabs v-model:active-key="activeTab" centered>
        <TabPane :tab="`已读(${readMembers.length})`" key="read">
          <PagedScroller :items="readMembers" :page-size="20" item-key="userId" class="h-75">
            <template #default="{ item }">
              <GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
            </template>
          </PagedScroller>
          <div
            v-if="readMembers.length === 0"
            class="py-5 text-12px text-center text-[var(--ant-color-text-disabled)]"
          >
            {{ groupMembers.length === 0 ? '群成员未加载' : '暂无已读' }}
          </div>
        </TabPane>
        <TabPane :tab="`未读(${unreadMembers.length})`" key="unread">
          <PagedScroller :items="unreadMembers" :page-size="20" item-key="userId" class="h-75">
            <template #default="{ item }">
              <GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
            </template>
          </PagedScroller>
          <div
            v-if="unreadMembers.length === 0"
            class="py-5 text-12px text-center text-[var(--ant-color-text-disabled)]"
          >
            {{ groupMembers.length === 0 ? '群成员未加载' : '全部已读' }}
          </div>
        </TabPane>
      </Tabs>
    </template>
  </Popover>
</template>
src/views/im/home/pages/conversation/components/message/reply-preview.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,234 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { formatFileSize } from '#/packages/utils/src'
import { CardLineLabel } from '#/views/im/home/components/card'
import { ImContentType } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import {
  type AudioMessage,
  type CardMessage,
  type FaceMessage,
  type FileMessage,
  getFileIconInfo,
  type ImageMessage,
  type MaterialMessage,
  parseMessage,
  type QuoteMessage,
  type TextMessage,
  type VideoMessage
} from '#/views/im/utils/message'
import { formatSeconds } from '#/views/im/utils/time'
import { getSenderDisplayName } from '#/views/im/utils/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useMessageStore } from '../../../../store/messageStore'
defineOptions({ name: 'ImReplyPreview' })
const props = withDefaults(
  defineProps<{
    /** æ°”泡内为 true æ”¯æŒç‚¹å‡»è·³è½¬,输入条为 false */
    clickable?: boolean
    /** è¾“入条为 true æ˜¾ç¤º Ã— å…³é—­æŒ‰é’® */
    closable?: boolean
    /** è‡ªå·±å‘送的气泡为 true,把竖线镜像到右侧,与气泡同侧 */
    mirrored?: boolean
    quote: QuoteMessage
  }>(),
  {
    clickable: false,
    closable: false,
    mirrored: false
  }
)
const emit = defineEmits<{
  close: []
  locate: [messageId: number]
}>()
const MAX_TEXT_PREVIEW_LEN = 60 // æ–‡æœ¬æ‘˜è¦åœ¨å¼•用块里展示的最大字符数
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
/** åœ¨å½“前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / ç¼©ç•¥å›¾éƒ½ä»Ž quote.content ç›´æŽ¥æ´¾ç”Ÿ */
const liveMessage = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || !props.quote.messageId) {
    return undefined
  }
  return messageStore
    .getMessages(getClientConversationId(conversation.type, conversation.targetId))
    .find((message) => message.id === props.quote.messageId)
})
/** å‘½ä¸­æœ¬åœ°ç¼“存且 type === RECALL æ‰åˆ¤å®šä¸ºå·²æ’¤å›ž;不在缓存的当快照仍有效 */
const isRecalled = computed(() => liveMessage.value?.type === ImContentType.RECALL)
/** æ¸²æŸ“时实时算,与气泡上方显示名走同一套规则,避免备注变更后引用块陈旧 */
const senderName = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return ''
  }
  return getSenderDisplayName(props.quote.senderId, conversation.type, conversation.targetId)
})
/** quote.content è§£æžä¸€æ¬¡ç¼“存,让多个 computed å¤ç”¨ï¼Œé•¿ä¼šè¯æ¯æ¡å¼•用气泡少一次 JSON.parse */
type AnyQuotePayload = Partial<
  AudioMessage &
    CardMessage &
    FaceMessage &
    FileMessage &
    ImageMessage &
    MaterialMessage &
    TextMessage &
    VideoMessage
>
const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content))
const isText = computed(() => props.quote.type === ImContentType.TEXT)
const isFile = computed(() => props.quote.type === ImContentType.FILE)
const isVoice = computed(() => props.quote.type === ImContentType.VOICE)
const isCard = computed(() => props.quote.type === ImContentType.CARD)
const isFace = computed(() => props.quote.type === ImContentType.FACE)
const isMaterial = computed(() => props.quote.type === ImContentType.MATERIAL)
/** æ–‡æœ¬è¶…过 MAX_TEXT_PREVIEW_LEN æˆªæ–­ï¼Œé•¿å†…容不撑爆引用块 */
const textPreview = computed(() => {
  const text = parsedPayload.value?.content ?? ''
  return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.slice(0, Math.max(0, MAX_TEXT_PREVIEW_LEN))}…`
})
/** æ–‡ä»¶ icon:按扩展名挑色,跟主气泡渲染同源 */
const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name))
/** ç¼©ç•¥å›¾ URL:图片 / è§†é¢‘ / è¡¨æƒ…贴图 / é¢‘道素材封面从 quote.content ç›´æŽ¥å–,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => {
  if (isRecalled.value) {
    return undefined
  }
  const { type } = props.quote
  if (type === ImContentType.IMAGE) {
    return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url
  }
  if (type === ImContentType.VIDEO || type === ImContentType.MATERIAL) {
    return parsedPayload.value?.coverUrl
  }
  if (type === ImContentType.FACE) {
    return parsedPayload.value?.url
  }
  return undefined
})
/** ä»… clickable ä¸”未撤回时触发跳转 */
function onClick() {
  if (!props.clickable || isRecalled.value) {
    return
  }
  emit('locate', props.quote.messageId)
}
</script>
<template>
  <!--
    å¼•用预览块(对齐微信 PC):2px ç«–线作为「引用」视觉标识 + ç´§å‡‘内容预览
    - clickable=true(气泡内):点击触发 locate emit;撤回态禁用跳转
    - closable=true(输入条):尾部 Ã— å…³é—­æŒ‰é’®
    - mirrored=true(自己发送的气泡):竖线镜像到右侧,与气泡同侧
    - å†…容预览(与主气泡同源,不缩写成「[文件]/[语音]」):
      - æ–‡æœ¬ï¼šæˆªæ–­åŽçº¯æ–‡å­—
      - å›¾ç‰‡ / è§†é¢‘:缩略图(IMAGE ç”¨ thumbnailUrl/url,VIDEO ç”¨ coverUrl)
      - æ–‡ä»¶ï¼šfile icon + æ–‡ä»¶å + å¤§å°
      - è¯­éŸ³ï¼šaudio icon + æ—¶é•¿
      - æ’¤å›žï¼ˆå‘½ä¸­æœ¬åœ°ç¼“存且 type === RECALL):「原消息已撤回」斜体灰字
  -->
  <div
    class="flex w-fit gap-1.5 items-center min-w-0 py-0.5 text-12px text-[var(--ant-color-text-secondary)] rounded transition-colors"
    :class="[
      mirrored
        ? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--ant-color-border)]'
        : 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--ant-color-border)]',
      {
        'cursor-pointer hover:text-[var(--ant-color-text)]': clickable && !isRecalled,
        'hover:bg-[var(--ant-color-fill-secondary)]': (clickable && !isRecalled) || closable
      }
    ]"
    @click="onClick"
  >
    <span class="flex-shrink-0">{{ senderName }}:</span>
    <!-- æ’¤å›žé™çº§ -->
    <span v-if="isRecalled" class="italic">原消息已撤回</span>
    <!-- æ–‡æœ¬ -->
    <span v-else-if="isText" class="min-w-0 line-clamp-2 break-words">{{ textPreview }}</span>
    <!-- æ–‡ä»¶ï¼šicon + æ–‡ä»¶å + å¤§å° -->
    <template v-else-if="isFile">
      <Icon :icon="fileIcon.icon" :color="fileIcon.color" :size="14" class="flex-shrink-0" />
      <span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
        {{ parsedPayload.name }}
      </span>
      <span
        v-if="parsedPayload?.size"
        class="flex-shrink-0 text-[var(--ant-color-text-placeholder)]"
      >
        {{ formatFileSize(parsedPayload.size) }}
      </span>
    </template>
    <!-- è¯­éŸ³ï¼šaudio icon + æ—¶é•¿ -->
    <template v-else-if="isVoice">
      <Icon icon="ant-design:audio-outlined" :size="14" class="flex-shrink-0" />
      <span v-if="parsedPayload?.duration" class="flex-shrink-0">
        {{ formatSeconds(parsedPayload.duration) }}
      </span>
    </template>
    <!-- åç‰‡ -->
    <CardLineLabel
      v-else-if="isCard"
      :card="parsedPayload"
      class="min-w-0 line-clamp-2 break-words"
    />
    <!-- è¡¨æƒ…贴图:缩略图 + name(无 name ä»…显示 [表情]) -->
    <template v-else-if="isFace">
      <span class="flex-shrink-0">[表情]</span>
      <span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
        {{ parsedPayload.name }}
      </span>
    </template>
    <!-- é¢‘道素材:[频道] + æ ‡é¢˜ + å°é¢ç¼©ç•¥å›¾ -->
    <template v-else-if="isMaterial">
      <span class="flex-shrink-0">[频道]</span>
      <span v-if="parsedPayload?.title" class="min-w-0 line-clamp-2 break-words">
        {{ parsedPayload.title }}
      </span>
    </template>
    <!-- å›¾ç‰‡ / è§†é¢‘ / è¡¨æƒ…贴图 / é¢‘道素材缩略图 -->
    <img
      v-if="thumbnailUrl"
      :src="thumbnailUrl"
      class="flex-shrink-0 object-cover w-6 h-6 rounded"
      alt=""
    />
    <!-- å…³é—­æŒ‰é’® -->
    <button
      v-if="closable"
      type="button"
      class="flex-shrink-0 inline-flex items-center justify-center w-4 h-4 cursor-pointer rounded-full bg-transparent border-none text-[var(--ant-color-text-secondary)] hover:bg-[var(--ant-color-fill)] hover:text-[var(--ant-color-text)]"
      @click.stop="emit('close')"
    >
      <Icon icon="ant-design:close-outlined" :size="10" />
    </button>
  </div>
</template>
src/views/im/home/pages/conversation/components/message/tip-segments.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
<!--
  æ¶ˆæ¯æ–‡æœ¬ç‰‡æ®µæ¸²æŸ“:tip æ–‡æ¡ˆ + TEXT æ°”泡共用
  - mention æ®µæŒ‚点击 â†’ openMentionUserInfoCardAtEvent
  - link æ®µæ¸²æŸ“ <a target="_blank">,浏览器默认行为打开新标签
  - text æ®µåŽŸæ ·è¾“å‡º
-->
<script lang="ts" setup>
import type { TipSegment } from '#/views/im/utils/message'
import { IM_AT_ALL_USER_ID } from '#/views/im/utils/constants'
import { openMentionUserInfoCardAtEvent } from '#/views/im/utils/user'
defineOptions({ name: 'ImTipSegments' })
defineProps<{
  segments: TipSegment[]
}>()
/** @全体成员是广播 mention,仅高亮配色,不挂可点击交互 */
function isClickableMention(segment: { userId: number }): boolean {
  return segment.userId !== IM_AT_ALL_USER_ID
}
/** mention æ®µç‚¹å‡»ï¼šfallbackName å– segment æ–‡æœ¬ï¼Œé¿å… friend / member éƒ½æŸ¥ä¸åˆ°æ—¶å¼¹ç©º */
function handleMentionClick(
  segment: { text: string; type: 'mention'; userId: number; },
  event: MouseEvent
) {
  if (!isClickableMention(segment)) {
    return
  }
  openMentionUserInfoCardAtEvent(segment.userId, event, segment.text)
}
</script>
<template>
  <template v-for="(segment, _index) in segments" :key="_index">
    <span
      v-if="segment.type === 'mention'"
      class="text-[#576b95]"
      :class="{ 'cursor-pointer hover:underline': isClickableMention(segment) }"
      @click.stop="handleMentionClick(segment, $event)"
      >{{ segment.text }}</span>
    <a
      v-else-if="segment.type === 'link'"
      :href="segment.href"
      target="_blank"
      rel="noopener noreferrer"
      class="text-[#576b95] hover:underline break-all"
      @click.stop
      >{{ segment.text }}</a>
    <span v-else>{{ segment.text }}</span>
  </template>
</template>
src/views/im/home/pages/conversation/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,281 @@
<script lang="ts" setup>
import type { Conversation } from '../../types'
import { computed, nextTick, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Button, Dropdown, Input, Menu } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { StorageKeys } from '../../../utils/db'
import { getGroupDisplayName } from '../../../utils/user'
import { ResizableAside } from '../../components'
import { FriendAddDialog } from '../../components/friend'
import { GroupCreateDialog } from '../../components/group'
import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore'
import { ConversationItem, MessagePanel } from './components'
defineOptions({ name: 'ImMessagePage' })
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const uiStore = useImUiStore()
// ==================== ä¼šè¯åˆ—表 ====================
const keyword = ref('')
const sortedConversations = computed(() => conversationStore.getSortedConversationList)
/** é¡¶éƒ¨æœç´¢æ¡†è¿‡æ»¤ä¼šè¯ï¼šåªæŒ‰ name æ¨¡ç³ŠåŒ¹é…ï¼Œé¿å…å‘½ä¸­ lastContent ç­‰æ¬¡è¦å­—段干扰 */
const filteredConversations = computed(() =>
  filterConversationsByKeyword(sortedConversations.value, keyword.value)
)
// ==================== ç½®é¡¶ç›¸å…³ ====================
const PINNED_FOLD_THRESHOLD = 3 // ç½®é¡¶è¶…过该数量时显示折叠入口;以下数量直接铺开(避免单条置顶就出折叠头视觉太重)
// ç½®é¡¶æŠ˜å å±•开态:localStorage æŒä¹…化,刷新后保留用户上次的选择,对齐微信
const pinnedExpanded = ref(
  localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true'
)
/** toggle + å†™ç›˜ */
function togglePinnedExpanded() {
  pinnedExpanded.value = !pinnedExpanded.value
  localStorage.setItem(
    StorageKeys.localStorage.conversationPinnedExpanded,
    String(pinnedExpanded.value)
  )
}
/** ç½®é¡¶ä¼šè¯ï¼šå•独切片,给折叠头计数 + æŠ˜å åŒºæ¸²æŸ“用 */
const pinnedConversations = computed(() => filteredConversations.value.filter((c) => c.top))
/** éžç½®é¡¶ä¼šè¯ï¼šæŠ˜å æ€ä¸‹å§‹ç»ˆé“ºå¼€åœ¨æŠ˜å å¤´ä¹‹ä¸‹ */
const normalConversations = computed(() => filteredConversations.value.filter((c) => !c.top))
/**
 * ç½®é¡¶åˆ†ä¸¤å †ï¼švisible(折叠头之上 = æœªè¯» + å½“前激活)/ foldable(折叠头之下);一次 partition å®Œæˆ
 *
 * å½“前激活会话也"钉"在 visible:避免点开未读置顶 â†’ ç«‹åˆ»è¢«è¯» â†’ åˆ—表一闪重排回折叠的体验
 */
const pinnedGroups = computed(() => {
  const visible: Conversation[] = []
  const foldable: Conversation[] = []
  for (const conversation of pinnedConversations.value) {
    if (isActiveConversation(conversation) || hasUnreadBadge(conversation)) {
      visible.push(conversation)
    } else {
      foldable.push(conversation)
    }
  }
  return { visible, foldable }
})
/** æŠ˜å æ—¶åªæ¸² visible(未读 / æ¿€æ´»ç©¿é€ï¼‰ï¼›å±•开时渲全部 â€”— å±•开后不分组,避免点击折叠区跨组上跳 */
const renderedPinnedConversations = computed(() =>
  pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible
)
/** ç½®é¡¶æŠ˜å æ—¶æ˜¯å¦ä¸Šæµ®åˆ°æŠ˜å å¤´ä¹‹ä¸Šï¼šä»…以数字徽标为准;免打扰即便有未读也只展示小红点,不参与上浮 */
function hasUnreadBadge(conversation: Conversation): boolean {
  return !conversation.silent && (conversation.unreadCount || 0) > 0
}
/** æ˜¯å¦ä¸ºå½“前激活会话 */
function isActiveConversation(conversation: Conversation): boolean {
  const active = conversationStore.activeConversation
  return !!active && getConversationKey(active) === getConversationKey(conversation)
}
/**
 * æ˜¯å¦è¿›å…¥"分组模式"(独立浅底 + å¯èƒ½æœ‰æŠ˜å å¤´ï¼‰ï¼šæœç´¢æ—¶ä¸åˆ†ç»„(用户在找人,别再让折叠挡住);
 * ç½®é¡¶æ•° < é˜ˆå€¼ä¹Ÿä¸åˆ†ç»„,避免单条置顶就出折叠头视觉太重
 */
const showPinnedSection = computed(
  () => !keyword.value.trim() && pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD
)
// ==================== æ·»åŠ æœ‹å‹ ====================
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // æ·»åŠ æœ‹å‹å¼¹çª— ref:右上角 +-下拉「添加朋友」入口调 open() è§¦å‘
// ==================== å»ºç¾¤ç›¸å…³ ====================
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // å‘起群聊弹窗 ref:handleOpenCreateGroup è°ƒ open() æ‰“å¼€
/** æ‰“开发起群聊弹窗:无锁定项的全局入口 */
function handleOpenCreateGroup() {
  createGroupDialogRef.value?.open()
}
/** å¤„理建群成功 */
function handleGroupCreated(groupId: number) {
  // GroupCreateDialog å·²ç» upsertGroup æŠŠæ–°ç¾¤å†™è¿› store,这里只 get + æ‰“开会话
  const group = groupStore.getGroup(groupId)
  if (!group) {
    return
  }
  conversationStore.openConversation(
    groupId,
    ImConversationType.GROUP,
    getGroupDisplayName(group),
    group.avatar || '',
    { silent: !!group.silent }
  )
}
// ==================== æ»šåŠ¨åˆ°ä¸‹ä¸€ä¸ªæœªè¯» ====================
// å·¥å…·æ ã€Œæ¶ˆæ¯ã€Tab äºŒæ¬¡ç‚¹å‡» â†’ uiStore.nextUnreadJumpNonce é€’增 â†’ æœ¬é¡µ watch æ»šåŠ¨åˆ°ä¸‹ä¸€æ¡æœªè¯»
// å«å…æ‰“扰会话(小红点也算未读);通过维护 lastJumpedConversationKey è®©è¿žç»­ç‚¹å‡»é¡ºåºç©¿è¿‡æ•´ä¸ªæœªè¯»åˆ—表
/** ä¸Šæ¬¡å‘½ä¸­çš„æœªè¯»ä¼šè¯ key;为空时从未读列表头开始 */
let lastJumpedConversationKey: null | string = null
/** æ»šåŠ¨åˆ°ä¸‹ä¸€ä¸ªæœªè¯»ä¼šè¯ï¼ˆå«å…æ‰“æ‰°ï¼‰ï¼›ç›®æ ‡è¢«æœç´¢æ¡†è¿‡æ»¤æˆ–è—åœ¨ç½®é¡¶æŠ˜å åŒºæ—¶å…ˆè§£é™¤æ‹¦æˆªå†æ»š */
async function jumpToNextUnread() {
  // å«å…æ‰“扰的全量未读会话;空则直接返回
  const unreadList = sortedConversations.value.filter((c) => (c.unreadCount || 0) > 0)
  if (unreadList.length === 0) {
    return
  }
  // ä»Žä¸Šæ¬¡å‘½ä¸­é‚£æ¡å¾€åŽæŽ¨ä¸€ä½ï¼›é¦–次或上次目标已读完不在列表里时,refIndex=-1,从头开始
  const refIndex = lastJumpedConversationKey
    ? unreadList.findIndex((c) => getConversationKey(c) === lastJumpedConversationKey)
    : -1
  const target = unreadList[(refIndex + 1) % unreadList.length]
  if (!target) {
    return
  }
  const key = getConversationKey(target)
  lastJumpedConversationKey = key
  // ç›®æ ‡è¢«æœç´¢å…³é”®å­—过滤掉:清空 keyword è®©å®ƒé‡æ–°è¿›å…¥å¯è§åˆ—表
  if (keyword.value && !filteredConversations.value.some((c) => getConversationKey(c) === key)) {
    keyword.value = ''
  }
  // ç›®æ ‡è—åœ¨ç½®é¡¶æŠ˜å åŒºï¼šä¸´æ—¶å±•开;不写 localStorage,刷新后还原折叠态
  const inFoldable = pinnedGroups.value.foldable.some((c) => getConversationKey(c) === key)
  if (inFoldable && !pinnedExpanded.value) {
    pinnedExpanded.value = true
  }
  // ç­‰ keyword / pinnedExpanded å˜åŒ–反应到 DOM åŽå†åŽ»å–ç›®æ ‡å…ƒç´ 
  await nextTick()
  const el = document.querySelector(`[data-conversation-key="${key}"]`) as HTMLElement | null
  if (!el) {
    return
  }
  // block: 'start' æŠŠç›®æ ‡ä¼šè¯é¡¶åˆ°åˆ—表可视区第一行;对齐微信"切到下一个未读=列表的第一条"
  el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread)
</script>
<template>
  <!-- æ¶ˆæ¯ Tab:左侧会话列表 + å³ä¾§èŠå¤©é¢æ¿ -->
  <div class="flex flex-1 min-w-0 h-full">
    <!-- å·¦ä¾§ä¼šè¯åˆ—表(可拖拽宽度) -->
    <ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth">
      <!-- é¡¶éƒ¨ï¼šæœç´¢æ¡† + "+" å·ä¸‹æ‹‰ï¼ˆå¯¹é½å¾®ä¿¡ PC:发起群聊 / æ·»åŠ æœ‹å‹ï¼‰ï¼›h-14 ä¸Žå³ä¾§ MessagePanel å¤´éƒ¨å¯¹é½ -->
      <div
        class="flex flex-shrink-0 gap-2 items-center h-14 px-4 border-b border-b-solid border-[var(--im-border-color-lighter)]"
      >
        <Input v-model:value="keyword" placeholder="搜索" allow-clear class="flex-1">
          <template #prefix>
            <Icon icon="ant-design:search-outlined" />
          </template>
        </Input>
        <Dropdown :trigger="['click']" placement="bottom">
          <Button size="small" shape="circle">
            <Icon icon="ant-design:plus-outlined" />
          </Button>
          <template #overlay>
            <Menu>
              <Menu.Item @click="handleOpenCreateGroup">
                <span class="inline-flex items-center gap-2">
                  <Icon icon="ant-design:message-outlined" :size="16" />
                  <span>发起群聊</span>
                </span>
              </Menu.Item>
              <Menu.Item @click="friendAddDialogRef?.open()">
                <span class="inline-flex items-center gap-2">
                  <Icon icon="ant-design:user-add-outlined" :size="16" />
                  <span>添加朋友</span>
                </span>
              </Menu.Item>
            </Menu>
          </template>
        </Dropdown>
      </div>
      <!-- ä¼šè¯åˆ—表主体 -->
      <div class="flex-1 overflow-y-auto">
        <!-- ç½®é¡¶ä¼šè¯ï¼šæ•°é‡ < é˜ˆå€¼æˆ–搜索中直接铺开;否则进入分组模式(独立浅底 + å¯é€‰æŠ˜å å¤´ï¼‰ -->
        <template v-if="!showPinnedSection">
          <ConversationItem
            v-for="conversation in pinnedConversations"
            :key="getConversationKey(conversation)"
            :conversation="conversation"
          />
        </template>
        <div v-else class="bg-[var(--ant-color-fill-secondary)]">
          <ConversationItem
            v-for="conversation in renderedPinnedConversations"
            :key="getConversationKey(conversation)"
            :conversation="conversation"
          />
          <!-- æŠ˜å å¤´ï¼šæ”¾åœ¨ç½®é¡¶åŒºåº•部对齐 WeChat mac;展开 / æŠ˜å æ€å…±ç”¨ï¼Œä»…在还有"可折叠"内容、或当前已展开时出现 -->
          <div
            v-if="pinnedGroups.foldable.length > 0 || pinnedExpanded"
            class="flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors text-13px text-[var(--ant-color-text)] border-y border-y-solid border-[var(--im-border-color-lighter)] hover:bg-[var(--ant-color-fill)]"
            @click="togglePinnedExpanded"
          >
            <span class="flex items-center gap-1.5">
              <Icon icon="ant-design:menu-outlined" :size="14" />
              {{
                pinnedExpanded
                  ? '折叠置顶聊天'
                  : `${pinnedGroups.foldable.length} ä¸ªç½®é¡¶èŠå¤©`
              }}
            </span>
            <Icon
              :icon="pinnedExpanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
              :size="11"
              class="text-[var(--ant-color-text-placeholder)]"
            />
          </div>
        </div>
        <!-- æ™®é€šä¼šè¯ -->
        <ConversationItem
          v-for="conversation in normalConversations"
          :key="getConversationKey(conversation)"
          :conversation="conversation"
        />
        <div
          v-if="filteredConversations.length === 0"
          class="flex items-center justify-center py-10 text-sm text-[var(--ant-color-text-secondary)]"
        >
          {{ keyword ? '没有满足条件的会话' : '暂无会话' }}
        </div>
      </div>
    </ResizableAside>
    <!-- å³ä¾§èŠå¤©é¢æ¿ -->
    <MessagePanel />
    <!-- æ·»åŠ æœ‹å‹ / å‘起群聊弹窗 -->
    <FriendAddDialog ref="friendAddDialogRef" />
    <GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
  </div>
</template>
在上述文件截断后对比
src/views/im/home/store/channelStore.ts src/views/im/home/store/conversationStore.ts src/views/im/home/store/faceStore.ts src/views/im/home/store/friendStore.ts src/views/im/home/store/groupRequestStore.ts src/views/im/home/store/groupStore.ts src/views/im/home/store/messageStore.ts src/views/im/home/store/rtcStore.ts src/views/im/home/store/uiStore.ts src/views/im/home/store/websocketStore.ts src/views/im/home/types/index.ts src/views/im/manager/channel/list/components/index.ts src/views/im/manager/channel/list/components/select.vue src/views/im/manager/channel/list/data.ts src/views/im/manager/channel/list/index.vue src/views/im/manager/channel/list/modules/form.vue src/views/im/manager/channel/material/components/index.ts src/views/im/manager/channel/material/components/select.vue src/views/im/manager/channel/material/data.ts src/views/im/manager/channel/material/index.vue src/views/im/manager/channel/material/modules/form.vue src/views/im/manager/channel/message/index.vue src/views/im/manager/channel/message/modules/send-form.vue src/views/im/manager/face/pack/data.ts src/views/im/manager/face/pack/index.vue src/views/im/manager/face/pack/modules/form.vue src/views/im/manager/face/pack/modules/item-drawer.vue src/views/im/manager/face/pack/modules/item-form.vue src/views/im/manager/face/userItem/index.vue src/views/im/manager/friend/data.ts src/views/im/manager/friend/index.vue src/views/im/manager/friend/request/index.vue src/views/im/manager/group/components/index.ts src/views/im/manager/group/components/select-dialog.vue src/views/im/manager/group/components/select.vue src/views/im/manager/group/data.ts src/views/im/manager/group/index.vue src/views/im/manager/group/modules/ban-form.vue src/views/im/manager/group/modules/detail.vue src/views/im/manager/group/request/index.vue src/views/im/manager/message/content-preview.vue src/views/im/manager/message/data.ts src/views/im/manager/message/group/index.vue src/views/im/manager/message/group/modules/detail.vue src/views/im/manager/message/index.ts src/views/im/manager/message/private/index.vue src/views/im/manager/message/private/modules/detail.vue src/views/im/manager/rtc/data.ts src/views/im/manager/rtc/index.vue src/views/im/manager/rtc/modules/detail.vue src/views/im/manager/sensitiveword/data.ts src/views/im/manager/sensitiveword/index.vue src/views/im/manager/sensitiveword/modules/form.vue src/views/im/manager/statistics/components/distribution-chart.vue src/views/im/manager/statistics/components/index.ts src/views/im/manager/statistics/components/overview-cards.vue src/views/im/manager/statistics/components/trend-chart.vue src/views/im/manager/statistics/index.vue src/views/im/manager/utils/format.ts src/views/im/utils/auth.ts src/views/im/utils/channel.ts src/views/im/utils/config.ts src/views/im/utils/constants.ts src/views/im/utils/conversation.ts src/views/im/utils/db.ts src/views/im/utils/emoji.ts src/views/im/utils/group.ts src/views/im/utils/image.ts src/views/im/utils/message.ts src/views/im/utils/pull.ts src/views/im/utils/time.ts src/views/im/utils/user.ts