From 27cd042df9aca0383a49f3514bc21958dd890912 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期一, 29 六月 2026 15:42:23 +0800
Subject: [PATCH] 银川 1.联调产品维护页面 2.添加IM即时通讯模块
---
src/views/im/home/components/group/group-member-remove-dialog.vue | 105
src/views/im/manager/rtc/data.ts | 144
src/views/im/home/pages/conversation/components/message/message-panel.vue | 805 +
src/views/im/manager/channel/list/modules/form.vue | 82
src/views/im/manager/message/group/index.vue | 110
src/views/im/utils/conversation.ts | 177
src/views/im/home/components/picker/friend-picker-panel.vue | 245
src/views/im/home/components/picker/index.ts | 3
src/views/im/home/components/picker/picker-dialog.scss | 13
src/views/im/home/composables/useMessagePuller.ts | 464
src/views/im/utils/group.ts | 289
src/views/im/manager/group/modules/ban-form.vue | 65
src/api/im/message/private/index.ts | 89
src/views/im/home/pages/conversation/components/conversation/conversation-private-side.vue | 285
src/views/im/manager/sensitiveword/index.vue | 175
src/views/im/home/components/rtc/index.ts | 8
src/views/im/home/components/user/recommend-card-dialog.vue | 322
src/views/im/manager/sensitiveword/data.ts | 120
src/views/im/home/pages/conversation/components/input/face-picker.vue | 353
src/views/im/utils/channel.ts | 18
src/views/im/manager/group/data.ts | 255
src/views/im/home/index.vue | 292
src/api/im/rtc/index.ts | 105
src/views/im/manager/utils/format.ts | 55
src/router/routes/modules/im.ts | 43
src/views/im/manager/statistics/components/distribution-chart.vue | 118
src/views/im/utils/message.ts | 1035 +
src/api/im/message/channel/index.ts | 34
src/views/im/home/components/user/index.ts | 5
src/views/im/home/components/group/group-info.vue | 154
src/views/im/home/components/picker/conversation-picker-panel.vue | 370
src/views/im/home/components/group/group-item.vue | 43
src/views/im/home/components/user/user-info.vue | 447
src/views/im/home/pages/conversation/components/message/tip-segments.vue | 54
src/views/im/home/components/rtc/rtc-call-participant-tile.vue | 110
src/views/im/utils/emoji.ts | 21
src/views/im/manager/statistics/components/overview-cards.vue | 105
src/views/im/manager/group/components/select.vue | 129
src/views/im/home/components/paged-scroller.vue | 109
src/views/im/utils/db.ts | 541
src/views/im/home/pages/conversation/components/message/material-bubble.vue | 170
src/views/im/home/components/card/card-line-label.vue | 29
src/views/im/home/pages/conversation/components/conversation/conversation-item.vue | 330
src/api/im/channel/material/index.ts | 24
src/views/im/manager/channel/material/components/select.vue | 71
src/views/im/home/components/group/group-member-add-dialog.vue | 167
src/views/im/home/composables/useLiveKitRoom.ts | 331
src/views/im/home/components/group/group-create-dialog.vue | 144
src/views/im/utils/pull.ts | 155
src/views/im/home/components/friend/friend-item.vue | 79
src/views/im/home/components/picker/group-member-picker-panel.vue | 242
src/views/im/home/pages/conversation/components/message/forward/message-merge-detail-dialog.vue | 129
src/views/im/manager/channel/list/index.vue | 130
src/api/im/manager/channel/material/index.ts | 68
src/views/im/home/components/card/card-bubble.vue | 68
src/api/im/conversation/read/index.ts | 25
src/views/im/manager/message/group/modules/detail.vue | 92
src/api/im/manager/channel/message/index.ts | 48
src/views/im/home/pages/conversation/components/message/message-read-status.vue | 169
src/views/im/home/components/group/group-member-grid.vue | 50
src/views/im/home/composables/useVoicePlayer.ts | 82
src/views/im/manager/message/private/index.vue | 101
src/views/im/home/components/group/group-mute-member-dialog.vue | 97
src/views/im/home/components/group/group-request-list-dialog.vue | 377
src/views/im/home/components/rtc/rtc-call-inviting.vue | 129
src/views/im/manager/face/pack/data.ts | 375
src/views/im/manager/face/pack/modules/form.vue | 83
src/views/im/utils/time.ts | 128
src/views/im/home/components/group/group-member-item.vue | 84
src/views/im/home/components/group/group-avatar.vue | 131
src/views/im/home/components/tool-bar.vue | 123
src/views/im/home/pages/conversation/components/conversation/conversation-group-side.vue | 832 +
src/views/im/home/pages/conversation/components/input/index.ts | 5
src/views/im/home/components/group/group-owner-transfer-dialog.vue | 126
src/api/im/group/member/index.ts | 78
src/views/im/manager/channel/material/modules/form.vue | 87
src/views/im/home/pages/conversation/components/index.ts | 3
src/views/im/home/pages/contact/friend-request-detail.vue | 229
src/views/im/home/pages/conversation/components/message/message-history.vue | 807 +
src/views/basicData/mdm/index.vue | 4
src/views/im/home/store/uiStore.ts | 155
src/views/im/home/components/context-menu.vue | 99
src/views/im/home/pages/contact/friend-list.vue | 73
src/views/im/home/store/groupRequestStore.ts | 242
src/views/im/home/pages/conversation/components/message/message-item.vue | 1055 +
src/views/im/home/composables/useMediaUploader.ts | 408
src/views/im/home/store/faceStore.ts | 151
src/views/im/utils/user.ts | 367
src/api/im/manager/friend/index.ts | 32
src/views/im/home/components/resizable-aside.vue | 111
src/api/im/manager/group/index.ts | 87
src/views/im/home/composables/useMessageMultiSelect.ts | 48
package.json | 1
src/views/im/home/components/rtc/rtc-call-incoming.vue | 112
src/views/im/home/components/friend/friend-add-dialog.vue | 289
src/api/im/manager/face/pack/index.ts | 55
src/api/im/friend/request/index.ts | 84
src/views/im/manager/friend/request/index.vue | 60
src/views/im/home/pages/conversation/components/conversation/index.ts | 3
src/views/im/home/pages/conversation/components/input/message-multi-select-bar.vue | 153
src/views/im/home/components/rtc/rtc-call-running.vue | 291
src/views/im/home/composables/useGroupCallMembers.ts | 53
src/views/im/utils/auth.ts | 14
src/views/im/home/components/rtc/rtc-group-call-banner.vue | 205
src/views/im/home/pages/contact/index.vue | 309
src/views/im/home/components/user/user-info-card.vue | 121
src/views/basicData/mdm/data.ts | 2
src/views/im/home/pages/conversation/components/message/forward/index.ts | 2
src/views/im/manager/message/private/modules/detail.vue | 75
src/views/im/home/components/friend/index.ts | 2
src/views/im/manager/channel/material/data.ts | 235
src/views/im/home/store/websocketStore.ts | 1259 ++
src/api/im/manager/sensitiveword/index.ts | 60
src/api/im/message/group/index.ts | 92
src/views/im/home/store/conversationStore.ts | 910 +
src/views/im/manager/channel/list/components/index.ts | 1
src/views/im/manager/channel/message/index.vue | 132
src/views/im/manager/statistics/components/index.ts | 3
src/views/im/home/pages/conversation/components/message/index.ts | 10
src/views/im/home/components/group/group-member.vue | 64
src/views/im/home/composables/useSelectedItems.ts | 75
src/views/im/home/store/friendStore.ts | 849 +
src/views/im/utils/constants.ts | 296
src/views/im/manager/rtc/index.vue | 98
src/views/im/manager/friend/index.vue | 84
src/views/im/home/components/group/group-admin-set-dialog.vue | 122
src/views/im/home/store/messageStore.ts | 953 +
src/views/im/manager/group/components/select-dialog.vue | 271
src/api/im/friend/index.ts | 78
src/api/im/manager/rtc/index.ts | 53
src/views/basicData/mdm/modules/form.vue | 4
src/views/im/manager/statistics/components/trend-chart.vue | 145
src/views/im/home/pages/conversation/components/message/reply-preview.vue | 234
src/views/im/manager/channel/material/components/index.ts | 1
src/api/im/group/index.ts | 146
src/api/im/face/pack/index.ts | 26
src/views/im/utils/image.ts | 57
src/views/im/home/composables/useFriendBuckets.ts | 99
src/views/im/home/pages/conversation/components/input/mention-picker.vue | 207
src/views/im/home/pages/conversation/components/input/message-input.vue | 1216 ++
src/views/im/home/components/card/index.ts | 2
src/api/im/face/userItem/index.ts | 38
src/views/im/manager/channel/material/index.vue | 138
src/views/im/home/pages/contact/group-list.vue | 65
src/views/im/home/pages/contact/friend-request-list.vue | 138
src/api/im/manager/face/userItem/index.ts | 33
src/views/im/home/composables/useMediaStreamElement.ts | 40
src/views/im/manager/face/pack/modules/item-form.vue | 119
src/views/im/manager/group/modules/detail.vue | 151
pnpm-lock.yaml | 98
src/views/im/home/pages/conversation/index.vue | 281
src/views/im/manager/statistics/index.vue | 45
src/views/im/utils/config.ts | 133
src/views/im/home/composables/useMuteOverlay.ts | 102
src/views/im/home/types/index.ts | 318
src/views/im/home/pages/conversation/components/message/forward/keys.ts | 24
src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue | 446
src/views/im/manager/rtc/modules/detail.vue | 119
src/views/im/home/store/channelStore.ts | 108
src/views/im/manager/sensitiveword/modules/form.vue | 83
src/views/im/home/components/user/user-avatar.vue | 134
src/api/im/manager/group/request/index.ts | 33
src/views/im/home/composables/useMessageSender.ts | 337
src/views/im/manager/message/content-preview.vue | 254
src/views/im/home/pages/conversation/components/input/voice-recorder.vue | 340
src/api/im/manager/message/group/index.ts | 40
src/api/im/manager/message/private/index.ts | 38
src/views/im/manager/channel/message/modules/send-form.vue | 100
src/views/im/home/store/groupStore.ts | 1017 +
src/views/im/home/components/rtc/rtc-call-container.vue | 488
src/views/im/manager/group/index.vue | 167
src/views/im/manager/channel/list/components/select.vue | 66
src/views/im/manager/message/index.ts | 1
src/api/im/manager/channel/index.ts | 56
src/views/im/manager/group/request/index.vue | 75
src/views/im/manager/friend/data.ts | 264
src/api/im/manager/friend/request/index.ts | 30
src/views/im/home/components/group/index.ts | 15
src/views/im/manager/face/pack/modules/item-drawer.vue | 217
src/views/im/home/pages/conversation/components/message/group-pinned-message.vue | 207
src/api/im/group/request/index.ts | 90
src/views/im/manager/face/userItem/index.vue | 106
src/views/im/manager/group/components/index.ts | 2
src/views/im/manager/channel/list/data.ts | 157
src/views/im/manager/message/data.ts | 254
src/views/im/manager/face/pack/index.vue | 198
src/views/im/home/components/rtc/rtc-call-member-picker-dialog.vue | 109
src/views/im/home/pages/conversation/components/message/group-request-pending.vue | 88
src/api/im/manager/face/item/index.ts | 63
src/views/im/home/pages/contact/group-detail.vue | 26
src/api/im/manager/statistics/index.ts | 88
src/views/im/home/store/rtcStore.ts | 450
src/views/im/home/components/index.ts | 4
src/views/im/home/components/group/group-info-card.vue | 116
src/views/im/home/pages/conversation/components/message/message-bubble.vue | 414
195 files changed, 35,250 insertions(+), 13 deletions(-)
diff --git a/package.json b/package.json
index 58da272..1d29683 100644
--- a/package.json
+++ b/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:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c9dd3e..54fadff 100644
--- a/pnpm-lock.yaml
+++ b/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
diff --git a/src/api/im/channel/material/index.ts b/src/api/im/channel/material/index.ts
new file mode 100644
index 0000000..9f0338f
--- /dev/null
+++ b/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 } },
+ );
+}
diff --git a/src/api/im/conversation/read/index.ts b/src/api/im/conversation/read/index.ts
new file mode 100644
index 0000000..cc2d3a8
--- /dev/null
+++ b/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 },
+ );
+}
diff --git a/src/api/im/face/pack/index.ts b/src/api/im/face/pack/index.ts
new file mode 100644
index 0000000..58ad781
--- /dev/null
+++ b/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');
+}
diff --git a/src/api/im/face/userItem/index.ts b/src/api/im/face/userItem/index.ts
new file mode 100644
index 0000000..07bcf1c
--- /dev/null
+++ b/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 },
+ });
+}
diff --git a/src/api/im/friend/index.ts b/src/api/im/friend/index.ts
new file mode 100644
index 0000000..4819158
--- /dev/null
+++ b/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=姝e父锛�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 },
+ });
+}
diff --git a/src/api/im/friend/request/index.ts b/src/api/im/friend/request/index.ts
new file mode 100644
index 0000000..00c98f0
--- /dev/null
+++ b/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 鍗曟煡銆屾垜鐩稿叧銆嶇殑鐢宠璁板綍锛堝甫瓒婃潈杩囨护锛沇ebSocket 閫氱煡鍒拌揪鍚庣敤锛� */
+export function getMyFriendRequest(id: number) {
+ return requestClient.get<ImFriendRequestApi.FriendRequestRespVO | null>(
+ '/im/friend-request/get',
+ { params: { id } },
+ );
+}
diff --git a/src/api/im/group/index.ts b/src/api/im/group/index.ts
new file mode 100644
index 0000000..ca5e78d
--- /dev/null
+++ b/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=姝e父锛�1=宸茶В鏁o級
+ dissolvedTime?: string; // 瑙f暎鏃堕棿
+ 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);
+}
+
+/** 瑙f暎缇� */
+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);
+}
diff --git a/src/api/im/group/member/index.ts b/src/api/im/group/member/index.ts
new file mode 100644
index 0000000..bd27a15
--- /dev/null
+++ b/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);
+}
diff --git a/src/api/im/group/request/index.ts b/src/api/im/group/request/index.ts
new file mode 100644
index 0000000..09eaf24
--- /dev/null
+++ b/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; // 閭�璇蜂汉锛汵ULL 琛ㄧず鐢ㄦ埛涓诲姩鐢宠
+ 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 鍗曟煡鐢宠璁板綍锛堝甫瓒婃潈杩囨护锛沇ebSocket 閫氱煡鍒拌揪鍚庣敤锛� */
+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 },
+ );
+}
diff --git a/src/api/im/manager/channel/index.ts b/src/api/im/manager/channel/index.ts
new file mode 100644
index 0000000..31b6f56
--- /dev/null
+++ b/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',
+ );
+}
diff --git a/src/api/im/manager/channel/material/index.ts b/src/api/im/manager/channel/material/index.ts
new file mode 100644
index 0000000..143fed7
--- /dev/null
+++ b/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 },
+ });
+}
diff --git a/src/api/im/manager/channel/message/index.ts b/src/api/im/manager/channel/message/index.ts
new file mode 100644
index 0000000..8e9af01
--- /dev/null
+++ b/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 },
+ );
+}
diff --git a/src/api/im/manager/face/item/index.ts b/src/api/im/manager/face/item/index.ts
new file mode 100644
index 0000000..4f50111
--- /dev/null
+++ b/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(',') } },
+ );
+}
diff --git a/src/api/im/manager/face/pack/index.ts b/src/api/im/manager/face/pack/index.ts
new file mode 100644
index 0000000..64f8c71
--- /dev/null
+++ b/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(',') },
+ });
+}
diff --git a/src/api/im/manager/face/userItem/index.ts b/src/api/im/manager/face/userItem/index.ts
new file mode 100644
index 0000000..f332944
--- /dev/null
+++ b/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 },
+ });
+}
diff --git a/src/api/im/manager/friend/index.ts b/src/api/im/manager/friend/index.ts
new file mode 100644
index 0000000..fc0ddf9
--- /dev/null
+++ b/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 },
+ );
+}
diff --git a/src/api/im/manager/friend/request/index.ts b/src/api/im/manager/friend/request/index.ts
new file mode 100644
index 0000000..8ef6deb
--- /dev/null
+++ b/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 },
+ );
+}
diff --git a/src/api/im/manager/group/index.ts b/src/api/im/manager/group/index.ts
new file mode 100644
index 0000000..adc4782
--- /dev/null
+++ b/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);
+}
+
+/** 瑙e皝缇� */
+export function unbanManagerGroup(id: number) {
+ return requestClient.put<boolean>('/im/manager/group/unban', undefined, {
+ params: { id },
+ });
+}
+
+/** 瑙f暎缇� */
+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 } },
+ );
+}
diff --git a/src/api/im/manager/group/request/index.ts b/src/api/im/manager/group/request/index.ts
new file mode 100644
index 0000000..1c799be
--- /dev/null
+++ b/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 },
+ );
+}
diff --git a/src/api/im/manager/message/group/index.ts b/src/api/im/manager/message/group/index.ts
new file mode 100644
index 0000000..b8b0fdc
--- /dev/null
+++ b/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 } },
+ );
+}
diff --git a/src/api/im/manager/message/private/index.ts b/src/api/im/manager/message/private/index.ts
new file mode 100644
index 0000000..711768d
--- /dev/null
+++ b/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 } },
+ );
+}
diff --git a/src/api/im/manager/rtc/index.ts b/src/api/im/manager/rtc/index.ts
new file mode 100644
index 0000000..a74f4f3
--- /dev/null
+++ b/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 } },
+ );
+}
diff --git a/src/api/im/manager/sensitiveword/index.ts b/src/api/im/manager/sensitiveword/index.ts
new file mode 100644
index 0000000..1847459
--- /dev/null
+++ b/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(',') } },
+ );
+}
diff --git a/src/api/im/manager/statistics/index.ts b/src/api/im/manager/statistics/index.ts
new file mode 100644
index 0000000..fb4a040
--- /dev/null
+++ b/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',
+ );
+}
diff --git a/src/api/im/message/channel/index.ts b/src/api/im/message/channel/index.ts
new file mode 100644
index 0000000..10d8617
--- /dev/null
+++ b/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 },
+ });
+}
diff --git a/src/api/im/message/group/index.ts b/src/api/im/message/group/index.ts
new file mode 100644
index 0000000..200e1c6
--- /dev/null
+++ b/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; // 娑堟伅鍐呭锛圝SON 鏍煎紡锛�
+ 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; // 娑堟伅鍐呭锛圝SON 鏍煎紡锛�
+ 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,
+ });
+}
diff --git a/src/api/im/message/private/index.ts b/src/api/im/message/private/index.ts
new file mode 100644
index 0000000..7c40ab6
--- /dev/null
+++ b/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; // 娑堟伅鍐呭锛圝SON 鏍煎紡锛�
+ status: number; // 娑堟伅鐘舵�侊紙姝e父 / 宸叉挙鍥烇級
+ receiptStatus?: number; // 鍥炴墽鐘舵�侊紙涓嶉渶瑕� / 寰呭畬鎴� / 宸插畬鎴愶級锛屽榻� ImMessageReceiptStatus
+ sendTime: string; // 鍙戦�佹椂闂�
+ }
+
+ /** 绉佽亰娑堟伅鍙戦�� Request VO */
+ export interface PrivateMessageSendReqVO {
+ clientMessageId: string; // 瀹㈡埛绔秷鎭紪鍙�
+ receiverId: number; // 鎺ユ敹浜虹紪鍙�
+ type: number; // 鍐呭绫诲瀷
+ content: string; // 娑堟伅鍐呭锛圝SON 鏍煎紡锛�
+ 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 } },
+ );
+}
diff --git a/src/api/im/rtc/index.ts b/src/api/im/rtc/index.ts
new file mode 100644
index 0000000..801df2a
--- /dev/null
+++ b/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锛沜reate / 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锛堟帴鍙i潤榛橈級 */
+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 } },
+ );
+}
diff --git a/src/router/routes/modules/im.ts b/src/router/routes/modules/im.ts
new file mode 100644
index 0000000..b2c0205
--- /dev/null
+++ b/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;
diff --git a/src/views/basicData/mdm/data.ts b/src/views/basicData/mdm/data.ts
index 2ae684a..a809585 100644
--- a/src/views/basicData/mdm/data.ts
+++ b/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,
diff --git a/src/views/basicData/mdm/index.vue b/src/views/basicData/mdm/index.vue
index b5a84fa..2331b95 100644
--- a/src/views/basicData/mdm/index.vue
+++ b/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';
diff --git a/src/views/basicData/mdm/modules/form.vue b/src/views/basicData/mdm/modules/form.vue
index 4d5be9a..6b339cb 100644
--- a/src/views/basicData/mdm/modules/form.vue
+++ b/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({
diff --git a/src/views/im/home/components/card/card-bubble.vue b/src/views/im/home/components/card/card-bubble.vue
new file mode 100644
index 0000000..0fb56da
--- /dev/null
+++ b/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<{
+ /** 鍚嶇墖鏁版嵁锛汣ardMessage锛堟帴鏀朵晶娑堟伅浣擄級鎴� 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>
diff --git a/src/views/im/home/components/card/card-line-label.vue b/src/views/im/home/components/card/card-line-label.vue
new file mode 100644
index 0000000..b722328
--- /dev/null
+++ b/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] 缇ゅ悕鐗� / 涓汉鍚嶇墖锛歺x銆嶏紱鍒楄〃鎽樿 / 寮曠敤棰勮 / 鍚庡彴棰勮澶嶇敤 -->
+ <span class="inline-flex gap-1.5 items-center">
+ <Icon :icon="labelInfo.icon" :size="iconSize" />
+ <span>{{ labelInfo.label }}锛歿{ card?.name || '' }}</span>
+ </span>
+</template>
diff --git a/src/views/im/home/components/card/index.ts b/src/views/im/home/components/card/index.ts
new file mode 100644
index 0000000..ac62b1b
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/components/context-menu.vue b/src/views/im/home/components/context-menu.vue
new file mode 100644
index 0000000..df364c9
--- /dev/null
+++ b/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)
+
+/**
+ * 璁$畻鑿滃崟瀹為檯娓叉煋鍧愭爣锛氶潬杩戣鍙e彸 / 涓嬭竟缂樻椂鍥炲脊锛岄伩鍏嶈彍鍗曡瑁佸壀
+ *
+ * itemHeight / menuWidth 鏄拰妯℃澘閲� px-4 py-2 + text-13px / min-w-30 閰嶅鐨勫疄闄呭昂瀵革紱
+ * dividerHeight = 9px锛坢y-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 鍏滃簳锛歸indow 涓嶅彲鐢ㄦ椂鐩存帴杩斿洖鍘熷鍧愭爣
+ 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 椤瑰拷鐣ワ紱姝e父椤硅皟 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>
diff --git a/src/views/im/home/components/friend/friend-add-dialog.vue b/src/views/im/home/components/friend/friend-add-dialog.vue
new file mode 100644
index 0000000..152f4ca
--- /dev/null
+++ b/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>('') // 鏉ユ簮闄勫甫淇℃伅锛歛ddSource=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') // 褰撳墠姝ラ锛歴earch=鎼滅储鍒楄〃锛沘pply=鐢宠琛ㄥ崟
+const targetUser = ref<null | SystemUserApi.UserSimple>(null) // 鐢宠鐩爣鐢ㄦ埛
+const applyContent = ref('') // 鐢宠鐞嗙敱锛堥粯璁ゅ~銆屾垜鏄� ${褰撳墠鏄电О}銆嶏紝瀵归綈寰俊浜や簰锛�
+const displayName = ref('') // 瀵规帴鏀舵柟鐨勫娉紙浠呰嚜宸卞彲瑙侊級
+const submitting = ref(false) // 鎻愪氦涓�
+
+/** 寮圭獥鏍囬闅忔楠ゅ垏鎹� */
+const dialogTitle = computed(() => (step.value === 'apply' ? '鐢宠娣诲姞鏈嬪弸' : '娣诲姞濂藉弸'))
+
+/** 鏄惁棰勫~妯″紡锛坧resetUser 涓嶄负绌� 鈫� 璺宠繃鎼滅储锛屽叧闂嵆閿�姣侊紝鏃犮�屽彇娑堣繑鍥炴悳绱€�嶆寜閽級 */
+const presetMode = computed(() => !!presetUser.value)
+
+function resetAll() {
+ keyword.value = ''
+ users.value = []
+ searched.value = false
+ // 棰勫~妯″紡锛氱洿鎺ヨ繘鐢宠琛ㄥ崟锛宼argetUser 鍙栬嚜 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 鎿嶄綔鎸夐挳锛坰lot 蹇呴』鏄� 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>
diff --git a/src/views/im/home/components/friend/friend-item.vue b/src/views/im/home/components/friend/friend-item.vue
new file mode 100644
index 0000000..9feebab
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/components/friend/index.ts b/src/views/im/home/components/friend/index.ts
new file mode 100644
index 0000000..443154c
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/components/group/group-admin-set-dialog.vue b/src/views/im/home/components/group/group-admin-set-dialog.vue
new file mode 100644
index 0000000..060355a
--- /dev/null
+++ b/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锛坓rid 褰㈡�佸榻愬綋鍓嶈瑙夛級
+ - 缇や富浠庡�欓�夐噷闅愯棌锛堜笉鑳借涓虹鐞嗗憳锛�
+ - 瀵瑰鎺ュ彛锛歳ef + 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>
diff --git a/src/views/im/home/components/group/group-avatar.vue b/src/views/im/home/components/group/group-avatar.vue
new file mode 100644
index 0000000..021714f
--- /dev/null
+++ b/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 // 鍦嗚锛孋SS 闀垮害
+ size?: number // 灏哄锛坧x锛夛紝姝f柟褰�
+ 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 灞忔嫾鍥剧硦锛汥PR 灏侀《 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 涓垚鍛樼殑鎷煎浘鍏ュ弬锛沶ame 璧� getMemberDisplayName 鍙e緞锛堝ソ鍙嬪娉� > 缇ゆ樀绉� > 鐪熷疄鏄电О锛� */
+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锛沵ergeToken 鏍¢獙閬垮厤鑰� 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>
diff --git a/src/views/im/home/components/group/group-create-dialog.vue b/src/views/im/home/components/group/group-create-dialog.vue
new file mode 100644
index 0000000..18b87a5
--- /dev/null
+++ b/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({
+ /** 鎵撳紑鍙戣捣缇よ亰寮圭獥锛歳eset 鈫� 鐏屽弬 鈫� 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 鏄叆鍙i攣瀹氶」锛屼笉绠�"鐢ㄦ埛涓诲姩閫夋嫨"锛� */
+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
+ - 瀵瑰鎺ュ彛锛歳ef + 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>
diff --git a/src/views/im/home/components/group/group-info-card.vue b/src/views/im/home/components/group/group-info-card.vue
new file mode 100644
index 0000000..05de318
--- /dev/null
+++ b/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>锛屼笌閫氳褰曡鎯呭叡鐢ㄤ竴浠界粍浠�
+ - 瑙﹀彂锛歶seImUiStore.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>
diff --git a/src/views/im/home/components/group/group-info.vue b/src/views/im/home/components/group/group-info.vue
new file mode 100644
index 0000000..9254cc1
--- /dev/null
+++ b/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 鏈媺锛歠etchGroupList 鎺ュ彛璇箟鍗炽�屾垜鍔犲叆鐨勭兢銆嶏紝鍛戒腑瑙嗕负 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 浼樺厛鐢ㄦ湰鍦版媺鍒扮殑鍒楄〃闀垮害锛宻tranger 鐢� 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>
diff --git a/src/views/im/home/components/group/group-item.vue b/src/views/im/home/components/group/group-item.vue
new file mode 100644
index 0000000..9fa60e2
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/components/group/group-member-add-dialog.vue b/src/views/im/home/components/group/group-member-add-dialog.vue
new file mode 100644
index 0000000..b0d0e12
--- /dev/null
+++ b/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 寮傛鍔犺浇鐨勭獥鍙o紱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 鐨�"宸插嬀閫夌伆鎬�"
+ - 瀵瑰鎺ュ彛锛歳ef + 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>
diff --git a/src/views/im/home/components/group/group-member-grid.vue b/src/views/im/home/components/group/group-member-grid.vue
new file mode 100644
index 0000000..e7cc7d6
--- /dev/null
+++ b/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 鍙充晶宸查�夊尯锛坓rid 褰㈡�侊級銆丆onversationGroupSide 缇ゆ垚鍛樺尯寰幆浣跨敤
+ -->
+ <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>
diff --git a/src/views/im/home/components/group/group-member-item.vue b/src/views/im/home/components/group/group-member-item.vue
new file mode 100644
index 0000000..23085c1
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/components/group/group-member-remove-dialog.vue b/src/views/im/home/components/group/group-member-remove-dialog.vue
new file mode 100644
index 0000000..b81b003
--- /dev/null
+++ b/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 娆℃帴鍙g渷寰�杩� */
+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 闅愯棌锛岀敱璋冪敤鏂逛紶鍏�
+ - 瀵瑰鎺ュ彛锛歳ef + 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>
diff --git a/src/views/im/home/components/group/group-member.vue b/src/views/im/home/components/group/group-member.vue
new file mode 100644
index 0000000..2ae97a2
--- /dev/null
+++ b/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 // 琛岄珮锛坧x锛夛紝褰卞搷澶村儚澶у皬
+ member: GroupMemberLite
+ }>(),
+ {
+ height: 50,
+ active: false,
+ clickable: false,
+ groupName: ''
+ }
+)
+
+/** 缇ゆ垚鍛樼粨鏋勶紙璺ㄥ澶勪娇鐢紝鏀捐繖閲屽仛绐勬帴鍙o紱鐙珛浜� types/index.ts锛� */
+export interface GroupMemberLite {
+ userId: number // 鐢ㄦ埛缂栧彿锛涚壒娈婂�艰 IM_AT_ALL_USER_ID锛園 鍏ㄤ綋鎴愬憳锛�
+ nickname: string // 鐪熷疄鏄电О锛氭案杩滄槸鐢ㄦ埛鐨� nickname锛屼笓缁� UserAvatar 鑹插崱鐢紝淇濊瘉鍚屼竴涓汉鑹插崱棣栧瓧姣嶅湪鎵�鏈夌晫闈竴鑷�
+ showName: string // 灞曠ず鏄电О锛氬ソ鍙嬪娉� > 鐢ㄦ埛缇ゅ娉紙displayUserName锛� > 鐪熷疄鏄电О锛坣ickname锛夛紝缁�"鏄剧ず缁欑敤鎴风湅"鐨勪綅缃敤锛堣鍐呮枃瀛椼�丂鍊欓�夋爣绛剧瓑锛�
+ 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>
diff --git a/src/views/im/home/components/group/group-mute-member-dialog.vue b/src/views/im/home/components/group/group-mute-member-dialog.vue
new file mode 100644
index 0000000..5f5315c
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/components/group/group-owner-transfer-dialog.vue b/src/views/im/home/components/group/group-owner-transfer-dialog.vue
new file mode 100644
index 0000000..529d344
--- /dev/null
+++ b/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({
+ /** 鎵撳紑杞缇や富寮圭獥锛歳eset 鈫� 鐏屽弬 鈫� 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 闄愬畾鍗曢��
+ - 瀵瑰鎺ュ彛锛歳ef + 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>
diff --git a/src/views/im/home/components/group/group-request-list-dialog.vue b/src/views/im/home/components/group/group-request-list-dialog.vue
new file mode 100644
index 0000000..0b4cf6d
--- /dev/null
+++ b/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>() // 褰撳墠灞曠ず鐨勭兢缂栧彿锛泆ndefined 鏃惰蛋鍏ㄥ眬鏈鐞嗗垪琛紙store.unhandledList锛�
+const loading = ref(false)
+const groupList = ref<ImGroupRequestApi.GroupRequestRespVO[]>([])
+const actingId = ref<null | number>(null)
+
+defineExpose({
+ /** 鎵撳紑杩涚兢鐢宠寮圭獥锛歳eset 鈫� 鐏屽弬 鈫� 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 涓綊灞炴湰缇ょ殑鏈鐞嗗垪琛ㄥ彉鍖栵細杩滅浜嬩欢锛圵S 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>
+ <!--
+ 缇ゃ�岃繘缇ょ敵璇枫�嶅垪琛ㄥ璇濇
+ - 浠呯兢涓� / 绠$悊鍛樺叆鍙e彲杈撅紱灞曠ず褰撳墠缇や笅鍏ㄩ儴鐢宠锛堝惈宸插鐞嗭級
+ - 椤堕儴鏈�鏂颁竴鏉″崱鐗囧寲绐佸嚭锛堝甫鐢宠鐞嗙敱锛夛紝鍏朵綑鎸� 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锛宻coped 閫変笉鍒帮紝鐣欓潪 scoped 鍏ㄥ眬瑕嗙洊 */
+.im-group-request-list__dialog .el-dialog__body {
+ padding: 12px 20px 8px;
+ background-color: var(--ant-color-fill-secondary);
+}
+</style>
diff --git a/src/views/im/home/components/group/index.ts b/src/views/im/home/components/group/index.ts
new file mode 100644
index 0000000..3e082af
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/components/index.ts b/src/views/im/home/components/index.ts
new file mode 100644
index 0000000..0d65403
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/components/paged-scroller.vue b/src/views/im/home/components/paged-scroller.vue
new file mode 100644
index 0000000..ed1842a
--- /dev/null
+++ b/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
+ }
+)
+
+/** 瑙f瀽姣忔潯 item 鐨� :key锛歝aller 浼� 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>
diff --git a/src/views/im/home/components/picker/conversation-picker-panel.vue b/src/views/im/home/components/picker/conversation-picker-panel.vue
new file mode 100644
index 0000000..b4c944c
--- /dev/null
+++ b/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锛坴-model锛夛紱key 鐢� getConversationKey 鐢熸垚 */
+ selectedKeys: string[]
+ /** 鏄惁灞曠ず銆屽垱寤鸿亰澶┿�嶅叆鍙� */
+ showCreateChat?: boolean
+ }>(),
+ {
+ recentForwardConversationKeys: () => [],
+ hideKeys: () => [],
+ maxSize: 0,
+ showCreateChat: false
+ }
+)
+
+const emit = defineEmits<{
+ createChat: []
+ /** 鐢ㄦ埛鍦ㄣ�屾渶杩戣浆鍙戙�嶆杩涘叆绉婚櫎妯″紡鍚庣偣 脳锛涗笟鍔″3鏀跺埌鍚庤皟 conversationStore.removeRecentForwardConversationKey 钀界洏 */
+ removeRecent: [key: string]
+ 'update:selectedKeys': [value: string[]]
+}>()
+
+const keyword = ref('')
+const recentRemoveMode = ref(false) // 銆屾渶杩戣浆鍙戙�嶆鏄惁澶勪簬绉婚櫎妯″紡锛歵rue 鏃跺ご鍍忓彸涓婅鍙� 脳 涓嶅啀鍒囧嬀閫�
+
+/** 鍏ㄩ噺浼氳瘽鐨� key鈫扖onversation 鏄犲皠锛屽凡閫� / 鏈�杩戣浆鍙戝弽鏌ュ叡鐢紝閬垮厤姣忔 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)))
+)
+
+/** 鏄惁灞曠ず銆屾渶杩戣浆鍙戙�嶆锛歬eyword 涓虹┖ + 鏈夋暟鎹椂鎵嶅睍绀猴紝鎼滅储鏃惰浣� */
+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 鐢变笟鍔″3鎸佹湁
+ - 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">
+ <!-- 鏈�杩戣浆鍙戞í鍚戝ご鍍忓尯锛歬eyword 涓虹┖ + 鏈夋渶杩戣浆鍙戞暟鎹椂灞曠ず -->
+ <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>
+
+ <!-- 鍒涘缓鑱婂ぉ鍏ュ彛锛歬eyword 涓虹┖ + 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>
diff --git a/src/views/im/home/components/picker/friend-picker-panel.vue b/src/views/im/home/components/picker/friend-picker-panel.vue
new file mode 100644
index 0000000..2773d08
--- /dev/null
+++ b/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锛氫笉灞曠ず锛坔ide > locked > disabled锛� */
+ hideIds?: number[]
+ /** 閿佸畾 id锛氶粯璁ゅ嬀閫夈�佷笉鍙彇娑堛�佽鍏ュ凡閫夋暟锛堝吀鍨嬶細绉佽亰渚� +寤虹兢閿佸畾瀵规柟锛� */
+ lockedIds?: number[]
+ /** 宸查�夋暟涓婇檺锛涗笉浼犳垨 <=0 鏃朵笉闄� */
+ maxSize?: number
+ /** 宸查�夊ソ鍙� id锛坴-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锛坔ide 浼樺厛绾ф渶楂橈級 */
+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)
+}
+
+/** 鏄惁閫変腑锛歭ocked 瑙嗕负姘歌繙閫変腑 */
+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 鐢变笟鍔″3鎸佹湁
+ - 涓夋�佽涔夛細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>
diff --git a/src/views/im/home/components/picker/group-member-picker-panel.vue b/src/views/im/home/components/picker/group-member-picker-panel.vue
new file mode 100644
index 0000000..4a80f4a
--- /dev/null
+++ b/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锛氫笉灞曠ず锛坔ide > locked > disabled锛� */
+ hideIds?: number[]
+ /** 閿佸畾 userId锛氶粯璁ゅ嬀閫夈�佷笉鍙彇娑堛�佽鍏ュ凡閫夋暟 */
+ lockedIds?: number[]
+ /** 宸查�夋暟涓婇檺锛涗笉浼犳垨 <=0 鏃朵笉闄� */
+ maxSize?: number
+ /** 缇ゆ垚鍛樺垪琛� */
+ members: GroupMemberLite[]
+ /** 宸查�夊尯灞曠ず褰㈡�侊細榛樿 list 瀵归綈寰俊鏂拌瑙� */
+ selectedDisplay?: 'grid' | 'list'
+ /** 宸查�� userId锛坴-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)
+}
+
+/** 鏄惁閫変腑锛歭ocked 瑙嗕负姘歌繙閫変腑 */
+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 鐢变笟鍔″3鎸佹湁
+ - 涓夋�佽涔夛細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 褰㈡�侊細绾靛悜琛岋紝姣忚甯� 脳 绉婚櫎锛坙ocked 涓嶆覆鏌� 脳锛� -->
+ <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 鎴愬憳鍙充笂瑙掑彔鍔� 脳 绉婚櫎锛坙ocked 涓嶆覆鏌擄級 -->
+ <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>
diff --git a/src/views/im/home/components/picker/index.ts b/src/views/im/home/components/picker/index.ts
new file mode 100644
index 0000000..7a534a8
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/components/picker/picker-dialog.scss b/src/views/im/home/components/picker/picker-dialog.scss
new file mode 100644
index 0000000..629b856
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/src/views/im/home/components/resizable-aside.vue b/src/views/im/home/components/resizable-aside.vue
new file mode 100644
index 0000000..e2785c1
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/components/rtc/index.ts b/src/views/im/home/components/rtc/index.ts
new file mode 100644
index 0000000..fafdd7a
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/components/rtc/rtc-call-container.vue b/src/views/im/home/components/rtc/rtc-call-container.vue
new file mode 100644
index 0000000..02be586
--- /dev/null
+++ b/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 鎴块棿骞舵敞鍐岀寮�鍥炶皟锛汭NVITING 涓诲彨棰勮繛鍜岃鍙� accept 鍚庤繛鍏ュ叡鐢� */
+async function connectLiveKit(livekitUrl: string, token: string) {
+ // 骞傜瓑锛歭k.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 })
+ // 鍏滃簳锛歝onnect 鏈熼棿鑻ュ凡鏈夎繙绔湪鎴匡紝浜嬩欢鍙兘鍦� 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)
+}
+
+// ==================== 鎸搩瓒呮椂鍏滃簳 ====================
+
+/** 閫氳瘽瀛樻椿鏈熼棿锛圛NVITING / 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 鎵嶈皟锛汭NVITING / RUNNING 鍙� call銆両NCOMING 鍙� incomingPayload锛涙帴鍙i潤榛橀敊璇� 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 杩藉姞閭�璇锋帴鍙o紝鍚庣鎺� 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>
+ <!-- 閫氳瘽闃舵瀵瑰簲寮圭獥锛汭NVITING / 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>
diff --git a/src/views/im/home/components/rtc/rtc-call-incoming.vue b/src/views/im/home/components/rtc/rtc-call-incoming.vue
new file mode 100644
index 0000000..a010fa0
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/components/rtc/rtc-call-inviting.vue b/src/views/im/home/components/rtc/rtc-call-inviting.vue
new file mode 100644
index 0000000..67d58cb
--- /dev/null
+++ b/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>
+
+ <!-- 搴曢儴鎿嶄綔鍖猴細楹﹀厠椋� / 鍙栨秷 / (鎽勫儚澶� | 鎵0鍣�) -->
+ <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 娌℃湁鍚屾簮闈欓煶鍙樹綋锛宱ff 鎬佸�� 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 ? '鎵0鍣ㄥ凡寮�' : '鎵0鍣ㄥ凡鍏�' }}
+ </span>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/src/views/im/home/components/rtc/rtc-call-member-picker-dialog.vue b/src/views/im/home/components/rtc/rtc-call-member-picker-dialog.vue
new file mode 100644
index 0000000..2844987
--- /dev/null
+++ b/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 鍒楄〃锛沘dd 鍦烘櫙鎶婂凡鍦ㄩ�氳瘽鍐呯殑浜虹鐢�
+const selectedIds = ref<number[]>([]) // 褰撳墠閫変腑鐨� userId 鍒楄〃锛汫roupMemberPickerPanel v-model 缁戣繃鏉�
+
+/** 鏍囬锛涙寜鐢ㄩ�斿垏鎹� */
+const title = computed(() => (mode.value === 'add' ? '娣诲姞鎴愬憳' : '閫夋嫨鎴愬憳'))
+
+/** 缇ゆ垚鍛樺垪琛紱浠� groupStore 鐜板彇锛宮ap 鎴� 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)
+
+/** 鎵撳紑寮圭獥锛沞xcludeUserIds 鐢ㄤ簬銆屾坊鍔犳垚鍛樸�嶆椂鎶婂凡鍦ㄩ�氳瘽鍐呯殑浜虹疆鐏� */
+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>
diff --git a/src/views/im/home/components/rtc/rtc-call-participant-tile.vue b/src/views/im/home/components/rtc/rtc-call-participant-tile.vue
new file mode 100644
index 0000000..6a12111
--- /dev/null
+++ b/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
+ /** 绛夊緟鍔犲叆锛沀I 鏄剧ず涓夌偣鍔ㄧ敾 */
+ pending?: boolean
+}
+
+const props = defineProps<{
+ participant: CallParticipantVM
+ /** 鎵0鍣ㄥ紑鍏筹紱涓� 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 鍏冪礌鎾斁锛屾湰绔潤闊抽伩鍏嶅洖澹帮紱鎵0鍣ㄥ叧闂椂鏁翠綋闈欓煶 -->
+ <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>
diff --git a/src/views/im/home/components/rtc/rtc-call-running.vue b/src/views/im/home/components/rtc/rtc-call-running.vue
new file mode 100644
index 0000000..e0b8c2b
--- /dev/null
+++ b/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
+ /** 鏄惁姝e湪灞忓箷鍏变韩锛涙寜閽珮浜� + 鏂囨鍒囨崲 */
+ screenShareEnabled?: boolean
+ /** 鎵0鍣ㄥ紑鍏筹紱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>
+
+ <!-- 搴曢儴鎿嶄綔鍖猴細楹﹀厠椋� / 鎵0鍣� / 鎽勫儚澶� / (缇よ亰锛氬叡浜睆骞� / 娣诲姞鎴愬憳) / 鎸傛柇 -->
+ <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 ? '鎵0鍣ㄥ凡寮�' : '鎵0鍣ㄥ凡鍏�' }}
+ </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>
diff --git a/src/views/im/home/components/rtc/rtc-group-call-banner.vue b/src/views/im/home/components/rtc/rtc-group-call-banner.vue
new file mode 100644
index 0000000..8789f53
--- /dev/null
+++ b/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)
+
+/** 褰撳墠缇ょ殑娲昏穬閫氳瘽锛況tcStore 缁存姢锛屽弬涓庤�呭姞鍏� / 绂诲紑閫氱煡澧炲垹 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 ? `姝e湪${media}閫氳瘽锛�${count} 浜猴級` : `姝e湪${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锛涙帴鍙h繑鍥炵┖ 鈫� 璇ョ兢宸叉棤娲昏穬閫氳瘽锛岀Щ闄ゆ湰鍦扮紦瀛�
+ 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))
+
+/** 鏈鏄惁姝e湪璇ユ埧闂撮�氳瘽锛堝浜� 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锛況tcStore 鎸� 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>
diff --git a/src/views/im/home/components/tool-bar.vue b/src/views/im/home/components/tool-bar.vue
new file mode 100644
index 0000000..b1195ba
--- /dev/null
+++ b/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锛欼M 宸︿晶宸ュ叿鏍�
+ 甯冨眬锛氶《閮ㄥご鍍� 鈫� 涓棿涓� 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>
diff --git a/src/views/im/home/components/user/index.ts b/src/views/im/home/components/user/index.ts
new file mode 100644
index 0000000..d25750e
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/components/user/recommend-card-dialog.vue b/src/views/im/home/components/user/recommend-card-dialog.vue
new file mode 100644
index 0000000..8c74cb1
--- /dev/null
+++ b/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') // 褰撳墠瑙嗗浘锛氶粯璁や細璇濋�夋嫨锛屻�屽垱寤鸿亰澶┿�嶅叆鍙e垏鍒板ソ鍙嬮�夋嫨
+const selectedKeys = ref<string[]>([])
+const selectedFriendIds = ref<number[]>([])
+const leaveMessage = ref('')
+const sending = ref(false)
+const emojiVisible = ref(false) // 琛ㄦ儏闈㈡澘鏄鹃殣锛氬彸渚� smile icon 鍒囨崲
+
+defineExpose({
+ /** 鎵撳紑鎺ㄨ崘寮圭獥锛歳eset 鈫� 鐏屽弬 鈫� 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 鎷煎埌鐣欒█鏈熬锛汧acePicker 鑷韩璐熻矗鍏抽棴闈㈡澘 */
+function handleEmojiSelect(emoji: string) {
+ leaveMessage.value = `${leaveMessage.value}${emoji}`
+}
+
+/** 鍒囧埌濂藉弸瑙嗗浘锛氭竻鎺変箣鍓嶅湪浼氳瘽瑙嗗浘杈撳叆鐨勭暀瑷�锛岄伩鍏嶅湪涓嶅彲瑙佽緭鍏ユ閲屾妸鐣欒█闈欓粯鍙戝埌鏂扮兢 */
+function handleSwitchToContact() {
+ view.value = 'contact'
+ leaveMessage.value = ''
+ emojiVisible.value = false
+}
+
+/**
+ * 纭鍙戦�侊紙浼氳瘽瑙嗗浘锛夛細姣忎釜閫変腑浼氳瘽鍏堝彂 CARD锛孋ARD 鎴愬姛鍚庢墠鍙戠暀瑷�锛堜繚璇併�屽厛鐪嬪埌鍚嶇墖銆嶇殑椤哄簭鎰忓浘锛�
+ *
+ * 鏂囨鑱氬悎锛氬叏閮ㄦ垚鍔熴�屽凡杞彂銆嶃�佸叏閮ㄥけ璐ャ�岃浆鍙戝け璐ワ細A銆丅銆嶃�侀儴鍒嗗け璐ャ�屽凡杞彂锛屼絾 X銆乊 澶辫触銆嶏紱
+ * 澶辫触鐨勬秷鎭互 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 鐢�
+ * 锛坰endRaw 鍐呴儴浼氳嚜鍔� 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'锛氫粠銆屽垱寤鸿亰澶┿�嶅叆鍙h繘鍏ワ紝閫夊ソ鍙嬪缓缇ゅ啀鍙戝悕鐗囷紝涓氬姟澹冲眰鍒囪鍥撅紝涓や釜 Panel 浜掍笉鐭ラ亾瀵规柟
+ - 閫� 1 璧般�屽彂閫併�嶃�佸涓蛋銆屽垎鍒彂閫�(n)銆嶆枃妗堬紝涓庡井淇′竴鑷�
+ - 澶辫触鐨勬秷鎭互 FAILED 鐘舵�佺暀鍦ㄥ搴斾細璇濇皵娉¢噷锛屼緵鍙抽敭閲嶈瘯
+ - 瀵瑰鎺ュ彛锛歳ef + 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>
diff --git a/src/views/im/home/components/user/user-avatar.vue b/src/views/im/home/components/user/user-avatar.vue
new file mode 100644
index 0000000..de5f633
--- /dev/null
+++ b/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 // 灏哄锛坧x锛夛紝姝f柟褰紱浼樺厛浜� width/height
+ url?: string // 澶村儚鍥剧墖 URL锛涗负绌烘椂璧拌壊鍗℃枃瀛�
+ user?: User // 棰濆鐨勭敤鎴蜂俊鎭紝浼犱簡鐐瑰嚮灏变笉鐢ㄧ幇鎷夋帴鍙o紙寮瑰悕鐗囩敤锛�
+ }>(),
+ {
+ 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))
+
+/** 澶村儚鐐瑰嚮锛歱reviewable 璧� 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锛坈lickable锛�
+ - 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>
diff --git a/src/views/im/home/components/user/user-info-card.vue b/src/views/im/home/components/user/user-info-card.vue
new file mode 100644
index 0000000..ec33489
--- /dev/null
+++ b/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()
+}
+
+/** 閿洏浜嬩欢锛欵sc 閿叧闂悕鐗� */
+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 璇︽儏鍏辩敤涓�浠界粍浠�
+ - 瑙﹀彂锛歶seImUiStore.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>
diff --git a/src/views/im/home/components/user/user-info.vue b/src/views/im/home/components/user/user-info.vue
new file mode 100644
index 0000000..4c09cf1
--- /dev/null
+++ b/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
+ /** 鏉ユ簮闄勫甫淇℃伅锛歛ddSource=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]
+ /** 鍒犻櫎鑱旂郴浜烘垚鍔熷悗閫氱煡鐖剁骇锛坈onfirm + 璋� 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) // 澶囨敞鍐呰仈缂栬緫锛歟ditingRemark 鎺у埗杈撳叆鎬侊紱user 鍒囨崲鏃剁敱涓嬮潰鐨� watch 澶嶄綅閬垮厤鑴忔�佹硠婕�
+const remarkInput = ref('')
+const remarkInputRef = ref<null | { focus: () => void; select?: () => void }>(null)
+
+/**
+ * user.id 鍙樺寲鐨勭粺涓�澶勭悊锛�
+ * 1. 璧锋墜鐢� prop 鍏滃簳棣栧睆锛坒ull = 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. 閲嶅叆淇濇姢鈥斺�攂lur + 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
+}
+
+/** 鍙戞秷鎭細瀵艰埅 / 鍏虫诞灞傝繖浜�"涓氬姟渚�"鍔ㄤ綔浜ょ粰鐖剁骇锛屾湰缁勪欢鍙礋璐i�氱煡 */
+function handleChat() {
+ if (!props.user) {
+ return
+ }
+ emit('chat', props.user)
+}
+
+/** 鍗犱綅鎻愮ず锛氳闊� / 瑙嗛鑱婂ぉ鑳藉姏灏氭湭鎺ュ叆锛屽厛浠�"寮�鍙戜腑"鍙嬪ソ鎻愮ず */
+function handleComingSoon(featureName: string) {
+ message.info(`${featureName} 鍔熻兘寮�鍙戜腑`)
+}
+
+// ==================== 娣诲姞濂藉弸 / 鍒犻櫎濂藉弸 ====================
+
+const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // 鍔犲ソ鍙嬪脊绐� ref锛歨andleAddFriend 璋� open({ presetUser, addSource, addSourceExtra }) 瑙﹀彂
+
+const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // 鎺ㄨ崘鍚嶇墖寮圭獥 ref锛歨andleRecommend 璋冪敤 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锛堣鍐咃級鍏辩敤锛歎serInfoCard 鎶婂畠鏀捐繘 teleport 娴眰锛宑ontact 鐩存帴 mount 鍒板彸鏍�
+ - 鍏崇郴鎬佺敱 relation prop 鍐冲畾锛坒riend / 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锛況eadonly 涓嶆覆鏌� -->
+ <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锛歟l-dropdown 鐨勪笅鎷夎彍鍗曡蛋 teleport 鍒� body锛宻coped 閫変笉鍒般��
+ UserInfoCard 娴眰鐢� z-9998锛岃鎶婅繖鍧楁姮鍒版洿楂橈紙榛樿 --ant-z-index-popup-base ~2050 浼氳閬僵鍘嬩綇锛夈��
+*/
+.im-user-info__more-menu {
+ z-index: 10000 !important;
+}
+</style>
diff --git a/src/views/im/home/composables/useFriendBuckets.ts b/src/views/im/home/composables/useFriendBuckets.ts
new file mode 100644
index 0000000..9b2ad1a
--- /dev/null
+++ b/src/views/im/home/composables/useFriendBuckets.ts
@@ -0,0 +1,99 @@
+import type { FriendLite } from '../types'
+
+import { computed, type ComputedRef, type Ref } from 'vue'
+
+/** 瀛楁瘝鍒嗘《缁撴灉锛歭etter 鍙� 'A'-'Z' 鎴栧厹搴� '#'锛沴ist 妗跺唴鎸夋嫾闊� / 鍚嶅瓧鑷劧搴� */
+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() : '#'
+}
+
+/** 鎷奸煶棣栧瓧姣嶆嫾鎺ワ細銆宭ao zhang銆嶁啋 'lz'锛屾敮鎸併�岃緭 lz 鎼滆�佸紶銆� */
+function pinyinInitials(pinyin?: string): string {
+ if (!pinyin) {
+ return ''
+ }
+ return pinyin
+ .split(' ')
+ .map((word) => word.charAt(0))
+ .join('')
+}
+
+/**
+ * 濂藉弸鍒楄〃鐨勬悳绱� + 瀛楁瘝鍒嗘《
+ *
+ * - 鎼滅储鍖归厤锛氬娉� / 鏄电О / 鍏ㄦ嫾锛堝幓绌烘牸锛�/ 棣栧瓧姣嶄换涓�鍛戒腑
+ * - 鍒嗘《瑙勫垯锛欰-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 || ''
+ // 鍏ㄦ嫾鎼滅储鍘绘帀绌烘牸锛岃銆宭aozhang銆嶄篃鑳藉懡涓�宭ao 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 }
+}
diff --git a/src/views/im/home/composables/useGroupCallMembers.ts b/src/views/im/home/composables/useGroupCallMembers.ts
new file mode 100644
index 0000000..b6fb416
--- /dev/null
+++ b/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 鍗婇�忔槑锛宩oined 涓嶉�忔槑 */
+export interface GroupCallMember {
+ userId: number
+ nickname: string
+ avatar?: string
+ pending: boolean
+}
+
+/**
+ * 缇ら�氳瘽鎴愬憳鍒楄〃 computed锛歫oined 鍦ㄥ墠锛屾湭 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 瑙f瀽鏄电О / 澶村儚 */
+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
+ }
+}
diff --git a/src/views/im/home/composables/useLiveKitRoom.ts b/src/views/im/home/composables/useLiveKitRoom.ts
new file mode 100644
index 0000000..7cc0a5a
--- /dev/null
+++ b/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 杩炴帴 / 璁惧 / 浜嬩欢鐨勮杽灏佽锛沀I 缁勪欢鍙叧蹇冨搷搴斿紡鐘舵�� */
+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 娣卞害浠g悊 SDK class 鍐呴儴 */
+ const remoteParticipants = shallowRef<RemoteParticipant[]>([])
+ /** 杩炴帴鐘舵�� */
+ const isConnected = ref(false)
+ /** 杩炴帴璐ㄩ噺 */
+ const connectionQuality = ref<ConnectionQuality>(ConnectionQuality.Unknown)
+ /** 楹﹀厠椋庡紑鍏� */
+ const micEnabled = ref(true)
+ /** 鎽勫儚澶村紑鍏� */
+ const cameraEnabled = ref(false)
+ /** 鎵0鍣ㄥ紑鍏筹紱娴忚鍣ㄦ棤绯荤粺绾� 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锛沘udio / 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 鐭矾閲嶇畻锛寁ideo 鍏冪礌鑳借В缁� 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 缂撳瓨锛沰ey 涓� `${participantSid}:${source}`锛寁alue 涓� `{ track, stream }`锛�
+ * 鍚屼竴鏉� MediaStreamTrack 澶嶇敤鍚屼竴涓� MediaStream锛岄伩鍏� <video>.srcObject 鍙嶅閲嶆寕瀵艰嚧瑙g爜绠$嚎閲嶅缓锛堣棰戦棯鐑侊級锛�
+ * track 寮曠敤鍒囨崲锛堥噸鏂拌闃� / 鍒囨祦锛夋椂鎸夐渶鏂板缓骞舵浛鎹�
+ */
+ const streamCache = new Map<string, { stream: MediaStream; track: MediaStreamTrack; }>()
+
+ /**
+ * 鍙栧弬涓庤�呮寚瀹氭潵婧愮殑杞ㄩ亾骞舵墦鍖呬负 MediaStream锛�
+ * 鍙備笌鑰呰蛋 unknown 鏄洜涓哄搷搴斿紡绯荤粺浼氫涪澶� livekit-client 鐨勭被绛惧悕锛屽嚱鏁板唴鎵嬪姩 cast 鍥炲弬涓庤�呯被鍨嬶紱
+ * 鍛戒腑缂撳瓨杩斿洖鍚屼竴 MediaStream 寮曠敤锛屼笅娓� watch / srcObject 鏃犻渶閲嶆寕锛�
+ * 杞ㄩ亾琚� mute锛堟湰绔叧鎽勫儚澶� / 杩滅鍏虫憚鍍忓ご锛夋椂杩斿洖 null锛岃 video 鍏冪礌瑙g粦 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锛沜learHandlers 涓� 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>
diff --git a/src/views/im/home/composables/useMediaStreamElement.ts b/src/views/im/home/composables/useMediaStreamElement.ts
new file mode 100644
index 0000000..aa6ae09
--- /dev/null
+++ b/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
+ }
+}
diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts
new file mode 100644
index 0000000..a841ecd
--- /dev/null
+++ b/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 瑙e嚭锛岄噸浼犱粠鏃� VideoMessage 鐩存帴鎷峰瓧娈碉級
+ * - videoCoverUrl锛氳棰戝皝闈㈢湡瀹� URL锛涘崰浣嶉樁娈典笉璁撅紙閬垮厤浼� blob 褰� poster 鍦ㄩ儴鍒嗘祻瑙堝櫒閫�鍖栵級锛宑ommit 闃舵鐢� cover 涓婁紶缁撴灉濉叆锛涢噸浼犳椂浠庢棫 VideoMessage.coverUrl 澶嶇敤锛屾棫鍊艰嫢鏄� blob 浼氳璺宠繃
+ */
+export interface MediaTypeContext {
+ voiceDuration?: number
+ videoProbe?: { duration?: number; height?: number; width?: number; }
+ videoCoverUrl?: string
+}
+
+export interface MediaTypeHandler {
+ /** 涓枃鍚嶏紝浠呮棩蹇楃敤锛堟浛浠d箣鍓嶆暎钀� 9 澶勭殑 kind 瀛楃涓诧級 */
+ kind: string
+ /** 鐢� file + url + context 鐢熸垚 payload锛涘崰浣嶆椂 url 鏄� blob URL锛宑ommit 鏃舵槸鐪熷疄 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锛泇ideo 璧颁綆灞� helper 鑷缁勮锛� */
+export interface UploadAndSendMediaOptions {
+ file: File
+ /** 瀵归綈 ImContentType锛沵ediaTypeHandlers 蹇呴』鏈夊搴旈」 */
+ type: number
+ /** 濯掍綋鐗瑰畾鐨勫厓鏁版嵁锛堝璇煶鏃堕暱 / 瑙嗛鍏冧俊鎭級锛涗笉浼犳寜绌哄璞″鐞� */
+ context?: MediaTypeContext
+ /** 寮曠敤娑堟伅锛堣嫢鏈夛級锛屽啓杩� payload.quote */
+ quote?: QuoteMessage
+ /** 閿佸畾璧峰浼氳瘽锛屼笂浼犳湡闂翠細璇濆垏璧板垯鏀惧純鍙戦�� */
+ conversation: Conversation
+ /** 閲嶈瘯宸叉湁鍗犱綅娑堟伅鏃跺鐢ㄧ殑瀹㈡埛绔秷鎭紪鍙� */
+ existingClientMessageId?: string
+}
+
+/**
+ * 濯掍綋涓婁紶 + 鍙戦�� composable锛坕mage / file / voice / video 鍏辩敤搴曞眰 helper锛�
+ *
+ * 涓� useMessageSender.sendRaw 鐨勩�屽厛鍙戣姹傚啀 ack銆嶄笉鍚岋紝濯掍綋閾捐矾蹇呴』銆屽厛鍗犱綅鍐嶄笂浼犮�嶏細
+ * 1. 绔嬪嵆 insertMessage 鍐欏叆鍗犱綅娑堟伅锛坰tatus=SENDING銆乧ontent 鐢� blob URL銆乢localFile 鍐呭瓨鐣� File锛�
+ * 2. uploadFile 涓婁紶锛宱nUploadProgress 鍥炶皟 patchMessage 鏇存柊 uploadProgress锛沀I 瀹炴椂鏄剧ず杩涘害鏉�
+ * 3. 涓婁紶鎴愬姛鍚庣敤鐪熷疄 url 閲嶇敓 content锛宲atchMessage 鏇挎崲锛涙棫 blob URL 鐢� store 鑷姩 revoke
+ * 4. 璧� sendRaw(existingClientMessageId) 澶嶇敤鍗犱綅鍙戦�佽姹傦紝閬垮厤閲嶅鎻掑叆涓ゆ潯
+ *
+ * 浠绘剰澶辫触鎶婃秷鎭姸鎬佺疆 FAILED锛汳essageItem 涓婄偣閲嶈瘯鍐嶈蛋涓�娆℃湰鍑芥暟锛坃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锛沬mage/file/voice 璧� uploadAndSendMedia 鍖呰锛寁ideo 鐩存帴鐢ㄦ湰鍑芥暟锛�
+ *
+ * 鐢� 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锛泂tore 鍐呴儴 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
+ })
+ }
+
+ /**
+ * 涓婁紶濯掍綋鏂囦欢骞跺彂閫佹秷鎭紙楂樺眰鍏ュ彛锛沬mage / 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 ''
+ }
+ // 浣撶Н涓婇檺鎷︽埅锛氬ぇ鏂囦欢娴忚鍣ㄥ唴鎴抚 / 瑙g爜鍙嚧 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
+ }
+}
diff --git a/src/views/im/home/composables/useMessageMultiSelect.ts b/src/views/im/home/composables/useMessageMultiSelect.ts
new file mode 100644
index 0000000..6b0d656
--- /dev/null
+++ b/src/views/im/home/composables/useMessageMultiSelect.ts
@@ -0,0 +1,48 @@
+import type { Message } from '../types'
+
+import { computed, reactive } from 'vue'
+
+/**
+ * 娑堟伅澶氶�夋ā寮�
+ *
+ * 妯″潡绾у崟渚� reactive state锛汳essagePanel 瀛愭爲鍐呭澶勫叡浜細
+ * - MessageItem 娓叉煋鍕鹃�� + 鍒囨崲鍕鹃��
+ * - MessageMultiSelectBar 搴曟爮璇婚�変腑鏁�
+ * - MessageForwardDialog 杞彂鎴愬姛鍚� exit
+ * - MessagePanel watch activeConversation 鍒囦細璇� exit
+ */
+const state = reactive({
+ active: false,
+ /** 宸查�� clientMessageId 鍒楄〃锛屾寜閫変腑椤哄簭淇濆簭 */
+ selectedClientMessageIds: [] as string[]
+})
+
+/** 宸查�� clientMessageId 闆嗗悎锛汳essageItem 澶ч噺 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 }
+}
diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts
new file mode 100644
index 0000000..95cf36d
--- /dev/null
+++ b/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 鑱斿悎绫诲瀷锛況unMinIdPull 鍙渶 id 鎺ㄨ繘娓告爣锛屽叿浣撳垎鍙戝湪 applyPage 鍐呮寜绫诲瀷 cast */
+type PulledRawMessage = ImChannelMessageApi.ChannelMessageRespVO | ImGroupMessageApi.GroupMessageRespVO | ImPrivateMessageApi.PrivateMessageRespVO
+
+/**
+ * 娑堟伅澧為噺鎷夊彇锛氱櫥褰曞悗鍒嗛〉鎷夊彇绂荤嚎鏈熼棿鐨勬柊娑堟伅
+ *
+ * 璁捐瑕佺偣锛�
+ * 1. 鍚屾椂鎷夊彇绉佽亰 + 缇よ亰锛屼娇鐢ㄥ悇鑷殑 `minId` 娓告爣锛坧rivateMessageMaxId / groupMessageMaxId锛�
+ * 2. 鍚庣涓�娆℃渶澶氳繑鍥� size 鏉★紱鍓嶇鎸� minId 鎸佺画缈婚〉锛岀洿鍒版帴鍙h繑鍥炵┖鍒楄〃涓烘
+ * 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 鐨勪細璇�"锛屽惁鍒欑畻"鍙戦�佹柟鐨勪細璇�"锛沜urry currentUserId 杩涢棴鍖呭噺灏� 3 澶勮皟鐢ㄦ柟鐨勬牱鏉� */
+ const getPrivatePeerId = (message: ImPrivateMessageApi.PrivateMessageRespVO) =>
+ getPrivateMessagePeerId(message, currentUserId)
+
+ /** 鏈嶅姟绔鑱婃秷鎭� -> 鏈湴 Message锛歵argetId 鏄細璇濅富閿紙瀵圭 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, // 棰戦亾鏃犳挙鍥烇紝鎭掍负姝e父
+ receiptStatus: message.receiptStatus, // 棰戦亾宸茶鎬侊細DONE 宸茶 / PENDING 鏈
+ sendTime: new Date(message.sendTime).getTime(),
+ senderId: 0, // 绯荤粺涓嬪彂锛屾棤鍙戦�佷汉
+ targetId: message.channelId, // 浼氳瘽褰掑睘鍒伴閬撶紪鍙�
+ selfSend: false,
+ materialId: message.materialId // 璇︽儏椤垫媺瀵屾枃鏈敤
+ }
+ }
+
+ /** 棰戦亾锛氫細璇濆綊灞炲埌 channelId锛沶ame / 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 璐熻矗锛岃繖閲屽彧鍋氭帴鍙e垎鏀� + 閫愭潯涓氬姟鍒嗗彂
+ * 锛堟挙鍥� / 濂藉弸閫氱煡 / 鏅�氭秷鎭級+ 鍏ュ簱銆�
+ *
+ * 鍙栨秷璇箟涓ゅ眰瀹堝崼锛岀粡 isActive 浼犲叆 runMinIdPull锛屼换涓�涓嶇瓑鍗充涪寮冩湰鎵逛笉鍏ュ簱銆佸仠姝㈢炕椤碉紝閬垮厤鏃� session 鍝嶅簲钀藉埌鏂� store锛�
+ * 1. startEpoch锛歝ancelPull() 閫掑 pullEpoch锛涚寮� IM / 鍒囪处鍙锋椂璺冲嚭
+ * 2. startUserId锛氭瘡鎵� await 鍚庢瘮瀵瑰綋鍓嶇櫥褰� userId锛涢槻寰� logout / 澶� tab 涓嬬敤鎴峰凡鍒囦絾 cancelPull 鏈Е鍙�
+ */
+ const pullByType = async (
+ conversationType: number,
+ startMinId: number,
+ startEpoch: number,
+ startUserId: number,
+ signal: AbortSignal
+ ) => {
+ // 绉佽亰 / 缇よ亰 / 棰戦亾鍚勮嚜涓�濂楁帴鍙o紱鎸� 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锛汻ECALL 淇″彿璧版壒閲� recall 鎶婂悓鎵瑰唴宸� insert 鐨勫師娑堟伅鏇存柊涓烘挙鍥炴彁绀恒��
+ // 鍚庣鎸� id 鍗囧簭杩斿洖锛屼笖淇″彿 id 涓�瀹� > 鍘熸秷鎭� id锛堝厛鏇存柊 status 鍐嶆彃淇″彿锛夛紝鎵�浠ュ師娑堟伅涓�瀹氬厛鍒般�乺ecallMessage 鎵惧緱鍒�
+ 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
+ }
+ // 鍏跺畠娑堟伅姝e父鍏ヤ細璇濇秷鎭垪琛�
+ 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锛沶extMinId 涓虹┖锛堟湰鎵规棤鏈夋晥 id锛夋椂涓嶆帹杩涙父鏍囷紝涓庢棫閫昏緫涓�鑷�
+ await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
+ }
+ })
+ }
+
+ /** 鍚屼竴鏃跺埢鍙厑璁镐竴娆� pull锛歩ndex.vue 鐨勬墜鍔ㄨ皟鐢ㄤ笌閲嶈繛 watch 瑙﹀彂鍙兘骞跺彂锛屽叡鐢ㄥ悓涓�涓� promise 鍗冲彲鍘婚噸 */
+ let pullPromise: null | Promise<void> = null
+ let pullAbortController: AbortController | null = null
+
+ /**
+ * 棣栨 pull 鏄惁宸插畬鎴愩�備粎鍦ㄧ疆 true 鍚庯紝isConnected watch 鎵嶄細瑙﹀彂 pull銆�
+ * 闃叉 socket onopen 姣� friendStore/groupStore 棰勬媺鍏堝埌杈炬椂锛寃atcher 鎶㈣窇閫犳垚娑堟伅鎻掑叆鏃╀簬浼氳瘽鍏冩暟鎹彲瑙�
+ */
+ let initialPulled = false
+
+ /**
+ * pull 杞璁℃暟锛涘垏璐﹀彿 / 绂诲紑 IM 鏃� cancelPull() 閫掑锛屾棫 pullByType 寰幆鎸� epoch 鑷鍚庤烦鍑�
+ * 閬垮厤鏃� session 鐨勬帴鍙e搷搴斿湪鏂� 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
+ // 鍚姩鏃剁殑鐢ㄦ埛蹇収锛沺ullByType 姣忔壒 await 鍚庢瘮瀵瑰綋鍓嶇櫥褰曠敤鎴凤紝璐﹀彿鍙樹簡绔嬪埢涓㈠純
+ const startUserId = currentUserId
+ const abortController = new AbortController()
+ pullAbortController = abortController
+ // 鏈疆 pull 浠嶅睘浜庡綋鍓� session锛歟poch 鏈紓 + 鐢ㄦ埛鏈垏锛涗换浣曞姩鏂� 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 瑙﹀彂
+ // 绉佽亰宸茶鍏抽棴鏃惰烦杩囷紝閬垮厤鎵撳埌宸茬鐢ㄦ帴鍙hЕ鍙戦敊璇棩蹇�
+ 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 宸叉竻锛宯o-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 鏄惧紡椹卞姩锛坧ullOnce 鎷夋秷鎭� + 鍚� store 棣栨媺锛夛紝杩欓噷浠呰鐩栦箣鍚庣殑閲嶈繛銆�
+ * 閲嶈繛鏃� store 宸插氨浣嶏紝pullStateEvents 涓� pullOnce 骞跺彂鍗冲彲锛屾棤闇�銆屽厛灏变綅鍐嶆媺娑堟伅銆嶇殑棣栫櫥椤哄簭绾︽潫銆�
+ */
+ watch(
+ () => wsStore.isConnected,
+ (isConnected) => {
+ if (isConnected && initialPulled) {
+ void pullOnce()
+ void pullStateEvents()
+ }
+ }
+ )
+
+ return { pullOnce, cancelPull, convertPrivateMessage, convertGroupMessage }
+}
diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts
new file mode 100644
index 0000000..4562128
--- /dev/null
+++ b/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
+ }
+ }
+
+ /**
+ * 鍙戦�佹枃鏈秷鎭紙鏈�甯哥敤鐨勫揩鎹峰叆鍙o級锛歮essage-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
+ // 鏈湴鏍囪宸茶锛氭湭璇绘暟娓呴浂锛圲I 绔嬪埢鍝嶅簲锛�
+ 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 }
+}
diff --git a/src/views/im/home/composables/useMuteOverlay.ts b/src/views/im/home/composables/useMuteOverlay.ts
new file mode 100644
index 0000000..b0ed764
--- /dev/null
+++ b/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锛泂cope 閿�姣佹椂鍙嶈闃咃紝鏈�鍚庝竴涓闃呰�呴��鍦哄悗 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' }
+ }
+ }
+ // 鎴愬憳绂佽█锛歮uteEndTime 鍦ㄦ湭鏉ユ墠绠楋紱鐢ㄥ搷搴斿紡 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
+ })
+}
diff --git a/src/views/im/home/composables/useSelectedItems.ts b/src/views/im/home/composables/useSelectedItems.ts
new file mode 100644
index 0000000..f8364b3
--- /dev/null
+++ b/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锛屼笉璁℃暟 / 涓嶈繘宸查�夊垪琛�
+ * - 椤哄簭锛歭ockedIds 鍦ㄥ墠锛宻electedIds 绱ч殢锛涙寜鏁扮粍椤哄簭鍗充负銆岀偣鍑婚『搴忋��
+ *
+ * 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 }
+}
diff --git a/src/views/im/home/composables/useVoicePlayer.ts b/src/views/im/home/composables/useVoicePlayer.ts
new file mode 100644
index 0000000..b396449
--- /dev/null
+++ b/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 鏄惁姝e湪鎾斁 */
+ function isPlaying(key: VoiceKey): boolean {
+ return currentTask.value?.key === key
+ }
+ return { isPlaying, play, stop }
+}
diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue
new file mode 100644
index 0000000..0689956
--- /dev/null
+++ b/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 鏁版嵁宸茬粡澶熸拺棣栧睆锛宲ullOnce 涔熻兘姝e父鍏ュ簱锛�
+ // 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锛坧ullOnce 娌¤窇鍒帮紝瀹冪殑 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 鑽夌闃熷垪锛沝ebounce 榛樿 trail-edge 瑙﹀彂锛屾渶鍚庝竴娆¤緭鍏ュ彲鑳借繕鍘嬪湪闃熷垪閲� */
+function onBeforeUnload() {
+ conversationStore.flushConversationDraftSave()
+}
+window.addEventListener('beforeunload', onBeforeUnload)
+
+/** 绂诲紑 IM 涓诲3锛氬彇娑� 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锛孖M 瀛愯矾鐢卞垏鎹紙娑堟伅 / 閫氳褰曪級涔熻兘閲嶆柊鍔犱笂鍓嶇紑
+ */
+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 澶栧眰瀹瑰櫒锛氳亰澶╂ā鍧楃殑鍏ㄥ睆娌夋蹈寮忓3
+ - 宸︿晶 ToolBar锛氬ご鍍� + 涓� Tab锛堟秷鎭�/濂藉弸/缇よ亰锛�+ 搴曢儴璁剧疆
+ - 鍙充晶 <router-view>锛氭寜璺敱娓叉煋 MessagePage / FriendPage / GroupPage
+ - 鎸傝浇鍏ㄥ眬寮瑰眰锛歎serInfoCard / 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 鐨勫共鍑�鐧藉樊璺濇槑鏄俱��
+ * 鍙栧�间緷缁勪欢瀹為檯鐢ㄦ硶锛歴econdary 澶氱敤浜庨潰鏉垮簳锛堝彇鏈�娴咃紝瀵归綈 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>
diff --git a/src/views/im/home/pages/contact/friend-list.vue b/src/views/im/home/pages/contact/friend-list.vue
new file mode 100644
index 0000000..faae9b3
--- /dev/null
+++ b/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 閫忎紶锛沜hat / 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>
diff --git a/src/views/im/home/pages/contact/friend-request-detail.vue b/src/views/im/home/pages/contact/friend-request-detail.vue
new file mode 100644
index 0000000..235af5c
--- /dev/null
+++ b/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 鏄剧ず锛沺rocessing 鏄法鎸夐挳浜掓枼閿侊紝閬垮厤鍚屾剰 / 鎷掔粷骞跺彂鎻愪氦鍚屼竴鐢宠
+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 宸︺�乿alue 鍙筹紝瀵归綈寰俊銆屾潵婧愩�嶈 -->
+ <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>
+ <!-- 鎷掔粷鐞嗙敱锛歭abel 宸︺�乿alue 鍙筹紱闀挎枃鏈� 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>
diff --git a/src/views/im/home/pages/contact/friend-request-list.vue b/src/views/im/home/pages/contact/friend-request-list.vue
new file mode 100644
index 0000000..f5044fc
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/pages/contact/group-detail.vue b/src/views/im/home/pages/contact/group-detail.vue
new file mode 100644
index 0000000..e93bb35
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/pages/contact/group-list.vue b/src/views/im/home/pages/contact/group-list.vue
new file mode 100644
index 0000000..cff18ca
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/pages/contact/index.vue b/src/views/im/home/pages/contact/index.vue
new file mode 100644
index 0000000..a711125
--- /dev/null
+++ b/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>锛坮elation=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>
diff --git a/src/views/im/home/pages/conversation/components/conversation/conversation-group-side.vue b/src/views/im/home/pages/conversation/components/conversation/conversation-group-side.vue
new file mode 100644
index 0000000..e3d2003
--- /dev/null
+++ b/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 // 鎶藉眽鏄惁鎵撳紑锛坴-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锛歨andleOpenInvite 璋� 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锛歨andleOpenRequestList 璋� open({ groupId }) 瑙﹀彂
+
+/** 鎵撳紑褰撳墠缇ょ殑杩涚兢鐢宠鍒楄〃 */
+function handleOpenRequestList() {
+ if (!props.group?.id) {
+ return
+ }
+ requestListDialogRef.value?.open({ groupId: props.group.id })
+}
+
+// ==================== 鍒嗕韩缇ゅ悕鐗� ====================
+
+const recommendCardDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // 鍒嗕韩缇ゅ悕鐗囧脊绐� ref锛歨andleShareGroupCard 璋冪敤 open({ target }) 鎵撳紑
+
+/** 鍒嗕韩缇ゅ悕鐗囷細鎶婂綋鍓嶇兢浣滀负鍚嶇墖娑堟伅鎺ㄨ崘缁欏叾浠栦細璇� */
+function handleShareGroupCard() {
+ const target = toGroupCardTarget(props.group)
+ if (!target) {
+ return
+ }
+ recommendCardDialogRef.value?.open({ target })
+}
+
+// ==================== 閫�鍑虹兢鑱� ====================
+
+/** 閫�鍑虹兢鑱婏紙鏅�氭垚鍛樺叆鍙o紱缇や富閫�鍑鸿蛋"瑙f暎缇�"鏄彟涓�鏉¤矾寰勶紝杩欓噷涓嶅鐞嗭級 */
+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
+}
+
+/** 瑙f暎缇よ亰锛堜粎缇や富鍏ュ彛锛� */
+async function handleDissolve() {
+ if (!props.group) {
+ return
+ }
+ try {
+ await confirm('瑙f暎鍚庢墍鏈夋垚鍛樺皢琚Щ鍑猴紝涓旀棤娉曟仮澶嶏紝纭瑙f暎鍚楋紵', '纭瑙f暎')
+ } 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 鍦ㄤ笂銆乿alue 鍦ㄤ笅锛岀旱鍚戝爢鍙狅紙瀵归綈寰俊 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>
+
+ <!-- ==================== 搴曢儴锛氶��鍑� / 瑙f暎缇よ亰锛堝巻鍙查��缇ょ兢闅愯棌锛屽凡閫�缇ゆ棤闇�鍐嶉��锛� ==================== -->
+ <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)]"
+ >
+ <!-- 缇や富锛氳В鏁g兢鑱� -->
+ <Button
+ v-if="isOwner"
+ class="w-full !h-9 text-14px"
+ danger
+ @click="handleDissolve"
+ >
+ 瑙f暎缇よ亰
+ </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 鏃跺叆鍙e彲瑙侊級 -->
+ <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>
diff --git a/src/views/im/home/pages/conversation/components/conversation/conversation-item.vue b/src/views/im/home/pages/conversation/components/conversation/conversation-item.vue
new file mode 100644
index 0000000..c7b1c51
--- /dev/null
+++ b/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' })
+
+// 鍛ㄤ腑鏂囧悕锛坉ayjs 鐨� 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锛坓etSenderDisplayName 绠椾笉鍑烘椂鍏滃簳锛� */
+const lastSenderDisplayName = computed(() => {
+ const senderId = props.conversation.lastSenderId
+ if (!senderId) {
+ return ''
+ }
+ return getSenderDisplayName(
+ senderId,
+ props.conversation.type,
+ props.conversation.targetId,
+ props.conversation.lastSenderDisplayName
+ )
+})
+
+/** 缇よ亰 + 鏈夋渶鍚庡彂閫佽�� + 鏈�鍚庝竴鏉℃槸鏅�氭秷鎭椂锛屾樉绀哄彂閫佽�呭墠缂�锛團RIEND_* / 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}鏉`
+})
+
+/** 缇よ亰鏈鐞嗗姞缇ょ敵璇风孩瀛楀墠缂�锛泂tore 宸茬粡鎸夈�屾垜绠$悊鐨勭兢銆嶈繃婊よ繃锛宑ount > 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>
+ <!-- @绾㈠瓧鎻愮ず锛歛tMe 浼樺厛浜� 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 }}:
+ </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>
diff --git a/src/views/im/home/pages/conversation/components/conversation/conversation-private-side.vue b/src/views/im/home/pages/conversation/components/conversation/conversation-private-side.vue
new file mode 100644
index 0000000..7939da1
--- /dev/null
+++ b/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锛歨andleOpenCreateGroup 璋� 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锛歛ntd Drawer 浼犻�佸埌 body 鍚� scoped data-v 钀戒笉鍒� body 涓婏紝闈� root-class-name 浣滅鍏堥�夋嫨鍣ㄥ啓鍏ㄥ眬瑙勫垯鍘嬫帀榛樿 padding -->
+<style>
+.im-conversation-private-side__modal .ant-drawer-body {
+ padding: 0;
+}
+</style>
diff --git a/src/views/im/home/pages/conversation/components/conversation/index.ts b/src/views/im/home/pages/conversation/components/conversation/index.ts
new file mode 100644
index 0000000..97c89ac
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/pages/conversation/components/index.ts b/src/views/im/home/pages/conversation/components/index.ts
new file mode 100644
index 0000000..9d4f6d4
--- /dev/null
+++ b/src/views/im/home/pages/conversation/components/index.ts
@@ -0,0 +1,3 @@
+export * from './conversation';
+export * from './input';
+export * from './message';
diff --git a/src/views/im/home/pages/conversation/components/input/face-picker.vue b/src/views/im/home/pages/conversation/components/input/face-picker.vue
new file mode 100644
index 0000000..b06de9e
--- /dev/null
+++ b/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锛歟moji + 涓汉琛ㄦ儏 + 绯荤粺鍖咃紙鑱婂ぉ涓昏緭鍏ョ敤锛夛紱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 鏍囪瘑甯搁噺锛沺ack: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] 瑙f瀽涓汉琛ㄦ儏澶辫触', error)
+ message.error('鍥剧墖瑙f瀽澶辫触')
+ 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锛沵ode='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 ]锛沵ode='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 姘旀场鎸囬拡锛沴eft 鍋忕Щ瀵瑰簲琛ㄦ儏鎸夐挳锛堝伐鍏锋爮 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>
diff --git a/src/views/im/home/pages/conversation/components/input/index.ts b/src/views/im/home/pages/conversation/components/input/index.ts
new file mode 100644
index 0000000..f48ba37
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/pages/conversation/components/input/mention-picker.vue b/src/views/im/home/pages/conversation/components/input/mention-picker.vue
new file mode 100644
index 0000000..ba510a7
--- /dev/null
+++ b/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[] // 褰撳墠缇ょ殑鎴愬憳鍒楄〃
+ // 娴眰浣嶇疆锛歺 妯潗鏍� + top / bottom 浜岄�変竴锛坆ottom 閿氬畾鏃� 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 鍙栵紝閬垮厤鏁h惤
+ */
+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 璧扮殑鎵佸钩鍒楄〃锛宎llItem 鍦ㄥ墠銆乵emberItems 鍦ㄥ悗 */
+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 鎺у埗鏄鹃殣锛宻earchText 杩囨护
+ - 鐖剁粍浠堕�氳繃 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>
+
+ <!-- 涓夎鎸囬拡锛歱icker 鍦� @ 涓嬫柟锛坧osition.top 閿氬畾锛夆啋 绠ご鏈濅笂璐撮《锛沺icker 鍦� @ 涓婃柟锛坧osition.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>
diff --git a/src/views/im/home/pages/conversation/components/input/message-input.vue b/src/views/im/home/pages/conversation/components/input/message-input.vue
new file mode 100644
index 0000000..6560c6e
--- /dev/null
+++ b/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锛夛紱涓嶅啓鑽夌锛宺estoreDraftToEditor 澶嶇敤閬垮厤鍥炴祦 */
+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 = ''
+ }
+}
+
+/** 鐢ㄦ埛缂栬緫鍏ュ彛鐨勭粺涓�鏀跺熬锛歎I 鐘舵�佸悓姝� + 鑽夌鍐欏洖 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 褰撳墠鍐呭鍐欏埌浼氳瘽鑽夌锛沺lain 鐢� collectFromEditor 鎷匡紝涓庡彂閫佹椂鍚屾簮閬垮厤鍒楄〃涓庡疄鍙戜笉涓�鑷� */
+function syncDraftToStore(editor: HTMLDivElement) {
+ const conversation = conversationStore.activeConversation
+ if (!conversation) {
+ return
+ }
+ // collectFromEditor 宸� trim锛宲lain 涓虹┖鏃� store 鍐呴儴鎸� clearConversationDraft 澶勭悊
+ // reply 閫忎紶褰撳墠蹇収锛歴etConversationDraft 鏄暣瀵硅薄鏇挎崲锛屼笉璇绘棫 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鈫抏ditor鈫抯tore 鍥炴祦 */
+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锛坱oken 棣栦綅閿氱偣鐢紝涓嶈繘鍙戦�佸唴瀹癸級
+ * 2. br锛氭嫾 \n锛圫hift+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. 闃插尽锛歝anSend 鏄� false锛坱rim 鍚庣┖ / 娌℃縺娲讳細璇濓級鎴� editor 娌� mount 鈫� 鐩存帴 return
+ * 2. 鏀堕泦锛欴OM walk 鎷垮埌瑕佸彂閫佺殑鏂囨湰 + atUserIds
+ * 3. 浜屾闃插尽锛歝ollectFromEditor 璧� 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锛圛DE 鏄剧ず鍒犻櫎绾匡級锛屼絾 'insertText' /
+ * 'insertLineBreak' 娌℃湁绛変环鐨� W3C 鏍囧噯鏇夸唬鈥斺�擱ange/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锛歝lick 鍦ㄦ煇浜涙祻瑙堝櫒閲� 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锛坋moji 闈㈡澘鍋风劍鐐瑰悗杩樿兘鍥炲師浣嶏級
+ * 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
+ * 锛坈ontenteditable 榛樿绮樿创浼氬甫 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锛歛dmin 涔熻兘 @ 鎵�鏈変汉锛� */
+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('')
+/** 娴眰瀹氫綅锛歺 鏄乏杈硅窛锛泃op / bottom 浜岄�変竴鈥斺�� bottom 閿氬畾锛坧icker 涓嬫部璐� @锛夋槸榛樿锛�
+ * 涓婃柟鏀句笉涓嬫椂閫�鍖栦负 top 閿氬畾锛坧icker 涓婃部璐� @ 涓嬫柟锛� */
+const mentionPosition = ref<{ bottom?: number; top?: number; x: number; }>({ x: 0, bottom: 0 })
+
+/** MentionPicker 鐨勫鍣ㄥ搴︼紙涓庣粍浠堕噷鐨� w-50 瀵归綈锛夛紝鐢ㄤ簬瑙嗗彛鍙虫部鍥炲脊锛�
+ * 楂樺害涓嶅啀鐢ㄥ父閲忕畻浣嶇疆鈥斺�攂ottom 閿氬畾鍚� picker 鍐呭澶氬閮戒笉褰卞搷涓嬫部浣嶇疆锛岃嚜鐒惰创 @ */
+const MENTION_WIDTH = 200
+const MENTION_MIN_FIT_ABOVE = 120 // 涓婃柟鍓╀綑绌洪棿鑷冲皯杩欎箞澶氭墠鏀句笂鏂癸紝鍚﹀垯缈诲埌涓嬫柟锛堥伩鍏� picker 琚鍙i《 / 椤堕儴 chat header 鍒囨帀锛�
+
+/** 褰撳墠 @ 鍏抽敭璇嶅湪 editor 閲岀殑鑼冨洿锛沷nMentionSelect 鐢ㄥ畠瀹氫綅鍒犻櫎 + 鎻掑叆 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)
+ // 鐩存帴鎵炬渶杩戠殑 @锛氫笉闄愬埗鍓嶇疆瀛楃锛屽榻愬井淇�"涓枃绱ц创 @ 涔熻兘鑱旀兂"锛堜綘濂紷寮犱笁銆侊紝@寮犱笁 閮借Е鍙戯級锛�
+ // 浠d环鏄� 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 閿氬畾锛坧icker 涓嬫部璐� @ 涓婃柟 8px锛夛紝涓婃柟涓嶅鎵嶇炕鎴� top 閿氬畾
+ *
+ * 1. 璁$畻 @ 瀛楃灞忓箷鍧愭爣 rect
+ * 2. 妯悜锛歱icker 宸﹁竟瀵归綈 @锛岃秺杩囪鍙e彸娌垮垯宸︽帹锛涜嚦灏戠暀 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. 妯悜锛歱icker 宸﹁竟瀵归綈 @锛岃秺杩囪鍙e彸娌垮垯宸︽帹锛涜嚦灏戠暀 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 鍓�
+ // 琛ヤ竴涓浂瀹界┖鏍煎綋閿氱偣锛汥OM walk 鏃朵細琚护鎺夛紝涓嶈繘鍏ュ彂閫佸唴瀹�
+ const prev = span.previousSibling
+ if (!prev || (prev.nodeType === Node.TEXT_NODE && !prev.textContent)) {
+ span.parentNode?.insertBefore(document.createTextNode('\u200B'), span)
+ }
+ // 鍦� token 鍚庤ˉ涓�涓� NBSP锛岃鍏夋爣鍙互缁х画杈撳叆锛汵BSP 姣旀櫘閫氱┖鏍兼洿绋筹紝閬垮厤琚祻瑙堝櫒鎶樺彔
+ 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锛孌OM 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锛孌OM 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 娑堟伅锛沺ayload 鐢� 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锛坉uration / 瀹介珮锛�+ 棣栧抚灏侀潰 blob
+ *
+ * 涓�涓� video 鍏冪礌涓蹭袱浠朵簨鏄负浜嗛伩鍏嶉噸澶� decode锛歮etadata 瑙e畬鍚庣洿鎺� seek 棣栧抚鍐嶆埅鍥俱��
+ * 鎴浘澶辫触涓嶆姏寮傚父锛屽彧璁� cover 缂哄け锛屼繚璇佷富娴佺▼浠嶈兘涓婁紶瑙嗛鏈綋銆�
+ *
+ * finally 閲屾樉寮忔柇寮曠敤鏄洜涓猴細浠� revokeObjectURL 涓嶈冻浠ヨ video decoder 绔嬪嵆閲婃斁锛�
+ * 閮ㄥ垎娴忚鍣ㄧ増鏈笂 4K 瑙嗛瑙g爜 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 鎶藉厓淇℃伅锛歞uration 鍋舵湁 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 瓒呮椂锛歝urrentTime 璁句负褰撳墠鍊硷紙璀宸茬粡鏄� 0 鐨勬瀬鐭棰戯級
+ // 閮ㄥ垎娴忚鍣ㄤ笉瑙﹀彂 onseeked锛宲romise 浼氫竴鐩� 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锛圴IDEO_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. 绔嬪嵆鍗犱綅锛歜lob URL 鏃㈠綋 video src 涔熷綋 poster锛屾祻瑙堝櫒浼氭嬁棣栧抚缁樺埗 cover锛泂tatus=SENDING + 杩涘害鏉�
+ * 2. probe 涓庤棰戜笂浼犲悓姝ヨ捣璺戯紱灏侀潰涓婁紶绛� probe 鍑� cover 鍚庝笌瑙嗛涓婁紶绔為��
+ * 锛坧robe 瑙g爜 + 灏侀潰涓婁紶閫氬父琚棰戜笂浼犳椂闀垮畬鍏ㄩ伄钄斤紝浣撴劅鑺傜渷鍑犵櫨 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. 绔嬪嵆鍗犱綅锛歶rl 璧� blob 璁� <video src> 鎷夐瀛楄妭娓叉煋锛沜overUrl 涓嶈 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. 涓夎矾骞惰璧疯窇锛坧robe 涓庝袱鏉′笂浼犳棤渚濊禆锛屽皝闈笂浼犵瓑 probe 鍑� cover 鍚庣珛鍗虫帴鍔涳級
+ // 2.1 瑙嗛鏈綋涓婁紶锛歛sync IIFE 鍖呬竴灞傝 await 鏄惧紡鍙锛坙int 涓嶅啀璇垽 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锛宲atch 杩涘崰浣� + 璧� 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>
+ <!--
+ 澶栧眰搴曡壊涓庢秷鎭祦锛坆g-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锛坱extarea 鎷夸笉鍒帮級
+ - 璁� @ 鎴愬憳浠� <span data-id> token 鑺傜偣瀛樺湪锛屽垹 token 鍗冲垹 id锛岄伩鍏� stale atUserIds
+ - placeholder 閫氳繃 data-empty + ::before 妯℃嫙锛坈ontenteditable 娌℃湁鍘熺敓 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 閿氱偣锛宲icker 鐢� 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锛坅nt-design outlined 绯诲垪锛夛細
+ - 瑙嗚椋庢牸鏇存帴杩戝井淇� PC锛堢嚎鎬с�佸渾瑙掞紝姣� Element Plus 鍐呯疆鐨勬洿杞婚噺锛�
+ - 绗戣劯 / 鍥剧墖 / 鏂囦欢澶� / 楹﹀厠椋� 鍚屾簮锛岄伩鍏嶄竴涓蛋 ep 涓�涓蛋 antd 瑙嗚鍓茶
+ - 澶栧眰 span 澶嶇敤 .message-input__tool 鐨� padding / hover 鏍峰紡锛宻coped 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 -->
+ <!-- 娉ㄦ剰锛歛nt-design-vue 鐨勫垎瑁傛寜閽槸 DropdownButton锛堥潪 Dropdown 鐨� split-button 灞炴�э紝閭f槸 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>
+
+ <!-- 琛ㄦ儏闈㈡澘锛歜ottom-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` 閿欒濉岀缉鎴愯8 `.dark`锛屽鑷磋繖浜涢鑹茶鐩存帴鍒峰埌 <html> 涓�
+ 锛堟殫鑹蹭笅鏁撮〉鍙戠孩/鍙戞殫榛勶級銆侭EM 绫诲悕鍞竴锛屾暣浣� 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>
diff --git a/src/views/im/home/pages/conversation/components/input/message-multi-select-bar.vue b/src/views/im/home/pages/conversation/components/input/message-multi-select-bar.vue
new file mode 100644
index 0000000..fa30729
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/pages/conversation/components/input/voice-recorder.vue b/src/views/im/home/pages/conversation/components/input/voice-recorder.vue
new file mode 100644
index 0000000..0598afa
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/pages/conversation/components/message/forward/index.ts b/src/views/im/home/pages/conversation/components/message/forward/index.ts
new file mode 100644
index 0000000..5e83f77
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/pages/conversation/components/message/forward/keys.ts b/src/views/im/home/pages/conversation/components/message/forward/keys.ts
new file mode 100644
index 0000000..f76c191
--- /dev/null
+++ b/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')
diff --git a/src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue b/src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue
new file mode 100644
index 0000000..b88eb23
--- /dev/null
+++ b/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') // 褰撳墠瑙嗗浘锛氶粯璁や細璇濋�夋嫨锛屻�屽垱寤鸿亰澶┿�嶅叆鍙e垏鍒板ソ鍙嬮�夋嫨
+const selectedKeys = ref<string[]>([])
+const selectedFriendIds = ref<number[]>([])
+const leaveMessage = ref('')
+const sending = ref(false)
+const emojiVisible = ref(false) // emoji picker 鏄鹃殣锛氬彸渚х瑧鑴告寜閽垏鎹�
+
+defineExpose({
+ /** 鎵撳紑杞彂寮圭獥锛歳eset 鈫� 鐏屽弬 鈫� 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锛氭嫾鍒扮暀瑷�鏈熬锛汧acePicker 鑷韩璐熻矗鍏抽棴闈㈡澘 */
+function handleEmojiSelect(emoji: string) {
+ leaveMessage.value = `${leaveMessage.value}${emoji}`
+}
+
+/** 鍚堝苟 payload + 搴忓垪鍖� content锛沵erge 妯″紡涓嬩竴娆℃瀯閫狅紝棰勮 / 鍙戦�佸叡鐢� */
+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銆乻ingle 鎸夋椂闂村簭閫愭潯 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銆丅銆嶃�侀儴鍒嗗け璐ャ�屽凡杞彂锛屼絾 X銆乊 澶辫触銆嶏紱
+ * 鐣欒█鍙湪璇ョ洰鏍囩殑杞彂娑堟伅鍏ㄩ儴鎴愬姛鍚庢墠杩藉姞锛岄伩鍏嶉敊搴�
+ */
+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
+ * 锛坰endRaw 鍐呴儴浼氳嚜鍔� 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'锛氫粠銆屽垱寤鸿亰澶┿�嶅叆鍙h繘鍏ワ紝閫夊ソ鍙嬪缓缇ゅ啀杞彂锛屼笟鍔″3灞傚垏瑙嗗浘
+ - footer slot 濉為瑙堝崱锛堝悎骞� / 閫愭潯涓嶅悓瑙嗚锛�+ 鐣欒█ + 鎻愪氦鎸夐挳
+ - 瀵瑰鎺ュ彛娌跨敤锛歳ef + 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>
diff --git a/src/views/im/home/pages/conversation/components/message/forward/message-merge-detail-dialog.vue b/src/views/im/home/pages/conversation/components/message/forward/message-merge-detail-dialog.vue
new file mode 100644
index 0000000..6a58e06
--- /dev/null
+++ b/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>
diff --git a/src/views/im/home/pages/conversation/components/message/group-pinned-message.vue b/src/views/im/home/pages/conversation/components/message/group-pinned-message.vue
new file mode 100644
index 0000000..b411944
--- /dev/null
+++ b/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
+)
+
+/** 褰撳墠鐢ㄦ埛鏄惁缇や富 / 绠$悊鍛橈紙鍐冲畾鏄惁鏄剧ず銆岀Щ闄ゃ�嶅叆鍙o級 */
+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锛宭oading 鏈熼棿绂佹閲嶅鐐癸紱鍚庣骞挎挱 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>
diff --git a/src/views/im/home/pages/conversation/components/message/group-request-pending.vue b/src/views/im/home/pages/conversation/components/message/group-request-pending.vue
new file mode 100644
index 0000000..9a6edd4
--- /dev/null
+++ b/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锛歨andleOpen 璋� 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>
diff --git a/src/views/im/home/pages/conversation/components/message/index.ts b/src/views/im/home/pages/conversation/components/message/index.ts
new file mode 100644
index 0000000..025a74c
--- /dev/null
+++ b/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';
diff --git a/src/views/im/home/pages/conversation/components/message/material-bubble.vue b/src/views/im/home/pages/conversation/components/message/material-bubble.vue
new file mode 100644
index 0000000..9f75df4
--- /dev/null
+++ b/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('')
+
+// 鐐瑰嚮琛屼负锛歶rl 闈炵┖璺冲閾撅紱涓虹┖鍒欐寜 payload.materialId 鎷夊瘜鏂囨湰姝f枃锛屽叏灞� 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] 鎷夊彇姝f枃澶辫触', 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"
+ >
+ 鏆傛棤姝f枃
+ </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>
diff --git a/src/views/im/home/pages/conversation/components/message/message-bubble.vue b/src/views/im/home/pages/conversation/components/message/message-bubble.vue
new file mode 100644
index 0000000..8a0cc97
--- /dev/null
+++ b/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锛圝SON 瀛楃涓诧級 */
+ 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 鍏ュ彛锛歝ontent 涓�鍙樺彧 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 鏁扮粍锛歮ention 楂樹寒 + 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]' : '']
+ }
+ }
+}
+
+/** 鏂囦欢鐐瑰嚮 鈫� 鏂扮獥鍙d笅杞斤紱涓婁紶涓烦杩� */
+function handleFileClick() {
+ if (isUploading.value || !filePayload.value?.url) {
+ return
+ }
+ openSafeUrl(filePayload.value.url)
+}
+
+const voicePlayer = useVoicePlayer() // 璇煶鐐瑰嚮锛氭墭绠$粰 useVoicePlayer 鍏ㄥ眬浜掓枼鎾斁锛屾柊鐐圭殑璇煶浼氬仠鎺夋棫鐨�
+/**
+ * 瀹炰緥绾у敮涓�鎾斁 key锛氭瘡涓� MessageBubble 瀹炰緥鐙珛涓�浠�
+ *
+ * 涓嶇敤 url 褰� key 鏄负浜嗛伩鍏嶃�屼富闈㈡澘 / 鍘嗗彶鎶藉眽 / 鍚堝苟璇︽儏鍚屼竴鏉¤闊炽�嶅叡浜韩浠斤細閭f牱涓夊姘旀场浼�
+ * 鍚屾椂鏄剧ず鎾斁鎬侊紝涓斾换浣曚竴澶勫嵏杞介兘浼� 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 娓叉煋锛宮ention 楂樹寒鍙偣鍑汇�乁RL 鑷姩璇嗗埆鎴愬彲鐐瑰嚮閾炬帴 -->
+ <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>
+
+ <!-- 鍥剧墖锛歟l-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 鍐呭祵鎾斁锛宲oster 璧板悗绔皝闈紱涓婁紶涓崐閫忔槑閬僵 -->
+ <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 瑙f瀽澶辫触 / 娌� 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>
+
+ <!-- 琛ㄦ儏璐村浘锛氳8 <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)"
+ />
+
+ <!-- 鍚堝苟杞彂姘旀场锛歵itle + 鎽樿棰勮 + 搴曢儴銆岃亰澶╄褰曘�嶆爣绛� -->
+ <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>
+ <!-- 鍚堝苟娑堟伅瑙f瀽澶辫触鍏滃簳 -->
+ <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` 濉岀缉鎴愯8 `.dark` 鑰屾妸鍙橀噺鍒峰埌 <html> */
+:global(.dark .message-bubble--other) {
+ --im-message-bubble-other-bg: rgb(255 255 255 / 12%);
+}
+
+/* :deep 绌块�� scoped 瀛愮粍浠� DOM锛沞l-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>
diff --git a/src/views/im/home/pages/conversation/components/message/message-history.vue b/src/views/im/home/pages/conversation/components/message/message-history.vue
new file mode 100644
index 0000000..a2c1312
--- /dev/null
+++ b/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 鐨勬垚鍛樺悕瑙f瀽鍣紱鎸夊綋鍓嶄細璇� 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 鏂囨锛歜uildRecallTip 鍐呴儴鎸� conversation 涓婁笅鏂囧疄鏃剁畻 sender 鍚� */
+function recallTipOf(message: Message): string {
+ return buildRecallTip(
+ message.senderId,
+ message.selfSend,
+ conversation.value?.type ?? 0,
+ conversation.value?.targetId ?? 0
+ )
+}
+
+/** 鍗曟潯鎾ゅ洖娑堟伅鐨� tip segments锛歴ender 鍚嶆鎸傚彲鐐瑰嚮 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 璧板垎椤垫帴鍙o紱CHANNEL 鍗曞悜骞挎挱銆佹病鏈� list 鎺ュ彛锛岃惤鍒� else 浼氳璋冪鑱婃帴鍙o紙receiverId 浼� channelId锛�
+ const requestedType = conversation.value.type
+ if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) {
+ return
+ }
+ // 蹇収褰撳墠浼氳瘽涓婚敭锛歛wait 鏈熼棿鐢ㄦ埛鍒囪蛋 / 鍏抽棴闈㈡澘鏃朵涪寮冨搷搴旓紝閬垮厤鏃т細璇濆巻鍙茶 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锛歱rependMessageList 鍐呴儴鍘婚噸 + 鍗囧簭鍚堝苟 + 钀� 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锛歞ialog 鐨� 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锛孌OM 涓婃病娉� querySelector
+ */
+function locateMessage(messageId: number) {
+ if (!messageId) {
+ return
+ }
+ emit('locate', messageId)
+ visible.value = false
+}
+</script>
+
+<template>
+ <!--
+ 鍘嗗彶娑堟伅寮圭獥锛堝榻愬井淇� PC锛�
+ - 灞呬腑 dialog锛宼itle "涓庛�孹銆嶈亰澶╄褰�" / "銆孹 缇ゃ�嶈亰澶╄褰�(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>
+ <!-- 鏃ユ湡锛歟l-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">
+ <!-- 濂藉弸浼氳瘽浜嬩欢锛團RIEND_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>
+
+ <!-- 缇ゅ箍鎾簨浠讹細璺熷ソ鍙嬩簨浠跺悓鐏拌壊鏍峰紡锛宮ention 娈垫寕鐐瑰嚮寮� 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锛宻ender 鍚嶆鍙偣鍑� -->
+ <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锛坮everse 鍚庢渶鏃╃殑鍦ㄦ渶涓嬶紝鎸夐挳鏀惧簳閮ㄦ洿鑷劧锛夛紱
+ 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 浼厓绱犵敾婵�娲讳笅鍒掔嚎锛泃ab 榛樿钃濊壊琛屽唴鏂囧瓧锛屾縺娲绘�佸姞涓嬪垝绾� */
+.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锛坒ullscreen=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>
diff --git a/src/views/im/home/pages/conversation/components/message/message-item.vue b/src/views/im/home/pages/conversation/components/message/message-item.vue
new file mode 100644
index 0000000..fa456f8
--- /dev/null
+++ b/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))
+
+// ==================== 娑堟伅鍐呭瑙f瀽 / 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)
+
+/**
+ * 鏄惁鍦ㄦ皵娉″熬閮ㄦ樉绀恒�屽彂閫佷腑銆峫oading 杞湀
+ *
+ * 鍥剧墖 / 瑙嗛 / 鏂囦欢姘旀场鍐呭祵宸叉湁杩涘害鍙嶉锛堥伄缃� / 杩涘害鏉★級锛屽灞� 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 灏辨槸瀵规柟澶村儚锛坥penConversation 鍏ュ弬绾﹀畾锛� */
+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锛氳嚜宸卞彂鐨勭兢娑堟伅涓斿悗绔紑鍚簡鍥炴墽锛圢O_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 甯搁噺锛沺ush 绔拰鍒嗗彂绔粠鍚屼竴澶勫彇锛宼ypo 缂栬瘧鏈熷氨鑳芥姄
+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'
+ })
+ }
+ // 銆岀疆椤躲�嶏細浠呯兢鑱� + 鏅�氭秷鎭� + 宸茶惤搴� + 鏈挙鍥� + 缇や富鎴栫鐞嗗憳锛涘凡缃《涓嶅啀灞曠ず锛岀敱缃《闈㈡澘鐨勩�岀Щ闄ゃ�嶅叆鍙f壙鎺�
+ // 涓嶆湰鍦伴鍒や笂闄愶紝璁╁悗绔牎楠岋紝瓒呴檺鏃堕�氳繃 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'
+ })
+ }
+ // 銆岀瑷� / 瑙g / 绉婚櫎銆嶏細缇よ亰 + 闈炶嚜宸辨秷鎭� + 鎴戞槸缇や富鎴栫鐞嗗憳
+ 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: '瑙i櫎绂佽█',
+ 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
+ })
+ }
+ // 銆屾挙鍥� / 鍒犻櫎銆嶄簩閫変竴锛�
+ // - 鑷繁鍙戦�� + 宸茶惤搴� + 鏈挙鍥� + 鍦ㄦ挙鍥炵獥鍙e唴 鈫� 鎾ゅ洖锛堟帹鏈嶅姟鍣ㄦ妸娑堟伅鎬佺疆 RECALL锛�
+ // - 鍏跺畠锛堝鏂规秷鎭� / 宸叉挙鍥� / 瓒呭嚭鎾ゅ洖绐楀彛锛夆啋 鍒犻櫎锛堜粎鏈湴娓咃紝涓嶅姩鍚庣锛�
+ // divided 鎶婅繖涓�椤瑰拰涓婇潰鐨勩�屽紩鐢ㄣ�嶉殧寮�锛宒anger 鏄剧孩瀵归綈寰俊
+ 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)
+}
+
+/** 鏄惁鍏佽缃《锛堝凡缃《娑堟伅涓嶅啀灞曠ず鑿滃崟椤癸紝鐢辩疆椤堕潰鏉跨殑銆岀Щ闄ゃ�嶅叆鍙f壙鎺ワ級锛氱兢鑱� + 鏅�氭秷鎭� + 宸茶惤搴� + 鏈挙鍥� + 缇や富鎴栫鐞嗗憳 + 鏈疆椤� */
+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锛孶I 鑷姩鍒囧埌"XX 鎾ゅ洖浜嗕竴鏉℃秷鎭�"
+ *
+ * 涓嶅仛涔愯鎾ゅ洖锛氬け璐� / 瓒呮椂 / 鍚庣鎷掔粷鏃舵湰绔姸鎬佸彲鑳戒笌鏈嶅姟绔紓绉伙紝缁熶竴璁� WS 鍥炴帹鏈�绋�
+ */
+async function handleRecall() {
+ try {
+ await confirm('纭畾瑕佹挙鍥炶繖鏉℃秷鎭悧锛�', '鎾ゅ洖娑堟伅')
+ await recall(props.message)
+ } catch {}
+}
+
+/**
+ * 澶辫触娑堟伅鐐瑰嚮閲嶈瘯
+ *
+ * - 濯掍綋娑堟伅锛坕mage / 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锛泃ype 鍒嗗彂 + 鏃у厓鏁版嵁澶嶇敤缁熶竴鍦� 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 绉婚櫎锛屼笉璋冨悗绔�
+ * 鍖哄埆浜�"鎾ゅ洖"锛氭湇鍔$娌″姩锛屽绔櫥褰曟椂鍏跺畠瀹㈡埛绔� / 缇ら噷鍏朵粬浜轰緷鐒惰兘鐪嬪埌杩欐潯
+ */
+/** 绂佽█锛歟mit 缁欑埗缁勪欢鎵撳紑鏃堕暱閫夋嫨寮圭獥锛堥伩鍏� MessageItem 杩囬噸锛� */
+function handleMute() {
+ const group = currentGroup.value
+ if (!group) {
+ return
+ }
+ const name = senderDisplayName.value || '璇ユ垚鍛�'
+ emit('mute', group.id, props.message.senderId, name)
+}
+
+/** 瑙i櫎绂佽█ */
+async function handleUnmute() {
+ const group = currentGroup.value
+ if (!group) {
+ return
+ }
+ try {
+ await confirm('纭畾瑙i櫎璇ユ垚鍛樼殑绂佽█鍚楋紵', '瑙i櫎绂佽█')
+ 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>
+
+ <!-- 濂藉弸浼氳瘽浜嬩欢锛團RIEND_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>
+
+ <!-- 缇ゅ箍鎾簨浠讹細璺熷ソ鍙嬩簨浠跺悓鐏拌壊鏍峰紡锛宮ention 娈垫寕鐐瑰嚮寮� 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>
+
+ <!-- 缇ら�氳瘽浜嬩欢锛圧TC_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>
+
+ <!-- 绉佽亰閫氳瘽缁撴潫锛圧TC_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锛宻ender 鍚嶆鍙偣鍑� -->
+ <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>
+ <!-- 寮曠敤鍧楋細姘旀场涓嬫柟锛宻elfSend 鏃剁珫绾垮湪鍙充晶 -->
+ <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 澹版槑锛汼ENDING 鐘舵�佺殑杞湀鍔ㄧ敾 */
+.im-loading-spin {
+ animation: im-loading-spin 1s linear infinite;
+}
+@keyframes im-loading-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+</style>
diff --git a/src/views/im/home/pages/conversation/components/message/message-panel.vue b/src/views/im/home/pages/conversation/components/message/message-panel.vue
new file mode 100644
index 0000000..ff1c133
--- /dev/null
+++ b/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 姘旀场鐐瑰嚮閲嶆嫧锛汳essageItem 娉ㄥ叆鍚庤皟鐢�
+
+// ==================== 澶氶�夋ā寮� ====================
+// 妯″潡绾у崟渚� state锛坈omposable锛夛紱鏈粍浠朵粎鍋氬垏浼氳瘽閫�鍑� + 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
+)
+
+/** 褰撳墠婵�娲讳細璇濇槸鍚﹀巻鍙查��缇ょ兢锛氱缇ら�氳瘽銆侀殣钘忕兢鐢宠妯箙绛夋搷浣滃叆鍙o紱鑱婂ぉ鍘嗗彶銆佺兢鍚嶅ご鍍忕収甯稿睍绀� */
+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 瑙﹀彂鍚庢墠浼氭湁瀹屾暣鏁版嵁锛�
+ * 鍏滃簳瀛楁锛坣ame / avatar锛変繚璇� header 涓嶄細"闂┖"锛宯otice / 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 缂撳瓨锛宮ap 鎴� 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锛氥�岃亰澶╁巻鍙层�峣con / 鎶藉眽銆屾煡鎵捐亰澶╁唴瀹广�嶅叆鍙i兘璋� 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) // 绉佽亰閫氳瘽鍏ュ彛锛歱opover 瑙﹀彂锛涚偣 璇煶 / 瑙嗛 鐩存帴鍙戣捣
+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 闃舵锛沜hat tip 鐢� RTC_CALL_END 鎺ㄩ�佸啓鍏ユ秷鎭祦
+ if (data.status === ImRtcCallStatus.ENDED) {
+ message.warning(resolveCallEndReasonText(data.endReason))
+ return
+ }
+ // 姝e父杩涘叆 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 瑙﹀彂婊氬姩锛泂mooth 浠� user 涓诲姩鐐广�屽洖鍒板簳閮ㄣ�嶇敤锛屽垵濮� / 鑷姩婊氳蛋 auto 閬垮厤鍔ㄧ敾鎷栨嫿鎰�
+ listRef.value.scrollTo({
+ top: listRef.value.scrollHeight,
+ behavior: smooth ? 'smooth' : 'auto'
+ })
+ newMessageCount.value = 0
+ showJumpToBottom.value = false
+ // 1.3 璁颁笅銆屾湡鏈涘仠涓嬬殑 scrollTop銆嶏紱鍥剧墖 / 瑙嗛鍔犺浇瀹屽簳閮ㄤ細涓婄Щ锛宻crollTop 娌″姩灏辫鏄庣敤鎴锋病鎵嬪姩婊氳蛋
+ // 涓嶈兘鐢� distanceFromBottom 鍒ゆ柇锛氬簳閮ㄤ笂绉讳細璁� distance 鍙樺ぇ锛岃璇垽涓恒�岀敤鎴锋粴璧颁簡銆嶇洿鎺ユ斁寮冭ˉ婊�
+ const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight
+
+ // 2. 绛夊獟浣撳姞杞藉畬鍚庤ˉ婊氬埌鐪熷疄搴曢儴
+ // 2.1 绛夊鍣ㄥ唴鏈姞杞藉畬鐨勫浘鐗� / 瑙嗛鍏冩暟鎹紱鍔犺浇瀹屽悗 scrollHeight 浼氬闀垮埌鐪熷疄搴曢儴
+ await waitMediaSettled()
+ if (!listRef.value) {
+ return
+ }
+ // 2.2 浠呭湪鐢ㄦ埛娌℃墜鍔ㄦ粴璧版椂锛坰crollTop 浠嶈创杩� expectedScrollTop锛夋墠琛ユ粴锛岄伩鍏嶇瓑寰呮湡闂寸敤鎴蜂笂缈昏鎵撴柇
+ if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) {
+ return
+ }
+ // 2.3 琛ユ粴鍒版柊搴曢儴
+ listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' })
+}
+
+/**
+ * 绛夊緟瀹瑰櫒鍐呮湭鍔犺浇瀹岀殑鍥剧墖 / 瑙嗛锛涙渶澶氱瓑 2s 闃叉瓒呭ぇ璧勬簮鎶婃暣涓粴鍔ㄨ窡杩涘崱浣�
+ *
+ * 浠呭叧蹇冨厓鏁版嵁锛坙oadedmetadata / img.complete锛夛紝涓嶇瓑鐪熸瑙g爜锛屽洜涓哄昂瀵稿绠� scrollHeight 灏辫
+ */
+function waitMediaSettled(): Promise<void> {
+ // 1. 鏀堕泦瀹瑰櫒鍐呮湭鍔犺浇瀹岀殑鍥剧墖 / 瑙嗛
+ if (!listRef.value) {
+ return Promise.resolve()
+ }
+ // 1.1 涓�娆℃壂 img + video锛屾寜 element 绫诲瀷鍒嗗埆鐪嬨�宑omplete / 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锛宻crollIntoView({ block: center }) 璁╂秷鎭惤鍒拌鍙d腑閮�
+ * 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>
+ <!-- 閫氳瘽鍏ュ彛锛氱鑱婂脊銆岃闊� / 瑙嗛銆峱opover锛涚兢鑱婄洿鎺ヨ繘閫変汉寮圭獥 -->
+ <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锛宻crollIntoView + 鍔犻珮浜� 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锛沞l-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 灞呬腑锛歠it-content 瀹藉害涓嶄細鎾戞弧锛宼ransform 姘村钩 -50% 鍋忕Щ锛涘悓鏃� transition opacity 鍜� transform 涓や釜灞炴�� */
+.message-panel__jump-bottom {
+ transform: translateX(-50%);
+ transition:
+ opacity 0.2s,
+ transform 0.2s;
+}
+
+/* JS 鍒囨崲 highlight class + background-color transition 鑱斿姩锛汳essageHistory銆屽畾浣嶃�嶈烦杩囨潵鏃剁煭鏆傞珮浜� 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>
diff --git a/src/views/im/home/pages/conversation/components/message/message-read-status.vue b/src/views/im/home/pages/conversation/components/message/message-read-status.vue
new file mode 100644
index 0000000..df5152c
--- /dev/null
+++ b/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锛氭湇鍔$纭鍏ㄥ憳宸茶 鈫� 鐩存帴鏄剧ず"鍏ㄩ儴宸茶"锛宺eadCount 涓嶅啀浣跨敤
+ * - readCount > 0锛氭樉绀�"N 浜哄凡璇�"
+ * - 鍏朵粬锛坮eadCount = 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 闈炵┖鏃讹紝鍙畻鍚嶅崟鍐呯殑鎴愬憳銆傚皯浜嗚繖灞傦紝鏈湪鎶曢�掕寖鍥撮噷鐨勪汉
+ * 浼氳閿欑畻鎴�"鏈"锛坓etGroupReadUsers 鍚庣宸叉寜鍙鎬ц繃婊わ紝鍓嶇蹇呴』閰嶅锛�
+ * 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 鍒楄〃
+ *
+ * 瑙﹀彂锛歟l-popover @show锛堟寜闇�鎳掑姞杞斤紝閬垮厤姣忔潯缇ゆ秷鎭兘棰勬媺锛�
+ * 璺宠繃锛氭湰鍦板崰浣嶆秷鎭紙id = 0锛岃繕娌℃嬁鍒版湇鍔$ id锛夛紝鍚庣娌℃硶鎸� messageId 鏌�
+ * 澶辫触锛氫粎鎺у埗鍙板憡璀︼紝readUserIds 淇濇寔绌烘暟缁� 鈫� label 璧� readCount 鍏滃簳锛屼笉闃诲 UI
+ *
+ * 鎷夊埌鍚嶅崟鍚庨『鎵嬫妸 readCount / receiptStatus 鍥炲啓鍒� messageStore锛岃 popover 澶栭潰鐨�
+ * label 涔熻窡鐫�璧版渶鏂版暟锛氱绾� / 婕忔敹 RECEIPT 浜嬩欢鏃舵湰鍦� readCount 浼氬亸鏃э紝寮瑰眰閲岀湅鍒�"宸茶 5"
+ * 浣嗗闈粛鏄�"鏈"鎴栨棫浜烘暟锛涜繖閲屼互鏈嶅姟绔繑鍥炰负鍑嗙煫姝e洖鍘�
+ */
+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锛宺eceiptStatus 缁存寔涓嶅彉锛圥ENDING锛�
+ 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>
+ <!--
+ 缇ゆ秷鎭凡璇荤姸鎬�
+ - 鏍囩褰㈡�侊細灞曠ず銆孨 宸茶銆嶆垨銆屽叏閮ㄥ凡璇汇�嶏紱鐐瑰嚮寮瑰嚭 popover 鐢� tab 鍒楀嚭宸茶 / 鏈鎴愬憳
+ - 娓叉煋鏉′欢锛氫粎鍦ㄧ兢鑱娿�佽嚜宸卞彂閫併�乺eceiptStatus !== 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>
diff --git a/src/views/im/home/pages/conversation/components/message/reply-preview.vue b/src/views/im/home/pages/conversation/components/message/reply-preview.vue
new file mode 100644
index 0000000..6ada968
--- /dev/null
+++ b/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 瑙f瀽涓�娆$紦瀛橈紝璁╁涓� 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锛堣嚜宸卞彂閫佺殑姘旀场锛夛細绔栫嚎闀滃儚鍒板彸渚э紝涓庢皵娉″悓渚�
+ - 鍐呭棰勮锛堜笌涓绘皵娉″悓婧愶紝涓嶇缉鍐欐垚銆孾鏂囦欢]/[璇煶]銆嶏級锛�
+ - 鏂囨湰锛氭埅鏂悗绾枃瀛�
+ - 鍥剧墖 / 瑙嗛锛氱缉鐣ュ浘锛圛MAGE 鐢� thumbnailUrl/url锛孷IDEO 鐢� coverUrl锛�
+ - 鏂囦欢锛歠ile icon + 鏂囦欢鍚� + 澶у皬
+ - 璇煶锛歛udio 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>
+
+ <!-- 鏂囦欢锛歩con + 鏂囦欢鍚� + 澶у皬 -->
+ <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>
+
+ <!-- 璇煶锛歛udio 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>
diff --git a/src/views/im/home/pages/conversation/components/message/tip-segments.vue b/src/views/im/home/pages/conversation/components/message/tip-segments.vue
new file mode 100644
index 0000000..19b7be2
--- /dev/null
+++ b/src/views/im/home/pages/conversation/components/message/tip-segments.vue
@@ -0,0 +1,54 @@
+<!--
+ 娑堟伅鏂囨湰鐗囨娓叉煋锛歵ip 鏂囨 + 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>
diff --git a/src/views/im/home/pages/conversation/index.vue b/src/views/im/home/pages/conversation/index.vue
new file mode 100644
index 0000000..58517c8
--- /dev/null
+++ b/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锛氬彸涓婅 +-涓嬫媺銆屾坊鍔犳湅鍙嬨�嶅叆鍙h皟 open() 瑙﹀彂
+
+// ==================== 寤虹兢鐩稿叧 ====================
+
+const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // 鍙戣捣缇よ亰寮圭獥 ref锛歨andleOpenCreateGroup 璋� 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 }
+ )
+}
+
+// ==================== 婊氬姩鍒颁笅涓�涓湭璇� ====================
+// 宸ュ叿鏍忋�屾秷鎭�峊ab 浜屾鐐瑰嚮 鈫� uiStore.nextUnreadJumpNonce 閫掑 鈫� 鏈〉 watch 婊氬姩鍒颁笅涓�鏉℃湭璇�
+// 鍚厤鎵撴壈浼氳瘽锛堝皬绾㈢偣涔熺畻鏈锛夛紱閫氳繃缁存姢 lastJumpedConversationKey 璁╄繛缁偣鍑婚『搴忕┛杩囨暣涓湭璇诲垪琛�
+
+/** 涓婃鍛戒腑鐨勬湭璇讳細璇� key锛涗负绌烘椂浠庢湭璇诲垪琛ㄥご寮�濮� */
+let lastJumpedConversationKey: null | string = null
+
+/** 婊氬姩鍒颁笅涓�涓湭璇讳細璇濓紙鍚厤鎵撴壈锛夛紱鐩爣琚悳绱㈡杩囨护鎴栬棌鍦ㄧ疆椤舵姌鍙犲尯鏃跺厛瑙i櫎鎷︽埅鍐嶆粴 */
+async function jumpToNextUnread() {
+ // 鍚厤鎵撴壈鐨勫叏閲忔湭璇讳細璇濓紱绌哄垯鐩存帴杩斿洖
+ const unreadList = sortedConversations.value.filter((c) => (c.unreadCount || 0) > 0)
+ if (unreadList.length === 0) {
+ return
+ }
+ // 浠庝笂娆″懡涓偅鏉″線鍚庢帹涓�浣嶏紱棣栨鎴栦笂娆$洰鏍囧凡璇诲畬涓嶅湪鍒楄〃閲屾椂锛宺efIndex=-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>
diff --git a/src/views/im/home/store/channelStore.ts b/src/views/im/home/store/channelStore.ts
new file mode 100644
index 0000000..3f059ca
--- /dev/null
+++ b/src/views/im/home/store/channelStore.ts
@@ -0,0 +1,108 @@
+import type { ImManagerChannelApi } from '#/api/im/manager/channel'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { getSimpleChannelList } from '#/api/im/manager/channel'
+
+import { ImConversationType } from '../../utils/constants'
+import { getDb } from '../../utils/db'
+import { useConversationStore } from './conversationStore'
+
+/**
+ * IM 棰戦亾 Store
+ *
+ * 璐熻矗锛氱紦瀛樺綋鍓嶇敤鎴疯兘鐪嬪埌鐨勯閬撶簿绠�鍒楄〃锛堝惈 id / code / name / avatar锛夛紝
+ * 渚涗細璇濆垪琛� / 鍗$墖娓叉煋鏃跺弽鏌ラ閬撳悕绉� + 澶村儚锛岄伩鍏嶆樉绀恒�岄閬� 1銆嶈繖绉嶅崰浣�
+ */
+export const useChannelStore = defineStore('imChannelStore', {
+ state: () => ({
+ channels: [] as ImManagerChannelApi.Channel[],
+ loaded: false
+ }),
+
+ getters: {
+ getChannel(state): (id: number) => ImManagerChannelApi.Channel | undefined {
+ return (id: number) => state.channels.find((c) => c.id === id)
+ }
+ },
+
+ actions: {
+ // ==================== 鏈湴缂撳瓨 ====================
+
+ /** 浠� IndexedDB 鎭㈠棰戦亾鍒楄〃 */
+ async loadChannelList(): Promise<boolean> {
+ try {
+ const cached = await getDb().getAll<ImManagerChannelApi.Channel>('channels')
+ if (!cached || cached.length === 0) {
+ return false
+ }
+ this.channels = cached
+ return true
+ } catch (error) {
+ console.warn('[IM channelStore] 鏈湴棰戦亾缂撳瓨璇诲彇澶辫触', error)
+ return false
+ }
+ },
+
+ /** 淇濆瓨棰戦亾鍒楄〃 */
+ saveChannelList(): void {
+ void getDb()
+ .transaction(['channels'], 'readwrite', async (tx) => {
+ const db = getDb()
+ await db.clearStore('channels', tx)
+ for (const channel of this.channels) {
+ await db.put('channels', channel, tx)
+ }
+ })
+ .catch((error) => console.warn('[IM channelStore] 鏈湴棰戦亾缂撳瓨鍐欏叆澶辫触', error))
+ },
+
+ // ==================== 杩滅鎷夊彇 ====================
+
+ /** 鎷夊彇鍚敤鐨勯閬撶簿绠�鍒楄〃锛涙垚鍔熷悗鍥炲~浼氳瘽鍒楄〃宸叉湁鐨勯閬� name / avatar锛岃鐩� IDB 鏃у崰浣� */
+ async fetchChannelList(force = false) {
+ if (this.loaded && !force) {
+ return
+ }
+ try {
+ this.channels = (await getSimpleChannelList()) || []
+ this.loaded = true
+ this.syncChannelConversationMetadata()
+ this.saveChannelList()
+ } catch (error) {
+ console.warn('[IM channelStore] fetchChannelList 澶辫触', error)
+ }
+ },
+
+ /** 鐢ㄦ渶鏂扮殑棰戦亾淇℃伅瑕嗙洊宸叉湁 CHANNEL 浼氳瘽鐨� name / avatar */
+ syncChannelConversationMetadata() {
+ const conversationStore = useConversationStore()
+ const indexed = new Map(this.channels.map((c) => [c.id, c]))
+ conversationStore.conversations.forEach((conversation) => {
+ if (conversation.type !== ImConversationType.CHANNEL) {
+ return
+ }
+ const channel = indexed.get(conversation.targetId)
+ if (!channel) {
+ return
+ }
+ conversationStore.updateConversation(ImConversationType.CHANNEL, conversation.targetId, {
+ name: channel.name,
+ avatar: channel.avatar
+ })
+ })
+ },
+
+ /** 娓呯┖棰戦亾鍐呭瓨 */
+ clear() {
+ this.channels = []
+ this.loaded = false
+ }
+ }
+})
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot))
+}
+
+export const useChannelStoreWithOut = () => useChannelStore()
diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts
new file mode 100644
index 0000000..a799ffc
--- /dev/null
+++ b/src/views/im/home/store/conversationStore.ts
@@ -0,0 +1,910 @@
+import type {
+ Conversation,
+ ConversationDO,
+ ConversationRead,
+ ConversationReadDO,
+ MessageDO
+} from '../types'
+
+import type { ImConversationReadApi } from '#/api/im/conversation/read'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { pullMyConversationReadList as apiPullMyConversationReadList } from '#/api/im/conversation/read'
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
+import {
+ ImConversationType,
+ ImMessageReceiptStatus,
+ ImMessageStatus,
+ isNormalMessage
+} from '../../utils/constants'
+import { type DbTransaction, getClientConversationId, getDb, StorageKeys } from '../../utils/db'
+import { runIncrementalPull } from '../../utils/pull'
+import { useMessageStore } from './messageStore'
+
+const PERSIST_DRAFT_DEBOUNCE_MS = 500
+const pendingDraftConversations = new Set<Conversation>()
+
+/** 鍒涘缓浼氳瘽璇讳綅缃褰� */
+function createConversationRead(
+ type: number,
+ targetId: number,
+ messageId: number
+): ConversationRead {
+ return {
+ conversationType: type,
+ targetId,
+ messageId,
+ updateTime: Date.now()
+ }
+}
+
+/** 鍒涘缓鑽夌淇濆瓨闃叉姈鍑芥暟 */
+function createDraftDebounce(fn: () => void, wait: number) {
+ let timer: ReturnType<typeof setTimeout> | undefined
+
+ const run = () => {
+ if (timer) {
+ clearTimeout(timer)
+ timer = undefined
+ }
+ fn()
+ }
+ const debounced = () => {
+ if (timer) {
+ clearTimeout(timer)
+ }
+ timer = setTimeout(run, wait)
+ }
+ debounced.cancel = () => {
+ if (timer) {
+ clearTimeout(timer)
+ timer = undefined
+ }
+ }
+ debounced.flush = run
+ return debounced
+}
+
+/** 浼氳瘽杞� IndexedDB 璁板綍 */
+function toConversationDO(conversation: Conversation): ConversationDO {
+ const draft = conversation.draft
+ return {
+ targetId: conversation.targetId,
+ type: conversation.type,
+ name: conversation.name,
+ avatar: conversation.avatar,
+ unreadCount: conversation.unreadCount,
+ lastContent: conversation.lastContent,
+ lastSendTime: conversation.lastSendTime,
+ lastSenderId: conversation.lastSenderId,
+ lastMessageType: conversation.lastMessageType,
+ lastMessageId: conversation.lastMessageId,
+ lastClientMessageId: conversation.lastClientMessageId,
+ lastMessageStatus: conversation.lastMessageStatus,
+ lastReceiptStatus: conversation.lastReceiptStatus,
+ lastSelfSend: conversation.lastSelfSend,
+ lastSenderDisplayName: conversation.lastSenderDisplayName,
+ reportedReadMessageId: conversation.reportedReadMessageId,
+ deleted: conversation.deleted,
+ top: conversation.top,
+ silent: conversation.silent,
+ atMe: conversation.atMe,
+ atAll: conversation.atAll,
+ draft: draft ? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined } : undefined,
+ clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
+ }
+}
+
+/** IndexedDB 璁板綍杞細璇� */
+function fromConversationDO(conversation: ConversationDO): Conversation {
+ const {
+ clientConversationId: _clientConversationId,
+ ...rest
+ } = conversation
+ return rest
+}
+
+/** 浼氳瘽璇讳綅缃浆 IndexedDB 璁板綍 */
+function toConversationReadDO(record: ConversationRead): ConversationReadDO {
+ return {
+ conversationType: record.conversationType,
+ targetId: record.targetId,
+ messageId: record.messageId,
+ updateTime: record.updateTime,
+ clientConversationId: getClientConversationId(record.conversationType, record.targetId)
+ }
+}
+
+/** IndexedDB 璁板綍杞細璇濊浣嶇疆 */
+function fromConversationReadDO(record: ConversationReadDO): ConversationRead {
+ const { clientConversationId: _clientConversationId, ...rest } = record
+ return rest
+}
+
+/** 鏄惁涓烘湁鏁堜細璇濊浣嶇疆 */
+function isValidConversationReadRecord(record: ImConversationReadApi.ConversationReadRespVO): boolean {
+ return !!record.conversationType && !!record.targetId && !!record.messageId
+}
+
+/** 鑾峰彇瀵规柟鏅�氭秷鎭渶澶х紪鍙� */
+function getMaxIncomingNormalMessageId(
+ messages: Array<Pick<MessageDO, 'id' | 'selfSend' | 'status' | 'type'>>
+): number {
+ let maxMessageId = 0
+ for (const message of messages) {
+ if (
+ message.id &&
+ !message.selfSend &&
+ isNormalMessage(message.type) &&
+ message.status !== ImMessageStatus.RECALL &&
+ message.id > maxMessageId
+ ) {
+ maxMessageId = message.id
+ }
+ }
+ return maxMessageId
+}
+
+export const useConversationStore = defineStore('imConversationStore', {
+ state: () => ({
+ conversations: [] as Conversation[], // 鍏ㄩ噺浼氳瘽鍒楄〃锛堢鑱� + 缇よ亰 + 棰戦亾锛�
+ conversationReads: {} as Record<string, ConversationRead>, // 浼氳瘽璇讳綅缃�
+ activeConversation: null as Conversation | null, // 褰撳墠婵�娲荤殑浼氳瘽
+ loading: false, // 鏄惁姝e湪鎵归噺鍔犺浇
+ recentForwardConversationKeys: [] as string[] // 鏈�杩戣浆鍙戜細璇� key 鍒楄〃
+ }),
+
+ getters: {
+ /** 鎺掑簭鍚庣殑浼氳瘽鍒楄〃 */
+ getSortedConversationList(state): Conversation[] {
+ return state.conversations
+ .filter((conversation) => !conversation.deleted)
+ .toSorted((a, b) => {
+ const aTop = a.top ? 1 : 0
+ const bTop = b.top ? 1 : 0
+ if (aTop !== bTop) {
+ return bTop - aTop
+ }
+ return (b.lastSendTime || 0) - (a.lastSendTime || 0)
+ })
+ },
+
+ /** 鏈鎬绘暟 */
+ getTotalUnreadCount(state): number {
+ return state.conversations
+ .filter((conversation) => !conversation.deleted && !conversation.silent)
+ .reduce((sum, conversation) => sum + (conversation.unreadCount || 0), 0)
+ },
+
+ /** 鏌ユ壘浼氳瘽 */
+ getConversation:
+ (state) =>
+ (type: number, targetId: number): Conversation | undefined =>
+ state.conversations.find(
+ (conversation) => conversation.type === type && conversation.targetId === targetId
+ ),
+
+ /** 鏌ユ壘浼氳瘽璇讳綅缃� */
+ getConversationRead:
+ (state) =>
+ (type: number, targetId: number): ConversationRead | undefined =>
+ state.conversationReads[getClientConversationId(type, targetId)]
+ },
+
+ actions: {
+ /** 鍔犺浇浼氳瘽 */
+ async loadConversationList() {
+ // 1. 娓呯悊鏃ц处鍙峰唴瀛�
+ const userId = getCurrentUserId()
+ if (!userId) {
+ this.clear()
+ return
+ }
+ const previousActiveKey = this.activeConversation
+ ? getClientConversationId(this.activeConversation.type, this.activeConversation.targetId)
+ : null
+ this.clear()
+ // 2. 浠� IndexedDB 璇诲彇浼氳瘽鍜岃交閲忚缃�
+ const db = getDb()
+ const [conversations, conversationReads, recent] = await Promise.all([
+ db.getAll<ConversationDO>('conversations'),
+ db.getAll<ConversationReadDO>('conversationReads'),
+ db.getSetting<string[]>(StorageKeys.settings.recentForwardConversationKeys)
+ ])
+ const nextConversationReads: Record<string, ConversationRead> = {}
+ for (const record of conversationReads) {
+ const item = fromConversationReadDO(record)
+ nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
+ }
+ const nextConversations = conversations.map((conversation) => fromConversationDO(conversation))
+ this.conversationReads = nextConversationReads
+ await this.applyLocalConversationReads(nextConversations)
+ this.conversations = nextConversations
+ if (Array.isArray(recent)) {
+ this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
+ }
+ // 3. 鎭㈠褰撳墠婵�娲讳細璇�
+ if (previousActiveKey) {
+ this.activeConversation =
+ this.conversations.find(
+ (conversation) =>
+ !conversation.deleted &&
+ getClientConversationId(conversation.type, conversation.targetId) ===
+ previousActiveKey
+ ) ?? null
+ }
+ },
+
+ /** 娓呯┖浼氳瘽鍐呭瓨 */
+ clear() {
+ saveDraftConversationListDebounced.cancel()
+ pendingDraftConversations.clear()
+ this.conversations = []
+ this.conversationReads = {}
+ this.activeConversation = null
+ this.recentForwardConversationKeys = []
+ },
+
+ /** 鎸佷箙鍖栦細璇濊浣嶇疆 */
+ async saveConversationReadRecord(
+ target: ConversationRead | ConversationRead[] | null | undefined,
+ tx?: DbTransaction
+ ): Promise<void> {
+ let targets: ConversationRead[] = []
+ if (Array.isArray(target)) {
+ targets = target
+ } else if (target) {
+ targets = [target]
+ }
+ const records = targets.map((record) => toConversationReadDO(record))
+ if (records.length === 0) {
+ return
+ }
+ const db = getDb()
+ if (tx) {
+ for (const record of records) {
+ await db.put('conversationReads', record, tx)
+ }
+ return
+ }
+ await db.transaction(['conversationReads'], 'readwrite', async (tx) => {
+ for (const record of records) {
+ await db.put('conversationReads', record, tx)
+ }
+ })
+ },
+
+ /** 搴旂敤鏈湴浼氳瘽璇讳綅缃� */
+ async applyLocalConversationReads(conversations?: Conversation[]) {
+ const targetConversations = conversations || this.conversations
+ const changedConversations: Conversation[] = []
+ for (const conversation of targetConversations) {
+ const record = this.getConversationRead(conversation.type, conversation.targetId)
+ if (!record) {
+ continue
+ }
+ if (this.applyReadToConversation(conversation, record.messageId)) {
+ changedConversations.push(conversation)
+ continue
+ }
+ if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) {
+ continue
+ }
+ const messages = await getDb().getAllByIndex<MessageDO>(
+ 'messages',
+ 'clientConversationId',
+ getClientConversationId(conversation.type, conversation.targetId)
+ )
+ const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages)
+ if (maxIncomingMessageId > 0 && maxIncomingMessageId <= record.messageId) {
+ conversation.unreadCount = 0
+ conversation.atMe = false
+ conversation.atAll = false
+ changedConversations.push(conversation)
+ }
+ }
+ if (changedConversations.length > 0) {
+ await this.saveConversationRecord(changedConversations)
+ }
+ },
+
+ /** 鍒ゆ柇娑堟伅鏄惁宸茶浼氳瘽璇讳綅缃鐩� */
+ isMessageCoveredByReadPosition(
+ conversation: Pick<Conversation, 'targetId' | 'type'>,
+ message?: null | { id?: number }
+ ): boolean {
+ if (!message?.id) {
+ return false
+ }
+ const record = this.getConversationRead(conversation.type, conversation.targetId)
+ return !!record && message.id <= record.messageId
+ },
+
+ /** 鍒ゆ柇浼氳瘽璇讳綅缃槸鍚﹁鐩栨秷鎭紪鍙� */
+ isReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
+ if (!messageId) {
+ return false
+ }
+ const record = this.getConversationRead(type, targetId)
+ return !!record && record.messageId >= messageId
+ },
+
+ /** 鍒ゆ柇鏈嶅姟绔凡璇讳綅缃槸鍚﹁鐩栨秷鎭紪鍙� */
+ isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
+ if (!messageId) {
+ return false
+ }
+ const conversation = this.getConversation(type, targetId)
+ return (conversation?.reportedReadMessageId || 0) >= messageId
+ },
+
+ /** 搴旂敤璇讳綅缃埌浼氳瘽 */
+ applyReadToConversation(conversation: Conversation, messageId: number): boolean {
+ if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
+ return false
+ }
+ if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) {
+ return false
+ }
+ conversation.unreadCount = 0
+ conversation.atMe = false
+ conversation.atAll = false
+ return true
+ },
+
+ /** 搴旂敤浼氳瘽璇讳綅缃� */
+ async applyConversationReadList(
+ records: ImConversationReadApi.ConversationReadRespVO[],
+ isActive?: () => boolean
+ ): Promise<void> {
+ if (records.length === 0) {
+ return
+ }
+ const changedReads = new Map<string, ConversationRead>()
+ const changedConversations = new Map<string, Conversation>()
+ const changedMessages = new Map<string, MessageDO>()
+ const db = getDb()
+ const messageStore = useMessageStore()
+
+ // 1. 鎸夎浣嶇疆鏇存柊浼氳瘽鏈鍜岄閬撳凡璇绘��
+ for (const record of records) {
+ if (isActive && !isActive()) {
+ return
+ }
+ if (!isValidConversationReadRecord(record)) {
+ continue
+ }
+ const clientConversationId = getClientConversationId(
+ record.conversationType,
+ record.targetId
+ )
+ let storedMessages: MessageDO[] | undefined
+ const getStoredMessages = async () => {
+ if (!storedMessages) {
+ storedMessages = await db.getAllByIndex<MessageDO>(
+ 'messages',
+ 'clientConversationId',
+ clientConversationId
+ )
+ }
+ return storedMessages
+ }
+ const current = this.conversationReads[clientConversationId]
+ const messageId = Math.max(record.messageId, current?.messageId || 0)
+ const conversation = this.getConversation(record.conversationType, record.targetId)
+ if (conversation && record.messageId > (conversation.reportedReadMessageId || 0)) {
+ conversation.reportedReadMessageId = record.messageId
+ changedConversations.set(clientConversationId, conversation)
+ }
+ if (!current || messageId > current.messageId) {
+ const next = {
+ conversationType: record.conversationType,
+ targetId: record.targetId,
+ messageId,
+ updateTime: record.updateTime
+ }
+ this.conversationReads[clientConversationId] = next
+ changedReads.set(clientConversationId, next)
+ }
+
+ if (conversation && this.applyReadToConversation(conversation, messageId)) {
+ changedConversations.set(clientConversationId, conversation)
+ } else if (conversation) {
+ const maxIncomingMessageId = getMaxIncomingNormalMessageId(await getStoredMessages())
+ if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) {
+ conversation.unreadCount = 0
+ conversation.atMe = false
+ conversation.atAll = false
+ changedConversations.set(clientConversationId, conversation)
+ }
+ }
+ if (record.conversationType !== ImConversationType.CHANNEL) {
+ continue
+ }
+ const memoryMessages = messageStore.getMessages(clientConversationId)
+ for (const message of memoryMessages) {
+ if (
+ message.id &&
+ message.id <= messageId &&
+ message.receiptStatus !== ImMessageReceiptStatus.DONE
+ ) {
+ message.receiptStatus = ImMessageReceiptStatus.DONE
+ }
+ }
+ for (const message of await getStoredMessages()) {
+ if (
+ message.id &&
+ message.id <= messageId &&
+ message.receiptStatus !== ImMessageReceiptStatus.DONE
+ ) {
+ message.receiptStatus = ImMessageReceiptStatus.DONE
+ changedMessages.set(message.messageKey, message)
+ }
+ }
+ }
+
+ // 2. 鎸佷箙鍖栨湰杞彉鏇�
+ if (
+ changedReads.size === 0 &&
+ changedConversations.size === 0 &&
+ changedMessages.size === 0
+ ) {
+ return
+ }
+ if (isActive && !isActive()) {
+ return
+ }
+ const stores: Array<'conversationReads' | 'conversations' | 'messages'> = []
+ if (changedReads.size > 0) {
+ stores.push('conversationReads')
+ }
+ if (changedConversations.size > 0) {
+ stores.push('conversations')
+ }
+ if (changedMessages.size > 0) {
+ stores.push('messages')
+ }
+ await db.transaction(stores, 'readwrite', async (tx) => {
+ if (changedReads.size > 0) {
+ await this.saveConversationReadRecord([...changedReads.values()], tx)
+ }
+ if (changedConversations.size > 0) {
+ await this.saveConversationRecord([...changedConversations.values()], tx)
+ }
+ for (const message of changedMessages.values()) {
+ await db.put('messages', message, tx)
+ }
+ })
+ },
+
+ /** 澧為噺鎷夊彇浼氳瘽璇讳綅缃� */
+ async pullConversationReads(isActive?: () => boolean): Promise<void> {
+ await runIncrementalPull(
+ StorageKeys.settings.conversationReadPullCursor,
+ apiPullMyConversationReadList,
+ async (records) => {
+ if (isActive && !isActive()) {
+ return false
+ }
+ await this.applyConversationReadList(records, isActive)
+ if (isActive && !isActive()) {
+ return false
+ }
+ return true
+ },
+ isActive
+ )
+ },
+
+ /** 鎵ц浼氳瘽璁板綍鎸佷箙鍖� */
+ async saveConversationRecord(
+ target: Conversation | Conversation[] | null | undefined,
+ tx?: DbTransaction
+ ): Promise<void> {
+ const db = getDb()
+ const conversations = (Array.isArray(target) ? target : (target ? [target] : [])).map(
+ (conversation) => toConversationDO(conversation)
+ )
+ if (conversations.length === 0) {
+ return
+ }
+ if (tx) {
+ for (const conversation of conversations) {
+ await db.put('conversations', conversation, tx)
+ }
+ return
+ }
+ await db.transaction(['conversations'], 'readwrite', async (tx) => {
+ for (const conversation of conversations) {
+ await db.put('conversations', conversation, tx)
+ }
+ })
+ },
+
+ /** 鎸佷箙鍖栧崟涓細璇� */
+ saveConversation(conversation: Conversation | null | undefined, tx?: DbTransaction): void {
+ if (!conversation) {
+ return
+ }
+ void this.saveConversationRecord(conversation, tx).catch((error) =>
+ console.warn('[IM conversationStore] 浼氳瘽鍐欏叆澶辫触', error)
+ )
+ },
+
+ /** 鎸佷箙鍖栦細璇濆垪琛� */
+ saveConversationList(conversations?: Conversation[] | null, tx?: DbTransaction): void {
+ if (this.loading && !tx) {
+ return
+ }
+ void this.saveConversationRecord(conversations || this.conversations, tx).catch((error) =>
+ console.warn('[IM conversationStore] 浼氳瘽鍐欏叆澶辫触', error)
+ )
+ },
+
+ /** 纭繚浼氳瘽瀛樺湪 */
+ ensureConversation(info: {
+ avatar: string
+ name: string
+ silent?: boolean
+ targetId: number
+ type: number
+ }): Conversation {
+ // 1. 鍒涘缓涓嶅瓨鍦ㄧ殑浼氳瘽
+ let conversation = this.getConversation(info.type, info.targetId)
+ if (!conversation) {
+ conversation = this.createEmptyConversation(
+ info.type,
+ info.targetId,
+ info.name,
+ info.avatar,
+ info.silent
+ )
+ this.conversations.unshift(conversation)
+ } else if (conversation.deleted) {
+ // 2. 鎭㈠杞垹闄や細璇�
+ conversation.deleted = false
+ conversation.name = info.name || conversation.name
+ conversation.avatar = info.avatar || conversation.avatar
+ if (info.silent !== undefined) {
+ conversation.silent = info.silent
+ }
+ } else {
+ // 3. 鍚屾浼氳瘽灞曠ず鍏冩暟鎹�
+ if (info.name) {
+ conversation.name = info.name
+ }
+ if (info.avatar) {
+ conversation.avatar = info.avatar
+ }
+ if (info.silent !== undefined) {
+ conversation.silent = info.silent
+ }
+ }
+ return conversation
+ },
+
+ /** 鎵撳紑鎴栧垱寤轰細璇� */
+ openConversation(
+ targetId: number,
+ type: number,
+ name: string,
+ avatar: string,
+ options?: { silent?: boolean }
+ ): Conversation {
+ // 1. 纭繚浼氳瘽鍦ㄥ垪琛ㄤ腑
+ const conversation = this.ensureConversation({
+ type,
+ targetId,
+ name,
+ avatar,
+ silent: options?.silent
+ })
+ // 2. 婵�娲讳細璇濆苟淇濆瓨
+ this.setActiveConversation(conversation)
+ this.saveConversation(conversation)
+ return conversation
+ },
+
+ /** 璁剧疆褰撳墠浼氳瘽 */
+ setActiveConversation(conversation: Conversation | null) {
+ this.activeConversation = conversation
+ if (!conversation) {
+ return
+ }
+ // 鎳掑姞杞芥秷鎭苟淇濆瓨浼氳瘽鎽樿
+ void useMessageStore().ensureConversationMessageListLoaded(conversation)
+ this.saveConversation(conversation)
+ },
+
+ /** 鍒涘缓绌轰細璇� */
+ createEmptyConversation(
+ type: number,
+ targetId: number,
+ name: string,
+ avatar: string,
+ silent = false
+ ): Conversation {
+ return {
+ targetId,
+ type,
+ name,
+ avatar,
+ lastContent: '',
+ lastSendTime: 0,
+ unreadCount: 0,
+ deleted: false,
+ top: false,
+ silent,
+ atMe: false,
+ atAll: false
+ }
+ },
+
+ /** 璁剧疆缃《 */
+ setConversationTop(type: number, targetId: number, top: boolean) {
+ const conversation = this.getConversation(type, targetId)
+ if (!conversation) {
+ return
+ }
+ conversation.top = top
+ this.saveConversation(conversation)
+ },
+
+ /** 璁剧疆鍏嶆墦鎵� */
+ setConversationSilent(type: number, targetId: number, silent: boolean) {
+ const conversation = this.getConversation(type, targetId)
+ if (!conversation) {
+ return
+ }
+ conversation.silent = silent
+ this.saveConversation(conversation)
+ },
+
+ /** 鍒犻櫎浼氳瘽 */
+ removeConversation(type: number, targetId: number) {
+ // 1. 鏍囪浼氳瘽鍒犻櫎
+ const conversation = this.getConversation(type, targetId)
+ if (!conversation) {
+ return
+ }
+ if (this.activeConversation === conversation) {
+ this.activeConversation = null
+ }
+ conversation.deleted = true
+ // 2. 鍒犻櫎浼氳瘽鍏宠仈鐨勬秷鎭拰鑽夌
+ useMessageStore().deleteConversationMessageList(type, targetId)
+ this.clearConversationDraft(conversation)
+ this.saveConversation(conversation)
+ },
+
+ /** 鍒犻櫎绉佽亰浼氳瘽 */
+ removePrivateConversation(friendId: number) {
+ this.removeConversation(ImConversationType.PRIVATE, friendId)
+ },
+
+ /** 鍒犻櫎缇よ亰浼氳瘽 */
+ removeGroupConversation(groupId: number) {
+ this.removeConversation(ImConversationType.GROUP, groupId)
+ },
+
+ /** 鏍囪浼氳瘽宸茶 */
+ markConversationRead(type: number, targetId: number, messageId?: number): void {
+ const conversation = this.getConversation(type, targetId)
+ if (!conversation) {
+ return
+ }
+ const key = getClientConversationId(type, targetId)
+ const current = this.conversationReads[key]
+ const readMessageIdAdvanced = !!messageId && messageId > (current?.messageId || 0)
+ if (
+ conversation.unreadCount === 0 &&
+ !conversation.atMe &&
+ !conversation.atAll &&
+ !readMessageIdAdvanced
+ ) {
+ return
+ }
+ conversation.unreadCount = 0
+ conversation.atMe = false
+ conversation.atAll = false
+ if (readMessageIdAdvanced) {
+ const record = createConversationRead(type, targetId, messageId)
+ this.conversationReads[key] = record
+ void getDb()
+ .transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
+ await this.saveConversationRecord(conversation, tx)
+ await this.saveConversationReadRecord(record, tx)
+ })
+ .catch((error) =>
+ console.warn(
+ '[IM conversationStore] 浼氳瘽宸茶鍐欏叆澶辫触',
+ {
+ conversationType: type,
+ targetId,
+ messageId,
+ conversationKey: key
+ },
+ error
+ )
+ )
+ return
+ }
+ this.saveConversation(conversation)
+ },
+
+ /** 鏍囪浼氳瘽宸蹭笂鎶ユ湇鍔$璇讳綅缃� */
+ markConversationReadReported(type: number, targetId: number, messageId?: number): void {
+ if (!messageId) {
+ return
+ }
+ const conversation = this.getConversation(type, targetId)
+ if (!conversation || messageId <= (conversation.reportedReadMessageId || 0)) {
+ return
+ }
+ conversation.reportedReadMessageId = messageId
+ this.saveConversation(conversation)
+ },
+
+ // ==================== 鏈�杩戣浆鍙� ====================
+
+ /** 鎺ㄩ�佹渶杩戣浆鍙戜細璇� */
+ pushRecentForwardConversationKeyList(keys: string[]) {
+ if (!keys || keys.length === 0) {
+ return
+ }
+ const merged = [...keys, ...this.recentForwardConversationKeys]
+ this.recentForwardConversationKeys = [...new Set(merged)].slice(
+ 0,
+ CONVERSATION_RECENT_FORWARD_MAX
+ )
+ this.saveRecentForwardConversationKeyList()
+ },
+
+ /** 绉婚櫎鏈�杩戣浆鍙戜細璇� */
+ removeRecentForwardConversationKey(key: string) {
+ const index = this.recentForwardConversationKeys.indexOf(key)
+ if (index === -1) {
+ return
+ }
+ this.recentForwardConversationKeys.splice(index, 1)
+ this.saveRecentForwardConversationKeyList()
+ },
+
+ /** 淇濆瓨鏈�杩戣浆鍙戜細璇� */
+ saveRecentForwardConversationKeyList() {
+ void getDb()
+ .setSetting(
+ StorageKeys.settings.recentForwardConversationKeys,
+ this.recentForwardConversationKeys.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
+ )
+ .catch((error) => console.warn('[IM conversationStore] 鏈�杩戣浆鍙戝垪琛ㄥ啓鍏ュけ璐�', error))
+ },
+
+ // ==================== 浼氳瘽缁存姢 ====================
+
+ /** 閲嶆帓浼氳瘽 */
+ sortConversationList() {
+ this.conversations.sort((a, b) => (b.lastSendTime || 0) - (a.lastSendTime || 0))
+ this.saveConversationList(this.conversations)
+ },
+
+ /** 鍚屾浼氳瘽灞曠ず鍏冩暟鎹� */
+ updateConversation(
+ type: number,
+ targetId: number,
+ info: { avatar?: string; name?: string; silent?: boolean }
+ ) {
+ const conversation = this.getConversation(type, targetId)
+ if (!conversation) {
+ return
+ }
+ let changed = false
+ if (info.name && conversation.name !== info.name) {
+ conversation.name = info.name
+ changed = true
+ }
+ if (info.avatar !== undefined && conversation.avatar !== info.avatar) {
+ conversation.avatar = info.avatar || ''
+ changed = true
+ }
+ if (info.silent !== undefined && conversation.silent !== info.silent) {
+ conversation.silent = info.silent
+ changed = true
+ }
+ if (changed) {
+ this.saveConversation(conversation)
+ }
+ },
+
+ // ==================== 鑽夌 ====================
+
+ /** 鑾峰彇鑽夌 */
+ getConversationDraft(conversation: { targetId: number; type: number; }): Conversation['draft'] | undefined {
+ return this.getConversation(conversation.type, conversation.targetId)?.draft
+ },
+
+ /** 璁剧疆鑽夌 */
+ setConversationDraft(
+ conversation: { targetId: number; type: number; },
+ snapshot: NonNullable<Conversation['draft']>
+ ): void {
+ if (!snapshot.plain.trim() && !snapshot.reply) {
+ this.clearConversationDraft(conversation)
+ return
+ }
+ const target = this.getConversation(conversation.type, conversation.targetId)
+ if (!target) {
+ return
+ }
+ target.draft = snapshot
+ this.scheduleConversationDraftSave(target)
+ },
+
+ /** 娓呴櫎鑽夌 */
+ clearConversationDraft(conversation: { targetId: number; type: number; }): void {
+ const target = this.getConversation(conversation.type, conversation.targetId)
+ if (!target?.draft) {
+ return
+ }
+ target.draft = undefined
+ this.scheduleConversationDraftSave(target)
+ },
+
+ /** 璁剧疆鍥炲鑽夌 */
+ setConversationReplyDraft(
+ conversation: { targetId: number; type: number; },
+ quote: NonNullable<Conversation['draft']>['reply']
+ ) {
+ if (!quote) {
+ return
+ }
+ const existing = this.getConversationDraft(conversation)
+ this.setConversationDraft(conversation, {
+ html: existing?.html ?? '',
+ plain: existing?.plain ?? '',
+ reply: quote
+ })
+ },
+
+ /** 娓呴櫎鍥炲鑽夌 */
+ clearConversationReplyDraft(conversation: { targetId: number; type: number; }): void {
+ const existing = this.getConversationDraft(conversation)
+ if (!existing?.reply) {
+ return
+ }
+ this.setConversationDraft(conversation, { ...existing, reply: undefined })
+ },
+
+ /** 璋冨害鑽夌淇濆瓨 */
+ scheduleConversationDraftSave(conversation: Conversation): void {
+ pendingDraftConversations.add(conversation)
+ saveDraftConversationListDebounced()
+ },
+
+ /** 绔嬪嵆淇濆瓨鑽夌 */
+ flushConversationDraftSave(): void {
+ saveDraftConversationListDebounced.flush()
+ }
+ }
+})
+
+export const useConversationStoreWithOut = () => useConversationStore()
+
+/** 鍚堝苟鑽夌鍐欏叆 */
+const saveDraftConversationListDebounced = createDraftDebounce(() => {
+ const conversations = [...pendingDraftConversations]
+ pendingDraftConversations.clear()
+ if (conversations.length === 0) {
+ return
+ }
+ void useConversationStoreWithOut()
+ .saveConversationRecord(conversations)
+ .catch((error) => console.warn('[IM conversationStore] 鑽夌鍐欏叆澶辫触', error))
+}, PERSIST_DRAFT_DEBOUNCE_MS)
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useConversationStore, import.meta.hot))
+}
diff --git a/src/views/im/home/store/faceStore.ts b/src/views/im/home/store/faceStore.ts
new file mode 100644
index 0000000..8122443
--- /dev/null
+++ b/src/views/im/home/store/faceStore.ts
@@ -0,0 +1,151 @@
+import type { ImFacePackApi } from '#/api/im/face/pack'
+import type { ImFaceUserItemApi } from '#/api/im/face/userItem'
+
+import { ref } from 'vue'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { getFacePackList as apiGetFacePackList } from '#/api/im/face/pack'
+import { createFaceUserItem as apiCreateFaceUserItem, deleteFaceUserItem as apiDeleteFaceUserItem, getFaceUserItemList as apiGetFaceUserItemList } from '#/api/im/face/userItem'
+
+/**
+ * IM 琛ㄦ儏闈㈡澘鏁版嵁 store锛堢郴缁熻〃鎯呭寘 + 涓汉琛ㄦ儏锛�
+ *
+ * 涓嶆寔涔呭寲锛氭暟鎹皬銆佷綆棰戯紱姣忔杩� IM 绗竴娆℃墦寮�琛ㄦ儏闈㈡澘鏃舵寜闇�鎷夛紝鍏� tab 鍗充涪寮�
+ * - 绯荤粺鍖咃細IM 涓婚〉 onMounted 鍚庡彴棰勬媺锛堜笉闃诲棣栧睆锛夛紝娑堥櫎闈㈡澘棣栨灞曞紑鐨勭櫧灞�
+ * - 涓汉琛ㄦ儏锛氬垏鍒般�屾敹钘忋�峵ab / 闀挎寜娑堟伅銆屾坊鍔犲埌琛ㄦ儏銆嶆椂鎸夐渶鎷�
+ */
+export const useFaceStore = defineStore('imFace', () => {
+
+ /** 绯荤粺琛ㄦ儏鍖呭垪琛紙鍚瘡涓寘鐨� items锛夛紱杩愯惀绠$悊鍚庡彴缁存姢 */
+ const facePacks = ref<ImFacePackApi.FacePackUser[]>([])
+ /** 涓汉琛ㄦ儏鍖呭垪琛紙鐢ㄦ埛闀挎寜銆屾坊鍔犲埌琛ㄦ儏銆�/ 涓婁紶浜х敓锛� */
+ const faceUserItems = ref<ImFaceUserItemApi.FaceUserItem[]>([])
+
+ /** clear() 鏃堕�掑锛涙棫璐﹀彿璇锋眰杩斿洖鍚庝笉鍐欏叆鏂拌处鍙峰唴瀛� */
+ let storeEpoch = 0
+
+ /**
+ * 绯荤粺琛ㄦ儏鍖呮媺鍙� promise锛沞nsureFacePackList 鍐� cache锛�
+ * - null = 杩樻病鎷夎繃锛屼笅娆¤皟鐢ㄧ湡鍙戣姹�
+ * - resolve 鍚庝繚鐣欏璞� = 鍚庣画璋冪敤 await 绔嬪嵆杩斿洖锛屼笉鍐嶅彂璇锋眰
+ * - reject 鍚庣疆鍥� null锛岃璋冪敤鏂逛笅娆¢噸璇�
+ */
+ let facePacksPromise: null | Promise<void> = null
+ /** 鎸夐渶鎷夊彇绯荤粺琛ㄦ儏鍖咃紙宸叉媺杩囧垯鐩存帴澶嶇敤 cached promise锛� */
+ async function ensureFacePackList(): Promise<void> {
+ if (!facePacksPromise) {
+ const requestEpoch = storeEpoch
+ facePacksPromise = apiGetFacePackList()
+ .then((data) => {
+ if (requestEpoch !== storeEpoch) {
+ return
+ }
+ facePacks.value = data || []
+ })
+ .catch((error) => {
+ console.warn('[IM] 鎷夊彇琛ㄦ儏鍖呭け璐�', error)
+ if (requestEpoch === storeEpoch) {
+ facePacksPromise = null
+ }
+ throw error
+ })
+ }
+ return facePacksPromise
+ }
+
+ /** 涓汉琛ㄦ儏鎷夊彇 promise锛涜涔夊悓涓� */
+ let faceUserItemsPromise: null | Promise<void> = null
+ /** 鎸夐渶鎷夊彇涓汉琛ㄦ儏锛堝凡鎷夎繃鍒欑洿鎺ュ鐢� cached promise锛� */
+ async function ensureFaceUserItemList(): Promise<void> {
+ if (!faceUserItemsPromise) {
+ const requestEpoch = storeEpoch
+ faceUserItemsPromise = apiGetFaceUserItemList()
+ .then((data) => {
+ if (requestEpoch !== storeEpoch) {
+ return
+ }
+ faceUserItems.value = data || []
+ })
+ .catch((error) => {
+ console.warn('[IM] 鎷夊彇涓汉琛ㄦ儏澶辫触', error)
+ if (requestEpoch === storeEpoch) {
+ faceUserItemsPromise = null
+ }
+ throw error
+ })
+ }
+ return faceUserItemsPromise
+ }
+
+ /**
+ * 娣诲姞涓汉琛ㄦ儏锛涙湇鍔$瀵瑰悓 URL 鎶� FACE_USER_ITEM_DUPLICATED 閿欒
+ *
+ * 鏉ユ簮锛�1. 鐢ㄦ埛鍦ㄨ〃鎯呴潰鏉裤��+銆嶄笂浼犲浘鐗� 2. 闀挎寜娑堟伅銆屾坊鍔犲埌琛ㄦ儏銆�
+ */
+ async function addFaceUserItem(reqVO: ImFaceUserItemApi.FaceUserItemSaveReqVO): Promise<boolean> {
+ const requestEpoch = storeEpoch
+ const id = await apiCreateFaceUserItem(reqVO)
+ if (!id) {
+ return false
+ }
+ // 宸插垏璐﹀彿鏃惰烦杩囨棫璇锋眰缁撴灉
+ if (requestEpoch !== storeEpoch) {
+ return false
+ }
+ // id 涓嶅湪缂撳瓨閲屾墠鎻掑叆锛涙湇鍔$鍞竴绾︽潫鍏滃簳浜� race锛屾湰鍦扮悊璁轰笂涓嶄細鎷垮埌閲嶅 id
+ if (!faceUserItems.value.some((item) => item.id === id)) {
+ faceUserItems.value.unshift({
+ id,
+ url: reqVO.url,
+ name: reqVO.name,
+ width: reqVO.width,
+ height: reqVO.height
+ })
+ }
+ return true
+ }
+
+ /** 鍒犻櫎涓汉琛ㄦ儏锛涙湰鍦扮珛鍗崇Щ闄� */
+ async function removeFaceUserItem(id: number): Promise<boolean> {
+ const requestEpoch = storeEpoch
+ try {
+ await apiDeleteFaceUserItem(id)
+ // 宸插垏璐﹀彿鏃惰烦杩囨棫璇锋眰缁撴灉
+ if (requestEpoch !== storeEpoch) {
+ return false
+ }
+ faceUserItems.value = faceUserItems.value.filter((item) => item.id !== id)
+ return true
+ } catch (error) {
+ console.warn('[IM] 鍒犻櫎涓汉琛ㄦ儏澶辫触', { id }, error)
+ return false
+ }
+ }
+
+ /** 娓呯┖琛ㄦ儏缂撳瓨 */
+ function clear(): void {
+ facePacks.value = []
+ faceUserItems.value = []
+ facePacksPromise = null
+ faceUserItemsPromise = null
+ storeEpoch++
+ }
+
+ return {
+ facePacks,
+ faceUserItems,
+ ensureFacePackList,
+ ensureFaceUserItemList,
+ addFaceUserItem,
+ removeFaceUserItem,
+ clear
+ }
+})
+
+/** 鍦� setup 澶栵紙璺敱瀹堝崼绛夛級鍙� store 瀹炰緥鐨勫伐鍏锋柟娉� */
+export const useFaceStoreWithOut = () => useFaceStore()
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useFaceStore, import.meta.hot))
+}
diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts
new file mode 100644
index 0000000..fbb9ed5
--- /dev/null
+++ b/src/views/im/home/store/friendStore.ts
@@ -0,0 +1,849 @@
+import type { Friend, FriendLite, FriendRequest } from '../types'
+
+import type { ImFriendApi } from '#/api/im/friend'
+import type { ImFriendRequestApi } from '#/api/im/friend/request'
+
+import { CommonStatusEnum } from '#/packages/constants/src'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { blockFriend as apiBlockFriend, deleteFriend as apiDeleteFriend, getFriend as apiGetFriend, getMyFriendList as apiGetMyFriendList, pullMyFriendList as apiPullMyFriendList, unblockFriend as apiUnblockFriend, updateFriend as apiUpdateFriend } from '#/api/im/friend'
+import { agreeFriendRequest as apiAgreeFriendRequest, applyFriendRequest as apiApplyFriendRequest, getMyFriendRequest as apiGetMyFriendRequest, getMyFriendRequestList as apiGetMyFriendRequestList, pullMyFriendRequestList as apiPullMyFriendRequestList, refuseFriendRequest as apiRefuseFriendRequest } from '#/api/im/friend/request'
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import { FRIEND_REQUEST_PAGE_SIZE } from '../../utils/config'
+import { ImConversationType, ImFriendRequestHandleResult } from '../../utils/constants'
+import { getDb, StorageKeys } from '../../utils/db'
+import { runIncrementalPull } from '../../utils/pull'
+import { getFriendDisplayName } from '../../utils/user'
+import { useConversationStore } from './conversationStore'
+
+type PendingRequest = { epoch: number; promise: Promise<void>; userId: number; }
+
+/** 褰撳墠姝e湪杩涜鐨勫ソ鍙嬪垪琛ㄦ媺鍙栵紱澶� dispatcher 鍚屾椂瑙﹀彂鏃跺鐢ㄥ悓涓� Promise锛岄伩鍏嶉洩宕╅噸鎷� */
+let pendingFetchFriends: null | PendingRequest = null
+/** 褰撳墠姝e湪杩涜鐨勫ソ鍙嬬敵璇峰垪琛ㄦ媺鍙栵紱澶氱杩炵画澶氭潯鐢宠鍒拌揪鏃跺鐢ㄥ悓涓� Promise锛岄伩鍏嶉洩宕╅噸鎷� */
+let pendingFetchRequests: null | PendingRequest = null
+/** 褰撳墠姝e湪杩涜鐨勩�屽姞杞芥洿澶氱敵璇枫�嶈姹� */
+let pendingLoadMoreRequests: null | PendingRequest = null
+/** 褰撳墠姝e湪杩涜鐨勫ソ鍙嬭鎯呰姹� */
+const pendingFetchFriendInfos = new Map<string, Promise<void>>()
+
+/** clear() 鏃堕�掑锛涙棫璐﹀彿閭f杩樻病杩斿洖鐨勮姹� resolve 鍚庢瘮瀵逛竴鑷存墠鍐� store锛岄槻璺ㄨ处鍙锋暟鎹硠婕� */
+let storeEpoch = 0
+
+/** 鏋勫缓濂藉弸璇︽儏璇锋眰鍘婚噸 key */
+function getPendingFriendInfoKey(userId: number, friendUserId: number): string {
+ return `${userId}:${friendUserId}`
+}
+
+/** 濂藉弸閫氱煡 payload锛堝榻愬悗绔� BaseFriendNotification + 瀛愮被瑁佸噺鍚庣殑瀛楁锛� */
+export interface FriendNotificationPayload {
+ operatorUserId: number
+ friendUserId: number
+ // FRIEND_REQUEST_* 绯诲垪锛氱敵璇疯褰曠殑鏍稿績瀛楁锛堥伩鍏� payload 鎼哄甫瀹屾暣 DO锛�
+ requestId?: number
+ applyContent?: string
+ handleContent?: string
+ addSource?: number
+ // FRIEND_REQUEST_RECEIVED锛氱敵璇锋柟鑱氬悎瀛楁锛屼緵鍓嶇鐩存帹 push 杩涘垪琛紝鏃犻渶鍥炴媺
+ fromNickname?: string
+ fromAvatar?: string
+ // FRIEND_UPDATE锛氬崟杈瑰睘鎬у彉鏇�
+ displayName?: string
+ silent?: boolean
+ pinned?: boolean
+ // FRIEND_DELETE锛氭槸鍚︾骇鑱旀竻鐞嗘湰绔浉鍏虫暟鎹紙濡傜鑱婁細璇濓級
+ clear?: boolean
+}
+
+/**
+ * IM 濂藉弸 Store
+ *
+ * 璐熻矗锛�
+ * - 鎷夊彇 / 缂撳瓨褰撳墠鐧诲綍鐢ㄦ埛鐨勫ソ鍙嬪垪琛� + 鐢宠鍒楄〃
+ * - 鐢宠-瀹℃壒娴佺▼锛坅pply / agree / refuse锛�+ 澶囨敞 / 鍏嶆墦鎵� / 鑱旂郴浜虹疆椤� / 鎷夐粦
+ * - 鎺ユ敹 WebSocket 1201-1210 娈典綅閫氱煡锛屾寜浜嬩欢鍒嗗彂鍒� friendStore 鍐呴儴鍚� dispatcher
+ */
+export const useFriendStore = defineStore('imFriendStore', {
+ state: () => ({
+ friends: [] as Friend[],
+ // 浠� fetchFriendList 鎴愬姛鍚庣疆浣嶏紱loadFriendData锛圛DB锛変笉缃綅锛屽惁鍒欏悗鍙� SWR 鍒锋柊浼氳缂撳瓨鍛戒腑璺宠繃
+ loaded: false,
+ /** 鎴戠浉鍏崇殑濂藉弸鐢宠鍒楄〃锛堝惈鎴戝彂璧风殑 + 鍒汉鍔犳垜鐨勶紱鍚庣鎸� id 鍊掑簭娓告爣鍒嗛〉锛� */
+ friendRequests: [] as FriendRequest[],
+ /** 鏄惁杩樻湁鏇存棭鐨勭敵璇疯褰曞彲鍔犺浇锛涜繑鍥炰笉婊� page size 鍗崇疆 false */
+ hasMoreFriendRequests: true
+ }),
+
+ getters: {
+ /**
+ * friendUserId 鈫� Friend 鐨� O(1) 绱㈠紩锛涗粠 friends 鏁扮粍娲剧敓锛孭inia 鍦� friends 鍙樺寲鏃惰嚜鍔ㄩ噸绠�
+ *
+ * 娑堟伅娓叉煋闇�瑕佹寜 senderId 鍙嶆煡鍙戦�佷汉澶村儚 / 澶囨敞锛屾瘡鏉℃秷鎭覆鏌撻兘浼� getFriend锛�
+ * 鐩存帴 find 鏃� N 鏉℃秷鎭� 脳 M 濂藉弸 = O(N脳M)锛涘缓绱㈠紩鍚庡崟娆¤ O(1)锛岄噸寤哄彧鍦ㄥ啓濂藉弸锛坒etchFriendList / upsertFriend 绛夛級鏃跺彂鐢�
+ */
+ getFriendMap: (state): Map<number, Friend> => {
+ const map = new Map<number, Friend>()
+ for (const friend of state.friends) {
+ map.set(friend.friendUserId, friend)
+ }
+ return map
+ },
+ /** 鎸� friendUserId 鎵惧ソ鍙嬶紙鍚凡杞垹鐨� DISABLE 璁板綍锛岃皟鐢ㄦ柟鑷鍒ゅ畾锛� */
+ getFriend(): (friendUserId: number) => Friend | undefined {
+ return (friendUserId: number) => this.getFriendMap.get(friendUserId)
+ },
+ /** 褰撳墠鐢熸晥鐨勫ソ鍙嬪垪琛紙杩囨护鎺� DISABLE 杞垹璁板綍锛� */
+ getActiveFriendList: (state): Friend[] => {
+ return state.friends.filter((friend) => friend.status !== CommonStatusEnum.DISABLE)
+ },
+ /** 褰撳墠鐢熸晥濂藉弸鐨� Lite 瑙嗗浘锛圥ickerPanel / 閫変汉寮圭獥鍏辩敤锛岃嚜甯︽嫾闊冲瓧娈典緵鍒嗘《 / 鎼滅储锛� */
+ getActiveFriendLiteList(): FriendLite[] {
+ return this.getActiveFriendList.map((friend: Friend) => ({
+ id: friend.friendUserId,
+ nickname: friend.nickname,
+ nicknamePinyin: friend.nicknamePinyin,
+ avatar: friend.avatar,
+ displayName: friend.displayName,
+ displayNamePinyin: friend.displayNamePinyin
+ }))
+ },
+ /** 鍒ゆ柇瀵规柟鏄惁鏄綋鍓嶇敤鎴风殑鏈夋晥濂藉弸锛堝瓨鍦� + 闈� DISABLE锛� */
+ isActiveFriend() {
+ return (friendUserId: number): boolean => {
+ const entry = this.getFriend(friendUserId)
+ return !!entry && entry.status !== CommonStatusEnum.DISABLE
+ }
+ },
+ /** 鎴戠殑榛戝悕鍗曪紙blocked=true 涓� ENABLE锛� */
+ getBlockedFriendList: (state): Friend[] => {
+ return state.friends.filter(
+ (friend) => friend.status !== CommonStatusEnum.DISABLE && friend.blocked === true
+ )
+ },
+ /** 鏈鐞嗙敵璇锋暟锛堟帴鏀舵柟=鎴戯級鈥斺�� 瀹炴椂娲剧敓锛屻�屾柊鐨勬湅鍙嬨�嶇孩鐐圭敤 */
+ getUnhandledRequestCount: (state): number => {
+ const currentUserId = getCurrentUserId()
+ return state.friendRequests.filter(
+ (request) =>
+ request.handleResult === ImFriendRequestHandleResult.UNHANDLED &&
+ request.toUserId === currentUserId
+ ).length
+ }
+ },
+
+ actions: {
+ // ==================== 鏈湴缂撳瓨 ====================
+
+ /** 浠� IndexedDB 鎭㈠濂藉弸鍜屽ソ鍙嬬敵璇� */
+ async loadFriendData(): Promise<boolean> {
+ try {
+ const [friends, friendRequests] = await Promise.all([
+ getDb().getAll<Friend>('friends'),
+ getDb().getAll<FriendRequest>('friendRequests')
+ ])
+ if (friends.length > 0) {
+ this.friends = friends
+ }
+ if (friendRequests.length > 0) {
+ this.friendRequests = friendRequests.toSorted(
+ (requestA, requestB) => requestB.id - requestA.id
+ )
+ this.hasMoreFriendRequests = friendRequests.length >= FRIEND_REQUEST_PAGE_SIZE
+ }
+ return friends.length > 0
+ } catch (error) {
+ console.warn('[IM friendStore] 鏈湴濂藉弸缂撳瓨璇诲彇澶辫触', error)
+ return false
+ }
+ },
+
+ /** 淇濆瓨濂藉弸鍒楄〃 */
+ saveFriendList(): void {
+ void getDb()
+ .transaction(['friends'], 'readwrite', async (tx) => {
+ const db = getDb()
+ await db.clearStore('friends', tx)
+ for (const friend of this.friends) {
+ if (friend.id) {
+ await db.put('friends', friend, tx)
+ }
+ }
+ })
+ .catch((error) => console.warn('[IM friendStore] 鏈湴濂藉弸缂撳瓨鍐欏叆澶辫触', error))
+ },
+
+ /** 淇濆瓨鍗曚釜濂藉弸 */
+ async saveFriendRecord(friend: Friend | undefined): Promise<void> {
+ if (!friend?.id) {
+ return
+ }
+ await getDb().put('friends', friend)
+ },
+
+ /** 淇濆瓨鍗曚釜濂藉弸 */
+ saveFriend(friend: Friend | undefined): void {
+ void this.saveFriendRecord(friend).catch((error) =>
+ console.warn('[IM friendStore] 鏈湴濂藉弸鍐欏叆澶辫触', error)
+ )
+ },
+
+ /** 淇濆瓨濂藉弸鐢宠鍒楄〃 */
+ saveFriendRequestList(): void {
+ void getDb()
+ .transaction(['friendRequests'], 'readwrite', async (tx) => {
+ const db = getDb()
+ await db.clearStore('friendRequests', tx)
+ for (const request of this.friendRequests) {
+ await db.put('friendRequests', request, tx)
+ }
+ })
+ .catch((error) => console.warn('[IM friendStore] 鏈湴濂藉弸鐢宠缂撳瓨鍐欏叆澶辫触', error))
+ },
+
+ /** 淇濆瓨鍗曟潯濂藉弸鐢宠 */
+ async saveFriendRequestRecord(request: FriendRequest | undefined): Promise<void> {
+ if (!request) {
+ return
+ }
+ await getDb().put('friendRequests', request)
+ },
+
+ /** 淇濆瓨鍗曟潯濂藉弸鐢宠 */
+ saveFriendRequest(request: FriendRequest | undefined): void {
+ void this.saveFriendRequestRecord(request).catch((error) =>
+ console.warn('[IM friendStore] 鏈湴濂藉弸鐢宠鍐欏叆澶辫触', error)
+ )
+ },
+
+ // ==================== 杩滅鎷夊彇 ====================
+
+ /** 浠庡悗绔媺鍙栧苟瑕嗙洊鏈湴鍒楄〃锛堝惈 DISABLE 鍘嗗彶濂藉弸缁欏凡鍒犲璇濆厹搴曪級锛涘彧鍚屾 ENABLE 鐨勪細璇濅俊鎭紝DISABLE 鐨勪笉鍔� 鈥斺�� cascade 娓呬細璇濈敱 WS dispatcher 鎸� payload.clear 澶勭悊锛岄伩鍏� fetchFriendList 瑕嗙洊鐢ㄦ埛銆屼笉娓呯┖鑱婂ぉ璁板綍銆嶇殑閫夋嫨 */
+ async fetchFriendList(force = false) {
+ if (this.loaded && !force) {
+ return
+ }
+ // 蹇収 epoch锛沜lear() 涔嬪悗鍒� .then 涔嬮棿瑙﹀彂鐨� epoch++ 琛ㄧず璐﹀彿宸插垏锛屾棫缁撴灉涓嶈兘鍐欏叆鏂� store
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ if (
+ pendingFetchFriends?.epoch === requestEpoch &&
+ pendingFetchFriends.userId === requestUserId
+ ) {
+ return pendingFetchFriends.promise
+ }
+ const promise = apiGetMyFriendList()
+ .then((list) => {
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ this.friends = (list || []).map((friend) => convertFriend(friend))
+ this.loaded = true
+ const conversationStore = useConversationStore()
+ for (const friend of this.friends) {
+ if (friend.status === CommonStatusEnum.DISABLE) {
+ continue
+ }
+ conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
+ name: getFriendDisplayName(friend),
+ avatar: friend.avatar,
+ silent: friend.silent
+ })
+ }
+ this.saveFriendList()
+ })
+ .finally(() => {
+ if (
+ pendingFetchFriends?.epoch === requestEpoch &&
+ pendingFetchFriends.userId === requestUserId
+ ) {
+ pendingFetchFriends = null
+ }
+ })
+ pendingFetchFriends = { epoch: requestEpoch, userId: requestUserId, promise }
+ return promise
+ },
+
+ /**
+ * 澧為噺鎷夊彇濂藉弸鍙樻洿骞跺悎骞讹細杩涘叆 IM 棣栧睆涓绘暟鎹仮澶嶏紙绌烘父鏍� = 棣栨鍏ㄩ噺澧為噺鎷夛級+ 閲嶈繛 / 绂荤嚎琛ュ伩
+ *
+ * 鍚凡鍒犻櫎濂藉弸锛屾寜 status 璧� upsert
+ */
+ async pullFriends() {
+ // 蹇収 epoch锛涜处鍙峰湪鎷夊彇閫斾腑鍒囨崲锛坈lear() 鈫� epoch++锛夋椂涓㈠純鏃ц处鍙烽偅鍑犻〉缁撴灉锛岄槻璺ㄨ处鍙锋暟鎹硠婕�
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ const isActive = () => requestEpoch === storeEpoch && getCurrentUserId() === requestUserId
+ await runIncrementalPull(
+ StorageKeys.settings.friendPullCursor,
+ apiPullMyFriendList,
+ async (records) => {
+ if (!isActive()) {
+ return false
+ }
+ await Promise.all(records.map((vo) => this.upsertFriendForPull(convertFriend(vo))))
+ return true
+ },
+ isActive
+ )
+ // 缃� loaded锛屼緵閫氳褰曢〉 fetchFriendList(force=false) 澶嶇敤缂撳瓨鑰岄潪閲嶅鍏ㄩ噺鎷�
+ if (isActive()) {
+ this.loaded = true
+ }
+ },
+
+ /** 鎸� friendUserId 鑾峰彇璇︽儏骞跺悎骞跺埌鏈湴锛堜繚璇� nickname / avatar 鏈�鏂帮級 */
+ async fetchFriendInfo(friendUserId: number) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ if (!requestUserId) {
+ return
+ }
+ const key = getPendingFriendInfoKey(requestUserId, friendUserId)
+ const inflight = pendingFetchFriendInfos.get(key)
+ if (inflight) {
+ return inflight
+ }
+ const promise = (async () => {
+ try {
+ const data = await apiGetFriend(friendUserId)
+ if (!data) {
+ return
+ }
+ // clear() 宸插垏璐﹀彿锛氭棫璇锋眰鐨勫ソ鍙嬭鎯呬笉鑳藉啀 upsert 杩涙柊璐﹀彿鐨� friends
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ this.upsertFriend(convertFriend(data))
+ } catch (error) {
+ console.warn('[IM friendStore] fetchFriendInfo 澶辫触', error)
+ }
+ })().finally(() => {
+ if (pendingFetchFriendInfos.get(key) === promise) {
+ pendingFetchFriendInfos.delete(key)
+ }
+ })
+ pendingFetchFriendInfos.set(key, promise)
+ return promise
+ },
+
+ // ==================== 鐢宠-瀹℃壒 ====================
+
+ /** 鍙戣捣濂藉弸鐢宠锛氭垚鍔熷悗绛夊緟瀵规柟鍚屾剰锛堜笉鐩存帴钀藉湴涓哄ソ鍙嬶級 */
+ async applyFriendRequest(reqVO: ImFriendRequestApi.FriendRequestApplyReqVO): Promise<null | number> {
+ return await apiApplyFriendRequest(reqVO)
+ },
+
+ /** 鍚屾剰涓�鏉″ソ鍙嬬敵璇凤紱鍚庣浼氬弻鍚戣惤搴� + 鎺� FRIEND_ADD锛屾湰绔瓑閫氱煡鍒拌揪鍐� upsertFriend */
+ async agreeFriendRequest(requestId: number) {
+ await apiAgreeFriendRequest(requestId)
+ await this.applyHandleResult(requestId, ImFriendRequestHandleResult.AGREED)
+ },
+
+ /** 鎷掔粷涓�鏉″ソ鍙嬬敵璇� */
+ async refuseFriendRequest(requestId: number, handleContent?: string) {
+ await apiRefuseFriendRequest(requestId, handleContent)
+ await this.applyHandleResult(requestId, ImFriendRequestHandleResult.REFUSED, handleContent)
+ },
+
+ /** 鎶� handleResult 搴旂敤鍒版湰鍦扮敵璇疯褰曪紱鎵句笉鍒板氨鎸� id 鍗曟煡鍏滃簳 upsert锛岄伩鍏嶇牬鍧� id 鍊掑簭 */
+ async applyHandleResult(
+ requestId: number,
+ result: number,
+ handleContent?: string
+ ): Promise<void> {
+ const request = this.getFriendRequest(requestId)
+ if (request) {
+ request.handleResult = result
+ if (handleContent !== undefined) {
+ request.handleContent = handleContent
+ }
+ request.handleTime = Date.now()
+ this.saveFriendRequest(request)
+ return
+ }
+ await this.fetchFriendRequest(requestId)
+ },
+
+ /** 鎷夊彇銆屾垜鐩稿叧銆嶇殑濂藉弸鐢宠鍒楄〃棣栭〉锛堥〉闈㈡墦寮� / 鏀跺埌 FRIEND_REQUEST_RECEIVED 鏃跺埛鏂帮級锛沺ending 鏈熼棿澶嶇敤鍚屼竴 Promise */
+ async fetchFriendRequestList() {
+ if (pendingFetchRequests) {
+ const currentUserId = getCurrentUserId()
+ if (
+ pendingFetchRequests.epoch === storeEpoch &&
+ pendingFetchRequests.userId === currentUserId
+ ) {
+ return pendingFetchRequests.promise
+ }
+ }
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ const promise = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE)
+ .then((list) => {
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ const items = (list || []).map((request) => convertFriendRequest(request))
+ this.friendRequests = items
+ // 涓嶈冻涓�椤靛嵆娌℃湁鏇村锛涙弧椤靛彲鑳借繕鏈夛紝绛� loadMore 鎷夊埌 0 鏉″啀纭畾
+ this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
+ this.saveFriendRequestList()
+ })
+ .finally(() => {
+ if (
+ pendingFetchRequests?.epoch === requestEpoch &&
+ pendingFetchRequests.userId === requestUserId
+ ) {
+ pendingFetchRequests = null
+ }
+ })
+ pendingFetchRequests = { epoch: requestEpoch, userId: requestUserId, promise }
+ return promise
+ },
+
+ /** 鍔犺浇鏇村鐢宠锛堟寜鏈湴鏈�鏃� requestId 娓告爣鍒嗛〉锛夛紱鏃犳洿澶� / pending 涓洿鎺ヨ繑鍥� */
+ async loadMoreFriendRequestList() {
+ const requestUserId = getCurrentUserId()
+ const hasSameFetchPending =
+ pendingFetchRequests?.epoch === storeEpoch && pendingFetchRequests.userId === requestUserId
+ if (!this.hasMoreFriendRequests || hasSameFetchPending) {
+ return
+ }
+ if (
+ pendingLoadMoreRequests?.epoch === storeEpoch &&
+ pendingLoadMoreRequests.userId === requestUserId
+ ) {
+ return pendingLoadMoreRequests.promise
+ }
+ const oldest = this.friendRequests[this.friendRequests.length - 1]
+ if (!oldest) {
+ return this.fetchFriendRequestList()
+ }
+ const requestEpoch = storeEpoch
+ const promise = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE, oldest.id)
+ .then((list) => {
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ const items = (list || []).map((request) => convertFriendRequest(request))
+ this.friendRequests.push(...items)
+ this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
+ this.saveFriendRequestList()
+ })
+ .finally(() => {
+ if (
+ pendingLoadMoreRequests?.epoch === requestEpoch &&
+ pendingLoadMoreRequests.userId === requestUserId
+ ) {
+ pendingLoadMoreRequests = null
+ }
+ })
+ pendingLoadMoreRequests = { epoch: requestEpoch, userId: requestUserId, promise }
+ return promise
+ },
+
+ /** 鎸� id 鏌ョ敵璇疯褰曪紱鍒楄〃鏄寜 id 鍊掑簭鐨勫皬鍒楄〃锛孫(n) find 鍗冲彲锛屼笉鍐嶇淮鎶� Map 绱㈠紩 */
+ getFriendRequest(requestId: number): FriendRequest | undefined {
+ return this.friendRequests.find((request) => request.id === requestId)
+ },
+
+ /** 鎸� id 浠庡悗绔崟鏌ュ苟 upsert 鍒版湰鍦帮紙dispatcher 鍏滃簳鐢紝閬垮厤鍏ㄩ噺閲嶆媺锛夛紱鍚庣甯﹁秺鏉冭繃婊� */
+ async fetchFriendRequest(requestId: number) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ const data = await apiGetMyFriendRequest(requestId)
+ if (!data) {
+ return
+ }
+ // clear() 宸插垏璐﹀彿锛氭棫璇锋眰鐨勭敵璇疯褰曚笉鑳藉啀鍐欒繘鏂拌处鍙风殑 friendRequests
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ this.upsertFriendRequest(convertFriendRequest(data))
+ },
+
+ /** 鍚堝苟鍗曟潯濂藉弸鐢宠锛氬凡鏈夊垯鎸� id 瑕嗙洊锛涙柊璁板綍鎸� id 鍊掑簭鎻掑叆锛堟瘮鏈湴鏈�鏃ц繕鑰佸垯璺宠繃锛岀暀缁� loadMore 甯﹀洖锛� */
+ upsertFriendRequest(next: FriendRequest) {
+ void this.upsertFriendRequestForPull(next).catch((error) =>
+ console.warn('[IM friendStore] 鏈湴濂藉弸鐢宠鍐欏叆澶辫触', error)
+ )
+ },
+
+ /** 鍚堝苟鍗曟潯濂藉弸鐢宠 */
+ async upsertFriendRequestForPull(next: FriendRequest): Promise<void> {
+ const existing = this.getFriendRequest(next.id)
+ if (existing) {
+ Object.assign(existing, next)
+ await this.saveFriendRequestRecord(existing)
+ return
+ }
+ // 姣旀湰鍦版渶鏃� id 杩樿�侊細涓嶅叆鍒楄〃锛岃 loadMore 鑷劧甯﹀洖锛岄伩鍏嶇牬鍧� id 鍊掑簭 / 鍚庣画 loadMore 閲嶅 push
+ const oldest = this.friendRequests[this.friendRequests.length - 1]
+ if (oldest && next.id < oldest.id) {
+ return
+ }
+ // 鎸� id 鍊掑簭鎵鹃涓瘮鑷繁灏忕殑浣嶇疆鎻掑叆锛涙壘涓嶅埌鍒欒拷鍔犳湯灏�
+ const insertIndex = this.friendRequests.findIndex((request) => request.id < next.id)
+ if (insertIndex === -1) {
+ this.friendRequests.push(next)
+ } else {
+ this.friendRequests.splice(insertIndex, 0, next)
+ }
+ await this.saveFriendRequestRecord(next)
+ },
+
+ /** 澧為噺鎷夊彇濂藉弸鐢宠鍙樻洿骞跺悎骞讹紙閲嶈繛 / 绂荤嚎琛ュ伩锛夛紱鎸� update_time + id 娓告爣锛屽凡澶勭悊鐨勬寜 handleResult 瑕嗙洊 */
+ async pullFriendRequests() {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ const isActive = () => requestEpoch === storeEpoch && getCurrentUserId() === requestUserId
+ await runIncrementalPull(
+ StorageKeys.settings.friendRequestPullCursor,
+ apiPullMyFriendRequestList,
+ async (records) => {
+ if (!isActive()) {
+ return false
+ }
+ await Promise.all(
+ records.map((vo) => this.upsertFriendRequestForPull(convertFriendRequest(vo)))
+ )
+ return true
+ },
+ isActive
+ )
+ },
+
+ // ==================== 濂藉弸鍏崇郴鎿嶄綔 ====================
+
+ /** 鍒犻櫎濂藉弸锛堝崟鍚戣蒋鍒狅紝鏈缃� DISABLE锛夛紱clear=true 鏃剁骇鑱旀竻鐞嗘湰鍦扮浉鍏虫暟鎹紙濡傜鑱婁細璇濓級锛屽苟閫忎紶鍚庣缁欏绔悓姝� */
+ async deleteFriend(friendUserId: number, clear: boolean = true) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ await apiDeleteFriend(friendUserId, clear)
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ this.removeFriend(friendUserId, clear)
+ },
+
+ /** 鍒囨崲鍏嶆墦鎵帮細鍚屾浼氳瘽鐨� silent 瀛楁锛岄伩鍏嶄細璇濆垪琛� silent 鍥炬爣绛� 1210 鎺ㄥ埌鎵嶆洿鏂� */
+ async setFriendSilent(friendUserId: number, silent: boolean) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ await apiUpdateFriend({ friendUserId, silent })
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ const friend = this.getFriend(friendUserId)
+ if (friend) {
+ friend.silent = silent
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, { silent })
+ this.saveFriend(friend)
+ }
+ },
+
+ /** 鍒囨崲鑱旂郴浜虹疆椤� */
+ async setFriendPinned(friendUserId: number, pinned: boolean) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ await apiUpdateFriend({ friendUserId, pinned })
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ const friend = this.getFriend(friendUserId)
+ if (friend) {
+ friend.pinned = pinned
+ this.saveFriend(friend)
+ }
+ },
+
+ /** 鎷夐粦濂藉弸锛氭湰绔箰瑙傛洿鏂� + 璋冩帴鍙o紱鍚庣 FRIEND_BLOCK 鎺ㄥ埌鏃剁敱 dispatcher 鍏滃簳鍚屾澶氱 */
+ async blockFriend(friendUserId: number) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ await apiBlockFriend(friendUserId)
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ const friend = this.getFriend(friendUserId)
+ if (friend) {
+ friend.blocked = true
+ this.saveFriend(friend)
+ }
+ },
+
+ /** 绉诲嚭榛戝悕鍗曪細鏈涔愯鏇存柊 + 璋冩帴鍙o紱鍚庣 FRIEND_UNBLOCK 鎺ㄥ埌鏃剁敱 dispatcher 鍏滃簳鍚屾澶氱 */
+ async unblockFriend(friendUserId: number) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ await apiUnblockFriend(friendUserId)
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ const friend = this.getFriend(friendUserId)
+ if (friend) {
+ friend.blocked = false
+ this.saveFriend(friend)
+ }
+ },
+
+ /** 淇敼濂藉弸灞曠ず澶囨敞锛堜粎鑷繁鍙锛� */
+ async setFriendDisplayName(friendUserId: number, displayName: string) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ const value = displayName.trim()
+ // 鍚庣 displayName 璇箟锛歯ull/undefined = 涓嶆敼锛�"" = 娓呯┖锛屾墍浠ヨ繖閲岀洿鎺ヤ紶 value锛堝彲鑳芥槸绌轰覆锛�
+ await apiUpdateFriend({ friendUserId, displayName: value })
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ const friend = this.getFriend(friendUserId)
+ if (friend) {
+ friend.displayName = value
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, {
+ name: getFriendDisplayName(friend)
+ })
+ this.saveFriend(friend)
+ }
+ },
+
+ /** 鏈湴鍚堝苟 / 鏂板鏌愪釜濂藉弸锛圵ebSocket 浜嬩欢 & 鎵嬪姩鍒锋柊閮界敤锛� */
+ upsertFriend(friend: Friend) {
+ void this.upsertFriendForPull(friend).catch((error) =>
+ console.warn('[IM friendStore] 鏈湴濂藉弸鍐欏叆澶辫触', error)
+ )
+ },
+
+ /** 鏈湴鍚堝苟 / 鏂板鏌愪釜濂藉弸 */
+ async upsertFriendForPull(friend: Friend): Promise<void> {
+ const index = this.friends.findIndex(
+ (existing) => existing.friendUserId === friend.friendUserId
+ )
+ if (index === -1) {
+ this.friends.push({
+ ...friend,
+ status: friend.status ?? CommonStatusEnum.ENABLE
+ })
+ } else {
+ this.friends[index] = {
+ ...this.friends[index],
+ ...friend,
+ status: friend.status ?? CommonStatusEnum.ENABLE
+ }
+ }
+ const conversationStore = useConversationStore()
+ const merged = this.getFriend(friend.friendUserId)
+ conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
+ name: merged ? getFriendDisplayName(merged) : friend.nickname,
+ avatar: friend.avatar,
+ silent: friend.silent
+ })
+ await this.saveFriendRecord(merged)
+ },
+
+ /** 鏈湴鏍囪鍒犻櫎锛圵ebSocket FRIEND_DELETE 浜嬩欢瑙﹀彂锛沜lear=true 鏃剁骇鑱旀竻鐩稿叧鏁版嵁濡傜鑱婁細璇濓級 */
+ removeFriend(friendUserId: number, clear: boolean = true) {
+ const friend = this.getFriend(friendUserId)
+ if (friend) {
+ // blocked 涓嶅姩锛岃窡鍚庣 deleteFriend0銆屽垹濂藉弸鏈熼棿淇濈暀鎷夐粦鐘舵�併�嶅榻�
+ friend.status = CommonStatusEnum.DISABLE
+ friend.deleteTime = Date.now()
+ }
+ if (clear) {
+ const conversationStore = useConversationStore()
+ conversationStore.removePrivateConversation(friendUserId)
+ }
+ this.saveFriend(friend)
+ },
+
+ // ==================== WebSocket 浜嬩欢 dispatcher锛�1201-1210 娈碉級 ====================
+
+ /** FRIEND_REQUEST_RECEIVED(1203)锛氭敹鍒版柊鐢宠锛沺ayload 宸插甫鐢宠鏂规樀绉� / 澶村儚锛屾寜 requestId 鐩存帹 push 杩涘垪琛� */
+ applyFriendRequestReceivedNotification(payload: FriendNotificationPayload) {
+ if (!payload.requestId) {
+ return
+ }
+ const currentUserId = getCurrentUserId()
+ const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId)
+ if (existingIndex !== -1) {
+ const existing = this.friendRequests.splice(existingIndex, 1)[0]
+ if (!existing) {
+ return
+ }
+ const next = {
+ ...existing,
+ fromUserId: payload.operatorUserId,
+ toUserId: currentUserId,
+ handleResult: ImFriendRequestHandleResult.UNHANDLED,
+ applyContent: payload.applyContent,
+ addSource: payload.addSource,
+ createTime: Date.now(),
+ fromNickname: payload.fromNickname,
+ fromAvatar: payload.fromAvatar
+ }
+ this.friendRequests.unshift(next)
+ this.saveFriendRequest(next)
+ return
+ }
+ const next = {
+ id: payload.requestId,
+ fromUserId: payload.operatorUserId,
+ toUserId: currentUserId,
+ handleResult: ImFriendRequestHandleResult.UNHANDLED,
+ applyContent: payload.applyContent,
+ addSource: payload.addSource,
+ createTime: Date.now(),
+ fromNickname: payload.fromNickname,
+ fromAvatar: payload.fromAvatar
+ }
+ this.friendRequests.unshift(next)
+ this.saveFriendRequest(next)
+ },
+
+ /** FRIEND_REQUEST_APPROVED(1201)锛氭垜鐨勭敵璇疯鍚屾剰锛涙寜 requestId 鏇存柊鐘舵�侊紙FRIEND_ADD 浼氬彟澶栨帹锛� */
+ applyFriendRequestApprovedNotification(payload: FriendNotificationPayload) {
+ if (!payload.requestId) {
+ return
+ }
+ void this.applyHandleResult(payload.requestId, ImFriendRequestHandleResult.AGREED)
+ },
+
+ /** FRIEND_REQUEST_REJECTED(1202)锛氭垜鐨勭敵璇疯鎷掔粷锛涙寜 requestId 鏇存柊鐘舵�� */
+ applyFriendRequestRejectedNotification(payload: FriendNotificationPayload) {
+ if (!payload.requestId) {
+ return
+ }
+ void this.applyHandleResult(
+ payload.requestId,
+ ImFriendRequestHandleResult.REFUSED,
+ payload.handleContent
+ )
+ },
+
+ /**
+ * FRIEND_ADD(1204)锛氭柊澧炲ソ鍙嬶紱鏈鎷夊彇濂藉弸璇︽儏骞跺叆搴�
+ * peerUserId 鐢� websocketStore 鎸夊抚 sender / receiver 绠楀ソ浼犲叆锛歜ecomeFriends 鍗曟潯鍏ュ簱鍚庡弻鏂规敹鍒板悓涓�浠� payload锛�
+ * 鏈鐪熸鐨勩�屽绔�嶆槸甯т笂鐨勫彟涓�涓敤鎴凤紝涓嶆槸 payload.friendUserId锛坧ayload 閲屽浐瀹氭槸 toUserId锛夈��
+ */
+ applyFriendAddNotification(_payload: FriendNotificationPayload, peerUserId: number) {
+ if (this.isActiveFriend(peerUserId)) {
+ return
+ }
+ void this.fetchFriendInfo(peerUserId)
+ },
+
+ /**
+ * FRIEND_DELETE(1205)锛氬ソ鍙嬭鍒犻櫎锛涙湰绔竻鐞� + 鎸� payload.clear 鍐冲畾鏄惁绾ц仈娓呬細璇濓紙澶氱璺熶富鎿嶄綔绔竴鑷达級
+ * peerUserId 鐢� websocketStore 鎸夊抚 sender / receiver 绠楀ソ浼犲叆锛涗笌 FRIEND_ADD 淇濇寔涓�鑷寸殑 peer 鎺ㄦ柇
+ */
+ applyFriendDeleteNotification(payload: FriendNotificationPayload, peerUserId: number) {
+ this.removeFriend(peerUserId, payload.clear !== false)
+ },
+
+ /** FRIEND_BLOCK(1207)锛氭媺榛戯紱澶氱鍚屾 */
+ applyFriendBlockNotification(payload: FriendNotificationPayload) {
+ const friend = this.getFriend(payload.friendUserId)
+ if (friend) {
+ friend.blocked = true
+ this.saveFriend(friend)
+ }
+ },
+
+ /** FRIEND_UNBLOCK(1208)锛氱Щ鍑洪粦鍚嶅崟锛涘绔悓姝� */
+ applyFriendUnblockNotification(payload: FriendNotificationPayload) {
+ const friend = this.getFriend(payload.friendUserId)
+ if (friend) {
+ friend.blocked = false
+ this.saveFriend(friend)
+ }
+ },
+
+ /** FRIEND_INFO_UPDATED(1209)锛氬ソ鍙嬭祫鏂欏彉鏇达紙鏄电О / 澶村儚锛夛紱閲嶆媺璇︽儏 */
+ applyFriendInfoUpdatedNotification(payload: FriendNotificationPayload) {
+ void this.fetchFriendInfo(payload.friendUserId)
+ },
+
+ /** FRIEND_UPDATE(1210)锛氭壒閲忔洿鏂帮紙澶囨敞 / 鍏嶆墦鎵� / 鑱旂郴浜虹疆椤讹級锛涘绔悓姝� */
+ applyFriendUpdateNotification(payload: FriendNotificationPayload) {
+ const friend = this.getFriend(payload.friendUserId)
+ if (!friend) {
+ return
+ }
+ if (payload.displayName != null) {
+ friend.displayName = payload.displayName
+ }
+ if (payload.silent != null) {
+ friend.silent = payload.silent
+ }
+ if (payload.pinned != null) {
+ friend.pinned = payload.pinned
+ }
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.PRIVATE, payload.friendUserId, {
+ name: getFriendDisplayName(friend),
+ silent: friend.silent
+ })
+ this.saveFriend(friend)
+ },
+
+ /** 娓呯┖濂藉弸鍐呭瓨鐘舵�侊紝骞跺簾寮冩湭杩斿洖璇锋眰锛坧ending Promise 缃┖ + storeEpoch++锛� */
+ clear() {
+ this.friends = []
+ this.friendRequests = []
+ this.loaded = false
+ this.hasMoreFriendRequests = true
+ pendingFetchFriends = null
+ pendingFetchRequests = null
+ pendingLoadMoreRequests = null
+ pendingFetchFriendInfos.clear()
+ storeEpoch++
+ }
+ }
+})
+
+function convertFriend(vo: ImFriendApi.FriendRespVO): Friend {
+ return {
+ id: vo.id,
+ friendUserId: vo.friendUserId,
+ nickname: vo.nickname || String(vo.friendUserId),
+ nicknamePinyin: vo.nicknamePinyin,
+ avatar: vo.avatar,
+ silent: !!vo.silent,
+ displayName: vo.displayName || '',
+ displayNamePinyin: vo.displayNamePinyin,
+ addSource: vo.addSource,
+ pinned: !!vo.pinned,
+ blocked: !!vo.blocked,
+ status: vo.status,
+ addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
+ deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined
+ }
+}
+
+function convertFriendRequest(vo: ImFriendRequestApi.FriendRequestRespVO): FriendRequest {
+ return {
+ id: vo.id,
+ fromUserId: vo.fromUserId,
+ toUserId: vo.toUserId,
+ handleResult: vo.handleResult,
+ applyContent: vo.applyContent,
+ handleContent: vo.handleContent,
+ addSource: vo.addSource,
+ handleTime: vo.handleTime ? new Date(vo.handleTime).getTime() : undefined,
+ createTime: vo.createTime ? new Date(vo.createTime).getTime() : 0,
+ fromNickname: vo.fromNickname,
+ fromAvatar: vo.fromAvatar,
+ toNickname: vo.toNickname,
+ toAvatar: vo.toAvatar
+ }
+}
+
+export const useFriendStoreWithOut = () => useFriendStore()
+
+// dev: 璁� Pinia 鐨� actions / state 鏀瑰姩鏀寔 HMR锛岄伩鍏嶆瘡娆℃敼 store 閮藉緱纭埛
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot))
+}
diff --git a/src/views/im/home/store/groupRequestStore.ts b/src/views/im/home/store/groupRequestStore.ts
new file mode 100644
index 0000000..24ea5da
--- /dev/null
+++ b/src/views/im/home/store/groupRequestStore.ts
@@ -0,0 +1,242 @@
+import type { ImGroupRequestApi } from '#/api/im/group/request'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { agreeGroupRequest as apiAgreeGroupRequest, getMyGroupRequest as apiGetMyGroupRequest, getUnhandledRequestList as apiGetUnhandledRequestList, pullMyGroupRequestList as apiPullMyGroupRequestList, refuseGroupRequest as apiRefuseGroupRequest } from '#/api/im/group/request'
+import { getCurrentUserId } from '#/views/im/utils/auth'
+import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
+
+import { getDb, StorageKeys } from '../../utils/db'
+import { runIncrementalPull } from '../../utils/pull'
+
+type PendingRequest = { epoch: number; promise: Promise<void>; userId: number; }
+
+/** clear() 鏃堕�掑锛涙棫璐﹀彿 in-flight 鐨� pullGroupRequests 缁撴灉 resolve 鍚庢瘮瀵逛竴鑷存墠鍐� store锛岄槻璺ㄨ处鍙风孩鐐规薄鏌擄紙涓� friendStore 鍚屽彛寰勶級 */
+let storeEpoch = 0
+let pendingUnhandledFetch: null | PendingRequest = null
+
+/**
+ * IM 鍔犵兢鐢宠 Store
+ *
+ * 浠呯淮鎶ゃ�屾垜绠$悊鐨勬墍鏈夌兢銆嶄笅鏈鐞嗙殑鐢宠鍒楄〃锛坲nhandledList锛夛紱
+ * 妯箙 / Drawer 閮戒粠杩欓噷娲剧敓 count 鍜屽垎缁勫垪琛紝閬垮厤缁� ImGroupApi.GroupRespVO 鎸� pendingRequestCount 瀛楁
+ *
+ * 鏁版嵁鐢熷懡鍛ㄦ湡锛�
+ * - 杩� IM 鍚庤皟涓�娆� fetchUnhandledGroupRequestList 鎷夐椤靛叏閲�
+ * - WebSocket 1503 鈫� 璋� addGroupRequestById(requestId) 鎷夊崟鏉� + push 鍒� unhandledList 澶撮儴
+ * - WebSocket 1505 / 1506 鈫� 鎸� requestId 浠� unhandledList 绉婚櫎
+ * - WebSocket 1517 GROUP_ADMIN_ADD锛堣嚜宸辫鍔犱负 admin锛夆啋 閲嶆柊璋� fetchUnhandledGroupRequestList
+ * - 鏈 agree / refuse 澶勭悊鍚� 鈫� 鏈湴鎸� requestId 绉婚櫎
+ */
+export const useGroupRequestStore = defineStore('imGroupRequestStore', {
+ state: () => ({
+ /** 鎴戠鐞嗙殑鎵�鏈夌兢涓嬫湭澶勭悊鐢宠鍒楄〃锛堟寜 id 鍊掑簭锛� */
+ unhandledList: [] as ImGroupRequestApi.GroupRequestRespVO[],
+ /** fetchUnhandledGroupRequestList 鏄惁鎴愬姛鎵ц杩囷紱閬垮厤妯箙鏄剧ず 0 鐒跺悗璺虫暟瀛楃殑闂儊 */
+ loaded: false
+ }),
+
+ getters: {
+ /**
+ * 鍚勭兢涓嬫湭澶勭悊鐢宠鏁扮殑 Map锛汷(N) 鎵竴娆$紦瀛樹緵 ConversationItem 绛� N 澶勫鐢紝閬垮厤 N脳M 閲嶅 filter
+ */
+ getUnhandledGroupRequestCountMap(state): Map<number, number> {
+ const map = new Map<number, number>()
+ for (const request of state.unhandledList) {
+ map.set(request.groupId, (map.get(request.groupId) ?? 0) + 1)
+ }
+ return map
+ },
+ /** 鎸囧畾缇や笅鐨勬湭澶勭悊鐢宠鏁� */
+ getUnhandledGroupRequestCount(): (groupId: number) => number {
+ return (groupId: number) => this.getUnhandledGroupRequestCountMap.get(groupId) ?? 0
+ },
+ /** 鎸囧畾缇や笅鐨勬湭澶勭悊鐢宠鍒楄〃 */
+ getUnhandledGroupRequestListByGroupId:
+ (state) =>
+ (groupId: number): ImGroupRequestApi.GroupRequestRespVO[] =>
+ state.unhandledList.filter((r) => r.groupId === groupId)
+ },
+
+ actions: {
+ /** 浠� IndexedDB 鎭㈠鍔犵兢鐢宠 */
+ async loadGroupRequestList(): Promise<boolean> {
+ try {
+ const cached = await getDb().getAll<ImGroupRequestApi.GroupRequestRespVO>('groupRequests')
+ if (!cached || cached.length === 0) {
+ return false
+ }
+ this.unhandledList = cached
+ .filter((request) => request.handleResult === ImGroupRequestHandleResult.UNHANDLED)
+ .toSorted((requestA, requestB) => requestB.id - requestA.id)
+ return true
+ } catch (error) {
+ console.warn('[IM groupRequestStore] 鏈湴鍔犵兢鐢宠缂撳瓨璇诲彇澶辫触', error)
+ return false
+ }
+ },
+
+ /** 淇濆瓨鍔犵兢鐢宠鍒楄〃 */
+ saveGroupRequestList(): void {
+ void getDb()
+ .transaction(['groupRequests'], 'readwrite', async (tx) => {
+ const db = getDb()
+ await db.clearStore('groupRequests', tx)
+ for (const request of this.unhandledList) {
+ await db.put('groupRequests', request, tx)
+ }
+ })
+ .catch((error) => console.warn('[IM groupRequestStore] 鏈湴鍔犵兢鐢宠缂撳瓨鍐欏叆澶辫触', error))
+ },
+
+ /** 淇濆瓨鍗曟潯鍔犵兢鐢宠 */
+ async saveGroupRequestRecord(request: ImGroupRequestApi.GroupRequestRespVO): Promise<void> {
+ await getDb().put('groupRequests', request)
+ },
+
+ /** 淇濆瓨鍗曟潯鍔犵兢鐢宠 */
+ saveGroupRequest(request: ImGroupRequestApi.GroupRequestRespVO): void {
+ void this.saveGroupRequestRecord(request).catch((error) =>
+ console.warn('[IM groupRequestStore] 鏈湴鍔犵兢鐢宠鍐欏叆澶辫触', error)
+ )
+ },
+
+ /** 鎷夊彇鎴戠鐞嗙殑鎵�鏈夌兢涓嬫湭澶勭悊鐢宠锛涜繘 IM 鍚� / 鍗囩骇 admin 鍚� / WS 鎺ㄩ�佹湁鍐茬獊鏃惰皟鐢� */
+ async fetchUnhandledGroupRequestList() {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ if (
+ pendingUnhandledFetch?.epoch === requestEpoch &&
+ pendingUnhandledFetch.userId === requestUserId
+ ) {
+ return pendingUnhandledFetch.promise
+ }
+ const promise = (async () => {
+ const list = await apiGetUnhandledRequestList()
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ this.unhandledList = list || []
+ this.loaded = true
+ this.saveGroupRequestList()
+ })().finally(() => {
+ if (
+ pendingUnhandledFetch?.epoch === requestEpoch &&
+ pendingUnhandledFetch.userId === requestUserId
+ ) {
+ pendingUnhandledFetch = null
+ }
+ })
+ pendingUnhandledFetch = { epoch: requestEpoch, userId: requestUserId, promise }
+ return promise
+ },
+
+ /**
+ * WS 鏀跺埌 1503锛氭媺鏈�鏂板唴瀹瑰苟缃《
+ *
+ * 鍚屼竴瀵� group_id, user_id 澶嶇敤璁板綍鏃� requestId 涓嶅彉浣� applyContent / inviterUserId 浼氬埛鏂帮紝鎵�浠ユ棤鏉′欢 fetch + 鎺掑埌澶撮儴
+ */
+ async addGroupRequestById(requestId: number) {
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ const request = await apiGetMyGroupRequest(requestId)
+ if (!request) {
+ return
+ }
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ this.upsertGroupRequest(request)
+ },
+
+ /**
+ * 鏈湴鍚堝苟 / 鏂板鍗曟潯鍔犵兢鐢宠锛圵S 鎺ㄩ�� & 澧為噺鎷夊彇鍏辩敤锛�
+ *
+ * 鏈鐞嗙殑鎸� id 鍘婚噸鍚庣疆椤讹紱宸插鐞嗙殑浠庢湭澶勭悊鍒楄〃绉婚櫎锛岄伩鍏嶈ˉ鍋挎椂鎶婂凡鍚屾剰 / 鎷掔粷鐨勮褰曞鍥炵孩鐐�
+ */
+ upsertGroupRequest(request: ImGroupRequestApi.GroupRequestRespVO) {
+ void this.upsertGroupRequestForPull(request).catch((error) =>
+ console.warn('[IM groupRequestStore] 鏈湴鍔犵兢鐢宠鍐欏叆澶辫触', error)
+ )
+ },
+
+ /** 鏈湴鍚堝苟 / 鏂板鍗曟潯鍔犵兢鐢宠 */
+ async upsertGroupRequestForPull(request: ImGroupRequestApi.GroupRequestRespVO): Promise<void> {
+ if (request.handleResult !== ImGroupRequestHandleResult.UNHANDLED) {
+ await this.removeGroupRequestByIdForPull(request.id)
+ return
+ }
+ this.unhandledList = [request, ...this.unhandledList.filter((r) => r.id !== request.id)]
+ await this.saveGroupRequestRecord(request)
+ },
+
+ /**
+ * 澧為噺鎷夊彇鍔犵兢鐢宠鍙樻洿骞跺悎骞讹紱鍚凡澶勭悊鐨勬寜 handleResult 璧扮Щ闄わ紙宸插鐞� 鈫� 浠庣孩鐐瑰垪琛ㄥ墧闄わ級
+ *
+ * 鍙仛閲嶈繛 / 鍚庣画绂荤嚎琛ュ伩锛屼笉璐熻矗棣栫櫥锛氶鐧荤孩鐐硅蛋 fetchUnhandledGroupRequestList锛堟湇鍔$鐩存帴杩囨护鏈鐞嗭紝璇箟鏇寸簿鍑嗐�佸惎鍔ㄦ洿杞伙級銆�
+ * 鏁呴娆¢噸杩炴椂娓告爣涓虹┖ = 涓�娆℃�у叏閲忚蛋涓�閬嶏紙宸插鐞嗚褰曞懡涓� removeGroupRequestById 涓� no-op锛岀孩鐐逛笉鍙楀奖鍝嶏級锛屼箣鍚庡閲忋��
+ */
+ async pullGroupRequests() {
+ // 蹇収 epoch锛涜处鍙峰湪鎷夊彇閫斾腑鍒囨崲锛坈lear() 鈫� epoch++锛夋椂涓㈠純鏃ц处鍙烽偅鍑犻〉缁撴灉锛岄槻璺ㄨ处鍙风孩鐐规薄鏌�
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ const isActive = () => requestEpoch === storeEpoch && getCurrentUserId() === requestUserId
+ await runIncrementalPull(
+ StorageKeys.settings.groupRequestPullCursor,
+ apiPullMyGroupRequestList,
+ async (records) => {
+ if (!isActive()) {
+ return false
+ }
+ await Promise.all(records.map((vo) => this.upsertGroupRequestForPull(vo)))
+ return true
+ },
+ isActive
+ )
+ if (isActive()) {
+ this.loaded = true
+ }
+ },
+
+ /** WS 鏀跺埌 1505 / 1506 鎴栨湰绔鐞嗗畬涓�鏉★細鎸� requestId 浠庡垪琛ㄧЩ闄� */
+ removeGroupRequestById(requestId: number) {
+ this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId)
+ void getDb()
+ .delete('groupRequests', requestId)
+ .catch((error) => console.warn('[IM groupRequestStore] 鏈湴鍔犵兢鐢宠鍒犻櫎澶辫触', error))
+ },
+
+ /** 鍒犻櫎鍗曟潯鍔犵兢鐢宠 */
+ async removeGroupRequestByIdForPull(requestId: number): Promise<void> {
+ this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId)
+ await getDb().delete('groupRequests', requestId)
+ },
+
+ /** 鍚屾剰鐢宠锛涙湰绔鐞嗗悗绔嬪嵆浠庡垪琛ㄧЩ闄わ紝閬垮厤琚弽澶嶇偣鍑� */
+ async agreeGroupRequest(requestId: number) {
+ await apiAgreeGroupRequest(requestId)
+ this.removeGroupRequestById(requestId)
+ },
+
+ /** 鎷掔粷鐢宠 */
+ async refuseGroupRequest(requestId: number, handleContent?: string) {
+ await apiRefuseGroupRequest(requestId, handleContent)
+ this.removeGroupRequestById(requestId)
+ },
+
+ /** 娓呯┖鍔犵兢鐢宠鍐呭瓨 */
+ clear() {
+ this.unhandledList = []
+ this.loaded = false
+ // 璐﹀彿鍒囨崲锛氶�掑 epoch 搴熷純鏃ц处鍙� in-flight 鐨� pullGroupRequests 缁撴灉锛岄伩鍏嶅啓杩涙柊璐﹀彿绾㈢偣鍒楄〃
+ storeEpoch++
+ pendingUnhandledFetch = null
+ }
+ }
+})
+
+export const useGroupRequestStoreWithOut = () => useGroupRequestStore()
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useGroupRequestStore, import.meta.hot))
+}
diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts
new file mode 100644
index 0000000..c9e2786
--- /dev/null
+++ b/src/views/im/home/store/groupStore.ts
@@ -0,0 +1,1017 @@
+import type { Group, GroupDO, GroupMember, Message } from '../types'
+
+import type { ImGroupApi } from '#/api/im/group'
+import type { ImGroupMemberApi } from '#/api/im/group/member'
+
+import { CommonStatusEnum } from '#/packages/constants/src'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { getGroup as apiGetGroup, getMyGroupList as apiGetMyGroupList } from '#/api/im/group'
+import { getGroupMember as apiGetGroupMember, getGroupMemberList as apiGetGroupMemberList, updateGroupMember as apiUpdateGroupMember } from '#/api/im/group/member'
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import {
+ ImContentType,
+ ImConversationType,
+ ImGroupMemberRole,
+ ImMessageStatus
+} from '../../utils/constants'
+import { getDb } from '../../utils/db'
+import { type GroupNotificationPayload } from '../../utils/message'
+import { getGroupDisplayName } from '../../utils/user'
+import { useConversationStore } from './conversationStore'
+import { useGroupRequestStore } from './groupRequestStore'
+
+/** clear() 鏃堕�掑锛涙棫璐﹀彿 in-flight 鐨勬垚鍛樿姹傝繑鍥炲悗姣斿涓�鑷存墠鍐� store */
+let storeEpoch = 0
+
+/**
+ * fetchGroupMemberList 骞跺彂鍘婚噸琛細鍚� groupId 鍚屾椂杩涚殑璇锋眰鍏辩敤涓�涓� Promise
+ *
+ * key 蹇呴』甯� userId鈥斺�旇处鍙峰垏鎹㈡椂 A 鐨勮姹備笉鑳借 B 澶嶇敤锛屽惁鍒� IIFE 鍐呴儴鐨� saveGroupMemberList 浼氭妸 A 鐨勬垚鍛樻暟鎹啓杩� B 鐨� IDB 妗�
+ */
+const pendingMemberFetches = new Map<string, Promise<GroupMember[]>>()
+const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${groupId}`
+
+/**
+ * fetchGroupMember 鍗曟垚鍛樺苟鍙戝幓閲嶈〃锛氬悓 (groupId, memberUserId) 鍚屾椂杩涚殑璇锋眰鍏辩敤涓�涓� Promise
+ *
+ * 璺熸暣缇よ〃鍒嗗紑锛氬崟鎴愬憳 fetch 璺熸暣缇� fetch 璇箟涓嶅悓锛堝崟鎴愬憳涓嶅洖濉� me 鐨� silent锛夛紝涓嶈兘浜掔浉浠f浛
+ */
+const pendingSingleMemberFetches = new Map<string, Promise<GroupMember | null>>()
+
+const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: number) =>
+ `${userId}:${groupId}:${memberUserId}`
+
+/** 鏋勫缓缇� IndexedDB 璁板綍 */
+function buildGroupDO(group: Group): GroupDO {
+ const {
+ activeCallExpired: _activeCallExpired,
+ activeCallLoaded: _activeCallLoaded,
+ infoLoaded: _infoLoaded,
+ members: _members,
+ membersLoaded: _membersLoaded,
+ membersExpired: _membersExpired,
+ ...record
+ } = group
+ return record
+}
+
+/** 鍒ゆ柇褰撳墠鐢ㄦ埛鏄惁鍦� payload.memberUserIds 閲岋紙GROUP_CREATE / INVITE / KICK 鑷垽鐢級 */
+function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean {
+ const selfUserId = getCurrentUserId()
+ return !!selfUserId && (payload.memberUserIds || []).includes(selfUserId)
+}
+
+/** 鍒锋柊鎴戠鐞嗙殑缇ょ敵璇风孩鐐� */
+function refreshUnhandledGroupRequests(): void {
+ useGroupRequestStore()
+ .fetchUnhandledGroupRequestList()
+ .catch(() => undefined)
+}
+
+/**
+ * IM 缇� Store
+ *
+ * 璐熻矗锛�
+ * - 鎷夊彇 / 缂撳瓨褰撳墠鐧诲綍鐢ㄦ埛鍔犲叆鐨勭兢鍒楄〃
+ * - 鎸� groupId 鎳掑姞杞界兢鎴愬憳锛堜緵 ConversationGroupSide / MentionPicker / MessageReadStatus 娑堣垂锛�
+ * - 鎴愬憳"宸茶 / 鏈"绛夎仛鍚堟煡璇㈢敱 MessageReadStatus 鍙﹁缁勫悎
+ */
+export const useGroupStore = defineStore('imGroupStore', {
+ state: () => ({
+ groups: [] as Group[],
+ loaded: false, // 浠� fetchGroupList 鎴愬姛鍚庣疆浣嶏紱loadGroupList锛圛DB锛変笉缃綅锛屽惁鍒欏悗鍙� SWR 鍒锋柊浼氳缂撳瓨鍛戒腑璺宠繃
+ groupMembersExpired: false // 杩涘叆 IM / 閲嶈繛鍚庣疆浣嶏紱IDB 閲岀殑鎴愬憳妗跺欢杩熷姞杞藉埌鍐呭瓨鏃讹紝涔熻鎸夎繃鏈熷鐞�
+ }),
+
+ getters: {
+ getGroup:
+ (state) =>
+ (id: number): Group | undefined => {
+ return state.groups.find((g) => g.id === id)
+ },
+ /** 缇ゆ垚鍛� userId 鈫� GroupMember 绱㈠紩锛涜皟鐢ㄦ柟鎸� userId 鍙嶆煡鏄电О / 澶村儚绛夊厓淇℃伅 */
+ getGroupMemberMap:
+ (state) =>
+ (id: number): Map<number, GroupMember> => {
+ const group = state.groups.find((g) => g.id === id)
+ return new Map((group?.members || []).map((m) => [m.userId, m]))
+ }
+ },
+
+ actions: {
+ // ==================== 鏈湴缂撳瓨 ====================
+
+ /** 浠� IndexedDB 鎭㈠缇ゅ垪琛� */
+ async loadGroupList(): Promise<boolean> {
+ try {
+ const cached = await getDb().getAll<GroupDO>('groups')
+ if (!cached || cached.length === 0) {
+ return false
+ }
+ this.groups = cached
+ return true
+ } catch (error) {
+ console.warn('[IM groupStore] 鏈湴缇ょ紦瀛樿鍙栧け璐�', error)
+ return false
+ }
+ },
+
+ /** 淇濆瓨缇ゅ垪琛� */
+ saveGroupList(): void {
+ void getDb()
+ .transaction(['groups'], 'readwrite', async (tx) => {
+ const db = getDb()
+ await db.clearStore('groups', tx)
+ for (const group of this.groups) {
+ await db.put('groups', buildGroupDO(group), tx)
+ }
+ })
+ .catch((error) => console.warn('[IM groupStore] 鏈湴缇ょ紦瀛樺啓鍏ュけ璐�', error))
+ },
+
+ /** 淇濆瓨鍗曚釜缇� */
+ async saveGroupRecord(group: Group | undefined): Promise<void> {
+ if (!group) {
+ return
+ }
+ await getDb().put('groups', buildGroupDO(group))
+ },
+
+ /** 淇濆瓨鍗曚釜缇� */
+ saveGroup(group: Group | undefined): void {
+ void this.saveGroupRecord(group).catch((error) =>
+ console.warn('[IM groupStore] 鏈湴缇ゅ啓鍏ュけ璐�', error)
+ )
+ },
+
+ /** 浠� IndexedDB 鎭㈠鎸囧畾缇ゆ垚鍛� */
+ async loadGroupMemberList(groupId: number): Promise<GroupMember[] | null> {
+ // in-memory 宸�"瀹屾暣"鍔犺浇锛坒etchGroupMemberList 璺戣繃鎴栦笂娆″喎鍚姩浠� IDB 鏁存《鎭㈠杩囷級锛氱洿鎺ュ鐢紱
+ // 鍗曟垚鍛樿ˉ榻愶紙fetchGroupMember锛夊啓杩涚殑 partial members 涓嶅湪姝よ繑鍥炵紦瀛樷�斺�斿叾 membersLoaded=false
+ const cachedGroup = this.getGroup(groupId)
+ if (cachedGroup?.members && cachedGroup.membersLoaded) {
+ return cachedGroup.members
+ }
+ try {
+ const cached = await getDb().getAllByIndex<GroupMember>(
+ 'groupMembers',
+ 'groupId',
+ groupId
+ )
+ if (!cached || cached.length === 0) {
+ return null
+ }
+ // 鎶� IDB 鎷垮埌鐨勬垚鍛樿惤鍒板搴� group
+ const group = this.getGroup(groupId)
+ if (group) {
+ group.members = cached
+ group.memberCount = cached.length
+ group.membersLoaded = true
+ group.membersExpired = this.groupMembersExpired
+ } else {
+ // group 杩樻病灏变綅锛氫粎 in-memory 鍗犱綅锛坣ame='' 琛ㄧず鏈煡锛夛紝涓嶈皟 upsertGroup 鈥斺�� 閬垮厤鎶婂亣鍚嶇亴杩� conversation.name + groups IDB 妗讹紱
+ // 鍚庣画锛岀瓑 fetchGroupList 娴呭悎骞舵椂锛岃鐪熷悕瑕嗙洊
+ this.groups.push({
+ id: groupId,
+ name: '',
+ members: cached,
+ memberCount: cached.length,
+ membersLoaded: true,
+ membersExpired: this.groupMembersExpired
+ })
+ }
+ return cached
+ } catch (error) {
+ console.warn('[IM groupStore] 鏈湴缇ゆ垚鍛樼紦瀛樿鍙栧け璐�', { groupId }, error)
+ return null
+ }
+ },
+
+ /** 淇濆瓨鎸囧畾缇ゆ垚鍛� */
+ saveGroupMemberList(groupId: number): void {
+ const members = this.getGroup(groupId)?.members
+ if (!members) {
+ return
+ }
+ void getDb()
+ .transaction(['groupMembers'], 'readwrite', async (tx) => {
+ const db = getDb()
+ await db.deleteByIndex('groupMembers', 'groupId', groupId, tx)
+ for (const member of members) {
+ if (member.id) {
+ await db.put('groupMembers', member, tx)
+ }
+ }
+ })
+ .catch((error) =>
+ console.warn(`[IM groupStore] 鏈湴缇ゆ垚鍛樼紦瀛樺啓鍏ュけ璐� (groupId=${groupId})`, error)
+ )
+ },
+
+ // ==================== 杩滅鎷夊彇 ====================
+
+ /** 鎷夊彇缇ゅ垪琛紱鍚屾鍒锋柊瀵瑰簲缇よ亰浼氳瘽鐨勫睍绀哄悕 / 澶村儚 + 钀� IDB */
+ async fetchGroupList(force = false) {
+ if (this.loaded && !force) {
+ return
+ }
+ const requestEpoch = storeEpoch
+ const requestUserId = getCurrentUserId()
+ // 鎷夊彇褰撳墠鐧诲綍鐢ㄦ埛鍔犲叆鐨勬墍鏈夌兢锛堜笉甯︽垚鍛橈紱鎴愬憳鎸夐渶鍐嶈蛋 fetchGroupMemberList锛�
+ const list = await apiGetMyGroupList()
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return
+ }
+ const fresh = (list || []).map((group) => convertGroup(group))
+ // 鍚堝苟鑰岄潪鍏ㄩ噺鏇挎崲锛氭垚鍛樼紦瀛樺彧鍦ㄦ垚鍛樺垪琛ㄦ帴鍙g淮鎶わ紝缇や釜浜鸿缃互缇ゅ垪琛ㄦ帴鍙d负鍑�
+ const groupMap = new Map(this.groups.map((group) => [group.id, group]))
+ this.groups = fresh.map((group) => {
+ const existing = groupMap.get(group.id)
+ if (!existing) {
+ return { ...group, activeCallExpired: true, infoLoaded: true }
+ }
+ return {
+ ...group,
+ infoLoaded: true,
+ activeCallExpired: existing.activeCallExpired,
+ activeCallLoaded: existing.activeCallLoaded,
+ members: existing.members,
+ memberCount: existing.memberCount ?? group.memberCount,
+ membersLoaded: existing.membersLoaded,
+ membersExpired: existing.membersExpired
+ }
+ })
+ this.loaded = true
+ const conversationStore = useConversationStore()
+ for (const group of this.groups) {
+ conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
+ name: getGroupDisplayName(group),
+ avatar: group.avatar,
+ silent: group.silent
+ })
+ }
+ this.saveGroupList()
+ this.preloadMembersForEmptyAvatarGroups()
+ },
+
+ /** 澶辨晥鍏ㄩ儴缇よ鎯呯紦瀛� */
+ markAllGroupInfoExpired() {
+ for (const group of this.groups) {
+ group.infoLoaded = false
+ }
+ },
+
+ /** 棰勫姞杞界┖缇ゅご鍍忕殑鎴愬憳鍒楄〃锛屼緵 GroupAvatar 寮傛鍚堟垚缇ゅご鍍� */
+ preloadMembersForEmptyAvatarGroups() {
+ for (const group of this.groups) {
+ if (
+ group.avatar ||
+ group.joinStatus === CommonStatusEnum.DISABLE ||
+ (group.membersLoaded && !group.membersExpired && group.members?.length)
+ ) {
+ continue
+ }
+ const force = !!group.membersLoaded && !group.membersExpired && !group.members?.length
+ this.fetchGroupMemberList(group.id, force).catch((error) => {
+ console.warn('[IM groupStore] 棰勫姞杞界兢澶村儚鎴愬憳澶辫触', { groupId: group.id }, error)
+ })
+ }
+ },
+
+ /** 澶辨晥鍏ㄩ儴缇ゆ垚鍛樼紦瀛� */
+ markAllGroupMembersExpired() {
+ this.groupMembersExpired = true
+ for (const group of this.groups) {
+ if (group.membersLoaded) {
+ group.membersExpired = true
+ }
+ }
+ },
+
+ /** 澶辨晥鍏ㄩ儴缇ら�氳瘽鎺㈡祴缂撳瓨 */
+ markAllGroupActiveCallsExpired() {
+ for (const group of this.groups) {
+ group.activeCallExpired = true
+ }
+ },
+
+ /** 鏍囪缇ら�氳瘽鎺㈡祴宸插姞杞� */
+ markGroupActiveCallLoaded(groupId: number) {
+ const group = this.getGroup(groupId)
+ if (!group) {
+ return
+ }
+ group.activeCallLoaded = true
+ group.activeCallExpired = false
+ },
+
+ /** 鍒ゆ柇缇ら�氳瘽鏄惁闇�瑕侀噸鏂版帰娴� */
+ isGroupActiveCallExpired(groupId: number): boolean {
+ const group = this.getGroup(groupId)
+ return !group?.activeCallLoaded || !!group.activeCallExpired
+ },
+
+ /** 澶辨晥鎸囧畾缇ゆ垚鍛樼紦瀛� */
+ markGroupMembersExpired(groupId: number) {
+ const group = this.getGroup(groupId)
+ if (group?.membersLoaded) {
+ group.membersExpired = true
+ }
+ },
+
+ /** 鍗曠兢鍒锋柊锛氱敤 /im/group/get 鎷変竴浠芥渶鏂板厓鏁版嵁鍐� upsert锛屽父鐢ㄤ簬 GROUP_UPDATE 鎺ㄩ�佸悗鎴栨墜鍔� reload */
+ async fetchGroupInfo(groupId: number, force = false) {
+ const cached = this.getGroup(groupId)
+ if (cached?.infoLoaded && !force) {
+ return
+ }
+ try {
+ const data = await apiGetGroup(groupId)
+ if (!data) {
+ return
+ }
+ this.upsertGroup({ ...convertGroup(data), infoLoaded: true })
+ } catch (error) {
+ console.warn('[IM groupStore] fetchGroupInfo 澶辫触', error)
+ }
+ },
+
+ /** 鎸夌兢鎷夊彇鎴愬憳锛坕n-memory 缂撳瓨 + 骞跺彂鍘婚噸锛宖orce=true 寮哄埛锛�+ 钀� IDB */
+ fetchGroupMemberList(groupId: number, force = false): Promise<GroupMember[]> {
+ // in-memory "瀹屾暣"鍔犺浇杩囨墠鍛戒腑鈥斺�斿崟鎴愬憳琛ラ綈鍐欏叆鐨� partial members 涓嶅湪姝よ繑鍥烇紙membersLoaded=false锛�
+ const cached = this.getGroup(groupId)
+ if (cached && cached.members && cached.membersLoaded && !cached.membersExpired && !force) {
+ return Promise.resolve(cached.members)
+ }
+ // 鏈櫥褰曪細涓嶅彂璧疯姹備篃涓嶇櫥璁� in-flight锛岄伩鍏嶆薄鏌撳崟椋炶〃
+ const requestUserId = getCurrentUserId()
+ if (!requestUserId) {
+ return Promise.resolve([])
+ }
+ const requestEpoch = storeEpoch
+ // 鍚� (userId, groupId) 宸茬粡鏈夋鍦ㄩ鐨勮姹傦細鐩存帴澶嶇敤锛岄伩鍏嶉噸澶嶆墦鎺ュ彛
+ const key = pendingMemberKey(requestUserId, groupId)
+ const inflight = pendingMemberFetches.get(key)
+ if (inflight) {
+ return inflight
+ }
+ const promise = (async () => {
+ // 鎷夋帴鍙� + 鍗� pass 杞崲锛氬悓鏃舵崟鑾� me 鐨勫師濮� VO锛岀粰涓嬮潰鍥炲~ user-per-group 瀛楁锛坰ilent / groupRemark锛夌敤
+ const list = await apiGetGroupMemberList(groupId)
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return []
+ }
+ let meRaw: ImGroupMemberApi.GroupMemberRespVO | undefined
+ const members = (list || []).map((member) => {
+ if (member.userId === requestUserId) {
+ meRaw = member
+ }
+ return convertGroupMember(member, groupId)
+ })
+ const silent = !!meRaw?.silent
+ const groupRemark = meRaw?.groupRemark || ''
+
+ // 蹇呴』 await 涔嬪悗閲嶆柊 getGroup锛岄伩鍏� fetchGroupList 宸插苟鍙戝啓鍏ョ湡瀹� group 鐨� race
+ const group = this.getGroup(groupId)
+ const isPlaceholder = !group
+ let groupFieldsChanged = false
+ if (group) {
+ group.members = members
+ group.memberCount = members.length
+ group.membersLoaded = true
+ group.membersExpired = false
+ // silent / groupRemark 浠讳竴鍙樺寲鎵嶅悓姝ュ埌 conversation 鍜� IDB锛沢roupRemark 鍙樺寲瑕侀『甯﹀埛浼氳瘽鍚�
+ if (group.silent !== silent || group.groupRemark !== groupRemark) {
+ group.silent = silent
+ group.groupRemark = groupRemark
+ groupFieldsChanged = true
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
+ name: getGroupDisplayName(group),
+ silent
+ })
+ }
+ } else {
+ // group 杩樻病灏变綅锛氫粎 in-memory push 鍗犱綅锛坣ame='' 琛ㄧず鏈煡锛夛紝涓嶈皟 upsertGroup鈥斺�旈伩鍏嶆妸鍋囧悕鐏岃繘 conversation.name + groups IDB 妗讹紱
+ // 鍚庣画锛岀瓑 fetchGroupList 娴呭悎骞舵椂锛岃鐪熷悕瑕嗙洊
+ this.groups.push({
+ id: groupId,
+ name: '',
+ members,
+ memberCount: members.length,
+ silent,
+ groupRemark,
+ membersLoaded: true,
+ membersExpired: false
+ })
+ }
+
+ // groups 妗朵粎鍦� user-per-group 瀛楁瀹為檯鍙樺寲鏃跺啓鈥斺�旈伩鍏嶄竴娆℃壒閲忚繘缇ゅ紩鍙戝娆℃暣妗堕噸鍐�
+ this.saveGroupMemberList(groupId)
+ if (!isPlaceholder && groupFieldsChanged) {
+ this.saveGroup(group)
+ }
+ return members
+ // 鏃犺鎴愬姛 / 澶辫触閮借浠庡崟椋炶〃娓呮帀锛屽惁鍒欏悗缁悓 group 璇锋眰姘歌繙鎷垮埌杩欎釜 stale Promise
+ })().finally(() => pendingMemberFetches.delete(key))
+
+ // 鎶� Promise 鐧昏杩涘崟椋炶〃锛岃姝ゅ悗鐭椂闂村唴鐨勫悓 (userId, groupId) 璇锋眰澶嶇敤
+ pendingMemberFetches.set(key, promise)
+ return promise
+ },
+
+ /**
+ * 鎸� (groupId, memberUserId) 鍗曟垚鍛樿ˉ榻愨�斺�攄eriveLastSenderDisplayName 鍏滃簳鍦烘櫙鐢�
+ *
+ * 璺� fetchGroupMemberList 鍖哄埆锛氬彧鎷夎繖涓�涓垚鍛橈紝涓嶅姩 me 鐨� silent / groupRemark锛堜笉鏄� me 鐨勮瘽鎷夸笉鍒帮級锛�
+ * 鍛戒腑鏃舵妸鎴愬憳 upsert 杩� group.members 鏁扮粍骞惰惤 IDB锛岃鍚庣画娓叉煋鑳界敤 displayUserName
+ */
+ fetchGroupMember(groupId: number, memberUserId: number): Promise<GroupMember | null> {
+ // in-memory 鍛戒腑鐩存帴杩斿洖锛屼笉鎵撴帴鍙�
+ const cached = this.getGroup(groupId)?.members?.find((m) => m.userId === memberUserId)
+ if (cached) {
+ return Promise.resolve(cached)
+ }
+ // 鏈櫥褰曪細涓嶅彂璧疯姹備篃涓嶇櫥璁� in-flight锛岄伩鍏嶆薄鏌撳崟椋炶〃
+ const requestUserId = getCurrentUserId()
+ if (!requestUserId) {
+ return Promise.resolve(null)
+ }
+ const requestEpoch = storeEpoch
+ // 鍚� (userId, groupId, memberUserId) 宸茬粡鏈夋鍦ㄩ鐨勮姹傦細鐩存帴澶嶇敤
+ const key = pendingSingleMemberKey(requestUserId, groupId, memberUserId)
+ const inflight = pendingSingleMemberFetches.get(key)
+ if (inflight) {
+ return inflight
+ }
+ const promise = (async () => {
+ const data = await apiGetGroupMember(groupId, memberUserId)
+ if (!data) {
+ return null
+ }
+ if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
+ return null
+ }
+ const member = convertGroupMember(data, groupId)
+ // 鎶婅繖涓�鏉� upsert 杩� group.members 浠呬緵 in-memory 娓叉煋鍏滃簳锛沢roup 杩樻病灏变綅鍒欑敤 placeholder
+ // 娉ㄦ剰锛氫笉鍐� IDB鈥斺�旀垚鍛樻《璇箟鏄�"鍏ㄩ噺"锛屽瓨"1 浜烘《"浼氭薄鏌撲笅娆″喎鍚姩鐨� loadGroupMemberList
+ const group = this.getGroup(groupId)
+ if (group) {
+ const memberList = group.members ?? []
+ const index = memberList.findIndex((m) => m.userId === memberUserId)
+ if (index === -1) {
+ memberList.push(member)
+ } else {
+ memberList[index] = member
+ }
+ group.members = memberList
+ } else {
+ // memberCount 涓嶈锛氬悗缁� fetchGroupList 鍚堝苟 `existing.memberCount ?? fresh.memberCount` 鏃讹紝
+ // 鍗犱綅鍊间細椤舵浛鐪熷疄鍊硷紙fresh 涓嶅甫 memberCount锛夛紝绛� fetchGroupMemberList 璺戣繃鎵嶈兘鎷垮埌鐪熷疄鏁�
+ this.groups.push({
+ id: groupId,
+ name: '',
+ members: [member]
+ })
+ }
+ return member
+ })().finally(() => pendingSingleMemberFetches.delete(key))
+ pendingSingleMemberFetches.set(key, promise)
+ return promise
+ },
+
+ /** 鎸� id 鎻掑叆鎴栧悎骞剁兢锛堝懡涓垯娴呭悎骞朵繚鐣欐棫瀛楁锛屾湭鍛戒腑鍒欒拷鍔狅級锛屽悓姝ユ妸灞曠ず鍚� / 澶村儚 / 鍏嶆墦鎵版帹鍒板搴斾細璇� */
+ upsertGroup(group: Group) {
+ void this.upsertGroupAndSave(group).catch((error) =>
+ console.warn('[IM groupStore] 鏈湴缇ゅ啓鍏ュけ璐�', error)
+ )
+ },
+
+ /** 鎸� id 鎻掑叆鎴栧悎骞剁兢 */
+ async upsertGroupAndSave(group: Group): Promise<void> {
+ const index = this.groups.findIndex((g) => g.id === group.id)
+ if (index === -1) {
+ this.groups.push(group)
+ } else {
+ this.groups[index] = { ...this.groups[index], ...group }
+ }
+ // 鍚屾鎺ㄥ埌 conversation锛氱兢鍚� / 澶村儚 / 鍏嶆墦鎵版槸浼氳瘽鍒楄〃灞曠ず鐢ㄧ殑锛屽繀椤荤揣闅� group 鍙樻洿
+ const merged = this.getGroup(group.id) ?? group
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
+ name: getGroupDisplayName(merged),
+ avatar: merged.avatar,
+ silent: merged.silent
+ })
+ // 鎸佷箙鍖栧埌 IDB锛坒ire-and-forget锛�
+ await this.saveGroupRecord(merged)
+ },
+
+ /** 鏈湴绉婚櫎缇ょ紦瀛樺拰缇や細璇濓紱缇よВ鏁o紙GROUP_DEL锛夈�侀��缇ゃ�佽韪㈤兘澶嶇敤 */
+ removeGroup(id: number) {
+ // 鏈湴纭垹锛堝尯鍒簬濂藉弸鍒犻櫎鐨勮蒋鍒犱繚鐣欒褰曪級锛涚骇鑱旀竻缇よ亰浼氳瘽閬垮厤鍒楄〃閲岀暀姝荤兢
+ this.groups = this.groups.filter((g) => g.id !== id)
+ const conversationStore = useConversationStore()
+ conversationStore.removeGroupConversation(id)
+ void getDb()
+ .transaction(['groups', 'groupMembers'], 'readwrite', async (tx) => {
+ const db = getDb()
+ await db.delete('groups', id, tx)
+ await db.deleteByIndex('groupMembers', 'groupId', id, tx)
+ })
+ .catch((error) => console.warn(`[IM groupStore] 缇ょ紦瀛樺垹闄ゅけ璐� (groupId=${id})`, error))
+ },
+
+ /** 鍒囨崲鍏嶆墦鎵帮細鎺ㄥ悗绔� + 钀芥湰鍦� + 鍚屾浼氳瘽鍒楄〃鐨� silent锛岄伩鍏� silent 鍥炬爣 / 鎬绘湭璇� / 鎻愮ず闊冲垽鏂笌璁剧疆婕傜Щ锛涘拰 friendStore.setFriendSilent 瀵归綈 */
+ async setGroupSilent(id: number, silent: boolean) {
+ await apiUpdateGroupMember({ groupId: id, silent })
+ const group = this.getGroup(id)
+ if (!group) {
+ return
+ }
+ group.silent = silent
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.GROUP, id, { silent })
+ this.saveGroup(group)
+ },
+
+ /** 鎵归噺鏇存柊缇ゆ垚鍛樿鑹诧紱鏈湴涓嶅懡涓垯蹇界暐锛岀瓑 fetchGroupMemberList 鍏滃簳 */
+ updateGroupMemberRoleList(groupId: number, userIds: number[], role: number) {
+ const group = this.getGroup(groupId)
+ if (!group?.members?.length) {
+ return
+ }
+ // 鍛戒腑鐩爣涓旇鑹插凡鍙樺寲鎵嶆爣璁� changed锛岄伩鍏嶆棤鍙樺寲鏃舵暣鏁扮粍閲嶅缓瑙﹀彂鍝嶅簲寮�
+ const idSet = new Set(userIds)
+ let changed = false
+ const newMembers = group.members.map((member) => {
+ if (!idSet.has(member.userId) || member.role === role) {
+ return member
+ }
+ changed = true
+ return { ...member, role }
+ })
+ // 鏈夊彉鍖栨墠鏁寸粍鏇挎崲锛岃鍝嶅簲寮忓彧鍦ㄧ湡鏈夋洿鏂版椂閫氱煡涓嬫父
+ if (changed) {
+ group.members = newMembers
+ this.saveGroupMemberList(groupId)
+ }
+ },
+
+ /** 缇や富杞锛氱兢琛� ownerUserId 鏀逛负鏂板�硷紱鏃х兢涓� role 鈫� NORMAL锛涙柊缇や富 role 鈫� OWNER */
+ transferGroupOwner(groupId: number, oldOwnerId: number, newOwnerId: number) {
+ const group = this.getGroup(groupId)
+ if (!group) {
+ return
+ }
+ if (group.ownerUserId !== newOwnerId) {
+ group.ownerUserId = newOwnerId
+ }
+ this.updateGroupMemberRoleList(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL)
+ this.updateGroupMemberRoleList(groupId, [newOwnerId], ImGroupMemberRole.OWNER)
+ this.saveGroup(group)
+ },
+
+ /** 鏈湴鍓旈櫎缇ゆ垚鍛橈紙GROUP_MEMBER_QUIT / KICK 浜嬩欢锛夛紱涓嶅懡涓垯绛� fetchGroupMemberList 鍏滃簳 */
+ removeLocalGroupMemberList(groupId: number, userIds: number[]) {
+ const group = this.getGroup(groupId)
+ if (!group?.members?.length || userIds.length === 0) {
+ return
+ }
+ const idSet = new Set(userIds)
+ const next = group.members.filter((member) => !idSet.has(member.userId))
+ if (next.length === group.members.length) {
+ return
+ }
+ group.members = next
+ group.memberCount = next.length
+ this.saveGroupMemberList(groupId)
+ },
+
+ /** 鏈湴鏇存柊缇ゆ垚鍛樼殑 status锛堣嚜宸遍��缇� / 琚涪鐨勬湰鍦伴缃紱璁� isMember 绔嬪嵆鏀舵暃鍒� stranger锛屼笉渚濊禆 removeGroup 鐨勬暣缇ょЩ闄わ級 */
+ updateGroupMemberStatus(groupId: number, userId: number, status: number) {
+ const group = this.getGroup(groupId)
+ const member = group?.members?.find((m) => m.userId === userId)
+ if (!member || member.status === status) {
+ return
+ }
+ member.status = status
+ this.saveGroupMemberList(groupId)
+ },
+
+ /** 鏈湴鏇存柊缇ゆ垚鍛樼殑 displayUserName锛圙ROUP_MEMBER_NICKNAME_UPDATE 浜嬩欢锛夛紱涓嶅懡涓垯绛� fetchGroupMemberList 鍏滃簳 */
+ updateGroupMemberDisplayUserName(groupId: number, userId: number, displayUserName: string) {
+ const group = this.getGroup(groupId)
+ const member = group?.members?.find((m) => m.userId === userId)
+ if (!member || member.displayUserName === displayUserName) {
+ return
+ }
+ member.displayUserName = displayUserName
+ this.saveGroupMemberList(groupId)
+ },
+
+ /** 灞�閮ㄦ洿鏂扮兢瀛楁锛坣ame / notice / avatar 绛夛級锛涙湭鍛戒腑鏈湴缂撳瓨鏃堕潤榛樺拷鐣ワ紝绛� fetchGroupList 鍏滃簳锛涙柊鍊艰窡鏃у�奸兘鐩稿悓鏃惰烦杩囧搷搴斿紡 + IDB 鍐� */
+ updateGroupFields(groupId: number, fields: Partial<Group>) {
+ const group = this.getGroup(groupId)
+ if (!group) {
+ return
+ }
+ const changed = (Object.keys(fields) as (keyof Group)[]).some((k) => group[k] !== fields[k])
+ if (!changed) {
+ return
+ }
+ Object.assign(group, fields)
+ const conversationStore = useConversationStore()
+ conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
+ name: getGroupDisplayName(group),
+ avatar: group.avatar,
+ silent: group.silent
+ })
+ this.saveGroup(group)
+ },
+
+ /**
+ * 鎺ユ敹 GROUP_* 缇ゅ箍鎾簨浠讹紝鎸� type 鍒嗗彂鍒板搴旂鏈� action
+ *
+ * WebSocket 瀹炴椂鏀惰蛋 messageStore.insertMessage 鏃佽矾璋冪敤
+ * store 閲屾病缂撳瓨鐨勭兢闈欓粯蹇界暐锛岀瓑 fetchGroupList 鍏滃簳
+ */
+ applyGroupNotification(groupId: number, type: number, content?: string) {
+ if (!groupId) {
+ return
+ }
+ let payload: GroupNotificationPayload
+ try {
+ payload = content ? JSON.parse(content) : {}
+ } catch (error) {
+ console.warn(
+ '[IM groupStore] applyGroupNotification 瑙f瀽 content 澶辫触',
+ { groupId, type, contentLength: content?.length ?? 0 },
+ error
+ )
+ return
+ }
+ switch (type) {
+ case ImContentType.GROUP_ADMIN_ADD: {
+ this.updateGroupMemberRoleList(
+ groupId,
+ payload.memberUserIds || [],
+ ImGroupMemberRole.ADMIN
+ )
+ this.markGroupMembersExpired(groupId)
+ // 鑷繁琚姞涓虹鐞嗗憳锛屽師鏈湅涓嶅埌鐨勭兢涓嬫湭澶勭悊鐢宠鐜板湪鍙樺彲瑙侊紝閲嶆柊鎷変竴娆� unhandledList
+ if (isSelfInPayloadMembers(payload)) {
+ refreshUnhandledGroupRequests()
+ }
+ break
+ }
+ case ImContentType.GROUP_ADMIN_REMOVE: {
+ this.updateGroupMemberRoleList(
+ groupId,
+ payload.memberUserIds || [],
+ ImGroupMemberRole.NORMAL
+ )
+ this.markGroupMembersExpired(groupId)
+ if (isSelfInPayloadMembers(payload)) {
+ refreshUnhandledGroupRequests()
+ }
+ break
+ }
+ case ImContentType.GROUP_BANNED: {
+ this.updateGroupFields(groupId, { banned: !!payload.banned })
+ break
+ }
+ case ImContentType.GROUP_CANCEL_MUTED: {
+ this.updateGroupFields(groupId, { mutedAll: false })
+ break
+ }
+ case ImContentType.GROUP_CREATE: {
+ this.applyGroupCreateNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_DISSOLVE: {
+ this.removeGroup(groupId)
+ break
+ }
+ case ImContentType.GROUP_INFO_UPDATE: {
+ this.applyGroupInfoUpdateNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MEMBER_CANCEL_MUTED: {
+ this.applyGroupMemberCancelMutedNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MEMBER_ENTER: {
+ this.applyGroupMemberEnterNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MEMBER_INVITE: {
+ this.applyGroupMemberInviteNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MEMBER_KICK: {
+ this.applyGroupMemberKickNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MEMBER_MUTED: {
+ this.applyGroupMemberMutedNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
+ this.applyGroupMemberNicknameUpdateNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MEMBER_QUIT: {
+ this.applyGroupMemberQuitNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MESSAGE_PIN: {
+ this.applyGroupMessagePinNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MESSAGE_UNPIN: {
+ this.applyGroupMessageUnpinNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_MUTED: {
+ this.updateGroupFields(groupId, { mutedAll: true })
+ break
+ }
+ case ImContentType.GROUP_NAME_UPDATE: {
+ this.applyGroupNameUpdateNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_NOTICE_UPDATE: {
+ this.applyGroupNoticeUpdateNotification(groupId, payload)
+ break
+ }
+ case ImContentType.GROUP_OWNER_TRANSFER: {
+ this.applyGroupOwnerTransferNotification(groupId, payload)
+ break
+ }
+ }
+ },
+
+ /** 鍒涘缓缇ゅ箍鎾細缇ゆ湭灏变綅鏃舵媺缇よ鎯� */
+ async applyGroupCreateNotification(groupId: number, payload: GroupNotificationPayload) {
+ if (!isSelfInPayloadMembers(payload)) {
+ return
+ }
+ const selfUserId = getCurrentUserId()
+ const selfIsOperator = !!selfUserId && payload.operatorUserId === selfUserId
+ if (selfIsOperator && this.getGroup(groupId)) {
+ return
+ }
+ await this.fetchGroupInfo(groupId, true)
+ },
+
+ /** 缇ゅ悕鍙樻洿锛氭寜 newName 灞�閮ㄦ洿鏂版湰鍦扮兢鍚� */
+ applyGroupNameUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
+ if (payload.newName) {
+ this.updateGroupFields(groupId, { name: payload.newName })
+ }
+ },
+
+ /** 缇ゅ叕鍛婂彉鏇达細鎸� newNotice 灞�閮ㄦ洿鏂帮紙鍏佽绌轰覆浣滀负銆屾竻绌哄叕鍛娿�嶏級 */
+ applyGroupNoticeUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
+ this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
+ },
+
+ /** 缇や俊鎭彉鏇达細鍚屾澶村儚銆佽繘缇ゅ鎵� */
+ applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
+ const fields: Partial<Group> = {}
+ if (payload.newAvatar) {
+ fields.avatar = payload.newAvatar
+ }
+ if (payload.newJoinApproval != null) {
+ fields.joinApproval = payload.newJoinApproval
+ }
+ if (Object.keys(fields).length > 0) {
+ this.updateGroupFields(groupId, fields)
+ }
+ },
+
+ /** 鎴愬憳鍔犲叆锛氳閭�璇疯�呮湰绔� group 鏈氨浣嶅厛 fetchGroupInfo 鍒濇鎷夊彇锛涙墍鏈変汉閮藉埛鎴愬憳鍒楄〃锛堟柊鎴愬憳 nickname / avatar 涓嶅湪 payload锛� */
+ async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
+ // 鑷繁鍒氳鎷夎繘鏉ワ細蹇呴』 await fetchGroupInfo 璁╃兢鍏� state.groups锛屽惁鍒� fetchGroupMemberList 鐨� guard 浼氬厹绌�
+ if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
+ await this.fetchGroupInfo(groupId, true)
+ }
+ this.markGroupMembersExpired(groupId)
+ this.fetchGroupMemberList(groupId, true).catch(() => undefined)
+ },
+
+ /** 鑷敱杩涚兢锛氳繘缇よ�呮湰绔� group 鏈氨浣嶅厛 fetchGroupInfo 鍒濇鎷夊彇锛涙墍鏈変汉閮藉埛鎴愬憳鍒楄〃 */
+ async applyGroupMemberEnterNotification(groupId: number, payload: GroupNotificationPayload) {
+ const selfUserId = getCurrentUserId()
+ // 鑷繁鑷敱杩涚兢锛氬繀椤� await fetchGroupInfo 璁╃兢鍏� state.groups锛屽惁鍒� fetchGroupMemberList 鐨� guard 浼氬厹绌�
+ if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
+ await this.fetchGroupInfo(groupId, true)
+ }
+ this.markGroupMembersExpired(groupId)
+ this.fetchGroupMemberList(groupId, true).catch(() => undefined)
+ },
+
+ /** 鎴愬憳閫�缇わ細閫�缇よ�呮湰浜哄厛鎶� self.status 缃� DISABLE 鍐� removeGroup锛堜繚鐣欑姸鎬佽涔� + 缁存寔 groups 鍒楄〃骞插噣锛夛紱鍏朵粬鎴愬憳浠庢湰鍦板垪琛ㄧЩ闄� quitter */
+ applyGroupMemberQuitNotification(groupId: number, payload: GroupNotificationPayload) {
+ const selfUserId = getCurrentUserId()
+ if (selfUserId && payload.operatorUserId === selfUserId) {
+ this.updateGroupMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
+ this.removeGroup(groupId)
+ } else if (payload.operatorUserId) {
+ this.removeLocalGroupMemberList(groupId, [payload.operatorUserId])
+ this.markGroupMembersExpired(groupId)
+ }
+ },
+
+ /** 鎴愬憳琚Щ鍑猴細琚涪鑰呮湰浜哄厛鎶� self.status 缃� DISABLE 鍐� removeGroup锛涘叾浠栨垚鍛樹粠鏈湴鍒楄〃绉婚櫎琚涪鑰� */
+ applyGroupMemberKickNotification(groupId: number, payload: GroupNotificationPayload) {
+ const memberIds = payload.memberUserIds || []
+ const selfUserId = getCurrentUserId()
+ if (isSelfInPayloadMembers(payload)) {
+ if (selfUserId) {
+ this.updateGroupMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
+ }
+ this.removeGroup(groupId)
+ } else if (memberIds.length > 0) {
+ this.removeLocalGroupMemberList(groupId, memberIds)
+ this.markGroupMembersExpired(groupId)
+ }
+ },
+
+ /** 鎴愬憳鏄电О鍙樻洿锛氭寜 operatorUserId 灞�閮ㄦ洿鏂板搴� member.displayUserName */
+ applyGroupMemberNicknameUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
+ if (payload.operatorUserId) {
+ this.updateGroupMemberDisplayUserName(
+ groupId,
+ payload.operatorUserId,
+ payload.displayUserName ?? ''
+ )
+ this.markGroupMembersExpired(groupId)
+ }
+ },
+
+ /** 缇や富杞锛氭棫缇や富 鈫� NORMAL锛屾柊缇や富 鈫� OWNER锛涙柊缇や富鑷繁渚ч噸鏂版媺鐢宠鍒楄〃 */
+ applyGroupOwnerTransferNotification(groupId: number, payload: GroupNotificationPayload) {
+ if (payload.operatorUserId && payload.newOwnerUserId) {
+ this.transferGroupOwner(groupId, payload.operatorUserId, payload.newOwnerUserId)
+ this.markGroupMembersExpired(groupId)
+ }
+ // 鑷繁鎺ョ缇や富锛氬師鏈湅涓嶅埌鐨勭兢涓嬫湭澶勭悊鐢宠鐜板湪鍙樺彲瑙侊紝閲嶆柊鎷変竴娆� unhandledList
+ const selfUserId = getCurrentUserId()
+ if (selfUserId && payload.newOwnerUserId === selfUserId) {
+ refreshUnhandledGroupRequests()
+ } else if (selfUserId && payload.operatorUserId === selfUserId) {
+ refreshUnhandledGroupRequests()
+ }
+ },
+
+ /** 缇ゆ秷鎭疆椤讹細浠� payload 鍙栨秷鎭睍绀烘暟鎹姞鍏ョ疆椤跺垪琛� */
+ applyGroupMessagePinNotification(groupId: number, payload: GroupNotificationPayload) {
+ const message = payload.message
+ if (!message) {
+ return
+ }
+ const group = this.getGroup(groupId)
+ if (!group) {
+ return
+ }
+ // 骞傜瓑锛氬凡瀛樺湪鍚� messageId 涓嶉噸澶� push
+ const existing = group.pinnedMessages || []
+ if (existing.some((msg) => msg.id === message.id)) {
+ return
+ }
+ group.pinnedMessages = [
+ ...existing,
+ {
+ id: message.id,
+ clientMessageId: '',
+ senderId: message.senderId,
+ type: message.type,
+ content: message.content,
+ status: ImMessageStatus.NORMAL,
+ sendTime: new Date(message.sendTime).getTime(),
+ targetId: message.groupId || groupId,
+ selfSend: message.senderId === getCurrentUserId(),
+ atUserIds: message.atUserIds ? [...message.atUserIds] : [],
+ receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : []
+ }
+ ]
+ this.saveGroup(group)
+ },
+
+ /** 缇ゆ秷鎭彇娑堢疆椤讹細鎸� messageId 浠庢湰鍦扮疆椤跺垪琛ㄤ腑绉婚櫎 */
+ applyGroupMessageUnpinNotification(groupId: number, payload: GroupNotificationPayload) {
+ if (!payload.messageId) {
+ return
+ }
+ const group = this.getGroup(groupId)
+ if (!group?.pinnedMessages?.length) {
+ return
+ }
+ const newPinnedMessages = group.pinnedMessages.filter((m) => m.id !== payload.messageId)
+ if (newPinnedMessages.length === group.pinnedMessages.length) {
+ return
+ }
+ group.pinnedMessages = newPinnedMessages
+ this.saveGroup(group)
+ },
+
+ /** 鍗曟垚鍛樼瑷�锛氭洿鏂扮洰鏍囨垚鍛樼殑 muteEndTime */
+ applyGroupMemberMutedNotification(groupId: number, payload: GroupNotificationPayload) {
+ const group = this.getGroup(groupId)
+ const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
+ if (member && payload.muteEndTime) {
+ member.muteEndTime = payload.muteEndTime
+ this.saveGroupMemberList(groupId)
+ this.markGroupMembersExpired(groupId)
+ }
+ },
+
+ /** 鍗曟垚鍛樺彇娑堢瑷�锛氭竻绌虹洰鏍囨垚鍛樼殑 muteEndTime */
+ applyGroupMemberCancelMutedNotification(groupId: number, payload: GroupNotificationPayload) {
+ const group = this.getGroup(groupId)
+ const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
+ if (member) {
+ member.muteEndTime = undefined
+ this.saveGroupMemberList(groupId)
+ this.markGroupMembersExpired(groupId)
+ }
+ },
+
+ /** 鍒囪处鍙锋椂浠呮竻 in-memory锛孖DB 鎸� userId 鍒嗘《澶╃劧闅旂锛屽洖鍒囩寮� */
+ clear() {
+ this.groups = []
+ this.loaded = false
+ this.groupMembersExpired = false
+ // 璐﹀彿鍒囨崲锛氶�掑 epoch 搴熷純鏃ц处鍙� in-flight 鐨勬垚鍛樿姹�
+ storeEpoch++
+ // 鍗曢琛ㄨ窡 in-memory state 涓�璧烽噸缃紱鏃ц处鍙� in-flight 鐨勮姹� finally 涔熶細鑷繁 delete key锛屾彁鍓嶆竻绌哄彧鏄洿骞茶剢
+ pendingMemberFetches.clear()
+ pendingSingleMemberFetches.clear()
+ }
+ }
+})
+
+function convertGroup(group: ImGroupApi.GroupRespVO): Group {
+ return {
+ id: group.id,
+ name: group.name,
+ avatar: group.avatar,
+ notice: group.notice,
+ ownerUserId: group.ownerUserId,
+ pinnedMessages: group.pinnedMessages?.map((message) => convertGroupMessageVO(message)),
+ mutedAll: group.mutedAll,
+ banned: group.banned,
+ joinApproval: group.joinApproval,
+ joinStatus: group.joinStatus,
+ groupRemark: group.groupRemark,
+ silent: group.silent
+ }
+}
+
+/** 鍚庣 ImGroupMessageApi.GroupMessageRespVO -> 鍓嶇 Message锛氳ˉ targetId / selfSend / sendTime 绛夋淳鐢熷瓧娈� */
+function convertGroupMessageVO(
+ message: NonNullable<ImGroupApi.GroupRespVO['pinnedMessages']>[number]
+): Message {
+ const currentUserId = getCurrentUserId()
+ return {
+ id: message.id,
+ clientMessageId: message.clientMessageId || '',
+ type: message.type,
+ content: message.content,
+ status: message.status,
+ sendTime: new Date(message.sendTime).getTime(),
+ senderId: message.senderId,
+ targetId: message.groupId,
+ selfSend: !!currentUserId && message.senderId === currentUserId,
+ atUserIds: message.atUserIds || [],
+ receiverUserIds: message.receiverUserIds || [],
+ receiptStatus: message.receiptStatus,
+ readCount: message.readCount
+ }
+}
+
+function convertGroupMember(member: ImGroupMemberApi.GroupMemberRespVO, groupId: number): GroupMember {
+ return {
+ id: member.id,
+ userId: member.userId,
+ groupId,
+ nickname: member.nickname || String(member.userId),
+ avatar: member.avatar,
+ displayUserName: member.displayUserName,
+ status: member.status,
+ role: member.role,
+ muteEndTime: member.muteEndTime
+ }
+}
+
+export const useGroupStoreWithOut = () => useGroupStore()
+
+// dev: 璁� Pinia 鐨� actions 鏀瑰姩鏀寔 HMR锛屽厤鍘绘瘡娆℃敼 store 閮借纭埛
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot))
+}
diff --git a/src/views/im/home/store/messageStore.ts b/src/views/im/home/store/messageStore.ts
new file mode 100644
index 0000000..fa17b67
--- /dev/null
+++ b/src/views/im/home/store/messageStore.ts
@@ -0,0 +1,953 @@
+import type { Conversation, Message, MessageDO } from '../types'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import {
+ IM_AT_ALL_USER_ID,
+ ImContentType,
+ ImConversationType,
+ ImMessageReceiptStatus,
+ ImMessageStatus,
+ isGroupNotification,
+ isNormalMessage
+} from '../../utils/constants'
+import { resolveConversationLastContent } from '../../utils/conversation'
+import {
+ type DbTransaction,
+ getClientConversationId,
+ getClientMessageKey,
+ getDb,
+ getServerMessageKey,
+ parseClientConversationId,
+ setMessageMaxId,
+ StorageKeys
+} from '../../utils/db'
+import {
+ generateClientMessageId,
+ parseRecallMessageId,
+ revokeBlobUrlsInContent
+} from '../../utils/message'
+import { isGroupQuit, tryGetSenderDisplayName } from '../../utils/user'
+import { useConversationStore } from './conversationStore'
+import { useGroupStore } from './groupStore'
+
+const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5
+const MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT = MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT + 1
+const ackMergingPromises = new Map<string, Promise<void>>()
+
+interface MessageConversationInfo {
+ type: number
+ targetId: number
+ name: string
+ avatar: string
+ silent?: boolean
+}
+
+interface PersistMessageRecordOptions {
+ mergeClientRecord?: boolean
+}
+
+/** 鎷夊彇娑堟伅鎵归噺澶勭悊椤� */
+export type PulledMessage =
+ | {
+ conversationInfo: MessageConversationInfo
+ kind: 'insert'
+ message: Message
+ }
+ | {
+ conversationType: number
+ kind: 'recall'
+ recallSignalContent: string
+ targetId: number
+ }
+
+/** 鑾峰彇浼氳瘽鐨勬秷鎭紦瀛� key */
+function getMessageCacheKey(type: number, targetId: number): string {
+ return getClientConversationId(type, targetId)
+}
+
+/** 鐢熸垚娑堟伅鏈湴涓婚敭 */
+function getMessageKey(
+ message: Pick<Message, 'clientMessageId' | 'id'>,
+ conversationType: number
+): string {
+ return message.id
+ ? getServerMessageKey(conversationType, message.id)
+ : getClientMessageKey(message.clientMessageId)
+}
+
+/** 琛ラ綈瀹㈡埛绔秷鎭紪鍙� */
+function ensureClientMessageId(message: Message): Message {
+ if (!message.clientMessageId) {
+ message.clientMessageId = generateClientMessageId()
+ }
+ if (!message.id) {
+ message.id = undefined
+ }
+ return message
+}
+
+/** 杞崲涓� IndexedDB 娑堟伅璁板綍 */
+function buildMessageDO(message: Message, conversationType: number): MessageDO {
+ return {
+ id: message.id,
+ clientMessageId: message.clientMessageId,
+ type: message.type,
+ content: message.content,
+ status: message.status,
+ sendTime: message.sendTime,
+ senderId: message.senderId,
+ atUserIds: message.atUserIds ? [...message.atUserIds] : undefined,
+ receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : undefined,
+ receiptStatus: message.receiptStatus,
+ readCount: message.readCount,
+ materialId: message.materialId,
+ targetId: message.targetId,
+ selfSend: message.selfSend,
+ messageKey: getMessageKey(message, conversationType),
+ conversationType,
+ clientConversationId: getClientConversationId(conversationType, message.targetId)
+ }
+}
+
+/** IndexedDB 娑堟伅璁板綍杞墠绔秷鎭� */
+function buildMessageFromDO(message: MessageDO): Message {
+ const {
+ messageKey: _messageKey,
+ conversationType: _conversationType,
+ clientConversationId: _clientConversationId,
+ ...rest
+ } = message
+ return rest
+}
+
+/** 绠楀嚭鏈潯娑堟伅鐨勫彂閫佷汉蹇収 */
+function deriveLastSenderDisplayName(
+ conversation: Conversation,
+ senderId: number
+): string | undefined {
+ // 1. 浼樺厛浣跨敤褰撳墠鍐呭瓨涓殑濂藉弸 / 缇ゆ垚鍛樹俊鎭�
+ const liveSenderName = tryGetSenderDisplayName(senderId, conversation.type, conversation.targetId)
+ if (liveSenderName) {
+ return liveSenderName
+ }
+ // 2. 缇ゆ垚鍛樼紦瀛樼己澶辨椂寮傛琛ラ綈
+ if (conversation.type === ImConversationType.GROUP) {
+ const groupStore = useGroupStore()
+ const group = groupStore.getGroup(conversation.targetId)
+ if (!group || isGroupQuit(group)) {
+ return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
+ }
+ const fetchPromise =
+ group?.membersLoaded && !group.membersExpired
+ ? groupStore.fetchGroupMember(conversation.targetId, senderId)
+ : groupStore.fetchGroupMemberList(conversation.targetId)
+ fetchPromise.catch((error) =>
+ console.warn(
+ '[IM messageStore] 鍏滃簳鎷夌兢鎴愬憳澶辫触',
+ { groupId: conversation.targetId, senderId, fullFetch: !group?.membersLoaded },
+ error
+ )
+ )
+ }
+ return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
+}
+
+/** 鎸夋秷鎭洿鏂颁細璇濇憳瑕� */
+function applyConversationSummary(conversation: Conversation, message: Message): void {
+ const senderDisplayName = deriveLastSenderDisplayName(conversation, message.senderId)
+ conversation.lastContent = resolveConversationLastContent(
+ message,
+ conversation.type,
+ conversation.targetId,
+ senderDisplayName
+ )
+ conversation.lastSendTime = message.sendTime || Date.now()
+ conversation.lastSenderId = message.senderId
+ conversation.lastMessageType = message.type
+ conversation.lastMessageId = message.id
+ conversation.lastClientMessageId = message.clientMessageId
+ conversation.lastMessageStatus = message.status
+ conversation.lastReceiptStatus = message.receiptStatus
+ conversation.lastSelfSend = message.selfSend
+ conversation.lastSenderDisplayName = senderDisplayName
+}
+
+/** 鎸夋湯鏉℃秷鎭噸绠椾細璇濇憳瑕� */
+function recomputeConversationLast(conversation: Conversation, messages: Message[]): void {
+ const last = messages[messages.length - 1]
+ if (last) {
+ applyConversationSummary(conversation, last)
+ return
+ }
+ conversation.lastContent = ''
+ conversation.lastSendTime = 0
+ conversation.lastSenderId = undefined
+ conversation.lastMessageType = undefined
+ conversation.lastMessageId = undefined
+ conversation.lastClientMessageId = undefined
+ conversation.lastMessageStatus = undefined
+ conversation.lastReceiptStatus = undefined
+ conversation.lastSelfSend = undefined
+ conversation.lastSenderDisplayName = undefined
+}
+
+/** 鍚屾缇� @ 鐘舵�� */
+function syncConversationAtFlags(conversation: Conversation, message: Message): void {
+ if (
+ message.selfSend ||
+ conversation.type !== ImConversationType.GROUP ||
+ !message.atUserIds ||
+ message.atUserIds.length === 0
+ ) {
+ return
+ }
+ const currentUserId = getCurrentUserId()
+ if (currentUserId && message.atUserIds.includes(currentUserId)) {
+ conversation.atMe = true
+ }
+ if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
+ conversation.atAll = true
+ }
+}
+
+/** 搴旂敤鏈嶅姟绔秷鎭洿鏂� */
+function applyServerMessageUpdate(message: Message, updates: Partial<Message>): void {
+ if (updates.content && updates.content !== message.content) {
+ revokeBlobUrlsInContent(message.content)
+ }
+ Object.assign(message, updates)
+ if (updates.id === 0) {
+ message.id = undefined
+ }
+ if (updates.status !== undefined && updates.status !== ImMessageStatus.SENDING) {
+ message.uploadProgress = undefined
+ if (updates.status !== ImMessageStatus.FAILED) {
+ message._localFile = undefined
+ }
+ }
+}
+
+/** 鍒ゆ柇鏄惁涓哄悓涓�鏉℃秷鎭� */
+function isSameMessage(left: Message, right: Message): boolean {
+ if (left.id && right.id && left.id === right.id) {
+ return true
+ }
+ return !!left.clientMessageId && left.clientMessageId === right.clientMessageId
+}
+
+export const useMessageStore = defineStore('imMessageStore', {
+ state: () => ({
+ messagesByConversation: {} as Record<string, Message[]>,
+ loadedConversationKeys: [] as string[],
+ privateReadMaxIds: {} as Partial<Record<number, number>>,
+ privateMessageMaxId: 0,
+ groupMessageMaxId: 0,
+ channelMessageMaxId: 0
+ }),
+
+ getters: {
+ /** 鑾峰彇浼氳瘽宸插姞杞芥秷鎭� */
+ getMessages:
+ (state) =>
+ (clientConversationId: string): Message[] =>
+ state.messagesByConversation[clientConversationId] || []
+ },
+
+ actions: {
+ /** 娓呯┖娑堟伅鍐呭瓨 */
+ clear() {
+ Object.values(this.messagesByConversation).forEach((messages) => {
+ messages.forEach((message) => {
+ revokeBlobUrlsInContent(message.content)
+ message._localFile = undefined
+ })
+ })
+ this.messagesByConversation = {}
+ this.loadedConversationKeys = []
+ this.privateReadMaxIds = {}
+ this.privateMessageMaxId = 0
+ this.groupMessageMaxId = 0
+ this.channelMessageMaxId = 0
+ ackMergingPromises.clear()
+ },
+
+ /** 浠� settings 鍔犺浇娑堟伅娓告爣 */
+ async loadMessageCursorList() {
+ const db = getDb()
+ const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
+ db.getSetting<number>(StorageKeys.settings.privateMessageMaxId),
+ db.getSetting<number>(StorageKeys.settings.groupMessageMaxId),
+ db.getSetting<number>(StorageKeys.settings.channelMessageMaxId)
+ ])
+ this.privateMessageMaxId = privateMaxId || 0
+ this.groupMessageMaxId = groupMaxId || 0
+ this.channelMessageMaxId = channelMaxId || 0
+ },
+
+ /** 鏇存柊鍐呭瓨娓告爣 */
+ updateMessageCursor(conversationType: number, messageId?: number) {
+ if (!messageId) {
+ return
+ }
+ if (conversationType === ImConversationType.PRIVATE && messageId > this.privateMessageMaxId) {
+ this.privateMessageMaxId = messageId
+ } else if (
+ conversationType === ImConversationType.GROUP &&
+ messageId > this.groupMessageMaxId
+ ) {
+ this.groupMessageMaxId = messageId
+ } else if (
+ conversationType === ImConversationType.CHANNEL &&
+ messageId > this.channelMessageMaxId
+ ) {
+ this.channelMessageMaxId = messageId
+ }
+ },
+
+ /** 鑾峰彇绉佽亰瀵规柟宸茶浣嶇疆缂撳瓨 */
+ getPrivateReadMaxId(peerId: number): number | undefined {
+ return this.privateReadMaxIds[peerId]
+ },
+
+ /** 鏇存柊绉佽亰瀵规柟宸茶浣嶇疆缂撳瓨 */
+ updatePrivateReadMaxId(peerId: number, maxReadId: null | number = 0): number {
+ if (!peerId) {
+ return 0
+ }
+ const nextMaxReadId = maxReadId || 0
+ const current = this.getPrivateReadMaxId(peerId)
+ if (current !== undefined && nextMaxReadId <= current) {
+ return current
+ }
+ this.privateReadMaxIds = { ...this.privateReadMaxIds, [peerId]: nextMaxReadId }
+ return nextMaxReadId
+ },
+
+ /** 娓呯┖绉佽亰瀵规柟宸茶浣嶇疆缂撳瓨 */
+ clearPrivateReadMaxIdCache(): void {
+ this.privateReadMaxIds = {}
+ },
+
+ /** 鏍囪浼氳瘽杩戞湡浣跨敤 */
+ touchConversationMessageCache(clientConversationId: string) {
+ this.loadedConversationKeys = [
+ clientConversationId,
+ ...this.loadedConversationKeys.filter((key) => key !== clientConversationId)
+ ]
+ // 淇濈暀褰撳墠娲昏穬浼氳瘽 + 鏈�杩戞墦寮�杩囩殑浼氳瘽
+ const retained = this.loadedConversationKeys.slice(0, MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
+ const removed = this.loadedConversationKeys.slice(MESSAGE_CACHE_RETAIN_CONVERSATION_LIMIT)
+ this.loadedConversationKeys = retained
+ removed.forEach((key) => {
+ Reflect.deleteProperty(this.messagesByConversation, key)
+ })
+ },
+
+ /** 鍔犺浇褰撳墠浼氳瘽鏈�杩戞秷鎭� */
+ async loadMoreMessageList(
+ clientConversationId: string,
+ beforeSendTime?: number,
+ limit = 50
+ ): Promise<Message[]> {
+ // 1. 浠� IndexedDB 鍊掑簭璇诲彇涓�椤碉紝杩斿洖鍓嶅凡鎸夋椂闂村崌搴忔帓鍒�
+ const list = await getDb().getMessageListByConversation(clientConversationId, {
+ beforeSendTime,
+ limit
+ })
+ // 2. 鍚堝苟鍒板唴瀛樼紦瀛橈紝杩囨护宸插瓨鍦ㄧ殑娑堟伅
+ const parsed = parseClientConversationId(clientConversationId)
+ if (!parsed) {
+ return []
+ }
+ const messages = list.map((message) => buildMessageFromDO(message))
+ const existing = this.messagesByConversation[clientConversationId] || []
+ const existingKeys = new Set(existing.map((message) => getMessageKey(message, parsed.type)))
+ const fresh = messages.filter(
+ (message) => !existingKeys.has(getMessageKey(message, parsed.type))
+ )
+ this.messagesByConversation[clientConversationId] = [...fresh, ...existing].toSorted(
+ (messageA, messageB) => (messageA.sendTime || 0) - (messageB.sendTime || 0)
+ )
+ this.touchConversationMessageCache(clientConversationId)
+ return fresh
+ },
+
+ /** 纭繚浼氳瘽娑堟伅宸插姞杞� */
+ async ensureConversationMessageListLoaded(conversation: Conversation) {
+ const key = getMessageCacheKey(conversation.type, conversation.targetId)
+ if (this.messagesByConversation[key]) {
+ this.touchConversationMessageCache(key)
+ return
+ }
+ await this.loadMoreMessageList(key)
+ },
+
+ /** 鑾峰彇鍐呭瓨娑堟伅鏁扮粍 */
+ getMessageList(conversationType: number, targetId: number): Message[] {
+ const key = getMessageCacheKey(conversationType, targetId)
+ if (!this.messagesByConversation[key]) {
+ this.messagesByConversation[key] = []
+ }
+ this.touchConversationMessageCache(key)
+ return this.messagesByConversation[key]
+ },
+
+ /** 鎸佷箙鍖栨秷鎭褰� */
+ async saveMessageRecord(
+ message: Message,
+ conversationType: number,
+ tx?: DbTransaction,
+ options?: PersistMessageRecordOptions
+ ) {
+ const db = getDb()
+ const next = buildMessageDO(message, conversationType)
+ // 鏈嶅姟绔� key 鏇挎崲 client key
+ if (options?.mergeClientRecord && message.id && message.clientMessageId) {
+ const existing = await db.getByIndex<MessageDO>(
+ 'messages',
+ 'clientMessageId',
+ message.clientMessageId,
+ tx
+ )
+ if (existing && existing.messageKey !== next.messageKey) {
+ await db.delete('messages', existing.messageKey, tx)
+ }
+ }
+ await db.put('messages', next, tx)
+ },
+
+ /** 淇濆瓨娑堟伅娓告爣 */
+ async saveMessageCursor(conversationType: number, messageId?: number, tx?: DbTransaction) {
+ await setMessageMaxId(conversationType, messageId, tx)
+ this.updateMessageCursor(conversationType, messageId)
+ },
+
+ /** 搴旂敤鎾ゅ洖鍒板唴瀛� */
+ applyRecallMessageInMemory(
+ conversationType: number,
+ targetId: number,
+ recallSignalContent: string
+ ) {
+ // 1. 瀹氫綅琚挙鍥炵殑鍘熸秷鎭�
+ const messageId = parseRecallMessageId(recallSignalContent)
+ if (!messageId) {
+ return null
+ }
+ const conversationStore = useConversationStore()
+ const conversation = conversationStore.getConversation(conversationType, targetId)
+ if (!conversation) {
+ return null
+ }
+ const messages = this.getMessageList(conversationType, targetId)
+ const message = messages.find((item) => item.id === messageId)
+ if (!message) {
+ return null
+ }
+ // 2. 鏇存柊娑堟伅鍜屼細璇濇憳瑕�
+ message.type = ImContentType.RECALL
+ message.status = ImMessageStatus.RECALL
+ message.content = ''
+ if (messages[messages.length - 1]?.id === messageId) {
+ recomputeConversationLast(conversation, messages)
+ }
+ return { conversation, message }
+ },
+
+ /** 鎵归噺鍐欏叆鎷夊彇娑堟伅 */
+ async applyPulledMessageList(
+ pulledMessages: PulledMessage[],
+ conversationType: number,
+ maxMessageId?: number
+ ) {
+ if (pulledMessages.length === 0) {
+ // 1. 绌烘壒娆″彧鎺ㄨ繘娓告爣
+ await this.saveMessageCursor(conversationType, maxMessageId)
+ return
+ }
+ const conversationStore = useConversationStore()
+ const persistedMessages = new Map<
+ string,
+ { conversationType: number; mergeClientRecord?: boolean; message: Message; }
+ >()
+ const changedConversations = new Map<string, Conversation>()
+
+ const addChanged = (
+ conversation: Conversation,
+ message: Message,
+ options?: PersistMessageRecordOptions
+ ) => {
+ const clientConversationId = getClientConversationId(
+ conversation.type,
+ conversation.targetId
+ )
+ changedConversations.set(clientConversationId, conversation)
+ persistedMessages.set(getMessageKey(message, conversation.type), {
+ message,
+ conversationType: conversation.type,
+ mergeClientRecord: options?.mergeClientRecord
+ })
+ }
+
+ // 1. 鍏堟洿鏂板唴瀛橈紝鏀堕泦闇�瑕佹寔涔呭寲鐨勬秷鎭拰浼氳瘽
+ for (const pulledMessage of pulledMessages) {
+ if (pulledMessage.kind === 'recall') {
+ // 1.1 鎾ゅ洖淇″彿鏇存柊鍘熸秷鎭�
+ const changed = this.applyRecallMessageInMemory(
+ pulledMessage.conversationType,
+ pulledMessage.targetId,
+ pulledMessage.recallSignalContent
+ )
+ if (changed) {
+ addChanged(changed.conversation, changed.message)
+ }
+ continue
+ }
+
+ const { conversationInfo } = pulledMessage
+ const hasServerClientMessageId = !!pulledMessage.message.clientMessageId
+ const message = ensureClientMessageId(pulledMessage.message)
+ // 1.2 纭繚浼氳瘽鍜屾秷鎭紦瀛樺瓨鍦�
+ const conversation = conversationStore.ensureConversation(conversationInfo)
+ const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
+ const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
+ if (existingIndex !== -1) {
+ const existing = messages[existingIndex]
+ if (!existing) {
+ continue
+ }
+ // 1.3 宸插瓨鍦ㄦ秷鎭悎骞舵湇鍔$鐘舵��
+ applyServerMessageUpdate(existing, message)
+ if (existingIndex === messages.length - 1) {
+ recomputeConversationLast(conversation, messages)
+ syncConversationAtFlags(conversation, message)
+ }
+ addChanged(conversation, existing, {
+ mergeClientRecord: hasServerClientMessageId
+ })
+ continue
+ }
+
+ // 1.4 鏂版秷鎭洿鏂颁細璇濇憳瑕佸拰鏈鐘舵��
+ applyConversationSummary(conversation, message)
+ syncConversationAtFlags(conversation, message)
+ const isActive =
+ conversationStore.activeConversation?.type === conversationInfo.type &&
+ conversationStore.activeConversation?.targetId === conversationInfo.targetId
+ if (
+ !message.selfSend &&
+ !isActive &&
+ !conversationStore.isMessageCoveredByReadPosition(conversation, message) &&
+ isNormalMessage(message.type) &&
+ message.status !== ImMessageStatus.RECALL
+ ) {
+ conversation.unreadCount++
+ }
+
+ // 1.5 鏂版秷鎭寜鏈嶅姟绔� id 鎻掑叆鍐呭瓨鍒楄〃
+ let insertIndex = messages.length
+ if (message.id) {
+ for (const [index, existing] of messages.entries()) {
+ if (existing.id && message.id < existing.id) {
+ insertIndex = index
+ break
+ }
+ }
+ }
+ messages.splice(insertIndex, 0, message)
+ addChanged(conversation, message, {
+ mergeClientRecord: hasServerClientMessageId && !!message.id
+ })
+ }
+
+ // 2. 鍗曚簨鍔″啓鍏ユ秷鎭�佷細璇濇憳瑕佸拰娓告爣
+ await getDb().transaction(
+ ['messages', 'conversations', 'settings'],
+ 'readwrite',
+ async (tx) => {
+ // 2.1 鍐欏叆鏈壒鍙樻洿娑堟伅
+ for (const item of persistedMessages.values()) {
+ await this.saveMessageRecord(item.message, item.conversationType, tx, {
+ mergeClientRecord: item.mergeClientRecord
+ })
+ }
+ // 2.2 鍐欏叆鏈壒鍙樻洿浼氳瘽
+ await conversationStore.saveConversationRecord([...changedConversations.values()], tx)
+ // 2.3 鍐欏叆鏈壒娓告爣
+ await setMessageMaxId(conversationType, maxMessageId, tx)
+ }
+ )
+ // 3. 鎸佷箙鍖栨垚鍔熷悗鎺ㄨ繘鍐呭瓨娓告爣
+ this.updateMessageCursor(conversationType, maxMessageId)
+ for (const item of persistedMessages.values()) {
+ this.updateMessageCursor(item.conversationType, item.message.id)
+ }
+ },
+
+ /** 鎻掑叆娑堟伅 */
+ insertMessage(
+ conversationInfo: MessageConversationInfo,
+ messageInfo: Message,
+ options?: { saveMaxId?: boolean }
+ ): Promise<void> {
+ const conversationStore = useConversationStore()
+ const hasIncomingClientMessageId = !!messageInfo.clientMessageId
+ const message = ensureClientMessageId(messageInfo)
+ // 1. 鍏堝鐞嗘秷鎭甫鏉ョ殑缇よ祫鏂欏彉鏇�
+ if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
+ useGroupStore().applyGroupNotification(
+ conversationInfo.targetId,
+ message.type,
+ message.content
+ )
+ }
+
+ // 2. 纭繚浼氳瘽鍜屾秷鎭紦瀛樺瓨鍦�
+ const conversation = conversationStore.ensureConversation(conversationInfo)
+ const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
+ const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
+ // 3. 宸插瓨鍦ㄦ秷鎭蛋瑕嗙洊鏇存柊
+ if (existingIndex !== -1) {
+ const existing = messages[existingIndex]
+ if (!existing) {
+ return Promise.resolve()
+ }
+ applyServerMessageUpdate(existing, message)
+ if (existingIndex === messages.length - 1) {
+ recomputeConversationLast(conversation, messages)
+ syncConversationAtFlags(conversation, message)
+ }
+ return getDb()
+ .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ await this.saveMessageRecord(existing, conversationInfo.type, tx, {
+ mergeClientRecord: hasIncomingClientMessageId
+ })
+ await conversationStore.saveConversationRecord(conversation, tx)
+ if (options?.saveMaxId !== false) {
+ await setMessageMaxId(conversationInfo.type, message.id, tx)
+ }
+ })
+ .catch((error) => {
+ console.error('[IM messageStore] 娑堟伅鍐欏叆澶辫触', error)
+ throw error
+ })
+ .then(() => {
+ this.updateMessageCursor(conversationInfo.type, message.id)
+ })
+ }
+
+ // 4. 鏂版秷鎭洿鏂颁細璇濇憳瑕佸拰鏈鐘舵��
+ applyConversationSummary(conversation, message)
+ syncConversationAtFlags(conversation, message)
+
+ const isActive =
+ conversationStore.activeConversation?.type === conversationInfo.type &&
+ conversationStore.activeConversation?.targetId === conversationInfo.targetId
+ if (
+ !message.selfSend &&
+ !isActive &&
+ !conversationStore.isMessageCoveredByReadPosition(conversation, message) &&
+ isNormalMessage(message.type) &&
+ message.status !== ImMessageStatus.RECALL
+ ) {
+ conversation.unreadCount++
+ }
+
+ // 5. 鏂版秷鎭寜 id 鎻掑叆鍒板唴瀛樻暟缁�
+ let insertIndex = messages.length
+ if (message.id) {
+ for (const [index, existing] of messages.entries()) {
+ if (existing.id && message.id < existing.id) {
+ insertIndex = index
+ break
+ }
+ }
+ }
+ messages.splice(insertIndex, 0, message)
+ // 6. 鍗曚簨鍔″啓鍏ユ秷鎭�佷細璇濇憳瑕佸拰娓告爣
+ return getDb()
+ .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ await this.saveMessageRecord(message, conversationInfo.type, tx, {
+ mergeClientRecord: hasIncomingClientMessageId && !!message.id
+ })
+ await conversationStore.saveConversationRecord(conversation, tx)
+ if (options?.saveMaxId !== false) {
+ await setMessageMaxId(conversationInfo.type, message.id, tx)
+ }
+ })
+ .catch((error) => {
+ console.error('[IM messageStore] 娑堟伅鍐欏叆澶辫触', error)
+ throw error
+ })
+ .then(() => {
+ this.updateMessageCursor(conversationInfo.type, message.id)
+ })
+ },
+
+ /** ack 鍚堝苟 */
+ ackMessage(
+ conversationType: number,
+ targetId: number,
+ clientMessageId: string,
+ updates: Partial<Message>
+ ) {
+ const mergeKey = `${conversationType}:${targetId}:${clientMessageId}`
+ const existingPromise = ackMergingPromises.get(mergeKey)
+ if (existingPromise) {
+ return existingPromise
+ }
+ const promise = this.doAckMessage(
+ conversationType,
+ targetId,
+ clientMessageId,
+ updates
+ ).finally(() => {
+ ackMergingPromises.delete(mergeKey)
+ })
+ ackMergingPromises.set(mergeKey, promise)
+ return promise
+ },
+
+ /** 鎵ц ack 鍚堝苟 */
+ async doAckMessage(
+ conversationType: number,
+ targetId: number,
+ clientMessageId: string,
+ updates: Partial<Message>
+ ) {
+ // 1. 瀹氫綅寰呭悎骞舵秷鎭�
+ const conversationStore = useConversationStore()
+ const conversation = conversationStore.getConversation(conversationType, targetId)
+ if (!conversation) {
+ return
+ }
+ const messages = this.getMessageList(conversationType, targetId)
+ const message = messages.find((item) => item.clientMessageId === clientMessageId)
+ if (!message) {
+ return
+ }
+ message._ackMerging = true
+ try {
+ // 2. 鍚堝苟鏈嶅姟绔� ack 鍒板唴瀛�
+ applyServerMessageUpdate(message, updates)
+ if (messages[messages.length - 1] === message) {
+ recomputeConversationLast(conversation, messages)
+ }
+ // 3. 鍗曚簨鍔″啓鍏ユ秷鎭�佷細璇濇憳瑕佸拰娓告爣
+ await getDb()
+ .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
+ await this.saveMessageRecord(message, conversationType, tx, {
+ mergeClientRecord: true
+ })
+ await conversationStore.saveConversationRecord(conversation, tx)
+ await setMessageMaxId(conversationType, message.id, tx)
+ })
+ .catch((error) => {
+ console.error('[IM messageStore] ack 鍐欏叆澶辫触', error)
+ throw error
+ })
+ this.updateMessageCursor(conversationType, message.id)
+ } finally {
+ // 4. 娓呯悊鍚堝苟鏍囪
+ message._ackMerging = false
+ }
+ },
+
+ /** 灞�閮ㄦ洿鏂版秷鎭� */
+ patchMessage(
+ conversationType: number,
+ targetId: number,
+ clientMessageId: string,
+ patch: Partial<Message>
+ ) {
+ const message = this.getMessageList(conversationType, targetId).find(
+ (item) => item.clientMessageId === clientMessageId
+ )
+ if (!message) {
+ return
+ }
+ let changed = false
+ for (const key in patch) {
+ if (
+ Object.prototype.hasOwnProperty.call(patch, key) &&
+ (patch as Record<string, unknown>)[key] !==
+ (message as unknown as Record<string, unknown>)[key]
+ ) {
+ changed = true
+ break
+ }
+ }
+ if (changed) {
+ applyServerMessageUpdate(message, patch)
+ }
+ },
+
+ /** 鎾ゅ洖娑堟伅 */
+ async recallMessage(
+ conversationType: number,
+ targetId: number,
+ recallSignalContent: string
+ ): Promise<void> {
+ const conversationStore = useConversationStore()
+ const changed = this.applyRecallMessageInMemory(
+ conversationType,
+ targetId,
+ recallSignalContent
+ )
+ if (!changed) {
+ return
+ }
+ await getDb()
+ .transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
+ await this.saveMessageRecord(changed.message, conversationType, tx)
+ await conversationStore.saveConversationRecord(changed.conversation, tx)
+ })
+ .catch((error) => {
+ console.error('[IM messageStore] 鎾ゅ洖娑堟伅鍐欏叆澶辫触', error)
+ throw error
+ })
+ },
+
+ /** 搴旂敤宸茶鍥炴墽 */
+ applyMessageReadReceipt(options: {
+ conversationType: number
+ groupMessageId?: number
+ privateReadMaxId?: number
+ readCount?: number
+ receiptStatus?: number
+ targetId: number
+ }) {
+ const messages = this.getMessageList(options.conversationType, options.targetId)
+ const changed: Message[] = []
+ // 1. 绉佽亰鍥炴墽鎵归噺鏇存柊鑷繁鍙戦�佺殑娑堟伅
+ if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) {
+ this.updatePrivateReadMaxId(options.targetId, options.privateReadMaxId)
+ const privateReadMaxId = options.privateReadMaxId
+ messages.forEach((message) => {
+ if (
+ message.selfSend &&
+ message.id &&
+ message.id <= privateReadMaxId &&
+ message.receiptStatus === ImMessageReceiptStatus.PENDING
+ ) {
+ message.receiptStatus = ImMessageReceiptStatus.DONE
+ changed.push(message)
+ }
+ })
+ } else if (options.conversationType === ImConversationType.GROUP && options.groupMessageId) {
+ // 2. 缇よ亰鍥炴墽鏇存柊鍗曟潯娑堟伅
+ const message = messages.find((item) => item.id === options.groupMessageId)
+ if (message) {
+ if (options.readCount !== undefined) {
+ message.readCount = options.readCount
+ }
+ if (options.receiptStatus !== undefined) {
+ message.receiptStatus = options.receiptStatus
+ }
+ changed.push(message)
+ }
+ }
+ if (changed.length === 0) {
+ return
+ }
+ // 3. 鍗曚簨鍔″啓鍏ュ彉鏇存秷鎭�
+ void getDb()
+ .transaction(['messages'], 'readwrite', async (tx) => {
+ for (const message of changed) {
+ await this.saveMessageRecord(message, options.conversationType, tx)
+ }
+ })
+ .catch((error) => console.warn('[IM messageStore] 鍥炴墽鍐欏叆澶辫触', error))
+ },
+
+ /** 鍓嶇疆鍘嗗彶娑堟伅 */
+ prependMessageList(conversationType: number, targetId: number, earlierMessages: Message[]) {
+ if (earlierMessages.length === 0) {
+ return
+ }
+ const messages = this.getMessageList(conversationType, targetId)
+ const existingIds = new Set(messages.map((message) => message.id).filter(Boolean))
+ const fresh = earlierMessages
+ .map((message) => ensureClientMessageId(message))
+ .filter((message) => message.id && !existingIds.has(message.id))
+ .toSorted((messageA, messageB) => (messageA.id || 0) - (messageB.id || 0))
+ if (fresh.length === 0) {
+ return
+ }
+ const key = getMessageCacheKey(conversationType, targetId)
+ this.messagesByConversation[key] = [...fresh, ...messages]
+ void getDb()
+ .transaction(['messages'], 'readwrite', async (tx) => {
+ for (const message of fresh) {
+ await this.saveMessageRecord(message, conversationType, tx)
+ }
+ })
+ .catch((error) => console.warn('[IM messageStore] 鍘嗗彶娑堟伅鍐欏叆澶辫触', error))
+ },
+
+ /** 鍒犻櫎鍗曟潯娑堟伅 */
+ removeMessage(
+ conversationType: number,
+ targetId: number,
+ key: { clientMessageId?: string; id?: number; }
+ ) {
+ // 1. 瀹氫綅浼氳瘽鍜屾秷鎭�
+ const conversationStore = useConversationStore()
+ const conversation = conversationStore.getConversation(conversationType, targetId)
+ if (!conversation) {
+ return
+ }
+ const messages = this.getMessageList(conversationType, targetId)
+ const index = messages.findIndex((message) => {
+ if (key.id && message.id && message.id === key.id) {
+ return true
+ }
+ return !!key.clientMessageId && message.clientMessageId === key.clientMessageId
+ })
+ if (index === -1) {
+ return
+ }
+ // 2. 浠庡唴瀛樼Щ闄ゆ秷鎭�
+ const [removed] = messages.splice(index, 1)
+ if (!removed) {
+ return
+ }
+ revokeBlobUrlsInContent(removed.content)
+ if (index === messages.length) {
+ recomputeConversationLast(conversation, messages)
+ }
+ // 3. 鍒犻櫎鏈湴璁板綍骞朵繚瀛樹細璇濇憳瑕�
+ getDb()
+ .delete('messages', getMessageKey(removed, conversationType))
+ .catch((error) => console.warn('[IM messageStore] 娑堟伅鍒犻櫎澶辫触', error))
+ conversationStore.saveConversation(conversation)
+ },
+
+ /** 鍒犻櫎浼氳瘽鍏ㄩ儴娑堟伅 */
+ deleteConversationMessageList(conversationType: number, targetId: number) {
+ // 1. 娓呯悊鍐呭瓨娑堟伅鍜屽獟浣撹祫婧�
+ const clientConversationId = getClientConversationId(conversationType, targetId)
+ const messages = this.messagesByConversation[clientConversationId] || []
+ messages.forEach((message) => {
+ revokeBlobUrlsInContent(message.content)
+ message._localFile = undefined
+ })
+ Reflect.deleteProperty(this.messagesByConversation, clientConversationId)
+ this.loadedConversationKeys = this.loadedConversationKeys.filter(
+ (key) => key !== clientConversationId
+ )
+ // 2. 鍒犻櫎 IndexedDB 娑堟伅
+ getDb()
+ .deleteByIndex('messages', 'clientConversationId', clientConversationId)
+ .catch((error) => console.warn('[IM messageStore] 浼氳瘽娑堟伅鍒犻櫎澶辫触', error))
+ }
+ }
+})
+
+export const useMessageStoreWithOut = () => useMessageStore()
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useMessageStore, import.meta.hot))
+}
diff --git a/src/views/im/home/store/rtcStore.ts b/src/views/im/home/store/rtcStore.ts
new file mode 100644
index 0000000..46a98b9
--- /dev/null
+++ b/src/views/im/home/store/rtcStore.ts
@@ -0,0 +1,450 @@
+import type { ImRtcApi } from '#/api/im/rtc'
+
+import { computed, ref } from 'vue'
+
+import { defineStore } from 'pinia'
+
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import {
+ ImConversationType,
+ type ImRtcCallEndReasonValue,
+ ImRtcCallStage,
+ type ImRtcCallStageValue,
+ ImRtcCallStatus,
+ type ImRtcParticipantStatusValue
+} from '../../utils/constants'
+import { useFriendStore } from './friendStore'
+import { useGroupStore } from './groupStore'
+
+type GroupActiveCallCache = {
+ participantsLoaded?: boolean // 鏄惁宸叉媺鍙栧畬鏁村弬涓庤�呭垪琛�
+} & ImRtcApi.RtcGroupCallRespVO
+
+// RTC_CALL 閫氳瘽淇′护杞借嵎锛涙寜 status 鍖哄垎瀛愮被鍨嬭涔�
+export interface ImRtcCallNotification {
+ status: ImRtcParticipantStatusValue
+ room: string
+ conversationType: number
+ mediaType: number
+ groupId?: number
+ // INVITE 涓撳睘锛氳鍙帴閫氶渶瑕佺殑 LiveKit 杩炴帴鍙傛暟 + 涓诲彨灞曠ず淇℃伅
+ livekitUrl?: string
+ token?: string
+ inviterUserId?: number
+ inviterNickname?: string
+ inviterAvatar?: string
+ // INVITE 涓撳睘锛氭湰娆¤閭�璇蜂汉鍒楄〃锛涘寘鍚敹浠朵汉鑷韩锛屽墠绔潵鐢靛皬鏉℃寜闇�杩囨护灞曠ず銆岄個璇风殑鍏朵粬浜恒��
+ inviteeIds?: number[]
+ // REJECT 涓撳睘锛氭搷浣滆�呭睍绀轰俊鎭紙鍏跺畠瀛愮被鍨嬭蛋 RTC_CALL_END锛�
+ operatorUserId?: number
+ operatorNickname?: string
+ operatorAvatar?: string
+}
+
+// RTC_PARTICIPANT_CONNECTED 閫氳瘽鍙備笌鑰呭姞鍏ヨ浇鑽凤紙LiveKit webhook 杞帹锛�
+export interface ImRtcParticipantConnectedNotification {
+ room: string
+ userId: number
+ conversationType: number
+ groupId?: number
+ // 缇よ亰鍦烘櫙闈為個璇锋垚鍛橀娆″~鍏呰兌鍥婃潯鐢�
+ mediaType?: number
+ inviterUserId?: number
+}
+
+// RTC_PARTICIPANT_DISCONNECTED 閫氳瘽鍙備笌鑰呯寮�杞借嵎锛圠iveKit webhook 杞帹锛�
+export interface ImRtcParticipantDisconnectedNotification {
+ room: string
+ userId: number
+ conversationType: number
+ groupId?: number
+}
+
+// RTC_CALL_END 閫氳瘽缁撴潫杞借嵎锛堝叆娑堟伅娴侊紱绉佽亰娓叉煋娑堟伅姘旀场锛岀兢鑱婃覆鏌撶郴缁熸彁绀鸿锛�
+export interface ImRtcCallEndNotification {
+ room: string
+ conversationType: number
+ mediaType: number
+ endReason: ImRtcCallEndReasonValue
+ durationSeconds?: number
+ // 鎿嶄綔鑰呰仛鍚堝瓧娈碉細HANGUP/CANCEL/REJECT 瑙﹀彂浜猴紱webhook 鍏滃簳涓� null
+ operatorUserId?: number
+ operatorNickname?: string
+ operatorAvatar?: string
+}
+
+export const useRtcStore = defineStore('imRtc', () => {
+ /** 褰撳墠闃舵 */
+ const stage = ref<ImRtcCallStageValue>(ImRtcCallStage.IDLE)
+ /** 褰撳墠閫氳瘽锛沬nvite / accept / refreshToken 鎷垮埌鐨勫畬鏁翠俊鎭� */
+ const call = ref<ImRtcApi.RtcCallRespVO | null>(null)
+ /** 鏉ョ數杞借嵎锛涗粎 INCOMING 闃舵浣跨敤锛泂tatus 鍥哄畾 INVITING锛屽叾瀹冨瓧娈� INVITE 涓撳睘 */
+ const incomingPayload = ref<ImRtcCallNotification | null>(null)
+ /** 杩涘叆 RUNNING 鐨勬椂闂存埑锛涚敤浜庨�氳瘽鏃堕暱灞曠ず锛況eset 鏃舵竻闆� */
+ const startedAt = ref(0)
+
+ /** 鏄惁澶勪簬閫氳瘽鐩稿叧闃舵 */
+ const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE)
+
+ /**
+ * 瀵圭灞曠ず鍚嶏紱鎸夐樁娈� + 浼氳瘽绫诲瀷鍒嗘敮锛�
+ * INCOMING 鍙栨潵鐢佃浇鑽风殑 inviterNickname锛涚兢閫氳瘽鍙栫兢鍚嶏紱绉佽亰鏌� friendStore 鍙嶆煡瀵圭 userId
+ */
+ const peerNickname = computed<string>(() => {
+ if (stage.value === ImRtcCallStage.INCOMING) {
+ return incomingPayload.value?.inviterNickname || ''
+ }
+ const c = call.value
+ if (!c) return ''
+ if (c.conversationType === ImConversationType.GROUP) {
+ return useGroupStore().getGroup(c.groupId ?? 0)?.name || ''
+ }
+ const peerUserId = resolvePrivatePeerUserId(c)
+ return (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || ''
+ })
+
+ /** 瀵圭澶村儚锛涚瓥鐣ュ悓 peerNickname */
+ const peerAvatar = computed<string>(() => {
+ if (stage.value === ImRtcCallStage.INCOMING) {
+ return incomingPayload.value?.inviterAvatar || ''
+ }
+ const c = call.value
+ if (!c) return ''
+ if (c.conversationType === ImConversationType.GROUP) {
+ return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || ''
+ }
+ const peerUserId = resolvePrivatePeerUserId(c)
+ return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || ''
+ })
+
+ /** 绉佽亰鍦烘櫙瀵圭 userId锛氳嚜宸辨槸涓诲彨鍒欏彇棣栦釜 invitee锛屽惁鍒欏彇 inviter */
+ function resolvePrivatePeerUserId(c: ImRtcApi.RtcCallRespVO): number | undefined {
+ const myId = getCurrentUserId()
+ return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId
+ }
+
+ /** 缇ゆ椿璺冮�氳瘽绱㈠紩锛沢roupId -> 缇ら�氳瘽鎽樿锛涚敤浜庣兢鑱婇《閮ㄨ兌鍥婃潯 */
+ const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
+
+ /**
+ * 宸查��鍑� / 宸叉嫆缁濈殑鐢ㄦ埛缂栧彿闆嗗悎锛涚兢閫氳瘽鍦烘櫙鍐� pending 鍗犱綅娓叉煋鏃舵帓闄わ紱
+ * 鏉ユ簮锛氬弬涓庤�呯寮�閫氱煡 + 缇ら�氳瘽鍗曚汉鎷掔粷鐨� operatorUserId锛涢�氳瘽缁撴潫锛坮eset锛夋椂娓呯┖
+ */
+ const leftUserIds = ref<Set<number>>(new Set())
+
+ /** 鏄惁宸茶褰曟煇 userId 宸查��鍑� / 鎷掔粷 */
+ function isUserLeft(userId: number): boolean {
+ return leftUserIds.value.has(userId)
+ }
+
+ /** 鏍囪鏌愪釜 userId 宸查��鍑� / 鎷掔粷锛涚敤浜� pending 鍗犱綅娓叉煋鏃舵帓闄� */
+ function markUserLeft(userId: number) {
+ if (!userId || leftUserIds.value.has(userId)) {
+ return
+ }
+ leftUserIds.value = new Set([...leftUserIds.value, userId])
+ }
+
+ /**
+ * 涓诲彨鍙戣捣閫氳瘽锛涙寜浼氳瘽绫诲瀷 + status 鍐冲畾 stage锛�
+ * 缇ら�氳瘽锛氬彂璧蜂汉鐩存帴杩� RUNNING 澶氫汉鍗$墖瑙嗗浘锛屾埧鍐呭彲鑳藉彧鏈夎嚜宸憋紝绛夊叾浠栦汉闄嗙画鍔犲叆锛�
+ * 绉佽亰锛氭寜 status 璧帮紱RUNNING锛堝凡鍔犲叆宸叉湁閫氳瘽鍦烘櫙锛夆啋 RUNNING锛汣REATED 鈫� INVITING 绛夎鍙帴閫�
+ */
+ function startInviting(data: ImRtcApi.RtcCallRespVO) {
+ call.value = data
+ // 缇ら�氳瘽鍦烘櫙鍐欏叆鏈湴鑳跺泭鏉$紦瀛�
+ syncGroupActiveCall(data)
+ // 鏇存柊 stage 鐘舵��
+ if (data.conversationType === ImConversationType.GROUP) {
+ stage.value = ImRtcCallStage.RUNNING
+ startedAt.value = Date.now()
+ return
+ }
+ const running = data.status === ImRtcCallStatus.RUNNING
+ stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING
+ if (running) {
+ startedAt.value = Date.now()
+ }
+ }
+
+ /** 琚彨鏀跺埌鏉ョ數锛涘垏鍒� INCOMING锛涙帴鏀� RTC_CALL(INVITE) payload */
+ function showIncoming(payload: ImRtcCallNotification) {
+ if (isActive.value) {
+ return
+ }
+ incomingPayload.value = payload
+ stage.value = ImRtcCallStage.INCOMING
+ // 鎸� inviter 鍏滃簳棣栨濉厖鑳跺泭鏉�
+ syncGroupActiveCall({
+ conversationType: payload.conversationType,
+ room: payload.room,
+ groupId: payload.groupId,
+ mediaType: payload.mediaType,
+ inviterId: payload.inviterUserId ?? 0,
+ joinedUserIds: payload.inviterUserId ? [payload.inviterUserId] : [],
+ inviteeIds: payload.inviteeIds
+ })
+ }
+
+ /** 杩涘叆閫氳瘽涓樁娈� */
+ function enterRunning(data: ImRtcApi.RtcCallRespVO) {
+ call.value = data
+ // 绂诲紑 INCOMING 闃舵锛涙竻绌烘潵鐢佃浇鑽�
+ incomingPayload.value = null
+ stage.value = ImRtcCallStage.RUNNING
+ startedAt.value = Date.now()
+ // 鎺ラ�氬悗鐢� RespVO 瀹屾暣瑕嗙洊鑳跺泭鏉�
+ syncGroupActiveCall(data)
+ }
+
+ /**
+ * 缇ら�氳瘽鍦烘櫙鍚屾鏈湴 groupActiveCalls 缂撳瓨锛涢潪缇ら�氳瘽鎴栫己 groupId 鐩存帴杩斿洖锛�
+ * 涓嶄緷璧栧悗绔� webhook 鎺ㄩ�佺殑 RTC_PARTICIPANT_CONNECTED 棣栨濉厖锛岄伩鍏嶈兌鍥婃潯鍑虹幇寤惰繜锛�
+ * 琚彨鍦烘櫙閫氱煡杞借嵎鏃� joinedUserIds锛岃皟鐢ㄦ柟鎸変富鍙汉鍏滃簳锛屽悗缁� getActiveCall / 鍙備笌鑰呬簨浠跺埛鏂版垚瀹屾暣鍒楄〃
+ */
+ function syncGroupActiveCall(input: {
+ conversationType: number
+ groupId?: number
+ inviteeIds?: number[]
+ inviterId: number
+ joinedUserIds?: number[]
+ mediaType: number
+ room: string
+ }) {
+ if (input.conversationType !== ImConversationType.GROUP || !input.groupId) {
+ return
+ }
+ // 鍐欏叆鎴栨洿鏂扮兢娲昏穬閫氳瘽缂撳瓨
+ setGroupCall({
+ room: input.room,
+ groupId: input.groupId,
+ mediaType: input.mediaType,
+ inviterId: input.inviterId,
+ joinedUserIds: input.joinedUserIds ?? [],
+ inviteeIds: input.inviteeIds ?? []
+ })
+ }
+
+ /** 閲嶇疆锛涢�氳瘽缁撴潫缁熶竴璋冪敤 */
+ function reset() {
+ stage.value = ImRtcCallStage.IDLE
+ call.value = null
+ incomingPayload.value = null
+ startedAt.value = 0
+ leftUserIds.value = new Set()
+ }
+
+ /** 閫氳瘽涓拷鍔犺閭�璇蜂汉锛涜 participants 缃戞牸鍑虹幇 pending 鍗犱綅銆佽兌鍥婃潯鍚屾鏇存柊 */
+ function appendInvitees(userIds: number[]) {
+ if (!call.value || userIds.length === 0) {
+ return
+ }
+ const existing = call.value.inviteeIds ?? []
+ const merged = [...new Set([...existing, ...userIds])]
+ if (merged.length === existing.length) {
+ return
+ }
+ call.value = { ...call.value, inviteeIds: merged }
+ syncGroupActiveCall(call.value)
+ }
+
+ // ==================== 缇ら�氳瘽鑳跺泭鏉$姸鎬� ====================
+
+ /**
+ * 缇ら�氳瘽寮�濮� / 鐘舵�佸埛鏂帮細鍐欏叆 / 鏇存柊 groupActiveCalls锛涘睍绀虹敤銆岃兌鍥婃潯銆嶏紱
+ * 鎴垮唴鎴愬憳鍚屾浜ょ粰 LiveKit 瀹㈡埛绔簨浠讹紙ParticipantConnected / Disconnected锛夛紱
+ * 鑳跺泭鏉′笉瀹炴椂鍒锋柊 joinedUserIds / inviteeIds锛屽睍寮� / 鍔犲叆鏃跺啀璧� getActiveCall 鎺ュ彛鎷夋渶鏂�
+ */
+ function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, participantsLoaded?: boolean) {
+ if (!payload?.groupId) {
+ return
+ }
+ useGroupStore().markGroupActiveCallLoaded(payload.groupId)
+ // 娴呮瘮杈冿細room / mediaType / joinedUserIds / inviteeIds 閮芥病鍙樺氨璺宠繃锛岄伩鍏嶄笅娓� watcher 鏃犳剰涔夐噸绠�
+ const existing = groupActiveCalls.value.get(payload.groupId)
+ const nextParticipantsLoaded =
+ participantsLoaded ?? (existing?.room === payload.room && !!existing.participantsLoaded)
+ if (
+ existing &&
+ isSameGroupCall(existing, payload) &&
+ !!existing.participantsLoaded === nextParticipantsLoaded
+ ) {
+ return
+ }
+ const newGroupActiveCalls = new Map(groupActiveCalls.value)
+ newGroupActiveCalls.set(payload.groupId, {
+ ...payload,
+ participantsLoaded: nextParticipantsLoaded
+ })
+ groupActiveCalls.value = newGroupActiveCalls
+ }
+
+ /** 娓呯┖鎸囧畾缇ょ殑閫氳瘽缂撳瓨 */
+ function clearGroupCallCache(groupId?: number) {
+ if (!groupId) {
+ groupActiveCalls.value = new Map()
+ return
+ }
+ const next = new Map(groupActiveCalls.value)
+ next.delete(groupId)
+ groupActiveCalls.value = next
+ }
+
+ /** 鍒ゆ柇缇ら�氳瘽鏄惁宸茶ˉ榻� */
+ function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
+ const call = groupActiveCalls.value.get(groupId)
+ return (
+ !!groupId &&
+ !!room &&
+ !!call &&
+ call.room === room &&
+ !!call.participantsLoaded
+ )
+ }
+
+ /** 涓ゆ潯缇ら�氳瘽鎽樿鍐呭鐩哥瓑锛坮oom / mediaType / inviterId / 涓や釜 userId 鏁扮粍閫愰」鐩哥瓑锛� */
+ function isSameGroupCall(a: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
+ if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
+ return false
+ }
+ return isSameNumberList(a.joinedUserIds ?? [], b.joinedUserIds ?? []) &&
+ isSameNumberList(a.inviteeIds ?? [], b.inviteeIds ?? [])
+ }
+
+ /** 鍒ゆ柇涓や釜鐢ㄦ埛缂栧彿鍒楄〃鏄惁閫愰」鐩哥瓑 */
+ function isSameNumberList(a: number[], b: number[]): boolean {
+ if (a.length !== b.length) {
+ return false
+ }
+ return a.every((item, index) => item === b[index])
+ }
+
+ /** 缇ら�氳瘽缁撴潫锛氫粠 groupActiveCalls 绉婚櫎锛涜兌鍥婃潯娑堝け */
+ function removeGroupCall(groupId: number, room?: string) {
+ if (!groupId) {
+ return
+ }
+ const existing = groupActiveCalls.value.get(groupId)
+ if (room && existing?.room !== room) {
+ return
+ }
+ clearGroupCallCache(groupId)
+ useGroupStore().markGroupActiveCallLoaded(groupId)
+ }
+
+ /** 鑾峰彇缇ゅ綋鍓嶆椿璺冮�氳瘽锛涚敤浜庤兌鍥婃潯鎸� groupId 鏌ヨ */
+ function getGroupCall(groupId: number): ImRtcApi.RtcGroupCallRespVO | undefined {
+ return groupActiveCalls.value.get(groupId)
+ }
+
+ /** 閫氳瘽鍙備笌鑰呭姞鍏ワ細鎶� userId 鍔犺繘 joinedUserIds锛涚兢鑱婂満鏅棤娲昏穬璁板綍鏃堕娆″~鍏呰兌鍥婃潯 */
+ function applyParticipantConnected(payload: ImRtcParticipantConnectedNotification) {
+ const isGroup = payload.conversationType === ImConversationType.GROUP
+ if (!isGroup || !payload.groupId) {
+ return
+ }
+ // 鑳跺泭鏉℃噿濉厖锛氭湰绔彲鑳藉湪閫氳瘽寮�濮嬪悗鎵嶆墦寮�璇ョ兢浼氳瘽锛屾病鏀跺埌杩� setGroupCall锛�
+ // 姝ゅ鐢ㄥ姞鍏ラ�氱煡寤轰竴鏉℃渶灏忚褰曪紝inviteeIds 鐣欑┖锛屽睍寮� popover / 鍔犲叆鏃跺啀璧� getActiveCall 琛�
+ const existing = groupActiveCalls.value.get(payload.groupId)
+ if (!existing) {
+ setGroupCall({
+ room: payload.room,
+ groupId: payload.groupId,
+ mediaType: payload.mediaType ?? 0,
+ inviterId: payload.inviterUserId ?? 0,
+ joinedUserIds: [payload.userId],
+ inviteeIds: []
+ })
+ return
+ }
+ if (existing.room !== payload.room) {
+ return
+ }
+ const joined = existing.joinedUserIds ?? []
+ if (joined.includes(payload.userId)) {
+ return
+ }
+ setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] })
+ }
+
+ /** 閫氳瘽鍙備笌鑰呯寮�锛氫粠 joinedUserIds 绉婚櫎锛涘悓鏃舵爣璁� leftUserIds锛坧ending 鍗犱綅娓叉煋鎺掗櫎锛� */
+ function applyParticipantDisconnected(payload: ImRtcParticipantDisconnectedNotification) {
+ markUserLeft(payload.userId)
+ const isGroup = payload.conversationType === ImConversationType.GROUP
+ if (!isGroup || !payload.groupId) {
+ return
+ }
+ dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId)
+ }
+
+ /** 缇ら�氳瘽鍗曚汉鎷掔粷閭�璇凤細鏍囪 leftUserIds + 浠庤兌鍥婃潯 inviteeIds 绉婚櫎锛堢鑱婃嫆缁濊蛋 RTC_CALL_END锛屼笉鍏ユ湰閫氶亾锛� */
+ function applyParticipantRejected(
+ payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'>
+ ) {
+ if (!payload.operatorUserId) {
+ return
+ }
+ markUserLeft(payload.operatorUserId)
+ if (payload.conversationType === ImConversationType.GROUP && payload.groupId) {
+ dropFromGroupActiveCall(payload.groupId, payload.room, payload.operatorUserId)
+ }
+ }
+
+ /** 缇ら�氳瘽鍗曚汉鎸搩瓒呮椂锛涘 banner 鐨勫鐞嗕笌鎷掓帴涓�鑷达紙璇箟鐙珛銆佸疄鐜板叡浜級 */
+ function applyParticipantNoAnswer(
+ payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'>
+ ) {
+ applyParticipantRejected(payload)
+ }
+
+ /** 浠庢寚瀹氱兢娲昏穬閫氳瘽鐨� joined / pending 鍒楄〃閲屽悓姝ョЩ闄ゆ煇鐢ㄦ埛锛涚敤浜� disconnect / reject 璁╄兌鍥婃潯涓嶅啀灞曠ず */
+ function dropFromGroupActiveCall(groupId: number, room: string, userId: number) {
+ const existing = groupActiveCalls.value.get(groupId)
+ if (!existing || existing.room !== room) {
+ return
+ }
+ const joined = existing.joinedUserIds ?? []
+ const invitee = existing.inviteeIds ?? []
+ const nextJoined = joined.filter((id) => id !== userId)
+ const nextInvitee = invitee.filter((id) => id !== userId)
+ if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
+ return
+ }
+ if (nextJoined.length === 0 && nextInvitee.length === 0) {
+ removeGroupCall(groupId, room)
+ return
+ }
+ setGroupCall({
+ ...existing,
+ joinedUserIds: nextJoined,
+ inviteeIds: nextInvitee
+ })
+ }
+
+ return {
+ stage,
+ call,
+ incomingPayload,
+ peerNickname,
+ peerAvatar,
+ startedAt,
+ isActive,
+ startInviting,
+ showIncoming,
+ enterRunning,
+ reset,
+ appendInvitees,
+ markUserLeft,
+ isUserLeft,
+ setGroupCall,
+ isGroupCallParticipantsLoaded,
+ clearGroupCallCache,
+ removeGroupCall,
+ getGroupCall,
+ applyParticipantConnected,
+ applyParticipantDisconnected,
+ applyParticipantRejected,
+ applyParticipantNoAnswer
+ }
+})
diff --git a/src/views/im/home/store/uiStore.ts b/src/views/im/home/store/uiStore.ts
new file mode 100644
index 0000000..93d0b6e
--- /dev/null
+++ b/src/views/im/home/store/uiStore.ts
@@ -0,0 +1,155 @@
+import type { GroupLite, User } from '../types'
+
+import { reactive, ref } from 'vue'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { ImFriendAddSource } from '../../utils/constants'
+
+/**
+ * IM 鍏ㄥ眬 UI store
+ *
+ * 鏀剁撼鏍囧噯锛氳Е鍙戠偣 N 涓�佹寕杞界偣鎯充繚鎸� 1 涓殑娴眰鐘舵�併��
+ * 浠绘剰浣嶇疆閮藉彲鑳� open锛屼絾 DOM 涓婂彧鎯崇暀涓�浠藉疄渚� 鈫� 璧� store 娲惧彂锛�
+ * 鐢� `index.vue` 鎸備竴涓闃呯粍浠剁粺涓�娓叉煋銆�
+ */
+export const useImUiStore = defineStore('imUiStore', () => {
+ // ==================== 鐢ㄦ埛鍚嶇墖 UserInfoCard ====================
+ // 鐢ㄦ埛鍚嶇墖鎮诞鍗�
+
+ const userInfoCard = reactive({
+ show: false,
+ user: null as null | User,
+ position: { x: 0, y: 0 },
+ // addSource / addSourceExtra 璺熼殢瑙﹀彂鐐瑰甫鍏ャ�屽姞濂藉弸銆嶆潵婧愶紙缇ゆ垚鍛樺叆鍙� = GROUP + 缇ゅ悕锛涘叾浣欓粯璁ゆ悳绱級
+ addSource: ImFriendAddSource.SEARCH as number,
+ addSourceExtra: '' as string
+ })
+
+ /** 鎵撳紑鐢ㄦ埛鍚嶇墖 */
+ function openUserInfoCard(
+ user: User,
+ position: { x: number; y: number },
+ addSource: number = ImFriendAddSource.SEARCH,
+ addSourceExtra: string = ''
+ ) {
+ const viewportWidth = document.documentElement.clientWidth
+ const viewportHeight = document.documentElement.clientHeight
+ userInfoCard.user = user
+ userInfoCard.position.x = Math.min(position.x, viewportWidth - 350)
+ userInfoCard.position.y = Math.min(position.y, viewportHeight - 220)
+ userInfoCard.addSource = addSource
+ userInfoCard.addSourceExtra = addSourceExtra
+ userInfoCard.show = true
+ }
+
+ /** 榧犳爣鐐瑰嚮浣嶇疆 + 20px 妯悜鍋忕Щ鎵撳紑鍚嶇墖锛氶伩鍏嶅悕鐗囩洿鎺ヨ鐩栬Е鍙戝厓绱狅紝瀵归綈澶村儚 / 鍚嶇墖娑堟伅绛夌偣鍑讳氦浜掔殑缁熶竴瑙傛劅 */
+ function openUserInfoCardAtEvent(
+ user: User,
+ e: MouseEvent,
+ addSource: number = ImFriendAddSource.SEARCH,
+ addSourceExtra: string = ''
+ ) {
+ openUserInfoCard(user, { x: e.clientX + 20, y: e.clientY }, addSource, addSourceExtra)
+ }
+
+ /** 鍏抽棴鐢ㄦ埛鍚嶇墖 */
+ function closeUserInfoCard() {
+ userInfoCard.show = false
+ }
+
+ // ==================== 缇ゅ悕鐗� GroupInfoCard ====================
+
+ const groupInfoCard = reactive({
+ show: false,
+ group: null as GroupLite | null,
+ position: { x: 0, y: 0 }
+ })
+
+ /** 榧犳爣鐐瑰嚮浣嶇疆 + 20px 妯悜鍋忕Щ鎵撳紑缇ゅ悕鐗囷紝瀵归綈 UserInfoCard 鐨勭粺涓�瑙傛劅 */
+ function openGroupInfoCardAtEvent(group: GroupLite, e: MouseEvent) {
+ const viewportWidth = document.documentElement.clientWidth
+ const viewportHeight = document.documentElement.clientHeight
+ groupInfoCard.group = group
+ groupInfoCard.position.x = Math.min(e.clientX + 20, viewportWidth - 350)
+ groupInfoCard.position.y = Math.min(e.clientY, viewportHeight - 220)
+ groupInfoCard.show = true
+ }
+
+ /** 鍏抽棴缇ゅ悕鐗� */
+ function closeGroupInfoCard() {
+ groupInfoCard.show = false
+ }
+
+ // ==================== 鍙抽敭鑿滃崟 ContextMenu ====================
+ // 鍙抽敭鑿滃崟铏界劧鏄竴涓粍浠舵寕鍦ㄤ富澹充笂锛屼絾鍏惰Е鍙戞椂鏈哄垎鏁e湪鍚勫垪琛�
+ interface ContextMenuItem {
+ key: string
+ name: string
+ disabled?: boolean
+ divided?: boolean // 鏄惁鍦ㄨ椤逛笂鏂规樉绀哄垎鍓茬嚎锛堢敤浜庢妸"鍒犻櫎"绛夊嵄闄╂搷浣滀笌涓婇潰鐨勫父瑙勯」闅斿紑锛�
+ danger?: boolean // 鏄惁璧板嵄闄╂搷浣滄牱寮忥紙绾㈣壊鏂囧瓧锛�
+ icon?: string // 鍙�� iconify 鍥炬爣鍚嶏紙濡� ant-design:delete-outlined锛夛紱涓嶄紶鍒欎笉娓叉煋鍓嶇疆鍥炬爣
+ }
+
+ const contextMenu = reactive({
+ show: false,
+ position: { x: 0, y: 0 },
+ items: [] as ContextMenuItem[],
+ /** 閫変腑鍥炶皟锛氭瘡娆� open 鏃剁敱璋冪敤鏂逛紶鍏� */
+ onSelect: null as ((item: ContextMenuItem) => void) | null
+ })
+
+ /** 鎵撳紑鍙抽敭鑿滃崟 */
+ function openContextMenu(
+ position: { x: number; y: number },
+ items: ContextMenuItem[],
+ onSelect: (item: ContextMenuItem) => void
+ ) {
+ contextMenu.position = position
+ contextMenu.items = items
+ contextMenu.onSelect = onSelect
+ contextMenu.show = true
+ }
+
+ /** 鍏抽棴鍙抽敭鑿滃崟 */
+ function closeContextMenu() {
+ contextMenu.show = false
+ contextMenu.onSelect = null
+ }
+
+ // ==================== 娑堟伅 Tab 璺宠浆涓嬩竴鏈 ====================
+ // 鍦� ImHomeConversation 椤甸潰鍐嶆鐐瑰嚮宸ュ叿鏍忋�屾秷鎭�嶆椂瑙﹀彂锛�
+ // 閫氳繃閫掑 nonce 璁� conversation/index.vue 鐨� watch 鎰熺煡鍚庢墽琛屾粴鍔� + 楂樹寒
+
+ const nextUnreadJumpNonce = ref(0)
+
+ /** 璇锋眰婊氬姩鍒颁笅涓�涓湭璇讳細璇濓紙鍚厤鎵撴壈锛� */
+ function requestNextUnreadJump() {
+ nextUnreadJumpNonce.value++
+ }
+
+ return {
+ userInfoCard,
+ openUserInfoCard,
+ openUserInfoCardAtEvent,
+ closeUserInfoCard,
+
+ groupInfoCard,
+ openGroupInfoCardAtEvent,
+ closeGroupInfoCard,
+
+ contextMenu,
+ openContextMenu,
+ closeContextMenu,
+
+ nextUnreadJumpNonce,
+ requestNextUnreadJump
+ }
+})
+
+// dev: 璁� Pinia 鐨� actions / state 鏀瑰姩鏀寔 HMR锛岄伩鍏嶆瘡娆℃敼 store 閮藉緱纭埛
+// 鍚﹀垯 Vite 鎶婃柊妯″潡鎺ㄤ笅鏉ュ悗锛岃�� store 瀹炰緥鐨� action 闂寘浠嶆寚鍚戞棫鍑芥暟浣�
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useImUiStore, import.meta.hot))
+}
diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts
new file mode 100644
index 0000000..f948bcd
--- /dev/null
+++ b/src/views/im/home/store/websocketStore.ts
@@ -0,0 +1,1259 @@
+import type {
+ Group,
+ ImGroupMessageNotification,
+ ImMessageReadNotification,
+ ImMessageReceiptNotification,
+ ImNoConversationNotification,
+ ImNotificationWebSocketDTO,
+ ImPrivateMessageNotification,
+ Message,
+ WebSocketFrame
+} from '../types'
+
+import type { ImChannelMessageApi } from '#/api/im/message/channel'
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+
+import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'
+import { readGroupMessages as apiReadGroupMessages } from '#/api/im/message/group'
+import { readPrivateMessages as apiReadPrivateMessages } from '#/api/im/message/private'
+import { getCurrentUserId, getRefreshToken } from '#/views/im/utils/auth'
+
+import { buildChannelConversationStub } from '../../utils/channel'
+import {
+ MESSAGE_GROUP_READ_ENABLED,
+ MESSAGE_PRIVATE_READ_ENABLED,
+ WS_RECONNECT_BASE_MS,
+ WS_RECONNECT_JITTER_MS,
+ WS_RECONNECT_MAX_MS
+} from '../../utils/config'
+import {
+ ImContentType,
+ ImConversationType,
+ ImMessageReceiptStatus,
+ ImMessageStatus,
+ ImRtcCallMediaType,
+ ImRtcParticipantStatus,
+ ImWebSocketMessageType,
+ isFriendChatTip,
+ isFriendNotification,
+ isGroupRequestNotification,
+ isNormalMessage
+} from '../../utils/constants'
+import {
+ getPrivateMessagePeerId,
+ parseRtcCallPayload,
+ playAudioTip,
+ resolveCallEndReasonText
+} from '../../utils/message'
+import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
+import { useConversationStore } from './conversationStore'
+import { type FriendNotificationPayload, useFriendStore } from './friendStore'
+import { useGroupRequestStore } from './groupRequestStore'
+import { useGroupStore } from './groupStore'
+import { useMessageStore } from './messageStore'
+import {
+ type ImRtcCallEndNotification,
+ type ImRtcCallNotification,
+ type ImRtcParticipantConnectedNotification,
+ type ImRtcParticipantDisconnectedNotification,
+ useRtcStore
+} from './rtcStore'
+
+/** FRIEND_DELETE 甯� payload 鏄惁甯� clear=true锛歝lear 璇箟鏄竻浼氳瘽鏈韩锛岃烦杩囨皵娉℃覆鏌� */
+const isFriendDeleteWithClear = (frame: ImPrivateMessageNotification): boolean => {
+ if (frame.type !== ImContentType.FRIEND_DELETE) {
+ return false
+ }
+ try {
+ const payload = JSON.parse(frame.content || '{}') as { clear?: boolean }
+ return payload.clear === true
+ } catch {
+ return false
+ }
+}
+
+/** 浠庣鑱婃秷鎭抚瑙f瀽濂藉弸閫氱煡 payload */
+const parseFriendNotificationPayload = (
+ frame: ImPrivateMessageNotification
+): FriendNotificationPayload => JSON.parse(frame.content || '{}') as FriendNotificationPayload
+
+/** 绉佽亰娑堟伅甯ф槸鍚﹀彲鎺ㄦ柇濂藉弸瀵圭 */
+const isPrivateMessageNotification = (
+ frame: ImNoConversationNotification | ImPrivateMessageNotification
+): frame is ImPrivateMessageNotification => 'senderId' in frame && 'receiverId' in frame
+
+const RTC_LIVEKIT_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:'])
+const RTC_MEDIA_TYPES = new Set<number>(Object.values(ImRtcCallMediaType))
+
+/** 蹇界暐鏅�氬疄鏃跺抚鎸佷箙鍖栧け璐� */
+function ignoreRealtimePersistError(promise: Promise<void>): void {
+ void promise.catch(() => undefined)
+}
+
+interface WebSocketListenerSet {
+ close: (event: CloseEvent) => void
+ error: (event: Event) => void
+ message: (event: MessageEvent) => void
+ open: (event: Event) => void
+}
+
+const websocketListenerMap = new WeakMap<WebSocket, WebSocketListenerSet>()
+
+/** 缁戝畾 WebSocket 浜嬩欢 */
+function bindWebSocketListeners(socket: WebSocket, listeners: WebSocketListenerSet): void {
+ websocketListenerMap.set(socket, listeners)
+ socket.addEventListener('open', listeners.open)
+ socket.addEventListener('message', listeners.message)
+ socket.addEventListener('close', listeners.close)
+ socket.addEventListener('error', listeners.error)
+}
+
+/** 瑙g粦 WebSocket 浜嬩欢 */
+function unbindWebSocketListeners(socket: WebSocket): void {
+ const listeners = websocketListenerMap.get(socket)
+ if (!listeners) {
+ return
+ }
+ socket.removeEventListener('open', listeners.open)
+ socket.removeEventListener('message', listeners.message)
+ socket.removeEventListener('close', listeners.close)
+ socket.removeEventListener('error', listeners.error)
+ websocketListenerMap.delete(socket)
+}
+
+/** 鏍¢獙 LiveKit 杩炴帴鍦板潃 */
+function isValidLiveKitUrl(url?: string): boolean {
+ if (!url) {
+ return false
+ }
+ try {
+ return RTC_LIVEKIT_PROTOCOLS.has(new URL(url).protocol)
+ } catch {
+ return false
+ }
+}
+
+/** 鏍¢獙鏉ョ數淇′护杞借嵎 */
+function isValidRtcInvitePayload(payload: ImRtcCallNotification): boolean {
+ if (!payload.room || !payload.token || !isValidLiveKitUrl(payload.livekitUrl)) {
+ return false
+ }
+ if (!RTC_MEDIA_TYPES.has(payload.mediaType) || !payload.inviterUserId) {
+ return false
+ }
+ if (payload.conversationType === ImConversationType.PRIVATE) {
+ return true
+ }
+ return payload.conversationType === ImConversationType.GROUP && !!payload.groupId
+}
+
+/**
+ * WebSocket 绉佽亰 DTO -> 鍓嶇 Message锛泃argetId 鏄細璇濅富閿紙瀵圭 userId锛�
+ * 涓嶅啓鍙戦�佷汉鍚嶅瓧娈碉細娓叉煋灞傝蛋 utils/user 瀹炴椂绠楋紙澶囨敞 / 缇ゆ樀绉板彉鏇村悗鍘嗗彶娑堟伅鑷姩鍒锋柊锛�
+ */
+const convertPrivateMessage = (
+ websocketMessage: ImPrivateMessageNotification,
+ currentUserId: number
+): Message => ({
+ id: websocketMessage.id,
+ clientMessageId: websocketMessage.clientMessageId,
+ type: websocketMessage.type,
+ content: websocketMessage.content,
+ status: websocketMessage.status,
+ receiptStatus: websocketMessage.receiptStatus,
+ sendTime: new Date(websocketMessage.sendTime).getTime(),
+ senderId: websocketMessage.senderId,
+ targetId: getPrivateMessagePeerId(websocketMessage, currentUserId),
+ selfSend: websocketMessage.senderId === currentUserId
+})
+
+/**
+ * WebSocket 缇よ亰 DTO -> 鍓嶇 Message
+ * 甯� atUserIds / receiverUserIds 缁� @ 鏍囪鍜屽畾鍚戞帴鏀剁敤锛�
+ * receiptStatus / readCount 璁╁绔悓姝ユ敹鍒拌嚜宸卞彂鐨勭兢娑堟伅鏃跺洖鎵� UI 绔嬪埢灏辨湁鏁版嵁
+ */
+const convertGroupMessage = (
+ websocketMessage: ImGroupMessageNotification,
+ currentUserId: number
+): Message => ({
+ id: websocketMessage.id,
+ clientMessageId: websocketMessage.clientMessageId,
+ type: websocketMessage.type,
+ content: websocketMessage.content,
+ status: websocketMessage.status,
+ sendTime: new Date(websocketMessage.sendTime).getTime(),
+ senderId: websocketMessage.senderId,
+ targetId: websocketMessage.groupId,
+ selfSend: websocketMessage.senderId === currentUserId,
+ atUserIds: websocketMessage.atUserIds || [],
+ receiverUserIds: websocketMessage.receiverUserIds || [],
+ receiptStatus: websocketMessage.receiptStatus,
+ readCount: websocketMessage.readCount
+})
+
+/**
+ * IM WebSocket Store
+ *
+ * 鑱岃矗锛堜笉鍙槸杩為�氫俊锛屼篃鏄悗绔� IM 浜嬩欢鐨勭粺涓�鍏ュ彛 鈫� 鑱斿姩 conversationStore / friendStore / groupStore锛夛細
+ *
+ * 1. 閾捐矾绠$悊锛氬缓杩� / 鏂繛 / 蹇冭烦淇濇椿 / 鑷姩閲嶈繛
+ * 2. 甯у垎鍙戯細dispatchFrame 鈫� dispatchPrivateFrame / dispatchGroupFrame锛屾寜浼氳瘽鍜屽唴瀹圭被鍨嬪垎娴�
+ * 3. 缂撳啿锛氬垵濮嬪寲鍔犺浇鏈燂紙conversationStore.loading=true锛夋殏瀛樻秷鎭紝绛� pull 瀹屾垚鍚庣敱 useMessagePuller 璋� flushBuffer 鍥炴斁
+ * 4. 浜嬩欢澶勭悊锛堟寜绫诲瀷鍒嗗彂鍒板搴� handle*锛岃仈鍔� conversation / friend / group store锛夛細
+ * - 鏅�氭秷鎭紙TEXT / IMAGE / FILE / VOICE / VIDEO锛夛細鍏ュ簱 + 褰撳墠浼氳瘽鑷姩宸茶 / 鎻愮ず闊�
+ * - 宸茶 / 鍥炴墽锛圧EAD / RECEIPT锛夛細澶氱宸茶鍚屾銆佸鏂硅鍚庡洖鎵�
+ * - 濂藉弸鍙樻洿锛團RIEND_*锛夛細鍚屾 friendStore + 绾ц仈鍒锋柊绉佽亰浼氳瘽锛汧RIEND_ADD / FRIEND_DELETE 棰濆鎻掑叆浼氳瘽姘旀场
+ * - 缇や釜浜轰俊鍙凤紙GROUP_MEMBER_SETTING_UPDATE锛夛細鍚屾 groupStore + 绾ц仈鍒锋柊缇よ亰浼氳瘽
+ * - 缇ゆ垚鍛樻樀绉板彉鏇达紙GROUP_MEMBER_NICKNAME_UPDATE锛夛細鍚屾 groupStore锛屼笉鎻掑叆娑堟伅鍒楄〃
+ * - 缇ゅ箍鎾簨浠讹紙GROUP_*锛夛細璧� handleGroupMessage + applyGroupNotification 鏃佽矾锛堝惈 DISSOLVE / QUIT / KICK 鑷垽娓呯兢锛�
+ */
+export const useImWebSocketStore = defineStore('imWebSocketStore', {
+ state: () => ({
+ socket: null as null | WebSocket,
+ isConnected: false,
+ reconnectTimer: null as null | ReturnType<typeof setTimeout>,
+ /** 杩炵画閲嶈繛澶辫触娆℃暟锛沷nopen 鎴愬姛 / disconnect 涓诲姩鏂紑鍚庢竻闆讹紝鐢ㄤ簬鎸囨暟閫�閬� */
+ reconnectAttempts: 0,
+ heartbeatTimer: null as null | ReturnType<typeof setInterval>,
+ messageBuffer: [] as Array<
+ | { conversationType: typeof ImConversationType.CHANNEL; payload: ImChannelMessageApi.ChannelMessageRespVO }
+ | {
+ conversationType: typeof ImConversationType.GROUP
+ payload: ImGroupMessageNotification
+ }
+ | {
+ conversationType: typeof ImConversationType.PRIVATE
+ payload: ImPrivateMessageNotification
+ }
+ > // 鍒濆鍖栧姞杞芥湡鍐咃紝鍏堟妸鏅�氭秷鎭涪杩涚紦鍐插尯锛宲ull 瀹屾垚鍚庡啀涓�娆℃�у洖鏀�
+ }),
+
+ actions: {
+ /**
+ * 鍙栧嚭缂撳啿鍖烘秷鎭苟娓呯┖锛堢敱 useMessagePuller 鍦� pull 瀹屾垚鍚庤皟鐢紝缁熶竴鍥炴斁缁� conversationStore锛�
+ * 閰嶅悎 messageBuffer 瀹炵幇锛氬湪 conversationStore.loading 鏈熼棿鏀跺埌鐨� WS 娑堟伅鍏堟殏瀛橈紝閬垮厤鍜� pull 鐨� minId 娓告爣鎵撴灦
+ */
+ flushBuffer() {
+ const msgs = [...this.messageBuffer]
+ this.messageBuffer = []
+ return msgs
+ },
+
+ /** 鐩存帴涓㈠純缂撳啿甯т笉鍥炴斁锛坈ancelPull / 绂诲紑 IM 璋冪敤锛岄槻姝笅娆¤繘 IM 鎶婃棫 session 甯у洖鏀捐繘鏂� store锛� */
+ discardBuffer() {
+ this.messageBuffer = []
+ },
+
+ /**
+ * 杩炴帴 WebSocket
+ * 澶嶇敤 yudao 鍐呯疆 /infra/ws 閫氶亾锛屽悗绔�氳繃 sendObject(type, content) 涓嬪彂
+ *
+ * 璋冪敤濂戠害锛氬垏璐﹀彿 / token 鍒锋柊鍓嶅繀椤诲厛 `disconnect()` 鍐� `connect()`锛�
+ * 鏈柟娉曚笉鎰熺煡 token 鍙樺寲锛屾棫 socket 鍦� CONNECTING / OPEN 鐘舵�佷細鐩存帴澶嶇敤鏃� token锛屽彲鑳芥嬁鍒伴敊璇韩浠�
+ */
+ connect() {
+ // 閴存潈鐢� refreshToken锛堢敓鍛藉懆鏈熸洿闀匡紱access token 杩囨湡鍚庢湇鍔$浼氶�氳繃 frame 閫氱煡閲嶇櫥锛�
+ const refreshToken = getRefreshToken()
+ if (!refreshToken) {
+ console.warn('[IM WS] refreshToken 涓虹┖锛岃烦杩囪繛鎺�')
+ return
+ }
+ // 鏃� socket 杩樺湪 CONNECTING / OPEN 鐩存帴澶嶇敤锛岄伩鍏嶅彔鍔犲浠� onmessage 鐩戝惉瀵艰嚧閲嶅娑堟伅 / 鎻愮ず闊� / 宸茶涓婃姤
+ const existingSocket = this.socket
+ if (
+ existingSocket &&
+ (existingSocket.readyState === WebSocket.OPEN ||
+ existingSocket.readyState === WebSocket.CONNECTING)
+ ) {
+ return
+ }
+ // 鏃� socket 宸� CLOSING / CLOSED锛氳В缁戝洖璋� + 娓呭紩鐢ㄥ啀 new锛岄伩鍏嶈�� handler 浠嶆寔鏈� store 寮曠敤闃荤 GC
+ if (existingSocket) {
+ unbindWebSocketListeners(existingSocket)
+ this.socket = null
+ }
+ const url = `${this.buildWsUrl()}/infra/ws?token=${refreshToken}`
+ const socket = new WebSocket(url)
+ this.socket = socket
+
+ bindWebSocketListeners(socket, {
+ // 杩炴帴寤虹珛锛氭爣璁颁笂绾� + 鍚姩蹇冭烦淇濇椿锛涢噸杩為��閬胯鏁板綊闆�
+ open: () => {
+ this.isConnected = true
+ this.reconnectAttempts = 0
+ console.log('[IM WS] connected')
+ this.startHeartbeat()
+ },
+
+ // 鏀跺埌甯э細'pong' 鏄績璺冲簲绛旂洿鎺ュ悶鎺夛紱鍏朵綑鎸� WebSocketFrame 瑙f瀽鍚庝氦缁� dispatchFrame 鍒嗘祦
+ message: (event) => {
+ if (event.data === 'pong') {
+ return
+ }
+ try {
+ const frame = JSON.parse(event.data) as WebSocketFrame
+ this.dispatchFrame(frame)
+ } catch (error) {
+ console.error('[IM WS] message parse error:', error)
+ }
+ },
+
+ // 鏈嶅姟绔叧闂� / 缃戠粶鏂細鏍囪涓嬬嚎锛屾寜鎸囨暟閫�閬胯嚜鍔ㄩ噸杩�
+ close: () => {
+ this.isConnected = false
+ console.log('[IM WS] disconnected')
+ this.reconnect()
+ },
+
+ // 寮傚父鏃朵笉涓诲姩 reconnect锛屼富鍔� close() 璁� close 鎴愪负鍞竴閲嶈繛鍏ュ彛
+ error: (error) => {
+ console.error('[IM WS] error:', error)
+ this.isConnected = false
+ this.socket?.close()
+ }
+ })
+ },
+
+ /** 鎷兼帴 WebSocket 鍩虹鍦板潃 */
+ buildWsUrl(): string {
+ // VITE_BASE_URL 鍙兘鏄� http:// 鎴� https:// 寮�澶达紝鏇挎崲鎴� ws:// 鎴� wss://锛涘鏋滄病閰嶇疆锛屽氨鐢ㄥ綋鍓嶉〉闈㈢殑鍗忚 + host
+ const baseUrl = (import.meta as any).env?.VITE_BASE_URL as string | undefined
+ if (baseUrl && baseUrl.length > 0) {
+ return baseUrl.replace(/^http/, 'ws')
+ }
+ // 褰撳墠椤甸潰鍗忚 + host锛堝 http://localhost:8080锛夛紝鏇挎崲鎴� ws://localhost:8080
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+ const host = window.location.host
+ return `${protocol}//${host}`
+ },
+
+ /**
+ * 鎸� IM 閫氱煡甯у垎鍙�
+ */
+ dispatchFrame(frame: WebSocketFrame) {
+ if (frame.type !== ImWebSocketMessageType.NOTIFICATION) {
+ console.debug('[IM WS] 鏈瘑鍒簨浠�', frame)
+ return
+ }
+
+ const notification = this.safeParse(frame.content) as ImNotificationWebSocketDTO | null
+ if (!notification?.payload || !notification.contentType) {
+ return
+ }
+ const payload = {
+ ...notification.payload,
+ type: notification.contentType
+ }
+ switch (notification.conversationType) {
+ case ImConversationType.CHANNEL: {
+ this.dispatchChannelFrame(payload as ImChannelMessageApi.ChannelMessageRespVO)
+ break
+ }
+ case ImConversationType.GROUP: {
+ this.dispatchGroupFrame(payload as ImGroupMessageNotification)
+ break
+ }
+ case ImConversationType.NONE: {
+ this.dispatchNoConversationFrame(payload as ImNoConversationNotification)
+ break
+ }
+ case ImConversationType.PRIVATE: {
+ this.dispatchPrivateFrame(payload as ImPrivateMessageNotification)
+ break
+ }
+ default: {
+ console.debug('[IM WS] 鏈瘑鍒�氱煡', notification)
+ }
+ }
+ },
+
+ /**
+ * 鏃犱細璇濋�氱煡鍒嗗彂
+ */
+ dispatchNoConversationFrame(websocketMessage: ImNoConversationNotification) {
+ if (isFriendNotification(websocketMessage.type)) {
+ this.handleFriendNotification(websocketMessage)
+ return
+ }
+ if (isGroupRequestNotification(websocketMessage.type)) {
+ this.handleGroupRequestNotification(websocketMessage)
+ return
+ }
+ switch (websocketMessage.type) {
+ case ImContentType.RTC_CALL:
+ case ImContentType.RTC_PARTICIPANT_CONNECTED:
+ case ImContentType.RTC_PARTICIPANT_DISCONNECTED: {
+ this.handleRtcSignaling(websocketMessage)
+ break
+ }
+ default: {
+ console.debug('[IM WS] 鏈瘑鍒棤浼氳瘽閫氱煡', websocketMessage)
+ }
+ }
+ },
+
+ /**
+ * 棰戦亾甯у垎鍙戯細鎸� payload.type 鍒嗗埌 READ锛堝绔凡璇诲悓姝ワ級鎴栨櫘閫氱礌鏉愭帹閫�
+ */
+ dispatchChannelFrame(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO) {
+ if (websocketMessage.type === ImContentType.READ) {
+ this.handleChannelRead(websocketMessage)
+ return
+ }
+ ignoreRealtimePersistError(this.handleChannelMessage(websocketMessage))
+ },
+
+ /** 棰戦亾 READ锛氳嚜宸卞叾瀹冪粓绔湪鏌愰閬撻噷鏍囦负宸茶锛屾湰绔悓姝ユ竻闆惰棰戦亾鏈 */
+ handleChannelRead(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO) {
+ void useConversationStore()
+ .applyConversationReadList([
+ {
+ id: websocketMessage.id,
+ conversationType: ImConversationType.CHANNEL,
+ targetId: websocketMessage.channelId,
+ messageId: websocketMessage.id
+ }
+ ])
+ .catch((error) => console.warn('[IM WS] 棰戦亾宸茶鍚屾澶辫触', error))
+ },
+
+ /**
+ * 棰戦亾娑堟伅瀹炴椂鍏ヤ細璇濓紱棰戦亾娑堟伅鍗曞悜 + 鏃犵姸鎬佹満锛岀洿鎺� insertMessage 鍗冲彲
+ * pull 涓� WS 鎷垮埌鍚屼竴鏉� id 鏃讹紝messageStore.insertMessage 鍐呴儴鎸� id 鍘婚噸锛屼笉浼氶噸澶�
+ */
+ handleChannelMessage(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO): Promise<void> {
+ const conversationStore = useConversationStore()
+ const messageStore = useMessageStore()
+ // 绂荤嚎鍔犺浇鏈熼棿鍏堢紦鍐诧紝绛� pull 瀹屾垚鍚庡啀缁熶竴鍥炴斁锛岄伩鍏嶉噸澶嶆垨椤哄簭閿欎贡
+ if (conversationStore.loading) {
+ this.messageBuffer.push({
+ conversationType: ImConversationType.CHANNEL,
+ payload: websocketMessage
+ })
+ return Promise.resolve()
+ }
+ const sendTimeMs =
+ typeof websocketMessage.sendTime === 'number'
+ ? websocketMessage.sendTime
+ : new Date(websocketMessage.sendTime).getTime()
+ const conversation = conversationStore.getConversation(
+ ImConversationType.CHANNEL,
+ websocketMessage.channelId
+ )
+ const isActive =
+ conversationStore.activeConversation?.type === ImConversationType.CHANNEL &&
+ conversationStore.activeConversation?.targetId === websocketMessage.channelId
+ // 棰戦亾鍗曞悜璁㈤槄锛宺eceiptStatus 琛ㄨ揪銆屾垜鏄惁宸茶杩欐潯銆嶏細浼氳瘽鎵撳紑鍗冲凡璇� DONE锛屽惁鍒� PENDING锛堜笌 pull 鍙e緞涓�鑷达級
+ const persistPromise = messageStore.insertMessage(
+ buildChannelConversationStub(websocketMessage.channelId),
+ {
+ id: websocketMessage.id,
+ clientMessageId: '',
+ type: websocketMessage.type,
+ content: websocketMessage.content,
+ status: ImMessageStatus.NORMAL,
+ receiptStatus: isActive ? ImMessageReceiptStatus.DONE : ImMessageReceiptStatus.PENDING,
+ sendTime: sendTimeMs,
+ senderId: 0,
+ targetId: websocketMessage.channelId,
+ selfSend: false,
+ materialId: websocketMessage.materialId
+ }
+ )
+ if (isActive) {
+ // 绐楀彛鎵撳紑 = 宸茶锛氭湰绔竻鏈 + 涓婃姤鏈嶅姟绔浣嶇疆锛岄伩鍏嶈浣嶇疆婊炲悗
+ const readReported = conversationStore.isReportedReadPositionCovered(
+ ImConversationType.CHANNEL,
+ websocketMessage.channelId,
+ websocketMessage.id
+ )
+ conversationStore.markConversationRead(
+ ImConversationType.CHANNEL,
+ websocketMessage.channelId,
+ websocketMessage.id
+ )
+ if (!readReported) {
+ apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
+ .then(() =>
+ conversationStore.markConversationReadReported(
+ ImConversationType.CHANNEL,
+ websocketMessage.channelId,
+ websocketMessage.id
+ )
+ )
+ .catch((error) => {
+ console.warn(
+ '[IM WS] 棰戦亾鑷姩宸茶涓婃姤澶辫触',
+ {
+ conversationType: ImConversationType.CHANNEL,
+ channelId: websocketMessage.channelId,
+ messageId: websocketMessage.id
+ },
+ error
+ )
+ })
+ }
+ } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
+ // 闈炲綋鍓嶄細璇濅笖鏈厤鎵撴壈锛氬搷涓�涓嬫彁绀洪煶
+ playAudioTip()
+ }
+ return persistPromise
+ },
+
+ /** content 鏃㈠彲鑳藉凡鏄璞′篃鍙兘鏄� JSON 瀛楃涓诧紙鍚庣鐢� Map 搴忓垪鍖栦笅鍙戯級 */
+ safeParse(raw: unknown): null | Record<string, any> {
+ if (!raw) {
+ return null
+ }
+ if (typeof raw === 'object') {
+ return raw as Record<string, any>
+ }
+ try {
+ return JSON.parse(raw as string)
+ } catch (error) {
+ console.error('[IM WS] content 瑙f瀽澶辫触', error)
+ return null
+ }
+ },
+
+ // ==================== 鏅�氭秷鎭� ====================
+
+ /**
+ * 绉佽亰缁熶竴甯у垎鍙戯細鎸� payload.type锛圛mContentType锛夊垎鍒板凡璇� / 鍥炴墽 / 濂藉弸閫氱煡 / 鏅�氭秷鎭�
+ *
+ * 娑堟伅閫氱煡銆佸凡璇婚�氱煡銆佸洖鎵ч�氱煡鐢卞灞� contentType 缁熶竴鍒嗗彂
+ */
+ dispatchPrivateFrame(websocketMessage: ImPrivateMessageNotification) {
+ try {
+ switch (websocketMessage.type) {
+ case ImContentType.READ: {
+ this.handlePrivateRead(websocketMessage as ImMessageReadNotification)
+ break
+ }
+ case ImContentType.RECEIPT: {
+ this.handlePrivateReceipt(websocketMessage as ImMessageReceiptNotification)
+ break
+ }
+ case ImContentType.RTC_CALL_END: {
+ // 鍏ュ簱 + 鍏抽棴閫氳瘽绐� + 娓叉煋鑱婂ぉ tip锛堢鑱婂満鏅級
+ this.handleRtcCallEnd(websocketMessage)
+ ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
+ break
+ }
+ default: {
+ if (isFriendChatTip(websocketMessage.type)) {
+ this.handleFriendNotification(websocketMessage)
+ // FRIEND_DELETE 鐨� clear=true 璇箟鏄竻浼氳瘽鏈韩锛岃烦杩囨皵娉¢伩鍏嶅湪宸叉竻浼氳瘽閲屽啓鍏ヨ櫄鎷熸秷鎭�
+ if (!isFriendDeleteWithClear(websocketMessage)) {
+ ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
+ }
+ } else {
+ // TEXT / IMAGE / FILE / VOICE / VIDEO 绛夋櫘閫氭秷鎭�
+ ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
+ }
+ }
+ }
+ } catch (error) {
+ // 鍗曟潯甯х殑澶勭悊寮傚父涓嶅簲闃绘柇鍚庣画甯э紱鎵撳嵃瀹屾暣 websocketMessage 渚夸簬鎺掓煡
+ console.warn('[IM WS] dispatchPrivateFrame 澶勭悊澶辫触', websocketMessage, error)
+ }
+ },
+
+ /**
+ * 缇よ亰缁熶竴甯у垎鍙戯細鎸� payload.type锛圛mContentType锛夊垎鍒板凡璇� / 鍥炴墽 / 缇や釜浜轰俊鍙� / 鏅�氭秷鎭�
+ *
+ * GROUP_MEMBER_SETTING_UPDATE / GROUP_MEMBER_NICKNAME_UPDATE 鏄垚鍛樿祫鏂欏悓姝ヤ俊鍙凤紱鍏跺畠缇ゅ箍鎾簨浠惰蛋 handleGroupMessage 鍏ュ簱 + 瑙﹀彂 applyGroupNotification 鏃佽矾
+ */
+ dispatchGroupFrame(websocketMessage: ImGroupMessageNotification) {
+ try {
+ switch (websocketMessage.type) {
+ case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
+ this.handleGroupMemberNicknameUpdate(websocketMessage)
+ break
+ }
+ case ImContentType.GROUP_MEMBER_SETTING_UPDATE: {
+ this.handleGroupMemberSettingUpdate(websocketMessage)
+ break
+ }
+ case ImContentType.READ: {
+ this.handleGroupRead(websocketMessage as ImMessageReadNotification)
+ break
+ }
+ case ImContentType.RECEIPT: {
+ this.handleGroupReceipt(websocketMessage as ImMessageReceiptNotification)
+ break
+ }
+ case ImContentType.RTC_CALL_END: {
+ // 鍏ュ簱 + 绉婚櫎鑳跺泭鏉� + 鍏抽棴閫氳瘽绐楋紙濡傛灉褰撳墠鍦ㄨ缇ら�氳瘽鍐咃級
+ this.handleRtcCallEnd(websocketMessage)
+ ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
+ break
+ }
+ case ImContentType.RTC_CALL_START: {
+ // 鍏ュ簱 + 娓叉煋鑱婂ぉ tip锛涘悓鏃剁敤 START payload 鍏堢敓鎴愭渶灏忚兌鍥婃潯锛屽悗缁� getActiveCall / 鍙備笌鑰呬簨浠跺啀琛ラ綈鎴愬憳
+ this.handleRtcCallStart(websocketMessage)
+ ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
+ break
+ }
+ default: {
+ // TEXT / IMAGE / FILE / VOICE / VIDEO + GROUP_* 缇ゅ箍鎾簨浠�
+ ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
+ }
+ }
+ } catch (error) {
+ // 鍗曟潯甯х殑澶勭悊寮傚父涓嶅簲闃绘柇鍚庣画甯э紱鎵撳嵃瀹屾暣 websocketMessage 渚夸簬鎺掓煡
+ console.warn('[IM WS] dispatchGroupFrame 澶勭悊澶辫触', websocketMessage, error)
+ }
+ },
+
+ /**
+ * 绉佽亰鏅�氭秷鎭紙TEXT / IMAGE / FILE / VOICE / VIDEO锛夊叆搴� + 鑷姩宸茶
+ *
+ * 娴佺▼锛�
+ * 1. 绂荤嚎鍔犺浇鏈熺紦鍐诧紙閬垮紑涓� pull 鍥炲~鐨勭珵鎬侊級
+ * 2. 璁$畻 selfSend / peerId 缁村害锛屾媺濂藉弸淇℃伅鍥炲~灞曠ず瀛楁
+ * 3. 鎾ゅ洖 TIP 鐩存帴杞蛋 recallMessage锛屼笉杩涙秷鎭垪琛�
+ * 4. 鏋勯�犲墠绔� Message锛屾彃鍏ュ埌瀵瑰簲绉佽亰浼氳瘽
+ * 5. 褰撳墠浼氳瘽婵�娲绘椂鑷姩涓婃姤宸茶锛涘惁鍒欓潪鍏嶆墦鎵板搷鎻愮ず闊�
+ */
+ handlePrivateMessage(websocketMessage: ImPrivateMessageNotification): Promise<void> {
+ const conversationStore = useConversationStore()
+ const friendStore = useFriendStore()
+ const currentUserId = getCurrentUserId()
+
+ // 0. 闃插尽灞傦細senderId / receiverId 鍧囦笉鍚綋鍓嶇敤鎴风殑绉佽亰甯х洿鎺ヤ涪寮冿紝閬垮厤鍚庣璺敱 / 澶氱涓插彿姹℃煋浼氳瘽
+ // 锛團RIEND_* 绛夌郴缁熼�氱煡涔熻蛋杩欐潯閫氶亾锛屼絾 fromUserId=senderId銆乼oUserId=receiverId 浠嶆槸褰撳墠鐢ㄦ埛瑙嗚锛�
+ if (
+ currentUserId &&
+ websocketMessage.senderId !== currentUserId &&
+ websocketMessage.receiverId !== currentUserId
+ ) {
+ console.warn('[IM WS] 涓㈠純涓嶅睘浜庡綋鍓嶇敤鎴风殑绉佽亰甯�', websocketMessage)
+ return Promise.resolve()
+ }
+
+ // 1. 绂荤嚎鍔犺浇鏈熼棿鍏堢紦鍐诧紝绛� pull 瀹屾垚鍚庡啀缁熶竴鍥炴斁锛岄伩鍏嶉噸澶嶆垨椤哄簭閿欎贡
+ if (conversationStore.loading) {
+ this.messageBuffer.push({
+ conversationType: ImConversationType.PRIVATE,
+ payload: websocketMessage
+ })
+ return Promise.resolve()
+ }
+
+ // 2. selfSend / peerId锛氳嚜宸卞彂鐨勬秷鎭睘浜庛�屽彂缁� receiverId 鐨勪細璇濄�嶏紝鍒汉鍙戠殑灞炰簬銆屽彂閫佽�呯殑浼氳瘽銆�
+ const selfSend = websocketMessage.senderId === currentUserId
+ const peerId = getPrivateMessagePeerId(websocketMessage, currentUserId)
+ // 鏈煡瀵圭锛堥檶鐢熶汉鍔犲ソ鍙嬪墠鍏堟敹鍒版秷鎭瓑鍦烘櫙锛夛細寮傛琛ユ媺涓�娆★紝涓嬫鍐嶆覆鏌撳氨鏈� name/avatar
+ const friend = friendStore.getFriend(peerId)
+ if (!friend) {
+ friendStore.fetchFriendInfo(peerId).catch(() => undefined)
+ }
+ // 浼氳瘽鏍囬姘歌繙璺熴�屽绔�嶈蛋锛堜笉绠¤皝鍙戠殑娑堟伅锛夛紱杩欓噷鍙畻涓�娆$粰 insertMessage 鐢�
+ const peerDisplayName = friend ? getFriendDisplayName(friend) : ''
+
+ // 3. 鍚庣鎾ゅ洖锛氫笅鍙戜竴鏉� RECALL 娑堟伅锛宑ontent 涓� `{"messageId": xxx}`锛堝榻� ImContentTypeEnum.RECALL 鈫� RecallMessage锛�
+ // 杩欓噷鎷︽埅涓嬫潵鏀硅蛋 recallMessage锛堟妸鍘熸秷鎭洿鏂颁负 RECALL 鎬侊級锛屼笉璁╁畠浣滀负鏂版秷鎭繘鍒楄〃
+ if (websocketMessage.type === ImContentType.RECALL) {
+ return useMessageStore().recallMessage(
+ ImConversationType.PRIVATE,
+ peerId,
+ websocketMessage.content
+ )
+ }
+
+ // 4. 鍚庣 DTO 鈫� 鍓嶇 Message锛氬彂閫佷汉鍚嶆覆鏌撴椂瀹炴椂绠楋紝涓嶅啓鍏ユ秷鎭瓧娈�
+ const message = convertPrivateMessage(websocketMessage, currentUserId)
+ const persistPromise = useMessageStore().insertMessage(
+ {
+ type: ImConversationType.PRIVATE,
+ targetId: peerId,
+ name: peerDisplayName || String(peerId),
+ avatar: friend?.avatar || '',
+ silent: friend?.silent
+ },
+ message
+ )
+
+ // 5. 浠呭鏂规秷鎭墠璧般�岃嚜鍔ㄥ凡璇� / 鎻愮ず闊炽�嶅垎鏀細鑷繁鍙戠殑涓嶄細瑙﹀彂
+ if (!selfSend) {
+ const conversation = conversationStore.getConversation(ImConversationType.PRIVATE, peerId)
+ const isActive =
+ conversationStore.activeConversation?.type === ImConversationType.PRIVATE &&
+ conversationStore.activeConversation?.targetId === peerId
+ if (isActive) {
+ // 鑱婂ぉ绐楀彛鎵撳紑 = 瀹為檯鐪嬪埌浜嗭細鏈娓呮湭璇伙紱绉佽亰宸茶寮�鍚椂鍐嶄笂鎶ュ悗绔紝璁╁鏂� UI 绔嬪埢鍒囧埌"宸茶"
+ // 宸茶浣嶇疆鐩存帴鐢ㄥ垰鍒扮殑娑堟伅 id锛堣繖鏉″氨鏄綋鍓嶄細璇濇渶澶� id锛�
+ const readReported = conversationStore.isReportedReadPositionCovered(
+ ImConversationType.PRIVATE,
+ peerId,
+ websocketMessage.id
+ )
+ conversationStore.markConversationRead(
+ ImConversationType.PRIVATE,
+ peerId,
+ websocketMessage.id
+ )
+ if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
+ apiReadPrivateMessages(peerId, websocketMessage.id)
+ .then(() =>
+ conversationStore.markConversationReadReported(
+ ImConversationType.PRIVATE,
+ peerId,
+ websocketMessage.id
+ )
+ )
+ .catch((error) => {
+ console.warn(
+ '[IM WS] 绉佽亰鑷姩宸茶涓婃姤澶辫触',
+ {
+ conversationType: ImConversationType.PRIVATE,
+ peerId,
+ messageId: websocketMessage.id
+ },
+ error
+ )
+ })
+ }
+ } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
+ // 闈炲綋鍓嶄細璇濅笖鏈厤鎵撴壈锛氬搷涓�涓嬫彁绀洪煶锛堝甫鑺傛祦锛岃瑙� playAudioTip锛夛紱FRIEND_* 绛夌郴缁熶簨浠朵笉鍝�
+ playAudioTip()
+ }
+ }
+ return persistPromise
+ },
+
+ /** 绉佽亰 READ 浜嬩欢锛氳嚜宸辩殑鍏跺畠缁堢鍦ㄥ鏂逛細璇濋噷鏍囦负宸茶锛屾湰绔悓姝ユ竻闆舵湭璇伙紱绉佽亰宸茶鍏抽棴鏃跺厹搴曞拷鐣� */
+ handlePrivateRead(websocketMessage: ImMessageReadNotification) {
+ if (!MESSAGE_PRIVATE_READ_ENABLED) {
+ return
+ }
+ if (!websocketMessage.id || !websocketMessage.receiverId) {
+ return
+ }
+ void useConversationStore()
+ .applyConversationReadList([
+ {
+ id: websocketMessage.id,
+ conversationType: ImConversationType.PRIVATE,
+ targetId: websocketMessage.receiverId,
+ messageId: websocketMessage.id
+ }
+ ])
+ .catch((error) => console.warn('[IM WS] 绉佽亰宸茶鍚屾澶辫触', error))
+ },
+
+ /**
+ * 绉佽亰 RECEIPT 浜嬩欢锛氬鏂硅浜嗘垜鐨勬秷鎭紝鎶婂拰瀵规柟浼氳瘽閲岃嚜宸卞彂鐨勬秷鎭爣涓哄凡璇�
+ * 鍚庣灏� maxReadId 缂栫爜鍦ㄩ�氱煡鐨� id 瀛楁锛�
+ * 杩欓噷鎹鍗¤竟鐣岋紝閬垮厤鎶�"鍥炴墽鍦ㄨ矾涓婃椂鍒氬彂鐨勬秷鎭�"璇爣涓哄凡璇伙紱绉佽亰宸茶鍏抽棴鏃跺厹搴曞拷鐣�
+ */
+ handlePrivateReceipt(websocketMessage: ImMessageReceiptNotification) {
+ if (!MESSAGE_PRIVATE_READ_ENABLED) {
+ return
+ }
+ if (!websocketMessage.id) {
+ return
+ }
+ if (!websocketMessage.senderId) {
+ return
+ }
+ useMessageStore().applyMessageReadReceipt({
+ conversationType: ImConversationType.PRIVATE,
+ targetId: websocketMessage.senderId,
+ privateReadMaxId: websocketMessage.id
+ })
+ },
+
+ /**
+ * 缇よ亰鏅�氭秷鎭叆搴� + 鑷姩宸茶锛堢粨鏋勪笌 handlePrivateMessage 瀵圭О锛�
+ *
+ * 娴佺▼锛�
+ * 1. 绂荤嚎鍔犺浇鏈熺紦鍐�
+ * 2. 鏈煡缇ゆ椂鎷夌兢璇︽儏鍏滃簳
+ * 3. 鎾ゅ洖 TIP 鐩存帴杞蛋
+ * 4. 鏋勯�� Message + at 瀛楁锛屾彃鍏ュ埌瀵瑰簲缇よ亰浼氳瘽锛堝彂閫佷汉鍚嶆覆鏌撴椂瀹炴椂绠楋級
+ * 5. 褰撳墠浼氳瘽婵�娲绘椂鑷姩涓婃姤宸茶锛堝甫 lastMessageId锛夛紱鍚﹀垯闈炲厤鎵撴壈鍝嶆彁绀洪煶
+ */
+ handleGroupMessage(websocketMessage: ImGroupMessageNotification): Promise<void> {
+ const conversationStore = useConversationStore()
+ const groupStore = useGroupStore()
+ const currentUserId = getCurrentUserId()
+ const selfSend = websocketMessage.senderId === currentUserId
+
+ // 0. 闃插尽灞傦細瀹氬悜缇ゆ秷鎭� receiverUserIds 闈炵┖涓旀湭鍖呭惈褰撳墠鐢ㄦ埛鏃朵涪寮�
+ // 鑷繁鍙戠殑锛坰elfSend锛夊缁堥�氳繃锛涘叏鍛樺彲瑙侊紙receiverUserIds 涓虹┖ / 缂哄け锛変篃閫氳繃
+ const receiverUserIds = websocketMessage.receiverUserIds
+ if (
+ currentUserId &&
+ !selfSend &&
+ Array.isArray(receiverUserIds) &&
+ receiverUserIds.length > 0 &&
+ !receiverUserIds.includes(currentUserId)
+ ) {
+ console.warn('[IM WS] 涓㈠純涓嶅睘浜庡綋鍓嶇敤鎴风殑瀹氬悜缇ゆ秷鎭�', websocketMessage)
+ return Promise.resolve()
+ }
+
+ // 1. 绂荤嚎鍔犺浇鏈熺紦鍐诧紙涓庣鑱婂绉帮級
+ if (conversationStore.loading) {
+ this.messageBuffer.push({
+ conversationType: ImConversationType.GROUP,
+ payload: websocketMessage
+ })
+ return Promise.resolve()
+ }
+
+ // 2. 鏈煡缇ゆ椂鑷姩鎷夌兢璇︽儏 + 鎴愬憳锛堣鎷夊叆缇や絾杩樻病鏀跺埌 GROUP_CREATE 鏃剁殑鍏滃簳锛�
+ const group = groupStore.getGroup(websocketMessage.groupId)
+ if (!group) {
+ groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined)
+ }
+
+ // 3. 鍚庣鎾ゅ洖锛氫笅鍙戜竴鏉� RECALL 娑堟伅锛宑ontent 涓� `{"messageId": xxx}`
+ // 杩欓噷鎷︽埅涓嬫潵鏀硅蛋 recallMessage锛堟妸鍘熸秷鎭洿鏂颁负 RECALL 鎬侊級
+ if (websocketMessage.type === ImContentType.RECALL) {
+ return useMessageStore().recallMessage(
+ ImConversationType.GROUP,
+ websocketMessage.groupId,
+ websocketMessage.content
+ )
+ }
+
+ // 4. 鍚庣 DTO 鈫� 鍓嶇 Message锛氬彂閫佷汉鍚嶆覆鏌撴椂瀹炴椂绠楋紝涓嶅啓鍏ユ秷鎭瓧娈�
+ const message = convertGroupMessage(websocketMessage, currentUserId)
+ const persistPromise = useMessageStore().insertMessage(
+ {
+ type: ImConversationType.GROUP,
+ targetId: websocketMessage.groupId,
+ name: group ? getGroupDisplayName(group) : String(websocketMessage.groupId),
+ avatar: group?.avatar || '',
+ silent: group?.silent
+ },
+ message
+ )
+
+ // 5. 浠呭鏂规秷鎭墠璧般�岃嚜鍔ㄥ凡璇� / 鎻愮ず闊炽�嶏紙涓庣鑱婂绉帮級
+ if (!selfSend) {
+ const conversation = conversationStore.getConversation(
+ ImConversationType.GROUP,
+ websocketMessage.groupId
+ )
+ const isActive =
+ conversationStore.activeConversation?.type === ImConversationType.GROUP &&
+ conversationStore.activeConversation?.targetId === websocketMessage.groupId
+ if (isActive) {
+ // 缇ゅ凡璇讳笂鎶ラ渶瑕佸甫 messageId锛堢兢娑堟伅浠�"璇诲埌绗嚑鏉�"鐨勬父鏍囦负鍑嗭紝鍖哄埆浜庣鑱婂彧鏍� receiverId锛夛紱缇ゅ凡璇诲叧闂椂浠呮湰鍦版竻闆�
+ const readReported = conversationStore.isReportedReadPositionCovered(
+ ImConversationType.GROUP,
+ websocketMessage.groupId,
+ websocketMessage.id
+ )
+ conversationStore.markConversationRead(
+ ImConversationType.GROUP,
+ websocketMessage.groupId,
+ websocketMessage.id
+ )
+ if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
+ apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
+ .then(() =>
+ conversationStore.markConversationReadReported(
+ ImConversationType.GROUP,
+ websocketMessage.groupId,
+ websocketMessage.id
+ )
+ )
+ .catch((error) => {
+ console.warn(
+ '[IM WS] 缇よ亰鑷姩宸茶涓婃姤澶辫触',
+ {
+ conversationType: ImConversationType.GROUP,
+ groupId: websocketMessage.groupId,
+ messageId: websocketMessage.id
+ },
+ error
+ )
+ })
+ }
+ } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
+ // GROUP_* 缇ゅ箍鎾簨浠剁瓑绯荤粺娑堟伅涓嶅搷鎻愮ず闊�
+ playAudioTip()
+ }
+ }
+ return persistPromise
+ },
+
+ // ==================== 缇よ亰宸茶 / 鍥炴墽 ====================
+
+ /** 缇よ亰 READ锛氳嚜宸卞叾瀹冪粓绔湪鏌愮兢閲屾爣涓哄凡璇伙紝鏈鍚屾娓呴浂璇ョ兢鏈 + @ 绾㈠瓧锛涚兢宸茶鍏抽棴鏃跺厹搴曞拷鐣� */
+ handleGroupRead(websocketMessage: ImMessageReadNotification) {
+ if (!MESSAGE_GROUP_READ_ENABLED) {
+ return
+ }
+ const readMessageId = websocketMessage.readId || websocketMessage.id
+ if (!readMessageId || !websocketMessage.groupId) {
+ return
+ }
+ void useConversationStore()
+ .applyConversationReadList([
+ {
+ id: readMessageId,
+ conversationType: ImConversationType.GROUP,
+ targetId: websocketMessage.groupId,
+ messageId: readMessageId
+ }
+ ])
+ .catch((error) => console.warn('[IM WS] 缇よ亰宸茶鍚屾澶辫触', error))
+ },
+
+ /** 缇よ亰 RECEIPT锛氭洿鏂版煇鏉$兢娑堟伅鐨� readCount / receiptStatus锛涚兢宸茶鍏抽棴鏃跺厹搴曞拷鐣� */
+ handleGroupReceipt(websocketMessage: ImMessageReceiptNotification) {
+ if (!MESSAGE_GROUP_READ_ENABLED) {
+ return
+ }
+ if (!websocketMessage.id || !websocketMessage.groupId) {
+ return
+ }
+ useMessageStore().applyMessageReadReceipt({
+ conversationType: ImConversationType.GROUP,
+ targetId: websocketMessage.groupId,
+ groupMessageId: websocketMessage.id,
+ readCount: websocketMessage.readCount,
+ receiptStatus: websocketMessage.receiptStatus
+ })
+ },
+
+ // ==================== 濂藉弸閫氱煡锛�1201-1210 娈典綅锛� ====================
+
+ /**
+ * 绠� FRIEND_ADD / FRIEND_DELETE 甯х殑銆屽绔� userId銆嶏細
+ * becomeFriends 鍗曟潯鍏ュ簱鍚庡弻鏂规敹鍒板悓涓�浠� payload锛宲ayload.friendUserId 鍥哄畾鏄� toUserId锛屾湰绔湡姝g殑瀵圭瑕佷粠甯� sender / receiver 鍙嶆帹
+ */
+ computeFriendPeerId(frame: ImPrivateMessageNotification): number {
+ const currentUserId = getCurrentUserId()
+ return getPrivateMessagePeerId(frame, currentUserId)
+ },
+
+ /**
+ * 濂藉弸閫氱煡缁熶竴鍏ュ彛锛氭寜 type 鍒嗗彂鍒� friendStore 鍐呴儴 dispatcher
+ */
+ handleFriendNotification(
+ websocketMessage: ImNoConversationNotification | ImPrivateMessageNotification
+ ) {
+ const payload = isPrivateMessageNotification(websocketMessage)
+ ? parseFriendNotificationPayload(websocketMessage)
+ : (websocketMessage as unknown as FriendNotificationPayload)
+ const friendStore = useFriendStore()
+ switch (websocketMessage.type) {
+ case ImContentType.FRIEND_ADD: {
+ friendStore.applyFriendAddNotification(
+ payload,
+ isPrivateMessageNotification(websocketMessage)
+ ? this.computeFriendPeerId(websocketMessage)
+ : payload.friendUserId
+ )
+ break
+ }
+ case ImContentType.FRIEND_BLOCK: {
+ friendStore.applyFriendBlockNotification(payload)
+ break
+ }
+ case ImContentType.FRIEND_DELETE: {
+ friendStore.applyFriendDeleteNotification(
+ payload,
+ isPrivateMessageNotification(websocketMessage)
+ ? this.computeFriendPeerId(websocketMessage)
+ : payload.friendUserId
+ )
+ break
+ }
+ case ImContentType.FRIEND_INFO_UPDATED: {
+ friendStore.applyFriendInfoUpdatedNotification(payload)
+ break
+ }
+ case ImContentType.FRIEND_REQUEST_APPROVED: {
+ friendStore.applyFriendRequestApprovedNotification(payload)
+ break
+ }
+ case ImContentType.FRIEND_REQUEST_RECEIVED: {
+ friendStore.applyFriendRequestReceivedNotification(payload)
+ break
+ }
+ case ImContentType.FRIEND_REQUEST_REJECTED: {
+ friendStore.applyFriendRequestRejectedNotification(payload)
+ break
+ }
+ case ImContentType.FRIEND_UNBLOCK: {
+ friendStore.applyFriendUnblockNotification(payload)
+ break
+ }
+ case ImContentType.FRIEND_UPDATE: {
+ friendStore.applyFriendUpdateNotification(payload)
+ break
+ }
+ default: {
+ console.debug('[IM WS] 鏈瘑鍒ソ鍙嬮�氱煡', websocketMessage)
+ }
+ }
+ },
+
+ // ==================== 鍔犵兢鐢宠閫氱煡锛�1503 / 1505 / 1506锛� ====================
+
+ /**
+ * 鍔犵兢鐢宠閫氱煡缁熶竴鍏ュ彛锛氬垎鍙戝埌 groupRequestStore锛岄┍鍔ㄦí骞� + Drawer 鍚屾
+ */
+ handleGroupRequestNotification(websocketMessage: ImNoConversationNotification) {
+ const payload = websocketMessage as { requestId?: number }
+ if (!payload.requestId) {
+ return
+ }
+ const groupRequestStore = useGroupRequestStore()
+ switch (websocketMessage.type) {
+ case ImContentType.GROUP_REQUEST_APPROVED:
+ case ImContentType.GROUP_REQUEST_REJECTED: {
+ groupRequestStore.removeGroupRequestById(payload.requestId)
+ break
+ }
+ case ImContentType.GROUP_REQUEST_RECEIVED: {
+ groupRequestStore.addGroupRequestById(payload.requestId).catch(() => undefined)
+ break
+ }
+ default: {
+ break
+ }
+ }
+ },
+
+ // ==================== 缇ゅ叧绯讳簨浠讹紙鎵胯浇浜庣兢鑱婇�氶亾锛屾寜 inner type 鍒嗘祦锛� ====================
+
+ /**
+ * GROUP_MEMBER_SETTING_UPDATE锛氬绔悓姝ユ垚鍛樹釜浜鸿缃彉鏇达紙silent / groupRemark锛�
+ *
+ * payload 鎼哄甫鍙樻洿瀛楁锛屾寜闈� null 瀛楁鐩存帴灞�閮ㄦ洿鏂帮紱鐪佷竴娆� fetchGroupMemberList 鎺ュ彛
+ */
+ handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageNotification) {
+ // content 瑙f瀽澶辫触鐢卞灞� dispatchGroupFrame 鐨� try-catch 鍏滃簳锛堝惈 websocketMessage 鎵撳嵃锛夛紝涓嶉噸澶� catch
+ const payload: { groupRemark?: string; silent?: boolean; } = JSON.parse(
+ websocketMessage.content || '{}'
+ )
+ const groupStore = useGroupStore()
+ const group = groupStore.getGroup(websocketMessage.groupId)
+ if (!group) {
+ return
+ }
+ const fields: Partial<Group> = {}
+ if (payload.silent != null) {
+ fields.silent = payload.silent
+ }
+ if (payload.groupRemark != null) {
+ fields.groupRemark = payload.groupRemark
+ }
+ if (Object.keys(fields).length > 0) {
+ groupStore.updateGroupFields(websocketMessage.groupId, fields)
+ }
+ },
+
+ /** GROUP_MEMBER_NICKNAME_UPDATE锛氬悓姝ユ垚鍛樺湪缇ら噷鐨勬樀绉� */
+ handleGroupMemberNicknameUpdate(websocketMessage: ImGroupMessageNotification) {
+ useGroupStore().applyGroupNotification(
+ websocketMessage.groupId,
+ websocketMessage.type,
+ websocketMessage.content
+ )
+ },
+
+ // ==================== 蹇冭烦 / 閲嶈繛 ====================
+
+ /** 蹇冭烦鍖咃細绾枃鏈� 'ping'锛屽搴旀湇鍔$ 'pong'锛堝悗绔繖灞傜敤绾瓧绗︿覆绾﹀畾锛岄伩鍏� JSON 瑙f瀽寮�閿�锛� */
+ sendHeartBeat() {
+ if (this.socket && this.isConnected) {
+ this.socket.send('ping')
+ }
+ },
+
+ /** 涓诲姩鏂紑锛堝垏鎹㈢敤鎴� / 閫�鍑虹櫥褰曟椂鐢級锛氬叧 socket + 鍋滃績璺� + 鍙栨秷寰呴噸杩� */
+ disconnect() {
+ if (this.socket) {
+ // close() 寮傛瑙﹀彂 onclose / onerror锛屽洖璋冮噷浼氭棤鏉′欢 reconnect锛�
+ // 涓诲姩鍏抽棴璺緞蹇呴』鍏堝叏閮ㄨВ缁戯紝鍚﹀垯 onclose 浼氬紩鍙戣嚜鍔ㄩ噸杩烇紝CONNECTING 鏈熼棿鐨� in-flight message 涔熷彲鑳借鑰� onmessage 鎶曢�掑埌 stale 涓婁笅鏂�
+ unbindWebSocketListeners(this.socket)
+ this.socket.close()
+ this.socket = null
+ }
+ // onclose 宸茶瑙g粦锛屼笉浼氬啀甯垜浠 isConnected=false锛岃繖閲屾墜鍔ㄥ浣�
+ this.isConnected = false
+ this.stopHeartbeat()
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ }
+ // 涓诲姩鏂紑锛堝垏璐﹀彿 / 閫�鍑猴級锛氭竻闆堕��閬胯鏁帮紝涓嬫 connect 閲嶆柊浠庢渶鐭棿闅旇捣绠�
+ this.reconnectAttempts = 0
+ },
+
+ /**
+ * 鑷姩閲嶈繛锛氭寚鏁伴��閬� base * 2^attempt锛堝皝椤� max锛�+ 0~jitter ms 闅忔満鍋忕Щ
+ *
+ * onclose 鏄敮涓�鍏ュ彛锛沷nerror 涓嶅啀璋冩湰鏂规硶锛堟祻瑙堝櫒瑙勮寖涓よ�呭繀鍚屾椂瑙﹀彂锛岄伩鍏嶈鏁� +2锛�
+ * 涓嶈娆℃暟涓婇檺锛岄鐜囧皝椤跺湪 WS_RECONNECT_MAX_MS锛堢害 30s锛夋寔缁噸璇曪紝鐩村埌閾捐矾鎭㈠鎴栦富鍔� disconnect
+ */
+ reconnect() {
+ this.stopHeartbeat()
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ }
+ const backoff = Math.min(
+ WS_RECONNECT_BASE_MS * 2 ** this.reconnectAttempts,
+ WS_RECONNECT_MAX_MS
+ )
+ const delay = backoff + Math.floor(Math.random() * WS_RECONNECT_JITTER_MS)
+ this.reconnectAttempts++
+ console.log(`[IM WS] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
+ this.reconnectTimer = setTimeout(() => {
+ this.connect()
+ }, delay)
+ },
+
+ /** 蹇冭烦 5 绉掍竴娆★紝淇濇椿 + 鎺㈡椿锛堥摼璺柇浜� onclose 浼氳Е鍙戯紝鐢� reconnect 鍏滃簳锛� */
+ startHeartbeat() {
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
+ this.heartbeatTimer = setInterval(() => {
+ if (this.socket && this.isConnected) {
+ this.sendHeartBeat()
+ }
+ }, 5000)
+ },
+
+ /** 鍋滃績璺筹細disconnect / 閲嶈繛鍓嶈皟锛岄伩鍏嶈�� timer 鍦ㄦ柊 socket 涓婄户缁Е鍙� sendHeartBeat */
+ stopHeartbeat() {
+ if (this.heartbeatTimer) {
+ clearInterval(this.heartbeatTimer)
+ this.heartbeatTimer = null
+ }
+ },
+
+ // ==================== 瀹炴椂閫氳瘽淇′护鍒嗗彂 ====================
+
+ /**
+ * 閫氳瘽淇′护鍒嗗彂锛�1601 RTC_CALL锛堟寜 status 鍖哄垎 INVITING / JOINED / REJECTED / NO_ANSWER / LEFT锛�+ 1602 / 1603 鍙備笌鑰呭姞鍏� / 绂诲紑
+ * <p>
+ * 鍗曚竴 dispatcher锛屾寜 type 鍒嗗彂鍒� rtcStore
+ */
+ handleRtcSignaling(websocketMessage: ImNoConversationNotification) {
+ const rtcStore = useRtcStore()
+ switch (websocketMessage.type) {
+ case ImContentType.RTC_CALL: {
+ const payload = websocketMessage as unknown as ImRtcCallNotification
+ switch (payload.status) {
+ case ImRtcParticipantStatus.INVITING: {
+ if (!isValidRtcInvitePayload(payload)) {
+ console.warn('[IM WS] RTC_CALL invite payload 涓嶅悎娉�', payload)
+ return
+ }
+ // 褰撳墠宸插湪閫氳瘽涓細蹇界暐鏂版潵鐢碉紱鍚庣灞傞潰涔熶細鎷掔粷锛岃繖閲屾槸鍏滃簳
+ if (!rtcStore.isActive) {
+ rtcStore.showIncoming(payload)
+ }
+ break
+ }
+ case ImRtcParticipantStatus.JOINED:
+ case ImRtcParticipantStatus.LEFT: {
+ // ACCEPT / HUNGUP 鏆備笉闇�瑕佹湰绔澶栧搷搴旓紱rtcStore 鐘舵�佺敱 1602/1603 + END 缁存姢
+ break
+ }
+ case ImRtcParticipantStatus.NO_ANSWER: {
+ // 缇ら�氳瘽鍗曚汉鎸搩瓒呮椂锛涗俊浠ょ嫭绔嬩繚鐣欒涔夛紝澶勭悊涓� REJECTED 涓�鑷�
+ rtcStore.applyParticipantNoAnswer(payload)
+ break
+ }
+ case ImRtcParticipantStatus.REJECTED: {
+ rtcStore.applyParticipantRejected(payload)
+ break
+ }
+ default: {
+ console.warn('[IM WS] 鏈瘑鍒殑 RTC_CALL status', payload)
+ }
+ }
+ return
+ }
+ case ImContentType.RTC_PARTICIPANT_CONNECTED: {
+ const payload = websocketMessage as unknown as ImRtcParticipantConnectedNotification
+ if (payload?.room && payload.userId) {
+ rtcStore.applyParticipantConnected(payload)
+ }
+ return
+ }
+ case ImContentType.RTC_PARTICIPANT_DISCONNECTED: {
+ const payload = websocketMessage as unknown as ImRtcParticipantDisconnectedNotification
+ if (payload?.room && payload.userId) {
+ rtcStore.applyParticipantDisconnected(payload)
+ }
+ }
+ }
+ },
+
+ /** RTC_CALL_START 閫氳瘽寮�濮� */
+ handleRtcCallStart(websocketMessage: ImGroupMessageNotification) {
+ const payload = parseRtcCallPayload(websocketMessage.content)
+ if (!payload?.room || !payload.mediaType || !payload.inviterUserId) {
+ console.warn('[IM WS] RTC_CALL_START payload 涓嶅悎娉�', {
+ groupId: websocketMessage.groupId,
+ messageId: websocketMessage.id,
+ contentLength: websocketMessage.content?.length ?? 0
+ })
+ return
+ }
+ useRtcStore().setGroupCall({
+ room: payload.room,
+ groupId: websocketMessage.groupId,
+ mediaType: payload.mediaType,
+ inviterId: payload.inviterUserId,
+ joinedUserIds: [payload.inviterUserId],
+ inviteeIds: []
+ })
+ },
+
+ /**
+ * RTC_CALL_END 閫氳瘽缁撴潫锛涚鑱� + 缇よ亰閮借蛋杩欎竴鏉★紱payload 鎼哄甫 conversationType 鍖哄垎
+ * <p>
+ * 绉佽亰锛氬叧闂綋鍓嶉�氳瘽绐�
+ * 缇よ亰锛氱Щ闄よ兌鍥婃潯锛涘鏈鍦ㄨ缇ら�氳瘽鍐呭垯鍏抽棴閫氳瘽绐�
+ */
+ handleRtcCallEnd(
+ websocketMessage: ImGroupMessageNotification | ImPrivateMessageNotification
+ ) {
+ const payload = this.safeParse(websocketMessage.content) as ImRtcCallEndNotification | null
+ if (!payload?.room) {
+ return
+ }
+ const rtcStore = useRtcStore()
+ const isGroup = payload.conversationType === ImConversationType.GROUP
+ // 缇ら�氳瘽锛氱Щ闄ゅ搴旀埧闂寸殑鑳跺泭鏉�
+ const groupId = (websocketMessage as ImGroupMessageNotification).groupId
+ if (isGroup && groupId) {
+ rtcStore.removeGroupCall(groupId, payload.room)
+ }
+ // 閫氳瘽绐� / 鏉ョ數绐楁寚鍚戝悓涓� room 鏃跺叧闂細
+ // RUNNING / INVITING 闃舵瀵规瘮 call.room锛汭NCOMING 闃舵瀵规瘮 incomingPayload.room
+ const matchCall = rtcStore.call?.room === payload.room
+ const matchIncoming = rtcStore.incomingPayload?.room === payload.room
+ if (rtcStore.isActive && (matchCall || matchIncoming)) {
+ const reasonText = resolveCallEndReasonText(payload.endReason)
+ console.info('[Call] end:', reasonText)
+ rtcStore.reset()
+ }
+ }
+ }
+})
+
+export const useImWebSocketStoreWithOut = () => {
+ return useImWebSocketStore()
+}
+
+// dev: 璁� Pinia 鐨� actions / state 鏀瑰姩鏀寔 HMR锛岄伩鍏嶆瘡娆℃敼 store 閮藉緱纭埛
+// 鍚﹀垯 Vite 鎶婃柊妯″潡鎺ㄤ笅鏉ュ悗锛岃�� store 瀹炰緥鐨� action 闂寘浠嶆寚鍚戞棫鍑芥暟浣�
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useImWebSocketStore, import.meta.hot))
+}
diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts
new file mode 100644
index 0000000..aaa2011
--- /dev/null
+++ b/src/views/im/home/types/index.ts
@@ -0,0 +1,318 @@
+// ==================== WebSocket 甯� / 浜嬩欢 ====================
+
+// 鍚庣 WebSocket 缁熶竴甯х粨鏋勶細{ type, content }
+export interface WebSocketFrame {
+ type: string // 甯х被鍨嬶紝瀵归綈 ImWebSocketMessageType
+ content: string // 甯у唴瀹癸紙JSON 瀛楃涓诧級
+}
+
+// IM WebSocket 閫氱煡 DTO锛堝榻愬悗绔� ImNotificationWebSocketDTO锛�
+export interface ImNotificationWebSocketDTO {
+ conversationType: number // 浼氳瘽绫诲瀷
+ contentType: number // 鍐呭绫诲瀷
+ payload: Record<string, any> // 璐熻浇鏁版嵁
+}
+
+// 鏃犱細璇濆湪绾块�氱煡锛堝榻愬悗绔� conversationType = NONE 鐨勭嫭绔� payload锛�
+export interface ImNoConversationNotification {
+ type: number // 鍐呭绫诲瀷
+ [key: string]: any
+}
+
+// 绉佽亰娑堟伅 DTO锛堝榻愬悗绔� ImPrivateMessageNotification锛�
+export interface ImPrivateMessageNotification {
+ id: number // 娑堟伅缂栧彿
+ clientMessageId: string // 瀹㈡埛绔秷鎭紪鍙�
+ senderId: number // 鍙戦�佷汉缂栧彿
+ receiverId: number // 鎺ユ敹浜虹紪鍙�
+ type: number // 鍐呭绫诲瀷
+ content: string // 娑堟伅鍐呭
+ status: number // 娑堟伅鐘舵��
+ receiptStatus?: number // 鍥炴墽鐘舵�侊紙涓嶉渶瑕� / 寰呭畬鎴� / 宸插畬鎴愶級
+ sendTime: string // 鍙戦�佹椂闂�
+}
+
+// 缇よ亰娑堟伅 DTO锛堝榻愬悗绔� ImGroupMessageNotification锛�
+export interface ImGroupMessageNotification {
+ id: number // 娑堟伅缂栧彿
+ clientMessageId: string // 瀹㈡埛绔秷鎭紪鍙�
+ senderId: number // 鍙戦�佷汉缂栧彿
+ groupId: number // 缇ょ紪鍙�
+ type: number // 鍐呭绫诲瀷
+ content: string // 娑堟伅鍐呭
+ status: number // 娑堟伅鐘舵��
+ sendTime: string // 鍙戦�佹椂闂�
+ atUserIds?: number[] // 缇� @ 鐩爣鐢ㄦ埛鍒楄〃
+ receiverUserIds?: number[] // 缇ゅ畾鍚戞帴鏀剁敤鎴峰垪琛�
+ readCount?: number // 缇ゅ洖鎵у凡璇讳汉鏁帮紙type = RECEIPT 鏃朵娇鐢級
+ receiptStatus?: number // 缇ゅ洖鎵х姸鎬侊紙type = RECEIPT 鏃朵娇鐢級
+ readId?: number // 宸茶浣嶇疆
+}
+
+// 娑堟伅宸茶鍚屾閫氱煡锛堝榻愬悗绔� ImMessageReadNotification锛�
+export interface ImMessageReadNotification {
+ id: number // 宸茶浣嶇疆
+ type: number // 鍐呭绫诲瀷
+ senderId?: number // 鍙戦�佷汉缂栧彿
+ receiverId?: number // 绉佽亰鎺ユ敹浜虹紪鍙�
+ groupId?: number // 缇ょ紪鍙�
+ channelId?: number // 棰戦亾缂栧彿
+ readId?: number // 宸茶浣嶇疆
+}
+
+// 娑堟伅鍥炴墽閫氱煡锛堝榻愬悗绔� ImMessageReceiptNotification锛�
+export interface ImMessageReceiptNotification {
+ id: number // 娑堟伅缂栧彿
+ type: number // 鍐呭绫诲瀷
+ senderId?: number // 宸茶鏂圭敤鎴风紪鍙�
+ receiverId?: number // 绉佽亰鎺ユ敹浜虹紪鍙�
+ groupId?: number // 缇ょ紪鍙�
+ readCount?: number // 缇ゅ洖鎵у凡璇讳汉鏁�
+ receiptStatus?: number // 缇ゅ洖鎵х姸鎬�
+}
+
+// ==================== 鏈湴浼氳瘽 / 娑堟伅缁撴瀯 ====================
+
+/** 寮曠敤娑堟伅 */
+export interface QuoteMessage {
+ messageId: number // 寮曠敤娑堟伅缂栧彿
+ senderId: number // 寮曠敤娑堟伅鍙戦�佷汉缂栧彿
+ type: number // 寮曠敤鍐呭绫诲瀷
+ content: string // 寮曠敤娑堟伅鍐呭
+}
+
+// 浼氳瘽鏁版嵁缁撴瀯锛堝墠绔嚜鏈夌粨鏋勶紝鍚庣鏃犲搴斿疄浣擄級
+export interface Conversation {
+ // ========== 鏍稿績鏍囪瘑 ==========
+ targetId: number // 浼氳瘽鐩爣缂栧彿锛氱鑱�=瀵规柟 userId锛涚兢鑱�=groupId
+ type: number // 浼氳瘽绫诲瀷锛屽榻� ImConversationType
+
+ // ========== 灞曠ず瀛楁 ==========
+ name: string // 灞曠ず鍚嶇О锛堢鑱�=濂藉弸鏄电О锛涚兢鑱�=缇ゅ悕锛�
+ avatar: string // 澶村儚
+ unreadCount: number // 鏈鏁�
+
+ // ========== 鏈�鍚庝竴鏉℃秷鎭簨瀹炵储寮� ==========
+ lastContent: string // 浼氳瘽鍒楄〃灞曠ず鐨勬渶鍚庝竴鏉℃秷鎭憳瑕�
+ lastSendTime: number // 鏈�鍚庝竴鏉℃秷鎭椂闂达紝鐢ㄤ簬鎺掑簭
+ lastSenderId?: number // 鍙戦�佷汉缂栧彿
+ lastMessageType?: number // 鍐呭绫诲瀷锛屽榻� ImContentType
+ lastMessageId?: number // 鏈�鍚庝竴鏉℃湇鍔$娑堟伅缂栧彿
+ lastClientMessageId?: string // 鏈�鍚庝竴鏉″鎴风娑堟伅缂栧彿
+ lastMessageStatus?: number // 鏈�鍚庝竴鏉℃秷鎭姸鎬�
+ lastReceiptStatus?: number // 鏈�鍚庝竴鏉$兢鍥炴墽鐘舵��
+ lastSelfSend?: boolean // 鏄惁鑷繁鍙戠殑
+ lastSenderDisplayName?: string // 鍙戦�佷汉鏄剧ず鍚嶅揩鐓р�斺�斾粎浣� utils/user.getSenderDisplayName 瀹炴椂绠椾笉鍑虹湡鍚嶆椂鐨� fallback
+
+ // ========== UI 鐘舵�� ==========
+ deleted?: boolean // 鏄惁宸插垹闄わ紙杞垹鏍囪锛屾寔涔呭寲鏃惰繃婊わ級
+ top?: boolean // 鏄惁缃《锛堟帓搴忔椂浼樺厛锛�
+ silent?: boolean // 鏄惁鍏嶆墦鎵帮紙涓嶅睍绀烘湭璇诲窘鏍� + 涓嶅搷鎻愮ず闊筹級
+ atMe?: boolean // 缇よ亰锛氭槸鍚︽湁浜� @鎴�
+ atAll?: boolean // 缇よ亰锛氭槸鍚︽湁浜� @鍏ㄤ綋鎴愬憳
+ reportedReadMessageId?: number // 宸蹭笂鎶ュ埌鏈嶅姟绔殑鏈�澶у凡璇绘秷鎭紪鍙�
+ draft?: {
+ html: string // 杈撳叆妗� HTML
+ plain: string // 杈撳叆妗嗙函鏂囨湰
+ reply?: QuoteMessage // 寮曠敤娑堟伅
+ } // 杈撳叆妗嗚崏绋�
+}
+
+// 娑堟伅鏁版嵁缁撴瀯
+export interface Message {
+ // ========== 鍚庣瀛楁锛堝榻� ImPrivateMessageNotification / ImGroupMessageNotification锛� ==========
+ id?: number // 鏈嶅姟绔秷鎭紪鍙凤紝鍙戦�佷腑涓虹┖
+ clientMessageId: string // 瀹㈡埛绔秷鎭紪鍙凤紝鏈湴鐢熸垚鐢ㄤ簬鍚堝苟鍘婚噸
+ type: number // 鍐呭绫诲瀷锛屽榻� ImContentType
+ content: string // 娑堟伅鍐呭锛孞SON 瀛楃涓�
+ status: number // 娑堟伅鐘舵�侊紝瀵归綈 ImMessageStatus
+ sendTime: number // 鍙戦�佹椂闂达紙鍓嶇杞绉掓椂闂存埑锛涘悗绔负 LocalDateTime 瀛楃涓诧級
+ senderId: number // 鍙戦�佷汉缂栧彿
+ atUserIds?: number[] // 缇� @ 鐩爣鐢ㄦ埛鍒楄〃
+ receiverUserIds?: number[] // 缇ゅ畾鍚戞帴鏀剁敤鎴峰垪琛�
+ receiptStatus?: number // 鍥炴墽鐘舵�侊紝瀵归綈 ImMessageReceiptStatus锛堢鑱� / 缇� / 棰戦亾閫氱敤锛�
+ readCount?: number // 缇ゅ洖鎵у凡璇讳汉鏁帮紙浠呯兢娑堟伅锛�
+ materialId?: number // 鍏宠仈棰戦亾绱犳潗缂栧彿锛堜粎棰戦亾娑堟伅 type=MATERIAL锛�
+
+ // ========== 鍓嶇鎵╁睍瀛楁 ==========
+ // 鍙戦�佷汉鏄剧ず鍚嶄竴寰嬫覆鏌撴椂瀹炴椂绠楋細utils/user.getSenderDisplayName / getSenderRealNickname
+ // 涓嶅湪 Message 涓婂瓨浠讳綍鍚嶅瓧蹇収锛岄伩鍏嶅娉� / 缇ゆ樀绉板彉鏇村悗鍘嗗彶娑堟伅鏄剧ず闄堟棫
+ targetId: number // 浼氳瘽鐩爣缂栧彿锛堢鑱�=瀵圭 userId / 缇よ亰=groupId锛夛紝涓� Conversation.targetId 涓�鑷�
+ selfSend: boolean // 鏄惁鑷繁鍙戦�侊紙鍓嶇鎸� senderId 璁$畻锛�
+ uploadProgress?: number // 濯掍綋娑堟伅涓婁紶杩涘害锛�0-100锛夛紱status=SENDING 鏈熼棿鎸佺画鏇存柊锛沘ck 鍚庣疆 undefined
+ // 濯掍綋娑堟伅鍐呭瓨涓繚鐣欑殑鍘熷 File锛涗笅鍒掔嚎鍓嶇紑琛ㄧず涓嶈繘 JSON / 涓嶆寔涔呭寲锛圛DB 鎭㈠鍚庡繀涓� undefined锛�
+ // 澶辫触閲嶈瘯鏃舵寜瀹冮噸璧颁笂浼狅紱椤甸潰鍒锋柊鍚庤瀛楁涓㈠け锛屾仮澶嶉樁娈电洿鎺� drop 鏁存潯娑堟伅
+ _localFile?: File
+ _ackMerging?: boolean // ack 鍚堝苟涓爣璁帮紝涓嶆寔涔呭寲
+}
+
+// ==================== IndexedDB 鏈湴瀛樺偍缁撴瀯 ====================
+
+/** 浼氳瘽 IndexedDB 瀛樺偍缁撴瀯 */
+export interface ConversationDO extends Conversation {
+ clientConversationId: string // `${type}:${targetId}`
+}
+
+export interface ConversationRead {
+ conversationType: number // 浼氳瘽绫诲瀷锛屽榻� ImConversationType
+ targetId: number // 浼氳瘽鐩爣缂栧彿
+ messageId: number // 褰撳墠鐢ㄦ埛宸茶鍒扮殑鏈�澶ф秷鎭紪鍙�
+ updateTime?: number // 鏇存柊鏃堕棿
+}
+
+/** 浼氳瘽璇讳綅缃� IndexedDB 瀛樺偍缁撴瀯 */
+export interface ConversationReadDO extends ConversationRead {
+ clientConversationId: string // `${conversationType}:${targetId}`
+}
+
+/** 娑堟伅 IndexedDB 瀛樺偍缁撴瀯 */
+export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> {
+ messageKey: string // `${conversationType}:${id}` 鎴� `client:${clientMessageId}`
+ conversationType: number // 浼氳瘽绫诲瀷锛屽榻� ImConversationType
+ clientConversationId: string // ConversationDO.clientConversationId
+}
+
+/** 璁剧疆 IndexedDB 瀛樺偍缁撴瀯 */
+export interface SettingDO<T = unknown> {
+ key: string
+ value: T
+ updateTime?: number
+}
+
+// ==================== 缇� / 缇ゆ垚鍛� ====================
+
+// 缇ゅ疄浣擄紙鍓嶇鍐呴儴缁撴瀯锛�
+export interface Group {
+ // ========== 鍚庣瀛楁锛堝榻� ImGroupApi.GroupRespVO锛� ==========
+ id: number // 缇ょ紪鍙�
+ name: string // 缇ゅ悕绉�
+ avatar?: string // 缇ゅご鍍�
+ notice?: string // 缇ゅ叕鍛�
+ ownerUserId?: number // 缇や富鐢ㄦ埛缂栧彿
+ pinnedMessages?: Message[] // 缇ょ疆椤舵秷鎭垪琛�
+ mutedAll?: boolean // 鏄惁鍏ㄧ兢绂佽█
+ banned?: boolean // 鏄惁琚鐞嗗憳灏佺
+ joinApproval?: boolean // 杩涚兢鏄惁闇�缇や富 / 绠$悊鍛樺鎵�
+ joinStatus?: number // 褰撳墠鐧诲綍鐢ㄦ埛鍦ㄨ缇ょ殑鎴愬憳鐘舵�侊紙鍙傝 CommonStatusEnum锛�0 鍦ㄧ兢 / 1 宸查��缇わ級锛涘巻鍙查��缇ょ兢浠嶈繑鍥烇紝渚涘睍绀哄巻鍙叉秷鎭殑缇ゅ悕 / 澶村儚
+
+ // ========== 鍓嶇鎵╁睍瀛楁锛坲ser-per-group 缁村害锛� ==========
+ silent?: boolean // 鏄惁鍏嶆墦鎵般�備粠褰撳墠鐢ㄦ埛鐨� GroupMember 鍥炲~
+ groupRemark?: string // 缇ゅ娉ㄣ�備粠褰撳墠鐢ㄦ埛鐨� GroupMember 鍥炲~锛堝綋鍓嶇敤鎴峰璇ョ兢鐨勮嚜瀹氫箟鍚嶏級
+ members?: GroupMember[] // 缇ゆ垚鍛樼紦瀛橈紙鎸夐渶鎳掑姞杞斤級
+ infoLoaded?: boolean // 缇よ鎯呮槸鍚﹀凡鍔犺浇锛屾湰杞細璇濆唴瀛樻爣璁帮紝涓嶆寔涔呭寲
+ activeCallLoaded?: boolean // 缇ゆ椿璺冮�氳瘽鏄惁宸叉帰娴嬶紝鏈疆浼氳瘽鍐呭瓨鏍囪锛屼笉鎸佷箙鍖�
+ activeCallExpired?: boolean // 缇ゆ椿璺冮�氳瘽鎺㈡祴鏄惁宸茶繃鏈�
+ membersLoaded?: boolean // members 鏄惁"瀹屾暣鍔犺浇"鈥斺�斿彧鏈夋暣缇� loadGroupMemberList / fetchGroupMemberList 鍛戒腑鏃朵负 true锛沠etchGroupMember 鍗曟垚鍛樿ˉ榻愪笉缃綅锛岄伩鍏� fetchGroupMemberList(force=false) 鍛戒腑缂撳瓨鏃惰鍒ゆ暣缇ゅ凡鍔犺浇
+ membersExpired?: boolean // 缇ゆ垚鍛樼紦瀛樻槸鍚﹀凡杩囨湡锛涢噸杩� / 閲嶆柊杩涘叆 IM 鍚庡彧鏍囪涓嶅垹闄わ紝涓嬫杩涘叆缇や細璇濆啀鍒锋柊
+ memberCount?: number // 鎴愬憳鎬绘暟
+}
+
+/** 缇� IndexedDB 瀛樺偍缁撴瀯 */
+export type GroupDO = Omit<
+ Group,
+ 'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'
+>
+
+// 缇ゆ垚鍛樺疄浣擄紙鍓嶇鍐呴儴缁撴瀯锛�
+export interface GroupMember {
+ // ========== 鍚庣瀛楁锛堝榻� ImGroupMemberApi.GroupMemberRespVO锛� ==========
+ id?: number // 缇ゆ垚鍛樺叧绯昏褰曠紪鍙�
+ groupId: number // 缇ょ紪鍙�
+ userId: number // 鐢ㄦ埛缂栧彿
+ avatar?: string // 澶村儚
+ nickname: string // 鐢ㄦ埛鏄电О
+ displayUserName?: string // 璇ユ垚鍛樺湪缇ゅ唴鑷畾涔夋樀绉帮紙姣忎釜 member 涓�浠斤紱涓嶄笌 nickname 鍚堝苟锛岀敱娑堣垂鏂规寜闇�鍙栬垗锛�
+ status?: number // 鍦ㄧ兢 / 閫�缇ょ姸鎬侊紝瀵归綈 CommonStatusEnum
+ role?: number // 鎴愬憳瑙掕壊锛屽弬瑙� ImGroupMemberRole 鏋氫妇锛�1=缇や富 2=绠$悊鍛� 3=鏅�氭垚鍛�
+ muteEndTime?: string // 绂佽█鍒版湡鏃堕棿锛圛SO 瀛楃涓诧級
+
+ // ========== 鍓嶇鎵╁睍瀛楁 ==========
+ isOwner?: boolean // 鏄惁缇や富锛堝墠绔粠 Group.ownerUserId 璁$畻锛�
+}
+
+// ==================== 濂藉弸 ====================
+
+// 濂藉弸瀹炰綋锛堝墠绔唴閮ㄧ粨鏋勶級
+export interface Friend {
+ // ========== 鍚庣瀛楁锛堝榻� ImFriendApi.FriendRespVO锛� ==========
+ id?: number // 濂藉弸鍏崇郴璁板綍缂栧彿锛堟湰鍦颁箰瑙傛柊澧炴椂鍙兘鏆傜己锛�
+ friendUserId: number // 濂藉弸鐢ㄦ埛缂栧彿锛堜笌 Conversation.targetId 瀵归綈锛�
+ nickname: string // 濂藉弸鏄电О锛堝鏂圭湡瀹炴樀绉帮紝姘歌繙涓嶈澶囨敞瑕嗙洊锛沀I 鏄剧ず璧� displayName || nickname锛�
+ nicknamePinyin?: string // 鏄电О鐨勬嫾闊筹紙鍚庣鐢� Pinyin4j 绠楀ソ鍥炲~锛屽皬鍐欐棤绌烘牸锛�
+ avatar?: string // 濂藉弸澶村儚
+ silent?: boolean // 鏄惁鍏嶆墦鎵帮紙涓嶅睍绀烘湭璇诲窘鏍� + 涓嶅搷鎻愮ず闊筹級
+ displayName?: string // 濂藉弸灞曠ず澶囨敞锛氫粎鑷繁鍙鐨勫埆鍚嶏紙鍗曞瓧娈典笉姝т箟锛屼笉甯� Friend 鍓嶇紑锛�
+ displayNamePinyin?: string // 澶囨敞鐨勬嫾闊筹紙鍚庣鐢� Pinyin4j 绠楀ソ鍥炲~锛屽皬鍐欐棤绌烘牸锛�
+ status?: number // 濂藉弸鐘舵�侊紝瀵归綈 CommonStatusEnum锛圖ISABLE = 宸插垹闄わ紝杞垹淇濈暀璁板綍锛�
+ addSource?: number // 娣诲姞鏉ユ簮锛涘弬瑙� ImFriendAddSourceEnum
+ pinned?: boolean // 鏄惁缃《鑱旂郴浜�
+ blocked?: boolean // 鏄惁鎷夐粦锛堜粎鑷繁鍙锛屽崟杈瑰睆钄藉鏂圭鑱婃秷鎭級
+ addTime?: number // 娣诲姞濂藉弸鏃堕棿锛堟绉掓椂闂存埑锛涘悗绔负 LocalDateTime 瀛楃涓诧紝鍦� convertFriend 杞崲锛�
+ deleteTime?: number // 鍒犻櫎濂藉弸鏃堕棿锛堟绉掓椂闂存埑锛涘悗绔负 LocalDateTime 瀛楃涓诧紝鍦� convertFriend 杞崲锛�
+}
+
+/**
+ * 濂藉弸鐢宠璁板綍锛堝墠绔唴閮ㄧ粨鏋勶紝瀵归綈鍚庣 ImFriendRequestApi.FriendRequestRespVO锛�
+ */
+export interface FriendRequest {
+ // ========== 鍚庣瀛楁锛堝榻� ImFriendRequestApi.FriendRequestRespVO锛� ==========
+ id: number // 鐢宠缂栧彿
+ fromUserId: number // 鍙戣捣鏂圭敤鎴风紪鍙�
+ toUserId: number // 鎺ユ敹鏂圭敤鎴风紪鍙�
+ handleResult: number // 澶勭悊缁撴灉锛�0=鏈鐞嗭紱1=鍚屾剰锛�2=鎷掔粷
+ applyContent?: string // 鐢宠鐞嗙敱锛堝彂璧锋柟濉啓锛�
+ handleContent?: string // 澶勭悊鐞嗙敱锛堟帴鏀舵柟鎷掔粷鏃跺彲閫夊~锛�
+ addSource?: number // 娣诲姞鏉ユ簮锛涘弬瑙� ImFriendAddSourceEnum
+ handleTime?: number // 澶勭悊鏃堕棿锛堟绉掓椂闂存埑锛�
+ createTime: number // 鐢宠鍒涘缓鏃堕棿锛堟绉掓椂闂存埑锛�
+
+ // ========== 鑱氬悎瀛楁锛堣嚜 AdminUser锛屼粎灞曠ず鐢級 ==========
+ fromNickname?: string // 鍙戣捣鏂规樀绉�
+ fromAvatar?: string // 鍙戣捣鏂瑰ご鍍�
+ toNickname?: string // 鎺ユ敹鏂规樀绉�
+ toAvatar?: string // 鎺ユ敹鏂瑰ご鍍�
+}
+
+// ==================== 鐢ㄦ埛鍚嶇墖 ====================
+
+// 鐢ㄦ埛绮剧畝淇℃伅锛堝榻愬悗绔� UserSimpleRespVO锛屽悕鐗� / 澶村儚 hover 绛夊満鏅叡鐢級
+export interface User {
+ id: number
+ nickname?: string
+ avatar?: string
+ sex?: number
+ deptId?: number
+ deptName?: string
+}
+
+// ==================== 鍒楄〃琛屽睍绀虹敤 Lite 绫诲瀷 ====================
+
+/**
+ * 濂藉弸鍒楄〃琛岋細浠� Friend 娲剧敓鐨勫睍绀哄揩鐓�
+ * - id 鐢� friendUserId锛堜笌鍒楄〃 click / 閫変腑姣斿涓�鑷达級锛屼笉鏄� Friend.id锛堝叧绯昏褰曚富閿級
+ * - 杞垹锛坰tatus === DISABLE锛夌敱涓婃父 friendStore.getActiveFriendList / getActiveFriendLiteList 缁熶竴杩囨护鎺�
+ */
+export interface FriendLite {
+ id: number
+ nickname: string
+ nicknamePinyin?: string // 鏄电О鎷奸煶锛堢敤浜庡瓧姣嶅垎妗� / 鎷奸煶鎼滅储锛�
+ avatar?: string
+ displayName?: string
+ displayNamePinyin?: string // 澶囨敞鎷奸煶锛堜紭鍏堜簬 nicknamePinyin 鍙備笌鍒嗘《锛�
+}
+
+/**
+ * 缇ゅ垪琛ㄨ锛氫粠 Group 娲剧敓鐨勫睍绀哄揩鐓�
+ * - showGroupName / showImage锛氳皟鐢ㄦ柟鍐冲畾甯︿笉甯﹀娉紙濡備釜浜哄娉ㄧ兢鍚嶏級锛涘睍绀烘寜 show* > 鍘熷�煎厹搴�
+ * - showImageThumb锛氶珮棰戝垪琛ㄧ敤缂╃暐鍥撅紝閬垮厤鎷夊師鍥鹃樆濉炴粴鍔�
+ */
+export interface GroupLite {
+ id: number
+ name?: string
+ showGroupName?: string
+ showImage?: string
+ showImageThumb?: string
+ memberCount?: number
+ ownerId?: number
+ joinApproval?: boolean // 杩涚兢鏄惁闇�缇や富 / 绠$悊鍛樺鎵�
+}
diff --git a/src/views/im/manager/channel/list/components/index.ts b/src/views/im/manager/channel/list/components/index.ts
new file mode 100644
index 0000000..da6d660
--- /dev/null
+++ b/src/views/im/manager/channel/list/components/index.ts
@@ -0,0 +1 @@
+export { default as ChannelSelect } from './select.vue';
diff --git a/src/views/im/manager/channel/list/components/select.vue b/src/views/im/manager/channel/list/components/select.vue
new file mode 100644
index 0000000..6195d1c
--- /dev/null
+++ b/src/views/im/manager/channel/list/components/select.vue
@@ -0,0 +1,66 @@
+<script lang="ts" setup>
+import type { ImManagerChannelApi } from '#/api/im/manager/channel';
+
+import { computed, onMounted, ref } from 'vue';
+
+import { Select } from 'ant-design-vue';
+
+import { getSimpleChannelList } from '#/api/im/manager/channel';
+
+defineOptions({ name: 'ImManagerChannelSelect' });
+
+const props = withDefaults(
+ defineProps<{
+ allowClear?: boolean
+ disabled?: boolean
+ modelValue?: number
+ placeholder?: string
+ }>(),
+ {
+ allowClear: true,
+ disabled: false,
+ modelValue: undefined,
+ placeholder: '璇烽�夋嫨棰戦亾',
+ },
+);
+
+const emit = defineEmits<{
+ 'update:modelValue': [value: number | undefined];
+}>();
+
+const loading = ref(false);
+const channelList = ref<ImManagerChannelApi.Channel[]>([]);
+
+const value = computed({
+ get: () => props.modelValue,
+ set: (val) => emit('update:modelValue', val),
+});
+
+const options = computed(() =>
+ channelList.value.map((item) => ({ label: item.name, value: item.id })),
+);
+
+/** 鍔犺浇棰戦亾鍒楄〃 */
+async function loadChannelList() {
+ loading.value = true;
+ try {
+ channelList.value = await getSimpleChannelList();
+ } finally {
+ loading.value = false;
+ }
+}
+
+onMounted(loadChannelList);
+</script>
+
+<template>
+ <Select
+ v-model:value="value"
+ :allow-clear="allowClear"
+ :disabled="disabled"
+ :loading="loading"
+ :options="options"
+ :placeholder="placeholder"
+ class="w-full"
+ />
+</template>
diff --git a/src/views/im/manager/channel/list/data.ts b/src/views/im/manager/channel/list/data.ts
new file mode 100644
index 0000000..b182db3
--- /dev/null
+++ b/src/views/im/manager/channel/list/data.ts
@@ -0,0 +1,157 @@
+import type { VbenFormSchema } from '#/adapter/form'
+import type { VxeTableGridOptions } from '#/adapter/vxe-table'
+
+import { CommonStatusEnum, DICT_TYPE } from '#/packages/constants/src'
+import { getDictOptions } from '#/packages/effects/hooks/src'
+
+import { z } from '#/adapter/form'
+
+/** 鏂板/淇敼鐨勮〃鍗� */
+export function useFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false
+ }
+ },
+ {
+ fieldName: 'code',
+ label: '棰戦亾缂栫爜',
+ component: 'Input',
+ componentProps: {
+ placeholder: '濡� system_notice'
+ },
+ rules: z
+ .string({ message: '棰戦亾缂栫爜涓嶈兘涓虹┖' })
+ .regex(/^[a-z][a-z0-9_]*$/, '鍙兘鐢卞皬鍐欏瓧姣� / 鏁板瓧 / 涓嬪垝绾跨粍鎴愶紝涓斾互瀛楁瘝寮�澶�')
+ },
+ {
+ fieldName: 'name',
+ label: '棰戦亾鍚嶇О',
+ component: 'Input',
+ componentProps: {
+ placeholder: '濡� 绯荤粺鍏憡'
+ },
+ rules: 'required'
+ },
+ {
+ fieldName: 'avatar',
+ label: '棰戦亾澶村儚',
+ component: 'ImageUpload',
+ componentProps: {
+ maxNumber: 1
+ },
+ rules: 'required'
+ },
+ {
+ fieldName: 'sort',
+ label: '鎺掑簭',
+ component: 'InputNumber',
+ componentProps: {
+ min: 0
+ },
+ rules: z.number().default(0)
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'RadioGroup',
+ componentProps: {
+ options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+ buttonStyle: 'solid',
+ optionType: 'button'
+ },
+ rules: z.number().default(CommonStatusEnum.ENABLE)
+ }
+ ]
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'code',
+ label: '缂栫爜',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '棰戦亾涓氬姟鐮�'
+ }
+ },
+ {
+ fieldName: 'name',
+ label: '鍚嶇О',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '棰戦亾鍚嶇О'
+ }
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨鐘舵��'
+ }
+ }
+ ]
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 80
+ },
+ {
+ field: 'avatar',
+ title: '澶村儚',
+ width: 80,
+ slots: { default: 'avatar' }
+ },
+ {
+ field: 'code',
+ title: '缂栫爜',
+ minWidth: 160
+ },
+ {
+ field: 'name',
+ title: '鍚嶇О',
+ minWidth: 140
+ },
+ {
+ field: 'sort',
+ title: '鎺掑簭',
+ width: 80
+ },
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.COMMON_STATUS }
+ }
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime'
+ },
+ {
+ title: '鎿嶄綔',
+ width: 160,
+ fixed: 'right',
+ slots: { default: 'actions' }
+ }
+ ]
+}
diff --git a/src/views/im/manager/channel/list/index.vue b/src/views/im/manager/channel/list/index.vue
new file mode 100644
index 0000000..8636943
--- /dev/null
+++ b/src/views/im/manager/channel/list/index.vue
@@ -0,0 +1,130 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table'
+import type { ImManagerChannelApi } from '#/api/im/manager/channel'
+
+import { Page, useVbenModal } from '#/packages/effects/common-ui/src'
+
+import { Image, message } from 'ant-design-vue'
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'
+import { deleteManagerChannel, getManagerChannelPage } from '#/api/im/manager/channel'
+import { $t } from '#/locales'
+
+import { useGridColumns, useGridFormSchema } from './data'
+import Form from './modules/form.vue'
+
+defineOptions({ name: 'ImManagerChannel' })
+
+const [FormModal, formModalApi] = useVbenModal({
+ connectedComponent: Form,
+ destroyOnClose: true
+})
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query()
+}
+
+/** 鍒涘缓棰戦亾 */
+function handleCreate() {
+ formModalApi.setData(null).open()
+}
+
+/** 缂栬緫棰戦亾 */
+function handleEdit(row: ImManagerChannelApi.Channel) {
+ formModalApi.setData(row).open()
+}
+
+/** 鍒犻櫎棰戦亾 */
+async function handleDelete(row: ImManagerChannelApi.Channel) {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deleting', [row.name]),
+ duration: 0
+ })
+ try {
+ await deleteManagerChannel(row.id)
+ message.success($t('ui.actionMessage.deleteSuccess', [row.name]))
+ handleRefresh()
+ } finally {
+ hideLoading()
+ }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGridFormSchema()
+ },
+ gridOptions: {
+ columns: useGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerChannelPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues
+ })
+ }
+ }
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true
+ }
+ } as VxeTableGridOptions<ImManagerChannelApi.Channel>
+})
+</script>
+
+<template>
+ <Page auto-content-height>
+ <FormModal @success="handleRefresh" />
+ <Grid table-title="棰戦亾鍒楄〃">
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: $t('ui.actionTitle.create', ['棰戦亾']),
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['im:manager:channel:create'],
+ onClick: handleCreate
+ }
+ ]"
+ />
+ </template>
+ <template #avatar="{ row }">
+ <Image v-if="row.avatar" :src="row.avatar" :width="32" :height="32" />
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: $t('common.edit'),
+ type: 'link',
+ icon: ACTION_ICON.EDIT,
+ auth: ['im:manager:channel:update'],
+ onClick: handleEdit.bind(null, row)
+ },
+ {
+ label: $t('common.delete'),
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:channel:delete'],
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteConfirm', [row.name]),
+ confirm: handleDelete.bind(null, row)
+ }
+ }
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/channel/list/modules/form.vue b/src/views/im/manager/channel/list/modules/form.vue
new file mode 100644
index 0000000..fa39414
--- /dev/null
+++ b/src/views/im/manager/channel/list/modules/form.vue
@@ -0,0 +1,82 @@
+<script lang="ts" setup>
+import type { ImManagerChannelApi } from '#/api/im/manager/channel'
+
+import { computed, ref } from 'vue'
+
+import { useVbenModal } from '#/packages/effects/common-ui/src'
+
+import { message } from 'ant-design-vue'
+
+import { useVbenForm } from '#/adapter/form'
+import {
+ createManagerChannel,
+ getManagerChannel,
+ updateManagerChannel
+} from '#/api/im/manager/channel'
+import { $t } from '#/locales'
+
+import { useFormSchema } from '../data'
+
+const emit = defineEmits(['success'])
+const formData = ref<ImManagerChannelApi.Channel>()
+const getTitle = computed(() => {
+ return formData.value?.id
+ ? $t('ui.actionTitle.edit', ['棰戦亾'])
+ : $t('ui.actionTitle.create', ['棰戦亾'])
+})
+
+const [Form, formApi] = useVbenForm({
+ commonConfig: {
+ componentProps: {
+ class: 'w-full'
+ },
+ formItemClass: 'col-span-2',
+ labelWidth: 100
+ },
+ layout: 'horizontal',
+ schema: useFormSchema(),
+ showDefaultActions: false
+})
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ const { valid } = await formApi.validate()
+ if (!valid) {
+ return
+ }
+ modalApi.lock()
+ const data = (await formApi.getValues()) as ImManagerChannelApi.Channel
+ try {
+ await (formData.value?.id ? updateManagerChannel(data) : createManagerChannel(data))
+ await modalApi.close()
+ emit('success')
+ message.success($t('ui.actionMessage.operationSuccess'))
+ } finally {
+ modalApi.unlock()
+ }
+ },
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ formData.value = undefined
+ return
+ }
+ const data = modalApi.getData<ImManagerChannelApi.Channel>()
+ if (!data || !data.id) {
+ return
+ }
+ modalApi.lock()
+ try {
+ formData.value = await getManagerChannel(data.id)
+ await formApi.setValues(formData.value)
+ } finally {
+ modalApi.unlock()
+ }
+ }
+})
+</script>
+
+<template>
+ <Modal :title="getTitle" class="w-1/3">
+ <Form class="mx-4" />
+ </Modal>
+</template>
diff --git a/src/views/im/manager/channel/material/components/index.ts b/src/views/im/manager/channel/material/components/index.ts
new file mode 100644
index 0000000..a0d10a8
--- /dev/null
+++ b/src/views/im/manager/channel/material/components/index.ts
@@ -0,0 +1 @@
+export { default as MaterialSelect } from './select.vue';
diff --git a/src/views/im/manager/channel/material/components/select.vue b/src/views/im/manager/channel/material/components/select.vue
new file mode 100644
index 0000000..c5e47b8
--- /dev/null
+++ b/src/views/im/manager/channel/material/components/select.vue
@@ -0,0 +1,71 @@
+<script lang="ts" setup>
+import type { ImManagerChannelMaterialApi } from '#/api/im/manager/channel/material';
+
+import { computed, ref, watch } from 'vue';
+
+import { Select } from 'ant-design-vue';
+
+import { getSimpleManagerChannelMaterialList } from '#/api/im/manager/channel/material';
+
+defineOptions({ name: 'ImManagerMaterialSelect' });
+
+const props = withDefaults(
+ defineProps<{
+ allowClear?: boolean
+ channelId?: number
+ modelValue?: number
+ placeholder?: string
+ }>(),
+ {
+ allowClear: true,
+ channelId: undefined,
+ modelValue: undefined,
+ placeholder: '璇烽�夋嫨绱犳潗',
+ },
+);
+
+const emit = defineEmits<{
+ 'update:modelValue': [value: number | undefined];
+}>();
+
+const loading = ref(false);
+const materialList = ref<ImManagerChannelMaterialApi.Material[]>([]);
+
+const value = computed({
+ get: () => props.modelValue,
+ set: (val) => emit('update:modelValue', val),
+});
+
+const options = computed(() =>
+ materialList.value.map((item) => ({ label: item.title, value: item.id })),
+);
+
+/** 鎸夐閬撳姞杞界礌鏉� */
+async function loadMaterialList(channelId?: number) {
+ emit('update:modelValue', undefined);
+ materialList.value = [];
+ if (!channelId) {
+ return;
+ }
+ loading.value = true;
+ try {
+ materialList.value = await getSimpleManagerChannelMaterialList(channelId);
+ } finally {
+ loading.value = false;
+ }
+}
+
+watch(() => props.channelId, loadMaterialList, { immediate: true });
+</script>
+
+<template>
+ <Select
+ v-model:value="value"
+ :allow-clear="allowClear"
+ :disabled="!channelId"
+ :loading="loading"
+ :options="options"
+ :placeholder="placeholder"
+ class="w-full"
+ />
+</template>
diff --git a/src/views/im/manager/channel/material/data.ts b/src/views/im/manager/channel/material/data.ts
new file mode 100644
index 0000000..34488e4
--- /dev/null
+++ b/src/views/im/manager/channel/material/data.ts
@@ -0,0 +1,235 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { markRaw } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { z } from '#/adapter/form';
+import { getRangePickerDefaultProps } from '#/utils';
+import { ChannelSelect } from '#/views/im/manager/channel/list/components';
+
+/** 鏂板/淇敼鐨勮〃鍗� */
+export function useFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'channelId',
+ label: '鎵�灞為閬�',
+ component: markRaw(ChannelSelect),
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'type',
+ label: '鍐呭绫诲瀷',
+ component: 'RadioGroup',
+ componentProps: {
+ options: getDictOptions(DICT_TYPE.IM_CHANNEL_MATERIAL_TYPE, 'number'),
+ buttonStyle: 'solid',
+ optionType: 'button',
+ },
+ rules: z.number().default(1),
+ },
+ {
+ fieldName: 'title',
+ label: '鏍囬',
+ component: 'Input',
+ componentProps: {
+ maxlength: 128,
+ placeholder: '璇疯緭鍏ユ爣棰�',
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'coverUrl',
+ label: '灏侀潰鍥�',
+ component: 'ImageUpload',
+ componentProps: {
+ maxNumber: 1,
+ },
+ },
+ {
+ fieldName: 'summary',
+ label: '鎽樿',
+ component: 'Textarea',
+ componentProps: {
+ maxlength: 255,
+ placeholder: '璇疯緭鍏ユ憳瑕�',
+ rows: 2,
+ },
+ },
+ {
+ fieldName: 'content',
+ label: '姝f枃',
+ component: 'RichTextarea',
+ dependencies: {
+ triggerFields: ['type'],
+ show: (values) => values.type === 1,
+ },
+ },
+ {
+ fieldName: 'url',
+ label: '璺宠浆閾炬帴',
+ component: 'Input',
+ componentProps: {
+ placeholder: 'https://example.com/...',
+ },
+ dependencies: {
+ triggerFields: ['type'],
+ show: (values) => values.type !== 1,
+ },
+ },
+ ];
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'channelId',
+ label: '棰戦亾',
+ component: markRaw(ChannelSelect),
+ componentProps: {
+ placeholder: '鍏ㄩ儴',
+ },
+ },
+ {
+ fieldName: 'title',
+ label: '鏍囬',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '鏍囬鍏抽敭瀛�',
+ },
+ },
+ ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 80,
+ },
+ {
+ field: 'coverUrl',
+ title: '灏侀潰',
+ width: 80,
+ slots: { default: 'cover' },
+ },
+ {
+ field: 'channelName',
+ title: '棰戦亾',
+ minWidth: 120,
+ },
+ {
+ field: 'type',
+ title: '鍐呭绫诲瀷',
+ width: 110,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_CHANNEL_MATERIAL_TYPE },
+ },
+ },
+ {
+ field: 'title',
+ title: '鏍囬',
+ minWidth: 180,
+ },
+ {
+ field: 'summary',
+ title: '鎽樿',
+ minWidth: 180,
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 160,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
+
+/** 棰戦亾娑堟伅鎼滅储琛ㄥ崟 */
+export function useMessageGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'channelId',
+ label: '棰戦亾',
+ component: markRaw(ChannelSelect),
+ componentProps: {
+ placeholder: '鍏ㄩ儴',
+ },
+ },
+ {
+ fieldName: 'sendTime',
+ label: '鍙戦�佹椂闂�',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 棰戦亾娑堟伅瀛楁 */
+export function useMessageGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 80,
+ },
+ {
+ field: 'materialCoverUrl',
+ title: '灏侀潰',
+ width: 80,
+ slots: { default: 'cover' },
+ },
+ {
+ field: 'channelName',
+ title: '棰戦亾',
+ minWidth: 120,
+ },
+ {
+ field: 'materialTitle',
+ title: '绱犳潗鏍囬',
+ minWidth: 180,
+ },
+ {
+ field: 'receiverUserIds',
+ title: '鎺ユ敹浜�',
+ width: 120,
+ slots: { default: 'receivers' },
+ },
+ {
+ field: 'sendTime',
+ title: '鍙戦�佹椂闂�',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 100,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
diff --git a/src/views/im/manager/channel/material/index.vue b/src/views/im/manager/channel/material/index.vue
new file mode 100644
index 0000000..5127f08
--- /dev/null
+++ b/src/views/im/manager/channel/material/index.vue
@@ -0,0 +1,138 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerChannelMaterialApi } from '#/api/im/manager/channel/material';
+
+import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { Image, message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ deleteManagerChannelMaterial,
+ getManagerChannelMaterialPage,
+} from '#/api/im/manager/channel/material';
+import { $t } from '#/locales';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+defineOptions({ name: 'ImManagerChannelMaterial' });
+
+const [FormModal, formModalApi] = useVbenModal({
+ connectedComponent: Form,
+ destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鍒涘缓绱犳潗 */
+function handleCreate() {
+ formModalApi.setData(null).open();
+}
+
+/** 缂栬緫绱犳潗 */
+function handleEdit(row: ImManagerChannelMaterialApi.Material) {
+ formModalApi.setData(row).open();
+}
+
+/** 鍒犻櫎绱犳潗 */
+async function handleDelete(row: ImManagerChannelMaterialApi.Material) {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deleting', [row.title]),
+ duration: 0,
+ });
+ try {
+ await deleteManagerChannelMaterial(row.id);
+ message.success($t('ui.actionMessage.deleteSuccess', [row.title]));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerChannelMaterialPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerChannelMaterialApi.Material>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <FormModal @success="handleRefresh" />
+ <Grid table-title="绱犳潗鍒楄〃">
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: $t('ui.actionTitle.create', ['绱犳潗']),
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['im:manager:channel-material:create'],
+ onClick: handleCreate,
+ },
+ ]"
+ />
+ </template>
+ <template #cover="{ row }">
+ <Image
+ v-if="row.coverUrl"
+ :height="40"
+ :src="row.coverUrl"
+ :width="40"
+ />
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: $t('common.edit'),
+ type: 'link',
+ icon: ACTION_ICON.EDIT,
+ auth: ['im:manager:channel-material:update'],
+ onClick: handleEdit.bind(null, row),
+ },
+ {
+ label: $t('common.delete'),
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:channel-material:delete'],
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteConfirm', [row.title]),
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/channel/material/modules/form.vue b/src/views/im/manager/channel/material/modules/form.vue
new file mode 100644
index 0000000..55009ae
--- /dev/null
+++ b/src/views/im/manager/channel/material/modules/form.vue
@@ -0,0 +1,87 @@
+<script lang="ts" setup>
+import type { ImManagerChannelMaterialApi } from '#/api/im/manager/channel/material';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+ createManagerChannelMaterial,
+ getManagerChannelMaterial,
+ updateManagerChannelMaterial,
+} from '#/api/im/manager/channel/material';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<ImManagerChannelMaterialApi.Material>();
+const getTitle = computed(() => {
+ return formData.value?.id
+ ? $t('ui.actionTitle.edit', ['绱犳潗'])
+ : $t('ui.actionTitle.create', ['绱犳潗']);
+});
+
+const [Form, formApi] = useVbenForm({
+ commonConfig: {
+ componentProps: {
+ class: 'w-full',
+ },
+ formItemClass: 'col-span-2',
+ labelWidth: 100,
+ },
+ layout: 'horizontal',
+ schema: useFormSchema(),
+ showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ const { valid } = await formApi.validate();
+ if (!valid) {
+ return;
+ }
+ modalApi.lock();
+ const data =
+ (await formApi.getValues()) as ImManagerChannelMaterialApi.Material;
+ try {
+ await (formData.value?.id
+ ? updateManagerChannelMaterial(data)
+ : createManagerChannelMaterial(data));
+ await modalApi.close();
+ emit('success');
+ message.success($t('ui.actionMessage.operationSuccess'));
+ } finally {
+ modalApi.unlock();
+ }
+ },
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ formData.value = undefined;
+ await formApi.resetForm();
+ return;
+ }
+ const data = modalApi.getData<ImManagerChannelMaterialApi.Material>();
+ await formApi.setValues({ type: 1 });
+ if (!data || !data.id) {
+ return;
+ }
+ modalApi.lock();
+ try {
+ formData.value = await getManagerChannelMaterial(data.id);
+ await formApi.setValues(formData.value);
+ } finally {
+ modalApi.unlock();
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal :title="getTitle" class="w-1/2">
+ <Form class="mx-4" />
+ </Modal>
+</template>
diff --git a/src/views/im/manager/channel/message/index.vue b/src/views/im/manager/channel/message/index.vue
new file mode 100644
index 0000000..1c42dc1
--- /dev/null
+++ b/src/views/im/manager/channel/message/index.vue
@@ -0,0 +1,132 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerChannelMessageApi } from '#/api/im/manager/channel/message';
+
+import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { Image, message, Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ deleteManagerChannelMessage,
+ getManagerChannelMessagePage,
+} from '#/api/im/manager/channel/message';
+import { $t } from '#/locales';
+
+import { useMessageGridColumns, useMessageGridFormSchema } from '../material/data';
+import SendForm from './modules/send-form.vue';
+
+defineOptions({ name: 'ImManagerChannelMessage' });
+
+const [SendModal, sendModalApi] = useVbenModal({
+ connectedComponent: SendForm,
+ destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鎵撳紑鍙戦�佸脊绐� */
+function handleSend() {
+ sendModalApi.open();
+}
+
+/** 鍒犻櫎棰戦亾娑堟伅 */
+async function handleDelete(row: ImManagerChannelMessageApi.ChannelMessage) {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deleting', [row.id]),
+ duration: 0,
+ });
+ try {
+ await deleteManagerChannelMessage(row.id);
+ message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useMessageGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useMessageGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerChannelMessagePage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerChannelMessageApi.ChannelMessage>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <SendModal @success="handleRefresh" />
+ <Grid table-title="棰戦亾娑堟伅鍒楄〃">
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: '绔嬪嵆鎺ㄩ��',
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['im:manager:channel-message:send'],
+ onClick: handleSend,
+ },
+ ]"
+ />
+ </template>
+ <template #cover="{ row }">
+ <Image
+ v-if="row.materialCoverUrl"
+ :height="40"
+ :src="row.materialCoverUrl"
+ :width="40"
+ />
+ </template>
+ <template #receivers="{ row }">
+ <Tag v-if="!row.receiverUserIds || row.receiverUserIds.length === 0" color="warning">
+ 鍏ㄥ憳
+ </Tag>
+ <span v-else>{{ row.receiverUserIds.length }} 浜�</span>
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: $t('common.delete'),
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:channel-message:delete'],
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteConfirm', [row.id]),
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/channel/message/modules/send-form.vue b/src/views/im/manager/channel/message/modules/send-form.vue
new file mode 100644
index 0000000..d3a7777
--- /dev/null
+++ b/src/views/im/manager/channel/message/modules/send-form.vue
@@ -0,0 +1,100 @@
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { Form, FormItem, message, Radio, RadioGroup } from 'ant-design-vue';
+
+import { sendManagerChannelMessage } from '#/api/im/manager/channel/message';
+import { ChannelSelect } from '#/views/im/manager/channel/list/components';
+import { MaterialSelect } from '#/views/im/manager/channel/material/components';
+import { UserSelect } from '#/views/system/user/components';
+
+const emit = defineEmits(['success']);
+
+const formData = ref({
+ channelId: undefined as number | undefined,
+ materialId: undefined as number | undefined,
+ receiverUserType: 'all' as 'all' | 'users',
+ receiverUserIds: [] as number[],
+});
+
+/** 閲嶇疆琛ㄥ崟 */
+function resetForm() {
+ formData.value = {
+ channelId: undefined,
+ materialId: undefined,
+ receiverUserType: 'all',
+ receiverUserIds: [],
+ };
+}
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ if (!formData.value.channelId || !formData.value.materialId) {
+ message.warning('璇烽�夋嫨棰戦亾鍜岀礌鏉�');
+ return;
+ }
+ if (
+ formData.value.receiverUserType === 'users' &&
+ formData.value.receiverUserIds.length === 0
+ ) {
+ message.warning('璇疯嚦灏戦�夋嫨涓�涓帴鏀剁敤鎴�');
+ return;
+ }
+ modalApi.lock();
+ try {
+ await sendManagerChannelMessage({
+ materialId: formData.value.materialId,
+ receiverUserIds:
+ formData.value.receiverUserType === 'users'
+ ? formData.value.receiverUserIds
+ : undefined,
+ });
+ await modalApi.close();
+ emit('success');
+ message.success('鎺ㄩ�佹垚鍔�');
+ } finally {
+ modalApi.unlock();
+ }
+ },
+ onOpenChange(isOpen: boolean) {
+ if (isOpen) {
+ resetForm();
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal class="w-2/5" title="绔嬪嵆鎺ㄩ�侀閬撴秷鎭�">
+ <Form :label-col="{ style: { width: '110px' } }">
+ <FormItem label="鎵�灞為閬�" required>
+ <ChannelSelect
+ v-model="formData.channelId"
+ placeholder="璇烽�夋嫨棰戦亾锛堢敤浜庡姞杞界礌鏉愶級"
+ />
+ </FormItem>
+ <FormItem label="绱犳潗" required>
+ <MaterialSelect
+ v-model="formData.materialId"
+ :channel-id="formData.channelId"
+ placeholder="璇烽�夋嫨绱犳潗"
+ />
+ </FormItem>
+ <FormItem label="鍙椾紬">
+ <RadioGroup v-model:value="formData.receiverUserType">
+ <Radio value="all">鍏ㄥ憳</Radio>
+ <Radio value="users">鎸囧畾鐢ㄦ埛</Radio>
+ </RadioGroup>
+ </FormItem>
+ <FormItem v-if="formData.receiverUserType === 'users'" label="鎺ユ敹鐢ㄦ埛" required>
+ <UserSelect
+ v-model="formData.receiverUserIds"
+ multiple
+ placeholder="璇烽�夋嫨鎺ユ敹鐢ㄦ埛"
+ />
+ </FormItem>
+ </Form>
+ </Modal>
+</template>
diff --git a/src/views/im/manager/face/pack/data.ts b/src/views/im/manager/face/pack/data.ts
new file mode 100644
index 0000000..143dfbe
--- /dev/null
+++ b/src/views/im/manager/face/pack/data.ts
@@ -0,0 +1,375 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { CommonStatusEnum, DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { z } from '#/adapter/form';
+import { getRangePickerDefaultProps } from '#/utils';
+
+/** 琛ㄦ儏鍖呰〃鍗� */
+export function usePackFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'icon',
+ label: '灏侀潰',
+ component: 'ImageUpload',
+ componentProps: {
+ maxNumber: 1,
+ },
+ },
+ {
+ fieldName: 'name',
+ label: '鍚嶇О',
+ component: 'Input',
+ componentProps: {
+ maxlength: 64,
+ placeholder: '璇疯緭鍏ヨ〃鎯呭寘鍚嶇О',
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'sort',
+ label: '鎺掑簭',
+ component: 'InputNumber',
+ componentProps: {
+ min: 0,
+ max: 9999,
+ },
+ rules: z.number().default(0),
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'RadioGroup',
+ componentProps: {
+ options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+ buttonStyle: 'solid',
+ optionType: 'button',
+ },
+ rules: z.number().default(CommonStatusEnum.ENABLE),
+ },
+ ];
+}
+
+/** 琛ㄦ儏鍖呮悳绱㈣〃鍗� */
+export function usePackGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'name',
+ label: '琛ㄦ儏鍖�',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ヨ〃鎯呭寘鍚嶇О',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨鐘舵��',
+ },
+ },
+ {
+ fieldName: 'createTime',
+ label: '鍒涘缓鏃堕棿',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 琛ㄦ儏鍖呭瓧娈� */
+export function usePackGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ { type: 'checkbox', width: 40 },
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ field: 'icon',
+ title: '灏侀潰',
+ width: 80,
+ slots: { default: 'icon' },
+ },
+ {
+ field: 'name',
+ title: '鍚嶇О',
+ minWidth: 140,
+ },
+ {
+ field: 'sort',
+ title: '鎺掑簭',
+ width: 80,
+ },
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.COMMON_STATUS },
+ },
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 240,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
+
+/** 琛ㄦ儏琛ㄥ崟 */
+export function useItemFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'packId',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'url',
+ label: '琛ㄦ儏鍥�',
+ component: 'ImageUpload',
+ componentProps: {
+ maxNumber: 1,
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'name',
+ label: '琛ㄦ儏鍚�',
+ component: 'Input',
+ componentProps: {
+ maxlength: 64,
+ placeholder: '璇疯緭鍏ヨ〃鎯呭悕',
+ },
+ },
+ {
+ fieldName: 'width',
+ label: '瀹藉害',
+ component: 'InputNumber',
+ componentProps: {
+ min: 1,
+ max: 2048,
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'height',
+ label: '楂樺害',
+ component: 'InputNumber',
+ componentProps: {
+ min: 1,
+ max: 2048,
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'sort',
+ label: '鎺掑簭',
+ component: 'InputNumber',
+ componentProps: {
+ min: 0,
+ max: 9999,
+ },
+ rules: z.number().default(0),
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'RadioGroup',
+ componentProps: {
+ options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+ buttonStyle: 'solid',
+ optionType: 'button',
+ },
+ rules: z.number().default(CommonStatusEnum.ENABLE),
+ },
+ ];
+}
+
+/** 琛ㄦ儏鎼滅储琛ㄥ崟 */
+export function useItemGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'name',
+ label: '琛ㄦ儏鍚�',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ヨ〃鎯呭悕',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨鐘舵��',
+ },
+ },
+ ];
+}
+
+/** 琛ㄦ儏瀛楁 */
+export function useItemGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ { type: 'checkbox', width: 40 },
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 80,
+ },
+ {
+ field: 'url',
+ title: '琛ㄦ儏鍥�',
+ width: 80,
+ slots: { default: 'image' },
+ },
+ {
+ field: 'name',
+ title: '琛ㄦ儏鍚�',
+ minWidth: 140,
+ },
+ {
+ title: '灏哄',
+ width: 120,
+ slots: { default: 'size' },
+ },
+ {
+ field: 'sort',
+ title: '鎺掑簭',
+ width: 80,
+ },
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.COMMON_STATUS },
+ },
+ },
+ {
+ title: '鎿嶄綔',
+ width: 160,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
+
+/** 鐢ㄦ埛琛ㄦ儏鎼滅储琛ㄥ崟 */
+export function useUserItemGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'userId',
+ label: '鐢ㄦ埛缂栧彿',
+ component: 'InputNumber',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ョ敤鎴风紪鍙�',
+ },
+ },
+ {
+ fieldName: 'name',
+ label: '琛ㄦ儏鍚�',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ヨ〃鎯呭悕',
+ },
+ },
+ {
+ fieldName: 'createTime',
+ label: '娣诲姞鏃堕棿',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 鐢ㄦ埛琛ㄦ儏瀛楁 */
+export function useUserItemGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ field: 'url',
+ title: '琛ㄦ儏鍥�',
+ width: 80,
+ slots: { default: 'image' },
+ },
+ {
+ field: 'name',
+ title: '琛ㄦ儏鍚�',
+ minWidth: 140,
+ },
+ {
+ title: '鎵�灞炵敤鎴�',
+ minWidth: 180,
+ slots: { default: 'user' },
+ },
+ {
+ title: '灏哄',
+ width: 120,
+ slots: { default: 'size' },
+ },
+ {
+ field: 'createTime',
+ title: '娣诲姞鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 100,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
diff --git a/src/views/im/manager/face/pack/index.vue b/src/views/im/manager/face/pack/index.vue
new file mode 100644
index 0000000..835b62d
--- /dev/null
+++ b/src/views/im/manager/face/pack/index.vue
@@ -0,0 +1,198 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerFacePackApi } from '#/api/im/manager/face/pack';
+
+import { ref } from 'vue';
+
+import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
+import { isEmpty } from '#/packages/utils/src';
+
+import { Image, message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ deleteManagerFacePack,
+ deleteManagerFacePackList,
+ getManagerFacePackPage,
+} from '#/api/im/manager/face/pack';
+import { $t } from '#/locales';
+
+import {
+ usePackGridColumns,
+ usePackGridFormSchema,
+} from './data';
+import Form from './modules/form.vue';
+import ItemDrawer from './modules/item-drawer.vue';
+
+defineOptions({ name: 'ImManagerFacePack' });
+
+const itemDrawerRef = ref<InstanceType<typeof ItemDrawer>>();
+const checkedIds = ref<number[]>([]);
+
+const [FormModal, formModalApi] = useVbenModal({
+ connectedComponent: Form,
+ destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鍒涘缓琛ㄦ儏鍖� */
+function handleCreate() {
+ formModalApi.setData(null).open();
+}
+
+/** 缂栬緫琛ㄦ儏鍖� */
+function handleEdit(row: ImManagerFacePackApi.FacePack) {
+ formModalApi.setData(row).open();
+}
+
+/** 鎵撳紑琛ㄦ儏绠$悊 */
+function handleItems(row: ImManagerFacePackApi.FacePack) {
+ itemDrawerRef.value?.open(row);
+}
+
+/** 鍒犻櫎琛ㄦ儏鍖� */
+async function handleDelete(row: ImManagerFacePackApi.FacePack) {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deleting', [row.name]),
+ duration: 0,
+ });
+ try {
+ await deleteManagerFacePack(row.id);
+ message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+/** 鎵归噺鍒犻櫎琛ㄦ儏鍖� */
+async function handleDeleteBatch() {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deletingBatch'),
+ duration: 0,
+ });
+ try {
+ await deleteManagerFacePackList(checkedIds.value);
+ checkedIds.value = [];
+ message.success($t('ui.actionMessage.deleteSuccess'));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+/** 琛ㄦ牸閫変腑鍙樺寲 */
+function handleRowCheckboxChange({
+ records,
+}: {
+ records: ImManagerFacePackApi.FacePack[];
+}) {
+ checkedIds.value = records.map((item) => item.id);
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: usePackGridFormSchema(),
+ },
+ gridOptions: {
+ columns: usePackGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerFacePackPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerFacePackApi.FacePack>,
+ gridEvents: {
+ checkboxAll: handleRowCheckboxChange,
+ checkboxChange: handleRowCheckboxChange,
+ },
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <FormModal @success="handleRefresh" />
+ <ItemDrawer ref="itemDrawerRef" />
+ <Grid table-title="琛ㄦ儏鍖呭垪琛�">
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: $t('ui.actionTitle.create', ['琛ㄦ儏鍖�']),
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['im:manager:face-pack:create'],
+ onClick: handleCreate,
+ },
+ {
+ label: $t('ui.actionTitle.deleteBatch'),
+ type: 'primary',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:face-pack:delete'],
+ disabled: isEmpty(checkedIds),
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteBatchConfirm'),
+ confirm: handleDeleteBatch,
+ },
+ },
+ ]"
+ />
+ </template>
+ <template #icon="{ row }">
+ <Image v-if="row.icon" :height="32" :src="row.icon" :width="32" />
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '绠$悊琛ㄦ儏',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ auth: ['im:manager:face-pack-item:query'],
+ onClick: handleItems.bind(null, row),
+ },
+ {
+ label: $t('common.edit'),
+ type: 'link',
+ icon: ACTION_ICON.EDIT,
+ auth: ['im:manager:face-pack:update'],
+ onClick: handleEdit.bind(null, row),
+ },
+ {
+ label: $t('common.delete'),
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:face-pack:delete'],
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteConfirm', [row.name]),
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/face/pack/modules/form.vue b/src/views/im/manager/face/pack/modules/form.vue
new file mode 100644
index 0000000..b02c878
--- /dev/null
+++ b/src/views/im/manager/face/pack/modules/form.vue
@@ -0,0 +1,83 @@
+<script lang="ts" setup>
+import type { ImManagerFacePackApi } from '#/api/im/manager/face/pack';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+ createManagerFacePack,
+ getManagerFacePack,
+ updateManagerFacePack,
+} from '#/api/im/manager/face/pack';
+import { $t } from '#/locales';
+
+import { usePackFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<ImManagerFacePackApi.FacePack>();
+const getTitle = computed(() => {
+ return formData.value?.id ? '淇敼琛ㄦ儏鍖�' : '鏂板琛ㄦ儏鍖�';
+});
+
+const [Form, formApi] = useVbenForm({
+ commonConfig: {
+ componentProps: {
+ class: 'w-full',
+ },
+ formItemClass: 'col-span-2',
+ labelWidth: 80,
+ },
+ layout: 'horizontal',
+ schema: usePackFormSchema(),
+ showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ const { valid } = await formApi.validate();
+ if (!valid) {
+ return;
+ }
+ modalApi.lock();
+ const data = (await formApi.getValues()) as ImManagerFacePackApi.FacePack;
+ try {
+ await (formData.value?.id
+ ? updateManagerFacePack(data)
+ : createManagerFacePack(data));
+ await modalApi.close();
+ emit('success');
+ message.success($t('ui.actionMessage.operationSuccess'));
+ } finally {
+ modalApi.unlock();
+ }
+ },
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ formData.value = undefined;
+ await formApi.resetForm();
+ return;
+ }
+ const data = modalApi.getData<ImManagerFacePackApi.FacePack>();
+ if (!data || !data.id) {
+ return;
+ }
+ modalApi.lock();
+ try {
+ formData.value = await getManagerFacePack(data.id);
+ await formApi.setValues(formData.value);
+ } finally {
+ modalApi.unlock();
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal :title="getTitle" class="w-1/3">
+ <Form class="mx-4" />
+ </Modal>
+</template>
diff --git a/src/views/im/manager/face/pack/modules/item-drawer.vue b/src/views/im/manager/face/pack/modules/item-drawer.vue
new file mode 100644
index 0000000..7d83847
--- /dev/null
+++ b/src/views/im/manager/face/pack/modules/item-drawer.vue
@@ -0,0 +1,217 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerFacePackItemApi } from '#/api/im/manager/face/item';
+import type { ImManagerFacePackApi } from '#/api/im/manager/face/pack';
+
+import { computed, nextTick, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+import { isEmpty } from '#/packages/utils/src';
+
+import { Drawer, Image, message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ deleteManagerFacePackItem,
+ deleteManagerFacePackItemList,
+ getManagerFacePackItemPage,
+} from '#/api/im/manager/face/item';
+import { $t } from '#/locales';
+
+import { useItemGridColumns, useItemGridFormSchema } from '../data';
+import ItemForm from './item-form.vue';
+
+const visible = ref(false);
+const currentPack = ref<ImManagerFacePackApi.FacePack>();
+const checkedIds = ref<number[]>([]);
+const title = computed(() =>
+ currentPack.value ? `銆�${currentPack.value.name}銆嶈〃鎯呯鐞哷 : '琛ㄦ儏绠$悊',
+);
+
+const [ItemFormModal, itemFormModalApi] = useVbenModal({
+ connectedComponent: ItemForm,
+ destroyOnClose: true,
+});
+
+/** 鎵撳紑鎶藉眽 */
+function open(pack: ImManagerFacePackApi.FacePack) {
+ currentPack.value = pack;
+ visible.value = true;
+ checkedIds.value = [];
+}
+
+defineExpose({ open });
+
+/** 鎶藉眽鎵撳紑鍚庢煡璇㈣〃鎯呭垪琛� */
+async function handleOpenChange(open: boolean) {
+ if (!open) {
+ return;
+ }
+ await nextTick();
+ gridApi.query();
+}
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鍒涘缓琛ㄦ儏 */
+function handleCreate() {
+ itemFormModalApi.setData({ packId: currentPack.value?.id }).open();
+}
+
+/** 缂栬緫琛ㄦ儏 */
+function handleEdit(row: ImManagerFacePackItemApi.FacePackItem) {
+ itemFormModalApi
+ .setData({ id: row.id, packId: currentPack.value?.id })
+ .open();
+}
+
+/** 鍒犻櫎琛ㄦ儏 */
+async function handleDelete(row: ImManagerFacePackItemApi.FacePackItem) {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deleting', [row.name || row.id]),
+ duration: 0,
+ });
+ try {
+ await deleteManagerFacePackItem(row.id);
+ message.success($t('ui.actionMessage.deleteSuccess', [row.name || row.id]));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+/** 鎵归噺鍒犻櫎琛ㄦ儏 */
+async function handleDeleteBatch() {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deletingBatch'),
+ duration: 0,
+ });
+ try {
+ await deleteManagerFacePackItemList(checkedIds.value);
+ checkedIds.value = [];
+ message.success($t('ui.actionMessage.deleteSuccess'));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+/** 琛ㄦ牸閫変腑鍙樺寲 */
+function handleRowCheckboxChange({
+ records,
+}: {
+ records: ImManagerFacePackItemApi.FacePackItem[];
+}) {
+ checkedIds.value = records.map((item) => item.id);
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useItemGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useItemGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerFacePackItemPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ packId: currentPack.value?.id,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerFacePackItemApi.FacePackItem>,
+ gridEvents: {
+ checkboxAll: handleRowCheckboxChange,
+ checkboxChange: handleRowCheckboxChange,
+ },
+});
+</script>
+
+<template>
+ <Drawer
+ v-model:open="visible"
+ :title="title"
+ destroy-on-close
+ width="65%"
+ @after-open-change="handleOpenChange"
+ >
+ <ItemFormModal @success="handleRefresh" />
+ <Grid table-title="琛ㄦ儏鍒楄〃">
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: $t('ui.actionTitle.create', ['琛ㄦ儏']),
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['im:manager:face-pack-item:create'],
+ onClick: handleCreate,
+ },
+ {
+ label: $t('ui.actionTitle.deleteBatch'),
+ type: 'primary',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:face-pack-item:delete'],
+ disabled: isEmpty(checkedIds),
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteBatchConfirm'),
+ confirm: handleDeleteBatch,
+ },
+ },
+ ]"
+ />
+ </template>
+ <template #image="{ row }">
+ <Image v-if="row.url" :height="40" :src="row.url" :width="40" />
+ </template>
+ <template #size="{ row }">
+ <span v-if="row.width || row.height">
+ {{ row.width || '?' }} 脳 {{ row.height || '?' }}
+ </span>
+ <span v-else>-</span>
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: $t('common.edit'),
+ type: 'link',
+ icon: ACTION_ICON.EDIT,
+ auth: ['im:manager:face-pack-item:update'],
+ onClick: handleEdit.bind(null, row),
+ },
+ {
+ label: $t('common.delete'),
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:face-pack-item:delete'],
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteConfirm', [row.name || row.id]),
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Drawer>
+</template>
diff --git a/src/views/im/manager/face/pack/modules/item-form.vue b/src/views/im/manager/face/pack/modules/item-form.vue
new file mode 100644
index 0000000..becd7b5
--- /dev/null
+++ b/src/views/im/manager/face/pack/modules/item-form.vue
@@ -0,0 +1,119 @@
+<script lang="ts" setup>
+import type { ImManagerFacePackItemApi } from '#/api/im/manager/face/item';
+
+import { computed, ref, watch } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+ createManagerFacePackItem,
+ getManagerFacePackItem,
+ updateManagerFacePackItem,
+} from '#/api/im/manager/face/item';
+import { $t } from '#/locales';
+import { probeImageSize } from '#/views/im/manager/utils/format';
+
+import { useItemFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<ImManagerFacePackItemApi.FacePackItem>();
+const packId = ref(0);
+const lastUrl = ref('');
+const getTitle = computed(() => {
+ return formData.value?.id ? '淇敼琛ㄦ儏' : '鏂板琛ㄦ儏';
+});
+
+const [Form, formApi] = useVbenForm({
+ commonConfig: {
+ componentProps: {
+ class: 'w-full',
+ },
+ formItemClass: 'col-span-2',
+ labelWidth: 80,
+ },
+ layout: 'horizontal',
+ schema: useItemFormSchema(),
+ showDefaultActions: false,
+});
+
+/** 鍥炲~鍥剧墖灏哄 */
+async function applyImageSize(url?: string) {
+ if (!url) {
+ lastUrl.value = '';
+ await formApi.setFieldValue('width', undefined);
+ await formApi.setFieldValue('height', undefined);
+ return;
+ }
+ if (url === lastUrl.value) {
+ return;
+ }
+ lastUrl.value = url;
+ try {
+ const size = await probeImageSize(url);
+ await formApi.setFieldValue('width', size.width);
+ await formApi.setFieldValue('height', size.height);
+ } catch {
+ message.warning('鍥剧墖灏哄鎺㈡祴澶辫触锛岃鎵嬪姩濉啓瀹介珮');
+ }
+}
+
+watch(
+ () => formApi.form.values?.url,
+ (url) => {
+ void applyImageSize(url as string | undefined);
+ },
+);
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ const { valid } = await formApi.validate();
+ if (!valid) {
+ return;
+ }
+ modalApi.lock();
+ const data = (await formApi.getValues()) as ImManagerFacePackItemApi.FacePackItem;
+ try {
+ data.packId = data.packId || packId.value;
+ await (formData.value?.id
+ ? updateManagerFacePackItem(data)
+ : createManagerFacePackItem(data));
+ await modalApi.close();
+ emit('success');
+ message.success($t('ui.actionMessage.operationSuccess'));
+ } finally {
+ modalApi.unlock();
+ }
+ },
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ formData.value = undefined;
+ lastUrl.value = '';
+ await formApi.resetForm();
+ return;
+ }
+ const data = modalApi.getData<{ id?: number; packId: number }>();
+ packId.value = data?.packId || 0;
+ await formApi.setValues({ packId: packId.value, sort: 0 });
+ if (!data?.id) {
+ return;
+ }
+ modalApi.lock();
+ try {
+ formData.value = await getManagerFacePackItem(data.id);
+ lastUrl.value = formData.value.url;
+ await formApi.setValues(formData.value);
+ } finally {
+ modalApi.unlock();
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal :title="getTitle" class="w-1/3">
+ <Form class="mx-4" />
+ </Modal>
+</template>
diff --git a/src/views/im/manager/face/userItem/index.vue b/src/views/im/manager/face/userItem/index.vue
new file mode 100644
index 0000000..f136657
--- /dev/null
+++ b/src/views/im/manager/face/userItem/index.vue
@@ -0,0 +1,106 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerFaceUserItemApi } from '#/api/im/manager/face/userItem';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { Image, message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ deleteManagerFaceUserItem,
+ getManagerFaceUserItemPage,
+} from '#/api/im/manager/face/userItem';
+import { $t } from '#/locales';
+import { formatUserLabel } from '#/views/im/manager/utils/format';
+
+import { useUserItemGridColumns, useUserItemGridFormSchema } from '../pack/data';
+
+defineOptions({ name: 'ImManagerFaceUserItem' });
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鍒犻櫎鐢ㄦ埛琛ㄦ儏 */
+async function handleDelete(row: ImManagerFaceUserItemApi.FaceUserItem) {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deleting', [row.name || row.id]),
+ duration: 0,
+ });
+ try {
+ await deleteManagerFaceUserItem(row.id);
+ message.success($t('ui.actionMessage.deleteSuccess', [row.name || row.id]));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useUserItemGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useUserItemGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerFaceUserItemPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerFaceUserItemApi.FaceUserItem>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <Grid table-title="鐢ㄦ埛琛ㄦ儏鍒楄〃">
+ <template #image="{ row }">
+ <Image v-if="row.url" :height="40" :src="row.url" :width="40" />
+ </template>
+ <template #user="{ row }">
+ {{ formatUserLabel(row.userNickname, row.userId) }}
+ </template>
+ <template #size="{ row }">
+ <span v-if="row.width || row.height">
+ {{ row.width || '?' }} 脳 {{ row.height || '?' }}
+ </span>
+ <span v-else>-</span>
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: $t('common.delete'),
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:face-user-item:delete'],
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteConfirm', [row.name || row.id]),
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/friend/data.ts b/src/views/im/manager/friend/data.ts
new file mode 100644
index 0000000..a4e94d5
--- /dev/null
+++ b/src/views/im/manager/friend/data.ts
@@ -0,0 +1,264 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { markRaw } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { getRangePickerDefaultProps } from '#/utils';
+import { UserSelect } from '#/views/system/user/components';
+
+/** 濂藉弸鍏崇郴鎼滅储琛ㄥ崟 */
+export function useFriendGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'userId',
+ label: '鐢ㄦ埛',
+ component: markRaw(UserSelect),
+ },
+ {
+ fieldName: 'friendUserId',
+ label: '濂藉弸',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨濂藉弸',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '濂藉弸鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_FRIEND_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨濂藉弸鐘舵��',
+ },
+ },
+ {
+ fieldName: 'silent',
+ label: '鍏嶆墦鎵�',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
+ placeholder: '璇烽�夋嫨鍏嶆墦鎵扮姸鎬�',
+ },
+ },
+ {
+ fieldName: 'addTime',
+ label: '娣诲姞鏃堕棿',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 濂藉弸鍏崇郴鍒楄〃瀛楁 */
+export function useFriendGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ title: '鐢ㄦ埛',
+ minWidth: 180,
+ slots: { default: 'user' },
+ },
+ {
+ title: '濂藉弸',
+ minWidth: 180,
+ slots: { default: 'friend' },
+ },
+ {
+ field: 'displayName',
+ title: '澶囨敞',
+ minWidth: 120,
+ },
+ {
+ field: 'addSource',
+ title: '娣诲姞鏉ユ簮',
+ width: 120,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_FRIEND_ADD_SOURCE },
+ },
+ },
+ {
+ field: 'silent',
+ title: '鍏嶆墦鎵�',
+ width: 90,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
+ },
+ },
+ {
+ field: 'pinned',
+ title: '缃《',
+ width: 80,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
+ },
+ },
+ {
+ field: 'blocked',
+ title: '鎷夐粦',
+ width: 80,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
+ },
+ },
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_FRIEND_STATUS },
+ },
+ },
+ {
+ field: 'addTime',
+ title: '娣诲姞鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ field: 'deleteTime',
+ title: '鍒犻櫎鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 120,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
+
+/** 濂藉弸鐢宠鎼滅储琛ㄥ崟 */
+export function useFriendRequestGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'fromUserId',
+ label: '鍙戣捣鏂�',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨鍙戣捣鏂�',
+ },
+ },
+ {
+ fieldName: 'toUserId',
+ label: '鎺ユ敹鏂�',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨鎺ユ敹鏂�',
+ },
+ },
+ {
+ fieldName: 'handleResult',
+ label: '澶勭悊缁撴灉',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, 'number'),
+ placeholder: '璇烽�夋嫨澶勭悊缁撴灉',
+ },
+ },
+ {
+ fieldName: 'addSource',
+ label: '娣诲姞鏉ユ簮',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_FRIEND_ADD_SOURCE, 'number'),
+ placeholder: '璇烽�夋嫨娣诲姞鏉ユ簮',
+ },
+ },
+ {
+ fieldName: 'createTime',
+ label: '鍒涘缓鏃堕棿',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 濂藉弸鐢宠鍒楄〃瀛楁 */
+export function useFriendRequestGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ title: '鍙戣捣鏂�',
+ minWidth: 180,
+ slots: { default: 'fromUser' },
+ },
+ {
+ title: '鎺ユ敹鏂�',
+ minWidth: 180,
+ slots: { default: 'toUser' },
+ },
+ {
+ field: 'applyContent',
+ title: '鐢宠鐞嗙敱',
+ minWidth: 160,
+ },
+ {
+ field: 'displayName',
+ title: '澶囨敞',
+ minWidth: 120,
+ },
+ {
+ field: 'addSource',
+ title: '娣诲姞鏉ユ簮',
+ width: 120,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_FRIEND_ADD_SOURCE },
+ },
+ },
+ {
+ field: 'handleResult',
+ title: '澶勭悊缁撴灉',
+ width: 110,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT },
+ },
+ },
+ {
+ field: 'handleContent',
+ title: '澶勭悊鐞嗙敱',
+ minWidth: 140,
+ },
+ {
+ field: 'handleTime',
+ title: '澶勭悊鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ ];
+}
diff --git a/src/views/im/manager/friend/index.vue b/src/views/im/manager/friend/index.vue
new file mode 100644
index 0000000..e0a9ead
--- /dev/null
+++ b/src/views/im/manager/friend/index.vue
@@ -0,0 +1,84 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerFriendApi } from '#/api/im/manager/friend';
+
+import { useRouter } from 'vue-router';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getManagerFriendPage } from '#/api/im/manager/friend';
+import { formatUserLabel } from '#/views/im/manager/utils/format';
+
+import { useFriendGridColumns, useFriendGridFormSchema } from './data';
+
+defineOptions({ name: 'ImManagerFriend' });
+
+const router = useRouter();
+
+/** 鏌ョ湅绉佽亰娑堟伅 */
+function handleConversation(row: ImManagerFriendApi.Friend) {
+ router.push({
+ name: 'ImPrivateMessage',
+ query: {
+ senderId: row.userId,
+ receiverId: row.friendUserId,
+ },
+ });
+}
+
+const [Grid] = useVbenVxeGrid({
+ formOptions: {
+ schema: useFriendGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useFriendGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerFriendPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerFriendApi.Friend>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <Grid table-title="濂藉弸鍏崇郴鍒楄〃">
+ <template #user="{ row }">
+ {{ formatUserLabel(row.userNickname, row.userId) }}
+ </template>
+ <template #friend="{ row }">
+ {{ formatUserLabel(row.friendNickname, row.friendUserId) }}
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '鏌ョ湅瀵硅瘽',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ onClick: handleConversation.bind(null, row),
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/friend/request/index.vue b/src/views/im/manager/friend/request/index.vue
new file mode 100644
index 0000000..7829f74
--- /dev/null
+++ b/src/views/im/manager/friend/request/index.vue
@@ -0,0 +1,60 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerFriendRequestApi } from '#/api/im/manager/friend/request';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getManagerFriendRequestPage } from '#/api/im/manager/friend/request';
+import { formatUserLabel } from '#/views/im/manager/utils/format';
+
+import {
+ useFriendRequestGridColumns,
+ useFriendRequestGridFormSchema,
+} from '../data';
+
+defineOptions({ name: 'ImManagerFriendRequest' });
+
+const [Grid] = useVbenVxeGrid({
+ formOptions: {
+ schema: useFriendRequestGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useFriendRequestGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerFriendRequestPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerFriendRequestApi.FriendRequest>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <Grid table-title="濂藉弸鐢宠鍒楄〃">
+ <template #fromUser="{ row }">
+ {{ formatUserLabel(row.fromNickname, row.fromUserId) }}
+ </template>
+ <template #toUser="{ row }">
+ {{ formatUserLabel(row.toNickname, row.toUserId) }}
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/group/components/index.ts b/src/views/im/manager/group/components/index.ts
new file mode 100644
index 0000000..4bbf84f
--- /dev/null
+++ b/src/views/im/manager/group/components/index.ts
@@ -0,0 +1,2 @@
+export { default as GroupSelectDialog } from './select-dialog.vue';
+export { default as GroupSelect } from './select.vue';
diff --git a/src/views/im/manager/group/components/select-dialog.vue b/src/views/im/manager/group/components/select-dialog.vue
new file mode 100644
index 0000000..f1af583
--- /dev/null
+++ b/src/views/im/manager/group/components/select-dialog.vue
@@ -0,0 +1,271 @@
+<script lang="ts" setup>
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerGroupApi } from '#/api/im/manager/group';
+
+import { nextTick, ref } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { Button, message, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ getManagerGroup,
+ getManagerGroupPage,
+} from '#/api/im/manager/group';
+
+const emit = defineEmits<{
+ selected: [rows: ImManagerGroupApi.Group[]];
+}>();
+
+const open = ref(false); // 寮圭獥鏄惁鎵撳紑
+const multiple = ref(false); // 鏄惁澶氶��
+const selectedRows = ref<ImManagerGroupApi.Group[]>([]); // 宸查�夌兢鍒楄〃
+const preSelectedIds = ref<number[]>([]); // 棰勯�夌兢缂栧彿鍒楄〃
+
+/** 缇ら�夋嫨寮圭獥鐨勬悳绱㈣〃鍗� */
+function useGroupSelectGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'name',
+ label: '缇ゅ悕绉�',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ョ兢鍚嶇О',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '缇ょ姸鎬�',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_GROUP_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨缇ょ姸鎬�',
+ },
+ },
+ ];
+}
+
+/** 缇ら�夋嫨寮圭獥鐨勫瓧娈� */
+function useGroupSelectGridColumns(
+ isMultiple = false,
+): VxeTableGridOptions<ImManagerGroupApi.Group>['columns'] {
+ return [
+ {
+ type: isMultiple ? 'checkbox' : 'radio',
+ width: 50,
+ },
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ field: 'name',
+ title: '缇ゅ悕绉�',
+ minWidth: 160,
+ },
+ {
+ field: 'ownerNickname',
+ title: '缇や富',
+ minWidth: 160,
+ },
+ {
+ field: 'memberCount',
+ title: '鎴愬憳鏁�',
+ width: 90,
+ },
+ {
+ field: 'status',
+ title: '缇ょ姸鎬�',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_GROUP_STATUS },
+ },
+ },
+ ];
+}
+
+/** 鑾峰彇澶氶�夎褰曪紝鍖呭惈 VXE reserve 璺ㄩ〉璁板綍 */
+function getMultipleSelectedRows() {
+ const selectedMap = new Map<number, ImManagerGroupApi.Group>();
+ const records = [
+ ...(gridApi.grid.getCheckboxReserveRecords?.() ?? []),
+ ...(gridApi.grid.getCheckboxRecords?.() ?? []),
+ ] as ImManagerGroupApi.Group[];
+ records.forEach((row) => {
+ selectedMap.set(row.id, row);
+ });
+ return [...selectedMap.values()];
+}
+
+/** 澶勭悊澶氶�夊嬀閫夊彉鍖� */
+function handleCheckboxSelectChange() {
+ selectedRows.value = getMultipleSelectedRows();
+}
+
+/** 澶勭悊鍗曢�夊垏鎹� */
+function handleRadioChange(row: ImManagerGroupApi.Group) {
+ selectedRows.value = [row];
+}
+
+/** 澶氶�夋ā寮忎笅鍒囨崲琛屽嬀閫� */
+async function toggleMultipleRow(row: ImManagerGroupApi.Group) {
+ const selected = gridApi.grid.isCheckedByCheckboxRow(row);
+ await gridApi.grid.setCheckboxRow(row, !selected);
+ selectedRows.value = getMultipleSelectedRows();
+}
+
+/** 澶勭悊琛屽弻鍑伙細鍗曢�夌洿鎺ョ‘璁わ紝澶氶�夊垏鎹㈠嬀閫� */
+async function handleCellDblclick({ row }: { row: ImManagerGroupApi.Group }) {
+ if (multiple.value) {
+ await toggleMultipleRow(row);
+ return;
+ }
+ selectedRows.value = [row];
+ await gridApi.grid.setRadioRow(row);
+ handleConfirm();
+}
+
+/** 鍥炴樉棰勯�夌兢 */
+async function applyPreSelection() {
+ if (preSelectedIds.value.length === 0) {
+ return;
+ }
+ const rows = gridApi.grid.getData() as ImManagerGroupApi.Group[];
+ for (const row of rows) {
+ if (!preSelectedIds.value.includes(row.id)) {
+ continue;
+ }
+ if (multiple.value) {
+ await gridApi.grid.setCheckboxRow(row, true);
+ } else {
+ await gridApi.grid.setRadioRow(row);
+ selectedRows.value = [row];
+ return;
+ }
+ }
+ if (multiple.value) {
+ selectedRows.value = getMultipleSelectedRows();
+ return;
+ }
+ const targetId = preSelectedIds.value[0];
+ if (targetId) {
+ selectedRows.value = [await getManagerGroup(targetId)];
+ }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGroupSelectGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useGroupSelectGridColumns(false),
+ height: 520,
+ keepSource: true,
+ checkboxConfig: {
+ highlight: true,
+ range: true,
+ reserve: true,
+ },
+ radioConfig: {
+ highlight: true,
+ trigger: 'row',
+ },
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerGroupPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerGroupApi.Group>,
+ gridEvents: {
+ cellDblclick: handleCellDblclick,
+ checkboxAll: handleCheckboxSelectChange,
+ checkboxChange: handleCheckboxSelectChange,
+ radioChange: ({ row }: { row: ImManagerGroupApi.Group }) => {
+ handleRadioChange(row);
+ },
+ },
+});
+
+/** 閲嶇疆鏌ヨ鍜岄�夋嫨鐘舵�� */
+async function resetQueryState() {
+ selectedRows.value = [];
+ await gridApi.grid.clearCheckboxRow();
+ await gridApi.grid.clearCheckboxReserve();
+ await gridApi.grid.clearRadioRow();
+ await gridApi.formApi.resetForm();
+}
+
+/** 鎵撳紑缇ら�夋嫨寮圭獥 */
+async function openModal(
+ selectedIds?: number[],
+ options?: { multiple?: boolean },
+) {
+ open.value = true;
+ multiple.value = options?.multiple ?? false;
+ preSelectedIds.value = selectedIds || [];
+ await nextTick();
+ gridApi.setGridOptions({
+ columns: useGroupSelectGridColumns(multiple.value),
+ });
+ await resetQueryState();
+ await gridApi.query();
+ await nextTick();
+ await applyPreSelection();
+}
+
+/** 鍏抽棴缇ら�夋嫨寮圭獥 */
+function closeModal() {
+ open.value = false;
+}
+
+/** 纭閫夋嫨缇� */
+function handleConfirm() {
+ const rows = multiple.value ? getMultipleSelectedRows() : selectedRows.value;
+ if (rows.length === 0) {
+ message.warning(multiple.value ? '璇疯嚦灏戦�夋嫨涓�鏉℃暟鎹�' : '璇烽�夋嫨涓�鏉℃暟鎹�');
+ return;
+ }
+ emit('selected', multiple.value ? rows : [rows[0]!]);
+ open.value = false;
+}
+
+defineExpose({ open: openModal });
+</script>
+
+<template>
+ <Modal
+ v-model:open="open"
+ title="缇ら�夋嫨"
+ width="70%"
+ :destroy-on-close="true"
+ @ok="handleConfirm"
+ @cancel="closeModal"
+ >
+ <Grid table-title="缇ゅ垪琛�" />
+ <template #footer>
+ <Button @click="closeModal">鍙栨秷</Button>
+ <Button type="primary" @click="handleConfirm">纭畾</Button>
+ </template>
+ </Modal>
+</template>
diff --git a/src/views/im/manager/group/components/select.vue b/src/views/im/manager/group/components/select.vue
new file mode 100644
index 0000000..4a0d4eb
--- /dev/null
+++ b/src/views/im/manager/group/components/select.vue
@@ -0,0 +1,129 @@
+<script lang="ts" setup>
+import type { ImManagerGroupApi } from '#/api/im/manager/group';
+
+import { computed, ref, useAttrs, watch } from 'vue';
+
+import { IconifyIcon } from '#/packages/icons/src';
+
+import { Input, Tooltip } from 'ant-design-vue';
+
+import { getManagerGroup } from '#/api/im/manager/group';
+
+import GroupSelectDialog from './select-dialog.vue';
+
+defineOptions({ name: 'ImManagerGroupSelect', inheritAttrs: false });
+
+const props = withDefaults(
+ defineProps<{
+ allowClear?: boolean;
+ disabled?: boolean;
+ modelValue?: number;
+ placeholder?: string;
+ }>(),
+ {
+ allowClear: true,
+ disabled: false,
+ modelValue: undefined,
+ placeholder: '璇烽�夋嫨缇�',
+ },
+);
+
+const emit = defineEmits<{
+ change: [item: ImManagerGroupApi.Group | undefined];
+ 'update:modelValue': [value: number | undefined];
+}>();
+
+const attrs = useAttrs();
+const dialogRef = ref<InstanceType<typeof GroupSelectDialog>>(); // 缇ら�夋嫨寮圭獥
+const hovering = ref(false); // 榧犳爣鏄惁鎮仠
+const selectedItem = ref<ImManagerGroupApi.Group>(); // 宸查�夌兢
+
+/** 杈撳叆妗嗘樉绀烘枃鏈� */
+const displayLabel = computed(() => selectedItem.value?.name ?? '');
+
+/** 鏄惁鏄剧ず娓呴櫎鍥炬爣 */
+const showClear = computed(() => {
+ return props.allowClear && !props.disabled && hovering.value && props.modelValue != null;
+});
+
+/** 鏍规嵁缂栧彿鏌ヨ缇や俊鎭紙鐢ㄤ簬缂栬緫鍥炴樉锛� */
+async function resolveItemById(id: number | undefined) {
+ if (id == null) {
+ selectedItem.value = undefined;
+ return;
+ }
+ if (selectedItem.value?.id === id) {
+ return;
+ }
+ selectedItem.value = await getManagerGroup(id);
+}
+
+watch(() => props.modelValue, resolveItemById, { immediate: true });
+
+/** 娓呯┖宸查�夌兢 */
+function clearSelected() {
+ selectedItem.value = undefined;
+ emit('update:modelValue', undefined);
+ emit('change', undefined);
+}
+
+/** 鎵撳紑缇ら�夋嫨寮圭獥 */
+function handleClick(event: MouseEvent) {
+ if (props.disabled) {
+ return;
+ }
+ const target = event.target as HTMLElement;
+ if (showClear.value && target.closest('.ant-input-suffix')) {
+ event.stopPropagation();
+ clearSelected();
+ return;
+ }
+ dialogRef.value?.open(props.modelValue == null ? [] : [props.modelValue]);
+}
+
+/** 寮圭獥閫変腑鍥炶皟 */
+function handleSelected(rows: ImManagerGroupApi.Group[]) {
+ const item = rows[0];
+ if (!item) {
+ return;
+ }
+ selectedItem.value = item;
+ emit('update:modelValue', item.id);
+ emit('change', item);
+}
+</script>
+
+<template>
+ <div
+ v-bind="attrs"
+ class="w-full"
+ :class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
+ @click="handleClick"
+ @mouseenter="hovering = true"
+ @mouseleave="hovering = false"
+ >
+ <Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false">
+ <template #title>
+ <div v-if="selectedItem" class="leading-6">
+ <div>缇ゅ悕绉帮細{{ selectedItem.name || '-' }}</div>
+ <div>缇や富锛歿{ selectedItem.ownerNickname || '-' }}</div>
+ <div>鎴愬憳鏁帮細{{ selectedItem.memberCount ?? '-' }}</div>
+ </div>
+ </template>
+ <Input
+ :disabled="disabled"
+ :placeholder="placeholder"
+ :value="displayLabel"
+ readonly
+ >
+ <template #suffix>
+ <IconifyIcon
+ class="size-4"
+ :icon="showClear ? 'lucide:circle-x' : 'lucide:search'"
+ />
+ </template>
+ </Input>
+ </Tooltip>
+ </div>
+ <GroupSelectDialog ref="dialogRef" @selected="handleSelected" />
+</template>
diff --git a/src/views/im/manager/group/data.ts b/src/views/im/manager/group/data.ts
new file mode 100644
index 0000000..f3f58cb
--- /dev/null
+++ b/src/views/im/manager/group/data.ts
@@ -0,0 +1,255 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { markRaw } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { getRangePickerDefaultProps } from '#/utils';
+import { GroupSelect } from '#/views/im/manager/group/components';
+import { UserSelect } from '#/views/system/user/components';
+
+/** 缇ゆ悳绱㈣〃鍗� */
+export function useGroupGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'name',
+ label: '缇ゅ悕绉�',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ョ兢鍚嶇О',
+ },
+ },
+ {
+ fieldName: 'ownerUserId',
+ label: '缇や富',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨缇や富',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '缇ょ姸鎬�',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_GROUP_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨缇ょ姸鎬�',
+ },
+ },
+ {
+ fieldName: 'banned',
+ label: '鏄惁灏佺',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
+ placeholder: '璇烽�夋嫨灏佺鐘舵��',
+ },
+ },
+ {
+ fieldName: 'createTime',
+ label: '鍒涘缓鏃堕棿',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 缇ゅ垪琛ㄥ瓧娈� */
+export function useGroupGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ field: 'avatar',
+ title: '澶村儚',
+ width: 80,
+ slots: { default: 'avatar' },
+ },
+ {
+ field: 'name',
+ title: '缇ゅ悕绉�',
+ minWidth: 160,
+ },
+ {
+ title: '缇や富',
+ minWidth: 180,
+ slots: { default: 'owner' },
+ },
+ {
+ field: 'memberCount',
+ title: '鎴愬憳鏁�',
+ width: 90,
+ },
+ {
+ field: 'status',
+ title: '缇ょ姸鎬�',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_GROUP_STATUS },
+ },
+ },
+ {
+ field: 'banned',
+ title: '灏佺鐘舵��',
+ width: 120,
+ slots: { default: 'banned' },
+ },
+ {
+ field: 'mutedAll',
+ title: '鍏ㄧ兢绂佽█',
+ width: 100,
+ slots: { default: 'mutedAll' },
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 320,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
+
+/** 鍔犵兢鐢宠鎼滅储琛ㄥ崟 */
+export function useGroupRequestGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'groupId',
+ label: '缇�',
+ component: markRaw(GroupSelect),
+ },
+ {
+ fieldName: 'userId',
+ label: '鐢宠浜�',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨鐢宠浜�',
+ },
+ },
+ {
+ fieldName: 'inviterUserId',
+ label: '閭�璇蜂汉',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨閭�璇蜂汉',
+ },
+ },
+ {
+ fieldName: 'handleResult',
+ label: '澶勭悊缁撴灉',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_GROUP_REQUEST_HANDLE_RESULT, 'number'),
+ placeholder: '璇烽�夋嫨澶勭悊缁撴灉',
+ },
+ },
+ {
+ fieldName: 'addSource',
+ label: '鍔犲叆鏉ユ簮',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_GROUP_ADD_SOURCE, 'number'),
+ placeholder: '璇烽�夋嫨鍔犲叆鏉ユ簮',
+ },
+ },
+ {
+ fieldName: 'createTime',
+ label: '鍒涘缓鏃堕棿',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 鍔犵兢鐢宠鍒楄〃瀛楁 */
+export function useGroupRequestGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ title: '缇�',
+ minWidth: 180,
+ slots: { default: 'group' },
+ },
+ {
+ title: '鐢宠浜� / 琚個璇蜂汉',
+ minWidth: 200,
+ slots: { default: 'user' },
+ },
+ {
+ title: '閭�璇蜂汉',
+ minWidth: 180,
+ slots: { default: 'inviter' },
+ },
+ {
+ field: 'applyContent',
+ title: '鐢宠鐞嗙敱',
+ minWidth: 160,
+ },
+ {
+ field: 'addSource',
+ title: '鍔犲叆鏉ユ簮',
+ width: 120,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_GROUP_ADD_SOURCE },
+ },
+ },
+ {
+ field: 'handleResult',
+ title: '澶勭悊缁撴灉',
+ width: 110,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_GROUP_REQUEST_HANDLE_RESULT },
+ },
+ },
+ {
+ title: '澶勭悊浜�',
+ minWidth: 180,
+ slots: { default: 'handler' },
+ },
+ {
+ field: 'handleContent',
+ title: '澶勭悊鐞嗙敱',
+ minWidth: 140,
+ },
+ {
+ field: 'handleTime',
+ title: '澶勭悊鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ ];
+}
diff --git a/src/views/im/manager/group/index.vue b/src/views/im/manager/group/index.vue
new file mode 100644
index 0000000..b234d21
--- /dev/null
+++ b/src/views/im/manager/group/index.vue
@@ -0,0 +1,167 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerGroupApi } from '#/api/im/manager/group';
+
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { confirm, Page, useVbenModal } from '#/packages/effects/common-ui/src';
+import { CommonStatusEnum, DICT_TYPE } from '#/packages/constants/src';
+
+import { Avatar, message, Tag, Tooltip } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ dissolveManagerGroup,
+ getManagerGroupPage,
+ unbanManagerGroup,
+} from '#/api/im/manager/group';
+import { DictTag } from '#/components/dict-tag';
+import { formatUserLabel } from '#/views/im/manager/utils/format';
+
+import { useGroupGridColumns, useGroupGridFormSchema } from './data';
+import BanForm from './modules/ban-form.vue';
+import Detail from './modules/detail.vue';
+
+defineOptions({ name: 'ImManagerGroup' });
+
+const router = useRouter();
+const detailRef = ref<InstanceType<typeof Detail>>();
+
+const [BanModal, banModalApi] = useVbenModal({
+ connectedComponent: BanForm,
+ destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鎵撳紑璇︽儏 */
+function handleDetail(row: ImManagerGroupApi.Group) {
+ detailRef.value?.open(row);
+}
+
+/** 鏌ョ湅缇よ亰娑堟伅 */
+function handleConversation(row: ImManagerGroupApi.Group) {
+ router.push({
+ name: 'ImGroupMessage',
+ query: { groupId: row.id },
+ });
+}
+
+/** 鎵撳紑灏佺寮圭獥 */
+function handleBan(row: ImManagerGroupApi.Group) {
+ banModalApi.setData(row).open();
+}
+
+/** 瑙e皝缇� */
+async function handleUnban(row: ImManagerGroupApi.Group) {
+ await confirm(`纭瑙e皝缇ゃ��${row.name}銆嶅悧锛焋);
+ await unbanManagerGroup(row.id);
+ message.success('瑙e皝鎴愬姛');
+ handleRefresh();
+}
+
+/** 瑙f暎缇� */
+async function handleDissolve(row: ImManagerGroupApi.Group) {
+ await confirm(`纭瑙f暎缇ゃ��${row.name}銆嶅悧锛焋);
+ await dissolveManagerGroup(row.id);
+ message.success('瑙f暎鎴愬姛');
+ handleRefresh();
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGroupGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useGroupGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerGroupPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerGroupApi.Group>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <BanModal @success="handleRefresh" />
+ <Detail ref="detailRef" />
+ <Grid table-title="缇ゅ垪琛�">
+ <template #avatar="{ row }">
+ <Avatar :src="row.avatar" :size="40">
+ {{ row.name?.charAt(0) || '?' }}
+ </Avatar>
+ </template>
+ <template #owner="{ row }">
+ {{ formatUserLabel(row.ownerNickname, row.ownerUserId) }}
+ </template>
+ <template #banned="{ row }">
+ <Tooltip v-if="row.banned" :title="row.bannedReason">
+ <DictTag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.banned" />
+ </Tooltip>
+ <DictTag v-else :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.banned" />
+ </template>
+ <template #mutedAll="{ row }">
+ <Tag v-if="row.mutedAll" color="error">宸茬瑷�</Tag>
+ <Tag v-else>鏈瑷�</Tag>
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '璇︽儏',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ auth: ['im:manager:group:query'],
+ onClick: handleDetail.bind(null, row),
+ },
+ {
+ label: '鏌ョ湅瀵硅瘽',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ onClick: handleConversation.bind(null, row),
+ },
+ {
+ label: row.banned ? '瑙e皝' : '灏佺',
+ type: 'link',
+ danger: !row.banned,
+ icon: ACTION_ICON.CLOSE,
+ auth: ['im:manager:group:ban'],
+ onClick: row.banned ? handleUnban.bind(null, row) : handleBan.bind(null, row),
+ },
+ {
+ label: '瑙f暎',
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:group:dissolve'],
+ ifShow: row.status === CommonStatusEnum.ENABLE,
+ onClick: handleDissolve.bind(null, row),
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/group/modules/ban-form.vue b/src/views/im/manager/group/modules/ban-form.vue
new file mode 100644
index 0000000..e610c4f
--- /dev/null
+++ b/src/views/im/manager/group/modules/ban-form.vue
@@ -0,0 +1,65 @@
+<script lang="ts" setup>
+import type { ImManagerGroupApi } from '#/api/im/manager/group';
+
+import { reactive } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { Form, FormItem, Input, message } from 'ant-design-vue';
+
+import { banManagerGroup } from '#/api/im/manager/group';
+
+const emit = defineEmits(['success']);
+
+const formData = reactive({
+ id: 0,
+ groupName: '',
+ reason: '',
+});
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ if (!formData.reason.trim()) {
+ message.warning('璇疯緭鍏ュ皝绂佸師鍥�');
+ return;
+ }
+ modalApi.lock();
+ try {
+ await banManagerGroup({ id: formData.id, reason: formData.reason });
+ await modalApi.close();
+ emit('success');
+ message.success('灏佺鎴愬姛');
+ } finally {
+ modalApi.unlock();
+ }
+ },
+ onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ return;
+ }
+ const data = modalApi.getData<ImManagerGroupApi.Group>();
+ formData.id = data.id;
+ formData.groupName = data.name;
+ formData.reason = '';
+ },
+});
+</script>
+
+<template>
+ <Modal class="w-1/3" title="灏佺缇�">
+ <Form :label-col="{ style: { width: '80px' } }">
+ <FormItem label="缇ゅ悕绉�">
+ {{ formData.groupName }}
+ </FormItem>
+ <FormItem label="灏佺鍘熷洜" required>
+ <Input.TextArea
+ v-model:value="formData.reason"
+ :maxlength="200"
+ :rows="3"
+ placeholder="璇疯緭鍏ュ皝绂佸師鍥�"
+ show-count
+ />
+ </FormItem>
+ </Form>
+ </Modal>
+</template>
diff --git a/src/views/im/manager/group/modules/detail.vue b/src/views/im/manager/group/modules/detail.vue
new file mode 100644
index 0000000..d182b3f
--- /dev/null
+++ b/src/views/im/manager/group/modules/detail.vue
@@ -0,0 +1,151 @@
+<script lang="ts" setup>
+import type { ImManagerGroupApi } from '#/api/im/manager/group';
+
+import { computed, ref } from 'vue';
+
+import { CommonStatusEnum, DICT_TYPE } from '#/packages/constants/src';
+
+import {
+ Avatar,
+ Checkbox,
+ Descriptions,
+ DescriptionsItem,
+ Drawer,
+ Table,
+ Tag,
+} from 'ant-design-vue';
+
+import { getManagerGroupMemberList } from '#/api/im/manager/group';
+import { DictTag } from '#/components/dict-tag';
+import {
+ formatDateTimeText,
+ formatUserLabel,
+} from '#/views/im/manager/utils/format';
+
+const visible = ref(false);
+const detail = ref<ImManagerGroupApi.Group>({} as ImManagerGroupApi.Group);
+const loading = ref(false);
+const activeOnly = ref(true);
+const memberList = ref<ImManagerGroupApi.GroupMember[]>([]);
+
+const filteredMembers = computed(() =>
+ activeOnly.value
+ ? memberList.value.filter((item) => item.status === CommonStatusEnum.ENABLE)
+ : memberList.value,
+);
+
+const columns = [
+ { title: '澶村儚', dataIndex: 'avatar', width: 80 },
+ { title: '鐢ㄦ埛缂栧彿', dataIndex: 'userId', width: 100 },
+ { title: '瑙掕壊', dataIndex: 'role', width: 100 },
+ { title: '鏄电О', dataIndex: 'nickname' },
+ { title: '缁勫唴鏄剧ず鍚�', dataIndex: 'displayUserName' },
+ { title: '缇ゅ娉�', dataIndex: 'groupRemark' },
+ { title: '鍏嶆墦鎵�', dataIndex: 'silent', width: 90 },
+ { title: '鐘舵��', dataIndex: 'status', width: 100 },
+ { title: '鍏ョ兢鏃堕棿', dataIndex: 'joinTime', width: 180 },
+ { title: '閫�缇ゆ椂闂�', dataIndex: 'quitTime', width: 180 },
+ { title: '绂佽█鐘舵��', dataIndex: 'muteEndTime', width: 180 },
+];
+
+/** 鎵撳紑璇︽儏 */
+async function open(row: ImManagerGroupApi.Group) {
+ detail.value = row;
+ visible.value = true;
+ activeOnly.value = true;
+ loading.value = true;
+ try {
+ memberList.value = await getManagerGroupMemberList(row.id);
+ } finally {
+ loading.value = false;
+ }
+}
+
+defineExpose({ open });
+</script>
+
+<template>
+ <Drawer v-model:open="visible" destroy-on-close title="缇よ鎯�" width="900">
+ <Descriptions bordered :column="2">
+ <DescriptionsItem label="缇ょ紪鍙�">{{ detail.id }}</DescriptionsItem>
+ <DescriptionsItem label="缇ゅ悕绉�">{{ detail.name }}</DescriptionsItem>
+ <DescriptionsItem label="澶村儚">
+ <Avatar :src="detail.avatar" :size="36">
+ {{ detail.name?.charAt(0) || '?' }}
+ </Avatar>
+ </DescriptionsItem>
+ <DescriptionsItem label="缇や富">
+ {{ formatUserLabel(detail.ownerNickname, detail.ownerUserId) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="鎴愬憳鏁�">{{ detail.memberCount || 0 }}</DescriptionsItem>
+ <DescriptionsItem label="缇ょ姸鎬�">
+ <DictTag :type="DICT_TYPE.IM_GROUP_STATUS" :value="detail.status" />
+ </DescriptionsItem>
+ <DescriptionsItem label="灏佺鐘舵��">
+ <DictTag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="detail.banned" />
+ <span v-if="detail.banned" class="ml-2 text-gray-400">
+ {{ detail.bannedReason }}
+ </span>
+ </DescriptionsItem>
+ <DescriptionsItem label="鍏ㄧ兢绂佽█">
+ <Tag v-if="detail.mutedAll" color="error">宸茬瑷�</Tag>
+ <Tag v-else>鏈瑷�</Tag>
+ </DescriptionsItem>
+ <DescriptionsItem label="缇ゅ叕鍛�" :span="2">
+ {{ detail.notice || '-' }}
+ </DescriptionsItem>
+ <DescriptionsItem label="鍒涘缓鏃堕棿" :span="2">
+ {{ formatDateTimeText(detail.createTime) }}
+ </DescriptionsItem>
+ </Descriptions>
+
+ <div class="mb-4 mt-5 flex items-center justify-between">
+ <span class="font-bold">缇ゆ垚鍛�</span>
+ <Checkbox v-model:checked="activeOnly">浠呭睍绀哄綋鍓嶇兢鍐呯殑鎴愬憳</Checkbox>
+ </div>
+ <Table
+ :columns="columns"
+ :data-source="filteredMembers"
+ :loading="loading"
+ :pagination="false"
+ bordered
+ row-key="userId"
+ size="small"
+ >
+ <template #bodyCell="{ column, record, text }">
+ <template v-if="column.dataIndex === 'avatar'">
+ <Avatar :src="record.avatar" :size="40">
+ {{ record.nickname?.charAt(0) || '?' }}
+ </Avatar>
+ </template>
+ <template v-else-if="column.dataIndex === 'role'">
+ <DictTag :type="DICT_TYPE.IM_GROUP_MEMBER_ROLE" :value="record.role" />
+ </template>
+ <template v-else-if="column.dataIndex === 'silent'">
+ <DictTag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="record.silent" />
+ </template>
+ <template v-else-if="column.dataIndex === 'status'">
+ <DictTag :type="DICT_TYPE.COMMON_STATUS" :value="record.status" />
+ </template>
+ <template v-else-if="column.dataIndex === 'joinTime'">
+ {{ formatDateTimeText(record.joinTime) }}
+ </template>
+ <template v-else-if="column.dataIndex === 'quitTime'">
+ {{ formatDateTimeText(record.quitTime) }}
+ </template>
+ <template v-else-if="column.dataIndex === 'muteEndTime'">
+ <template v-if="record.muteEndTime && new Date(record.muteEndTime) > new Date()">
+ <Tag color="error">绂佽█涓�</Tag>
+ <div class="mt-1 text-xs text-gray-400">
+ {{ formatDateTimeText(record.muteEndTime) }}
+ </div>
+ </template>
+ <span v-else>-</span>
+ </template>
+ <template v-else>
+ {{ text || '-' }}
+ </template>
+ </template>
+ </Table>
+ </Drawer>
+</template>
diff --git a/src/views/im/manager/group/request/index.vue b/src/views/im/manager/group/request/index.vue
new file mode 100644
index 0000000..a97cf1a
--- /dev/null
+++ b/src/views/im/manager/group/request/index.vue
@@ -0,0 +1,75 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerGroupRequestApi } from '#/api/im/manager/group/request';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getManagerGroupRequestPage } from '#/api/im/manager/group/request';
+import {
+ formatGroupLabel,
+ formatUserLabel,
+} from '#/views/im/manager/utils/format';
+
+import {
+ useGroupRequestGridColumns,
+ useGroupRequestGridFormSchema,
+} from '../data';
+
+defineOptions({ name: 'ImManagerGroupRequest' });
+
+const [Grid] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGroupRequestGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useGroupRequestGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerGroupRequestPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerGroupRequestApi.GroupRequest>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <Grid table-title="鍔犵兢鐢宠鍒楄〃">
+ <template #group="{ row }">
+ {{ formatGroupLabel(row.groupName, row.groupId) }}
+ </template>
+ <template #user="{ row }">
+ {{ formatUserLabel(row.userNickname, row.userId) }}
+ </template>
+ <template #inviter="{ row }">
+ <template v-if="row.inviterUserId">
+ {{ formatUserLabel(row.inviterNickname, row.inviterUserId) }}
+ </template>
+ <span v-else>涓诲姩鐢宠</span>
+ </template>
+ <template #handler="{ row }">
+ <template v-if="row.handleUserId">
+ {{ formatUserLabel(row.handleNickname, row.handleUserId) }}
+ </template>
+ <span v-else>-</span>
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/message/content-preview.vue b/src/views/im/manager/message/content-preview.vue
new file mode 100644
index 0000000..c9b3fdb
--- /dev/null
+++ b/src/views/im/manager/message/content-preview.vue
@@ -0,0 +1,254 @@
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+import { getDictObj } from '#/packages/effects/hooks/src';
+import { IconifyIcon } from '#/packages/icons/src';
+import { formatFileSize, openSafeUrl } from '#/packages/utils/src';
+
+import { Image } from 'ant-design-vue';
+
+import { CardLineLabel } from '#/views/im/home/components/card';
+import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config';
+import {
+ ImContentType,
+ ImRtcCallEndReason,
+ ImRtcCallMediaType,
+ isFriendChatTip,
+ isGroupNotification,
+ isRtcCallTip,
+} from '#/views/im/utils/constants';
+import {
+ buildFacePreviewText,
+ summarizeMessageContent,
+} from '#/views/im/utils/conversation';
+import {
+ getFileIconInfo,
+ parseMessage,
+ parseRtcCallPayload,
+ resolveFriendNotificationText,
+ resolveGroupNotificationText,
+} from '#/views/im/utils/message';
+import { formatSeconds } from '#/views/im/utils/time';
+
+defineOptions({ name: 'ImManagerMessageContentPreview' });
+
+const props = defineProps<{
+ content?: string;
+ senderNickname?: string;
+ type?: number;
+}>();
+
+const payload = computed<null | Record<string, any>>(() =>
+ parseMessage<Record<string, any>>(props.content ?? ''),
+);
+
+const textContent = computed(() => payload.value?.content || '');
+
+const fileIconInfo = computed(() => getFileIconInfo(payload.value?.name));
+
+const cardPayload = computed(() => {
+ if (props.type !== ImContentType.CARD || !payload.value) {
+ return undefined;
+ }
+ return {
+ ...payload.value,
+ name: payload.value.name || payload.value.nickname || payload.value.userId || '',
+ };
+});
+
+const mergePreviewLines = computed(() => {
+ const messages = payload.value?.messages;
+ if (!Array.isArray(messages)) {
+ return [];
+ }
+ return messages
+ .slice(0, MESSAGE_MERGE_PREVIEW_LINES)
+ .map((item) => `${item.senderNickname}锛�${summarizeMessageContent(item)}`);
+});
+
+const fallbackText = computed(() => {
+ const raw = props.content || '';
+ if (!raw) {
+ return '';
+ }
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object' && parsed.content) {
+ return String(parsed.content);
+ }
+ } catch {}
+ return raw;
+});
+
+const friendTipText = computed(() =>
+ resolveFriendNotificationText({ type: props.type }),
+);
+
+const groupTipText = computed(() =>
+ resolveGroupNotificationText(
+ { type: props.type, content: props.content },
+ (id) => `鐢ㄦ埛(${id})`,
+ props.senderNickname,
+ ),
+);
+
+const rtcCallTipText = computed(() => {
+ const rtcPayload = parseRtcCallPayload(props.content);
+ if (!rtcPayload) {
+ return '';
+ }
+ const mediaLabel =
+ rtcPayload.mediaType === ImRtcCallMediaType.VIDEO ? '瑙嗛' : '璇煶';
+ if (props.type === ImContentType.RTC_CALL_START) {
+ const inviter =
+ rtcPayload.inviterNickname?.trim() ||
+ `鐢ㄦ埛(${rtcPayload.inviterUserId ?? ''})`;
+ return `${inviter} 鍙戣捣浜�${mediaLabel}閫氳瘽`;
+ }
+ const segments = [`${mediaLabel}閫氳瘽宸茬粨鏉焋];
+ if (
+ rtcPayload.endReason &&
+ rtcPayload.endReason !== ImRtcCallEndReason.HANGUP
+ ) {
+ const reason = getDictObj(
+ 'im_rtc_call_end_reason',
+ String(rtcPayload.endReason),
+ )?.label;
+ if (reason) {
+ segments.push(reason);
+ }
+ }
+ const duration = rtcPayload.durationSeconds ?? 0;
+ if (duration > 0) {
+ segments.push(`鏃堕暱 ${formatSeconds(duration)}`);
+ }
+ return segments.join('锛�');
+});
+
+/** 鎵撳紑瑙嗛 */
+function openVideo() {
+ openSafeUrl(payload.value?.url);
+}
+</script>
+
+<template>
+ <span v-if="type === ImContentType.TEXT" class="whitespace-pre-wrap break-all">
+ {{ textContent }}
+ </span>
+
+ <Image
+ v-else-if="type === ImContentType.IMAGE && payload"
+ :height="60"
+ :src="payload.thumbnailUrl || payload.url"
+ :width="60"
+ />
+
+ <span
+ v-else-if="type === ImContentType.FILE && payload"
+ class="inline-flex items-center gap-1.5"
+ >
+ <IconifyIcon
+ :color="fileIconInfo.color"
+ :icon="fileIconInfo.icon"
+ class="size-4"
+ />
+ <span class="max-w-52 truncate">{{ payload.name }}</span>
+ <span v-if="payload.size > 0" class="text-xs text-gray-400">
+ {{ formatFileSize(payload.size) }}
+ </span>
+ </span>
+
+ <span
+ v-else-if="type === ImContentType.VOICE && payload"
+ class="inline-flex items-center gap-1.5"
+ >
+ <IconifyIcon class="size-4" icon="ant-design:audio-outlined" />
+ <span>{{ formatSeconds(payload.duration || 0) }}</span>
+ </span>
+
+ <span
+ v-else-if="type === ImContentType.VIDEO && payload"
+ class="inline-flex items-center gap-1.5"
+ >
+ <button
+ v-if="payload.coverUrl"
+ class="relative size-[60px] overflow-hidden rounded border-0 p-0"
+ type="button"
+ @click="openVideo"
+ >
+ <img :src="payload.coverUrl" class="size-full object-cover" />
+ <IconifyIcon
+ class="absolute inset-0 m-auto size-6 text-white drop-shadow"
+ icon="ant-design:play-circle-filled"
+ />
+ </button>
+ <span v-else class="inline-flex items-center gap-1.5">
+ <IconifyIcon class="size-4" icon="ant-design:video-camera-filled" />
+ <span>[瑙嗛]</span>
+ </span>
+ <span v-if="payload.duration" class="text-xs text-gray-400">
+ {{ formatSeconds(payload.duration) }}
+ </span>
+ <span v-if="payload.size > 0" class="text-xs text-gray-400">
+ {{ formatFileSize(payload.size) }}
+ </span>
+ </span>
+
+ <CardLineLabel
+ v-else-if="type === ImContentType.CARD && cardPayload"
+ :card="cardPayload"
+ :icon-size="16"
+ />
+
+ <span
+ v-else-if="type === ImContentType.FACE && payload"
+ class="inline-flex items-center gap-1.5"
+ >
+ <img
+ v-if="payload.url"
+ :alt="payload.name || '琛ㄦ儏'"
+ :src="payload.url"
+ class="size-[30px] rounded object-contain"
+ draggable="false"
+ />
+ <span>{{ buildFacePreviewText(payload) }}</span>
+ </span>
+
+ <span
+ v-else-if="type === ImContentType.MERGE && payload"
+ class="inline-flex flex-col gap-0.5 align-middle"
+ >
+ <span>[鑱婂ぉ璁板綍] {{ payload.title }}</span>
+ <span
+ v-for="(line, index) in mergePreviewLines"
+ :key="index"
+ class="truncate text-xs text-gray-400"
+ >
+ {{ line }}
+ </span>
+ </span>
+
+ <span v-else-if="type === ImContentType.RECALL" class="text-xs text-gray-400">
+ [娑堟伅宸叉挙鍥瀅
+ </span>
+ <span v-else-if="type === ImContentType.READ" class="text-xs text-gray-400">
+ [宸茶鍥炴墽]
+ </span>
+ <span v-else-if="type === ImContentType.RECEIPT" class="text-xs text-gray-400">
+ [鍥炴墽]
+ </span>
+ <span v-else-if="isGroupNotification(type ?? -1)" class="text-xs text-gray-400">
+ {{ groupTipText }}
+ </span>
+ <span v-else-if="isFriendChatTip(type ?? -1)" class="text-xs text-gray-400">
+ {{ friendTipText }}
+ </span>
+ <span
+ v-else-if="isRtcCallTip(type ?? -1)"
+ class="inline-flex items-center gap-1.5 text-xs text-gray-400"
+ >
+ <IconifyIcon class="size-4 rotate-[135deg]" icon="ant-design:phone-outlined" />
+ <span>{{ rtcCallTipText }}</span>
+ </span>
+ <span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span>
+</template>
diff --git a/src/views/im/manager/message/data.ts b/src/views/im/manager/message/data.ts
new file mode 100644
index 0000000..af92764
--- /dev/null
+++ b/src/views/im/manager/message/data.ts
@@ -0,0 +1,254 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { markRaw } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { getRangePickerDefaultProps } from '#/utils';
+import { GroupSelect } from '#/views/im/manager/group/components';
+import { UserSelect } from '#/views/system/user/components';
+
+/** 绉佽亰娑堟伅鎼滅储琛ㄥ崟 */
+export function usePrivateGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'senderId',
+ label: '鍙戦�佷汉',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨鍙戦�佷汉',
+ },
+ },
+ {
+ fieldName: 'receiverId',
+ label: '鎺ユ敹浜�',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨鎺ユ敹浜�',
+ },
+ },
+ {
+ fieldName: 'type',
+ label: '鍐呭绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_CONTENT_TYPE, 'number'),
+ placeholder: '璇烽�夋嫨鍐呭绫诲瀷',
+ },
+ },
+ {
+ fieldName: 'content',
+ label: '娑堟伅鍐呭',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ユ秷鎭唴瀹�',
+ },
+ },
+ {
+ fieldName: 'sendTime',
+ label: '鍙戦�佹椂闂�',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 绉佽亰娑堟伅瀛楁 */
+export function usePrivateGridColumns(showReadColumns: boolean): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ title: '鍙戦�佷汉',
+ minWidth: 160,
+ slots: { default: 'sender' },
+ },
+ {
+ title: '鎺ユ敹浜�',
+ minWidth: 160,
+ slots: { default: 'receiver' },
+ },
+ {
+ field: 'type',
+ title: '绫诲瀷',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_CONTENT_TYPE },
+ },
+ },
+ {
+ title: '鍐呭棰勮',
+ minWidth: 260,
+ align: 'left',
+ slots: { default: 'content' },
+ },
+ ...(showReadColumns
+ ? [
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_MESSAGE_STATUS },
+ },
+ },
+ {
+ field: 'receiptStatus',
+ title: '鍥炴墽',
+ width: 110,
+ cellRender: {
+ name: 'CellDict',
+ // 鍥炴墽鐘舵�侊紙绉佽亰 / 缇よ亰鍏辩敤 im_message_receipt_status锛夛紝涓庢簮绔�屽洖鎵с�嶅垪瀵归綈
+ props: { type: DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS },
+ },
+ },
+ ]
+ : []),
+ {
+ field: 'sendTime',
+ title: '鍙戦�佹椂闂�',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 100,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
+
+/** 缇よ亰娑堟伅鎼滅储琛ㄥ崟 */
+export function useGroupGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'groupId',
+ label: '缇�',
+ component: markRaw(GroupSelect),
+ },
+ {
+ fieldName: 'senderId',
+ label: '鍙戦�佷汉',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨鍙戦�佷汉',
+ },
+ },
+ {
+ fieldName: 'type',
+ label: '鍐呭绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_CONTENT_TYPE, 'number'),
+ placeholder: '璇烽�夋嫨鍐呭绫诲瀷',
+ },
+ },
+ {
+ fieldName: 'content',
+ label: '娑堟伅鍐呭',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ユ秷鎭唴瀹�',
+ },
+ },
+ {
+ fieldName: 'sendTime',
+ label: '鍙戦�佹椂闂�',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 缇よ亰娑堟伅瀛楁 */
+export function useGroupGridColumns(showReadColumns: boolean): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ title: '缇�',
+ minWidth: 160,
+ slots: { default: 'group' },
+ },
+ {
+ title: '鍙戦�佷汉',
+ minWidth: 160,
+ slots: { default: 'sender' },
+ },
+ {
+ field: 'type',
+ title: '绫诲瀷',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_CONTENT_TYPE },
+ },
+ },
+ {
+ title: '@鐢ㄦ埛',
+ minWidth: 180,
+ slots: { default: 'atUsers' },
+ },
+ {
+ title: '鍐呭棰勮',
+ minWidth: 260,
+ align: 'left',
+ slots: { default: 'content' },
+ },
+ ...(showReadColumns
+ ? [
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_MESSAGE_STATUS },
+ },
+ },
+ {
+ field: 'receiptStatus',
+ title: '鍥炴墽',
+ width: 110,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS },
+ },
+ },
+ ]
+ : []),
+ {
+ field: 'sendTime',
+ title: '鍙戦�佹椂闂�',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 100,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
diff --git a/src/views/im/manager/message/group/index.vue b/src/views/im/manager/message/group/index.vue
new file mode 100644
index 0000000..2a97b5f
--- /dev/null
+++ b/src/views/im/manager/message/group/index.vue
@@ -0,0 +1,110 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerGroupMessageApi } from '#/api/im/manager/message/group';
+
+import { ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getManagerGroupMessagePage } from '#/api/im/manager/message/group';
+import {
+ formatGroupLabel,
+ formatUserLabel,
+} from '#/views/im/manager/utils/format';
+import { MESSAGE_GROUP_READ_ENABLED } from '#/views/im/utils/config';
+import {
+ IM_AT_ALL_NICKNAME,
+ IM_AT_ALL_USER_ID,
+} from '#/views/im/utils/constants';
+
+import { MessageContentPreview } from '..';
+import { useGroupGridColumns, useGroupGridFormSchema } from '../data';
+import Detail from './modules/detail.vue';
+
+defineOptions({ name: 'ImGroupMessage' });
+
+const route = useRoute();
+const detailRef = ref<InstanceType<typeof Detail>>();
+
+/** 鎵撳紑璇︽儏 */
+function handleDetail(row: ImManagerGroupMessageApi.GroupMessage) {
+ detailRef.value?.open(row);
+}
+
+const [Grid] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGroupGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useGroupGridColumns(MESSAGE_GROUP_READ_ENABLED),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerGroupMessagePage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ groupId: route.query.groupId ? Number(route.query.groupId) : undefined,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerGroupMessageApi.GroupMessage>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <Detail ref="detailRef" />
+ <Grid table-title="缇よ亰娑堟伅鍒楄〃">
+ <template #group="{ row }">
+ {{ formatGroupLabel(row.groupName, row.groupId) }}
+ </template>
+ <template #sender="{ row }">
+ {{ formatUserLabel(row.senderNickname, row.senderId) }}
+ </template>
+ <template #atUsers="{ row }">
+ <template v-if="row.atUserIds?.length">
+ <span v-for="(userId, index) in row.atUserIds" :key="userId">
+ <span v-if="Number(index) > 0">銆�</span>
+ <template v-if="userId === IM_AT_ALL_USER_ID">@{{ IM_AT_ALL_NICKNAME }}</template>
+ <template v-else>@{{ row.atUserNicknames?.[index] || userId }}</template>
+ </span>
+ </template>
+ <span v-else>-</span>
+ </template>
+ <template #content="{ row }">
+ <MessageContentPreview
+ :content="row.content"
+ :sender-nickname="row.senderNickname"
+ :type="row.type"
+ />
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '璇︽儏',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ auth: ['im:manager:message:query'],
+ onClick: handleDetail.bind(null, row),
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/message/group/modules/detail.vue b/src/views/im/manager/message/group/modules/detail.vue
new file mode 100644
index 0000000..dae8977
--- /dev/null
+++ b/src/views/im/manager/message/group/modules/detail.vue
@@ -0,0 +1,92 @@
+<script lang="ts" setup>
+import type { ImManagerGroupMessageApi } from '#/api/im/manager/message/group';
+
+import { ref } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+
+import { Descriptions, DescriptionsItem, Modal } from 'ant-design-vue';
+
+import { DictTag } from '#/components/dict-tag';
+import {
+ formatDateTimeText,
+ formatGroupLabel,
+ formatJsonText,
+ formatUserLabel,
+} from '#/views/im/manager/utils/format';
+import { MESSAGE_GROUP_READ_ENABLED } from '#/views/im/utils/config';
+import {
+ IM_AT_ALL_NICKNAME,
+ IM_AT_ALL_USER_ID,
+} from '#/views/im/utils/constants';
+
+import { MessageContentPreview } from '../..';
+
+const visible = ref(false);
+const detail = ref<ImManagerGroupMessageApi.GroupMessage>({} as ImManagerGroupMessageApi.GroupMessage);
+
+/** 鎵撳紑璇︽儏 */
+function open(row: ImManagerGroupMessageApi.GroupMessage) {
+ detail.value = row;
+ visible.value = true;
+}
+
+defineExpose({ open });
+</script>
+
+<template>
+ <Modal v-model:open="visible" :footer="null" title="缇よ亰娑堟伅璇︽儏" width="700px">
+ <Descriptions bordered :column="2">
+ <DescriptionsItem label="缂栧彿">{{ detail.id }}</DescriptionsItem>
+ <DescriptionsItem label="瀹㈡埛绔紪鍙�">
+ {{ detail.clientMessageId || '-' }}
+ </DescriptionsItem>
+ <DescriptionsItem label="缇�">
+ {{ formatGroupLabel(detail.groupName, detail.groupId) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="鍙戦�佷汉">
+ {{ formatUserLabel(detail.senderNickname, detail.senderId) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="绫诲瀷">
+ <DictTag :type="DICT_TYPE.IM_CONTENT_TYPE" :value="detail.type" />
+ </DescriptionsItem>
+ <DescriptionsItem label="鐘舵��">
+ <DictTag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
+ </DescriptionsItem>
+ <DescriptionsItem v-if="MESSAGE_GROUP_READ_ENABLED" label="鍥炴墽" :span="2">
+ <DictTag
+ :type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS"
+ :value="detail.receiptStatus"
+ />
+ </DescriptionsItem>
+ <DescriptionsItem label="@鐢ㄦ埛" :span="2">
+ <template v-if="detail.atUserIds?.length">
+ <span v-for="(userId, index) in detail.atUserIds" :key="userId">
+ <span v-if="Number(index) > 0">銆�</span>
+ <template v-if="userId === IM_AT_ALL_USER_ID">
+ @{{ IM_AT_ALL_NICKNAME }}
+ </template>
+ <template v-else>
+ @{{ detail.atUserNicknames?.[index] || userId }}
+ <span class="text-gray-400">({{ userId }})</span>
+ </template>
+ </span>
+ </template>
+ <span v-else>-</span>
+ </DescriptionsItem>
+ <DescriptionsItem label="鍙戦�佹椂闂�" :span="2">
+ {{ formatDateTimeText(detail.sendTime) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="娑堟伅鍐呭" :span="2">
+ <MessageContentPreview
+ :content="detail.content"
+ :sender-nickname="detail.senderNickname"
+ :type="detail.type"
+ />
+ </DescriptionsItem>
+ <DescriptionsItem label="鍘熷 JSON" :span="2">
+ <pre class="m-0 whitespace-pre-wrap break-all rounded bg-gray-100 p-2 font-mono text-xs">{{ formatJsonText(detail.content) }}</pre>
+ </DescriptionsItem>
+ </Descriptions>
+ </Modal>
+</template>
diff --git a/src/views/im/manager/message/index.ts b/src/views/im/manager/message/index.ts
new file mode 100644
index 0000000..63c961a
--- /dev/null
+++ b/src/views/im/manager/message/index.ts
@@ -0,0 +1 @@
+export { default as MessageContentPreview } from './content-preview.vue';
diff --git a/src/views/im/manager/message/private/index.vue b/src/views/im/manager/message/private/index.vue
new file mode 100644
index 0000000..26bbcef
--- /dev/null
+++ b/src/views/im/manager/message/private/index.vue
@@ -0,0 +1,101 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerPrivateMessageApi } from '#/api/im/manager/message/private';
+
+import { ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getManagerPrivateMessagePage } from '#/api/im/manager/message/private';
+import { formatUserLabel } from '#/views/im/manager/utils/format';
+import { MESSAGE_PRIVATE_READ_ENABLED } from '#/views/im/utils/config';
+
+import { MessageContentPreview } from '..';
+import {
+ usePrivateGridColumns,
+ usePrivateGridFormSchema,
+} from '../data';
+import Detail from './modules/detail.vue';
+
+defineOptions({ name: 'ImPrivateMessage' });
+
+const route = useRoute();
+const detailRef = ref<InstanceType<typeof Detail>>();
+
+/** 鎵撳紑璇︽儏 */
+function handleDetail(row: ImManagerPrivateMessageApi.PrivateMessage) {
+ detailRef.value?.open(row);
+}
+
+const [Grid] = useVbenVxeGrid({
+ formOptions: {
+ schema: usePrivateGridFormSchema(),
+ },
+ gridOptions: {
+ columns: usePrivateGridColumns(MESSAGE_PRIVATE_READ_ENABLED),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerPrivateMessagePage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ senderId: route.query.senderId
+ ? Number(route.query.senderId)
+ : undefined,
+ receiverId: route.query.receiverId
+ ? Number(route.query.receiverId)
+ : undefined,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerPrivateMessageApi.PrivateMessage>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <Detail ref="detailRef" />
+ <Grid table-title="绉佽亰娑堟伅鍒楄〃">
+ <template #sender="{ row }">
+ {{ formatUserLabel(row.senderNickname, row.senderId) }}
+ </template>
+ <template #receiver="{ row }">
+ {{ formatUserLabel(row.receiverNickname, row.receiverId) }}
+ </template>
+ <template #content="{ row }">
+ <MessageContentPreview
+ :content="row.content"
+ :sender-nickname="row.senderNickname"
+ :type="row.type"
+ />
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '璇︽儏',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ auth: ['im:manager:message:query'],
+ onClick: handleDetail.bind(null, row),
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/message/private/modules/detail.vue b/src/views/im/manager/message/private/modules/detail.vue
new file mode 100644
index 0000000..ec08242
--- /dev/null
+++ b/src/views/im/manager/message/private/modules/detail.vue
@@ -0,0 +1,75 @@
+<script lang="ts" setup>
+import type { ImManagerPrivateMessageApi } from '#/api/im/manager/message/private';
+
+import { ref } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+
+import { Descriptions, DescriptionsItem, Modal } from 'ant-design-vue';
+
+import { DictTag } from '#/components/dict-tag';
+import {
+ formatDateTimeText,
+ formatJsonText,
+ formatUserLabel,
+} from '#/views/im/manager/utils/format';
+import { MESSAGE_PRIVATE_READ_ENABLED } from '#/views/im/utils/config';
+
+import { MessageContentPreview } from '../..';
+
+const visible = ref(false);
+const detail = ref<ImManagerPrivateMessageApi.PrivateMessage>(
+ {} as ImManagerPrivateMessageApi.PrivateMessage,
+);
+
+/** 鎵撳紑璇︽儏 */
+function open(row: ImManagerPrivateMessageApi.PrivateMessage) {
+ detail.value = row;
+ visible.value = true;
+}
+
+defineExpose({ open });
+</script>
+
+<template>
+ <Modal v-model:open="visible" :footer="null" title="绉佽亰娑堟伅璇︽儏" width="700px">
+ <Descriptions bordered :column="2">
+ <DescriptionsItem label="缂栧彿">{{ detail.id }}</DescriptionsItem>
+ <DescriptionsItem label="瀹㈡埛绔紪鍙�">
+ {{ detail.clientMessageId || '-' }}
+ </DescriptionsItem>
+ <DescriptionsItem label="鍙戦�佷汉">
+ {{ formatUserLabel(detail.senderNickname, detail.senderId) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="鎺ユ敹浜�">
+ {{ formatUserLabel(detail.receiverNickname, detail.receiverId) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="绫诲瀷">
+ <DictTag :type="DICT_TYPE.IM_CONTENT_TYPE" :value="detail.type" />
+ </DescriptionsItem>
+ <DescriptionsItem label="鐘舵��">
+ <DictTag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
+ </DescriptionsItem>
+ <DescriptionsItem v-if="MESSAGE_PRIVATE_READ_ENABLED" label="鍥炴墽">
+ <!-- 鍥炴墽鐘舵�侊紙绉佽亰 / 缇よ亰鍏辩敤 im_message_receipt_status锛夛紝涓庢簮绔鑱婅鎯呫�屽洖鎵с�嶅榻� -->
+ <DictTag
+ :type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS"
+ :value="detail.receiptStatus"
+ />
+ </DescriptionsItem>
+ <DescriptionsItem label="鍙戦�佹椂闂�" :span="2">
+ {{ formatDateTimeText(detail.sendTime) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="娑堟伅鍐呭" :span="2">
+ <MessageContentPreview
+ :content="detail.content"
+ :sender-nickname="detail.senderNickname"
+ :type="detail.type"
+ />
+ </DescriptionsItem>
+ <DescriptionsItem label="鍘熷 JSON" :span="2">
+ <pre class="m-0 whitespace-pre-wrap break-all rounded bg-gray-100 p-2 font-mono text-xs">{{ formatJsonText(detail.content) }}</pre>
+ </DescriptionsItem>
+ </Descriptions>
+ </Modal>
+</template>
diff --git a/src/views/im/manager/rtc/data.ts b/src/views/im/manager/rtc/data.ts
new file mode 100644
index 0000000..ab9dc53
--- /dev/null
+++ b/src/views/im/manager/rtc/data.ts
@@ -0,0 +1,144 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { markRaw } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { getRangePickerDefaultProps } from '#/utils';
+import { UserSelect } from '#/views/system/user/components';
+
+/** 閫氳瘽璁板綍鎼滅储琛ㄥ崟 */
+export function useRtcGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'inviterUserId',
+ label: '鍙戣捣浜�',
+ component: markRaw(UserSelect),
+ componentProps: {
+ placeholder: '璇烽�夋嫨鍙戣捣浜�',
+ },
+ },
+ {
+ fieldName: 'conversationType',
+ label: '浼氳瘽绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE, 'number'),
+ placeholder: '璇烽�夋嫨浼氳瘽绫诲瀷',
+ },
+ },
+ {
+ fieldName: 'mediaType',
+ label: '濯掍綋绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, 'number'),
+ placeholder: '璇烽�夋嫨濯掍綋绫诲瀷',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '閫氳瘽鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_RTC_CALL_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨閫氳瘽鐘舵��',
+ },
+ },
+ {
+ fieldName: 'endReason',
+ label: '缁撴潫鍘熷洜',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.IM_RTC_CALL_END_REASON, 'number'),
+ placeholder: '璇烽�夋嫨缁撴潫鍘熷洜',
+ },
+ },
+ {
+ fieldName: 'startTime',
+ label: '鍙戣捣鏃堕棿',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 閫氳瘽璁板綍瀛楁 */
+export function useRtcGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ title: '鍙戣捣浜�',
+ minWidth: 160,
+ slots: { default: 'inviter' },
+ },
+ {
+ field: 'conversationType',
+ title: '浼氳瘽绫诲瀷',
+ width: 110,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE },
+ },
+ },
+ {
+ title: '缇�',
+ minWidth: 160,
+ slots: { default: 'group' },
+ },
+ {
+ field: 'mediaType',
+ title: '濯掍綋绫诲瀷',
+ width: 110,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE },
+ },
+ },
+ {
+ field: 'status',
+ title: '閫氳瘽鐘舵��',
+ width: 110,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.IM_RTC_CALL_STATUS },
+ },
+ },
+ {
+ field: 'endReason',
+ title: '缁撴潫鍘熷洜',
+ width: 120,
+ slots: { default: 'endReason' },
+ },
+ {
+ title: '閫氳瘽鏃堕暱',
+ width: 120,
+ slots: { default: 'duration' },
+ },
+ {
+ field: 'startTime',
+ title: '鍙戣捣鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 100,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
diff --git a/src/views/im/manager/rtc/index.vue b/src/views/im/manager/rtc/index.vue
new file mode 100644
index 0000000..8a0cdfb
--- /dev/null
+++ b/src/views/im/manager/rtc/index.vue
@@ -0,0 +1,98 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerRtcApi } from '#/api/im/manager/rtc';
+
+import { ref } from 'vue';
+
+import { Page } from '#/packages/effects/common-ui/src';
+import { DICT_TYPE } from '#/packages/constants/src';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { getManagerRtcCallPage } from '#/api/im/manager/rtc';
+import { DictTag } from '#/components/dict-tag';
+import {
+ formatGroupLabel,
+ formatUserLabel,
+} from '#/views/im/manager/utils/format';
+import { resolveCallDuration } from '#/views/im/utils/time';
+
+import { useRtcGridColumns, useRtcGridFormSchema } from './data';
+import Detail from './modules/detail.vue';
+
+defineOptions({ name: 'ImManagerRtcCall' });
+
+const detailRef = ref<InstanceType<typeof Detail>>();
+
+/** 鎵撳紑璇︽儏 */
+function handleDetail(row: ImManagerRtcApi.RtcCall) {
+ detailRef.value?.open(row);
+}
+
+const [Grid] = useVbenVxeGrid({
+ formOptions: {
+ schema: useRtcGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useRtcGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerRtcCallPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerRtcApi.RtcCall>,
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <Detail ref="detailRef" />
+ <Grid table-title="閫氳瘽璁板綍鍒楄〃">
+ <template #inviter="{ row }">
+ {{ formatUserLabel(row.inviterNickname, row.inviterUserId) }}
+ </template>
+ <template #group="{ row }">
+ {{ formatGroupLabel(row.groupName, row.groupId) }}
+ </template>
+ <template #endReason="{ row }">
+ <DictTag
+ v-if="row.endReason"
+ :type="DICT_TYPE.IM_RTC_CALL_END_REASON"
+ :value="row.endReason"
+ />
+ <span v-else>-</span>
+ </template>
+ <template #duration="{ row }">
+ {{ resolveCallDuration(row.acceptTime, row.endTime) }}
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '璇︽儏',
+ type: 'link',
+ icon: ACTION_ICON.VIEW,
+ auth: ['im:manager:rtc:query'],
+ onClick: handleDetail.bind(null, row),
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/rtc/modules/detail.vue b/src/views/im/manager/rtc/modules/detail.vue
new file mode 100644
index 0000000..35c3a8d
--- /dev/null
+++ b/src/views/im/manager/rtc/modules/detail.vue
@@ -0,0 +1,119 @@
+<script lang="ts" setup>
+import type { ImManagerRtcApi } from '#/api/im/manager/rtc';
+
+import { computed, ref } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+
+import { Descriptions, DescriptionsItem, Drawer, Table } from 'ant-design-vue';
+
+import { getManagerRtcCallParticipantList } from '#/api/im/manager/rtc';
+import { DictTag } from '#/components/dict-tag';
+import {
+ formatDateTimeText,
+ formatGroupLabel,
+ formatUserLabel,
+} from '#/views/im/manager/utils/format';
+import { resolveCallDuration } from '#/views/im/utils/time';
+
+const visible = ref(false);
+const detail = ref<ImManagerRtcApi.RtcCall>({} as ImManagerRtcApi.RtcCall);
+const loading = ref(false);
+const participants = ref<ImManagerRtcApi.RtcParticipant[]>([]);
+
+const duration = computed(() =>
+ resolveCallDuration(detail.value.acceptTime, detail.value.endTime),
+);
+
+const columns = [
+ { title: '鐢ㄦ埛缂栧彿', dataIndex: 'userId', width: 120 },
+ { title: '鏄电О', dataIndex: 'userNickname' },
+ { title: '鍙備笌瑙掕壊', dataIndex: 'role', width: 120 },
+ { title: '鍙備笌鐘舵��', dataIndex: 'status', width: 120 },
+ { title: '琚個璇锋椂闂�', dataIndex: 'inviteTime', width: 180 },
+ { title: '鎺ュ惉鏃堕棿', dataIndex: 'acceptTime', width: 180 },
+ { title: '绂诲紑鏃堕棿', dataIndex: 'leaveTime', width: 180 },
+];
+
+/** 鎵撳紑璇︽儏 */
+async function open(row: ImManagerRtcApi.RtcCall) {
+ detail.value = row;
+ visible.value = true;
+ loading.value = true;
+ try {
+ participants.value = await getManagerRtcCallParticipantList(row.id);
+ } finally {
+ loading.value = false;
+ }
+}
+
+defineExpose({ open });
+</script>
+
+<template>
+ <Drawer v-model:open="visible" destroy-on-close title="閫氳瘽璁板綍璇︽儏" width="900">
+ <Descriptions bordered :column="2">
+ <DescriptionsItem label="缂栧彿">{{ detail.id }}</DescriptionsItem>
+ <DescriptionsItem label="涓氬姟閫氳瘽缂栧彿">{{ detail.room }}</DescriptionsItem>
+ <DescriptionsItem label="鍙戣捣浜�">
+ {{ formatUserLabel(detail.inviterNickname, detail.inviterUserId) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="浼氳瘽绫诲瀷">
+ <DictTag :type="DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE" :value="detail.conversationType" />
+ </DescriptionsItem>
+ <DescriptionsItem label="缇�">
+ {{ formatGroupLabel(detail.groupName, detail.groupId) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="濯掍綋绫诲瀷">
+ <DictTag :type="DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE" :value="detail.mediaType" />
+ </DescriptionsItem>
+ <DescriptionsItem label="閫氳瘽鐘舵��">
+ <DictTag :type="DICT_TYPE.IM_RTC_CALL_STATUS" :value="detail.status" />
+ </DescriptionsItem>
+ <DescriptionsItem label="缁撴潫鍘熷洜">
+ <DictTag
+ v-if="detail.endReason"
+ :type="DICT_TYPE.IM_RTC_CALL_END_REASON"
+ :value="detail.endReason"
+ />
+ <span v-else>-</span>
+ </DescriptionsItem>
+ <DescriptionsItem label="鍙戣捣鏃堕棿">
+ {{ formatDateTimeText(detail.startTime) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="鎺ラ�氭椂闂�">
+ {{ formatDateTimeText(detail.acceptTime) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="缁撴潫鏃堕棿">
+ {{ formatDateTimeText(detail.endTime) }}
+ </DescriptionsItem>
+ <DescriptionsItem label="閫氳瘽鏃堕暱">{{ duration }}</DescriptionsItem>
+ </Descriptions>
+
+ <div class="mb-4 mt-5 font-bold">鍙備笌鑰呭垪琛�</div>
+ <Table
+ :columns="columns"
+ :data-source="participants"
+ :loading="loading"
+ :pagination="false"
+ bordered
+ row-key="id"
+ size="small"
+ >
+ <template #bodyCell="{ column, record, text }">
+ <template v-if="column.dataIndex === 'role'">
+ <DictTag :type="DICT_TYPE.IM_RTC_PARTICIPANT_ROLE" :value="record.role" />
+ </template>
+ <template v-else-if="column.dataIndex === 'status'">
+ <DictTag :type="DICT_TYPE.IM_RTC_PARTICIPANT_STATUS" :value="record.status" />
+ </template>
+ <template v-else-if="['inviteTime', 'acceptTime', 'leaveTime'].includes(column.dataIndex as string)">
+ {{ formatDateTimeText(text) }}
+ </template>
+ <template v-else>
+ {{ text || '-' }}
+ </template>
+ </template>
+ </Table>
+ </Drawer>
+</template>
diff --git a/src/views/im/manager/sensitiveword/data.ts b/src/views/im/manager/sensitiveword/data.ts
new file mode 100644
index 0000000..2fefb72
--- /dev/null
+++ b/src/views/im/manager/sensitiveword/data.ts
@@ -0,0 +1,120 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { CommonStatusEnum, DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { z } from '#/adapter/form';
+import { getRangePickerDefaultProps } from '#/utils';
+
+/** 鏂板/淇敼鐨勮〃鍗� */
+export function useFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: {
+ triggerFields: [''],
+ show: () => false,
+ },
+ },
+ {
+ fieldName: 'word',
+ label: '鏁忔劅璇�',
+ component: 'Input',
+ componentProps: {
+ maxlength: 64,
+ placeholder: '璇疯緭鍏ユ晱鎰熻瘝',
+ },
+ rules: 'required',
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'RadioGroup',
+ componentProps: {
+ options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+ buttonStyle: 'solid',
+ optionType: 'button',
+ },
+ rules: z.number().default(CommonStatusEnum.ENABLE),
+ },
+ ];
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'word',
+ label: '鏁忔劅璇�',
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ placeholder: '璇疯緭鍏ユ晱鎰熻瘝',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨鐘舵��',
+ },
+ },
+ {
+ fieldName: 'createTime',
+ label: '鍒涘缓鏃堕棿',
+ component: 'RangePicker',
+ componentProps: {
+ ...getRangePickerDefaultProps(),
+ allowClear: true,
+ },
+ },
+ ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions['columns'] {
+ return [
+ { type: 'checkbox', width: 40 },
+ {
+ field: 'id',
+ title: '缂栧彿',
+ width: 100,
+ },
+ {
+ field: 'word',
+ title: '鏁忔劅璇�',
+ minWidth: 180,
+ },
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.COMMON_STATUS },
+ },
+ },
+ {
+ field: 'creatorName',
+ title: '鍒涘缓浜�',
+ minWidth: 120,
+ },
+ {
+ field: 'createTime',
+ title: '鍒涘缓鏃堕棿',
+ minWidth: 180,
+ formatter: 'formatDateTime',
+ },
+ {
+ title: '鎿嶄綔',
+ width: 160,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
diff --git a/src/views/im/manager/sensitiveword/index.vue b/src/views/im/manager/sensitiveword/index.vue
new file mode 100644
index 0000000..4b81a83
--- /dev/null
+++ b/src/views/im/manager/sensitiveword/index.vue
@@ -0,0 +1,175 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { ImManagerSensitiveWordApi } from '#/api/im/manager/sensitiveword';
+
+import { ref } from 'vue';
+
+import { confirm, Page, useVbenModal } from '#/packages/effects/common-ui/src';
+import { isEmpty } from '#/packages/utils/src';
+
+import { message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ deleteManagerSensitiveWord,
+ deleteManagerSensitiveWordList,
+ getManagerSensitiveWordPage,
+} from '#/api/im/manager/sensitiveword';
+import { $t } from '#/locales';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+defineOptions({ name: 'ImManagerSensitiveWord' });
+
+const [FormModal, formModalApi] = useVbenModal({
+ connectedComponent: Form,
+ destroyOnClose: true,
+});
+
+/** 鍒锋柊琛ㄦ牸 */
+function handleRefresh() {
+ gridApi.query();
+}
+
+/** 鍒涘缓鏁忔劅璇� */
+function handleCreate() {
+ formModalApi.setData(null).open();
+}
+
+/** 缂栬緫鏁忔劅璇� */
+function handleEdit(row: ImManagerSensitiveWordApi.SensitiveWord) {
+ formModalApi.setData(row).open();
+}
+
+/** 鍒犻櫎鏁忔劅璇� */
+async function handleDelete(row: ImManagerSensitiveWordApi.SensitiveWord) {
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deleting', [row.word]),
+ duration: 0,
+ });
+ try {
+ await deleteManagerSensitiveWord(row.id);
+ message.success($t('ui.actionMessage.deleteSuccess', [row.word]));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+const checkedIds = ref<number[]>([]);
+
+/** 琛ㄦ牸閫変腑鍙樺寲 */
+function handleRowCheckboxChange({
+ records,
+}: {
+ records: ImManagerSensitiveWordApi.SensitiveWord[];
+}) {
+ checkedIds.value = records.map((item) => item.id);
+}
+
+/** 鎵归噺鍒犻櫎鏁忔劅璇� */
+async function handleDeleteBatch() {
+ await confirm($t('ui.actionMessage.deleteBatchConfirm'));
+ const hideLoading = message.loading({
+ content: $t('ui.actionMessage.deletingBatch'),
+ duration: 0,
+ });
+ try {
+ await deleteManagerSensitiveWordList(checkedIds.value);
+ checkedIds.value = [];
+ message.success($t('ui.actionMessage.deleteSuccess'));
+ handleRefresh();
+ } finally {
+ hideLoading();
+ }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) => {
+ return await getManagerSensitiveWordPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ });
+ },
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<ImManagerSensitiveWordApi.SensitiveWord>,
+ gridEvents: {
+ checkboxAll: handleRowCheckboxChange,
+ checkboxChange: handleRowCheckboxChange,
+ },
+});
+</script>
+
+<template>
+ <Page auto-content-height>
+ <FormModal @success="handleRefresh" />
+ <Grid table-title="鏁忔劅璇嶅垪琛�">
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: $t('ui.actionTitle.create', ['鏁忔劅璇�']),
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['im:manager:sensitive-word:create'],
+ onClick: handleCreate,
+ },
+ {
+ label: $t('ui.actionTitle.deleteBatch'),
+ type: 'primary',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:sensitive-word:delete'],
+ disabled: isEmpty(checkedIds),
+ onClick: handleDeleteBatch,
+ },
+ ]"
+ />
+ </template>
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: $t('common.edit'),
+ type: 'link',
+ icon: ACTION_ICON.EDIT,
+ auth: ['im:manager:sensitive-word:update'],
+ onClick: handleEdit.bind(null, row),
+ },
+ {
+ label: $t('common.delete'),
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['im:manager:sensitive-word:delete'],
+ popConfirm: {
+ title: $t('ui.actionMessage.deleteConfirm', [row.word]),
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/im/manager/sensitiveword/modules/form.vue b/src/views/im/manager/sensitiveword/modules/form.vue
new file mode 100644
index 0000000..dedc272
--- /dev/null
+++ b/src/views/im/manager/sensitiveword/modules/form.vue
@@ -0,0 +1,83 @@
+<script lang="ts" setup>
+import type { ImManagerSensitiveWordApi } from '#/api/im/manager/sensitiveword';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+ createManagerSensitiveWord,
+ getManagerSensitiveWord,
+ updateManagerSensitiveWord,
+} from '#/api/im/manager/sensitiveword';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<ImManagerSensitiveWordApi.SensitiveWord>();
+const getTitle = computed(() => {
+ return formData.value?.id ? '淇敼鏁忔劅璇�' : '鏂板鏁忔劅璇�';
+});
+
+const [Form, formApi] = useVbenForm({
+ commonConfig: {
+ componentProps: {
+ class: 'w-full',
+ },
+ formItemClass: 'col-span-2',
+ labelWidth: 80,
+ },
+ layout: 'horizontal',
+ schema: useFormSchema(),
+ showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ const { valid } = await formApi.validate();
+ if (!valid) {
+ return;
+ }
+ modalApi.lock();
+ const data = (await formApi.getValues()) as ImManagerSensitiveWordApi.SensitiveWord;
+ try {
+ await (formData.value?.id
+ ? updateManagerSensitiveWord(data)
+ : createManagerSensitiveWord(data));
+ await modalApi.close();
+ emit('success');
+ message.success($t('ui.actionMessage.operationSuccess'));
+ } finally {
+ modalApi.unlock();
+ }
+ },
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ formData.value = undefined;
+ await formApi.resetForm();
+ return;
+ }
+ const data = modalApi.getData<ImManagerSensitiveWordApi.SensitiveWord>();
+ if (!data || !data.id) {
+ return;
+ }
+ modalApi.lock();
+ try {
+ formData.value = await getManagerSensitiveWord(data.id);
+ await formApi.setValues(formData.value);
+ } finally {
+ modalApi.unlock();
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal :title="getTitle" class="w-1/3">
+ <Form class="mx-4" />
+ </Modal>
+</template>
diff --git a/src/views/im/manager/statistics/components/distribution-chart.vue b/src/views/im/manager/statistics/components/distribution-chart.vue
new file mode 100644
index 0000000..9deb0e4
--- /dev/null
+++ b/src/views/im/manager/statistics/components/distribution-chart.vue
@@ -0,0 +1,118 @@
+<script lang="ts" setup>
+import type { EchartsUIType } from '#/packages/effects/plugins/src/echarts';
+
+import { nextTick, onMounted, ref } from 'vue';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictObj } from '#/packages/effects/hooks/src';
+import { EchartsUI, useEcharts } from '#/packages/effects/plugins/src/echarts';
+
+import { Card } from 'ant-design-vue';
+
+import {
+ getGroupSizeDistribution,
+ getMessageTypeDistribution,
+ getTopSenders,
+} from '#/api/im/manager/statistics';
+
+const props = defineProps<{
+ type: 'groupSize' | 'messageType' | 'topSenders';
+}>();
+
+const titleMap = {
+ groupSize: '缇よ妯″垎甯�',
+ messageType: '娑堟伅绫诲瀷鍒嗗竷',
+ topSenders: '娑堟伅鍙戦�� TOP 10',
+};
+
+const loading = ref(false);
+const chartRef = ref<EchartsUIType>();
+const { renderEcharts } = useEcharts(chartRef);
+
+/** 鍔犺浇鍥捐〃鏁版嵁 */
+async function loadData() {
+ loading.value = true;
+ try {
+ await nextTick();
+ if (props.type === 'groupSize') {
+ const data = await getGroupSizeDistribution();
+ await renderEcharts({
+ tooltip: { trigger: 'axis' },
+ grid: { left: 24, right: 24, bottom: 16, top: 28, containLabel: true },
+ xAxis: { type: 'category', data: data.map((item) => item.range) },
+ yAxis: { type: 'value', name: '缇ょ粍鏁�' },
+ series: [
+ {
+ type: 'bar',
+ data: data.map((item) => item.count),
+ itemStyle: { color: '#18a058', borderRadius: [4, 4, 0, 0] },
+ barMaxWidth: 48,
+ },
+ ],
+ });
+ return;
+ }
+ if (props.type === 'messageType') {
+ const data = await getMessageTypeDistribution();
+ const items = data.map((item) => ({
+ name:
+ getDictObj(DICT_TYPE.IM_CONTENT_TYPE, String(item.type))?.label ||
+ `鏈煡(${item.type})`,
+ value: item.value,
+ }));
+ await renderEcharts({
+ tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
+ legend: { orient: 'vertical', right: 8, top: 'middle' },
+ series: [
+ {
+ type: 'pie',
+ radius: ['42%', '70%'],
+ data: items,
+ itemStyle: { borderColor: '#fff', borderRadius: 4, borderWidth: 2 },
+ label: { show: false },
+ },
+ ],
+ });
+ return;
+ }
+ const data = await getTopSenders();
+ const sorted = data.toSorted((a, b) => a.messageCount - b.messageCount);
+ await renderEcharts({
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+ grid: { left: 24, right: 24, bottom: 16, top: 20, containLabel: true },
+ xAxis: { type: 'value', name: '娑堟伅鏁�' },
+ yAxis: {
+ type: 'category',
+ data: sorted.map((item) => `${item.nickname || item.userId}(${item.userId})`),
+ axisLabel: { overflow: 'truncate', width: 110 },
+ },
+ series: [
+ {
+ type: 'bar',
+ data: sorted.map((item) => item.messageCount),
+ itemStyle: { color: '#2f7df6', borderRadius: [0, 4, 4, 0] },
+ barMaxWidth: 18,
+ },
+ ],
+ });
+ } finally {
+ loading.value = false;
+ }
+}
+
+onMounted(loadData);
+</script>
+
+<template>
+ <Card :body-style="{ padding: '12px 16px 16px' }" :title="titleMap[type]">
+ <div class="relative min-h-[320px]">
+ <EchartsUI ref="chartRef" height="320px" />
+ <div
+ v-if="loading"
+ class="absolute inset-0 flex items-center justify-center bg-card/70 text-sm text-muted-foreground"
+ >
+ 鍔犺浇涓�
+ </div>
+ </div>
+ </Card>
+</template>
diff --git a/src/views/im/manager/statistics/components/index.ts b/src/views/im/manager/statistics/components/index.ts
new file mode 100644
index 0000000..c7228a3
--- /dev/null
+++ b/src/views/im/manager/statistics/components/index.ts
@@ -0,0 +1,3 @@
+export { default as DistributionChart } from './distribution-chart.vue';
+export { default as OverviewCards } from './overview-cards.vue';
+export { default as TrendChart } from './trend-chart.vue';
diff --git a/src/views/im/manager/statistics/components/overview-cards.vue b/src/views/im/manager/statistics/components/overview-cards.vue
new file mode 100644
index 0000000..f757953
--- /dev/null
+++ b/src/views/im/manager/statistics/components/overview-cards.vue
@@ -0,0 +1,105 @@
+<script lang="ts" setup>
+import type { ImManagerStatisticsApi } from '#/api/im/manager/statistics';
+
+import { computed } from 'vue';
+
+import { IconifyIcon } from '#/packages/icons/src';
+
+import { Card } from 'ant-design-vue';
+
+defineOptions({ name: 'ImManagerStatisticsOverviewCards' });
+
+const props = defineProps<{
+ overview: ImManagerStatisticsApi.Overview;
+}>();
+
+/** 璁$畻鐜瘮 */
+function calcRatio(today: number, yesterday: number) {
+ if (!yesterday) {
+ return { label: '鏃犳槰鏃ユ暟鎹�', className: 'text-gray-400' };
+ }
+ const diff = ((today - yesterday) / yesterday) * 100;
+ const sign = diff >= 0 ? '+' : '';
+ return {
+ label: `${sign}${diff.toFixed(1)}%`,
+ className: diff >= 0 ? 'text-green-500' : 'text-red-500',
+ };
+}
+
+const cards = computed(() => {
+ const overview = props.overview;
+ const totalMsgToday =
+ (overview.privateMessageToday || 0) + (overview.groupMessageToday || 0);
+ const totalMsgYesterday =
+ (overview.privateMessageYesterday || 0) +
+ (overview.groupMessageYesterday || 0);
+ const msgRatio = calcRatio(totalMsgToday, totalMsgYesterday);
+ return [
+ {
+ title: '鎬荤敤鎴�',
+ value: overview.totalUser || 0,
+ icon: 'lucide:user',
+ color: '#2f7df6',
+ metaLabel: '浠婃棩鏂板',
+ metaValue: `+${overview.newUserToday || 0}`,
+ metaClass: 'text-green-500',
+ },
+ {
+ title: '鎬荤兢缁�',
+ value: overview.totalGroup || 0,
+ icon: 'lucide:messages-square',
+ color: '#18a058',
+ metaLabel: '浠婃棩鏂板',
+ metaValue: `+${overview.newGroupToday || 0}`,
+ metaClass: 'text-green-500',
+ },
+ {
+ title: '鏃ユ椿鐢ㄦ埛',
+ value: overview.activeUserDaily || 0,
+ icon: 'lucide:timer',
+ color: '#f59e0b',
+ metaLabel: '鍛�/鏈堟椿',
+ metaValue: `${overview.activeUserWeekly || 0} / ${overview.activeUserMonthly || 0}`,
+ metaClass: 'text-gray-500',
+ },
+ {
+ title: '浠婃棩娑堟伅',
+ value: totalMsgToday,
+ suffix: `锛圥 ${overview.privateMessageToday}/G ${overview.groupMessageToday}锛塦,
+ icon: 'lucide:message-circle',
+ color: '#64748b',
+ metaLabel: '鐜瘮鏄ㄦ棩',
+ metaValue: msgRatio.label,
+ metaClass: msgRatio.className,
+ },
+ ];
+});
+</script>
+
+<template>
+ <div class="grid grid-cols-4 gap-4 max-xl:grid-cols-2 max-md:grid-cols-1">
+ <Card v-for="card in cards" :key="card.title" :body-style="{ padding: '16px' }">
+ <div class="flex items-center">
+ <div
+ class="mr-3 flex size-12 shrink-0 items-center justify-center rounded"
+ :style="{ backgroundColor: card.color }"
+ >
+ <IconifyIcon :icon="card.icon" class="size-6 text-white" />
+ </div>
+ <div class="min-w-0 flex-1">
+ <div class="mb-1 text-sm text-muted-foreground">{{ card.title }}</div>
+ <div class="truncate text-2xl font-semibold leading-none">
+ {{ card.value }}
+ <span v-if="card.suffix" class="ml-1 text-xs font-normal text-gray-400">
+ {{ card.suffix }}
+ </span>
+ </div>
+ <div class="mt-2 text-xs text-gray-400">
+ {{ card.metaLabel }}锛�
+ <span :class="card.metaClass">{{ card.metaValue }}</span>
+ </div>
+ </div>
+ </div>
+ </Card>
+ </div>
+</template>
diff --git a/src/views/im/manager/statistics/components/trend-chart.vue b/src/views/im/manager/statistics/components/trend-chart.vue
new file mode 100644
index 0000000..3d1c33b
--- /dev/null
+++ b/src/views/im/manager/statistics/components/trend-chart.vue
@@ -0,0 +1,145 @@
+<script lang="ts" setup>
+import type { EChartsOption, EchartsUIType } from '#/packages/effects/plugins/src/echarts';
+
+import { nextTick, onMounted, ref } from 'vue';
+
+import { EchartsUI, useEcharts } from '#/packages/effects/plugins/src/echarts';
+
+import { Card, Select } from 'ant-design-vue';
+
+import {
+ getMessageTrend,
+ getUserTrend,
+} from '#/api/im/manager/statistics';
+
+const props = defineProps<{
+ type: 'message' | 'user';
+}>();
+
+const title =
+ props.type === 'message'
+ ? '娑堟伅瓒嬪娍锛堢鑱� + 缇よ亰锛�'
+ : '鐢ㄦ埛瓒嬪娍锛堟柊澧炴敞鍐� + 鏃ユ椿锛�';
+
+const days = ref(7);
+const loading = ref(false);
+const chartRef = ref<EchartsUIType>();
+const { renderEcharts } = useEcharts(chartRef);
+
+/** 鑾峰彇鍥捐〃閰嶇疆 */
+function buildOptions(
+ dates: string[],
+ series: Record<string, number[]>,
+): EChartsOption {
+ if (props.type === 'message') {
+ return {
+ tooltip: { trigger: 'axis' },
+ legend: { data: ['绉佽亰娑堟伅', '缇よ亰娑堟伅'], top: 0 },
+ grid: { left: 24, right: 24, bottom: 16, top: 42, containLabel: true },
+ xAxis: {
+ type: 'category',
+ data: dates,
+ axisLabel: { formatter: (value: string) => value.slice(5, 10) },
+ },
+ yAxis: { type: 'value', name: '娑堟伅閲�' },
+ series: [
+ {
+ name: '绉佽亰娑堟伅',
+ type: 'line',
+ smooth: true,
+ data: series.private || [],
+ itemStyle: { color: '#2f7df6' },
+ areaStyle: { color: 'rgba(47,125,246,0.12)' },
+ },
+ {
+ name: '缇よ亰娑堟伅',
+ type: 'line',
+ smooth: true,
+ data: series.group || [],
+ itemStyle: { color: '#18a058' },
+ areaStyle: { color: 'rgba(24,160,88,0.12)' },
+ },
+ ],
+ };
+ }
+ return {
+ tooltip: { trigger: 'axis' },
+ legend: { data: ['鏂板娉ㄥ唽', '鏃ユ椿'], top: 0 },
+ grid: { left: 24, right: 32, bottom: 16, top: 42, containLabel: true },
+ xAxis: {
+ type: 'category',
+ data: dates,
+ axisLabel: { formatter: (value: string) => value.slice(5, 10) },
+ },
+ yAxis: [
+ { type: 'value', name: '鏂板娉ㄥ唽', position: 'left' },
+ { type: 'value', name: '鏃ユ椿', position: 'right' },
+ ],
+ series: [
+ {
+ name: '鏂板娉ㄥ唽',
+ type: 'bar',
+ yAxisIndex: 0,
+ data: series.register || [],
+ itemStyle: { color: '#f59e0b' },
+ barMaxWidth: 24,
+ },
+ {
+ name: '鏃ユ椿',
+ type: 'line',
+ yAxisIndex: 1,
+ smooth: true,
+ data: series.active || [],
+ itemStyle: { color: '#ef4444' },
+ },
+ ],
+ };
+}
+
+/** 鍔犺浇鍥捐〃鏁版嵁 */
+async function loadData() {
+ loading.value = true;
+ try {
+ const data =
+ props.type === 'message'
+ ? await getMessageTrend(days.value)
+ : await getUserTrend(days.value);
+ await nextTick();
+ await renderEcharts(buildOptions(data.dates, data.series));
+ } finally {
+ loading.value = false;
+ }
+}
+
+onMounted(loadData);
+</script>
+
+<template>
+ <Card :body-style="{ padding: '12px 16px 16px' }">
+ <template #title>
+ <div class="flex items-center justify-between">
+ <span>{{ title }}</span>
+ <Select
+ v-model:value="days"
+ :options="[
+ { label: '杩� 7 澶�', value: 7 },
+ { label: '杩� 15 澶�', value: 15 },
+ { label: '杩� 30 澶�', value: 30 },
+ ]"
+ class="w-28"
+ size="small"
+ @change="loadData"
+ />
+ </div>
+ </template>
+ <div class="relative min-h-[320px]">
+ <EchartsUI ref="chartRef" height="320px" />
+ <div
+ v-if="loading"
+ class="absolute inset-0 flex items-center justify-center bg-card/70 text-sm text-muted-foreground"
+ >
+ 鍔犺浇涓�
+ </div>
+ </div>
+ </Card>
+</template>
diff --git a/src/views/im/manager/statistics/index.vue b/src/views/im/manager/statistics/index.vue
new file mode 100644
index 0000000..a969b7a
--- /dev/null
+++ b/src/views/im/manager/statistics/index.vue
@@ -0,0 +1,45 @@
+<script lang="ts" setup>
+import type { ImManagerStatisticsApi } from '#/api/im/manager/statistics';
+
+import { onMounted, ref } from 'vue';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { getStatisticsOverview } from '#/api/im/manager/statistics';
+
+import {
+ DistributionChart,
+ OverviewCards,
+ TrendChart,
+} from './components';
+
+defineOptions({ name: 'ImManagerStatistics' });
+
+const overview = ref<ImManagerStatisticsApi.Overview>();
+
+/** 鍔犺浇姒傝鏁版嵁 */
+async function loadOverview() {
+ overview.value = await getStatisticsOverview();
+}
+
+onMounted(loadOverview);
+</script>
+
+<template>
+ <Page auto-content-height>
+ <div class="space-y-4">
+ <OverviewCards v-if="overview" :overview="overview" />
+
+ <div class="grid grid-cols-2 gap-4 max-xl:grid-cols-1">
+ <TrendChart type="message" />
+ <TrendChart type="user" />
+ </div>
+
+ <div class="grid grid-cols-3 gap-4 max-2xl:grid-cols-2 max-xl:grid-cols-1">
+ <DistributionChart type="messageType" />
+ <DistributionChart type="groupSize" />
+ <DistributionChart type="topSenders" />
+ </div>
+ </div>
+ </Page>
+</template>
diff --git a/src/views/im/manager/utils/format.ts b/src/views/im/manager/utils/format.ts
new file mode 100644
index 0000000..d98df1a
--- /dev/null
+++ b/src/views/im/manager/utils/format.ts
@@ -0,0 +1,55 @@
+import { formatDateTime } from '#/packages/utils/src';
+
+/** 灞曠ず鐢ㄦ埛鍚嶇О */
+export function formatUserLabel(
+ nickname: string | undefined,
+ userId: number | undefined,
+) {
+ if (!userId) {
+ return '-';
+ }
+ return `${nickname || '-'} (${userId})`;
+}
+
+/** 灞曠ず缇ゅ悕绉� */
+export function formatGroupLabel(
+ name: string | undefined,
+ groupId: number | undefined,
+) {
+ if (!groupId) {
+ return '-';
+ }
+ return `${name || '-'} (${groupId})`;
+}
+
+/** 灞曠ず鏃ユ湡鏃堕棿 */
+export function formatDateTimeText(value?: Date | number | string) {
+ return value ? (formatDateTime(value) as string) : '-';
+}
+
+/** 鏍煎紡鍖� JSON */
+export function formatJsonText(content?: string) {
+ if (!content) {
+ return '';
+ }
+ try {
+ return JSON.stringify(JSON.parse(content), null, 2);
+ } catch {
+ return content;
+ }
+}
+
+/** 鎺㈡祴鍥剧墖灏哄 */
+export function probeImageSize(url: string) {
+ return new Promise<{ height: number; width: number }>((resolve, reject) => {
+ const img = new Image();
+ img.addEventListener('load', () => {
+ resolve({
+ width: img.naturalWidth || img.width,
+ height: img.naturalHeight || img.height,
+ });
+ });
+ img.addEventListener('error', reject);
+ img.src = url;
+ });
+}
diff --git a/src/views/im/utils/auth.ts b/src/views/im/utils/auth.ts
new file mode 100644
index 0000000..188af5d
--- /dev/null
+++ b/src/views/im/utils/auth.ts
@@ -0,0 +1,14 @@
+import { useAccessStore, useUserStore } from '#/packages/stores/src'
+
+// TODO DONE @AI锛氬凡浣跨敤 Vben 鐨� useUserStore / useAccessStore 鑾峰彇鐧诲綍淇℃伅锛�
+
+/** 鑾峰彇褰撳墠鐢ㄦ埛缂栧彿 */
+export function getCurrentUserId(): number {
+ const userInfo = useUserStore().userInfo
+ return Number(userInfo?.id ?? userInfo?.userId ?? 0)
+}
+
+/** 鑾峰彇鍒锋柊浠ょ墝 */
+export function getRefreshToken(): null | string {
+ return useAccessStore().refreshToken
+}
diff --git a/src/views/im/utils/channel.ts b/src/views/im/utils/channel.ts
new file mode 100644
index 0000000..5276fb2
--- /dev/null
+++ b/src/views/im/utils/channel.ts
@@ -0,0 +1,18 @@
+import { useChannelStore } from '../home/store/channelStore'
+import { ImConversationType } from './constants'
+
+/**
+ * 鏋勫缓棰戦亾浼氳瘽鎻忚堪锛坱ype / targetId / name / avatar锛�
+ *
+ * 浼樺厛鐢� channelStore 鎷夊埌鐨勭湡瀹炲悕绉� / 澶村儚锛涙湰鍦扮紦瀛樿繕娌℃媺鍒版椂閫�鍖栦负銆岄閬� N銆嶅崰浣嶆枃妗堛��
+ * 鎶藉埌 utils/channel.ts 鍚庯紝useMessagePuller / websocketStore 鍏辩敤鍚屼竴浠藉崰浣嶉�昏緫锛岄伩鍏嶅澶勫鍒讹紱绫讳技 utils/user.ts 鍙栨樀绉扮殑宸ュ叿闆嗐��
+ */
+export const buildChannelConversationStub = (channelId: number) => {
+ const channel = useChannelStore().getChannel(channelId)
+ return {
+ type: ImConversationType.CHANNEL,
+ targetId: channelId,
+ name: channel?.name || `棰戦亾 ${channelId}`,
+ avatar: channel?.avatar || ''
+ }
+}
diff --git a/src/views/im/utils/config.ts b/src/views/im/utils/config.ts
new file mode 100644
index 0000000..6d153f0
--- /dev/null
+++ b/src/views/im/utils/config.ts
@@ -0,0 +1,133 @@
+/**
+ * IM 鍓嶇杩愯鍙傛暟 / UI 闃堝��
+ *
+ * 涓� constants.ts 鐨勫垎宸ワ細
+ * - constants.ts锛氬崗璁灇涓撅紙涓庡悗绔� enum 涓�涓�瀵瑰簲锛�+ 鍗忚濂戠害鍊硷紙濡� IM_AT_ALL_USER_ID锛�
+ * - config.ts锛氬彲璋冭妭鐨勭瓥鐣ユ暟鍊硷紝鍖呮嫭闀滃儚鍚庣 ImProperties 鐨勯粯璁ゅ�间笌绾墠绔� UI 闃堝��
+ *
+ * 鏍囨敞銆屽悗绔暅鍍忋�嶇殑甯搁噺涓� yudao.im.* 閰嶇疆榛樿鍊间竴鑷达紱璋冩暣鍚庣閰嶇疆鏃堕渶鍚屾淇敼杩欓噷
+ */
+
+// ==================== 鍚庣闀滃儚锛堜笌 ImProperties 榛樿鍊煎榻愶級 ====================
+
+/** 缇ゆ渶澶ф垚鍛樹汉鏁帮紙瀵归綈 yudao.im.group.max-member锛� */
+export const GROUP_MAX_MEMBER = 500
+
+/** 鍗曠兢绠$悊鍛樹汉鏁颁笂闄愶紙瀵归綈 yudao.im.group.admin-max-count锛� */
+export const GROUP_ADMIN_MAX_COUNT = 3
+
+/** 鍗曠兢缃《娑堟伅鏉℃暟涓婇檺锛堝榻� yudao.im.group.pin-max-count锛� */
+export const GROUP_PIN_MAX_COUNT = 5
+
+/**
+ * 鏄惁鍚敤绉佽亰宸茶鍔熻兘锛堝榻� yudao.im.message.private-read-enabled锛�
+ *
+ * 鍏抽棴鍚庯細杩涘叆绉佽亰浼氳瘽涓嶅啀涓婃姤宸茶浣嶇疆锛涙皵娉$殑銆屽凡璇� / 鏈銆嶆爣绛鹃殣钘忥紱
+ * 绠$悊鍚庡彴绉佽亰娑堟伅鍒楄〃鐨勩�岀姸鎬併�嶅垪涓庤鎯呬腑鐨勭姸鎬佸瓧娈甸殣钘�
+ */
+export const MESSAGE_PRIVATE_READ_ENABLED = true
+
+/**
+ * 鏄惁鍚敤缇よ亰宸茶鍔熻兘锛堝惈缇ゆ秷鎭洖鎵э紝瀵归綈 yudao.im.message.group-read-enabled锛�
+ *
+ * 鍏抽棴鍚庯細杩涘叆缇や細璇濅笉鍐嶄笂鎶ュ凡璇讳綅缃紱杈撳叆妗嗙殑銆屽彂閫佸洖鎵ф秷鎭�嶅叆鍙i殣钘忥紱
+ * 缇ゆ秷鎭皵娉′笂鐨勩�孨 浜哄凡璇汇�峱opover 闅愯棌锛涚鐞嗗悗鍙扮兢娑堟伅鍒楄〃鐨勩�岀姸鎬併�嶃�屽洖鎵с�嶅垪涓庤鎯呬腑瀵瑰簲瀛楁闅愯棌
+ */
+export const MESSAGE_GROUP_READ_ENABLED = true
+
+/** 娑堟伅鎾ゅ洖鏃堕棿闄愬埗锛堝垎閽燂紝瀵归綈 yudao.im.message.recall-timeout-minutes锛� */
+export const MESSAGE_RECALL_TIMEOUT_MINUTES = 5
+
+/** 绉佽亰绂荤嚎娑堟伅鏈�澶ф媺鍙栧ぉ鏁帮紙瀵归綈 yudao.im.message.private-pull-max-days锛� */
+export const MESSAGE_PRIVATE_PULL_MAX_DAYS = 30
+
+/** 缇よ亰绂荤嚎娑堟伅鏈�澶ф媺鍙栧ぉ鏁帮紙瀵归綈 yudao.im.message.group-pull-max-days锛� */
+export const MESSAGE_GROUP_PULL_MAX_DAYS = 30
+
+// ==================== 鍓嶇鐙湁锛氭媺鍙� / 鍒嗛〉 ====================
+
+/** 姣忔鎷夊彇绉佽亰娑堟伅鐨勬渶澶ф潯鏁帮紙鍚庣涓婇檺 1000锛屽墠绔彇淇濆畧鍊� 100锛� */
+export const MESSAGE_PRIVATE_PULL_SIZE = 100
+
+/** 姣忔鎷夊彇缇よ亰娑堟伅鐨勬渶澶ф潯鏁帮紙鍚庣涓婇檺 1000锛屽墠绔彇淇濆畧鍊� 100锛� */
+export const MESSAGE_GROUP_PULL_SIZE = 100
+
+/** 銆屾垜鐩稿叧銆嶅ソ鍙嬬敵璇峰垪琛ㄧ殑鍗曟鎷夊彇鏉℃暟锛堟父鏍囧垎椤� page size锛� */
+export const FRIEND_REQUEST_PAGE_SIZE = 100
+
+/** 銆屾垜鐩稿叧銆嶅姞缇ょ敵璇峰垪琛ㄧ殑鍗曟鎷夊彇鏉℃暟 */
+export const GROUP_REQUEST_PAGE_SIZE = 100
+
+// ==================== 涓婁紶瀹夊叏绛栫暐 ====================
+// 鏁板�间笌鍚庣 Spring multipart 閰嶇疆瀵归綈锛坄spring.servlet.multipart.max-file-size = 16MB`锛�
+// 鍚庣璋冨ぇ鍚庤繖閲屽悓姝ヨ皟锛涗笉瑕佸崟杈规斁瀹斤紝鍚﹀垯鐢ㄦ埛涓婁紶瀹屽埌鍚庣 413
+
+/** 鍥剧墖 / 瑙嗛 / 鏅�氭枃浠跺崟鏂囦欢涓婇檺锛圡B锛夛紝瀵归綈鍚庣 max-file-size */
+export const MESSAGE_IMAGE_MAX_MB = 16
+export const MESSAGE_VIDEO_MAX_MB = 16
+export const MESSAGE_FILE_MAX_MB = 16
+/** 璇煶鍗曟枃浠朵笂闄愶紙MB锛夛細60s @ 64kbps 绾� 0.5MB锛�5MB 宸茶繙瓒呭綍鍒跺彲鑳戒骇鍑虹殑澶у皬 */
+export const MESSAGE_VOICE_MAX_MB = 5
+
+/** 鍙墽琛� / 鑴氭湰绫绘墿灞曞悕榛戝悕鍗曪紱鎺ユ敹绔偣鍑讳笅杞藉悗鏈湴鍙屽嚮灏辫窇锛宧tml 鏈湴鎵撳紑杩樿兘鎵ц鑴氭湰 */
+export const DANGEROUS_FILE_EXTENSIONS = [
+ 'exe',
+ 'bat',
+ 'cmd',
+ 'com',
+ 'msi',
+ 'scr',
+ 'pif',
+ 'vbs',
+ 'vbe',
+ 'wsf',
+ 'ws',
+ 'js',
+ 'jse',
+ 'jar',
+ 'sh',
+ 'app',
+ 'ps1',
+ 'reg',
+ 'html',
+ 'htm'
+]
+
+// ==================== 鍓嶇鐙湁锛歎I 闃堝�� ====================
+
+/** 娑堟伅涔嬮棿娓叉煋銆屾椂闂村垎闅旀潯銆嶇殑闃堝�硷細10 鍒嗛挓 */
+export const MESSAGE_TIME_TIP_GAP_MS = 10 * 60 * 1000
+
+/**
+ * 鎾ゅ洖鑿滃崟鍙鐨勬椂闂寸獥锛氳嚜宸卞彂閫佺殑娑堟伅瓒呰繃杩欎釜鏃堕暱灏变笉鑳藉啀鎾ゅ洖锛岃彍鍗曞洖閫�涓恒�屽垹闄ゃ��
+ *
+ * 姣斿悗绔� MESSAGE_RECALL_TIMEOUT_MINUTES锛�5 鍒嗛挓锛変弗鏍硷紝瀵归綈寰俊 PC 鐨� 2 鍒嗛挓
+ */
+export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000
+
+/** 鍚堝苟杞彂娑堟伅锛圡ERGE 绫诲瀷锛夋皵娉″唴棰勮鐨勬渶澶ц鏁帮紙瀵归綈寰俊銆岃亰澶╄褰曘�嶆皵娉★級 */
+export const MESSAGE_MERGE_PREVIEW_LINES = 3
+
+/** 鏈�杩戣浆鍙戜細璇� key 鍒楄〃鐨勬渶澶т繚鐣欐暟閲忥紙瀵归綈寰俊 PC 妯悜澶村儚鍖哄彲瑙佸閲忥級 */
+export const CONVERSATION_RECENT_FORWARD_MAX = 12
+
+// ==================== 鍓嶇鐙湁锛歊TC 閫氳瘽鍏滃簳 ====================
+
+/**
+ * 鎸搩瓒呮椂鍏滃簳锛涢�氳瘽瀛樻椿鏈熼棿 timer 璋冪敤 noAnswerCallCheck 鎺ュ彛鐨勯棿闅旓紱
+ * 瀹為檯瓒呮椂闃堝�肩敱鍚庣 yudao.im.rtc.invite-timeout-minutes 鍐冲畾锛屽墠绔粎鍐冲畾瑙﹀彂棰戠巼
+ */
+export const RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS = 60 * 1000
+
+// ==================== 鍓嶇鐙湁锛歐ebSocket 鑷姩閲嶈繛 ====================
+// 鎸囨暟閫�閬匡細base * 2^attempt锛屼笂闄愬皝椤讹紱姣忔鍐嶅彔鍔� 0~jitter ms 闅忔満鍋忕Щ
+// 閬垮厤鏈嶅姟绔噸鍚椂鍏ㄩ噺瀹㈡埛绔湪鍚屼竴绉掓墦杩囨潵褰㈡垚銆屾儕缇ゃ�嶏紱涓嶈娆℃暟涓婇檺锛屾寔缁噸杩炵洿鍒伴摼璺仮澶�
+
+/** 棣栨閲嶈繛绛夊緟锛屽崟浣� ms */
+export const WS_RECONNECT_BASE_MS = 1000
+
+/** 閫�閬夸笂闄愶紝鍗曚綅 ms锛涜繛缁け璐� 5 娆″悗绋冲畾鍦� 30s 涓嶅啀澧為暱 */
+export const WS_RECONNECT_MAX_MS = 30 * 1000
+
+/** 閫�閬垮彔鍔犵殑闅忔満鎶栧姩涓婇檺锛屽崟浣� ms */
+export const WS_RECONNECT_JITTER_MS = 1000
diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts
new file mode 100644
index 0000000..895571e
--- /dev/null
+++ b/src/views/im/utils/constants.ts
@@ -0,0 +1,296 @@
+/** IM 鍐呭绫诲瀷鏋氫妇锛堝榻愬悗绔� ImContentTypeEnum锛� */
+export const ImContentType = {
+ // ========== 鐢ㄦ埛鑱婂ぉ娑堟伅锛�101-105 鐩存帴澶嶇敤 OpenIM 娈典綅缂栧彿锛� ==========
+ TEXT: 101, // 鏂囨湰锛堝搴� OpenIM Text=101锛�
+ IMAGE: 102, // 鍥剧墖锛堝搴� OpenIM Picture=102锛�
+ VOICE: 103, // 璇煶锛堝搴� OpenIM Sound=103锛�
+ VIDEO: 104, // 瑙嗛锛堝搴� OpenIM Video=104锛�
+ FILE: 105, // 鏂囦欢锛堝搴� OpenIM File=105锛�
+ MERGE: 107, // 鍚堝苟杞彂锛堝搴� OpenIM Merger=107锛沺ayload 鍐呭祵瀹屾暣蹇収锛�
+ CARD: 108, // 鍚嶇墖锛堝搴� OpenIM Card=108锛�
+ FACE: 115, // 琛ㄦ儏璐村浘锛堝搴� OpenIM Face=115锛沀nicode emoji 浠嶈蛋 TEXT锛�
+ // ========== 棰戦亾娑堟伅鎵╁睍娈碉紙125+锛汷penIM 122 涔嬪悗鏈崰鐢紝浣滀负棰戦亾 / 鍏紬鍙风被娑堟伅鎵╁睍锛� ==========
+ MATERIAL: 125, // 棰戦亾绱犳潗锛堝浘鏂囧崱鐗囷細title + coverUrl + summary + url锛涜鎯呮媺 get-content锛�
+ // ========== 淇″彿绫伙紙2101 / 2200 鐩存帴澶嶇敤 OpenIM 娈典綅缂栧彿锛�2201 鑷湁鎵╁睍锛� ==========
+ RECALL: 2101, // 鎾ゅ洖锛堝搴� OpenIM RevokeNotification=2101锛�
+ RECEIPT: 2200, // 鍥炴墽锛堝搴� OpenIM HasReadReceipt=2200锛�
+ READ: 2201, // 宸茶锛堝绔悓姝ワ紝OpenIM 鏃犲搴旓紱鑷湁鎵╁睍锛�
+ // ========== 瀹炴椂閫氳瘽淇′护锛�1601-1605 娈典綅涓� OpenIM 瀵归綈锛�1610+ 鑷湁鎵╁睍锛� ==========
+ RTC_CALL: 1601, // 閫氳瘽淇′护缁熶竴鍏ュ彛锛堝搴� OpenIM SignalingNotification=1601锛�
+ RTC_PARTICIPANT_CONNECTED: 1602, // 閫氳瘽鍙備笌鑰呭姞鍏ワ紙瀵瑰簲 OpenIM RoomParticipantsConnectedNotification=1602锛�
+ RTC_PARTICIPANT_DISCONNECTED: 1603, // 閫氳瘽鍙備笌鑰呯寮�锛堝搴� OpenIM RoomParticipantsDisconnectedNotification=1603锛�
+ // 1604-1609 OpenIM 宸茬敤 / 鐣欎綔鎵╁睍锛屾湰绯荤粺鏆備笉浣跨敤
+ RTC_CALL_START: 1610, // 閫氳瘽寮�濮嬶紙鑷湁鎵╁睍锛孫penIM 鏃狅紱浠呯兢鑱婏紱涓� END 涓ゆ寮忛厤瀵癸級
+ RTC_CALL_END: 1611, // 閫氳瘽缁撴潫锛堣嚜鏈夋墿灞曪紝OpenIM 鏃狅紱绉佽亰 / 缇よ亰锛�
+ // ========== 濂藉弸閫氱煡锛�1201-1210 鐩存帴澶嶇敤 OpenIM 娈典綅缂栧彿锛� ==========
+ FRIEND_REQUEST_APPROVED: 1201, // 濂藉弸鐢宠琚悓鎰�
+ FRIEND_REQUEST_REJECTED: 1202, // 濂藉弸鐢宠琚嫆缁�
+ FRIEND_REQUEST_RECEIVED: 1203, // 鏀跺埌鏂扮殑濂藉弸鐢宠
+ FRIEND_ADD: 1204, // 鏂板濂藉弸锛堝弻鏂瑰缓绔嬪叧绯伙級
+ FRIEND_DELETE: 1205, // 濂藉弸琚垹闄�
+ // 1206 瀵瑰簲 OpenIM FriendRemarkSetNotification锛涙湰绯荤粺骞跺叆 FRIEND_UPDATE(1210) 缁熶竴鎺ㄩ�侊紝鍗曚竴瀛楁鍙樻洿涓嶅啀鐙珛閫氶亾
+ FRIEND_BLOCK: 1207, // 鍔犲叆榛戝悕鍗�
+ FRIEND_UNBLOCK: 1208, // 绉诲嚭榛戝悕鍗�
+ FRIEND_INFO_UPDATED: 1209, // 濂藉弸璧勬枡鍙樻洿锛堟樀绉� / 澶村儚锛�
+ FRIEND_UPDATE: 1210, // 濂藉弸淇℃伅鎵归噺鏇存柊锛坰ilent / pinned锛�
+ // ========== 缇や簨浠讹紙1501-1520 鐩存帴澶嶇敤 OpenIM 娈典綅缂栧彿锛�1530+ 鑷湁鎵╁睍娈碉級 ==========
+ GROUP_CREATE: 1501, // 缇ゅ垱寤�
+ GROUP_INFO_UPDATE: 1502, // 缇や俊鎭彉鏇达紙NAME / NOTICE 涔嬪瀛楁鍏滃簳锛�
+ GROUP_REQUEST_RECEIVED: 1503, // 鏀跺埌鏂扮殑鍏ョ兢鐢宠锛堢鑱婂畾鍚戞帹閫佺粰缇や富 + 鍏ㄩ儴绠$悊鍛橈級
+ GROUP_MEMBER_QUIT: 1504, // 鎴愬憳閫�缇�
+ GROUP_REQUEST_APPROVED: 1505, // 鍏ョ兢鐢宠琚悓鎰忥紙绉佽亰鎺ㄧ粰鐢宠浜� + 缇や富 + 鍏ㄩ儴绠$悊鍛橈級
+ GROUP_REQUEST_REJECTED: 1506, // 鍏ョ兢鐢宠琚嫆缁濓紙鍚屼笂锛�
+ GROUP_OWNER_TRANSFER: 1507, // 缇や富杞
+ GROUP_MEMBER_KICK: 1508, // 鎴愬憳琚Щ鍑�
+ GROUP_MEMBER_INVITE: 1509, // 鎴愬憳鍔犲叆
+ GROUP_MEMBER_ENTER: 1510, // 鑷敱杩涚兢锛團REE 妯″紡鎴栫敵璇烽�氳繃鍚庯紱鍏ㄥ憳骞挎挱锛�
+ GROUP_DISSOLVE: 1511, // 缇よВ鏁�
+ GROUP_MEMBER_MUTED: 1512, // 鍗曟垚鍛樼瑷�
+ GROUP_MEMBER_CANCEL_MUTED: 1513, // 鍗曟垚鍛樺彇娑堢瑷�
+ GROUP_MUTED: 1514, // 鍏ㄧ兢绂佽█
+ GROUP_CANCEL_MUTED: 1515, // 鍏ㄧ兢鍙栨秷绂佽█
+ GROUP_MEMBER_NICKNAME_UPDATE: 1516, // 鎴愬憳鏄电О鍙樻洿锛堢獎鍖栧埌 displayUserName锛�
+ GROUP_ADMIN_ADD: 1517, // 娣诲姞绠$悊鍛�
+ GROUP_ADMIN_REMOVE: 1518, // 鎾ら攢绠$悊鍛�
+ GROUP_NOTICE_UPDATE: 1519, // 缇ゅ叕鍛婂彉鏇�
+ GROUP_NAME_UPDATE: 1520, // 缇ゅ悕鍙樻洿
+ // ========== 鑷湁鎵╁睍娈碉紙1530+锛孫penIM 1500-1520 娈典綅鏃犲搴旂墿锛� ==========
+ GROUP_MEMBER_SETTING_UPDATE: 1530, // 缇ゆ垚鍛樹釜浜鸿缃彉鏇达細silent / groupRemark 涓汉澶氱鍚屾
+ GROUP_MESSAGE_PIN: 1531, // 缇ゆ秷鎭疆椤讹紙鑷湁鎵╁睍锛孫penIM 鏃狅級
+ GROUP_MESSAGE_UNPIN: 1532, // 缇ゆ秷鎭彇娑堢疆椤讹紙鑷湁鎵╁睍锛孫penIM 鏃狅級
+ GROUP_BANNED: 1533 // 缇ゅ皝绂佸彉鏇达紙鑷湁鎵╁睍锛孫penIM 鏃狅級
+} as const
+
+/** 鍒ゆ柇鏄惁銆岀兢骞挎挱浜嬩欢銆嶏細[GROUP_CREATE, GROUP_BANNED] 娈典綅閮界畻锛屼粎 GROUP_MEMBER_SETTING_UPDATE 鏄釜浜轰俊鍙锋帓闄� */
+export function isGroupNotification(type: number): boolean {
+ return (
+ type >= ImContentType.GROUP_CREATE &&
+ type <= ImContentType.GROUP_BANNED &&
+ type !== ImContentType.GROUP_MEMBER_SETTING_UPDATE
+ )
+}
+
+/** 鍒ゆ柇鏄惁銆屽ソ鍙嬮�氱煡浜嬩欢銆嶏細1201-1210 娈典綅 */
+export function isFriendNotification(type: number): boolean {
+ return type >= ImContentType.FRIEND_REQUEST_APPROVED && type <= ImContentType.FRIEND_UPDATE
+}
+
+/** 鍒ゆ柇鏄惁銆屽姞缇ょ敵璇烽�氱煡浜嬩欢銆嶏細1503/1505/1506 */
+export function isGroupRequestNotification(type: number): boolean {
+ return (
+ type === ImContentType.GROUP_REQUEST_RECEIVED ||
+ type === ImContentType.GROUP_REQUEST_APPROVED ||
+ type === ImContentType.GROUP_REQUEST_REJECTED
+ )
+}
+
+/** 鍒ゆ柇鏄惁銆屼細璇濆唴鐨勫ソ鍙嬩簨浠舵皵娉°�嶏細FRIEND_ADD / FRIEND_DELETE 鐩存帴娓叉煋鎴愮伆鑹叉彁绀猴紝涓庣兢浜嬩欢鍚屽鐞� */
+export function isFriendChatTip(type: number): boolean {
+ return type === ImContentType.FRIEND_ADD || type === ImContentType.FRIEND_DELETE
+}
+
+/** 鍒ゆ柇鏄惁銆屼細璇濆唴鐨勯�氳瘽浜嬩欢姘旀场銆嶏細RTC_CALL_START / RTC_CALL_END 娓叉煋鎴愮伆鑹叉彁绀� */
+export function isRtcCallTip(type: number): boolean {
+ return type === ImContentType.RTC_CALL_START || type === ImContentType.RTC_CALL_END
+}
+
+/**
+ * IM 鏅�氬唴瀹圭被鍨嬮泦鍚堬紙normal vs event 浜屽垎锛涗笌鍚庣 ImContentTypeEnum.normal 瀛楁璇箟涓�鑷达級
+ *
+ * 杩欎釜闆嗗悎鍦ㄥ澶勮澶嶇敤锛屾柊澧炵被鍨嬪墠鍏堢‘璁ゆ墍鏈夊壇浣滅敤閮界鍚堥鏈燂細
+ * 1. 鍚庣鍙戦�佸叆鍙f牎楠岋紙Im{Private,Group}MessageSendReqVO.isNormalType锛夆�斺�� 鐢ㄦ埛鍙戦�佺殑鍐呭绫诲瀷蹇呴』 normal=true
+ * 2. 鍓嶇鎺ユ敹渚ф湭璇� / 鎻愮ず闊筹紙websocketStore锛夆�斺�� normal 娑堟伅璁″叆浼氳瘽鏈鏁� + 瑙﹀彂澹伴煶
+ * 3. 鍓嶇浼氳瘽鍒楄〃 lastType / @ 鏍囩锛圕onversationItem锛夆�斺�� 鍙湁 normal 鎵嶇畻銆屾渶鍚庝竴鏉¤亰澶╂秷鎭��
+ * 4. 鍓嶇缇ゆ秷鎭疆椤惰彍鍗曪紙message-item.vue 鐨� canPin锛夆�斺�� normal 鎵嶅厑璁哥兢涓� / 绠$悊鍛樼疆椤�
+ *
+ * 鍚嶇墖锛圕ARD锛�/ 琛ㄦ儏锛團ACE锛夐兘鏄�岀敤鎴蜂富鍔ㄥ彂鐨勮亰澶╂秷鎭�嶏紝1/2/3 閮界鍚堥鏈燂紱4 鍚屾椂鏀惧紑 = 缇や富鍙疆椤讹紝璇箟鍚堢悊
+ */
+const ImContentTypeNormals = new Set<number>([
+ ImContentType.CARD,
+ ImContentType.FACE,
+ ImContentType.FILE,
+ ImContentType.IMAGE,
+ ImContentType.MATERIAL, // 棰戦亾绱犳潗璁″叆鏈鏁� + 杩涗細璇濆垪琛�
+ ImContentType.MERGE,
+ ImContentType.TEXT,
+ ImContentType.VIDEO,
+ ImContentType.VOICE
+])
+
+/** 鍒ゆ柇鏄惁"鏅�氭秷鎭�" */
+export function isNormalMessage(type: number): boolean {
+ return ImContentTypeNormals.has(type)
+}
+
+/** IM 濯掍綋鍐呭绫诲瀷闆嗗悎锛氬彂閫佷緷璧栨湰鍦� File 涓婁紶锛屽埛鏂板悗 _localFile 涓㈠け鍗充笉鍙仮澶� */
+const ImContentTypeMedia = new Set<number>([
+ ImContentType.FILE,
+ ImContentType.IMAGE,
+ ImContentType.VIDEO,
+ ImContentType.VOICE
+])
+
+/** 鍒ゆ柇鏄惁銆屽獟浣撴秷鎭�嶏細鍥剧墖 / 鏂囦欢 / 璇煶 / 瑙嗛 */
+export function isMediaMessageType(type: number): boolean {
+ return ImContentTypeMedia.has(type)
+}
+
+/**
+ * IM 娑堟伅鐘舵�佹灇涓撅紙瀵归綈鍚庣 ImMessageStatusEnum锛屽墠绔墿灞� SENDING + FAILED锛�
+ *
+ * 鐢熷懡鍛ㄦ湡锛欶AILED(-2) 鈫� SENDING(-1) 鈫� NORMAL(0) 鈫� RECALL(2)
+ */
+export const ImMessageStatus = {
+ FAILED: -2, // 鍙戦�佸け璐ワ紙鍓嶇鐙湁锛�
+ SENDING: -1, // 鍙戦�佷腑锛堝墠绔嫭鏈夛級
+ NORMAL: 0, // 姝e父
+ RECALL: 2 // 宸叉挙鍥�
+} as const
+
+/** IM 浼氳瘽绫诲瀷鏋氫妇 */
+export const ImConversationType = {
+ NONE: 0, // 鏃犱細璇�
+ PRIVATE: 1, // 绉佽亰
+ GROUP: 2, // 缇よ亰
+ CHANNEL: 3 // 棰戦亾 / 鍏紬鍙�
+} as const
+
+/** ImConversationType 鍙栧�硷紙鐢ㄤ簬娑堟伅 payload 瀛楁绫诲瀷鏀剁獎锛� */
+export type ImConversationTypeValue = (typeof ImConversationType)[keyof typeof ImConversationType]
+
+/** 鏄惁绉佽亰浼氳瘽锛涘悓鏃舵敹绐勭被鍨� */
+export function isPrivateConversation(type: number | undefined): boolean {
+ return type === ImConversationType.PRIVATE
+}
+
+/** 鏄惁缇よ亰浼氳瘽锛涘悓鏃舵敹绐勭被鍨� */
+export function isGroupConversation(type: number | undefined): boolean {
+ return type === ImConversationType.GROUP
+}
+
+/** 鏄惁棰戦亾浼氳瘽锛涘悓鏃舵敹绐勭被鍨� */
+export function isChannelConversation(type: number | undefined): boolean {
+ return type === ImConversationType.CHANNEL
+}
+
+/** IM 閫氳瘽濯掍綋绫诲瀷锛堝榻愬悗绔� ImRtcCallMediaTypeEnum锛� */
+export const ImRtcCallMediaType = {
+ VOICE: 1,
+ VIDEO: 2
+} as const
+
+/** IM 閫氳瘽鐘舵�侊紙瀵归綈鍚庣 ImRtcCallStatusEnum锛� */
+export const ImRtcCallStatus = {
+ CREATED: 10, // 鍒涘缓锛氱鑱婄瓑琚彨鎺ュ惉锛涚兢鑱婂彂璧蜂汉宸茶繘鎴跨瓑鍏朵粬浜哄姞鍏�
+ RUNNING: 20, // 杩涜涓細绗竴涓潪鍙戣捣浜烘帴閫氬悗杩涘叆
+ ENDED: 30 // 宸茬粨鏉�
+} as const
+
+/** IM 閫氳瘽缁撴潫鍘熷洜锛堝榻愬悗绔� ImRtcCallEndReasonEnum锛� */
+export const ImRtcCallEndReason = {
+ HANGUP: 1, // 鎺ラ�氬悗浠讳竴鏂逛富鍔ㄦ寕鏂�
+ REJECT: 2, // 琚彨鎺ラ�氬墠鐐规嫆鎺�
+ CANCEL: 3, // 涓诲彨鎺ラ�氬墠涓诲姩鍙栨秷
+ NO_ANSWER: 4, // 鎸搩瓒呮椂鏈帴閫�
+ BUSY: 5, // 绉佽亰鍛煎彨鏃跺鏂规蹇�
+ ERROR: 9 // 缃戠粶涓柇 / 璁惧澶辫触
+} as const
+
+/** ImRtcCallEndReason 鍙栧�肩被鍨� */
+export type ImRtcCallEndReasonValue = (typeof ImRtcCallEndReason)[keyof typeof ImRtcCallEndReason]
+
+/** IM 閫氳瘽鍙備笌鑰呯姸鎬侊紙瀵归綈鍚庣 ImRtcParticipantStatusEnum锛夛紱鍚屾椂浣滀负 RTC_CALL 淇′护 status 瀛楁鍙栧�� */
+export const ImRtcParticipantStatus = {
+ INVITING: 10, // 鏉ョ數閭�璇�
+ JOINED: 20, // 鎺ュ惉 / 宸插姞鍏�
+ REJECTED: 30, // 鎷掓帴
+ NO_ANSWER: 40, // 涓诲彨鍙栨秷锛岃閭�璇锋柟鏈簲绛�
+ LEFT: 50 // 鎸傛柇绂诲紑
+} as const
+
+/** ImRtcParticipantStatus 鍙栧�肩被鍨� */
+export type ImRtcParticipantStatusValue =
+ (typeof ImRtcParticipantStatus)[keyof typeof ImRtcParticipantStatus]
+
+/**
+ * IM 閫氳瘽 UI 闃舵锛涘墠绔嫭鏈夛紝鐢ㄤ簬椹卞姩 inviting / incoming / running 涓夌寮圭獥鍒囨崲锛�
+ * 璺熷悗绔� ImRtcCallStatus 涓嶆槸 1:1 鏄犲皠锛宻tage 澶氫簡銆岃嚜宸辨槸涓诲彨杩樻槸琚彨銆嶇殑瑙掕壊缁村害
+ */
+export const ImRtcCallStage = {
+ IDLE: 'idle', // 绌洪棽锛涘悗绔棤瀵瑰簲锛堟湰绔棤娲昏穬閫氳瘽锛�
+ INVITING: 'inviting', // 涓诲彨绛夊緟瀵规柟鎺ュ彈锛涘搴斿悗绔� ImRtcCallStatus.CREATED锛堣嚜宸辨槸涓诲彨锛�
+ INCOMING: 'incoming', // 琚彨鏉ョ數鍝嶉搩锛涘搴斿悗绔� ImRtcCallStatus.CREATED锛堣嚜宸辨槸琚彨锛�
+ RUNNING: 'running' // 閫氳瘽涓紱瀵瑰簲鍚庣 ImRtcCallStatus.RUNNING
+} as const
+
+/** ImRtcCallStage 鍙栧�肩被鍨� */
+export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage]
+
+/** IM WebSocket 澶栧眰甯х被鍨� */
+export const ImWebSocketMessageType = {
+ NOTIFICATION: 'im-notification' // IM 閫氱煡
+} as const
+
+/** IM 娑堟伅鍥炴墽鐘舵�佹灇涓撅紙瀵归綈鍚庣 ImMessageReceiptStatusEnum锛� */
+export const ImMessageReceiptStatus = {
+ NO_RECEIPT: 0, // 涓嶉渶瑕佸洖鎵�
+ PENDING: 1, // 寰呭畬鎴�
+ DONE: 2 // 宸插畬鎴�
+} as const
+
+/** 缇ゆ垚鍛樿鑹诧紙瀵归綈鍚庣 ImGroupMemberRoleEnum锛� */
+export const ImGroupMemberRole = {
+ OWNER: 1, // 缇や富
+ ADMIN: 2, // 绠$悊鍛�
+ NORMAL: 3 // 鏅�氭垚鍛�
+} as const
+
+/** 鍔犵兢鏉ユ簮锛堝榻愬悗绔� ImGroupAddSourceEnum锛� */
+export const ImGroupAddSource = {
+ SEARCH: 1, // 鎼滅储
+ INVITE: 2, // 閭�璇�
+ QR_CODE: 3, // 鎵爜
+ SHARE_LINK: 4 // 鍒嗕韩閾炬帴
+} as const
+
+/** 鍔犵兢鐢宠澶勭悊缁撴灉锛堝榻愬悗绔� ImGroupRequestHandleResultEnum锛� */
+export const ImGroupRequestHandleResult = {
+ UNHANDLED: 0, // 鏈鐞�
+ AGREED: 1, // 鍚屾剰
+ REFUSED: 2 // 鎷掔粷
+} as const
+
+/** 濂藉弸娣诲姞鏉ユ簮锛堝榻愬悗绔� ImFriendAddSourceEnum锛� */
+export const ImFriendAddSource = {
+ SEARCH: 1, // 鎼滅储
+ GROUP: 2, // 缇よ亰
+ QR_CODE: 3, // 鎵爜
+ CARD: 4 // 鍚嶇墖
+} as const
+
+/** 濂藉弸鐢宠澶勭悊缁撴灉锛堝榻愬悗绔� ImFriendRequestHandleResultEnum锛� */
+export const ImFriendRequestHandleResult = {
+ UNHANDLED: 0, // 鏈鐞�
+ AGREED: 1, // 鍚屾剰
+ REFUSED: 2 // 鎷掔粷
+} as const
+
+/**
+ * @鍏ㄤ綋鎴愬憳 鐨勭壒娈� userId 鏍囪瘑锛歛tUserIds 涓寘鍚� -1 琛ㄧず @ 鍏ㄤ綋鎴愬憳
+ *
+ * 涓庡悗绔害瀹氾細缇ゆ秷鎭� atUserIds 鏁扮粍閲屽嚭鐜� -1 鏃讹紝鎵�鏈夋垚鍛橀兘鏀跺埌鎻愰啋
+ * MentionPicker 娓叉煋铏氭嫙椤� + conversationStore.applyAt 鍒ゅ畾 atAll 閮介潬杩欎釜鍊�
+ */
+export const IM_AT_ALL_USER_ID = -1
+
+/** @鍏ㄤ綋鎴愬憳 鐨勫睍绀哄悕锛堝榻愬井淇� PC锛� */
+export const IM_AT_ALL_NICKNAME = '鎵�鏈変汉'
+
+/** 杞彂妯″紡锛歋INGLE 閫愭潯鍘熸牱杞� / MERGE 鎵撳寘鎴� MergeMessage */
+export const ImForwardMode = {
+ SINGLE: 'single',
+ MERGE: 'merge'
+} as const
+
+/** ImForwardMode 鍙栧�肩被鍨� */
+export type ImForwardModeValue = (typeof ImForwardMode)[keyof typeof ImForwardMode]
diff --git a/src/views/im/utils/conversation.ts b/src/views/im/utils/conversation.ts
new file mode 100644
index 0000000..f846705
--- /dev/null
+++ b/src/views/im/utils/conversation.ts
@@ -0,0 +1,177 @@
+// ====================================================================
+// IM 浼氳瘽 / 鎾ゅ洖灞曠ず utility
+// ====================================================================
+// 鍩轰簬 conversation 涓婁笅鏂� + sender 淇℃伅瀹炴椂绠�"灞曠ず鏂囨"銆�
+// 1. 鍙戦�佷汉鍚嶄竴寰嬬敱 utils/user.getSenderDisplayName 瀹炴椂绠楋紝鏈枃浠跺彧璐熻矗鎷�"鎾ゅ洖 tip / 鎽樿"绛夋枃妗堛��
+// 2. fallbackName 鐢辫皟鐢ㄦ柟浼犲叆锛堝吀鍨嬫潵婧愶細Conversation.lastSenderDisplayName 蹇収锛夛紝閫忎紶鍒� getSenderDisplayName 鍐呴儴锛岀畻涓嶅嚭鐪熷悕鏃跺厹搴�
+// ====================================================================
+
+import type { Message } from '../home/types'
+
+import { ImContentType, ImConversationType, isFriendChatTip, isGroupNotification, isRtcCallTip } from './constants'
+import {
+ type CardMessage,
+ type FaceMessage,
+ type FileMessage,
+ getCardLabelInfo,
+ type MaterialMessage,
+ parseMessage,
+ segmentsToText,
+ type TextMessage,
+ tipMention,
+ type TipSegment,
+ tipText
+} from './message'
+import { resolveFriendNotificationText, resolveGroupNotificationText, resolveRtcCallLastContent } from './message'
+import { getSenderDisplayName } from './user'
+
+/** 浼氳瘽涓婚敭锛歚type-targetId` 鎷兼垚绋冲畾瀛楃涓诧紝缁� v-for :key銆乤ctive 姣斿銆乵ap key 绛夊満鏅叡鐢� */
+export function getConversationKey(conversation: { targetId: number; type: number; }): string {
+ return `${conversation.type}-${conversation.targetId}`
+}
+
+/** 鎸夋樀绉版ā绯婅繃婊や細璇濆垪琛細绌� keyword 鍘熸牱杩斿洖锛屽懡涓蛋 toLowerCase 涓嶅尯鍒嗗ぇ灏忓啓 */
+export function filterConversationsByKeyword<T extends { name?: string }>(
+ list: T[],
+ keyword: string
+): T[] {
+ const trimmed = keyword.trim().toLowerCase()
+ if (!trimmed) {
+ return list
+ }
+ return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed))
+}
+
+/**
+ * 琛ㄦ儏娑堟伅鐨勭粺涓�鏂囨湰棰勮锛氭憳瑕� / 鍘嗗彶鎼滅储 / 寮曠敤鍧� / 绠$悊鍚庡彴棰勮鍏辩敤涓�浠�
+ * 鏈� name 鏃惰蛋 `[琛ㄦ儏] name`锛堢郴缁熷寘锛夛紝鏃� name 鏃惰蛋 `[琛ㄦ儏]`锛堜釜浜鸿〃鎯呴�氬父鏃� name锛�
+ */
+export function buildFacePreviewText(facePayload: null | undefined | { name?: string }): string {
+ return facePayload?.name ? `[琛ㄦ儏] ${facePayload.name}` : '[琛ㄦ儏]'
+}
+
+/**
+ * 鎾ゅ洖鎻愮ず segments
+ *
+ * senderId 缂哄け鏃朵笉鎸� mention 娈碉紝閬垮厤鐐瑰嚭閿欏彿锛涚畻涓嶅嚭鐪熷悕闄嶇骇涓恒�屽鏂广�嶇函鏂囨湰
+ */
+export function buildRecallTipSegments(
+ senderId: number,
+ selfSend: boolean,
+ conversationType: number,
+ conversationTargetId: number,
+ fallbackName?: string
+): TipSegment[] {
+ if (selfSend) {
+ return [tipText('浣犳挙鍥炰簡涓�鏉℃秷鎭�')]
+ }
+ const senderDisplayName = getSenderDisplayName(
+ senderId,
+ conversationType,
+ conversationTargetId,
+ fallbackName
+ )
+ if (!senderId) {
+ return [tipText(`${senderDisplayName || '瀵规柟'} 鎾ゅ洖浜嗕竴鏉℃秷鎭痐)]
+ }
+ return [
+ tipMention(senderId, senderDisplayName || '瀵规柟'),
+ tipText(' 鎾ゅ洖浜嗕竴鏉℃秷鎭�')
+ ]
+}
+
+/** 鎾ゅ洖鎻愮ず鏂囨锛氳嚜宸辨挙鍥炲浐瀹氭枃妗堬紝瀵规柟鎾ゅ洖甯� sender 鍚嶏紙瀹炴椂绠� + fallbackName 鍏滃簳锛� */
+export function buildRecallTip(
+ senderId: number,
+ selfSend: boolean,
+ conversationType: number,
+ conversationTargetId: number,
+ fallbackName?: string
+): string {
+ return segmentsToText(
+ buildRecallTipSegments(senderId, selfSend, conversationType, conversationTargetId, fallbackName)
+ )
+}
+
+/**
+ * 鍗曟潯娑堟伅鐨勭函鏂囨湰鎽樿锛涗笉渚濊禆 conversation 涓婁笅鏂�
+ *
+ * - withFileName=true 鏃舵枃浠舵秷鎭檮甯︽枃浠跺悕锛堝悎骞惰浆鍙戣鎯呯敤锛�
+ * - 涓嶅鐞� RECALL / 缇ゅ箍鎾� / 濂藉弸閫氱煡锛岀敱璋冪敤鏂瑰寘涓�灞�
+ */
+export function summarizeMessageContent(
+ message: Pick<Message, 'content' | 'type'>,
+ opts?: { withFileName?: boolean }
+): string {
+ switch (message.type) {
+ case ImContentType.CARD: {
+ return `[${getCardLabelInfo(parseMessage<CardMessage>(message.content)).label}]`
+ }
+ case ImContentType.FACE: {
+ return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
+ }
+ case ImContentType.FILE: {
+ if (opts?.withFileName) {
+ const file = parseMessage<FileMessage>(message.content)
+ return file?.name ? `[鏂囦欢] ${file.name}` : '[鏂囦欢]'
+ }
+ return '[鏂囦欢]'
+ }
+ case ImContentType.IMAGE: {
+ return '[鍥剧墖]'
+ }
+ case ImContentType.MATERIAL: {
+ const material = parseMessage<MaterialMessage>(message.content)
+ return material?.title ? `[棰戦亾] ${material.title}` : '[棰戦亾]'
+ }
+ case ImContentType.MERGE: {
+ return '[鑱婂ぉ璁板綍]'
+ }
+ case ImContentType.RTC_CALL_END:
+ case ImContentType.RTC_CALL_START: {
+ return '[璇煶閫氳瘽]'
+ }
+ case ImContentType.TEXT: {
+ return parseMessage<TextMessage>(message.content)?.content ?? ''
+ }
+ case ImContentType.VIDEO: {
+ return '[瑙嗛]'
+ }
+ case ImContentType.VOICE: {
+ return '[璇煶]'
+ }
+ default: {
+ return ''
+ }
+ }
+}
+
+/** 浼氳瘽鍒楄〃鏈�鍚庝竴鏉℃憳瑕侊細RECALL 璧� buildRecallTip + fallbackName锛涘叾瀹冩寜鍐呭绫诲瀷娲剧敓 */
+export function resolveConversationLastContent(
+ message: Message,
+ conversationType: number,
+ conversationTargetId: number,
+ fallbackName?: string
+): string {
+ if (message.type === ImContentType.RECALL) {
+ return buildRecallTip(
+ message.senderId,
+ message.selfSend,
+ conversationType,
+ conversationTargetId,
+ fallbackName
+ )
+ }
+ if (isFriendChatTip(message.type)) {
+ return resolveFriendNotificationText(message)
+ }
+ if (isGroupNotification(message.type)) {
+ return resolveGroupNotificationText(message, (id) =>
+ getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
+ )
+ }
+ if (isRtcCallTip(message.type)) {
+ return resolveRtcCallLastContent(message, conversationType)
+ }
+ return summarizeMessageContent(message)
+}
diff --git a/src/views/im/utils/db.ts b/src/views/im/utils/db.ts
new file mode 100644
index 0000000..aabf4e9
--- /dev/null
+++ b/src/views/im/utils/db.ts
@@ -0,0 +1,541 @@
+import type { MessageDO, SettingDO } from '../home/types'
+
+import { toRaw } from 'vue'
+
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import { ImConversationType } from './constants'
+
+export const DB_SCHEMA_VERSION = 2
+
+export type DbStoreName =
+ | 'channels'
+ | 'conversationReads'
+ | 'conversations'
+ | 'friendRequests'
+ | 'friends'
+ | 'groupMembers'
+ | 'groupRequests'
+ | 'groups'
+ | 'messages'
+ | 'settings'
+
+export type DbTransaction = IDBTransaction
+
+/** IM 鏈湴瀛樺偍 key */
+export const StorageKeys = {
+ localStorage: {
+ /** 渚ц竟鏍忓搴︼紝涓変釜 Tab 鍏辩敤涓�浠借蹇� */
+ asideWidth: 'im:aside',
+ /** 浼氳瘽鍒楄〃缃《鎶樺彔灞曞紑鎬� */
+ conversationPinnedExpanded: 'im:conversation:pinnedExpanded'
+ },
+ settings: {
+ /** 绉佽亰娑堟伅鎷夊彇娓告爣 */
+ privateMessageMaxId: 'privateMessageMaxId',
+ /** 缇よ亰娑堟伅鎷夊彇娓告爣 */
+ groupMessageMaxId: 'groupMessageMaxId',
+ /** 棰戦亾娑堟伅鎷夊彇娓告爣 */
+ channelMessageMaxId: 'channelMessageMaxId',
+ /** 鏈�杩戣浆鍙戜細璇� key 鍒楄〃 */
+ recentForwardConversationKeys: 'recentForwardConversationKeys',
+ // 鐘舵�佷簨浠惰ˉ鍋垮閲忔媺鍙栨父鏍囷細涓庝笂闈㈡秷鎭� maxId 娓告爣鍏辩敤鍚屼竴 settings keyspace锛岀粺涓�鐧昏鍦ㄦ閬垮厤鎾� key锛�
+ // 璧� update_time + id 澶嶅悎娓告爣锛堥潪鍗曟潯 maxId锛夛紝鏁呯敤 PullCursor 鍚庣紑鍖哄垎璇箟
+ /** 濂藉弸鍏崇郴澧為噺鎷夊彇娓告爣 */
+ friendPullCursor: 'friendPullCursor',
+ /** 濂藉弸鐢宠澧為噺鎷夊彇娓告爣 */
+ friendRequestPullCursor: 'friendRequestPullCursor',
+ /** 鍔犵兢鐢宠澧為噺鎷夊彇娓告爣 */
+ groupRequestPullCursor: 'groupRequestPullCursor',
+ /** 浼氳瘽璇讳綅缃閲忔媺鍙栨父鏍� */
+ conversationReadPullCursor: 'conversationReadPullCursor'
+ }
+} as const
+
+let currentDb: IDBDatabase | null = null
+let currentUserId: null | number = null
+let currentSession = 0
+
+/** 鏍¢獙褰撳墠 IM IndexedDB session 浠嶆湁鏁� */
+export function isCurrentDbSession(session: number): boolean {
+ return session === currentSession
+}
+
+/** 鑾峰彇褰撳墠 IM IndexedDB session */
+export function getDbSession(): number {
+ return currentSession
+}
+
+/** 鎷兼帴褰撳墠鐢ㄦ埛 IM DB 鍚嶇О */
+function getDbName(userId: number): string {
+ return `im:${userId}`
+}
+
+/** 鍖呰 IndexedDB request */
+function requestToPromise<T = unknown>(request: IDBRequest<T>): Promise<T> {
+ return new Promise((resolve, reject) => {
+ request.addEventListener('success', () => resolve(request.result))
+ request.addEventListener('error', () => reject(request.error))
+ })
+}
+
+/** 绛夊緟浜嬪姟瀹屾垚 */
+function transactionDone(transaction: DbTransaction): Promise<void> {
+ return new Promise((resolve, reject) => {
+ transaction.addEventListener('complete', () => resolve())
+ transaction.addEventListener('error', () => reject(transaction.error))
+ transaction.addEventListener('abort', () => reject(transaction.error))
+ })
+}
+
+/** 鍒涘缓绱㈠紩 */
+function createIndex(
+ store: IDBObjectStore,
+ name: string,
+ keyPath: string | string[],
+ options?: IDBIndexParameters
+) {
+ if (!store.indexNames.contains(name)) {
+ store.createIndex(name, keyPath, options)
+ }
+}
+
+/** 鍒濆鍖� schema */
+function upgradeSchema(db: IDBDatabase) {
+ if (!db.objectStoreNames.contains('conversations')) {
+ const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' })
+ createIndex(store, 'lastSendTime', 'lastSendTime')
+ }
+ if (!db.objectStoreNames.contains('conversationReads')) {
+ const store = db.createObjectStore('conversationReads', { keyPath: 'clientConversationId' })
+ createIndex(store, 'conversationType+targetId', ['conversationType', 'targetId'], {
+ unique: true
+ })
+ }
+ if (!db.objectStoreNames.contains('messages')) {
+ const store = db.createObjectStore('messages', { keyPath: 'messageKey' })
+ createIndex(store, 'clientConversationId', 'clientConversationId')
+ createIndex(store, 'clientConversationId+sendTime', ['clientConversationId', 'sendTime'])
+ createIndex(store, 'clientMessageId', 'clientMessageId', { unique: true })
+ }
+ if (!db.objectStoreNames.contains('friends')) {
+ const store = db.createObjectStore('friends', { keyPath: 'id' })
+ createIndex(store, 'friendUserId', 'friendUserId', { unique: true })
+ createIndex(store, 'status', 'status')
+ }
+ if (!db.objectStoreNames.contains('friendRequests')) {
+ const store = db.createObjectStore('friendRequests', { keyPath: 'id' })
+ createIndex(store, 'status', 'status')
+ createIndex(store, 'createTime', 'createTime')
+ }
+ if (!db.objectStoreNames.contains('groups')) {
+ const store = db.createObjectStore('groups', { keyPath: 'id' })
+ createIndex(store, 'name', 'name')
+ createIndex(store, 'status', 'status')
+ }
+ if (!db.objectStoreNames.contains('groupMembers')) {
+ const store = db.createObjectStore('groupMembers', { keyPath: 'id' })
+ createIndex(store, 'groupId', 'groupId')
+ createIndex(store, 'groupId+userId', ['groupId', 'userId'], { unique: true })
+ }
+ if (!db.objectStoreNames.contains('groupRequests')) {
+ const store = db.createObjectStore('groupRequests', { keyPath: 'id' })
+ createIndex(store, 'status', 'status')
+ createIndex(store, 'createTime', 'createTime')
+ }
+ if (!db.objectStoreNames.contains('channels')) {
+ const store = db.createObjectStore('channels', { keyPath: 'id' })
+ createIndex(store, 'status', 'status')
+ createIndex(store, 'sort', 'sort')
+ }
+ if (!db.objectStoreNames.contains('settings')) {
+ db.createObjectStore('settings', { keyPath: 'key' })
+ }
+}
+
+/** 鎵撳紑 IM IndexedDB */
+function openDb(name: string): Promise<IDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(name, DB_SCHEMA_VERSION)
+ // 鍒涘缓鎴栧崌绾у璞′粨搴�
+ request.addEventListener('upgradeneeded', () => upgradeSchema(request.result))
+ // 杩斿洖鍙鐢ㄨ繛鎺�
+ request.addEventListener('success', () => resolve(request.result))
+ request.addEventListener('error', () => reject(request.error))
+ })
+}
+
+/** 鍒濆鍖栧綋鍓嶇敤鎴� IM DB */
+export async function initDb(): Promise<void> {
+ const userId = getCurrentUserId()
+ if (!Number.isFinite(userId) || userId <= 0) {
+ throw new Error('褰撳墠鐢ㄦ埛涓嶅瓨鍦紝鏃犳硶鍒濆鍖� IM DB')
+ }
+ if (currentDb && currentUserId === userId) {
+ return
+ }
+ currentDb?.close()
+ currentSession++
+ currentUserId = userId
+ currentDb = await openDb(getDbName(userId))
+}
+
+/** 鍏抽棴褰撳墠 IM DB 杩炴帴 */
+function closeDbConnection() {
+ currentDb?.close()
+ currentDb = null
+ currentUserId = null
+}
+
+/** 鑾峰彇褰撳墠 IM DB */
+function getRawDb(): IDBDatabase {
+ if (!currentDb) {
+ throw new Error('IM DB 鏈垵濮嬪寲')
+ }
+ return currentDb
+}
+
+/** 鏍¢獙鍗曟鍐欏叆 session */
+function guardSession(session: number) {
+ if (!isCurrentDbSession(session)) {
+ throw new Error('IM DB session 宸插け鏁�')
+ }
+}
+
+/** 鍏嬮殕鍙叆搴撳璞� */
+function toDbValue<T>(value: T): T {
+ return cloneDbValue(value) as T
+}
+
+/** 杞崲涓� IndexedDB 鍙厠闅嗗璞� */
+function cloneDbValue(value: unknown): unknown {
+ const raw = toRaw(value)
+ if (Array.isArray(raw)) {
+ return raw.map((item) => cloneDbValue(item))
+ }
+ if (!raw || typeof raw !== 'object') {
+ return raw
+ }
+ const prototype = Object.getPrototypeOf(raw)
+ if (prototype !== Object.prototype && prototype !== null) {
+ return raw
+ }
+ return Object.fromEntries(
+ Object.entries(raw as Record<string, unknown>).map(([key, item]) => [key, cloneDbValue(item)])
+ )
+}
+
+class DbClient {
+ /** 娓呯┖ store 璁板綍 */
+ async clearStore(storeName: DbStoreName, tx?: DbTransaction): Promise<void> {
+ if (tx) {
+ await requestToPromise(tx.objectStore(storeName).clear())
+ return
+ }
+ await this.transaction([storeName], 'readwrite', (tx) => this.clearStore(storeName, tx))
+ }
+
+ /** 鍒犻櫎璁板綍 */
+ async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTransaction): Promise<void> {
+ if (tx) {
+ await requestToPromise(tx.objectStore(storeName).delete(key))
+ return
+ }
+ await this.transaction([storeName], 'readwrite', (tx) => this.delete(storeName, key, tx))
+ }
+
+ /** 鎸夌储寮曞垹闄よ褰� */
+ async deleteByIndex(
+ storeName: DbStoreName,
+ indexName: string,
+ query: IDBKeyRange | IDBValidKey,
+ tx?: DbTransaction
+ ): Promise<void> {
+ if (!tx) {
+ await this.transaction([storeName], 'readwrite', (tx) =>
+ this.deleteByIndex(storeName, indexName, query, tx)
+ )
+ return
+ }
+ const index = tx.objectStore(storeName).index(indexName)
+ await new Promise<void>((resolve, reject) => {
+ const request = index.openCursor(query)
+ request.addEventListener('error', () => reject(request.error))
+ request.addEventListener('success', () => {
+ const cursor = request.result
+ if (!cursor) {
+ resolve()
+ return
+ }
+ cursor.delete()
+ cursor.continue()
+ })
+ })
+ }
+
+ /** 鑾峰彇鍗曟潯璁板綍 */
+ async get<T>(
+ storeName: DbStoreName,
+ key: IDBValidKey,
+ tx?: DbTransaction
+ ): Promise<T | undefined> {
+ if (tx) {
+ return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key))
+ }
+ return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
+ this.get<T>(storeName, key, tx)
+ )
+ }
+
+ /** 鑾峰彇 store 鍏ㄩ噺璁板綍 */
+ async getAll<T>(storeName: DbStoreName, tx?: DbTransaction): Promise<T[]> {
+ if (tx) {
+ return requestToPromise<T[]>(tx.objectStore(storeName).getAll())
+ }
+ return this.transaction<T[]>([storeName], 'readonly', (tx) => this.getAll<T>(storeName, tx))
+ }
+
+ /** 鎸夌储寮曡幏鍙栬褰曞垪琛� */
+ async getAllByIndex<T>(
+ storeName: DbStoreName,
+ indexName: string,
+ query?: IDBKeyRange | IDBValidKey,
+ tx?: DbTransaction
+ ): Promise<T[]> {
+ if (tx) {
+ return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query))
+ }
+ return this.transaction<T[]>([storeName], 'readonly', (tx) =>
+ this.getAllByIndex<T>(storeName, indexName, query, tx)
+ )
+ }
+
+ /** 鎸夊敮涓�绱㈠紩鑾峰彇鍗曟潯璁板綍 */
+ async getByIndex<T>(
+ storeName: DbStoreName,
+ indexName: string,
+ query: IDBKeyRange | IDBValidKey,
+ tx?: DbTransaction
+ ): Promise<T | undefined> {
+ if (tx) {
+ return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query))
+ }
+ return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
+ this.getByIndex<T>(storeName, indexName, query, tx)
+ )
+ }
+
+ /** 鎸変細璇濆垎椤佃幏鍙栨秷鎭� */
+ async getMessageListByConversation(
+ clientConversationId: string,
+ options?: { beforeSendTime?: number; limit?: number },
+ tx?: DbTransaction
+ ): Promise<MessageDO[]> {
+ const limit = options?.limit ?? 50
+ const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
+ const range = IDBKeyRange.bound(
+ [clientConversationId, 0],
+ [clientConversationId, upper],
+ false,
+ true
+ )
+ const read = async (tx: DbTransaction): Promise<MessageDO[]> => {
+ const index = tx.objectStore('messages').index('clientConversationId+sendTime')
+ const out: MessageDO[] = []
+ await new Promise<void>((resolve, reject) => {
+ // 浠庢柊鍒版棫璇诲彇涓�椤�
+ const request = index.openCursor(range, 'prev')
+ request.addEventListener('error', () => reject(request.error))
+ request.addEventListener('success', () => {
+ const cursor = request.result
+ if (!cursor || out.length >= limit) {
+ resolve()
+ return
+ }
+ out.push(cursor.value as MessageDO)
+ cursor.continue()
+ })
+ })
+ // 姘旀场娓叉煋闇�瑕佹寜鏃堕棿鍗囧簭
+ return out.toReversed()
+ }
+ if (tx) {
+ return read(tx)
+ }
+ return this.transaction<MessageDO[]>(['messages'], 'readonly', read)
+ }
+
+ /** 璇诲彇璁剧疆 */
+ async getSetting<T>(key: string, tx?: DbTransaction): Promise<T | undefined> {
+ const item = await this.get<SettingDO<T>>('settings', key, tx)
+ return item?.value
+ }
+
+ /** 鍐欏叆璁板綍 */
+ async put<T>(storeName: DbStoreName, value: T, tx?: DbTransaction): Promise<void> {
+ if (tx) {
+ await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
+ return
+ }
+ await this.transaction([storeName], 'readwrite', (tx) => this.put(storeName, value, tx))
+ }
+
+ /** 鍐欏叆璁剧疆 */
+ async setSetting<T>(key: string, value: T, tx?: DbTransaction): Promise<void> {
+ await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx)
+ }
+
+ /** 鎵ц浜嬪姟 */
+ async transaction<T>(
+ storeNames: DbStoreName[],
+ mode: IDBTransactionMode,
+ runner: (tx: DbTransaction) => Promise<T>
+ ): Promise<T> {
+ // 寮�鍚簨鍔″墠鏍¢獙 session
+ const session = getDbSession()
+ guardSession(session)
+ const tx = getRawDb().transaction(storeNames, mode)
+ const done = transactionDone(tx)
+ let result: T
+ try {
+ // 浜嬪姟鍐呭彧鎵ц IndexedDB request 閾�
+ result = await runner(tx)
+ } catch (error) {
+ try {
+ tx.abort()
+ } catch {}
+ await done.catch(() => undefined)
+ throw error
+ }
+ // commit 鍚庡啀娆℃牎楠� session
+ await done
+ guardSession(session)
+ return result
+ }
+}
+
+const dbClient = new DbClient()
+
+/** 鑾峰彇褰撳墠 IM DB client */
+export function getDb(): DbClient {
+ return dbClient
+}
+
+/** 褰撳墠鐢ㄦ埛浼氳瘽涓婚敭 */
+export function getClientConversationId(type: number, targetId: number): string {
+ return `${type}:${targetId}`
+}
+
+/** 瑙f瀽褰撳墠鐢ㄦ埛浼氳瘽涓婚敭 */
+export function parseClientConversationId(
+ clientConversationId: string
+): null | { targetId: number; type: number; } {
+ const [typeText, targetIdText] = clientConversationId.split(':')
+ const type = Number(typeText)
+ const targetId = Number(targetIdText)
+ if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
+ return null
+ }
+ return { type, targetId }
+}
+
+/** 鏈嶅姟绔秷鎭富閿� */
+export function getServerMessageKey(conversationType: number, id: number): string {
+ return `${conversationType}:${id}`
+}
+
+/** 瀹㈡埛绔复鏃舵秷鎭富閿� */
+export function getClientMessageKey(clientMessageId: string): string {
+ return `client:${clientMessageId}`
+}
+
+/** 瑙f瀽鏈湴娑堟伅涓婚敭 */
+export function parseMessageKey(
+ messageKey: string
+):
+ | null
+ | { clientMessageId: string; kind: 'client'; }
+ | { conversationType: number; id: number; kind: 'server'; } {
+ if (!messageKey) {
+ return null
+ }
+ if (messageKey.startsWith('client:')) {
+ const clientMessageId = messageKey.slice('client:'.length)
+ return clientMessageId ? { kind: 'client', clientMessageId } : null
+ }
+ const [conversationTypeText, idText] = messageKey.split(':')
+ const conversationType = Number(conversationTypeText)
+ const id = Number(idText)
+ if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) {
+ return null
+ }
+ return { kind: 'server', conversationType, id }
+}
+
+/** 鏇存柊娑堟伅鎷夊彇娓告爣 */
+export async function setMessageMaxId(
+ conversationType: number,
+ maxId: number | undefined,
+ tx?: DbTransaction
+): Promise<void> {
+ if (!maxId) {
+ return
+ }
+ let key: string
+ switch (conversationType) {
+ case ImConversationType.CHANNEL: {
+ key = StorageKeys.settings.channelMessageMaxId
+ break
+ }
+ case ImConversationType.GROUP: {
+ key = StorageKeys.settings.groupMessageMaxId
+ break
+ }
+ case ImConversationType.PRIVATE: {
+ key = StorageKeys.settings.privateMessageMaxId
+ break
+ }
+ default: {
+ throw new Error(`鏈煡 IM 浼氳瘽绫诲瀷锛�${conversationType}`)
+ }
+ }
+ const db = getDb()
+ const current = (await db.getSetting<number>(key, tx)) || 0
+ if (maxId > current) {
+ await db.setSetting(key, maxId, tx)
+ }
+}
+
+/** 鍋滄褰撳墠 IM DB session */
+export async function stopRequests(): Promise<void> {
+ currentSession++
+ const [
+ { useMessageStoreWithOut },
+ { useConversationStoreWithOut },
+ { useFriendStoreWithOut },
+ { useGroupStoreWithOut },
+ { useChannelStoreWithOut },
+ { useGroupRequestStoreWithOut },
+ { useFaceStoreWithOut },
+ { useRtcStore }
+ ] = await Promise.all([
+ import('../home/store/messageStore'),
+ import('../home/store/conversationStore'),
+ import('../home/store/friendStore'),
+ import('../home/store/groupStore'),
+ import('../home/store/channelStore'),
+ import('../home/store/groupRequestStore'),
+ import('../home/store/faceStore'),
+ import('../home/store/rtcStore')
+ ])
+ useMessageStoreWithOut().clear()
+ useConversationStoreWithOut().clear()
+ useFriendStoreWithOut().clear()
+ useGroupStoreWithOut().clear()
+ useChannelStoreWithOut().clear()
+ useGroupRequestStoreWithOut().clear()
+ useFaceStoreWithOut().clear()
+ useRtcStore().reset()
+ useRtcStore().clearGroupCallCache()
+ closeDbConnection()
+}
diff --git a/src/views/im/utils/emoji.ts b/src/views/im/utils/emoji.ts
new file mode 100644
index 0000000..2a25315
--- /dev/null
+++ b/src/views/im/utils/emoji.ts
@@ -0,0 +1,21 @@
+/**
+ * IM 甯哥敤 Unicode emoji 鍒楄〃
+ *
+ * 鎵�瑙佸嵆鎵�寰楋紙涓嶄緷璧栧浘鐗囪祫婧愶級锛岀敱琛ㄦ儏闈㈡澘锛團acePicker 鐨� emoji tab锛変娇鐢紱
+ * Unicode emoji 閫変腑鍚庣敱璋冪敤鏂规彃鍏ュ埌杈撳叆妗嗚蛋 TEXT 閫氶亾锛屼笉璧� FACE 鍐呭绫诲瀷
+ */
+export const IM_EMOJI_LIST: string[] = [
+ '馃榾', '馃榿', '馃槀', '馃ぃ', '馃槂', '馃槃', '馃槄', '馃槅', '馃槈', '馃槉',
+ '馃構', '馃槑', '馃槏', '馃槝', '馃槜', '馃槞', '馃槡', '馃檪', '馃', '馃ぉ',
+ '馃', '馃え', '馃槓', '馃槕', '馃樁', '馃檮', '馃槒', '馃槪', '馃槬', '馃槷',
+ '馃', '馃槸', '馃槳', '馃槴', '馃槾', '馃槍', '馃槢', '馃槣', '馃槤', '馃い',
+ '馃槖', '馃槗', '馃様', '馃槙', '馃檭', '馃', '馃槻', '鈽癸笍', '馃檨', '馃槚',
+ '馃槥', '馃槦', '馃槫', '馃槩', '馃槶', '馃槮', '馃槯', '馃槰', '馃槱', '馃く',
+ '馃槵', '馃槹', '馃槺', '馃サ', '馃ザ', '馃槼', '馃お', '馃樀', '馃槨', '馃槧',
+ '馃が', '馃樂', '馃', '馃', '馃あ', '馃ぎ', '馃ぇ', '馃槆', '馃', '馃コ',
+ '馃憤', '馃憥', '馃憣', '鉁岋笍', '馃', '馃', '馃', '馃', '馃憟', '馃憠',
+ '馃憜', '馃憞', '鉁�', '馃', '馃枑', '馃枛', '馃憢', '馃', '馃檹', '馃挭',
+ '鉂わ笍', '馃А', '馃挍', '馃挌', '馃挋', '馃挏', '馃枻', '馃挃', '馃挄', '馃挅',
+ '馃帀', '馃帄', '馃巵', '馃巶', '馃嵃', '馃尮', '馃尫', '馃尭', '馃幍', '馃幎',
+ '猸�', '馃専', '鉁�', '馃挮', '馃敟', '馃挴', '鉁�', '鉂�', '鈿狅笍', '鉂�'
+]
diff --git a/src/views/im/utils/group.ts b/src/views/im/utils/group.ts
new file mode 100644
index 0000000..ddc53fa
--- /dev/null
+++ b/src/views/im/utils/group.ts
@@ -0,0 +1,289 @@
+import type { FriendLite } from '../home/types'
+
+import { loadImage } from './image'
+import { getAvatarBgColor, getAvatarText } from './user'
+
+/** 榛樿缇ゅ悕鐢熸垚锛氭墍閫夊ソ鍙嬪墠 4 涓悕瀛楁嫾鎺ワ紝瓒呰繃琛ャ�岀瓑 N 浜恒�嶏紱涓虹┖鍏滃簳銆岀兢鑱娿�� */
+export function buildDefaultGroupName(members: FriendLite[]): string {
+ if (members.length === 0) {
+ return '缇よ亰'
+ }
+ const names = members.slice(0, 4).map((m) => m.displayName || m.nickname || '')
+ const head = names.filter(Boolean).join('銆�')
+ if (members.length > 4) {
+ // members 鍙惈琚�夊ソ鍙嬶紝+1 鎶婂垱寤鸿�呬篃璁″叆瀹為檯鎴愬憳鏁�
+ return `${head}绛�${members.length + 1}浜篳
+ }
+ return head || '缇よ亰'
+}
+
+/** 缇ゅご鍍忓崟鏍肩殑杈撳叆锛氬ご鍍� URL + 鍚嶅瓧 锛岃嚦灏戠粰涓�涓紝浜岃�呴兘缂烘椂璧扮伆搴曠┖鏍� */
+export interface GroupAvatarMember {
+ /** 澶村儚 URL锛涚己澶辨垨鍔犺浇澶辫触鏃舵寜 name 鐢昏壊鍗� */
+ avatar?: string
+ /** 鏄剧ず鍚嶏紙鏄电О / 澶囨敞锛夛紱鑹插崱鏂囧瓧 + 搴曡壊 hash 鏉ユ簮 */
+ name?: string
+}
+
+/** 缇ゅご鍍忔嫾鎺ョ殑鍙�夊弬鏁� */
+export interface BuildGroupAvatarOptions {
+ /** 杈撳嚭鐢诲竷杈归暱锛堝儚绱狅級锛涢粯璁� 64 */
+ targetSize?: number
+ /** 鍗曟牸涔嬮棿鐨勯棿闅旓紙鍍忕礌锛夛紱榛樿 1 */
+ divider?: number
+ /** 鐢诲竷搴曡壊锛涢粯璁ら�忔槑锛岃涓婁笅鐣欑櫧閫忓嚭瀹夸富瀹瑰櫒搴曡壊 */
+ background?: string
+}
+
+/** 鍗曟牸鍦ㄧ敾甯冧笂鐨勪綅缃� + 灏哄 */
+interface CellRect {
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+/** 鍗曟牸鐨勬渶缁堢粯鍒跺唴瀹� */
+type Cell =
+ | { bg: string; kind: 'color'; text: string; }
+ | { img: HTMLImageElement; kind: 'image'; }
+
+/**
+ * 鎶婄兢鎴愬憳澶村儚鎷兼垚涓�寮犳柟褰㈢兢澶村儚 dataURL锛屾寜 1 ~ 9 寮犺蛋涔濆鏍煎彉浣撳竷灞�
+ *
+ * - 浠呭彇鍓� 9 涓垚鍛�
+ * - 鍗曟牸 avatar 涓虹┖ / 鍔犺浇澶辫触锛氭寜 name 鍦� canvas 涓婄敾鑹插崱鍏滃簳
+ * - 璺ㄥ煙瑕佹眰鍥剧墖婧愬紑鍚� CORS锛坕mg.crossOrigin = 'anonymous'锛�
+ */
+export async function buildGroupAvatar(
+ members: GroupAvatarMember[],
+ options: BuildGroupAvatarOptions = {}
+): Promise<string> {
+ const targetSize = options.targetSize ?? 64
+ const divider = options.divider ?? 1
+ const background = options.background
+
+ const top = members.slice(0, 9)
+ if (top.length === 0) {
+ return ''
+ }
+
+ const cells = await Promise.all(top.map((m) => resolveCell(m)))
+ const rects = computeCellRects(cells.length, targetSize, divider)
+
+ const canvas = document.createElement('canvas')
+ canvas.width = targetSize
+ canvas.height = targetSize
+ const ctx = canvas.getContext('2d')
+ if (!ctx) {
+ return ''
+ }
+ if (background) {
+ ctx.fillStyle = background
+ ctx.fillRect(0, 0, targetSize, targetSize)
+ }
+ cells.forEach((cell, idx) => {
+ const rect = rects[idx]
+ if (!rect) {
+ return
+ }
+ drawCell(ctx, rect, cell)
+ })
+ return canvas.toDataURL('image/png')
+}
+
+/** 缇ゅご鍍� dataURL 缂撳瓨涓婇檺锛氭瘡鏉$害 5 ~ 30KB锛�200 鏉¤蒋灏侀《绾� 5MB 甯搁┗ */
+const MERGED_AVATAR_CACHE_MAX = 200
+const mergedAvatarCache = new Map<string, string>()
+
+/** 鍙栫兢澶村儚 dataURL 缂撳瓨锛涘懡涓椂鎸� LRU 鎻愬埌鏈熬 */
+export function getCachedGroupAvatar(key: string): string | undefined {
+ const cached = mergedAvatarCache.get(key)
+ if (cached === undefined) {
+ return undefined
+ }
+ mergedAvatarCache.delete(key)
+ mergedAvatarCache.set(key, cached)
+ return cached
+}
+
+/** 鍐欑兢澶村儚 dataURL 缂撳瓨锛涜秴涓婇檺鏃朵涪寮冩渶鏃╀竴鏉★紙Map 杩唬椤哄簭 = 鎻掑叆椤哄簭锛岄厤鍚� get 鐨勬彁鍗囧嵆 LRU锛� */
+export function setCachedGroupAvatar(key: string, value: string): void {
+ if (mergedAvatarCache.has(key)) {
+ mergedAvatarCache.delete(key)
+ } else if (mergedAvatarCache.size >= MERGED_AVATAR_CACHE_MAX) {
+ const oldest = mergedAvatarCache.keys().next().value
+ if (oldest !== undefined) {
+ mergedAvatarCache.delete(oldest)
+ }
+ }
+ mergedAvatarCache.set(key, value)
+}
+
+/** 鍗曟垚鍛� 鈫� Cell锛氭湁 avatar 涓斿姞杞芥垚鍔熻蛋 image锛屽惁鍒欒蛋 color */
+async function resolveCell(member: GroupAvatarMember): Promise<Cell> {
+ if (member.avatar) {
+ const img = await loadImage(member.avatar)
+ if (img) {
+ return { kind: 'image', img }
+ }
+ }
+ return {
+ kind: 'color',
+ text: getAvatarText(member.name),
+ bg: getAvatarBgColor(member.name)
+ }
+}
+
+/** 鎶婂崟涓� Cell 鐢诲埌 ctx 涓婃寚瀹氭牸瀛愰噷锛沬mage 璧� cover 瑁佸壀淇濇瘮渚嬶紝color 璧� fillRect + 灞呬腑鏂囧瓧 */
+function drawCell(
+ ctx: CanvasRenderingContext2D,
+ rect: CellRect,
+ cell: Cell
+): void {
+ const { x, y, w, h } = rect
+ if (cell.kind === 'image') {
+ drawImageCover(ctx, cell.img, x, y, w, h)
+ return
+ }
+ ctx.fillStyle = cell.bg
+ ctx.fillRect(x, y, w, h)
+ if (!cell.text) {
+ return
+ }
+ // 瀛楀彿鎸夌煭杈圭畻锛涘崟瀛� 0.42銆佸弻瀛� 0.34
+ const baseSize = Math.min(w, h)
+ const fontSize = Math.floor(baseSize * (cell.text.length > 1 ? 0.34 : 0.42))
+ ctx.fillStyle = '#FFFFFF'
+ ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(cell.text, x + w / 2, y + h / 2)
+}
+
+/** cover 瑁佸壀锛氫粠婧愬浘涓績瑁佸嚭涓庣洰鏍囩煩褰㈠悓姣斾緥鐨勫瓙鍖哄煙锛岀敾鍒扮洰鏍囩煩褰紙淇濇寔姣斾緥涓嶅彉褰級 */
+function drawImageCover(
+ ctx: CanvasRenderingContext2D,
+ img: HTMLImageElement,
+ dx: number,
+ dy: number,
+ dw: number,
+ dh: number
+): void {
+ const srcAspect = img.width / img.height
+ const dstAspect = dw / dh
+ let sx = 0
+ let sy = 0
+ let sw = img.width
+ let sh = img.height
+ if (srcAspect > dstAspect) {
+ sw = sh * dstAspect
+ sx = (img.width - sw) / 2
+ } else if (srcAspect < dstAspect) {
+ sh = sw / dstAspect
+ sy = (img.height - sh) / 2
+ }
+ ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh)
+}
+
+/**
+ * 鎸夊紶鏁拌绠楁瘡鏍肩殑浣嶇疆 + 灏哄
+ *
+ * - 1 寮狅細鍗犳弧鐢诲竷
+ * - 2~4 寮狅細2 鍒� 2 琛屾鏂瑰舰鍗曟牸
+ * - 5~6 寮狅細3 鍒� 2 琛屾鏂瑰舰鍗曟牸 + 鍨傜洿灞呬腑
+ * - 7~9 寮狅細3 鍒� 3 琛屾鏂瑰舰鍗曟牸
+ */
+function computeCellRects(count: number, target: number, divider: number): CellRect[] {
+ if (count <= 0) {
+ return []
+ }
+ if (count === 1) {
+ return [{ x: 0, y: 0, w: target, h: target }]
+ }
+ if (count <= 4) {
+ return computeCellRectsSmall(count, target, divider)
+ }
+ if (count <= 6) {
+ return computeCellRectsMedium(count, target, divider)
+ }
+ return computeCellRectsLarge(count, target, divider)
+}
+
+/** 2~4 寮狅細2 鍒� 2 琛屾鏂瑰舰 */
+function computeCellRectsSmall(count: number, target: number, divider: number): CellRect[] {
+ const s = (target - 3 * divider) / 2
+ const step = s + divider
+ const rects: CellRect[] = []
+ switch (count) {
+ case 2: {
+ // 灞呬腑 1 琛岋紙鐣欎笂涓嬬┖鐧斤級
+ const y = target / 4
+ rects.push({ x: divider, y, w: s, h: s }, { x: divider + step, y, w: s, h: s })
+ break
+ }
+ case 3: {
+ // 涓� 1 灞呬腑 + 涓� 2
+ rects.push({ x: target / 4, y: divider, w: s, h: s })
+ const y1 = divider + step
+ rects.push({ x: divider, y: y1, w: s, h: s }, { x: divider + step, y: y1, w: s, h: s })
+ break
+ }
+ case 4: {
+ // 2脳2 婊¢摵
+ rects.push({ x: divider, y: divider, w: s, h: s }, { x: divider + step, y: divider, w: s, h: s })
+ const y1 = divider + step
+ rects.push({ x: divider, y: y1, w: s, h: s }, { x: divider + step, y: y1, w: s, h: s })
+ break
+ }
+ }
+ return rects
+}
+
+/** 5~6 寮狅細3 鍒� 2 琛屾鏂瑰舰鍗曟牸 + 鍨傜洿灞呬腑锛涗笂涓嬬暀鐧界敱閫忔槑鑳屾櫙閫忓嚭 GroupAvatar 瀹瑰櫒搴曡壊 */
+function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] {
+ const s = (target - 4 * divider) / 3
+ const step = s + divider
+ const xs = [divider, divider + step, divider + 2 * step] as const
+ // 2 琛岄珮 + 1 琛岄棿 div
+ const totalH = 2 * s + divider
+ const y0 = (target - totalH) / 2
+ const y1 = y0 + step
+ const rects: CellRect[] = []
+ if (count === 5) {
+ // 涓� 2 灞呬腑锛堝乏鍙冲绉扮暀鐧斤級 + 涓� 3 宸﹀彸璐磋竟
+ const upX0 = (target - 2 * s - divider) / 2
+ rects.push({ x: upX0, y: y0, w: s, h: s }, { x: upX0 + step, y: y0, w: s, h: s }, { x: xs[0], y: y1, w: s, h: s }, { x: xs[1], y: y1, w: s, h: s }, { x: xs[2], y: y1, w: s, h: s })
+ return rects
+ }
+ // count === 6锛氫笂 3 + 涓� 3 婊¢摵
+ rects.push({ x: xs[0], y: y0, w: s, h: s }, { x: xs[1], y: y0, w: s, h: s }, { x: xs[2], y: y0, w: s, h: s }, { x: xs[0], y: y1, w: s, h: s }, { x: xs[1], y: y1, w: s, h: s }, { x: xs[2], y: y1, w: s, h: s })
+ return rects
+}
+
+/** 7~9 寮狅細3 鍒� 3 琛屾鏂瑰舰鍗曟牸 */
+function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] {
+ const s = (target - 4 * divider) / 3
+ const step = s + divider
+ const xs = [divider, divider + step, divider + 2 * step] as const
+ const ys = [divider, divider + step, divider + 2 * step] as const
+ const rects: CellRect[] = []
+ if (count === 7) {
+ // 涓� 1 灞呬腑 + 涓� 3 + 涓� 3
+ rects.push({ x: xs[1], y: ys[0], w: s, h: s }, { x: xs[0], y: ys[1], w: s, h: s }, { x: xs[1], y: ys[1], w: s, h: s }, { x: xs[2], y: ys[1], w: s, h: s }, { x: xs[0], y: ys[2], w: s, h: s }, { x: xs[1], y: ys[2], w: s, h: s }, { x: xs[2], y: ys[2], w: s, h: s })
+ return rects
+ }
+ if (count === 8) {
+ // 涓� 2 灞呬腑 + 涓� 3 + 涓� 3
+ const upX0 = (target - 2 * s - divider) / 2
+ rects.push({ x: upX0, y: ys[0], w: s, h: s }, { x: upX0 + step, y: ys[0], w: s, h: s }, { x: xs[0], y: ys[1], w: s, h: s }, { x: xs[1], y: ys[1], w: s, h: s }, { x: xs[2], y: ys[1], w: s, h: s }, { x: xs[0], y: ys[2], w: s, h: s }, { x: xs[1], y: ys[2], w: s, h: s }, { x: xs[2], y: ys[2], w: s, h: s })
+ return rects
+ }
+ // count === 9锛�3脳3 婊¢摵
+ for (const y of ys) {
+ for (const x of xs) {
+ rects.push({ x, y, w: s, h: s })
+ }
+ }
+ return rects
+}
diff --git a/src/views/im/utils/image.ts b/src/views/im/utils/image.ts
new file mode 100644
index 0000000..207947d
--- /dev/null
+++ b/src/views/im/utils/image.ts
@@ -0,0 +1,57 @@
+// ====================================================================
+// IM 鍥剧墖宸ュ叿
+// ====================================================================
+// - probeImageSize锛氬姞杞芥湰鍦� File 鎴栬繙绋� URL锛岃鍑� naturalWidth / naturalHeight
+// - loadImage锛氬姞杞借繙绋� URL 鍒� HTMLImageElement锛堝け璐ヨ繑鍥� null锛屼笉鎶涢敊锛夛紝渚� canvas 缁樺埗浣跨敤
+// ====================================================================
+
+/** 榛樿鍗犱綅灏哄锛歱robe 澶辫触 / 瑙g爜寮傚父鏃跺厹搴曪紝閬垮厤 width/height 涓� 0 璁╂秷鎭覆鏌撳鎺� */
+const DEFAULT_FALLBACK_SIZE = { width: 200, height: 200 } as const
+
+/** 鍔犺浇杩滅▼ URL 鍒� HTMLImageElement锛屽け璐ヨ繑鍥� null锛沜anvas 缁樺埗瑕佹眰 crossOrigin=anonymous锛堥粯璁ゅ紑锛� */
+export function loadImage(
+ src: string,
+ options: { crossOrigin?: 'anonymous' | 'use-credentials' | null } = {}
+): Promise<HTMLImageElement | null> {
+ const crossOrigin = options.crossOrigin === undefined ? 'anonymous' : options.crossOrigin
+ return new Promise((resolve) => {
+ const img = new Image()
+ if (crossOrigin) {
+ img.crossOrigin = crossOrigin
+ }
+ img.addEventListener('load', () => resolve(img))
+ img.addEventListener('error', () => resolve(null))
+ img.src = src
+ })
+}
+
+/**
+ * 鍔犺浇鏈湴 File 鎴栬繙绋� URL锛岃В鍑� naturalWidth / naturalHeight
+ *
+ * - File锛氬唴閮� createObjectURL + revokeObjectURL锛岄伩鍏嶅唴瀛樼疮绉�
+ * - 杩滅▼ URL锛氱洿鎺ヨ蛋 <img>.src 瑙﹀彂娴忚鍣ㄥ姞杞斤紙鍙� CORS 褰卞搷锛涘彧璇诲昂瀵镐笉闇�瑕佺敾 canvas锛岃法鍩熶篃鑳芥嬁鍒帮級
+ * - 瑙g爜澶辫触 / 涓嶆槸鍥剧墖锛氳繑鍥� 200脳200 鍏滃簳
+ */
+export function probeImageSize(source: File | string): Promise<{ height: number; width: number; }> {
+ const isFile = source instanceof File
+ const src = isFile ? URL.createObjectURL(source) : source
+ return new Promise((resolve) => {
+ const img = new Image()
+ img.addEventListener('load', () => {
+ if (isFile) {
+ URL.revokeObjectURL(src)
+ }
+ resolve({
+ width: img.naturalWidth || DEFAULT_FALLBACK_SIZE.width,
+ height: img.naturalHeight || DEFAULT_FALLBACK_SIZE.height
+ })
+ })
+ img.addEventListener('error', () => {
+ if (isFile) {
+ URL.revokeObjectURL(src)
+ }
+ resolve({ ...DEFAULT_FALLBACK_SIZE })
+ })
+ img.src = src
+ })
+}
diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts
new file mode 100644
index 0000000..5bea3af
--- /dev/null
+++ b/src/views/im/utils/message.ts
@@ -0,0 +1,1035 @@
+import type { Conversation, GroupLite, Message, QuoteMessage, User } from '../home/types'
+
+import { useUserStore } from '#/packages/stores/src'
+
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import { useFriendStore } from '../home/store/friendStore'
+import { useGroupStore } from '../home/store/groupStore'
+import {
+ ImContentType,
+ ImConversationType,
+ type ImConversationTypeValue,
+ ImRtcCallEndReason
+} from './constants'
+import { formatCallDuration } from './time'
+
+export type { QuoteMessage } from '../home/types'
+
+// ====================================================================
+// IM 娑堟伅 content 缂栬В鐮� & 灞曠ず宸ュ叿
+// ====================================================================
+// 绾﹀畾锛氭秷鎭殑 content 瀛楁缁熶竴瀛� JSON 瀛楃涓诧紝瀛楁鍚嶃�佺粨鏋勫榻愬悗绔�
+// cn.iocoder.yudao.module.im.service.websocket.notification.message.* 涓嬬殑 DTO銆�
+// 鍚勭被娑堟伅 payload interface 瀛楁瀵归綈鍚庣锛涜В鏋愮粺涓�鐢� parseMessage<T>锛�
+// 搴忓垪鍖栫洿鎺� JSON.stringify(payload)銆�
+// ====================================================================
+
+// ==================== 瀹㈡埛绔� ID ====================
+
+/** 鐢熸垚瀹㈡埛绔秷鎭� ID锛堢函 UUID锛夛紝鐢ㄤ簬鍓嶇鍘婚噸 & ACK 鍥炲啓 */
+export const generateClientMessageId = (): string => {
+ if (globalThis.crypto?.randomUUID) {
+ return globalThis.crypto.randomUUID()
+ }
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAll(/[xy]/g, (char) => {
+ const random = Math.trunc(Math.random() * 16)
+ const value = char === 'x' ? random : (random & 0x3) | 0x8
+ return value.toString(16)
+ })
+}
+
+// ==================== 绉佽亰瀵圭 userId ====================
+
+/**
+ * 绉佽亰娑堟伅 / DTO 鐨勫绔� userId锛氳嚜宸卞彂鐨勫绔槸 receiver锛屽埆浜哄彂鐨勫绔槸 sender
+ *
+ * 鏀跺彛 4 澶勬棫 inline锛坵ebsocketStore.convertPrivateMessage / handlePrivateMessage / computeFriendPeerId
+ * 鍜� useMessagePuller.getPrivatePeerId锛夛紝缁撴瀯绫诲瀷鍙 senderId / receiverId 涓や釜瀛楁锛孯EST 涓� WS DTO 閮芥弧瓒�
+ */
+export function getPrivateMessagePeerId(
+ message: { receiverId: number; senderId: number; },
+ currentUserId: number
+): number {
+ return message.senderId === currentUserId ? message.receiverId : message.senderId
+}
+
+// ==================== 鏂囨湰鐗囨锛坱ip 鏂囨 + TEXT 姘旀场鍏辩敤锛� ====================
+// 鏃㈢敤浜庣伆鏉� tip锛�"XX 閭�璇� YY 鍔犲叆缇よ亰"锛夛紝涔熺敤浜� TEXT 姘旀场姝f枃锛園xxx 楂樹寒 + URL 鑷姩璇嗗埆锛夈��
+// mention 娈垫惡甯� userId 鐢ㄤ簬鎸傜偣鍑诲脊 UserInfoCard锛沴ink 娈垫惡甯� href 鐢ㄤ簬 <a> 璺宠浆锛�
+// text 娈垫槸绾枃鏈厹搴曘�傛覆鏌撳眰锛坱ip-segments.vue锛夋寜 type 鍒嗗彂缁熶竴澶勭悊銆�
+
+export type TipSegment =
+ | { href: string; text: string; type: 'link'; }
+ | { text: string; type: 'mention'; userId: number; }
+ | { text: string; type: 'text'; }
+
+export const tipText = (text: string): TipSegment => ({ type: 'text', text })
+
+export const tipMention = (userId: number, text: string): TipSegment => ({
+ type: 'mention',
+ userId,
+ text
+})
+
+export const tipLink = (href: string, text: string): TipSegment => ({
+ type: 'link',
+ href,
+ text
+})
+
+export const segmentsToText = (segments: TipSegment[]): string =>
+ segments.map((s) => s.text).join('')
+
+/** 澶氫釜 userId 鐢ㄥ悓涓�涓垎闅旂鎻掑�兼垚 segments锛屾瘡涓� user 鍗曠嫭鎴� mention 娈� */
+export function joinMentionSegments(
+ userIds: number[],
+ separator: string,
+ resolveName: (userId: number) => string
+): TipSegment[] {
+ return userIds.flatMap((id, index) =>
+ index === 0
+ ? [tipMention(id, resolveName(id))]
+ : [tipText(separator), tipMention(id, resolveName(id))]
+ )
+}
+
+/** mention 鍊欓�夛紱name 鐢ㄤ簬鍖归厤鏂囨湰瀛楅潰閲忥紝displayName 鐢ㄤ簬瑕嗙洊娓叉煋锛堜笉浼犲垯鎸� name 娓叉煋锛� */
+export interface MentionCandidate {
+ userId: number
+ /** 鐢ㄦ潵鍦� content 涓墠缂�鍖归厤鐨勫瓧闈㈤噺锛堜笉鍚� @锛� */
+ name: string
+ /**
+ * 娓叉煋鏃跺己鍒朵娇鐢ㄧ殑鏄剧ず鍚嶏紙涓嶅惈 @锛�
+ *
+ * 鐢ㄤ簬銆屽巻鍙叉秷鎭� content 閲屽啓鐨勬槸澶囨敞鍚� / 缇ゆ樀绉帮紝浣嗘帴鏀剁瑕佹寜鐪熷疄鏄电О娓叉煋銆嶇殑鍦烘櫙锛�
+ * 鍊欓�夊彲浠ユ惡甯﹀绉嶅瓧闈㈤噺锛坢atch 鐢級锛屼絾 displayName 缁熶竴鎸囧悜 nickname锛�
+ * 璁╂墍鏈夊巻鍙� / 鏂版秷鎭殑 @ 閮芥敹鏁涘埌涓�鑷寸殑灞曠ず
+ */
+ displayName?: string
+ /**
+ * 姝т箟鏍囪锛氬悓 name 瀵瑰簲澶氫釜 userId 鏃朵负 true
+ *
+ * parser 浠嶄細鎸� name 鍛戒腑锛堟渶闀夸紭鍏堬級锛屼絾鍛戒腑鍚庢暣娈靛悆鎴愭櫘閫氭枃鏈�斺��
+ * 閬垮厤銆孈寮犱笁銆嶈鍓旈櫎鍚庯紝鐭墠缂�鍊欓�夈�孈寮犮�嶆姠鍖归厤閿欑粦
+ */
+ ambiguous?: boolean
+}
+
+/** URL 閿氬畾姝e垯锛涚粓姝簬绌虹櫧銆佷腑鏂囥�丂锛堥伩鍏嶅悶鎺変笅涓�涓� mention锛夈��< > " '锛堥槻 HTML / 寮曞彿涓插叆锛夛紱y 鏍囧織璧� sticky 鍖归厤锛岀渷 text.slice */
+const URL_STICKY_REGEX = /(https?:\/\/[^\s涓�-榫<>"']+|www\.[^\s涓�-榫<>"']+)/iy
+
+/** URL 鏈熬甯歌鏍囩偣鍓旈櫎锛岄伩鍏嶅悶鎺夊彞鏈腑鑻辨枃鏍囩偣 */
+const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]銆侊紝銆傦紒锛燂紱锛氾級銆慮+$/
+
+/** 鏈�鐭� URL锛歚www.ab` / `http:/` 杩欑瀛ゆ涓嶇畻閾炬帴 */
+const URL_MIN_LENGTH = 6
+
+/**
+ * 鏂囨湰姘旀场 content 鎷嗘锛歮ention 娈垫寜鍊欓�夋渶闀垮墠缂�鍖归厤锛孶RL 娈佃蛋閿氬畾姝e垯锛屽墿浣欏綊 text
+ *
+ * mentions 涓嶄紶鎴栧尮閰嶄笉涓婃椂锛孈xxx 閫�鍖栦负鏅�� text锛涜В鏋愪笌娓叉煋瑙h�︼紝宸ュ叿鍑芥暟鏈韩涓嶄緷璧� store
+ */
+export function parseTextSegments(text: string, mentions: MentionCandidate[] = []): TipSegment[] {
+ if (!text) {
+ return []
+ }
+ // 銆孈寮犱笁涓般�嶄笉鑳借銆孈寮犱笁銆嶆埅鑳★紝鍊欓�夋寜 name 闀垮害鍊掑簭
+ const sortedMentions =
+ mentions.length > 1 ? mentions.toSorted((a, b) => b.name.length - a.name.length) : mentions
+ const out: TipSegment[] = []
+ let buffer = ''
+
+ const flush = () => {
+ if (buffer) {
+ out.push(tipText(buffer))
+ buffer = ''
+ }
+ }
+
+ let i = 0
+ while (i < text.length) {
+ if (text[i] === '@' && sortedMentions.length > 0) {
+ const matched = sortedMentions.find((m) => m.name && text.startsWith(m.name, i + 1))
+ if (matched) {
+ if (matched.ambiguous) {
+ // 鍚屽瓧闈㈤噺瀵瑰簲澶� userId锛屾棤娉曞垽瀹氭剰鍥撅紱鏁存绱叆 buffer 璁╃煭鍓嶇紑鍊欓�夋病鏈轰細鍐嶆壂杩欐
+ buffer += '@' + matched.name
+ } else {
+ flush()
+ // 娓叉煋缁熶竴璧� displayName锛堝嵆鐪熷疄鏄电О锛夛紝璁╁巻鍙� content 閲屾畫鐣欑殑澶囨敞 / 缇ゆ樀绉颁篃鏀舵暃鍒� nickname
+ out.push(tipMention(matched.userId, '@' + (matched.displayName || matched.name)))
+ }
+ i += 1 + matched.name.length
+ continue
+ }
+ }
+ const head = text[i]
+ if (head === 'h' || head === 'H' || head === 'w' || head === 'W') {
+ URL_STICKY_REGEX.lastIndex = i
+ const linkMatch = URL_STICKY_REGEX.exec(text)
+ if (linkMatch) {
+ const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, '')
+ if (urlText.length >= URL_MIN_LENGTH) {
+ flush()
+ const href = /^https?:\/\//i.test(urlText) ? urlText : `https://${urlText}`
+ out.push(tipLink(href, urlText))
+ i += urlText.length
+ continue
+ }
+ }
+ }
+ buffer += text[i]
+ i += 1
+ }
+ flush()
+ return out
+}
+
+// ==================== 寮曠敤娑堟伅 ====================
+
+/** 寮曠敤瀹瑰櫒锛�5 绉嶆櫘閫氭秷鎭�(TEXT / IMAGE / FILE / VOICE / VIDEO)閮藉彲鎼哄甫 quote */
+interface Quotable {
+ quote?: QuoteMessage
+}
+
+// ==================== 娑堟伅 payload ====================
+
+/** 鏂囨湰娑堟伅 payload锛堝榻愬悗绔� TextMessage锛� */
+export interface TextMessage extends Quotable {
+ content: string
+}
+
+/** 鍥剧墖娑堟伅 payload锛堝榻愬悗绔� ImageMessage锛� */
+export interface ImageMessage extends Quotable {
+ url: string
+ /** 缂╃暐鍥� URL */
+ thumbnailUrl?: string
+ /** 鍥剧墖瀹藉害 */
+ width?: number
+ /** 鍥剧墖楂樺害 */
+ height?: number
+ /** 鏂囦欢澶у皬锛堝瓧鑺傦級 */
+ size?: number
+}
+
+/** 璇煶娑堟伅 payload锛堝榻愬悗绔� AudioMessage锛汭mContentType 淇濈暀 VOICE 鍛藉悕锛� */
+export interface AudioMessage extends Quotable {
+ url: string
+ /** 鏃堕暱锛堢锛� */
+ duration: number
+ /** 鏂囦欢澶у皬锛堝瓧鑺傦級 */
+ size?: number
+}
+
+/** 鏂囦欢娑堟伅 payload锛堝榻愬悗绔� FileMessage锛� */
+export interface FileMessage extends Quotable {
+ url: string
+ name: string
+ size: number
+ /** MIME 绫诲瀷 */
+ type?: string
+}
+
+/** 瑙嗛娑堟伅 payload锛堝榻愬悗绔� VideoMessage锛涙殏鏈帴鍏ユ覆鏌擄級 */
+export interface VideoMessage extends Quotable {
+ url: string
+ /** 灏侀潰 URL */
+ coverUrl?: string
+ /** 鏃堕暱锛堢锛� */
+ duration?: number
+ width?: number
+ height?: number
+ /** 鏂囦欢澶у皬锛堝瓧鑺傦級 */
+ size?: number
+}
+
+/**
+ * 鍚嶇墖娑堟伅 payload锛堝榻愬悗绔� CardMessage锛�
+ *
+ * 鎸� targetType 鍖哄垎鐢ㄦ埛鍚嶇墖 / 缇ゅ悕鐗囷細
+ * - PRIVATE锛歵argetId = 鐢ㄦ埛缂栧彿锛宯ame = 鐢ㄦ埛鏄电О锛堝彇鐪熷疄鏄电О锛岄潪澶囨敞锛�
+ * - GROUP锛歵argetId = 缇ょ紪鍙凤紝name = 缇ゅ悕锛屽彲甯� memberCount
+ */
+export interface CardMessage extends Quotable {
+ /** 鍚嶇墖瀵硅薄绫诲瀷 */
+ targetType: ImConversationTypeValue
+ /** 鐩爣瀵硅薄缂栧彿锛歅RIVATE 鏃� = userId锛汫ROUP 鏃� = groupId */
+ targetId: number
+ /** 鏄剧ず鍚嶅揩鐓э細PRIVATE 鏃� = 鐢ㄦ埛鏄电О锛汫ROUP 鏃� = 缇ゅ悕 */
+ name: string
+ /** 澶村儚锛堝揩鐓э級 */
+ avatar?: string
+ /** 缇ゆ垚鍛樻暟锛堜粎 targetType = GROUP锛涙帴鏀剁灞曠ず銆孨 浜虹兢鑱娿�嶏級 */
+ memberCount?: number
+}
+
+/**
+ * 鍚嶇墖杞彂鐨勬簮瀵硅薄锛圧ecommendCardDialog 鍏ュ弬锛夛紱瀛楁涓� CardMessage 1:1锛屾棤 quote
+ */
+export type CardTarget = Omit<CardMessage, 'quote'>
+
+/** 鐢ㄦ埛瀵硅薄 鈫� 鐢ㄦ埛鍚嶇墖婧愶紙PRIVATE锛夛紱缂� id 杩斿洖 null 璁╄皟鐢ㄦ柟 v-bind 缁戝畾涓轰笉娓叉煋 */
+export function toUserCardTarget(user: null | undefined | User): CardTarget | null {
+ if (!user?.id) {
+ return null
+ }
+ return {
+ targetType: ImConversationType.PRIVATE,
+ targetId: user.id,
+ name: user.nickname || '',
+ avatar: user.avatar
+ }
+}
+
+/** 缇ゅ璞� 鈫� 缇ゅ悕鐗囨簮锛圙ROUP锛夛紱缂� id 杩斿洖 null */
+export function toGroupCardTarget(group: GroupLite | null | undefined): CardTarget | null {
+ if (!group?.id) {
+ return null
+ }
+ return {
+ targetType: ImConversationType.GROUP,
+ targetId: group.id,
+ name: group.name || '',
+ avatar: group.showImage || group.showImageThumb,
+ memberCount: group.memberCount
+ }
+}
+
+/**
+ * 鍚嶇墖鏍囩 + 鍥炬爣锛堟寜 targetType 浜屽垎锛夛紝缁熶竴鑱婂ぉ姘旀场 / 寮曠敤棰勮 / 鍘嗗彶鎽樿 / 鍚庡彴棰勮鐨勬枃妗堜笌鍥炬爣
+ *
+ * 缂哄け / 闈炴硶 targetType 璧般�屼釜浜哄悕鐗囥�嶅厹搴曪紝閬垮厤鑰佹秷鎭垨鑴忔暟鎹鑷� UI 绌虹櫧
+ */
+export function getCardLabelInfo(card: null | undefined | { targetType?: number }): {
+ icon: string
+ label: string
+} {
+ if (card?.targetType === ImConversationType.GROUP) {
+ return { label: '缇ゅ悕鐗�', icon: 'ant-design:usergroup-outlined' }
+ }
+ return { label: '涓汉鍚嶇墖', icon: 'ant-design:user-outlined' }
+}
+
+/** 琛ㄦ儏娑堟伅 payload锛堝榻愬悗绔� FaceMessage锛沀nicode emoji 浠嶈蛋 TEXT锛屾湰绫诲瀷鍙壙杞借创鍥� / 鑷畾涔夎〃鎯呭寘锛� */
+export interface FaceMessage extends Quotable {
+ /** 琛ㄦ儏鍥� URL */
+ url: string
+ /** 娓叉煋瀹藉害锛堝儚绱狅級锛岄伩鍏嶅竷灞�鎶栧姩锛涘彲閫夛紝缂哄け鏃惰皟鐢ㄦ柟璧� CSS max-w 鍏滃簳 */
+ width?: number
+ /** 娓叉煋楂樺害锛堝儚绱狅級锛屽彲閫� */
+ height?: number
+ /** 琛ㄦ儏鍚嶏紙绯荤粺鍖呴�氬父鏈夛紝涓汉琛ㄦ儏鍖呴�氬父鏃狅級 */
+ name?: string
+}
+
+/** 鍚堝苟杞彂鐨勫崟鏉″唴宓屾秷鎭揩鐓э紙瀵归綈鍚庣 MergeMessage.Item锛� */
+export interface MergeMessageItem {
+ /** 鍘熸秷鎭紪鍙凤紱浠呭仛婧簮鏍囪瘑 */
+ messageId: number
+ /** 鍙戦�佷汉缂栧彿 */
+ senderId: number
+ /** 鍙戦�佷汉鏄电О蹇収锛涘绔彲鑳戒笉鍦ㄥ師浼氳瘽閲岋紝鏃犳硶瀹炴椂鏌ュ埌 */
+ senderNickname: string
+ /** 鍙戦�佷汉澶村儚蹇収 */
+ senderAvatar?: string
+ /** 鍐呭绫诲瀷锛屽榻� ImContentType */
+ type: number
+ /** 鍘熸秷鎭� content锛圝SON锛夛紱宓屽鍚堝苟娑堟伅鏃朵粛鎸夋湰缁撴瀯灞傚眰灞曞紑 */
+ content: string
+ /** 鍙戦�佹椂闂存埑锛堟绉掞級 */
+ sendTime: number
+}
+
+/** 鍚堝苟杞彂娑堟伅 payload锛堝榻愬悗绔� MergeMessage锛� */
+export interface MergeMessage {
+ title: string // 鍚堝苟鏍囬锛涗緥锛氥�屽紶涓夊拰鏉庡洓鐨勮亰澶╄褰曘�嶃�岀兢鑱婄殑鑱婂ぉ璁板綍銆�
+ messages: MergeMessageItem[] // 鍐呭祵鐨勫畬鏁存秷鎭揩鐓�
+}
+
+/** 棰戦亾绱犳潗娑堟伅 payload锛堝榻愬悗绔� MaterialMessage锛� */
+export interface MaterialMessage {
+ materialId?: number
+ channelId?: number // 棰戦亾缂栧彿锛涜浆鍙戝悗娓叉煋鍗$墖搴曢儴鐨勯閬撳ご鍍� + 鍚嶇О
+ title?: string
+ coverUrl?: string
+ summary?: string
+ url?: string // 璺宠浆閾炬帴锛涗负绌烘椂鐐瑰嚮鍦ㄥ鎴风鍐呯疆璇︽儏椤垫寜 materialId 鎷� content 娓叉煋锛涢潪绌哄垯璺� url
+}
+
+// ==================== 鍚堝苟杞彂 payload 鏋勯�� ====================
+
+/** 鍗曚釜鍙戦�佷汉鐨勫揩鐓ф樀绉� / 澶村儚 */
+interface SenderSnapshot {
+ nickname: string
+ avatar: string
+}
+
+/**
+ * 涓�娆℃�ф瀯閫� senderId 鈫� SenderSnapshot 鏄犲皠锛涢伩鍏� N 鏉℃秷鎭�愭潯 find 缇ゆ垚鍛�
+ *
+ * 缇よ亰浠� group.members 涓�娆℃�� indexBy锛涚鑱婂彧闇� self + friend锛涘鍔犲叏浣� senderId 涓婂仛 friend 鍏滃簳
+ */
+function buildSenderSnapshotMap(
+ senderIds: number[],
+ conversation: Conversation
+): Map<number, SenderSnapshot> {
+ const userStore = useUserStore()
+ const friendStore = useFriendStore()
+ const selfUserId = getCurrentUserId()
+ const result = new Map<number, SenderSnapshot>()
+
+ let groupMembers: Map<number, { avatar?: string; nickname: string; }> | null = null
+ if (conversation.type === ImConversationType.GROUP) {
+ const group = useGroupStore().getGroup(conversation.targetId)
+ groupMembers = new Map((group?.members || []).map((m) => [m.userId, m]))
+ }
+
+ for (const senderId of senderIds) {
+ if (result.has(senderId)) {
+ continue
+ }
+ if (senderId === selfUserId) {
+ result.set(senderId, {
+ nickname: userStore.userInfo?.nickname || String(senderId),
+ avatar: userStore.userInfo?.avatar || ''
+ })
+ continue
+ }
+ const member = groupMembers?.get(senderId)
+ if (member?.nickname) {
+ result.set(senderId, { nickname: member.nickname, avatar: member.avatar || '' })
+ continue
+ }
+ const friend = friendStore.getFriend(senderId)
+ result.set(senderId, {
+ nickname: friend?.nickname || String(senderId),
+ avatar: friend?.avatar || ''
+ })
+ }
+ return result
+}
+
+/** 鎶婂崟鏉� Message 杞垚 MergeMessageItem 蹇収锛涘墺绂� quote 闃插紩鐢ㄧ┛閫� */
+function mapMessageToMergeItem(
+ message: Message,
+ senderSnapshots: Map<number, SenderSnapshot>
+): MergeMessageItem {
+ const snapshot = senderSnapshots.get(message.senderId)
+ return {
+ messageId: message.id || 0,
+ senderId: message.senderId,
+ senderNickname: snapshot?.nickname ?? String(message.senderId),
+ senderAvatar: snapshot?.avatar ?? '',
+ type: message.type,
+ content: removeQuotePayload(message.content),
+ sendTime: message.sendTime
+ }
+}
+
+/** 鍚堝苟杞彂鏍囬锛氱鑱娿�寋瀵规柟} 鍜� {鑷繁} 鐨勮亰澶╄褰曘�嶏紱缇よ亰銆寋缇ゅ悕} 鐨勮亰澶╄褰曘�� */
+export function buildMergeTitle(conversation: Conversation): string {
+ if (conversation.type === ImConversationType.GROUP) {
+ return `${conversation.name || '缇よ亰'} 鐨勮亰澶╄褰昤
+ }
+ const myName = useUserStore().userInfo?.nickname || '鎴�'
+ return `${conversation.name || '瀵规柟'} 鍜� ${myName} 鐨勮亰澶╄褰昤
+}
+
+/** 鎶婁竴缁� Message 鎵撳寘鎴� MergeMessage payload锛涜皟鐢ㄦ柟璐熻矗鎸� sendTime 鎺掑簭鍚庝紶鍏� */
+export function buildMergeMessagePayload(
+ messages: Message[],
+ conversation: Conversation
+): MergeMessage {
+ const senderIds = messages.map((m) => m.senderId)
+ const senderSnapshots = buildSenderSnapshotMap(senderIds, conversation)
+ return {
+ title: buildMergeTitle(conversation),
+ messages: messages.map((m) => mapMessageToMergeItem(m, senderSnapshots))
+ }
+}
+
+/** 銆屾坊鍔犲埌琛ㄦ儏銆嶇殑鍙彂璧锋簮锛欶ACE / IMAGE 閮藉厑璁革紙GIF 鍥剧墖涔熷父琚敹钘忥級 */
+export interface AddableFacePayload {
+ url: string
+ width: number
+ height: number
+ name?: string
+}
+
+/**
+ * 浠庢秷鎭娊鍙栥�屾坊鍔犲埌琛ㄦ儏銆嶇殑 payload锛涘綋鍓嶅唴瀹圭被鍨嬩笉鍙坊鍔犺繑鍥� null
+ *
+ * 璋冪敤鏂癸紙MessageItem 鐨勫彸閿彍鍗曪級鎸� nullable 鍐冲畾鏄惁灞曠ず銆屾坊鍔犲埌琛ㄦ儏銆嶅叆鍙�
+ */
+export function extractAddableFace(message: Message): AddableFacePayload | null {
+ if (message.type === ImContentType.FACE) {
+ const face = parseMessage<FaceMessage>(message.content)
+ if (!face?.url) {
+ return null
+ }
+ return { url: face.url, width: face.width || 200, height: face.height || 200, name: face.name }
+ }
+ if (message.type === ImContentType.IMAGE) {
+ const image = parseMessage<ImageMessage>(message.content)
+ if (!image?.url) {
+ return null
+ }
+ return { url: image.url, width: image.width || 200, height: image.height || 200 }
+ }
+ return null
+}
+
+/** 瑙f瀽娑堟伅 content锛圝SON 瀛楃涓诧級涓烘寚瀹� payload锛岄潪娉� JSON 杩斿洖 null */
+export const parseMessage = <T>(content: string): null | T => {
+ try {
+ return JSON.parse(content) as T
+ } catch {
+ return null
+ }
+}
+
+/** 搴忓垪鍖栨秷鎭� payload 涓� content JSON 瀛楃涓诧紱涓� parseMessage 瀵圭О */
+export const serializeMessage = <T>(payload: T): string => JSON.stringify(payload)
+
+/** `URL.createObjectURL(file)` 鐢熸垚鐨� URL 鍓嶇紑锛涘崰浣� / revoke / 閲嶄紶鏃у�艰瘑鍒叡鐢� */
+export const BLOB_URL_PREFIX = 'blob:'
+
+/**
+ * 濯掍綋 payload 閲屽彲鑳藉寘鍚� blob URL 鐨勫瓧娈碉紙鍥剧墖/鏂囦欢/瑙嗛/璇煶閮藉榻愯繖濂� url 瀛楁鍛藉悕锛�
+ *
+ * 璺熼殢 ImageMessage / VideoMessage / FileMessage / AudioMessage interface 瀹氫箟鍚屾锛�
+ * - url锛氫富浣撹祫婧愶紙鍗犱綅鏃舵槸 blob URL锛宎ck 鍚庢槸鐪熷疄 URL锛�
+ * - coverUrl锛氳棰戝皝闈紙commit 鍚庢槸鐪熷疄 URL锛涘崰浣嶉樁娈典笉璁句互閬垮厤浼� blob 褰� poster 鍦ㄩ儴鍒嗘祻瑙堝櫒閫�鍖栵級
+ * - thumbnailUrl锛氬浘鐗囩缉鐣ュ浘锛堝綋鍓嶆湭鍗犱綅鏃朵娇鐢� blob锛岄鐣欙級
+ */
+const MEDIA_BLOB_URL_FIELDS = ['url', 'coverUrl', 'thumbnailUrl'] as const
+
+/**
+ * 閲婃斁 content 涓獟浣� payload 瀛楁涓婄殑 blob URL 鍐呭瓨鏄犲皠
+ *
+ * 浠呮壂鎻� url / coverUrl / thumbnailUrl 涓変釜宸茬煡瀛楁锛岄伩鍏� regex 鍏ㄦ枃 grep 璇激 quote.content 閲屽祵濂楃殑鍚屽悕 blob URL銆�
+ * 浠呭褰撳墠 document 鍐呭垱寤虹殑 blob URL 鏈夋晥锛汭ndexedDB 鎭㈠鍑烘潵鐨勬棫 blob URL 宸查殢鏃� document 澶辨晥锛岃皟瀹冩棤瀹充絾鏃犳剰涔�
+ */
+export const revokeBlobUrlsInContent = (content: string): void => {
+ if (!content || !content.includes(BLOB_URL_PREFIX)) {
+ return
+ }
+ const payload = parseMessage<Record<string, unknown>>(content)
+ if (!payload) {
+ return
+ }
+ for (const field of MEDIA_BLOB_URL_FIELDS) {
+ const value = payload[field]
+ if (typeof value === 'string' && value.startsWith(BLOB_URL_PREFIX)) {
+ URL.revokeObjectURL(value)
+ }
+ }
+}
+
+// ==================== 寮曠敤娑堟伅 helper ====================
+
+/** 鎶� quote 鍚堣繘 payload(搴忓垪鍖栧墠璋冪敤);quote 缂哄け鏃跺師鏍疯繑鍥� */
+export const withQuotePayload = <T extends Quotable>(payload: T, quote?: QuoteMessage): T => {
+ if (!quote) {
+ return payload
+ }
+ return { ...payload, quote }
+}
+
+/**
+ * 浠� content JSON 瀛楃涓查噷娓呮帀 quote 瀛楁
+ *
+ * 瀹㈡埛绔箰瑙傛覆鏌撴瀯閫� quote 鏃惰皟鐢�,閬垮厤"鍥炲涓�鏉″甫寮曠敤鐨勬秷鎭�"閫犳垚 quote 宓屽婊氶洩鐞�;
+ * 涓庡悗绔� ImMessageUtils.removeQuote 瀵归綈
+ */
+export const removeQuotePayload = (content: string): string => {
+ if (!content || !content.includes('"quote"')) {
+ return content
+ }
+ const parsed = parseMessage<Record<string, unknown>>(content)
+ if (!parsed || !('quote' in parsed)) {
+ return content
+ }
+ delete parsed.quote
+ return JSON.stringify(parsed)
+}
+
+/** 鐢� Message 娲剧敓 QuoteMessage 鐢ㄤ簬涔愯娓叉煋;ack 鍚庝細琚湇鍔$鏉冨▉鐗堟湰瑕嗙洊 */
+export const buildQuoteFromMessage = (message: Message): QuoteMessage => {
+ return {
+ messageId: message.id || 0,
+ senderId: message.senderId,
+ type: message.type,
+ content: removeQuotePayload(message.content)
+ }
+}
+
+/** 浠庡凡搴忓垪鍖� message.content 涓В鍑� quote;闈� JSON / 鏃� quote 杩斿洖 null */
+export const getQuoteFromMessage = (content: string): null | QuoteMessage => {
+ // 闀夸細璇濇瘡鏉℃秷鎭覆鏌撻兘璧� quote computed,闈炲紩鐢ㄦ秷鎭瓧绗︿覆棰勬壂鐩存帴杩斿洖,鍏嶄竴娆� JSON.parse
+ if (!content || !content.includes('"quote"')) {
+ return null
+ }
+ const parsed = parseMessage<Quotable>(content)
+ return parsed?.quote ?? null
+}
+
+// ==================== 鎾ゅ洖 ====================
+
+/**
+ * 浠庡悗绔笅鍙戠殑鎾ゅ洖 RecallMessage content 涓В鏋愬嚭琚挙鍥炵殑鍘熸秷鎭� id
+ * content 褰㈠ `{"messageId": 123}`锛岃嫢涓嶅惈 messageId 鍒欒繑鍥� 0锛堣〃绀鸿繖鏉′笉鏄挙鍥炴秷鎭級
+ */
+export const parseRecallMessageId = (content: string): number => {
+ try {
+ const parsed = JSON.parse(content)
+ return parsed?.messageId == null ? 0 : Number(parsed.messageId)
+ } catch {
+ return 0
+ }
+}
+
+// ==================== 鏂版秷鎭彁绀洪煶 ====================
+
+import tipAudioUrl from '#/assets/audio/im/message-tip.mp3'
+
+/**
+ * 鏂版秷鎭彁绀洪煶锛屽甫 1 绉掕妭娴侊細鐭椂闂村唴澶氭潯娑堟伅鍙搷涓�娆★紝閬垮厤鐤插姵杞扮偢銆�
+ *
+ * 娴忚鍣ㄨ嚜鍔ㄦ挱鏀剧瓥鐣ヨ姹傞〉闈㈡湁杩囩敤鎴蜂氦浜掞紱鑻ユ棤娉曟挱鏀句細闈欓粯澶辫触銆�
+ */
+let __lastPlayAudioTipAt = 0
+let __tipAudio: HTMLAudioElement | null = null
+export const playAudioTip = () => {
+ const now = Date.now()
+ if (now - __lastPlayAudioTipAt < 1000) {
+ return
+ }
+ __lastPlayAudioTipAt = now
+ try {
+ if (!__tipAudio) {
+ __tipAudio = new Audio(tipAudioUrl)
+ __tipAudio.preload = 'auto'
+ }
+ __tipAudio.currentTime = 0
+ const playPromise = __tipAudio.play()
+ if (playPromise && typeof playPromise.catch === 'function') {
+ playPromise.catch((error) => console.debug('[IM] playAudioTip 澶辫触', error))
+ }
+ } catch (error) {
+ console.debug('[IM] playAudioTip 澶辫触', error)
+ }
+}
+
+// ==================== 鏂囦欢鍥炬爣 ====================
+
+/**
+ * 鎸夋枃浠舵墿灞曞悕鎸戞枃浠跺浘鏍� + 棰滆壊锛屽榻愬井淇¤鎰�
+ *
+ * message-item.vue锛堜富姘旀场锛夊拰 reply-preview.vue锛堝紩鐢ㄩ瑙堬級鍏辩敤鍚屼竴浠芥槧灏勶紝閬垮厤瑙嗚涓ゅ涓嶄竴鑷�
+ */
+export function getFileIconInfo(filename: string | undefined): { color: string; icon: string; } {
+ const ext = (filename || '').split('.').pop()?.toLowerCase() || ''
+ if (ext === 'pdf') {
+ return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }
+ }
+ if (['doc', 'docx'].includes(ext)) {
+ return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }
+ }
+ if (['xls', 'xlsx'].includes(ext)) {
+ return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }
+ }
+ if (['ppt', 'pptx'].includes(ext)) {
+ return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }
+ }
+ if (['7z', 'gz', 'rar', 'tar', 'zip'].includes(ext)) {
+ return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }
+ }
+ if (['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(ext)) {
+ return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }
+ }
+ if (['avi', 'flv', 'mkv', 'mov', 'mp4', 'wmv'].includes(ext)) {
+ return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }
+ }
+ if (['aac', 'flac', 'mp3', 'ogg', 'wav'].includes(ext)) {
+ return { icon: 'ant-design:audio-filled', color: '#9c27b0' }
+ }
+ if (['json', 'log', 'md', 'txt', 'xml'].includes(ext)) {
+ return { icon: 'ant-design:file-text-filled', color: '#909399' }
+ }
+ return { icon: 'ant-design:file-filled', color: '#909399' }
+}
+
+// ==================== 绠$悊鍚庡彴灞曠ず宸ュ叿 ====================
+
+/** 璇︽儏寮圭獥閲屾妸 content JSON 缇庡寲鎴� 2 缂╄繘 */
+export const formatJson = (content?: string): string => {
+ if (!content) return ''
+ try {
+ return JSON.stringify(JSON.parse(content), null, 2)
+ } catch {
+ return content
+ }
+}
+
+// ==================== 缇ゅ箍鎾簨浠� payload + 鏂囨 ====================
+
+// 缇ゅ箍鎾簨浠� payload锛涘榻愬悗绔� GroupNotificationMessage 瀛愮被鑱氬悎瀛楁
+export type GroupNotificationPayload = {
+ addSource?: number
+ // 鍏ㄧ兢灏佺
+ banned?: boolean
+ displayUserName?: string
+ // 鑷敱杩涚兢浜嬩欢
+ entrantUserId?: number
+ memberUserIds?: number[]
+ // PIN 浜嬩欢鎼哄甫鐨勮缃《娑堟伅灞曠ず鏁版嵁
+ message?: {
+ atUserIds?: number[]
+ content: string
+ groupId: number
+ id: number
+ receiverUserIds?: number[]
+ senderId: number
+ sendTime: string
+ type: number
+ }
+ messageId?: number
+ // 绂佽█浜嬩欢
+ mutedUserId?: number
+ muteEndTime?: string
+ newAvatar?: string
+ newJoinApproval?: boolean
+ newName?: string
+ newNotice?: string
+ newOwnerUserId?: number
+ oldAvatar?: string
+ oldJoinApproval?: boolean
+ oldName?: string
+ oldNotice?: string
+ operatorUserId?: number
+}
+
+/**
+ * 缇ゅ箍鎾簨浠� segments
+ *
+ * resolveName 鐢辫皟鐢ㄦ柟娉ㄥ叆锛堥粯璁ゅ満鏅紶 getSenderDisplayName锛夛紱
+ * operatorNameOverride 浠呰鐩� operator 娈垫枃妗堬紝mention userId 浠嶇敤 payload.operatorUserId
+ */
+export function resolveGroupNotificationSegments(
+ message: { content?: string; targetId?: number; type?: number; },
+ resolveName: (userId: number) => string,
+ operatorNameOverride?: string
+): TipSegment[] {
+ let payload: GroupNotificationPayload
+ try {
+ payload = JSON.parse(message.content || '{}')
+ } catch {
+ return []
+ }
+ // ENTER 涓昏鏄� entrant 鑰岄潪 operator锛岀嫭绔嬪鐞嗭紱鍏跺畠 case 閮戒互 operatorUserId 涓轰富璇�
+ if (message.type === ImContentType.GROUP_MEMBER_ENTER) {
+ const entrantId = payload.entrantUserId ?? payload.operatorUserId
+ return entrantId ? [tipMention(entrantId, resolveName(entrantId)), tipText(' 鍔犲叆浜嗙兢鑱�')] : []
+ }
+ if (!payload.operatorUserId) {
+ return []
+ }
+ const operatorSegment = tipMention(
+ payload.operatorUserId,
+ operatorNameOverride ?? resolveName(payload.operatorUserId)
+ )
+ const memberSegments = joinMentionSegments(payload.memberUserIds || [], '銆�', resolveName)
+
+ switch (message.type) {
+ case ImContentType.GROUP_ADMIN_ADD: {
+ return [operatorSegment, tipText(' 灏� '), ...memberSegments, tipText(' 璁句负绠$悊鍛�')]
+ }
+ case ImContentType.GROUP_ADMIN_REMOVE: {
+ return [operatorSegment, tipText(' 鎾ら攢浜� '), ...memberSegments, tipText(' 鐨勭鐞嗗憳韬唤')]
+ }
+ case ImContentType.GROUP_BANNED: {
+ return [operatorSegment, tipText(payload.banned ? ' 灏佺浜嗚缇�' : ' 瑙e皝浜嗚缇�')]
+ }
+ case ImContentType.GROUP_CANCEL_MUTED: {
+ return [operatorSegment, tipText(' 鍏抽棴浜嗗叏缇ょ瑷�')]
+ }
+ case ImContentType.GROUP_CREATE: {
+ return [operatorSegment, tipText(' 鍒涘缓浜嗙兢鑱�')]
+ }
+ case ImContentType.GROUP_DISSOLVE: {
+ return [operatorSegment, tipText(' 瑙f暎浜嗙兢鑱�')]
+ }
+ case ImContentType.GROUP_INFO_UPDATE: {
+ return payload.newAvatar
+ ? [operatorSegment, tipText(' 鏇存崲浜嗙兢澶村儚')]
+ : [operatorSegment, tipText(' 鏇存柊浜嗙兢淇℃伅')]
+ }
+ case ImContentType.GROUP_MEMBER_CANCEL_MUTED: {
+ return payload.mutedUserId
+ ? [
+ operatorSegment,
+ tipText(' 瑙i櫎浜� '),
+ tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)),
+ tipText(' 鐨勭瑷�')
+ ]
+ : []
+ }
+ case ImContentType.GROUP_MEMBER_INVITE: {
+ return [operatorSegment, tipText(' 閭�璇� '), ...memberSegments, tipText(' 鍔犲叆缇よ亰')]
+ }
+ case ImContentType.GROUP_MEMBER_KICK: {
+ return [operatorSegment, tipText(' 绉诲嚭浜� '), ...memberSegments]
+ }
+ case ImContentType.GROUP_MEMBER_MUTED: {
+ return payload.mutedUserId
+ ? [
+ operatorSegment,
+ tipText(' 灏� '),
+ tipMention(payload.mutedUserId, resolveName(payload.mutedUserId)),
+ tipText(' 绂佽█')
+ ]
+ : []
+ }
+ case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
+ return [operatorSegment, tipText(` 淇敼缇ゆ樀绉颁负 "${payload.displayUserName ?? ''}"`)]
+ }
+ case ImContentType.GROUP_MEMBER_QUIT: {
+ return [operatorSegment, tipText(' 閫�鍑轰簡缇よ亰')]
+ }
+ case ImContentType.GROUP_MESSAGE_PIN: {
+ return [operatorSegment, tipText(' 缃《浜嗕竴鏉℃秷鎭�')]
+ }
+ case ImContentType.GROUP_MESSAGE_UNPIN: {
+ return [operatorSegment, tipText(' 鍙栨秷浜嗕竴鏉$疆椤舵秷鎭�')]
+ }
+ case ImContentType.GROUP_MUTED: {
+ return [operatorSegment, tipText(' 寮�鍚簡鍏ㄧ兢绂佽█')]
+ }
+ case ImContentType.GROUP_NAME_UPDATE: {
+ return [operatorSegment, tipText(` 灏嗙兢鍚嶄慨鏀逛负 "${payload.newName ?? ''}"`)]
+ }
+ case ImContentType.GROUP_NOTICE_UPDATE: {
+ return [operatorSegment, tipText(' 鏇存柊浜嗙兢鍏憡')]
+ }
+ case ImContentType.GROUP_OWNER_TRANSFER: {
+ return payload.newOwnerUserId
+ ? [
+ operatorSegment,
+ tipText(' 宸插皢缇や富杞缁� '),
+ tipMention(payload.newOwnerUserId, resolveName(payload.newOwnerUserId))
+ ]
+ : []
+ }
+ default: {
+ return []
+ }
+ }
+}
+
+/** 缇ゅ箍鎾簨浠朵腑鏂囨枃妗� */
+export function resolveGroupNotificationText(
+ message: { content?: string; targetId?: number; type?: number; },
+ resolveName: (userId: number) => string,
+ operatorNameOverride?: string
+): string {
+ return segmentsToText(
+ resolveGroupNotificationSegments(message, resolveName, operatorNameOverride)
+ )
+}
+
+// ==================== 濂藉弸浜嬩欢 ====================
+
+/** 浼氳瘽鍐呭ソ鍙嬩簨浠� segments */
+export function resolveFriendNotificationSegments(message: { type?: number }): TipSegment[] {
+ switch (message.type) {
+ case ImContentType.FRIEND_ADD: {
+ return [tipText('浣犱滑宸茬粡鏄ソ鍙嬩簡锛屽紑濮嬭亰澶╁惂')]
+ }
+ case ImContentType.FRIEND_DELETE: {
+ return [tipText('浣犲凡鍒犻櫎濂藉弸')]
+ }
+ default: {
+ return []
+ }
+ }
+}
+
+/** 浼氳瘽鍐呭ソ鍙嬩簨浠舵枃妗堬細FRIEND_ADD / FRIEND_DELETE 娓叉煋鎴愮伆鑹叉彁绀烘皵娉★紝鏂囨鍥哄畾涓嶄緷璧� payload */
+export function resolveFriendNotificationText(message: { type?: number }): string {
+ return segmentsToText(resolveFriendNotificationSegments(message))
+}
+
+// ==================== RTC 閫氳瘽浜嬩欢 ====================
+
+// RTC_CALL_START payload锛涗粎缇よ亰锛涚敤浜庤亰澶� tip 鏂囨銆寋inviter} 鍙戣捣浜唟voice/video}閫氳瘽銆�
+export type RtcCallStartPayload = {
+ conversationType?: number
+ inviterAvatar?: string
+ inviterNickname?: string
+ inviterUserId?: number
+ mediaType?: number
+ room?: string
+}
+
+// RTC_CALL_END payload锛涚鑱婂噯姘旀场 + 缇よ亰銆岄�氳瘽宸茬粨鏉� [鏃堕暱 X]銆嶅叡鐢�
+export type RtcCallEndPayload = {
+ conversationType?: number
+ durationSeconds?: number
+ endReason?: number
+ mediaType?: number
+ operatorAvatar?: string
+ operatorNickname?: string
+ operatorUserId?: number
+ room?: string
+}
+
+/** 瑙f瀽 RTC_CALL_START / RTC_CALL_END 娑堟伅 content锛涜В鏋愬け璐ヨ繑鍥� null */
+export function parseRtcCallPayload(
+ content?: string
+): null | (RtcCallEndPayload & RtcCallStartPayload) {
+ return content ? parseMessage<RtcCallEndPayload & RtcCallStartPayload>(content) : null
+}
+
+/**
+ * 浼氳瘽鍐呴�氳瘽浜嬩欢 segments锛圧TC_CALL_START / RTC_CALL_END锛�
+ * <p>
+ * 缇よ亰涓ゆ寮忥細START銆寋inviter} 鍙戣捣浜嗚闊抽�氳瘽銆�+ END銆岃闊抽�氳瘽宸茬粡缁撴潫銆�
+ * <p>
+ * 绉佽亰姘旀场璧� {@link resolveRtcCallPrivateBubbleText}
+ */
+export function resolveRtcCallTipSegments(message: {
+ content?: string
+ selfSend?: boolean
+ type?: number
+}): TipSegment[] {
+ const payload = parseRtcCallPayload(message.content)
+ if (!payload) {
+ return []
+ }
+ if (message.type === ImContentType.RTC_CALL_START) {
+ return payload.inviterUserId
+ ? [
+ tipMention(payload.inviterUserId, resolveRtcInviterLabel(payload)),
+ tipText(' 鍙戣捣浜嗚闊抽�氳瘽')
+ ]
+ : []
+ }
+ if (message.type === ImContentType.RTC_CALL_END) {
+ return [tipText('璇煶閫氳瘽宸茬粡缁撴潫')]
+ }
+ return []
+}
+
+/** 鍙� RTC 閫氳瘽鍙戣捣浜哄睍绀哄悕锛涙樀绉颁负绌烘椂鍥為��銆岀敤鎴� {id}銆� */
+function resolveRtcInviterLabel(payload: RtcCallStartPayload): string {
+ return payload.inviterNickname?.trim() || `鐢ㄦ埛 ${payload.inviterUserId ?? ''}`
+}
+
+/**
+ * 浼氳瘽鍒楄〃鏈�鍚庝竴鏉¢瑙堟枃妗堬紙RTC_CALL_START / RTC_CALL_END锛�
+ * <p>
+ * 绉佽亰锛歋TART / END 閮藉睍绀恒�孾璇煶閫氳瘽]銆嶏紝瀵归綈 [鍥剧墖] / [璇煶] 椋庢牸
+ * <p>
+ * 缇よ亰锛歋TART銆寋inviter} 鍙戣捣浜嗚闊抽�氳瘽銆嶃�丒ND銆岃闊抽�氳瘽宸茬粡缁撴潫銆嶏紝涓庤亰澶� tip 鍚屾簮
+ */
+export function resolveRtcCallLastContent(
+ message: { content?: string; type?: number; },
+ conversationType: number
+): string {
+ if (conversationType === ImConversationType.PRIVATE) {
+ return '[璇煶閫氳瘽]'
+ }
+ if (message.type === ImContentType.RTC_CALL_END) {
+ return '璇煶閫氳瘽宸茬粡缁撴潫'
+ }
+ if (message.type === ImContentType.RTC_CALL_START) {
+ const payload = parseRtcCallPayload(message.content)
+ if (payload) {
+ return `${resolveRtcInviterLabel(payload)} 鍙戣捣浜嗚闊抽�氳瘽`
+ }
+ }
+ return ''
+}
+
+/**
+ * 绉佽亰 RTC_CALL_END 姘旀场鍐呮枃妗堬紱鎸� operatorUserId 鏄笉鏄嚜宸辨覆鏌撲袱绔笉鍚屾枃妗堬紙瀵归綈寰俊锛�
+ * <p>
+ * 鏂囨瑙勫垯锛�
+ * HANGUP duration > 0 鈫� 銆岄�氳瘽鏃堕暱 N銆嶏紙鍙屾柟涓�鑷达級
+ * HANGUP duration 鈮� 0 鈫� 銆岄�氳瘽涓柇銆嶏紙鏈帴閫氱殑鍏滃簳锛�
+ * CANCEL 鈫� 鎿嶄綔鑰呫�屽凡鍙栨秷銆�/ 鍙︿竴鏂广�屽鏂瑰凡鍙栨秷銆�
+ * REJECT 鈫� 鎿嶄綔鑰呫�屽凡鎷掔粷銆�/ 鍙︿竴鏂广�屽鏂瑰凡鎷掔粷銆�
+ * BUSY 鈫� 鎿嶄綔鑰呫�屽繖绾挎湭鎺ュ惉銆�/ 鍙︿竴鏂广�屽鏂瑰繖绾夸腑銆�
+ * NO_ANSWER 鈫� 鎿嶄綔鑰呫�屾湭鎺ュ惉銆�/ 鍙︿竴鏂广�屽鏂规湭鎺ュ惉銆嶏紙鎸搩瓒呮椂 Job 瑙﹀彂锛�
+ * ERROR 鈫� 銆岄�氳瘽涓柇 [N]銆嶏紙鎺ラ�氬悗寮傚父鏂紑锛沝uration > 0 鏃跺甫鏃堕暱锛�
+ */
+export function resolveRtcCallPrivateBubbleText(payload: null | RtcCallEndPayload): string {
+ if (!payload) {
+ return '閫氳瘽宸茬粨鏉�'
+ }
+ const duration = payload.durationSeconds ?? 0
+ const hasDuration = duration > 0
+ const isOperator = payload.operatorUserId === getCurrentUserId()
+ switch (payload.endReason) {
+ case ImRtcCallEndReason.BUSY: {
+ return isOperator ? '蹇欑嚎鏈帴鍚�' : '瀵规柟蹇欑嚎涓�'
+ }
+ case ImRtcCallEndReason.CANCEL: {
+ return isOperator ? '宸插彇娑�' : '瀵规柟宸插彇娑�'
+ }
+ case ImRtcCallEndReason.ERROR: {
+ return hasDuration ? `閫氳瘽涓柇 ${formatCallDuration(duration)}` : '閫氳瘽涓柇'
+ }
+ case ImRtcCallEndReason.HANGUP: {
+ return hasDuration ? `閫氳瘽鏃堕暱 ${formatCallDuration(duration)}` : '閫氳瘽涓柇'
+ }
+ case ImRtcCallEndReason.NO_ANSWER: {
+ return isOperator ? '鏈帴鍚�' : '瀵规柟鏈帴鍚�'
+ }
+ case ImRtcCallEndReason.REJECT: {
+ return isOperator ? '宸叉嫆缁�' : '瀵规柟宸叉嫆缁�'
+ }
+ default: {
+ return hasDuration ? `閫氳瘽鏃堕暱 ${formatCallDuration(duration)}` : '閫氳瘽宸茬粨鏉�'
+ }
+ }
+}
+
+/** 浼氳瘽鍐呴�氳瘽浜嬩欢鏂囨 */
+export function resolveRtcCallTipText(message: {
+ content?: string
+ selfSend?: boolean
+ type?: number
+}): string {
+ return segmentsToText(resolveRtcCallTipSegments(message))
+}
+
+/**
+ * RTC_CALL_END 缁撴潫鍘熷洜鍏滃簳鏂囨锛涘墠绔� toast / console 鍏滃簳鐢�
+ * <p>
+ * 缂� operator 淇℃伅锛堝悓姝ュ搷搴� + WS push 鍏滃簳鍦烘櫙锛夋椂鐨勯�氱敤鏂囨锛涚粏鍒嗘枃妗堬紙鎸夊彂閫佹柟瑙嗚锛夎蛋 {@link resolveRtcCallPrivateBubbleText}
+ */
+export function resolveCallEndReasonText(reason: number | undefined): string {
+ switch (reason) {
+ case ImRtcCallEndReason.BUSY: {
+ return '瀵规柟蹇欑嚎涓�'
+ }
+ case ImRtcCallEndReason.CANCEL: {
+ return '瀵规柟宸插彇娑�'
+ }
+ case ImRtcCallEndReason.ERROR: {
+ return '閫氳瘽寮傚父'
+ }
+ case ImRtcCallEndReason.HANGUP: {
+ return '閫氳瘽宸茬粨鏉�'
+ }
+ case ImRtcCallEndReason.REJECT: {
+ return '瀵规柟宸叉嫆缁�'
+ }
+ default: {
+ return '閫氳瘽宸叉柇寮�'
+ }
+ }
+}
diff --git a/src/views/im/utils/pull.ts b/src/views/im/utils/pull.ts
new file mode 100644
index 0000000..4e3ec11
--- /dev/null
+++ b/src/views/im/utils/pull.ts
@@ -0,0 +1,155 @@
+import { getDb } from './db'
+
+/**
+ * IM 鐘舵�佷簨浠惰ˉ鍋匡紙澧為噺鎷夊彇锛夐�氱敤缂栨帓
+ *
+ * 鍚勪笟鍔℃ā鍧楋紙濂藉弸銆佺兢銆佺敵璇枫�佽浣嶇疆锛夊叡鐢ㄤ竴濂� update_time + id 姝e悜娓告爣锛�
+ * 浠庢寔涔呭寲娓告爣鍑哄彂寰幆鎷夊彇鍙樻洿骞跺悎骞惰繘鏈湴 store锛岀洿鍒版煇椤典笉婊★紙娌℃湁鏇村锛夛紝
+ * 鐢ㄤ簬 WebSocket 婕忔帹鍚庣殑鍏滃簳琛ュ伩锛堣繘鍏� IM銆佹柇绾块噸杩炴椂瑙﹀彂锛�
+ */
+
+/** 澧為噺鎷夊彇娓告爣锛氫笂娆℃媺鍒扮殑浣嶇疆 */
+export interface PullCursor {
+ lastUpdateTime?: number
+ lastId?: number
+}
+
+/** 鍙綔涓烘父鏍囩殑鎷夊彇璁板綍锛氭湇鍔$鎸� update_time + id 杩斿洖锛屽鎴风鍙栨渶鍚庝竴鏉℃帹杩涙父鏍� */
+interface PullRecord {
+ id: number
+ updateTime?: number
+}
+
+/** 鍗曟鎷夊彇鏉℃暟锛堜笌鍚庣 limit 涓婇檺瀵归綈锛� */
+const PULL_PAGE_SIZE = 100
+/** 鍗曡疆鏈�澶氱炕椤垫暟锛屽厹搴曢槻寰″紓甯告父鏍囧鑷寸殑姝诲惊鐜� */
+const PULL_MAX_PAGES = 100
+/** 鐘舵�佷簨浠舵媺鍙栧洖鎵獥鍙o紝瑕嗙洊鍚岀鍐呮棫琛屾洿鏂板拰瀹㈡埛绔� / 鏈嶅姟绔椂閽熺簿搴﹀樊 */
+const PULL_OVERLAP_MS = 5000
+/** 娑堟伅绫� minId 鎷夊彇鐨勫崟杞炕椤典笂闄愶紝鍏滃簳闃插尽寮傚父娓告爣姝荤炕锛涙秷鎭噺鍙兘杩滃ぇ浜庣姸鎬佷簨浠讹紝鏀惧鍒� 1000 */
+const MIN_ID_PULL_MAX_PAGES = 1000
+
+/** 璇诲彇鏌愭ā鍧楃殑鎷夊彇娓告爣锛涙棤鍒欒繑鍥炵┖娓告爣锛堥娆℃媺鍏ㄩ噺锛� */
+export async function getPullCursor(key: string): Promise<PullCursor> {
+ return (await getDb().getSetting<PullCursor>(key)) ?? {}
+}
+
+/**
+ * 閫氱敤澧為噺鎷夊彇锛氫粠鎸佷箙鍖栨父鏍囧嚭鍙戯紝寰幆鎷夊彇骞跺簲鐢ㄥ彉鏇达紝鐩村埌鏌愰〉涓嶆弧
+ *
+ * @param cursorKey 娓告爣鍦� settings 閲岀殑 key锛堟瘡涓ā鍧椾竴浠斤級
+ * @param fetchPage 鎸夋父鏍囨媺涓�椤碉紙璋冨搴� pull 鎺ュ彛锛岃繑鍥� VO 鍒楄〃锛�
+ * @param apply 鎶婁竴椤靛彉鏇村悎骞惰繘鏈湴 store锛涜繑鍥� false 琛ㄧず鏈〉鏈畬鍏ㄨ惤鍦帮紙濡傝处鍙峰凡鍒囥�佷緷璧栬祫婧愯ˉ鎷夊け璐ワ級锛�
+ * 姝ゆ椂涓嶆帹杩涙父鏍囧苟缁堟鏈疆锛岄伩鍏嶆父鏍囪秺杩囨湭钀藉湴鐨勮褰曘�佸鑷村悗缁閲忔案涔呮紡鎷夈�傚彲杩斿洖 Promise
+ */
+export async function runIncrementalPull<T extends PullRecord>(
+ cursorKey: string,
+ fetchPage: (params: { lastId?: number; lastUpdateTime?: number; limit: number }) => Promise<T[]>,
+ apply: (records: T[]) => boolean | Promise<boolean>,
+ isActive?: () => boolean
+): Promise<void> {
+ const storedCursor = await getPullCursor(cursorKey)
+ const highWater = { ...storedCursor }
+ let cursor =
+ storedCursor.lastUpdateTime == null
+ ? {}
+ : { lastUpdateTime: Math.max(0, storedCursor.lastUpdateTime - PULL_OVERLAP_MS), lastId: 0 }
+ for (let page = 0; page < PULL_MAX_PAGES; page++) {
+ if (isActive && !isActive()) {
+ return
+ }
+ const list = await fetchPage({
+ lastUpdateTime: cursor.lastUpdateTime,
+ lastId: cursor.lastId,
+ limit: PULL_PAGE_SIZE
+ })
+ if (isActive && !isActive()) {
+ return
+ }
+ if (list.length > 0) {
+ // apply 鏈畬鍏ㄨ惤鍦帮紙杩斿洖 false锛夋椂鐩存帴缁堟锛氭父鏍囧彧鑳借窡鐫�宸茶惤鍦扮殑鏁版嵁璧帮紝鍚﹀垯浼氳烦杩囨湰椤佃褰�
+ if ((await apply(list)) === false) {
+ return
+ }
+ if (isActive && !isActive()) {
+ return
+ }
+ // 鎺ㄨ繘娓告爣鍒版湰椤垫渶鍚庝竴鏉″苟鎸佷箙鍖栵細涓嬫浠庤繖閲屾帴鐫�鎷�
+ const last = list[list.length - 1]
+ if (!last) {
+ return
+ }
+ if (last.updateTime == null) {
+ return
+ }
+ cursor = { lastUpdateTime: last.updateTime, lastId: last.id }
+ if (
+ highWater.lastUpdateTime == null ||
+ cursor.lastUpdateTime > highWater.lastUpdateTime ||
+ (cursor.lastUpdateTime === highWater.lastUpdateTime &&
+ cursor.lastId > (highWater.lastId ?? 0))
+ ) {
+ highWater.lastUpdateTime = cursor.lastUpdateTime
+ highWater.lastId = cursor.lastId
+ await getDb().setSetting(cursorKey, highWater)
+ }
+ }
+ // 涓嶆弧涓�椤� = 娌℃湁鏇村鍙樻洿
+ if (list.length < PULL_PAGE_SIZE) {
+ return
+ }
+ }
+ console.warn(`[IM pull] ${cursorKey} 杈惧埌鍗曡疆缈婚〉涓婇檺锛屾彁鍓嶇粨鏉熸湰杞ˉ鍋縛)
+}
+
+/**
+ * 娑堟伅绫诲閲忔媺鍙栭�氱敤缂栨帓锛氭寜鍗曡皟 minId 娓告爣寰幆缈婚〉锛岀洿鍒扮┖椤垫垨娓告爣涓嶅啀鍓嶈繘
+ *
+ * 涓� runIncrementalPull 鐨勫尯鍒細娑堟伅娓告爣鏄崟璋冩秷鎭� id锛堥潪 update_time + id 澶嶅悎娓告爣锛夛紝涓� messageMaxId 鐨勬寔涔呭寲
+ * 璺熼殢娑堟伅鍏ュ簱鍦� applyPage 鍐呭畬鎴愶紙messageStore锛夛紝鏁呮湰鍑芥暟鍙礋璐c�屽惊鐜炕椤垫満鍒躲�嶏紝涓嶇 settings锛屼篃涓嶆幒娑堟伅涓氬姟銆�
+ *
+ * @param initialMinId 璧峰娓告爣锛氫笂娆″叆搴撶殑鏈�澶ф秷鎭� id
+ * @param pageSize 鍗曢〉鏉℃暟
+ * @param fetchPage 鎸� minId 鎷変竴椤�
+ * @param applyPage 澶勭悊鏈〉锛堝缓浼氳瘽 / 鍒嗗彂 / 鍏ュ簱 + 鎺ㄨ繘 messageMaxId锛夛紱杩斿洖 false 琛ㄧず鏈〉鏈惤鍦帮紝鍋滄涓斾笉鎺ㄨ繘娓告爣
+ * @param isActive 姣忔 await 鍓嶅悗鑷锛氬彇娑� / 鍒囪处鍙锋椂杩斿洖 false锛屼涪寮冩湰鎵逛笉鍏ュ簱銆佷笉鍐嶇炕椤�
+ * @param maxPages 鍗曡疆缈婚〉涓婇檺
+ */
+export async function runMinIdPull<T extends { id?: number }>(options: {
+ applyPage: (records: T[], nextMinId?: number) => Promise<boolean> | Promise<void>
+ fetchPage: (params: { minId: number; size: number }) => Promise<T[]>
+ initialMinId: number
+ isActive?: () => boolean
+ maxPages?: number
+ pageSize: number
+}): Promise<void> {
+ const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options
+ const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES
+ let minId = initialMinId || 0
+ for (let page = 0; page < maxPages; page++) {
+ if (isActive && !isActive()) {
+ return
+ }
+ const list = await fetchPage({ minId, size: pageSize })
+ // 鎷夊彇鏈熼棿鍙栨秷 / 鍒囪处鍙凤細涓㈠純鏈壒涓嶅叆搴擄紝涔熶笉鍐嶇炕椤�
+ if (isActive && !isActive()) {
+ return
+ }
+ if (!list || list.length === 0) {
+ return
+ }
+ // 鏈壒鏈�澶ф秷鎭� id 浣滀负涓嬫娓告爣锛涙棤鏈夋晥 id 鍒欐湰鎵� apply 鍚庡仠锛堟棤娉曠户缁炕椤碉級
+ const validIds = list.map((record) => record.id).filter((id): id is number => id != null)
+ const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined
+ // applyPage 杩斿洖 false锛氭湰椤垫湭钀藉湴锛堝鍏ュ簱澶辫触锛夛紝涓嶆帹杩涙父鏍囧苟缁堟锛岄伩鍏嶆紡娑堟伅
+ if ((await applyPage(list, nextMinId)) === false) {
+ return
+ }
+ // 鏃犳湁鏁� id锛屾垨娓告爣娌″墠杩涳紙鍚庣濂戠害鏄� id > minId锛岀悊璁轰笉浼氬嚭鐜帮級锛氬仠锛岄槻寰℃缈�
+ if (nextMinId == null || nextMinId <= minId) {
+ return
+ }
+ minId = nextMinId
+ }
+ console.warn('[IM pull] runMinIdPull 杈惧埌鍗曡疆缈婚〉涓婇檺锛屾彁鍓嶇粨鏉熸湰杞�')
+}
diff --git a/src/views/im/utils/time.ts b/src/views/im/utils/time.ts
new file mode 100644
index 0000000..7c5b6e8
--- /dev/null
+++ b/src/views/im/utils/time.ts
@@ -0,0 +1,128 @@
+import dayjs from 'dayjs'
+
+// ====================================================================
+// IM 鏃堕棿鏍煎紡鍖�
+// ====================================================================
+// 4 绫诲満鏅紝鏂囨瑙勫垯鍚勬湁宸紓锛涚粺涓�璧� dayjs锛岄伩鍏� raw Date 鏁g偣鍐欐硶
+
+const WEEKDAY_NAMES_FULL = ['鏄熸湡鏃�', '鏄熸湡涓�', '鏄熸湡浜�', '鏄熸湡涓�', '鏄熸湡鍥�', '鏄熸湡浜�', '鏄熸湡鍏�']
+const WEEKDAY_NAMES_SHORT = ['鍛ㄦ棩', '鍛ㄤ竴', '鍛ㄤ簩', '鍛ㄤ笁', '鍛ㄥ洓', '鍛ㄤ簲', '鍛ㄥ叚']
+
+/**
+ * 娑堟伅鍒楄〃鐨勬椂闂村垎闅旀潯
+ *
+ * - 浠婂ぉ锛欻H:mm
+ * - 鏄ㄥぉ锛氭槰澶� HH:mm
+ * - 涓�鍛ㄥ唴锛氬懆X HH:mm
+ * - 瓒呰繃涓�鍛細MM-DD HH:mm
+ */
+export function formatTimeTip(timestamp: number): string {
+ if (!timestamp) {
+ return ''
+ }
+ const target = dayjs(timestamp)
+ const now = dayjs()
+ const time = target.format('HH:mm')
+ if (target.isSame(now, 'day')) {
+ return time
+ }
+ if (target.isSame(now.subtract(1, 'day'), 'day')) {
+ return `鏄ㄥぉ ${time}`
+ }
+ const diffDays = now.startOf('day').diff(target.startOf('day'), 'day')
+ if (diffDays >= 2 && diffDays <= 6) {
+ return `${WEEKDAY_NAMES_SHORT[target.day()]} ${time}`
+ }
+ return target.format('MM-DD HH:mm')
+}
+
+/**
+ * 浼氳瘽鍒楄〃閲岀殑鏃堕棿锛堟棤 HH:mm 鍚庣紑锛屽彧灞曠ず鏃ユ湡绮掑害锛�
+ *
+ * - 浠婂ぉ锛欻H:mm
+ * - 鏄ㄥぉ锛氭槰澶� HH:mm
+ * - 鏈懆鍐咃紙2-6 澶╁墠锛夛細鏄熸湡X
+ * - 鍚屽勾鍏朵粬锛歁M/DD
+ * - 璺ㄥ勾锛歒YYY/MM/DD
+ */
+export function formatConversationTime(timestamp: number): string {
+ if (!timestamp) {
+ return ''
+ }
+ const target = dayjs(timestamp)
+ const now = dayjs()
+ if (target.isSame(now, 'day')) {
+ return target.format('HH:mm')
+ }
+ if (target.isSame(now.subtract(1, 'day'), 'day')) {
+ return `鏄ㄥぉ ${target.format('HH:mm')}`
+ }
+ // 鐢� startOf('day') 鍏滃簳璺ㄦ椂闂寸偣鐨勫樊鍊硷紝閬垮厤鎺ヨ繎鍑屾櫒鏃跺彇鍒� 1.x 鈫� diff 绠楁垚 1 婕忔帀鏄ㄥぉ鍒嗘敮
+ const diffDays = now.startOf('day').diff(target.startOf('day'), 'day')
+ if (diffDays >= 2 && diffDays <= 6) {
+ return WEEKDAY_NAMES_FULL[target.day()] || ''
+ }
+ return target.year() === now.year() ? target.format('MM/DD') : target.format('YYYY/MM/DD')
+}
+
+/**
+ * 鍘嗗彶娑堟伅鎼滅储鍒楄〃鐨勬椂闂达細灞曠ず缁濆鏃ユ湡
+ *
+ * - 鍚屽勾锛歁鏈圖鏃� HH:mm
+ * - 璺ㄥ勾锛歒YYY骞碝鏈圖鏃� HH:mm
+ */
+export function formatHistoryTime(timestamp: number): string {
+ if (!timestamp) {
+ return ''
+ }
+ const target = dayjs(timestamp)
+ return target.year() === dayjs().year()
+ ? target.format('M鏈圖鏃� HH:mm')
+ : target.format('YYYY骞碝鏈圖鏃� HH:mm')
+}
+
+/** 鍚堝苟娑堟伅璇︽儏鍒楄〃閲屾瘡琛岀殑鏃堕棿锛歁M-DD HH:mm */
+export function formatMergeItemTime(timestamp: number): string {
+ if (!timestamp) {
+ return ''
+ }
+ return dayjs(timestamp).format('MM-DD HH:mm')
+}
+
+/** RTC 閫氳瘽鏃堕暱锛堢锛夆啋 "00:06" / "1:23:45" */
+export function formatCallDuration(seconds: number | undefined): string {
+ const total = Math.max(0, Math.floor(seconds || 0))
+ const h = Math.floor(total / 3600)
+ const m = Math.floor((total % 3600) / 60)
+ const s = total % 60
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
+}
+
+/** 绉掓暟鏍煎紡鍖栦负 mm:ss */
+export function formatSeconds(seconds?: number): string {
+ const value = Math.max(0, Number(seconds || 0))
+ const minute = Math.floor(value / 60)
+ const second = Math.floor(value % 60)
+ return `${minute.toString().padStart(2, '0')}:${second.toString().padStart(2, '0')}`
+}
+
+/** 鏍煎紡鍖栨棩鏈� */
+export function formatDate(
+ date?: Date | number | string,
+ format = 'YYYY-MM-DD HH:mm:ss'
+) {
+ return dayjs(date).format(format)
+}
+
+/** 鎺ラ�氬埌缁撴潫鐨勯�氳瘽鏃堕暱锛涗换涓�鏃堕棿缂哄け杩斿洖 '-' */
+export function resolveCallDuration(
+ acceptTime: Date | string | undefined,
+ endTime: Date | string | undefined
+): string {
+ if (!acceptTime || !endTime) {
+ return '-'
+ }
+ const seconds = Math.floor((new Date(endTime).getTime() - new Date(acceptTime).getTime()) / 1000)
+ return seconds > 0 ? formatCallDuration(seconds) : '-'
+}
diff --git a/src/views/im/utils/user.ts b/src/views/im/utils/user.ts
new file mode 100644
index 0000000..8775dba
--- /dev/null
+++ b/src/views/im/utils/user.ts
@@ -0,0 +1,367 @@
+// ====================================================================
+// IM 鐢ㄦ埛灞曠ず utility
+// ====================================================================
+// 鑱岃矗锛氱粺涓�鍥炵瓟"鏌愪釜鐢ㄦ埛鍦� UI 涓婂簲璇ュ浣曞睍绀�"锛屽寘鍚細
+// 1. 鏄剧ず鍚嶏紙getFriendDisplayName / getMemberDisplayName / getGroupDisplayName / getSenderDisplayName 绛夛級
+// 2. 涓婁笅鏂囨劅鐭ュ悕锛坱ryGetSenderDisplayName / getSenderRealNickname锛夛細娓叉煋鏃舵寜 conversation 涓婁笅鏂囧疄鏃舵煡 friendStore / groupStore / userStore锛岃澶囨敞 / 缇ゆ樀绉� / 鐪熷疄鏄电О鍙樻洿鍚庢墍鏈夊巻鍙叉秷鎭珛鍗冲搷搴斿紡鍒锋柊
+// 3. 鎬у埆鍥炬爣 / 棰滆壊锛坓etGenderIcon / getGenderColor锛夛細鐢疯摑銆佸コ绮夛紝鏈煡涓嶅睍绀猴紝鎵�鏈� UserInfo 鍗$墖 / 鍒楄〃琛屽叡鐢�
+//
+// 鍛藉悕绾﹀畾锛氭樉绀哄悕鐩稿叧鍑芥暟涓�寰嬩娇鐢� displayName锛屼笌 friend.displayName / member.displayUserName 瀛楁瀵归綈
+// ====================================================================
+
+import type { Conversation, Friend, Group, User } from '../home/types'
+
+import { CommonStatusEnum } from '#/packages/constants/src'
+import { useUserStore } from '#/packages/stores/src'
+
+import { getCurrentUserId } from '#/views/im/utils/auth'
+
+import { useConversationStore } from '../home/store/conversationStore'
+import { useFriendStore } from '../home/store/friendStore'
+import { useGroupStore } from '../home/store/groupStore'
+import { useImUiStore } from '../home/store/uiStore'
+import {
+ IM_AT_ALL_NICKNAME,
+ IM_AT_ALL_USER_ID,
+ ImConversationType,
+ ImFriendAddSource
+} from './constants'
+import { type MentionCandidate } from './message'
+
+const SystemUserSexEnum = {
+ FEMALE: 2,
+ MALE: 1,
+ UNKNOWN: 0
+} as const
+
+// 鍊欓�夌己澶卞満鏅殑绋冲畾绌烘暟缁勶紱璁� textMentions computed 鍦ㄩ潪 TEXT / 闈炵兢鑱� / 鏃� @ 鏃惰繑鍥炲悓涓�寮曠敤锛�
+// MessageBubble 鐨� textSegments 鎵嶄笉浼氳窡鐫�鏃犺皳閲嶇畻
+const EMPTY_MENTIONS: MentionCandidate[] = []
+
+/**
+ * 鏄惁鍘嗗彶閫�缇ょ兢锛歫oinStatus 涓� DISABLE锛堝凡閫�缇� / 琚Щ闄わ級
+ *
+ * /im/group/list 浼氬甫鍥炲巻鍙查��缇ょ兢锛堜緵灞曠ず鍘嗗彶娑堟伅鐨勭兢鍚� / 澶村儚锛夛紱杩欑被缇ゅ簲绂佹鍙戦�併�侀殣钘忕兢鎿嶄綔鍏ュ彛銆佷笉鍙杞彂 / 鎺ㄨ崘閫変腑
+ */
+export function isGroupQuit(group?: Group | null): boolean {
+ return group?.joinStatus === CommonStatusEnum.DISABLE
+}
+
+/**
+ * 绉佽亰濂藉弸鏄剧ず鍚嶏細澶囨敞 > 鐪熷疄鏄电О
+ *
+ * displayName 鏄�屾垜瀵硅繖涓汉鐨勭浜虹О鍛笺�嶅睘浜庢垜鐨勬暟鎹紝鍒犲ソ鍙嬶紙DISABLE锛変篃淇濈暀锛涘垹浜嗗啀鍔犲洖鏉ユ椂澶囨敞鑷劧寤剁画锛屽巻鍙叉秷鎭噷浠嶄互澶囨敞杈ㄨ瘑
+ */
+export function getFriendDisplayName(friend: Pick<Friend, 'displayName' | 'nickname'>): string {
+ return friend.displayName || friend.nickname
+}
+
+/**
+ * 缇ゆ垚鍛樻樉绀哄悕锛氬ソ鍙嬪娉� > 鐢ㄦ埛缇ゅ娉紙displayUserName锛� > 鐪熷疄鏄电О
+ *
+ * WeChat 浼樺厛绾э細濂藉弸澶囨敞鏄�"鎴�"瀵硅鎴愬憳鐨勭浜虹О鍛硷紝鏈�楂樹紭鍏堬紱鍏舵鏄� ta 鍦ㄧ兢鍐呰嚜瀹氫箟鏄电О锛涙渶鍚庣湡瀹炴樀绉板厹搴曪紱璋冪敤鏂规嬁鍒� friend 鎵嶄紶鍏ワ紝娌℃嬁鍒帮紙闄岀敓浜猴級灏卞彧鐢� member 瀛楁闄嶇骇
+ */
+export function getMemberDisplayName(
+ member: { displayUserName?: string; nickname: string },
+ friend?: null | Pick<Friend, 'displayName'>
+): string {
+ return friend?.displayName || member.displayUserName || member.nickname
+}
+
+/** 缇ゆ樉绀哄悕锛氬綋鍓嶇敤鎴峰璇ョ兢鐨勫娉紙groupRemark锛� > 缇ゅ悕锛坣ame锛� */
+export function getGroupDisplayName(group: Pick<Group, 'groupRemark' | 'name'>): string {
+ return group.groupRemark || group.name
+}
+
+/**
+ * 娑堟伅鍙戦�佽�呮樉绀哄悕锛堜弗鏍肩増锛夛細绠椾笉鍑虹湡鍚嶈繑鍥� undefined
+ *
+ * 缁欓渶瑕�"鏄惁鐪熷悕"淇″彿鐨勮皟鐢ㄦ柟鐢ㄢ�斺�旀瘮濡� conversationStore 鍐冲畾瑕佷笉瑕佸啓 lastSenderDisplayName 蹇収銆佽涓嶈瑙﹀彂 fetchGroupMemberList 鍏滃簳鎷夋垚鍛�
+ *
+ * GROUP 鍦烘櫙涓� member 宸插氨浣嶄紭鍏� displayUserName / 濂藉弸澶囨敞 / 鐪熷疄鏄电О锛沵ember 缂哄け鏃跺尯鍒嗕袱绉� sender锛�
+ * - self 鈫� 鐩存帴鎷� userStore.nickname 鍏滃簳锛堟湰绔案杩滅煡閬撹嚜宸辩殑鏄电О锛屼笉闇�瑕� fetch锛涘悓鏃堕伩鍏� self 閫�缇ゅ悗 GROUP_MEMBER_QUIT 閫氱煡瑙﹀彂鍏滃簳鎷夋垚鍛� 鈫� 403锛�
+ * - 鍏朵粬浜� 鈫� 杩斿洖 undefined锛岃 deriveLastSenderDisplayName 璧板厹搴曟媺鎴愬憳
+ */
+export function tryGetSenderDisplayName(
+ senderId: number,
+ conversationType: number,
+ conversationTargetId: number
+): string | undefined {
+ if (conversationType === ImConversationType.GROUP) {
+ const group = useGroupStore().getGroup(conversationTargetId)
+ const member = group?.members?.find((m) => m.userId === senderId)
+ if (member) {
+ const friend = useFriendStore().getFriend(senderId)
+ return getMemberDisplayName(member, friend)
+ }
+ if (senderId === getCurrentUserId()) {
+ return useUserStore().userInfo?.nickname || undefined
+ }
+ return undefined
+ }
+
+ // PRIVATE / 鏈煡浼氳瘽绫诲瀷锛歴elf 璧� userStore锛屽鏂硅蛋 friend
+ if (senderId === getCurrentUserId()) {
+ return useUserStore().userInfo?.nickname || undefined
+ }
+ if (conversationType === ImConversationType.PRIVATE) {
+ const friend = useFriendStore().getFriend(senderId)
+ return friend ? getFriendDisplayName(friend) : undefined
+ }
+ return undefined
+}
+
+/**
+ * 娑堟伅鍙戦�佽�呮樉绀哄悕锛氭覆鏌撴椂瀹炴椂绠楋紝鎸� WeChat 浼樺厛绾�
+ *
+ * - 鑷繁锛歶serStore.nickname
+ * - 绉佽亰瀵规柟锛氬ソ鍙嬪娉� > 鐪熷疄鏄电О
+ * - 缇よ亰瀵规柟锛氬ソ鍙嬪娉� > 缇ゅ娉紙displayUserName锛� > 鐪熷疄鏄电О
+ * - 鏌ヤ笉鍒帮細fallbackName || (self 璧� userStore.nickname) || String(senderId)
+ */
+export function getSenderDisplayName(
+ senderId: number,
+ conversationType: number,
+ conversationTargetId: number,
+ fallbackName?: string
+): string {
+ const real = tryGetSenderDisplayName(senderId, conversationType, conversationTargetId)
+ if (real) {
+ return real
+ }
+ if (fallbackName) {
+ return fallbackName
+ }
+ // self 鍦� GROUP members 娌″姞杞芥椂锛岃嚦灏戠敤鐪熷疄鏄电О鍏滃簳娓叉煋锛堟瘮 String(senderId) 鍙嬪ソ锛夛紱鍏滃簳鎷夋垚鍛樼敱 conversationStore 瑙﹀彂锛屽洖鏉ュ悗 try 鐗堟湰鑳藉懡涓湡鍚嶈嚜鐒跺埛鏂�
+ const userStore = useUserStore()
+ if (senderId === getCurrentUserId()) {
+ return userStore.userInfo?.nickname || String(senderId)
+ }
+ return String(senderId)
+}
+
+/**
+ * 娑堟伅鍙戦�佽�呫�岀湡瀹炴樀绉般�嶏細姘歌繙鏄� nickname锛屼笉鎺哄娉�
+ *
+ * - 鑷繁锛歶serStore.nickname
+ * - 绉佽亰瀵规柟锛歠riend.nickname
+ * - 缇よ亰瀵规柟锛歮ember.nickname
+ *
+ * 涓撶粰 UserAvatar 鐨� :name 鐢ㄢ�斺�旇壊鍗¢瀛楁瘝 / alt 鏂囨湰瑕佷繚璇佸悓涓�涓汉鍦ㄦ墍鏈夌晫闈竴鑷达紝涓嶈窡澶囨敞鍙�
+ */
+export function getSenderRealNickname(
+ senderId: number,
+ conversationType: number,
+ conversationTargetId: number
+): string {
+ const userStore = useUserStore()
+ const selfUserId = getCurrentUserId()
+
+ // 缇よ亰鍏堣蛋 member.nickname锛坰elf 涔熸槸 member锛夛紝寮傚父鏃跺啀璧� self / senderId 鍏滃簳
+ if (conversationType === ImConversationType.GROUP) {
+ const group = useGroupStore().getGroup(conversationTargetId)
+ const member = group?.members?.find((m) => m.userId === senderId)
+ if (member?.nickname) {
+ return member.nickname
+ }
+ if (senderId === selfUserId) {
+ return userStore.userInfo?.nickname || String(senderId)
+ }
+ return String(senderId)
+ }
+
+ if (conversationType === ImConversationType.PRIVATE) {
+ if (senderId === selfUserId) {
+ return userStore.userInfo?.nickname || String(senderId)
+ }
+ const friend = useFriendStore().getFriend(senderId)
+ return friend?.nickname || String(senderId)
+ }
+
+ if (senderId === selfUserId) {
+ return userStore.userInfo?.nickname || String(senderId)
+ }
+ return String(senderId)
+}
+
+/**
+ * 娑堟伅鍙戦�佽�呭ご鍍忥紱鎸� conversation 涓婁笅鏂囧疄鏃舵煡 group.members / friend / userStore
+ *
+ * - 鑷繁锛歶serStore.avatar
+ * - 绉佽亰瀵规柟锛歠riend.avatar
+ * - 缇よ亰瀵规柟锛氬厛 member.avatar锛岀己鍒欓檷绾� friend.avatar
+ * - 鏌ヤ笉鍒帮細杩斿洖绌轰覆锛岃皟鐢ㄦ柟璧� UserAvatar 鑹插崱鍏滃簳
+ */
+export function getSenderAvatar(
+ senderId: number,
+ conversationType: number,
+ conversationTargetId: number
+): string {
+ const userStore = useUserStore()
+ if (senderId === getCurrentUserId()) {
+ return userStore.userInfo?.avatar || ''
+ }
+ if (conversationType === ImConversationType.GROUP) {
+ const group = useGroupStore().getGroup(conversationTargetId)
+ const member = group?.members?.find((m) => m.userId === senderId)
+ if (member?.avatar) {
+ return member.avatar
+ }
+ }
+ return useFriendStore().getFriend(senderId)?.avatar || ''
+}
+
+/**
+ * 缇ゆ秷鎭� @ mention 鍊欓�夛細鎸� atUserIds 鍙嶆煡缇ゆ垚鍛橈紝name 鏀堕泦鎵�鏈夊彲鑳藉瓧闈㈤噺锛堝惈濂藉弸澶囨敞 / 缇よ嚜瀹氫箟鏄电О锛夛紝displayName 缁熶竴鎸囧悜鐪熷疄鏄电О
+ *
+ * 鍚屽瓧闈㈤噺琚 userId 鍏变韩鏃舵爣璁� ambiguous锛岀敱 parser 鏁存鍚冩垚鏅�氭枃鏈紱
+ * 鐩存帴鍓旈櫎浼氳鐭墠缂�鍊欓�夛紙濡傘�孈寮犮�嶏級鎶㈠悆銆孈寮犱笁銆嶇殑鍓嶇紑锛岄敊缁戝埌鍞竴鐨勩�屽紶銆嶇敤鎴�
+ */
+export function getMentionCandidates(
+ atUserIds: number[] | undefined,
+ conversation: null | Pick<Conversation, 'targetId' | 'type'> | undefined
+): MentionCandidate[] {
+ if (!atUserIds || atUserIds.length === 0) {
+ return EMPTY_MENTIONS
+ }
+ if (!conversation || conversation.type !== ImConversationType.GROUP) {
+ return EMPTY_MENTIONS
+ }
+ // 缇ゆ垚鍛橀寤� Map锛岄伩鍏嶆瘡涓� atUserId 璧颁竴娆� array find锛園鍏ㄤ綋鎴愬憳鍦烘櫙涓嬫垚鍛樻暟 脳 atUserIds 鏄� N虏锛�
+ const members = useGroupStore().getGroup(conversation.targetId)?.members || []
+ const memberById = new Map(members.map((m) => [m.userId, m]))
+ const friendStore = useFriendStore()
+ const candidates: MentionCandidate[] = []
+ const seen = new Set<string>()
+ for (const userId of atUserIds) {
+ // @鍏ㄤ綋鎴愬憳鏄櫄鎷熶吉鎴愬憳锛寀serId = -1 鍦� group.members 閲屾煡涓嶅埌锛屾敞鍏ュ瓧闈㈤噺銆屾墍鏈変汉銆嶅�欓��
+ if (userId === IM_AT_ALL_USER_ID) {
+ const key = `${IM_AT_ALL_USER_ID}#${IM_AT_ALL_NICKNAME}`
+ if (!seen.has(key)) {
+ seen.add(key)
+ candidates.push({
+ userId: IM_AT_ALL_USER_ID,
+ name: IM_AT_ALL_NICKNAME,
+ displayName: IM_AT_ALL_NICKNAME
+ })
+ }
+ continue
+ }
+ const member = memberById.get(userId)
+ const friend = friendStore.getFriend(userId)
+ const nickname = (member?.nickname || friend?.nickname || '').trim()
+ if (!nickname) {
+ continue
+ }
+ for (const literal of [nickname, friend?.displayName, member?.displayUserName]) {
+ const trimmed = (literal || '').trim()
+ if (!trimmed) {
+ continue
+ }
+ const key = `${userId}#${trimmed}`
+ if (seen.has(key)) {
+ continue
+ }
+ seen.add(key)
+ candidates.push({ userId, name: trimmed, displayName: nickname })
+ }
+ }
+ const nameCount = new Map<string, number>()
+ for (const candidate of candidates) {
+ nameCount.set(candidate.name, (nameCount.get(candidate.name) ?? 0) + 1)
+ }
+ for (const candidate of candidates) {
+ if ((nameCount.get(candidate.name) ?? 0) > 1) {
+ candidate.ambiguous = true
+ }
+ }
+ return candidates
+}
+
+/**
+ * 鏂囨湰 / tip 閲� mention 娈电偣鍑荤殑缁熶竴鍏ュ彛锛氭淳鐢� User 寮� UserInfoCard
+ *
+ * 澶村儚璧� getSenderAvatar锛涙樀绉板彇鐪熷疄鏄电О锛宖riend / member 閮芥煡涓嶅埌鏃剁敤 fallbackName 鍏滃簳锛堝鐏版潯 tip 娈电殑瀛楅潰閲忔枃鏈級锛�
+ * addSource 缇よ亰甯� GROUP + 缇ゅ悕锛堢敤浜庛�屽姞涓哄ソ鍙嬨�嶇殑鏉ユ簮 + 澶囨敞寮规枃妗堬級锛岀鑱婇檷绾� SEARCH锛�
+ * @鍏ㄤ綋鎴愬憳 鏄箍鎾� mention 娌℃湁鍏蜂綋鐢ㄦ埛瀹炰綋锛屽榻愬井淇� PC 涓嶅脊鍗$墖
+ */
+export function openMentionUserInfoCardAtEvent(
+ userId: number,
+ event: MouseEvent,
+ fallbackName?: string
+): void {
+ if (userId === IM_AT_ALL_USER_ID) {
+ return
+ }
+ const conversation = useConversationStore().activeConversation
+ const isGroup = conversation?.type === ImConversationType.GROUP
+ const group = isGroup && conversation ? useGroupStore().getGroup(conversation.targetId) : undefined
+ const member = group?.members?.find((m) => m.userId === userId)
+ const friend = useFriendStore().getFriend(userId)
+ const user: User = {
+ id: userId,
+ nickname: friend?.nickname || member?.nickname || fallbackName || String(userId),
+ avatar: getSenderAvatar(userId, conversation?.type ?? 0, conversation?.targetId ?? 0)
+ }
+ useImUiStore().openUserInfoCardAtEvent(
+ user,
+ event,
+ isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
+ isGroup ? group?.name || '' : ''
+ )
+}
+
+/** 鎬у埆鍥炬爣锛沀NKNOWN / null / undefined 涓�寰嬩笉灞曠ず锛屽榻愬井淇$暀鐧� */
+export function getGenderIcon(sex?: number): string {
+ if (sex === SystemUserSexEnum.MALE) {
+ return 'mdi:human-male'
+ }
+ if (sex === SystemUserSexEnum.FEMALE) {
+ return 'mdi:human-female'
+ }
+ return ''
+}
+
+/** 鎬у埆鍥炬爣涓婚鑹诧細鐢疯摑銆佸コ绮� */
+export function getGenderColor(sex?: number): string {
+ if (sex === SystemUserSexEnum.MALE) {
+ return '#5b97f5'
+ }
+ if (sex === SystemUserSexEnum.FEMALE) {
+ return '#f56c92'
+ }
+ return ''
+}
+
+/** 澶村儚鑹插崱搴曡壊璋冭壊鏉匡紙鍙傝�冨井淇★級 */
+const AVATAR_BG_COLORS = ['#07C160', '#1A95FF', '#FA9D3B', '#9163E0', '#F76760', '#1ABC9C']
+
+/** 澶村儚鑹插崱鏂囧瓧锛氫腑鏂囧彇棣栧瓧銆佽嫳鏂囧彇鍓� 2 瀛楁瘝澶у啓銆佸叾浠栧彇棣栧瓧澶у啓銆佺┖鍚嶈繑鍥炵┖涓� */
+export function getAvatarText(name?: string): string {
+ const trimmed = name?.trim()
+ if (!trimmed) {
+ return ''
+ }
+ const first = trimmed.charAt(0)
+ const code = first.codePointAt(0) ?? 0
+ if (code >= 0x4E_00 && code <= 0x9F_A5) {
+ return first
+ }
+ const letters = trimmed.match(/[A-Za-z]/g)
+ if (!letters || letters.length === 0) {
+ return first.toUpperCase()
+ }
+ return letters.slice(0, 2).join('').toUpperCase()
+}
+
+/** 澶村儚鑹插崱搴曡壊锛氭寜 name charCode 涔嬪拰鍙栬皟鑹叉澘鑹诧紝绌哄悕璧伴粯璁ょ伆 */
+export function getAvatarBgColor(name?: string): string {
+ if (!name) {
+ return '#909399'
+ }
+ let hash = 0
+ for (let i = 0; i < name.length; i++) {
+ hash += name.codePointAt(i) ?? 0
+ }
+ return AVATAR_BG_COLORS[hash % AVATAR_BG_COLORS.length] || '#909399'
+}
--
Gitblit v1.9.3