| | |
| | | "diagram-js": "catalog:", |
| | | "fast-xml-parser": "catalog:", |
| | | "highlight.js": "catalog:", |
| | | "livekit-client": "^2.20.0", |
| | | "pinia": "catalog:", |
| | | "steady-xml": "catalog:", |
| | | "tinymce": "catalog:", |
| | |
| | | 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)) |
| | |
| | | '@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==} |
| | | |
| | |
| | | '@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: |
| | |
| | | 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==} |
| | |
| | | '@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==} |
| | | |
| | |
| | | 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==} |
| | |
| | | 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'} |
| | |
| | | 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'} |
| | |
| | | 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==} |
| | |
| | | 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'} |
| | |
| | | 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'} |
| | |
| | | |
| | | 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==} |
| | |
| | | transitivePeerDependencies: |
| | | - '@bpmn-io/cm-theme' |
| | | |
| | | '@bufbuild/protobuf@1.10.0': {} |
| | | |
| | | '@bufbuild/protobuf@2.12.1': {} |
| | | |
| | | '@cacheable/memory@2.0.9': |
| | |
| | | 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 |
| | | |
| | |
| | | '@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) |
| | |
| | | - encoding |
| | | - supports-color |
| | | |
| | | '@marijn/find-cluster-break@1.0.2': {} |
| | | '@marijn/find-cluster-break@1.0.3': {} |
| | | |
| | | '@microsoft/fetch-event-source@2.0.1': {} |
| | | |
| | |
| | | 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': {} |
| | | |
| | |
| | | crc-32: 1.2.2 |
| | | readable-stream: 4.7.0 |
| | | |
| | | crelt@1.0.6: {} |
| | | crelt@1.0.7: {} |
| | | |
| | | croner@10.0.1: {} |
| | | |
| | |
| | | |
| | | jju@1.4.0: {} |
| | | |
| | | jose@6.2.3: {} |
| | | |
| | | js-beautify@1.15.4: |
| | | dependencies: |
| | | config-chain: 1.1.13 |
| | |
| | | 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 |
| | |
| | | dependencies: |
| | | is-unicode-supported: 2.1.0 |
| | | yoctocolors: 2.1.2 |
| | | |
| | | loglevel@1.9.2: {} |
| | | |
| | | loose-envify@1.4.0: |
| | | dependencies: |
| | |
| | | |
| | | scule@1.3.0: {} |
| | | |
| | | sdp-transform@2.15.0: {} |
| | | |
| | | sdp@3.2.2: {} |
| | | |
| | | secure-ls@2.0.0: |
| | | dependencies: |
| | | crypto-js: 4.2.0 |
| | |
| | | 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: {} |
| | |
| | | |
| | | 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 |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ImFriendApi { |
| | | /** IM 好å Response VO */ |
| | | export interface FriendRespVO { |
| | | id: number; // å
³ç³»è®°å½ç¼å· |
| | | friendUserId: number; // 好åçç¨æ·ç¼å· |
| | | silent?: boolean; // æ¯å¦å
ææ° |
| | | displayName?: string; // 好åå±ç¤ºå¤æ³¨ï¼ä»
èªå·±å¯è§ï¼ |
| | | displayNamePinyin?: string; // 夿³¨çæ¼é³ï¼å°åæ ç©ºæ ¼ï¼å端æé¦åæ¯åæ¡¶ / æ¼é³æç´¢ï¼ |
| | | addSource?: number; // æ·»å æ¥æºï¼åè§ ImFriendAddSourceEnum |
| | | pinned?: boolean; // æ¯å¦ç½®é¡¶è系人 |
| | | blocked?: boolean; // æ¯å¦æé» |
| | | status?: number; // 好åç¶æï¼0=æ£å¸¸ï¼1=å·²å é¤ï¼ |
| | | addTime?: string; // æ·»å å¥½åæ¶é´ |
| | | deleteTime?: string; // å é¤å¥½åæ¶é´ |
| | | updateTime?: number; // æè¿æ´æ°æ¶é´ï¼æ¯«ç§æ¶é´æ³ï¼å¢éæå游æ ç¨ï¼ |
| | | nickname?: string; // å¥½åæµç§° |
| | | nicknamePinyin?: string; // æµç§°çæ¼é³ï¼å°åæ ç©ºæ ¼ï¼å端æé¦åæ¯åæ¡¶ / æ¼é³æç´¢ï¼ |
| | | avatar?: string; // 好å头å |
| | | } |
| | | |
| | | /** IM å¥½åæ´æ° Request VO */ |
| | | export interface FriendUpdateReqVO { |
| | | friendUserId: number; // 好åçç¨æ·ç¼å· |
| | | silent?: boolean; // æ¯å¦å
ææ° |
| | | displayName?: string; // 好åå±ç¤ºå¤æ³¨ |
| | | pinned?: boolean; // æ¯å¦ç½®é¡¶è系人 |
| | | } |
| | | } |
| | | |
| | | |
| | | /** è·å¾å½åç»å½ç¨æ·ç好åå表 */ |
| | | export function getMyFriendList() { |
| | | return requestClient.get<ImFriendApi.FriendRespVO[]>('/im/friend/list'); |
| | | } |
| | | |
| | | /** å¢éæåå½åç¨æ·ç好åå
³ç³»ï¼éè¿ / 离线补å¿ï¼ */ |
| | | export function pullMyFriendList(params: { |
| | | lastId?: number; |
| | | lastUpdateTime?: number; |
| | | limit: number; |
| | | }) { |
| | | return requestClient.get<ImFriendApi.FriendRespVO[]>('/im/friend/pull', { params }); |
| | | } |
| | | |
| | | /** è·å¾å¥½å详æ
*/ |
| | | export function getFriend(friendUserId: number | string) { |
| | | return requestClient.get<ImFriendApi.FriendRespVO>('/im/friend/get', { |
| | | params: { friendUserId }, |
| | | }); |
| | | } |
| | | |
| | | /** å é¤å¥½åï¼åå软å é¤ï¼ */ |
| | | export function deleteFriend(friendUserId: number | string, clear: boolean) { |
| | | return requestClient.delete<boolean>('/im/friend/delete', { |
| | | params: { friendUserId, clear }, |
| | | }); |
| | | } |
| | | |
| | | /** æ´æ°å¥½åä¿¡æ¯ï¼å¤æ³¨ / å
ææ° / èç³»äººç½®é¡¶ï¼ */ |
| | | export function updateFriend(data: ImFriendApi.FriendUpdateReqVO) { |
| | | return requestClient.put<boolean>('/im/friend/update', data); |
| | | } |
| | | |
| | | /** æé»å¥½åï¼å¿
é¡»å
æ¯å¥½åï¼åè¾¹å±è½å¯¹æ¹ç§èæ¶æ¯ï¼ */ |
| | | export function blockFriend(friendUserId: number | string) { |
| | | return requestClient.put<boolean>('/im/friend/block', undefined, { |
| | | params: { friendUserId }, |
| | | }); |
| | | } |
| | | |
| | | /** ç§»åºé»åå */ |
| | | export function unblockFriend(friendUserId: number | string) { |
| | | return requestClient.put<boolean>('/im/friend/unblock', undefined, { |
| | | params: { friendUserId }, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ImFriendRequestApi { |
| | | /** IM 好åç³è¯· Response VO */ |
| | | export interface FriendRequestRespVO { |
| | | id: number; // ç³è¯·ç¼å· |
| | | fromUserId: number; // åèµ·æ¹ç¨æ·ç¼å· |
| | | toUserId: number; // æ¥æ¶æ¹ç¨æ·ç¼å· |
| | | handleResult: number; // å¤çç»æï¼0=æªå¤çï¼1=åæï¼2=æç» |
| | | applyContent?: string; // ç³è¯·çç± |
| | | handleContent?: string; // å¤ççç±ï¼æ¥æ¶æ¹æç»æ¶å¯éå¡«ï¼ |
| | | addSource?: number; // æ·»å æ¥æºï¼åè§ ImFriendAddSourceEnum |
| | | handleTime?: string; // å¤çæ¶é´ |
| | | createTime: string; // ç³è¯·å建æ¶é´ |
| | | updateTime?: number; // æè¿æ´æ°æ¶é´ï¼æ¯«ç§æ¶é´æ³ï¼å¢éæå游æ ç¨ï¼ |
| | | fromNickname?: string; // åèµ·æ¹æµç§° |
| | | fromAvatar?: string; // åèµ·æ¹å¤´å |
| | | toNickname?: string; // æ¥æ¶æ¹æµç§° |
| | | toAvatar?: string; // æ¥æ¶æ¹å¤´å |
| | | } |
| | | |
| | | /** IM 好åç³è¯·åèµ· Request VO */ |
| | | export interface FriendRequestApplyReqVO { |
| | | toUserId: number; // æ¥æ¶æ¹ç¨æ·ç¼å· |
| | | applyContent?: string; // ç³è¯·çç± |
| | | displayName?: string; // å¯¹æ¥æ¶æ¹ç夿³¨ï¼ä»
èªå·±å¯è§ï¼ |
| | | addSource?: number; // æ·»å æ¥æº |
| | | } |
| | | } |
| | | |
| | | |
| | | /** å起好åç³è¯· */ |
| | | export function applyFriendRequest(data: ImFriendRequestApi.FriendRequestApplyReqVO) { |
| | | return requestClient.post<null | number>('/im/friend-request/apply', data); |
| | | } |
| | | |
| | | /** åæå¥½åç³è¯· */ |
| | | export function agreeFriendRequest(id: number | string) { |
| | | return requestClient.put<boolean>('/im/friend-request/agree', undefined, { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** æç»å¥½åç³è¯· */ |
| | | export function refuseFriendRequest( |
| | | id: number | string, |
| | | handleContent?: string, |
| | | ) { |
| | | return requestClient.put<boolean>('/im/friend-request/refuse', undefined, { |
| | | params: { id, handleContent }, |
| | | }); |
| | | } |
| | | |
| | | /** æ¥è¯¢ãæç¸å
³ãç好åç³è¯·åè¡¨ï¼æ¸¸æ å页ï¼ä¼ maxId å è½½æ´å¤ï¼ */ |
| | | export function getMyFriendRequestList(limit: number, maxId?: number) { |
| | | const params: Record<string, number> = { limit }; |
| | | if (maxId != null) { |
| | | params.maxId = maxId; |
| | | } |
| | | return requestClient.get<ImFriendRequestApi.FriendRequestRespVO[]>( |
| | | '/im/friend-request/list', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** å¢éæåãæç¸å
³ãç好åç³è¯·åæ´ï¼éè¿ / 离线补å¿ï¼ */ |
| | | export function pullMyFriendRequestList(params: { |
| | | lastId?: number; |
| | | lastUpdateTime?: number; |
| | | limit: number; |
| | | }) { |
| | | return requestClient.get<ImFriendRequestApi.FriendRequestRespVO[]>( |
| | | '/im/friend-request/pull', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** æ id 忥ãæç¸å
³ãçç³è¯·è®°å½ï¼å¸¦è¶æè¿æ»¤ï¼WebSocket éç¥å°è¾¾åç¨ï¼ */ |
| | | export function getMyFriendRequest(id: number) { |
| | | return requestClient.get<ImFriendRequestApi.FriendRequestRespVO | null>( |
| | | '/im/friend-request/get', |
| | | { params: { id } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { ImGroupMessageApi } from '#/api/im/message/group'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ImGroupApi { |
| | | /** 群 Response VO */ |
| | | export interface GroupRespVO { |
| | | id: number; // ç¼å· |
| | | name: string; // 群åç§° |
| | | ownerUserId: number; // ç¾¤ä¸»ç¨æ·ç¼å· |
| | | avatar?: string; // 群头å |
| | | notice?: string; // 群å
Œ |
| | | banned?: boolean; // æ¯å¦å°ç¦ |
| | | mutedAll?: boolean; // æ¯å¦å
¨ç¾¤ç¦è¨ |
| | | joinApproval?: boolean; // è¿ç¾¤æ¯å¦é群主 / 管çåå®¡æ¹ |
| | | bannedTime?: string; // å°ç¦æ¶é´ |
| | | status: number; // ç¾¤ç¶æï¼0=æ£å¸¸ï¼1=已解æ£ï¼ |
| | | dissolvedTime?: string; // è§£æ£æ¶é´ |
| | | createTime?: string; // å建æ¶é´ |
| | | pinnedMessages?: ImGroupMessageApi.GroupMessageRespVO[]; // ç¾¤ç½®é¡¶æ¶æ¯å表ï¼å端å
³èåå¡«ï¼ä»
å½ç»å½ç¨æ·æ¯ç¾¤æåæ¶éç©ºï¼ |
| | | joinStatus?: number; // å½åç»å½ç¨æ·å¨è¯¥ç¾¤çæåç¶æï¼åè§ CommonStatusEnumï¼0 å¨ç¾¤ / 1 å·²é群ï¼ï¼åå²é群群ä»è¿åï¼ä¾å±ç¤ºç¦»çº¿æ¶æ¯ç群å / 头å |
| | | groupRemark?: string; // å½åç»å½ç¨æ·å¯¹è¯¥ç¾¤ç夿³¨ |
| | | silent?: boolean; // å½åç»å½ç¨æ·æ¯å¦å
ææ° |
| | | } |
| | | |
| | | /** ç¾¤æ¶æ¯ç½®é¡¶ / åæ¶ç½®é¡¶ Request VO */ |
| | | export interface GroupMessagePinReqVO { |
| | | id: number; // 群ç¼å· |
| | | messageId: number; // æ¶æ¯ç¼å· |
| | | } |
| | | |
| | | /** 群å建 Request VO */ |
| | | export interface GroupCreateReqVO { |
| | | name: string; // 群åç§° |
| | | memberUserIds?: number[]; // åå§æåç¨æ·ç¼å·å表ï¼å»ºç¾¤åæ¶é请ç好åï¼ä¸å«å建è
èªå·±ï¼ |
| | | joinApproval?: boolean; // è¿ç¾¤æ¯å¦é审æ¹ï¼ä¸ä¼ é»è®¤ false èªç±è¿ç¾¤ |
| | | } |
| | | |
| | | /** ç¾¤æ´æ° Request VO */ |
| | | export interface GroupUpdateReqVO { |
| | | id: number; // 群ç¼å· |
| | | name?: string; // 群åç§° |
| | | avatar?: string; // 群头å |
| | | notice?: string; // 群å
Œ |
| | | joinApproval?: boolean; // è¿ç¾¤æ¯å¦éå®¡æ¹ |
| | | } |
| | | |
| | | /** æ·»å / æ¤é群管çå Request VO */ |
| | | export interface GroupAdminReqVO { |
| | | id: number; // 群ç¼å· |
| | | userIds: number[]; // ç®æ ç¨æ·ç¼å·å表 |
| | | } |
| | | |
| | | /** 群主转让 Request VO */ |
| | | export interface GroupTransferOwnerReqVO { |
| | | id: number; // 群ç¼å· |
| | | newOwnerUserId: number; // æ°ç¾¤ä¸»ç¨æ·ç¼å· |
| | | } |
| | | |
| | | /** å
¨ç¾¤ç¦è¨ / åæ¶ Request VO */ |
| | | export interface GroupMuteAllReqVO { |
| | | id: number; // 群ç¼å· |
| | | mutedAll: boolean; // æ¯å¦å
¨ç¾¤ç¦è¨ |
| | | } |
| | | |
| | | /** æåç¦è¨ Request VO */ |
| | | export interface GroupMuteMemberReqVO { |
| | | id: number; // 群ç¼å· |
| | | userId: number; // 被ç¦è¨çç¨æ·ç¼å· |
| | | mutedSeconds: number; // ç¦è¨æ¶é¿ï¼ç§ï¼ï¼0 表示永ä¹
ç¦è¨ |
| | | } |
| | | |
| | | /** åæ¶æåç¦è¨ Request VO */ |
| | | export interface GroupCancelMuteMemberReqVO { |
| | | id: number; // 群ç¼å· |
| | | userId: number; // è¢«åæ¶ç¦è¨çç¨æ·ç¼å· |
| | | } |
| | | } |
| | | |
| | | |
| | | /** è·å¾å½åç»å½ç¨æ·ç群å表 */ |
| | | export function getMyGroupList() { |
| | | return requestClient.get<ImGroupApi.GroupRespVO[]>('/im/group/list'); |
| | | } |
| | | |
| | | /** è·å¾ç¾¤è¯¦æ
*/ |
| | | export function getGroup(id: number | string) { |
| | | return requestClient.get<ImGroupApi.GroupRespVO>('/im/group/get', { params: { id } }); |
| | | } |
| | | |
| | | /** å建群 */ |
| | | export function createGroup(data: ImGroupApi.GroupCreateReqVO) { |
| | | return requestClient.post<ImGroupApi.GroupRespVO>('/im/group/create', data); |
| | | } |
| | | |
| | | /** æ´æ°ç¾¤ */ |
| | | export function updateGroup(data: ImGroupApi.GroupUpdateReqVO) { |
| | | return requestClient.put<ImGroupApi.GroupRespVO>('/im/group/update', data); |
| | | } |
| | | |
| | | /** è§£æ£ç¾¤ */ |
| | | export function dissolveGroup(id: number | string) { |
| | | return requestClient.delete<boolean>('/im/group/dissolve', { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** æ·»å 群管çåï¼ä»
群主å¯è°ï¼ */ |
| | | export function addGroupAdmin(data: ImGroupApi.GroupAdminReqVO) { |
| | | return requestClient.put<boolean>('/im/group/add-admin', data); |
| | | } |
| | | |
| | | /** æ¤é群管çåï¼ä»
群主å¯è°ï¼ */ |
| | | export function removeGroupAdmin(data: ImGroupApi.GroupAdminReqVO) { |
| | | return requestClient.put<boolean>('/im/group/remove-admin', data); |
| | | } |
| | | |
| | | /** 转让群主ï¼ä»
è群主å¯è°ï¼æ§ç¾¤ä¸»è½¬è®©åé为æ®éæåï¼ */ |
| | | export function transferGroupOwner(data: ImGroupApi.GroupTransferOwnerReqVO) { |
| | | return requestClient.put<boolean>('/im/group/transfer-owner', data); |
| | | } |
| | | |
| | | /** ç½®é¡¶ç¾¤æ¶æ¯ï¼ä»
群主 / 管çåå¯è°ï¼ */ |
| | | export function pinGroupMessage(data: ImGroupApi.GroupMessagePinReqVO) { |
| | | return requestClient.put<boolean>('/im/group/pin-message', data); |
| | | } |
| | | |
| | | /** åæ¶ç½®é¡¶ç¾¤æ¶æ¯ï¼ä»
群主 / 管çåå¯è°ï¼ */ |
| | | export function unpinGroupMessage(data: ImGroupApi.GroupMessagePinReqVO) { |
| | | return requestClient.put<boolean>('/im/group/unpin-message', data); |
| | | } |
| | | |
| | | /** å
¨ç¾¤ç¦è¨ / åæ¶ï¼ä»
群主 / 管çåå¯è°ï¼ */ |
| | | export function muteAll(data: ImGroupApi.GroupMuteAllReqVO) { |
| | | return requestClient.put<boolean>('/im/group/mute-all', data); |
| | | } |
| | | |
| | | /** ç¦è¨æå */ |
| | | export function muteMember(data: ImGroupApi.GroupMuteMemberReqVO) { |
| | | return requestClient.put<boolean>('/im/group/mute-member', data); |
| | | } |
| | | |
| | | /** åæ¶æåç¦è¨ */ |
| | | export function cancelMuteMember(data: ImGroupApi.GroupCancelMuteMemberReqVO) { |
| | | return requestClient.put<boolean>('/im/group/cancel-mute-member', data); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ImGroupRequestApi { |
| | | /** IM å 群ç³è¯· Response VO */ |
| | | export interface GroupRequestRespVO { |
| | | id: number; // ç³è¯·ç¼å· |
| | | groupId: number; // 群ç¼å· |
| | | userId: number; // ç³è¯·äºº / 被éè¯·äººç¨æ·ç¼å· |
| | | inviterUserId?: number; // é请人ï¼NULL è¡¨ç¤ºç¨æ·ä¸»å¨ç³è¯· |
| | | handleResult: number; // å¤çç»æï¼0=æªå¤çï¼1=åæï¼2=æç» |
| | | applyContent?: string; // ç³è¯·çç± |
| | | handleContent?: string; // å¤ççç±ï¼æç»æ¶å¯éå¡«ï¼ |
| | | handleUserId?: number; // å¤çäººç¨æ·ç¼å· |
| | | addSource?: number; // å å
¥æ¥æºï¼åè§ ImGroupAddSourceEnum |
| | | handleTime?: string; // å¤çæ¶é´ |
| | | createTime: string; // ç³è¯·å建æ¶é´ |
| | | updateTime?: number; // æè¿æ´æ°æ¶é´ï¼æ¯«ç§æ¶é´æ³ï¼å¢éæå游æ ç¨ï¼ |
| | | userNickname?: string; // ç³è¯·äºº / 被é请人æµç§° |
| | | userAvatar?: string; // ç³è¯·äºº / 被é请人头å |
| | | inviterNickname?: string; // é请人æµç§° |
| | | inviterAvatar?: string; // é请人头å |
| | | groupName?: string; // 群åç§° |
| | | groupAvatar?: string; // 群头å |
| | | } |
| | | |
| | | /** IM å 群ç³è¯·åèµ· Request VO */ |
| | | export interface GroupRequestApplyReqVO { |
| | | groupId: number; // 群ç¼å· |
| | | applyContent?: string; // ç³è¯·çç± |
| | | addSource?: number; // å å
¥æ¥æº |
| | | } |
| | | } |
| | | |
| | | |
| | | /** ç³è¯·å 群 */ |
| | | export function applyJoinGroup(data: ImGroupRequestApi.GroupRequestApplyReqVO) { |
| | | return requestClient.post<null | number>('/im/group-request/apply', data); |
| | | } |
| | | |
| | | /** åæå 群ç³è¯·ï¼ç¾¤ä¸»æç®¡çåï¼ */ |
| | | export function agreeGroupRequest(id: number | string) { |
| | | return requestClient.put<boolean>('/im/group-request/agree', undefined, { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** æç»å 群ç³è¯·ï¼ç¾¤ä¸»æç®¡çåï¼ */ |
| | | export function refuseGroupRequest( |
| | | id: number | string, |
| | | handleContent?: string, |
| | | ) { |
| | | return requestClient.put<boolean>('/im/group-request/refuse', undefined, { |
| | | params: { id, handleContent }, |
| | | }); |
| | | } |
| | | |
| | | /** æ¥è¯¢ãæç®¡ççææç¾¤ãä¸çæªå¤çå 群ç³è¯·å表ï¼ä¸å页ï¼ï¼å端 store æ®æ¤æ´¾ç横å¹
çº¢ç¹ + Drawer å表 */ |
| | | export function getUnhandledRequestList() { |
| | | return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>( |
| | | '/im/group-request/unhandled-list', |
| | | ); |
| | | } |
| | | |
| | | /** æ¥è¯¢æå®ç¾¤ä¸çå
¨é¨å 群ç³è¯·ï¼å«å·²å¤çï¼ï¼ä»
群主 / 管çå坿¥ */ |
| | | export function getGroupRequestListByGroupId(groupId: number) { |
| | | return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>( |
| | | '/im/group-request/list-by-group', |
| | | { params: { groupId } }, |
| | | ); |
| | | } |
| | | |
| | | /** æ id 忥ç³è¯·è®°å½ï¼å¸¦è¶æè¿æ»¤ï¼WebSocket éç¥å°è¾¾åç¨ï¼ */ |
| | | export function getMyGroupRequest(id: number) { |
| | | return requestClient.get<ImGroupRequestApi.GroupRequestRespVO | null>( |
| | | '/im/group-request/get', |
| | | { params: { id } }, |
| | | ); |
| | | } |
| | | |
| | | /** å¢éæåæç®¡ççææç¾¤ä¸å 群ç³è¯·åæ´ï¼éè¿ / 离线补å¿ï¼ */ |
| | | export function pullMyGroupRequestList(params: { |
| | | lastId?: number; |
| | | lastUpdateTime?: number; |
| | | limit: number; |
| | | }) { |
| | | return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>( |
| | | '/im/group-request/pull', |
| | | { params }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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', |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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(',') } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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(',') }, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '..\..\..\..\packages\effects\request\src'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ImManagerGroupApi { |
| | | /** 群 */ |
| | | export interface Group { |
| | | id: number; |
| | | name: string; |
| | | avatar?: string; |
| | | notice?: string; |
| | | ownerUserId: number; |
| | | ownerNickname?: string; |
| | | memberCount?: number; |
| | | status: number; |
| | | banned: boolean; |
| | | mutedAll?: boolean; // æ¯å¦å
¨ç¾¤ç¦è¨ |
| | | bannedReason?: string; |
| | | bannedTime?: Date; |
| | | dissolvedTime?: Date; |
| | | createTime: Date; |
| | | } |
| | | |
| | | /** 群æå */ |
| | | export interface GroupMember { |
| | | userId: number; |
| | | nickname?: string; |
| | | avatar?: string; |
| | | displayUserName?: string; |
| | | groupRemark?: string; |
| | | silent?: boolean; |
| | | status: number; |
| | | role?: number; // æåè§è²ï¼åè§ ImGroupMemberRole æä¸¾ |
| | | joinTime?: Date; |
| | | quitTime?: Date; |
| | | muteEndTime?: Date; // ç¦è¨å°ææ¶é´ |
| | | } |
| | | |
| | | /** 群å°ç¦è¯·æ± */ |
| | | export interface GroupBanReqVO { |
| | | id: number; |
| | | reason: string; |
| | | } |
| | | } |
| | | |
| | | |
| | | /** è·å¾ç¾¤å页 */ |
| | | export function getManagerGroupPage(params: PageParam) { |
| | | return requestClient.get<PageResult<ImManagerGroupApi.Group>>( |
| | | '/im/manager/group/page', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** è·å¾ç¾¤è¯¦æ
*/ |
| | | export function getManagerGroup(id: number) { |
| | | return requestClient.get<ImManagerGroupApi.Group>('/im/manager/group/get', { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** å°ç¦ç¾¤ */ |
| | | export function banManagerGroup(data: ImManagerGroupApi.GroupBanReqVO) { |
| | | return requestClient.put<boolean>('/im/manager/group/ban', data); |
| | | } |
| | | |
| | | /** è§£å°ç¾¤ */ |
| | | export function unbanManagerGroup(id: number) { |
| | | return requestClient.put<boolean>('/im/manager/group/unban', undefined, { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** è§£æ£ç¾¤ */ |
| | | export function dissolveManagerGroup(id: number) { |
| | | return requestClient.delete<boolean>('/im/manager/group/dissolve', { |
| | | params: { id }, |
| | | }); |
| | | } |
| | | |
| | | /** è·å¾ç¾¤æåå表ï¼å«å·²é群æåï¼ç±å端æéè¿æ»¤ï¼ */ |
| | | export function getManagerGroupMemberList(groupId: number) { |
| | | return requestClient.get<ImManagerGroupApi.GroupMember[]>( |
| | | '/im/manager/group/member/list', |
| | | { params: { groupId } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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(',') } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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', |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ImGroupMessageApi { |
| | | /** ç¾¤èæ¶æ¯ Response VO */ |
| | | export interface GroupMessageRespVO { |
| | | id: number; // æ¶æ¯ç¼å· |
| | | clientMessageId: string; // 客æ·ç«¯æ¶æ¯ç¼å· |
| | | senderId: number; // åé人ç¼å· |
| | | groupId: number; // 群ç¼å· |
| | | type: number; // å
容类å |
| | | content: string; // æ¶æ¯å
容ï¼JSON æ ¼å¼ï¼ |
| | | status: number; // æ¶æ¯ç¶æ |
| | | sendTime: string; // åéæ¶é´ |
| | | atUserIds?: number[]; // @ ç®æ ç¨æ·ç¼å·å表 |
| | | receiverUserIds?: number[]; // å®åæ¥æ¶ç¨æ·ç¼å·å表 |
| | | receiptStatus?: number; // åæ§ç¶æ |
| | | readCount?: number; // 已读人æ°ï¼åæ§æ¶æ¯ãä¸åé人为å½åç¨æ·æ¶æå¼ï¼ |
| | | } |
| | | |
| | | /** ç¾¤èæ¶æ¯åé Request VO */ |
| | | export interface GroupMessageSendReqVO { |
| | | clientMessageId: string; // 客æ·ç«¯æ¶æ¯ç¼å· |
| | | groupId: number; // 群ç¼å· |
| | | type: number; // å
容类å |
| | | content: string; // æ¶æ¯å
容ï¼JSON æ ¼å¼ï¼ |
| | | atUserIds?: number[]; // @ ç®æ ç¨æ·ç¼å·å表 |
| | | receipt?: boolean; // æ¯å¦éè¦åæ§ |
| | | } |
| | | |
| | | /** 群èå岿¶æ¯å表 Request VO */ |
| | | export interface GroupMessageListReqVO { |
| | | groupId: number | string; // 群ç¼å· |
| | | maxId?: number | string; // èµ·å§æ¶æ¯ç¼å·ï¼ä¸å«ï¼ï¼ä¸ºç©ºåä»ææ°æ¶æ¯å¼å§ |
| | | limit: number; // æåæ°éï¼1 ~ 200ï¼ |
| | | } |
| | | } |
| | | |
| | | |
| | | /** åéç¾¤èæ¶æ¯ */ |
| | | export function sendGroupMessage(data: ImGroupMessageApi.GroupMessageSendReqVO) { |
| | | return requestClient.post<ImGroupMessageApi.GroupMessageRespVO>( |
| | | '/im/message/group/send', |
| | | data, |
| | | ); |
| | | } |
| | | |
| | | /** æåç¾¤èæ¶æ¯ï¼å¢éï¼ */ |
| | | export function pullGroupMessageList( |
| | | params: { minId: number | string; size: number }, |
| | | signal?: AbortSignal, |
| | | ) { |
| | | return requestClient.get<ImGroupMessageApi.GroupMessageRespVO[]>( |
| | | '/im/message/group/pull', |
| | | { params, signal }, |
| | | ); |
| | | } |
| | | |
| | | /** æ¥è¯¢ç¾¤èå岿¶æ¯ */ |
| | | export function getGroupMessageList(params: ImGroupMessageApi.GroupMessageListReqVO) { |
| | | return requestClient.get<ImGroupMessageApi.GroupMessageRespVO[]>( |
| | | '/im/message/group/list', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** æ è®°ç¾¤èæ¶æ¯å·²è¯» */ |
| | | export function readGroupMessages( |
| | | groupId: number | string, |
| | | messageId: number | string, |
| | | ) { |
| | | return requestClient.put<boolean>('/im/message/group/read', undefined, { |
| | | params: { groupId, messageId }, |
| | | }); |
| | | } |
| | | |
| | | /** æ¤åç¾¤èæ¶æ¯ */ |
| | | export function recallGroupMessage(id: number | string) { |
| | | return requestClient.delete<ImGroupMessageApi.GroupMessageRespVO>( |
| | | '/im/message/group/recall', |
| | | { params: { id } }, |
| | | ); |
| | | } |
| | | |
| | | /** è·åç¾¤æ¶æ¯å·²è¯»ç¨æ·å表 */ |
| | | export function getGroupReadUsers(params: { |
| | | groupId: number | string; |
| | | messageId: number | string; |
| | | }) { |
| | | return requestClient.get<number[]>('/im/message/group/get-read-user-ids', { |
| | | params, |
| | | }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ImPrivateMessageApi { |
| | | /** ç§èæ¶æ¯ Response VO */ |
| | | export interface PrivateMessageRespVO { |
| | | id: number; // æ¶æ¯ç¼å· |
| | | clientMessageId: string; // 客æ·ç«¯æ¶æ¯ç¼å· |
| | | senderId: number; // åé人ç¼å· |
| | | receiverId: number; // æ¥æ¶äººç¼å· |
| | | type: number; // å
容类å |
| | | content: string; // æ¶æ¯å
容ï¼JSON æ ¼å¼ï¼ |
| | | status: number; // æ¶æ¯ç¶æï¼æ£å¸¸ / å·²æ¤åï¼ |
| | | receiptStatus?: number; // åæ§ç¶æï¼ä¸éè¦ / å¾
宿 / 已宿ï¼ï¼å¯¹é½ ImMessageReceiptStatus |
| | | sendTime: string; // åéæ¶é´ |
| | | } |
| | | |
| | | /** ç§èæ¶æ¯åé Request VO */ |
| | | export interface PrivateMessageSendReqVO { |
| | | clientMessageId: string; // 客æ·ç«¯æ¶æ¯ç¼å· |
| | | receiverId: number; // æ¥æ¶äººç¼å· |
| | | type: number; // å
容类å |
| | | content: string; // æ¶æ¯å
容ï¼JSON æ ¼å¼ï¼ |
| | | receipt?: boolean; // æ¯å¦éè¦åæ§ï¼ä¸ä¼ å端é»è®¤ trueï¼æ®éç§èç¨æ·æ¶æ¯ï¼ |
| | | } |
| | | |
| | | /** ç§èå岿¶æ¯å表 Request VO */ |
| | | export interface PrivateMessageListReqVO { |
| | | receiverId: number | string; // æ¥æ¶äººç¼å·ï¼å¯¹æ¹ï¼ |
| | | maxId?: number | string; // èµ·å§æ¶æ¯ç¼å·ï¼ä¸å«ï¼ï¼ä¸ºç©ºåä»ææ°æ¶æ¯å¼å§ |
| | | limit: number; // æåæ°éï¼1 ~ 200ï¼ |
| | | } |
| | | } |
| | | |
| | | |
| | | /** åéç§èæ¶æ¯ */ |
| | | export function sendPrivateMessage(data: ImPrivateMessageApi.PrivateMessageSendReqVO) { |
| | | return requestClient.post<ImPrivateMessageApi.PrivateMessageRespVO>( |
| | | '/im/message/private/send', |
| | | data, |
| | | ); |
| | | } |
| | | |
| | | /** æåç§èæ¶æ¯ï¼å¢éï¼ */ |
| | | export function pullPrivateMessageList( |
| | | params: { minId: number | string; size: number }, |
| | | signal?: AbortSignal, |
| | | ) { |
| | | return requestClient.get<ImPrivateMessageApi.PrivateMessageRespVO[]>( |
| | | '/im/message/private/pull', |
| | | { params, signal }, |
| | | ); |
| | | } |
| | | |
| | | /** æ¥è¯¢ç§èå岿¶æ¯ */ |
| | | export function getPrivateMessageList(params: ImPrivateMessageApi.PrivateMessageListReqVO) { |
| | | return requestClient.get<ImPrivateMessageApi.PrivateMessageRespVO[]>( |
| | | '/im/message/private/list', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** æ è®°ç§èæ¶æ¯å·²è¯» */ |
| | | export function readPrivateMessages( |
| | | receiverId: number | string, |
| | | messageId: number | string, |
| | | ) { |
| | | return requestClient.put<boolean>('/im/message/private/read', undefined, { |
| | | params: { receiverId, messageId }, |
| | | }); |
| | | } |
| | | |
| | | /** æ¥è¯¢å¯¹æ¹å·²è¯»å°æåçæå¤§æ¶æ¯ idï¼å¤ç«¯ / 离线åç¨äºè¡¥é½å·²è¯»ç¶æï¼ */ |
| | | export function getPrivateMaxReadMessageId( |
| | | peerId: number | string, |
| | | signal?: AbortSignal, |
| | | ) { |
| | | return requestClient.get<null | number>( |
| | | '/im/message/private/max-read-message-id', |
| | | { params: { peerId }, signal }, |
| | | ); |
| | | } |
| | | |
| | | /** æ¤åç§èæ¶æ¯ */ |
| | | export function recallPrivateMessage(id: number | string) { |
| | | return requestClient.delete<ImPrivateMessageApi.PrivateMessageRespVO>( |
| | | '/im/message/private/recall', |
| | | { params: { id } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ImRtcApi { |
| | | /** å建æ°éè¯è¯·æ± VO */ |
| | | export interface RtcCallCreateReqVO { |
| | | conversationType: number; |
| | | mediaType: number; |
| | | groupId?: number; |
| | | inviteeIds: number[]; // 被é请çç¨æ·ç¼å·éåï¼ç§èå¿
ä¼ 1 个对端ï¼ç¾¤èå¿
ä¼ è³å° 1 人 |
| | | } |
| | | |
| | | /** éè¯ä¸è¿½å éè¯·è¯·æ± VOï¼ä»
群éè¯å¯ç¨ */ |
| | | export interface RtcCallInviteReqVO { |
| | | room: string; |
| | | inviteeIds: number[]; |
| | | } |
| | | |
| | | /** éè¯ä¼è¯ VOï¼create / join / accept / refreshToken å
±ç¨ */ |
| | | export interface RtcCallRespVO { |
| | | room: string; // ä¸å¡éè¯ç¼å·ï¼åæ¶ä½ä¸º LiveKit æ¿é´åï¼ |
| | | livekitUrl: string; |
| | | token?: string; // ENDED ç¶ææ¶ä¸º nullï¼æ é connect LiveKitï¼ |
| | | conversationType: number; |
| | | mediaType: number; |
| | | status: number; |
| | | endReason?: number; // ç»æåå ï¼ä»
status=ENDED æ¶æå¼ |
| | | inviterId: number; |
| | | groupId?: number; |
| | | inviteeIds?: number[]; |
| | | joinedUserIds?: number[]; |
| | | } |
| | | |
| | | /** 群活è·éè¯æ¥è¯¢ååºï¼ä¸å« token */ |
| | | export interface RtcGroupCallRespVO { |
| | | room: string; |
| | | groupId: number; |
| | | mediaType: number; |
| | | inviterId: number; |
| | | joinedUserIds?: number[]; |
| | | inviteeIds?: number[]; |
| | | } |
| | | } |
| | | |
| | | |
| | | /** å建æ°éè¯ï¼ç§èæç¾¤èæ ¹æ® conversationType åºå */ |
| | | export function createCall(data: ImRtcApi.RtcCallCreateReqVO) { |
| | | return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/create', data); |
| | | } |
| | | |
| | | /** éè¯ä¸è¿½å é请ï¼ä»
群éè¯å¯ç¨ */ |
| | | export function inviteCall(data: ImRtcApi.RtcCallInviteReqVO) { |
| | | return requestClient.post<boolean>('/im/rtc/invite', data); |
| | | } |
| | | |
| | | /** å å
¥å·²æç¾¤éè¯ï¼ç¨äºè¶åæ¡ãå å
¥ãæé® */ |
| | | export function joinCall(room: string) { |
| | | return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/join', undefined, { |
| | | params: { room }, |
| | | }); |
| | | } |
| | | |
| | | /** æ¥å¬éè¯ */ |
| | | export function acceptCall(room: string) { |
| | | return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/accept', undefined, { |
| | | params: { room }, |
| | | }); |
| | | } |
| | | |
| | | /** æç»éè¯ */ |
| | | export function rejectCall(room: string) { |
| | | return requestClient.post<boolean>('/im/rtc/reject', undefined, { |
| | | params: { room }, |
| | | }); |
| | | } |
| | | |
| | | /** åæ¶é请ï¼ä¸»å«æ¥éåè°ç¨ */ |
| | | export function cancelCall(room: string) { |
| | | return requestClient.post<boolean>('/im/rtc/cancel', undefined, { |
| | | params: { room }, |
| | | }); |
| | | } |
| | | |
| | | /** 离å¼éè¯ï¼æ¥éåè°ç¨ */ |
| | | export function leaveCall(room: string) { |
| | | return requestClient.post<boolean>('/im/rtc/leave', undefined, { |
| | | params: { room }, |
| | | }); |
| | | } |
| | | |
| | | /** æ¯éè¶
æ¶æ£æ¥ï¼RUNNING 端 timer å
åºï¼è§¦åå端ç«å³æ«æè¯¥ room çè¶
æ¶ INVITINGï¼æ¥å£éé»ï¼ */ |
| | | export function noAnswerCallCheck(room: string) { |
| | | return requestClient.post<boolean>( |
| | | '/im/rtc/no-answer-call-check', |
| | | undefined, |
| | | { params: { room } }, |
| | | ); |
| | | } |
| | | |
| | | /** æ¥è¯¢å½åè¿è¡ä¸çéè¯ï¼ç®åä»
群èåºæ¯ï¼è¶åæ¡ï¼ï¼è¿å null è¡¨ç¤ºæ æ´»è·éè¯ */ |
| | | export function getActiveCall(groupId: number) { |
| | | return requestClient.get<ImRtcApi.RtcGroupCallRespVO | null>( |
| | | '/im/rtc/get-active-call', |
| | | { params: { groupId } }, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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; |
| | |
| | | fieldName: 'isBatchManaged', |
| | | label: 'æ¹æ¬¡ç®¡ç', |
| | | componentProps: { |
| | | class: 'w-auto', |
| | | checkedChildren: 'æ¯', |
| | | unCheckedChildren: 'å¦', |
| | | }, |
| | |
| | | component: 'Textarea', |
| | | fieldName: 'remark', |
| | | label: '夿³¨', |
| | | formItemClass: 'col-span-2', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥å¤æ³¨', |
| | | rows: 3, |
| | |
| | | |
| | | 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'; |
| | | |
| | |
| | | |
| | | 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'; |
| | | |
| | |
| | | componentProps: { |
| | | class: 'w-full', |
| | | }, |
| | | formItemClass: 'col-span-1', |
| | | labelWidth: 100, |
| | | }, |
| | | layout: 'horizontal', |
| | | schema: useFormSchema(), |
| | | showDefaultActions: false, |
| | | wrapperClass: 'grid-cols-2', |
| | | }); |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue' |
| | | |
| | | import { isPrivateConversation } from '#/views/im/utils/constants' |
| | | import { |
| | | type CardMessage, |
| | | type CardTarget, |
| | | getCardLabelInfo |
| | | } from '#/views/im/utils/message' |
| | | |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImCardBubble' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** åçæ°æ®ï¼CardMessageï¼æ¥æ¶ä¾§æ¶æ¯ä½ï¼æ CardTargetï¼åéä¾§é¢è§ï¼å
±ç¨ç»æ */ |
| | | card: CardMessage | CardTarget |
| | | /** æ¯å¦æ¾ç¤º cursor: pointerï¼è°ç¨æ¹è´è´£ç» @click çå¬ */ |
| | | clickable?: boolean |
| | | }>(), |
| | | { clickable: false } |
| | | ) |
| | | |
| | | /** æ¯å¦ç¨æ·åçï¼å³å® UserAvatar æ¯å¦å¸¦ id 触å UserInfoCard */ |
| | | const isUser = computed(() => isPrivateConversation(props.card.targetType)) |
| | | /** åçæ ç¾ä¿¡æ¯ */ |
| | | const labelInfo = computed(() => getCardLabelInfo(props.card)) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | åçæ¶æ¯æ°æ³¡ / åçé¢è§å¡ï¼240pxï¼ï¼ç¨æ·åç + 群åçéç¨ |
| | | - 头å + åå + 群æåæ°å¯æ é¢ï¼ä»
群åçï¼+ åºé¨åéæ¡ã群åç / 个人åçã |
| | | - ç¨æ·åçæ :id ä¼ ç» UserAvatar 让ç¹å» avatar å¼¹ UserInfoCardï¼ç¾¤åçä¸ä¼ id |
| | | - æ´å¡ click ç±è°ç¨æ¹çå¬ï¼@clickï¼ï¼ç»ä»¶ä¸å
åµä¸å¡é»è¾ |
| | | --> |
| | | <div |
| | | class="flex flex-col w-[240px] rounded-md overflow-hidden bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]" |
| | | :class="{ 'cursor-pointer': clickable }" |
| | | > |
| | | <div class="flex gap-2.5 items-center px-3 py-2.5"> |
| | | <UserAvatar |
| | | :id="isUser ? card.targetId : undefined" |
| | | :url="card.avatar" |
| | | :name="card.name" |
| | | :size="40" |
| | | :clickable="false" |
| | | /> |
| | | <div class="flex flex-col flex-1 min-w-0"> |
| | | <div class="text-sm font-medium truncate text-[var(--ant-color-text)]"> |
| | | {{ card.name }} |
| | | </div> |
| | | <div |
| | | v-if="!isUser && card.memberCount" |
| | | class="text-12px truncate text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | {{ card.memberCount }} 人群è |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div |
| | | class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--im-border-color-lighter)] bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | {{ labelInfo.label }} |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { getCardLabelInfo } from '#/views/im/utils/message' |
| | | |
| | | defineOptions({ name: 'ImCardLineLabel' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** åçæ°æ®ï¼åªè¯» targetType / name æ´¾çæ ç¾ + æ¾ç¤ºï¼ç»ææ§ç±»åå
¼å®¹ CardMessage / å¼ç¨é¢è§ç partial */ |
| | | card: null | undefined | { name?: string; targetType?: number; } |
| | | iconSize?: number |
| | | }>(), |
| | | { iconSize: 14 } |
| | | ) |
| | | |
| | | /** æ ç¾ + 徿 æ targetType äºåï¼å
åºã个人åçãé¿å
null æ¶ UI ç©ºç½ */ |
| | | const labelInfo = computed(() => getCardLabelInfo(props.card)) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- åçåè¡ inline labelï¼ã[icon] 群åç / 个人åçï¼xxãï¼å表æè¦ / å¼ç¨é¢è§ / åå°é¢è§å¤ç¨ --> |
| | | <span class="inline-flex gap-1.5 items-center"> |
| | | <Icon :icon="labelInfo.icon" :size="iconSize" /> |
| | | <span>{{ labelInfo.label }}ï¼{{ card?.name || '' }}</span> |
| | | </span> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | export { default as CardBubble } from './card-bubble.vue'; |
| | | export { default as CardLineLabel } from './card-line-label.vue'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { useImUiStore } from '../store/uiStore' |
| | | |
| | | defineOptions({ name: 'ImContextMenu' }) |
| | | |
| | | const uiStore = useImUiStore() |
| | | const contextMenu = computed(() => uiStore.contextMenu) |
| | | |
| | | /** |
| | | * 计ç®èåå®é
渲æåæ ï¼é è¿è§å£å³ / ä¸è¾¹ç¼æ¶åå¼¹ï¼é¿å
èå被è£åª |
| | | * |
| | | * itemHeight / menuWidth æ¯å模æ¿é px-4 py-2 + text-13px / min-w-30 é
å¥çå®é
å°ºå¯¸ï¼ |
| | | * dividerHeight = 9pxï¼my-1 ä¸ä¸å 4 + 1px borderï¼ï¼ä»
éé¦é¡¹ç divided 计å
¥ï¼ |
| | | * menuHeight é¢å¤å 8 æ¯å¤å± py-1 çä¸ä¸ padding ä¹åï¼4px à 2ï¼ |
| | | */ |
| | | const adjustedPosition = computed(() => { |
| | | const items = contextMenu.value.items |
| | | const itemHeight = 34 |
| | | const dividerCount = items.filter((it, i) => it.divided && i > 0).length |
| | | const menuHeight = items.length * itemHeight + dividerCount * 9 + 8 |
| | | const menuWidth = 120 |
| | | let x = contextMenu.value.position.x |
| | | let y = contextMenu.value.position.y |
| | | // SSR å
åºï¼window ä¸å¯ç¨æ¶ç´æ¥è¿ååå§åæ |
| | | if (typeof window !== 'undefined') { |
| | | if (y + menuHeight > window.innerHeight) { |
| | | y = window.innerHeight - menuHeight |
| | | } |
| | | if (x + menuWidth > window.innerWidth) { |
| | | x = window.innerWidth - menuWidth |
| | | } |
| | | } |
| | | // è§å£å¾å° / èå项å¾å¤æ¶ä¸é¢åæ³ä¼ç®åºè´å¼ï¼æèåé¡¶ / 左边æ¨å° 0 å
åº |
| | | return { x: Math.max(0, x), y: Math.max(0, y) } |
| | | }) |
| | | |
| | | type MenuItem = (typeof contextMenu.value.items)[number] |
| | | |
| | | /** éä¸èå项ï¼disabled 项忽ç¥ï¼æ£å¸¸é¡¹è° onSelect åè°åå
³éèå */ |
| | | function handleSelect(item: MenuItem) { |
| | | if (item.disabled) { |
| | | return |
| | | } |
| | | uiStore.contextMenu.onSelect?.(item) |
| | | uiStore.closeContextMenu() |
| | | } |
| | | |
| | | /** å
³éèåï¼ç¹é®ç½© / å¨é®ç½©ä¸å次å³é®é½ä¼è§¦å */ |
| | | function handleClose() { |
| | | uiStore.closeContextMenu() |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | éç¨å³é®èå |
| | | ç± useImUiStore.openContextMenu(position, items, onSelect) 触åå
¨å±åä¾å±ç¤º |
| | | è°ç¨æ¹å¨ @contextmenu.prevent äºä»¶éè° openContextMenu å³å¯ï¼ä¸éè¦èªå·±æç»ä»¶ |
| | | --> |
| | | <teleport to="body"> |
| | | <div |
| | | v-if="contextMenu.show" |
| | | class="fixed inset-0 z-9999" |
| | | @click.stop="handleClose" |
| | | @contextmenu.prevent="handleClose" |
| | | > |
| | | <div |
| | | class="fixed min-w-30 py-1 bg-[var(--ant-color-bg-elevated)] rounded-md shadow-lg" |
| | | :style="{ left: `${adjustedPosition.x }px`, top: `${adjustedPosition.y }px` }" |
| | | > |
| | | <template v-for="(item, index) in contextMenu.items" :key="item.key"> |
| | | <!-- divided 项䏿¹æä¸æ¡åå²çº¿ï¼é¦é¡¹è·³è¿ï¼é¿å
空ç½ï¼ --> |
| | | <div |
| | | v-if="item.divided && index > 0" |
| | | class="my-1 mx-2 h-[1px] bg-[var(--ant-color-border-secondary)]" |
| | | ></div> |
| | | <div |
| | | class="flex gap-2 items-center px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class="[ |
| | | item.disabled |
| | | ? '!text-[var(--ant-color-text-disabled)] cursor-not-allowed hover:!bg-transparent' |
| | | : item.danger |
| | | ? 'text-[#f56c6c]' |
| | | : 'text-[var(--ant-color-text)]' |
| | | ]" |
| | | @click.stop="handleSelect(item)" |
| | | > |
| | | <Icon v-if="item.icon" :icon="item.icon" :size="14" /> |
| | | <span>{{ item.name }}</span> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | </div> |
| | | </teleport> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { SystemUserApi } from '#/api/system/user' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { useUserStore } from '#/packages/stores/src' |
| | | |
| | | import { Button, Input, message, Modal, Spin } from 'ant-design-vue' |
| | | |
| | | import { getSimpleUserListByNickname } from '#/api/system/user' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { ImFriendAddSource } from '../../../utils/constants' |
| | | import { getGenderColor, getGenderIcon } from '../../../utils/user' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImFriendAddDialog' }) |
| | | |
| | | const visible = ref(false) // å¼¹çªæ¯å¦å¯è§ |
| | | const presetUser = ref<null | SystemUserApi.UserSimple>(null) // é¢å¡«ç®æ ç¨æ·ï¼é null æ¶è·³è¿æç´¢æ¥éª¤ï¼ç´æ¥è¿å
¥ç³è¯·è¡¨åï¼ç¾¤æåå 好å / åçå 好åçåºæ¯ï¼ |
| | | const addSource = ref<number>(ImFriendAddSource.SEARCH) // æ·»å æ¥æºï¼åè§ ImFriendAddSourceEnumï¼é»è®¤ SEARCH |
| | | const addSourceExtra = ref<string>('') // æ¥æºé带信æ¯ï¼addSource=ImFriendAddSource.GROUP æ¶ä¼ 群åï¼è¯æ¯æ¼ä¸ºãææ¯ XX 群ç YYã |
| | | |
| | | defineExpose({ |
| | | /** æå¼å 好åå¼¹çªï¼reset â çå â visible=trueï¼ä¸ä¼ opts èµ°æç´¢æ¨¡å¼ */ |
| | | open(opts?: { addSource?: number; addSourceExtra?: string; presetUser?: null | SystemUserApi.UserSimple; }) { |
| | | presetUser.value = opts?.presetUser ?? null |
| | | addSource.value = opts?.addSource ?? ImFriendAddSource.SEARCH |
| | | addSourceExtra.value = opts?.addSourceExtra ?? '' |
| | | resetAll() |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | const friendStore = useFriendStore() |
| | | const userStore = useUserStore() |
| | | |
| | | /** å½åç»å½ç¨æ·ç¼å·ï¼ç¨ computed å
ä¸å±ï¼åè´¦å·åé wsCache éåï¼é¿å
顶屿±å¼å¨ keep-alive å®ä¾éæææ§ id */ |
| | | const currentUserId = computed(() => getCurrentUserId()) |
| | | |
| | | /** æç´¢ç»æè¿æ»¤æèªå·±ï¼ç¨ v-if èé v-showï¼é¿å
DOM å ä½ + å¤´åæ æè¯·æ± */ |
| | | const visibleUsers = computed(() => |
| | | users.value.filter((user) => user.id !== currentUserId.value) |
| | | ) |
| | | const keyword = ref('') // æç´¢å
³é®å |
| | | const users = ref<SystemUserApi.UserSimple[]>([]) // æç´¢ç»æ |
| | | const searched = ref(false) // æ¯å¦å·²æç´¢ |
| | | const loading = ref(false) // æç´¢å è½½ä¸ |
| | | const step = ref<'apply' | 'search'>('search') // å½åæ¥éª¤ï¼search=æç´¢å表ï¼apply=ç³è¯·è¡¨å |
| | | const targetUser = ref<null | SystemUserApi.UserSimple>(null) // ç³è¯·ç®æ ç¨æ· |
| | | const applyContent = ref('') // ç³è¯·çç±ï¼é»è®¤å¡«ãææ¯ ${å½åæµç§°}ãï¼å¯¹é½å¾®ä¿¡äº¤äºï¼ |
| | | const displayName = ref('') // å¯¹æ¥æ¶æ¹ç夿³¨ï¼ä»
èªå·±å¯è§ï¼ |
| | | const submitting = ref(false) // æäº¤ä¸ |
| | | |
| | | /** å¼¹çªæ é¢éæ¥éª¤åæ¢ */ |
| | | const dialogTitle = computed(() => (step.value === 'apply' ? 'ç³è¯·æ·»å æå' : 'æ·»å 好å')) |
| | | |
| | | /** æ¯å¦é¢å¡«æ¨¡å¼ï¼presetUser ä¸ä¸ºç©º â è·³è¿æç´¢ï¼å
³éå³éæ¯ï¼æ ãåæ¶è¿åæç´¢ãæé®ï¼ */ |
| | | const presetMode = computed(() => !!presetUser.value) |
| | | |
| | | function resetAll() { |
| | | keyword.value = '' |
| | | users.value = [] |
| | | searched.value = false |
| | | // é¢å¡«æ¨¡å¼ï¼ç´æ¥è¿ç³è¯·è¡¨åï¼targetUser åèª presetUserï¼ç³è¯·çç±æ addSource åºåè¯æ¯ |
| | | if (presetUser.value) { |
| | | targetUser.value = presetUser.value |
| | | applyContent.value = buildPresetApplyContent() |
| | | displayName.value = '' |
| | | step.value = 'apply' |
| | | return |
| | | } |
| | | // éé¢å¡«æ¨¡å¼ï¼é»è®¤è¿æç´¢æ¥éª¤ |
| | | step.value = 'search' |
| | | targetUser.value = null |
| | | applyContent.value = '' |
| | | displayName.value = '' |
| | | } |
| | | |
| | | /** é¢å¡«æ¨¡å¼ä¸çç³è¯·çç±è¯æ¯ï¼ç¾¤èãææ¯"XX 群"ç YYãï¼å
¶å®ãææ¯ YYã */ |
| | | function buildPresetApplyContent(): string { |
| | | const myNickname = userStore.userInfo?.nickname || '' |
| | | if (!myNickname) { |
| | | return '' |
| | | } |
| | | // 群èåºæ¯æ¼å¸¦ç¾¤åçè¯æ¯ï¼å
¶å®åºæ¯é»è®¤ãææ¯ YYã |
| | | const groupExtra = addSource.value === ImFriendAddSource.GROUP ? addSourceExtra.value : '' |
| | | return groupExtra ? `ææ¯"${groupExtra}"ç${myNickname}` : `ææ¯${myNickname}` |
| | | } |
| | | |
| | | /** ææµç§°æç´¢ç¨æ·ï¼ç©ºå
³é®åç´æ¥æ¸
ç©ºç»æ */ |
| | | async function handleSearch() { |
| | | searched.value = true |
| | | if (!keyword.value.trim()) { |
| | | users.value = [] |
| | | return |
| | | } |
| | | loading.value = true |
| | | try { |
| | | users.value = (await getSimpleUserListByNickname(keyword.value.trim())) || [] |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | /** è¿å
¥ç³è¯·æ¥éª¤ï¼é¢å¡«ç³è¯·çç±ãææ¯ ${å½åç¨æ·æµç§°}ãï¼å¯¹é½å¾®ä¿¡äº¤äºï¼ */ |
| | | function enterApply(user: SystemUserApi.UserSimple) { |
| | | targetUser.value = user |
| | | const myNickname = userStore.userInfo?.nickname || '' |
| | | applyContent.value = myNickname ? `ææ¯${myNickname}` : '' |
| | | displayName.value = '' |
| | | step.value = 'apply' |
| | | } |
| | | |
| | | /** åæ¶ç³è¯·ï¼åå°æç´¢æ¥éª¤ */ |
| | | function backToSearch() { |
| | | step.value = 'search' |
| | | targetUser.value = null |
| | | } |
| | | |
| | | /** æäº¤å¥½åç³è¯·ï¼è¿å requestId èµ°ãçå¾
éªè¯ãï¼è¿å null 表示å端å½ä¸ãåå好åéé»éå¯ã忝ï¼å·²ç´æ¥æä¸ºå¥½å */ |
| | | async function handleSubmitApply() { |
| | | if (!targetUser.value) { |
| | | return |
| | | } |
| | | // 颿 ¡éªï¼ä¸è½å èªå·±ï¼æç´¢åè¡¨å·²è¿æ»¤ï¼è¿éå
åº presetUser / åçå
¥å£çåºæ¯ï¼ |
| | | if (targetUser.value.id === currentUserId.value) { |
| | | message.warning('ä¸è½æ·»å èªå·±ä¸ºå¥½å') |
| | | return |
| | | } |
| | | submitting.value = true |
| | | try { |
| | | const requestId = await friendStore.applyFriendRequest({ |
| | | toUserId: targetUser.value.id, |
| | | applyContent: applyContent.value.trim() || undefined, |
| | | displayName: displayName.value.trim() || undefined, |
| | | addSource: addSource.value |
| | | }) |
| | | // silent 忝ï¼å·²æ¯åå好å被éé»éå¯ï¼ï¼ä¸»å¨ fetchFriendInfo å
¥åºï¼ä¸ä¾èµ WS FRIEND_ADD æ¨éï¼é¿å
ä¸¢æ¨æ¶å表çä¸å° |
| | | if (requestId === null) { |
| | | await friendStore.fetchFriendInfo(targetUser.value.id) |
| | | } |
| | | message.success(requestId ? 'ç³è¯·å·²åéï¼çå¾
对æ¹éªè¯' : '已添å 为好å') |
| | | visible.value = false |
| | | } catch { |
| | | // ä¸å¡é误ï¼å·²æ¯å¥½å / è¢«å¯¹æ¹æé» / ç¨æ·è¢«ç¦ç¨ çï¼ï¼å
¨å±æ¦æªå¨å·²å¼¹é误æç¤ºï¼æ¬å°å
³å¼¹çªé¿å
èç¶æåç |
| | | visible.value = false |
| | | } finally { |
| | | submitting.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | æ·»å 好åå¯¹è¯æ¡ï¼å屿µç¨ï¼ |
| | | - 第ä¸å± searchï¼ææµç§°æç´¢ç¨æ·å表 |
| | | - 第äºå± applyï¼éä¸ç¨æ·åå±å¼ãç³è¯·æ·»å æåã表åï¼ç³è¯·çç± + 夿³¨ï¼ |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | :title="dialogTitle" |
| | | width="480px" |
| | | :mask-closable="false" |
| | | :footer="step === 'apply' ? undefined : null" |
| | | > |
| | | <!-- 第ä¸å±ï¼æç´¢ + ç¨æ·å表 --> |
| | | <template v-if="step === 'search'"> |
| | | <Input |
| | | v-model:value="keyword" |
| | | placeholder="è¾å
¥æµç§°å车æç´¢ï¼æå¤å±ç¤º 20 æ¡ï¼" |
| | | allow-clear |
| | | @keyup.enter="handleSearch" |
| | | > |
| | | <template #suffix> |
| | | <Icon icon="ant-design:search-outlined" class="cursor-pointer" @click="handleSearch" /> |
| | | </template> |
| | | </Input> |
| | | |
| | | <Spin :spinning="loading" wrapper-class-name="w-full"> |
| | | <div class="h-[400px] mt-2.5"> |
| | | <div |
| | | v-if="visibleUsers.length === 0" |
| | | class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ searched ? '没ææå°ç¨æ·' : 'è¾å
¥å
³é®ååå车å¼å§æç´¢' }} |
| | | </div> |
| | | <div |
| | | v-for="user in visibleUsers" |
| | | :key="user.id" |
| | | class="flex gap-3 items-center px-2 py-2.5 border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <UserAvatar |
| | | :id="user.id" |
| | | :url="user.avatar" |
| | | :name="user.nickname" |
| | | :size="42" |
| | | :clickable="false" |
| | | /> |
| | | <div class="flex-1 min-w-0 overflow-hidden"> |
| | | <!-- æµç§° + æ§å«å¾æ --> |
| | | <div |
| | | class="flex items-center gap-1 text-sm font-semibold text-[var(--ant-color-text)]" |
| | | > |
| | | <span class="truncate">{{ user.nickname }}</span> |
| | | <Icon |
| | | v-if="getGenderIcon(user.sex)" |
| | | :icon="getGenderIcon(user.sex)" |
| | | :size="14" |
| | | :color="getGenderColor(user.sex)" |
| | | class="flex-shrink-0" |
| | | /> |
| | | </div> |
| | | <!-- é¨é¨ --> |
| | | <div |
| | | v-if="user.deptName" |
| | | class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | {{ user.deptName }} |
| | | </div> |
| | | </div> |
| | | <!-- å·²æ¯å¥½åæ¾ç¤ºã已添å ãï¼å¦åæ¾ç¤ºãæ·»å ãï¼ç¹å»è¿å
¥ apply æ¥éª¤ï¼ --> |
| | | <Button |
| | | v-if="!friendStore.isActiveFriend(user.id)" |
| | | type="primary" |
| | | size="small" |
| | | @click="enterApply(user)" |
| | | > |
| | | æ·»å |
| | | </Button> |
| | | <Button v-else size="small" disabled>已添å </Button> |
| | | </div> |
| | | </div> |
| | | </Spin> |
| | | </template> |
| | | |
| | | <!-- 第äºå±ï¼ç³è¯·è¡¨åï¼å¯¹é½å¾®ä¿¡ãç³è¯·æ·»å æåãï¼ --> |
| | | <template v-if="step === 'apply' && targetUser"> |
| | | <!-- éä¸çç¨æ·å¡ç --> |
| | | <div |
| | | class="flex gap-3 items-center px-2 py-3 mb-4 rounded-md bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <UserAvatar |
| | | :id="targetUser.id" |
| | | :url="targetUser.avatar" |
| | | :name="targetUser.nickname" |
| | | :size="42" |
| | | :clickable="false" |
| | | /> |
| | | <div class="flex-1 min-w-0 overflow-hidden"> |
| | | <div class="text-sm font-semibold text-[var(--ant-color-text)] truncate"> |
| | | {{ targetUser.nickname }} |
| | | </div> |
| | | <div |
| | | v-if="targetUser.deptName" |
| | | class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | {{ targetUser.deptName }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="text-13px text-[var(--ant-color-text-secondary)] mb-1.5">åéæ·»å æåç³è¯·</div> |
| | | <Input.TextArea |
| | | v-model:value="applyContent" |
| | | :rows="3" |
| | | :maxlength="255" |
| | | show-count |
| | | placeholder="请填åç³è¯·çç±" |
| | | /> |
| | | |
| | | <div class="text-13px text-[var(--ant-color-text-secondary)] mt-3 mb-1.5">夿³¨</div> |
| | | <Input |
| | | v-model:value="displayName" |
| | | :maxlength="16" |
| | | placeholder="ç»å¯¹æ¹èµ·ä¸ªå¤æ³¨ï¼ä»
èªå·±å¯è§ï¼å¯ä¸å¡«ï¼" |
| | | /> |
| | | </template> |
| | | |
| | | <!-- ä»
å¨ apply æ¥éª¤æ¾ç¤º footer æä½æé®ï¼slot å¿
é¡»æ¯ el-dialog ç´æ¥åèç¹ï¼ --> |
| | | <template v-if="step === 'apply'" #footer> |
| | | <!-- é¢å¡«æ¨¡å¼æ æç´¢æ¥éª¤ï¼ãåæ¶ãç´æ¥å
³éå¼¹çª --> |
| | | <Button @click="presetMode ? (visible = false) : backToSearch()">åæ¶</Button> |
| | | <Button type="primary" :loading="submitting" @click="handleSubmitApply"> ç¡®å® </Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | export { default as FriendAddDialog } from './friend-add-dialog.vue'; |
| | | export { default as FriendItem } from './friend-item.vue'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from './group-member.vue' |
| | | |
| | | import { ref } from 'vue' |
| | | |
| | | import { Button, message, Modal } from 'ant-design-vue' |
| | | |
| | | import { addGroupAdmin, removeGroupAdmin } from '#/api/im/group' |
| | | import { GROUP_ADMIN_MAX_COUNT } from '#/views/im/utils/config' |
| | | |
| | | import { GroupMemberPickerPanel } from '../picker' |
| | | |
| | | defineOptions({ name: 'ImGroupAdminSetDialog' }) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** 管çååæ´æåï¼ç¶ä¾§éå¸¸ç¨æ¥ reload ç¾¤æ°æ® */ |
| | | reload: [] |
| | | }>() |
| | | |
| | | const visible = ref(false) |
| | | const submitting = ref(false) |
| | | const groupId = ref(0) |
| | | const members = ref<GroupMemberLite[]>([]) |
| | | const currentAdminIds = ref<number[]>([]) // å½å管çå userId å表ï¼é»è®¤å¾é + æäº¤æ¶ diff |
| | | const hideIds = ref<number[]>([]) |
| | | const maxSize = ref(GROUP_ADMIN_MAX_COUNT) |
| | | const selectedIds = ref<number[]>([]) |
| | | |
| | | defineExpose({ |
| | | /** æå¼è®¾ç½®ç®¡çåå¼¹çªï¼reset â çå â visible=true */ |
| | | open(opts: { |
| | | /** å½å管çå userId å表ï¼é»è®¤å¾éï¼ */ |
| | | currentAdminIds: number[] |
| | | groupId: number |
| | | /** éè userIdï¼ç¾¤ä¸»ï¼ */ |
| | | hideIds?: number[] |
| | | /** 已鿰ä¸éï¼ä¸ä¼ èµ° GROUP_ADMIN_MAX_COUNT */ |
| | | maxSize?: number |
| | | members: GroupMemberLite[] |
| | | }) { |
| | | groupId.value = opts.groupId |
| | | members.value = opts.members |
| | | currentAdminIds.value = [...opts.currentAdminIds] |
| | | hideIds.value = opts.hideIds ? [...opts.hideIds] : [] |
| | | maxSize.value = opts.maxSize ?? GROUP_ADMIN_MAX_COUNT |
| | | selectedIds.value = [...opts.currentAdminIds] |
| | | submitting.value = false |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** è·å½å管çåå表åå·®éï¼å嫿¿å°è¦æ°å¢ / æ¤éç userId */ |
| | | async function handleOk() { |
| | | if (!groupId.value) { |
| | | return |
| | | } |
| | | const previousIds = currentAdminIds.value |
| | | const previousIdSet = new Set(previousIds) |
| | | const nextIds = selectedIds.value |
| | | const nextIdSet = new Set(nextIds) |
| | | const addedIds = nextIds.filter((id) => !previousIdSet.has(id)) |
| | | const removedIds = previousIds.filter((id) => !nextIdSet.has(id)) |
| | | if (addedIds.length === 0 && removedIds.length === 0) { |
| | | visible.value = false |
| | | return |
| | | } |
| | | submitting.value = true |
| | | try { |
| | | if (addedIds.length > 0) { |
| | | await addGroupAdmin({ id: groupId.value, userIds: addedIds }) |
| | | } |
| | | if (removedIds.length > 0) { |
| | | await removeGroupAdmin({ id: groupId.value, userIds: removedIds }) |
| | | } |
| | | message.success(`å·²æ´æ°ç¾¤ç®¡çåï¼æ°å¢ ${addedIds.length} ä½ï¼æ¤é ${removedIds.length} ä½ï¼`) |
| | | emit('reload') |
| | | visible.value = false |
| | | } finally { |
| | | submitting.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 设置群管çåï¼ä¸ä¸ªå¼¹çªåå¹¶å¢ / å ï¼æäº¤æ¶è·å½å管çåå表 diff |
| | | - dialog 壳æ¬ç»ä»¶ææï¼éæ© UI å§æ GroupMemberPickerPanelï¼grid å½¢æå¯¹é½å½åè§è§ï¼ |
| | | - 群主ä»åéééèï¼ä¸è½è®¾ä¸ºç®¡çåï¼ |
| | | - 坹夿¥å£ï¼ref + open({ groupId, members, currentAdminIds, hideIds, maxSize }) + emit reload() |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | title="设置群管çå" |
| | | width="700px" |
| | | :mask-closable="false" |
| | | class="im-picker-dialog" |
| | | > |
| | | <div class="h-[480px]"> |
| | | <GroupMemberPickerPanel |
| | | v-model:selected-ids="selectedIds" |
| | | :members="members" |
| | | :hide-ids="hideIds" |
| | | :max-size="maxSize" |
| | | selected-display="grid" |
| | | /> |
| | | </div> |
| | | |
| | | <template #footer> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button type="primary" :loading="submitting" @click="handleOk">ç¡®å®</Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | @use '../picker/picker-dialog' as picker; |
| | | |
| | | /* :deep ç©¿é el-dialog å
é¨ç±»ï¼å¤ç¨ picker å
Œ
± mixin */ |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMember } from '../../types' |
| | | |
| | | import { computed, ref, watch } from 'vue' |
| | | |
| | | import { |
| | | buildGroupAvatar, |
| | | getCachedGroupAvatar, |
| | | setCachedGroupAvatar |
| | | } from '../../../utils/group' |
| | | import { getMemberDisplayName } from '../../../utils/user' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImGroupAvatar' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | clickable?: boolean // æ¯å¦å¯ç¹å»ï¼é»è®¤ falseï¼å表éä»
å±ç¤ºï¼ |
| | | groupId: number // 群ç¼å·ï¼ç¨äºæ¥ store æ¿æå头å |
| | | name?: string // 群åï¼è²å¡å
åºæå |
| | | previewable?: boolean // æ¯å¦ç¹å¤´åç´æ¥æ¾å¤§é¢è§ï¼ç¾¤è¯¦æ
大头åä½ç¨ï¼ |
| | | previewZIndex?: number // é¢è§å± z-index |
| | | radius?: string // åè§ï¼CSS é¿åº¦ |
| | | size?: number // 尺寸ï¼pxï¼ï¼æ£æ¹å½¢ |
| | | url?: string // æå¡ç«¯å·²è®¾ç½®ç群头å URLï¼é空åç´æ¥ç¨ï¼ä¸æ¼å¾ |
| | | }>(), |
| | | { |
| | | clickable: false, |
| | | name: '', |
| | | previewable: false, |
| | | previewZIndex: 2000, |
| | | radius: '15%', |
| | | size: 42, |
| | | url: '' |
| | | } |
| | | ) |
| | | |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | const mergedUrl = ref('') |
| | | // ç«æä¿æ¤ï¼ä¸¢å¼è¿æ await ç»æ |
| | | let mergeToken = 0 |
| | | |
| | | /** æå®¹å¨ size à DPR ç® canvas å®é
åç´ ï¼é¿å
2x / 3x retina 屿¼å¾ç³ï¼DPR å°é¡¶ 3 鲿¢è¶
é«å辨çç»å¸è¿å¤§ */ |
| | | function getTargetSize(size: number): number { |
| | | const dpr = Math.min(window.devicePixelRatio || 1, 3) |
| | | return Math.max(Math.round(size * dpr), 64) |
| | | } |
| | | |
| | | /** store éæ´ç¾¤æåæ¯å¦ã宿´å è½½ãè¿ï¼åªå¨ä¸º true æ¶ææ¼å¾ï¼é¿å
åè¡¨åºæ¯æ¹éåæ¥å£ */ |
| | | const loadedMembers = computed<GroupMember[] | null>(() => { |
| | | const g = groupStore.getGroup(props.groupId) |
| | | if (!g?.membersLoaded || !g.members) { |
| | | return null |
| | | } |
| | | return g.members |
| | | }) |
| | | |
| | | /** å 9 个æåçæ¼å¾å
¥åï¼name èµ° getMemberDisplayName å£å¾ï¼å¥½å夿³¨ > 群æµç§° > ç宿µç§°ï¼ */ |
| | | const memberItems = computed(() => { |
| | | const members = loadedMembers.value |
| | | if (!members) { |
| | | return [] |
| | | } |
| | | return members.slice(0, 9).map((m) => ({ |
| | | avatar: m.avatar || '', |
| | | name: getMemberDisplayName(m, friendStore.getFriend(m.userId)) |
| | | })) |
| | | }) |
| | | |
| | | /** æåå¿«ç
§ç¾åï¼æ¼ (avatar, name) åæ®µï¼åå°ä¿®æ¹ä»»ä¸å段é½ä¼è®© watch éç® */ |
| | | const memberSignature = computed(() => |
| | | memberItems.value.map((it) => `${it.avatar}#${it.name}`).join('|') |
| | | ) |
| | | |
| | | /** èµ° buildGroupAvatar æ¼å¾å¹¶åå mergedUrlï¼mergeToken æ ¡éªé¿å
è await è¦çæ°ç»æ */ |
| | | async function applyMerge(key: string, targetSize: number): Promise<void> { |
| | | const myToken = ++mergeToken |
| | | const cached = getCachedGroupAvatar(key) |
| | | if (cached) { |
| | | mergedUrl.value = cached |
| | | return |
| | | } |
| | | const dataUrl = await buildGroupAvatar(memberItems.value, { targetSize }) |
| | | if (myToken !== mergeToken) { |
| | | return |
| | | } |
| | | if (dataUrl) { |
| | | setCachedGroupAvatar(key, dataUrl) |
| | | } |
| | | mergedUrl.value = dataUrl |
| | | } |
| | | |
| | | watch( |
| | | () => [props.url, props.groupId, props.size, memberSignature.value] as const, |
| | | ([url, groupId, size, signature]) => { |
| | | if (url) { |
| | | mergedUrl.value = '' |
| | | return |
| | | } |
| | | if (!signature) { |
| | | mergeToken++ |
| | | mergedUrl.value = '' |
| | | groupStore.loadGroupMemberList(groupId) |
| | | return |
| | | } |
| | | const targetSize = getTargetSize(size) |
| | | const key = `${groupId}:${targetSize}:${signature}` |
| | | applyMerge(key, targetSize) |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | /** æç»å±ç¤º urlï¼æå¡ç«¯ url ä¼å
â æ¼å¾ â 空å符串ï¼è®© UserAvatar èµ°è²å¡ï¼ */ |
| | | const finalUrl = computed(() => props.url || mergedUrl.value) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- url é空走åå¾ï¼url 空æ¶åå 9 个æå头忼ä¹å®«æ ¼ dataURLï¼æåæªå¨ store ç¼åæ¶èµ°è²å¡å
åº --> |
| | | <UserAvatar |
| | | :url="finalUrl" |
| | | :name="name" |
| | | :size="size" |
| | | :radius="radius" |
| | | :clickable="clickable" |
| | | :previewable="previewable" |
| | | :preview-z-index="previewZIndex" |
| | | /> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { FriendLite } from '../../types' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { Button, message, Modal } from 'ant-design-vue' |
| | | |
| | | import { createGroup } from '#/api/im/group' |
| | | |
| | | import { buildDefaultGroupName } from '../../../utils/group' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { FriendPickerPanel } from '../picker' |
| | | |
| | | defineOptions({ name: 'ImGroupCreateDialog' }) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** å建æåï¼æºå¸¦æ°ç¾¤ç¼å·ï¼ç¶ä¾§éå¸¸ç¨æ¥è·³è½¬å°æ°ç¾¤ä¼è¯ */ |
| | | created: [groupId: number] |
| | | }>() |
| | | |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | |
| | | const visible = ref(false) |
| | | const submitting = ref(false) |
| | | const lockedIds = ref<number[]>([]) |
| | | const selectedIds = ref<number[]>([]) |
| | | |
| | | defineExpose({ |
| | | /** æå¼å起群èå¼¹çªï¼reset â çå â visible=true */ |
| | | open(opts?: { lockedIds?: number[] }) { |
| | | lockedIds.value = opts?.lockedIds ? [...opts.lockedIds] : [] |
| | | selectedIds.value = [] |
| | | submitting.value = false |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** å
¨é好åï¼ç´æ¥å¤ç¨ friendStore Lite è§å¾ï¼å¸¦æ¼é³å段ä¾åæ¡¶ç¨ï¼ */ |
| | | const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList) |
| | | |
| | | /** 宿æé®å¯ç¹ï¼è³å°æ 1 个é locked å¾éï¼locked æ¯å
¥å£éå®é¡¹ï¼ä¸ç®"ç¨æ·ä¸»å¨éæ©"ï¼ */ |
| | | const canSubmit = computed(() => selectedIds.value.length > 0) |
| | | |
| | | /** æ¿å°ææè¦è¿ç¾¤ç好åï¼locked + selectedï¼ï¼å»ºç¾¤é»è®¤ç¾¤åæè¿æ¹äººçæ */ |
| | | function resolveMembersToInvite(): FriendLite[] { |
| | | const seen = new Set<number>() |
| | | const result: FriendLite[] = [] |
| | | const byId = new Map(friends.value.map((f) => [f.id, f])) |
| | | for (const id of lockedIds.value) { |
| | | if (seen.has(id)) { |
| | | continue |
| | | } |
| | | const friend = byId.get(id) |
| | | if (friend) { |
| | | seen.add(id) |
| | | result.push(friend) |
| | | } |
| | | } |
| | | for (const id of selectedIds.value) { |
| | | if (seen.has(id)) { |
| | | continue |
| | | } |
| | | const friend = byId.get(id) |
| | | if (friend) { |
| | | seen.add(id) |
| | | result.push(friend) |
| | | } |
| | | } |
| | | return result |
| | | } |
| | | |
| | | /** å建群èï¼å»ºç¾¤ï¼åæ¶é请åå§æåï¼â upsert groupStore â emit created 让ç¶é¡µè·³è½¬æ°ä¼è¯ */ |
| | | async function handleOk() { |
| | | const members = resolveMembersToInvite() |
| | | if (members.length === 0) { |
| | | return |
| | | } |
| | | submitting.value = true |
| | | try { |
| | | const memberUserIds = members.map((m) => m.id) |
| | | const name = buildDefaultGroupName(members) |
| | | const group = await createGroup({ name, memberUserIds, joinApproval: false }) |
| | | if (!group?.id) { |
| | | throw new Error('åå»ºç¾¤å¤±è´¥ï¼æªè¿å群ç¼å·') |
| | | } |
| | | // ç´æ¥ upsert è¿ groupStoreï¼ç䏿¬¡ fetchGroupList ââ æå¡ç«¯è¿å VO å·²ç»å¤å»ºä¼è¯äº |
| | | groupStore.upsertGroup({ |
| | | id: group.id, |
| | | name: group.name, |
| | | avatar: group.avatar, |
| | | notice: group.notice, |
| | | ownerUserId: group.ownerUserId |
| | | }) |
| | | message.success('群èå建æå') |
| | | emit('created', group.id) |
| | | visible.value = false |
| | | } finally { |
| | | submitting.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | å起群èï¼é好å â é»è®¤ææéæååçæç¾¤å â createGroup |
| | | - dialog 壳æ¬ç»ä»¶ææï¼éæ© UI å§æ FriendPickerPanel |
| | | - lockedIds ç±è°ç¨æ¹éè¿ open({ lockedIds }) ä¼ å
¥ï¼ç§èä¾§ +建群éå®å¯¹æ¹ |
| | | - ä¸åè¦æ±å
è¾å
¥ç¾¤å / ä¸åå±ç¤ºãè¿ç¾¤å®¡æ¹ãå¼å
³ï¼å¯¹é½å¾®ä¿¡ PC |
| | | - 坹夿¥å£ï¼ref + open({ lockedIds }) + emit created(groupId) |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | title="å起群è" |
| | | width="720px" |
| | | :mask-closable="false" |
| | | class="im-picker-dialog" |
| | | > |
| | | <div class="h-[480px]"> |
| | | <FriendPickerPanel |
| | | v-model:selected-ids="selectedIds" |
| | | :friends="friends" |
| | | :locked-ids="lockedIds" |
| | | /> |
| | | </div> |
| | | |
| | | <template #footer> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk"> |
| | | 宿 |
| | | </Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | @use '../picker/picker-dialog' as picker; |
| | | |
| | | /* :deep ç©¿é el-dialog å
é¨ç±»ï¼å¤ç¨ picker å
Œ
± mixin */ |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupLite } from '../../types' |
| | | |
| | | import { computed, onMounted, onUnmounted } from 'vue' |
| | | import { useRouter } from 'vue-router' |
| | | |
| | | import { prompt } from '#/packages/effects/common-ui/src' |
| | | |
| | | import { Input, message } from 'ant-design-vue' |
| | | |
| | | import { applyJoinGroup } from '#/api/im/group/request' |
| | | |
| | | import { ImConversationType, ImGroupAddSource } from '../../../utils/constants' |
| | | import { getGroupDisplayName } from '../../../utils/user' |
| | | import { useConversationStore } from '../../store/conversationStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { useImUiStore } from '../../store/uiStore' |
| | | import GroupInfo from './group-info.vue' |
| | | |
| | | defineOptions({ name: 'ImGroupInfoCard' }) |
| | | |
| | | const uiStore = useImUiStore() |
| | | const conversationStore = useConversationStore() |
| | | const groupStore = useGroupStore() |
| | | const router = useRouter() |
| | | |
| | | const card = computed(() => uiStore.groupInfoCard) |
| | | |
| | | /** å
³éæµ®å± */ |
| | | function handleClose() { |
| | | uiStore.closeGroupInfoCard() |
| | | } |
| | | |
| | | /** Esc å
³é */ |
| | | function handleKeydown(e: KeyboardEvent) { |
| | | if (e.key === 'Escape' && card.value.show) { |
| | | handleClose() |
| | | } |
| | | } |
| | | |
| | | onMounted(() => window.addEventListener('keydown', handleKeydown)) |
| | | onUnmounted(() => window.removeEventListener('keydown', handleKeydown)) |
| | | |
| | | /** è¿å
¥ç¾¤èï¼åæ¬å°ææ°ç¾¤ä¿¡æ¯ï¼å« silent / ç¾¤å¤æ³¨ï¼ï¼æ°å»ºææ¿æ´»ä¼è¯ + è·³è·¯ç± */ |
| | | function handleChat(group: GroupLite) { |
| | | const cached = groupStore.getGroup(group.id) |
| | | // cached å½ä¸èµ° getGroupDisplayName è®©ç¾¤å¤æ³¨ä¼å
ï¼ä¸ contact / ä¼è¯å表çå±ç¤ºåä¸è´ï¼ï¼ç¼º cached æ¶åè½ showGroupName / å群å |
| | | const displayName = cached |
| | | ? getGroupDisplayName(cached) |
| | | : group.showGroupName || group.name || '' |
| | | // æå¼ææ°å»ºä¼è¯ |
| | | conversationStore.openConversation( |
| | | group.id, |
| | | ImConversationType.GROUP, |
| | | displayName, |
| | | cached?.avatar || group.showImage || '', |
| | | { silent: !!cached?.silent } |
| | | ) |
| | | |
| | | // 妿ä¸å¨ä¼è¯é¡µï¼å
è·³è¿å»ï¼å¦æå¨äºï¼MessagePanel ä¼èªå·±æç¥ä¼è¯ååå·æ°ï¼ |
| | | if (router.currentRoute.value.name !== 'ImHomeConversation') { |
| | | router.push({ name: 'ImHomeConversation' }) |
| | | } |
| | | handleClose() |
| | | } |
| | | |
| | | /** å å
¥ç¾¤èï¼å
å
³æµ®å±ï¼é¿å
ä¸ prompt ç mask äºç¸é®æ¡ï¼â å¼¹ç³è¯·çç±ï¼å¯éï¼â applyJoinGroup */ |
| | | async function handleApply(group: GroupLite) { |
| | | handleClose() |
| | | let applyContent: string |
| | | try { |
| | | const result = await prompt<string>({ |
| | | cancelText: 'åæ¶', |
| | | component: Input, |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请填åéªè¯æ¶æ¯ï¼å¯éï¼' |
| | | }, |
| | | content: '', |
| | | defaultValue: '', |
| | | confirmText: 'åéç³è¯·', |
| | | modelPropName: 'value', |
| | | title: `ç³è¯·å å
¥ã${group.name || ''}ã` |
| | | }) |
| | | applyContent = (result || '').trim() |
| | | } catch { |
| | | return |
| | | } |
| | | await applyJoinGroup({ |
| | | groupId: group.id, |
| | | applyContent: applyContent || undefined, |
| | | addSource: ImGroupAddSource.SHARE_LINK |
| | | }) |
| | | message.success('å 群ç³è¯·å·²åé') |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | ç¾¤ä¿¡æ¯æµ®å±ï¼ä¸ UserInfoCard 对ä½ï¼ |
| | | - ä»
æ¿æ
ãæµ®å±å®ä½ + å
³éé»è¾ï¼ç¹é®ç½© / Escï¼ãï¼ç¾¤ä¿¡æ¯è§è§èµ° <GroupInfo>ï¼ä¸é讯å½è¯¦æ
å
±ç¨ä¸ä»½ç»ä»¶ |
| | | - 触åï¼useImUiStore.openGroupInfoCardAtEvent(group, e) |
| | | - GroupInfo å
鍿 groupStore ç¼åæ¨å¯¼ member / strangerï¼æµ®å±åªè´è´£æ¥ chat / apply äºä»¶åä¸å¡ |
| | | --> |
| | | <teleport to="body"> |
| | | <div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose"> |
| | | <div |
| | | class="fixed w-80 p-4 bg-[var(--ant-color-bg-elevated)] rounded-md shadow-xl" |
| | | :style="{ left: `${card.position.x }px`, top: `${card.position.y }px` }" |
| | | @click.stop |
| | | > |
| | | <GroupInfo v-if="card.group" :group="card.group" @chat="handleChat" @apply="handleApply" /> |
| | | </div> |
| | | </div> |
| | | </teleport> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Friend, GroupLite, GroupMember } from '../../types' |
| | | import type { GroupMemberLite } from './group-member.vue' |
| | | |
| | | import { computed, ref, watch } from 'vue' |
| | | |
| | | import { CommonStatusEnum } from '#/packages/constants/src' |
| | | |
| | | import { Button } from 'ant-design-vue' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { getMemberDisplayName, isGroupQuit } from '../../../utils/user' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import GroupAvatar from './group-avatar.vue' |
| | | import GroupMemberGrid from './group-member-grid.vue' |
| | | |
| | | defineOptions({ name: 'ImGroupInfo' }) |
| | | |
| | | const props = defineProps<{ |
| | | group: GroupLite |
| | | }>() |
| | | |
| | | const emit = defineEmits<{ |
| | | /** stranger ç¹ãå å
¥ç¾¤èãï¼ç¶çº§è´è´£å¼¹ç³è¯·çç± + è° applyJoinGroup */ |
| | | apply: [group: GroupLite] |
| | | /** member ç¹ãè¿å
¥ç¾¤èãï¼ç¶çº§è´è´£åä¼è¯ + å
³æµ®å± */ |
| | | chat: [group: GroupLite] |
| | | }>() |
| | | |
| | | const groupStore = useGroupStore() |
| | | const friendStore = useFriendStore() |
| | | |
| | | const members = ref<GroupMemberLite[]>([]) |
| | | |
| | | /** |
| | | * æ¯å¦å·²å 群ï¼åºäº"èªå·±ç¡®å®å¨æåå表é"夿 |
| | | * - ç¼åæªå½ä¸ï¼ç´æ¥ falseï¼éçç¾¤ï¼ |
| | | * - å½ä¸ä¸ members å·²æï¼ç²¾åæ¥ self.userId å¨ä¸å¨ |
| | | * - å½ä¸ä½ members æªæï¼fetchGroupList æ¥å£è¯ä¹å³ãæå å
¥ç群ãï¼å½ä¸è§ä¸º memberï¼ææååä¼èªå¨æ¶æï¼ |
| | | */ |
| | | const isMember = computed(() => { |
| | | if (!props.group?.id) { |
| | | return false |
| | | } |
| | | const cached = groupStore.getGroup(props.group.id) |
| | | if (!cached) { |
| | | return false |
| | | } |
| | | // åå²é群群ï¼ç´æ¥å¤ falseï¼é¿å
æåæªå è½½æ¶è¯¯æ¾ç¤ºãè¿å
¥ç¾¤èã |
| | | if (isGroupQuit(cached)) { |
| | | return false |
| | | } |
| | | if (cached.membersLoaded && cached.members) { |
| | | const myId = getCurrentUserId() |
| | | return cached.members.some((m) => m.userId === myId && m.status === CommonStatusEnum.ENABLE) |
| | | } |
| | | return true |
| | | }) |
| | | /** åå²é群群ï¼åªè¯»ï¼å¨ä½åºä¸¤ä¸ªæé®é½ä¸æ¸²æï¼æ¢ä¸ãè¿å
¥ç¾¤èãä¹ä¸ãå å
¥ç¾¤èãï¼ */ |
| | | const isQuitGroup = computed(() => { |
| | | const id = props.group?.id |
| | | return id != null && isGroupQuit(groupStore.getGroup(id)) |
| | | }) |
| | | /** æ¯å¦æªå ç¾¤ï¼æ idãéæåãä¸éåå²é群群ï¼åªæç·éç人æç»ãå å
¥ç¾¤èã */ |
| | | const isStranger = computed(() => !!props.group?.id && !isMember.value && !isQuitGroup.value) |
| | | |
| | | /** æåæ°ææ¡ï¼member ä¼å
ç¨æ¬å°æå°çå表é¿åº¦ï¼stranger ç¨ props.group.memberCount å¡çå¿«ç
§ */ |
| | | const memberCountText = computed(() => { |
| | | const count = isMember.value |
| | | ? props.group.memberCount || members.value.length |
| | | : props.group.memberCount |
| | | return count ? `${count} 使å` : '' |
| | | }) |
| | | |
| | | /** member å群 / é¦æï¼ææåï¼ç«æç¨ group.id æ¯å¯¹ä¸¢å¼éæ§ååºé¿å
ä¸ä¸æ¡ç¾¤æåéä½ */ |
| | | watch( |
| | | () => [props.group?.id, isMember.value] as const, |
| | | async ([id, member]) => { |
| | | members.value = [] |
| | | if (!id || !member) { |
| | | return |
| | | } |
| | | const list = await groupStore.fetchGroupMemberList(id, true) |
| | | if (props.group?.id !== id) { |
| | | return |
| | | } |
| | | members.value = list.map((m) => convertGroupMemberLite(m, friendStore.getFriend(m.userId))) |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | /** 群æå â å表项 */ |
| | | function convertGroupMemberLite(member: GroupMember, friend: Friend | undefined): GroupMemberLite { |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status, |
| | | role: member.role |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 群信æ¯å
容ç»ä»¶ï¼ä¸ UserInfo 对ä½ï¼ |
| | | - 头å + 群å + æåæ° + æåå®«æ ¼ + å¨ä½åºï¼çº¯å±ç¤º + æäºä»¶ï¼ä¸å¡ç±ç¶çº§æ¿æ¥ |
| | | - relation èµ° groupStore ç¼åæ¨å¯¼ï¼å½ä¸ = memberï¼å·²å 群ï¼ï¼å¦å = strangerï¼æªå 群ï¼ï¼æ id = readonly |
| | | - æåå®«æ ¼ä»
member æ¶æåï¼éç群æä¸å°ï¼ææä¿¡æ¯èµ° props.group å¡çå¿«ç
§ï¼ |
| | | --> |
| | | <div class="flex justify-center"> |
| | | <div class="w-full max-w-[320px] flex flex-col gap-3 items-center"> |
| | | <GroupAvatar |
| | | :group-id="group.id" |
| | | :url="group.showImage || group.showImageThumb" |
| | | :name="group.showGroupName || group.name" |
| | | :size="72" |
| | | :previewable="isMember" |
| | | /> |
| | | <div |
| | | class="w-full text-lg font-semibold leading-snug text-[var(--ant-color-text)] truncate text-center" |
| | | > |
| | | {{ group.showGroupName || group.name }} |
| | | </div> |
| | | <div v-if="memberCountText" class="text-13px text-[var(--ant-color-text-secondary)]"> |
| | | {{ memberCountText }} |
| | | </div> |
| | | |
| | | <!-- æåå®«æ ¼ï¼ä»
member 渲æï¼éç群æä¸å°æåï¼ --> |
| | | <div |
| | | v-if="isMember && members.length > 0" |
| | | class="flex flex-wrap gap-2 justify-center w-full pt-2" |
| | | > |
| | | <GroupMemberGrid |
| | | v-for="member in members" |
| | | :key="member.userId" |
| | | :member="member" |
| | | :group-name="group.name" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- å¨ä½åºï¼member è¿å
¥ç¾¤è / stranger å å
¥ç¾¤è / readonly 䏿¸²æ --> |
| | | <div v-if="isMember" class="mt-4"> |
| | | <Button type="primary" @click="emit('chat', group)">è¿å
¥ç¾¤è</Button> |
| | | </div> |
| | | <div v-else-if="isStranger" class="mt-4"> |
| | | <Button type="primary" @click="emit('apply', group)">å å
¥ç¾¤è</Button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from './group-member.vue' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { CommonStatusEnum } from '#/packages/constants/src' |
| | | |
| | | import { Button, message, Modal } from 'ant-design-vue' |
| | | |
| | | import { inviteGroupMember } from '#/api/im/group/member' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { GROUP_MAX_MEMBER } from '#/views/im/utils/config' |
| | | import { ImGroupMemberRole } from '#/views/im/utils/constants' |
| | | |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { FriendPickerPanel } from '../picker' |
| | | |
| | | defineOptions({ name: 'ImGroupMemberAddDialog' }) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** é请æåï¼æºå¸¦è¢«é请ç好å id å表ï¼ç¶ä¾§éå¸¸ç¨æ¥ reload 群æå */ |
| | | reload: [friendIds: number[]] |
| | | }>() |
| | | |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | |
| | | const visible = ref(false) |
| | | const submitting = ref(false) |
| | | const groupId = ref(0) |
| | | const selectedIds = ref<number[]>([]) |
| | | |
| | | defineExpose({ |
| | | /** æå¼æ·»å 群æåå¼¹çªï¼reset â çå â visible=true */ |
| | | open(opts: { groupId: number }) { |
| | | groupId.value = opts.groupId |
| | | selectedIds.value = [] |
| | | submitting.value = false |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** å½å群æåå表ï¼ä» groupStore ç°åï¼é¿å
é groupId ååæ¶ç¶ä¾§ prop æ´æ°å»¶è¿ */ |
| | | const members = computed<GroupMemberLite[]>(() => { |
| | | const group = groupStore.getGroup(groupId.value) |
| | | return (group?.members || []).map((member) => ({ |
| | | userId: member.userId, |
| | | nickname: member.nickname, |
| | | showName: member.displayUserName || member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status, |
| | | role: member.role |
| | | })) |
| | | }) |
| | | |
| | | /** å
¨é好åï¼ç´æ¥å¤ç¨ friendStore Lite è§å¾ */ |
| | | const friends = computed(() => friendStore.getActiveFriendLiteList) |
| | | |
| | | /** å·²å¨ç¾¤éç好å idï¼ä¼ ç» Panel ç disabledIds ç½®ç° + ä¸è®¡å
¥å·²é */ |
| | | const disabledIds = computed<number[]>(() => |
| | | members.value |
| | | .filter((member) => member.status !== CommonStatusEnum.DISABLE) |
| | | .map((member) => member.userId) |
| | | ) |
| | | |
| | | /** æ¯å¦èµ°å®¡æ¹åæ¯ï¼ç¾¤å¼å¯ joinApproval + å½åç¨æ·æ¯æ®éæåï¼ç¾¤ä¸» / 管çåé请ç´è¿ï¼ */ |
| | | const willGoApproval = computed(() => { |
| | | const group = groupStore.getGroup(groupId.value) |
| | | if (!group?.joinApproval) { |
| | | return false |
| | | } |
| | | const myId = getCurrentUserId() |
| | | if (!myId) { |
| | | return false |
| | | } |
| | | // 群主ç´å¤ï¼é¿å¼ members 弿¥å è½½ççªå£ï¼admin ä»ä¾èµ members |
| | | if (group.ownerUserId === myId) { |
| | | return false |
| | | } |
| | | // members æªå°ä½æ¶æ æ³å¤å® adminï¼ä¿å®æé审æ¹å¤çï¼å®å¯æ¼æ¥ãçå¾
审æ¹ãä¹ä¸è¯¯æ¥ç»çå®ç®¡çå |
| | | const myRole = members.value.find((member) => member.userId === myId)?.role |
| | | if (myRole == null) { |
| | | return false |
| | | } |
| | | return myRole !== ImGroupMemberRole.ADMIN |
| | | }) |
| | | |
| | | /** å½å群已å¯ç¨æåæ°ï¼DISABLE å³é群 / 被踢ä¸è®¡å
¥ï¼ï¼ç¨äºä¸éå¤å® */ |
| | | const activeMemberCount = computed( |
| | | () => members.value.filter((member) => member.status !== CommonStatusEnum.DISABLE).length |
| | | ) |
| | | |
| | | /** é请å群æ»äººæ°è¥è¶
GROUP_MAX_MEMBERï¼å端å
æ¦ï¼activeMemberCount + selectedIds.length å³é请åçæåæ° */ |
| | | const willExceedLimit = computed( |
| | | () => activeMemberCount.value + selectedIds.value.length > GROUP_MAX_MEMBER |
| | | ) |
| | | |
| | | /** æ·»å æé®å¯ç¹ï¼è³å°æ 1 个æ°é请ç好å + ä¸è¶
群人æ°ä¸é */ |
| | | const canSubmit = computed(() => selectedIds.value.length > 0 && !willExceedLimit.value) |
| | | |
| | | /** é请å
¥ç¾¤ï¼è° /im/group/inviteï¼æåå emit reload 让ç¶ä¾§å·æ°ç¾¤æå */ |
| | | async function handleOk() { |
| | | if (!groupId.value) { |
| | | return |
| | | } |
| | | const memberUserIds = [...selectedIds.value] |
| | | if (memberUserIds.length === 0) { |
| | | return |
| | | } |
| | | // 群人æ°ä¸éåä½é²å¾¡ï¼ä¸ canSubmit éå¤å¤ä¸æ¬¡ï¼é²æ¢ç¶æå¨ await é´éååæè°ç¨æ¹ç»è¿æé®ç´æ¥è° |
| | | if (activeMemberCount.value + memberUserIds.length > GROUP_MAX_MEMBER) { |
| | | message.warning(`群æåä¸é为 ${GROUP_MAX_MEMBER} 人`) |
| | | return |
| | | } |
| | | submitting.value = true |
| | | try { |
| | | await inviteGroupMember({ groupId: groupId.value, memberUserIds }) |
| | | // 审æ¹åæ¯ï¼å端ä»
è½å®¡æ¹è®°å½ï¼æªå
¥ç¾¤ |
| | | message.success(willGoApproval.value ? 'é请已åèµ·ï¼çå¾
群主 / 管çå审æ¹' : 'é请æå') |
| | | emit('reload', memberUserIds) |
| | | visible.value = false |
| | | } finally { |
| | | submitting.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | æ·»å 群æåï¼é好åé请å
¥ç¾¤ï¼å·²å¨ç¾¤æåç½®ç°ãä¸è®¡å
¥å·²éï¼ |
| | | - dialog 壳æ¬ç»ä»¶ææï¼éæ© UI å§æ FriendPickerPanel |
| | | - å·²å¨ç¾¤æåéè¿ disabledIds ä¼ å
¥ï¼ä¸åèµ° checked+disabled ç"å·²å¾éç°æ" |
| | | - 坹夿¥å£ï¼ref + open({ groupId }) + emit reload(friendIds) |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | title="æ·»å 群æå" |
| | | width="720px" |
| | | :mask-closable="false" |
| | | class="im-picker-dialog" |
| | | > |
| | | <div class="h-[480px]"> |
| | | <FriendPickerPanel |
| | | v-model:selected-ids="selectedIds" |
| | | :friends="friends" |
| | | :disabled-ids="disabledIds" |
| | | /> |
| | | </div> |
| | | |
| | | <template #footer> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk"> |
| | | æ·»å |
| | | </Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | @use '../picker/picker-dialog' as picker; |
| | | |
| | | /* :deep ç©¿é el-dialog å
é¨ç±»ï¼å¤ç¨ picker å
Œ
± mixin */ |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from './group-member.vue' |
| | | |
| | | import { ImFriendAddSource } from '../../../utils/constants' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImGroupMemberGrid' }) |
| | | |
| | | withDefaults( |
| | | defineProps<{ |
| | | clickable?: boolean // 头åç¹å»æ¯å¦å¼¹ UserInfoCardï¼éæ©å¨å®«æ ¼ééè¦ä¿æå
³éï¼é¿å
åå¾é交äºå²çª |
| | | groupName?: string // 群åï¼å 好忶æ¼ãææ¯ 'XX 群' ç YYãè¯æ¯ï¼è½åº add_source=GROUP |
| | | member: GroupMemberLite |
| | | size?: number // 头ååç´ å¤§å°ï¼é»è®¤ 38ï¼å
¼å®¹éæ©å¨å³ä¾§å·²éåºï¼ï¼ç¾¤ä¿¡æ¯æ½å±ä¼ 50 对é½å¾®ä¿¡ PC |
| | | }>(), |
| | | { |
| | | clickable: false, |
| | | size: 38, |
| | | groupName: '' |
| | | } |
| | | ) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 群æåå®«æ ¼åå
|
| | | - å®«æ ¼å±ç¤ºçæå°åä½ï¼å¤´åå¨ä¸ãååå¨ä¸ï¼å宽 = size + 16ï¼èªéåº size çå¼å¸ç©ºé´ |
| | | - 被 GroupMemberPickerPanel å³ä¾§å·²éåºï¼grid å½¢æï¼ãConversationGroupSide 群æååºå¾ªç¯ä½¿ç¨ |
| | | --> |
| | | <div |
| | | class="relative flex flex-col items-center px-0.5 py-1" |
| | | :style="{ width: `${size! + 16}px` }" |
| | | > |
| | | <UserAvatar |
| | | :id="member.userId" |
| | | :url="member.avatar" |
| | | :name="member.nickname" |
| | | :size="size" |
| | | :clickable="clickable" |
| | | :add-source="ImFriendAddSource.GROUP" |
| | | :add-source-extra="groupName" |
| | | /> |
| | | <div |
| | | class="w-full mt-1 overflow-hidden text-12px leading-[18px] text-center truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ member.showName }} |
| | | </div> |
| | | <slot></slot> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from './group-member.vue' |
| | | |
| | | import { ref } from 'vue' |
| | | |
| | | import { Button, message, Modal } from 'ant-design-vue' |
| | | |
| | | import { removeGroupMember } from '#/api/im/group/member' |
| | | |
| | | import { GroupMemberPickerPanel } from '../picker' |
| | | |
| | | defineOptions({ name: 'ImGroupMemberRemoveDialog' }) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** ç§»åºæåï¼ç¶ä¾§éå¸¸ç¨æ¥ reload ç¾¤æ°æ® */ |
| | | reload: [] |
| | | }>() |
| | | |
| | | const visible = ref(false) |
| | | const submitting = ref(false) |
| | | const groupId = ref(0) |
| | | const members = ref<GroupMemberLite[]>([]) |
| | | const hideIds = ref<number[]>([]) |
| | | const selectedIds = ref<number[]>([]) |
| | | |
| | | defineExpose({ |
| | | /** æå¼ç§»é¤ç¾¤æåå¼¹çªï¼reset â çå â visible=true */ |
| | | open(opts: { |
| | | groupId: number |
| | | /** éè userIdï¼ç¾¤ä¸»å§ç»éèï¼ç®¡çåè§è§é¢å¤éèå
¶å®ç®¡çå */ |
| | | hideIds?: number[] |
| | | members: GroupMemberLite[] |
| | | }) { |
| | | groupId.value = opts.groupId |
| | | members.value = opts.members |
| | | hideIds.value = opts.hideIds ? [...opts.hideIds] : [] |
| | | selectedIds.value = [] |
| | | submitting.value = false |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** 䏿¬¡æ§æ¹é踢人ï¼é䏿å userId æ°ç»ä¼ ç»åç«¯ï¼æ¯å¾ªç¯è° N 次æ¥å£çå¾è¿ */ |
| | | async function handleOk() { |
| | | if (!groupId.value || selectedIds.value.length === 0) { |
| | | return |
| | | } |
| | | submitting.value = true |
| | | try { |
| | | const memberUserIds = [...selectedIds.value] |
| | | await removeGroupMember({ groupId: groupId.value, memberUserIds }) |
| | | message.success(`å·²ç§»é¤ ${memberUserIds.length} 使å`) |
| | | emit('reload') |
| | | visible.value = false |
| | | } finally { |
| | | submitting.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | ç§»é¤ç¾¤æåï¼éæå â removeGroupMember æ¹é踢人 |
| | | - dialog 壳æ¬ç»ä»¶ææï¼éæ© UI å§æ GroupMemberPickerPanel |
| | | - 群主 / 管çåè§è§çä¸å¯ç§»é¤æåéè¿ hideIds éèï¼ç±è°ç¨æ¹ä¼ å
¥ |
| | | - 坹夿¥å£ï¼ref + open({ groupId, members, hideIds }) + emit reload() |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | title="ç§»åºç¾¤æå" |
| | | width="700px" |
| | | :mask-closable="false" |
| | | class="im-picker-dialog" |
| | | > |
| | | <div class="h-[480px]"> |
| | | <GroupMemberPickerPanel |
| | | v-model:selected-ids="selectedIds" |
| | | :members="members" |
| | | :hide-ids="hideIds" |
| | | :max-size="50" |
| | | /> |
| | | </div> |
| | | |
| | | <template #footer> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="submitting" |
| | | :disabled="selectedIds.length === 0" |
| | | @click="handleOk" |
| | | > |
| | | ç§»åº |
| | | </Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | @use '../picker/picker-dialog' as picker; |
| | | |
| | | /* :deep ç©¿é el-dialog å
é¨ç±»ï¼å¤ç¨ picker å
Œ
± mixin */ |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue' |
| | | |
| | | import { ImFriendAddSource } from '../../../utils/constants' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImGroupMember' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | active?: boolean // é䏿ï¼@åéé®çé«äº®çï¼ |
| | | clickable?: boolean // 头åç¹å»æ¯å¦å¼¹ UserInfoCardï¼@åéåºæ¯é常ç¦ç¨ï¼é¿å
åµå¥äº¤äºï¼ |
| | | groupName?: string // 群åï¼å 好忶æ¼ãææ¯ 'XX 群' ç YYãè¯æ¯ï¼è½åº add_source=GROUP |
| | | height?: number // è¡é«ï¼pxï¼ï¼å½±å头åå¤§å° |
| | | member: GroupMemberLite |
| | | }>(), |
| | | { |
| | | height: 50, |
| | | active: false, |
| | | clickable: false, |
| | | groupName: '' |
| | | } |
| | | ) |
| | | |
| | | /** 群æåç»æï¼è·¨å¤å¤ä½¿ç¨ï¼æ¾è¿éåçªæ¥å£ï¼ç¬ç«äº types/index.tsï¼ */ |
| | | export interface GroupMemberLite { |
| | | userId: number // ç¨æ·ç¼å·ï¼ç¹æ®å¼è§ IM_AT_ALL_USER_IDï¼@ å
¨ä½æåï¼ |
| | | nickname: string // ç宿µç§°ï¼æ°¸è¿æ¯ç¨æ·ç nicknameï¼ä¸ç» UserAvatar è²å¡ç¨ï¼ä¿è¯åä¸ä¸ªäººè²å¡é¦åæ¯å¨ææçé¢ä¸è´ |
| | | showName: string // å±ç¤ºæµç§°ï¼å¥½å夿³¨ > ç¨æ·ç¾¤å¤æ³¨ï¼displayUserNameï¼ > ç宿µç§°ï¼nicknameï¼ï¼ç»"æ¾ç¤ºç»ç¨æ·ç"çä½ç½®ç¨ï¼è¡å
æåã@åéæ ç¾çï¼ |
| | | avatar?: string |
| | | status?: number |
| | | role?: number // æåè§è²ï¼ä»
å¨ç¾¤ä¿¡æ¯æ½å±çéè¦å±ç¤ºè§è²æ ç¾çåºæ¯éä¼ ï¼@åé / 已读å表çåºæ¯å¯ä¸ä¼ |
| | | } |
| | | |
| | | const avatarSize = computed(() => Math.ceil(props.height * 0.75)) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 群æååè¡ |
| | | è·¨ååå¤ç¨ï¼@åé (MentionPicker) / 已读å表 (MessageReadStatus) / 群æåå®«æ ¼ (ConversationGroupSide) |
| | | --> |
| | | <div |
| | | class="relative flex items-center px-[5px] box-border whitespace-nowrap" |
| | | :class="{ 'bg-[#e1eaf7] dark:bg-[var(--ant-color-primary-bg)]': active }" |
| | | :style="{ height: `${height }px` }" |
| | | > |
| | | <UserAvatar |
| | | :size="avatarSize" |
| | | :name="member.nickname" |
| | | :url="member.avatar" |
| | | :clickable="clickable" |
| | | :id="member.userId" |
| | | :add-source="ImFriendAddSource.GROUP" |
| | | :add-source-extra="groupName" |
| | | /> |
| | | <div |
| | | class="flex-1 h-full pl-2.5 overflow-hidden text-sm text-left truncate text-[var(--ant-color-text)]" |
| | | :style="{ lineHeight: `${height }px` }" |
| | | > |
| | | {{ member.showName }} |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from './group-member.vue' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { confirm } from '#/packages/effects/common-ui/src' |
| | | |
| | | import { Button, message, Modal } from 'ant-design-vue' |
| | | |
| | | import { transferGroupOwner } from '#/api/im/group' |
| | | |
| | | import { GroupMemberPickerPanel } from '../picker' |
| | | |
| | | defineOptions({ name: 'ImGroupOwnerTransferDialog' }) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** 转让æåï¼ç¶ä¾§éå¸¸ç¨æ¥ reload ç¾¤æ°æ® */ |
| | | reload: [] |
| | | }>() |
| | | |
| | | const visible = ref(false) |
| | | const submitting = ref(false) |
| | | const groupId = ref(0) |
| | | const members = ref<GroupMemberLite[]>([]) |
| | | const hideIds = ref<number[]>([]) |
| | | const selectedIds = ref<number[]>([]) |
| | | |
| | | defineExpose({ |
| | | /** æå¼è½¬è®©ç¾¤ä¸»å¼¹çªï¼reset â çå â visible=true */ |
| | | open(opts: { |
| | | groupId: number |
| | | /** éè userIdï¼å½åç¨æ·ï¼ä¸è½è½¬ç»èªå·±ï¼ */ |
| | | hideIds?: number[] |
| | | members: GroupMemberLite[] |
| | | }) { |
| | | groupId.value = opts.groupId |
| | | members.value = opts.members |
| | | hideIds.value = opts.hideIds ? [...opts.hideIds] : [] |
| | | selectedIds.value = [] |
| | | submitting.value = false |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** éä¸çæ°ç¾¤ä¸»å¯¹è±¡ï¼åæ°ç»é¦é¡¹ï¼ */ |
| | | const newOwner = computed<GroupMemberLite | undefined>(() => { |
| | | if (selectedIds.value.length === 0) { |
| | | return undefined |
| | | } |
| | | return members.value.find((member) => member.userId === selectedIds.value[0]) |
| | | }) |
| | | |
| | | /** äºæ¬¡ç¡®è®¤è½¬è®©ï¼è½¬è®©åæ§ç¾¤ä¸»é为æ®éæåï¼æ æ³æ¤é */ |
| | | async function handleOk() { |
| | | if (!groupId.value || !newOwner.value) { |
| | | return |
| | | } |
| | | try { |
| | | await confirm( |
| | | `ç¡®å®å°ç¾¤ä¸»è½¬è®©ç» ${newOwner.value.showName}ï¼è½¬è®©åä½ å°å为æ®éæåï¼æ æ³æ¤éã`, |
| | | '确认转让群主' |
| | | ) |
| | | } catch { |
| | | return |
| | | } |
| | | submitting.value = true |
| | | try { |
| | | await transferGroupOwner({ |
| | | id: groupId.value, |
| | | newOwnerUserId: newOwner.value.userId |
| | | }) |
| | | message.success('群主转让æå') |
| | | emit('reload') |
| | | visible.value = false |
| | | } finally { |
| | | submitting.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 转让群主ï¼é 1 使°ç¾¤ä¸» â äºæ¬¡ç¡®è®¤ â transferGroupOwner |
| | | - dialog 壳æ¬ç»ä»¶ææï¼éæ© UI å§æ GroupMemberPickerPanel |
| | | - å½åç¨æ·ä»åéééèï¼ä¸è½è½¬ç»èªå·±ï¼ |
| | | - maxSize=1 éå®åé |
| | | - 坹夿¥å£ï¼ref + open({ groupId, members, hideIds }) + emit reload() |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | title="éæ©æ°ç¾¤ä¸»" |
| | | width="700px" |
| | | :mask-closable="false" |
| | | class="im-picker-dialog" |
| | | > |
| | | <div class="h-[480px]"> |
| | | <GroupMemberPickerPanel |
| | | v-model:selected-ids="selectedIds" |
| | | :members="members" |
| | | :hide-ids="hideIds" |
| | | :max-size="1" |
| | | /> |
| | | </div> |
| | | |
| | | <template #footer> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="submitting" |
| | | :disabled="selectedIds.length === 0" |
| | | @click="handleOk" |
| | | > |
| | | ç¡®å® |
| | | </Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | @use '../picker/picker-dialog' as picker; |
| | | |
| | | /* :deep ç©¿é el-dialog å
é¨ç±»ï¼å¤ç¨ picker å
Œ
± mixin */ |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { ImGroupRequestApi } from '#/api/im/group/request' |
| | | |
| | | import { computed, ref, watch } from 'vue' |
| | | |
| | | import { prompt } from '#/packages/effects/common-ui/src' |
| | | |
| | | import { Empty, Input, message, Modal, Spin } from 'ant-design-vue' |
| | | |
| | | import { getGroupRequestListByGroupId } from '#/api/im/group/request' |
| | | import { ImGroupRequestHandleResult } from '#/views/im/utils/constants' |
| | | |
| | | import { useGroupRequestStore } from '../../store/groupRequestStore' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImGroupRequestListDialog' }) |
| | | |
| | | const groupRequestStore = useGroupRequestStore() |
| | | |
| | | const visible = ref(false) |
| | | const groupId = ref<number | undefined>() // å½åå±ç¤ºç群ç¼å·ï¼undefined æ¶èµ°å
¨å±æªå¤çå表ï¼store.unhandledListï¼ |
| | | const loading = ref(false) |
| | | const groupList = ref<ImGroupRequestApi.GroupRequestRespVO[]>([]) |
| | | const actingId = ref<null | number>(null) |
| | | |
| | | defineExpose({ |
| | | /** æå¼è¿ç¾¤ç³è¯·å¼¹çªï¼reset â çå â visible=trueï¼ä¸ä¼ groupId èµ°å
¨å±æªå¤çå表 */ |
| | | open(opts?: { groupId?: number }) { |
| | | groupId.value = opts?.groupId |
| | | actingId.value = null |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** æ°æ®æºï¼å群模å¼ç¨ fetch 忥ç groupListï¼å
¨å±æ¨¡å¼ç´æ¥è¯» store.unhandledListï¼å¤çå store èªå¨ reactive 忥 */ |
| | | const list = computed<ImGroupRequestApi.GroupRequestRespVO[]>(() => |
| | | groupId.value ? groupList.value : groupRequestStore.unhandledList |
| | | ) |
| | | |
| | | /** é¡¶é¨å¡çï¼ææ°ä¸æ¡ï¼ç©ºæ°ç»æ¶ä¸º null */ |
| | | const latest = computed(() => list.value[0] || null) |
| | | /** åå²å表ï¼é¤ææ°ä¸æ¡å¤çå
¶ä½ */ |
| | | const histories = computed(() => list.value.slice(1)) |
| | | |
| | | /** æå¼ dialog æ¶ææ°æ®ï¼å群æ APIï¼å
¨å±ç´æ¥è¯» storeï¼å
³éæ¶æ¸
æå群ç¼å */ |
| | | watch( |
| | | [visible, groupId], |
| | | ([isVisible, currentGroupId]) => { |
| | | if (isVisible && currentGroupId) { |
| | | void fetchList(currentGroupId) |
| | | } else if (!isVisible) { |
| | | groupList.value = [] |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | /** |
| | | * å群模å¼ä¸è®¢é
store ä¸å½å±æ¬ç¾¤çæªå¤çå表ååï¼è¿ç«¯äºä»¶ï¼WS 1503 æ°ç³è¯· / å
¶ä»ç®¡çåå¤çï¼è§¦åæ¶ refetch |
| | | * æ¿ææ° handleResultï¼æ¬ç«¯ agree / refuse æé´ actingId éä½ï¼è·³è¿æ¬ç«¯å¨ä½å¼åç store ååé¿å
åä½ RTT |
| | | * |
| | | * key ä¸è½åª join idï¼å¤ç¨æ§è®°å½æ¶åä¸ requestId ç applyContent / inviterUserId ä¼å·æ°ä½ id ä¸åï¼å¿
é¡»æå
容忮µä¹çº³å
¥è§¦å |
| | | */ |
| | | watch( |
| | | () => |
| | | groupId.value && visible.value |
| | | ? groupRequestStore.unhandledList |
| | | .filter((request) => request.groupId === groupId.value) |
| | | .map( |
| | | (request) => |
| | | `${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}` |
| | | ) |
| | | .join(',') |
| | | : null, |
| | | (current, previous) => { |
| | | if (current === null || previous === undefined || current === previous) { |
| | | return |
| | | } |
| | | if (actingId.value !== null) { |
| | | return |
| | | } |
| | | if (groupId.value) { |
| | | void fetchList(groupId.value) |
| | | } |
| | | } |
| | | ) |
| | | |
| | | let fetchSeq = 0 // åè°éå¢è¯·æ±åºå·ï¼å群ä¹ä¼å 为 WS 1503 æ¨é触åé¢å¤ fetchï¼ä¹±åºè¿åæ¶æ§ååºä¸è½è¦çæ°æ°æ® |
| | | async function fetchList(targetGroupId: number) { |
| | | const seq = ++fetchSeq |
| | | loading.value = true |
| | | try { |
| | | const data = (await getGroupRequestListByGroupId(targetGroupId)) || [] |
| | | // æé´å群 / å
³å¼¹çª / åè§¦åæ´æ° fetchï¼ä¸¢ååº |
| | | if (seq !== fetchSeq || !visible.value || groupId.value !== targetGroupId) { |
| | | return |
| | | } |
| | | groupList.value = data |
| | | } finally { |
| | | // æ§è¯·æ± finally å½ä¸æ¶æ°è¯·æ±ä»å¨è·ï¼è·³è¿é¿å
æåå
³ loading |
| | | if (seq === fetchSeq) { |
| | | loading.value = false |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** åæï¼èµ° store 忥å
¨å±æªå¤çå表 + æ¬å°æ´æ° handleResult 让æé®åç° */ |
| | | async function handleAgree(item: ImGroupRequestApi.GroupRequestRespVO) { |
| | | if (actingId.value !== null) return |
| | | actingId.value = item.id |
| | | try { |
| | | await groupRequestStore.agreeGroupRequest(item.id) |
| | | updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED) |
| | | message.success('å·²åæ') |
| | | } finally { |
| | | actingId.value = null |
| | | } |
| | | } |
| | | |
| | | /** æç»ï¼å¼¹çç±è¾å
¥æ¡ï¼ä¸ºç©ºåä¸å¸¦ handleContent */ |
| | | async function handleRefuse(item: ImGroupRequestApi.GroupRequestRespVO) { |
| | | if (actingId.value !== null) return |
| | | let handleContent: string |
| | | try { |
| | | const result = await prompt<string>({ |
| | | component: Input, |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥æç»çç±ï¼å¯éï¼' |
| | | }, |
| | | content: '', |
| | | modelPropName: 'value', |
| | | title: 'æç»ç³è¯·' |
| | | }) |
| | | handleContent = result || '' |
| | | } catch { |
| | | return |
| | | } |
| | | actingId.value = item.id |
| | | try { |
| | | await groupRequestStore.refuseGroupRequest(item.id, handleContent || undefined) |
| | | updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED) |
| | | message.success('å·²æç»') |
| | | } finally { |
| | | actingId.value = null |
| | | } |
| | | } |
| | | |
| | | /** å群模å¼ä¸å¤çåæ´æ° groupList éç handleResultï¼æé®è½¬ãå·²åæ / å·²æç»ãç°æï¼å
¨å±æ¨¡å¼ store ç´æ¥ç§»é¤è¯¥é¡¹æ éæ´æ° */ |
| | | function updateLocalResult(id: number, handleResult: number) { |
| | | const target = groupList.value.find((r) => r.id === id) |
| | | if (target) { |
| | | target.handleResult = handleResult |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 群ãè¿ç¾¤ç³è¯·ãåè¡¨å¯¹è¯æ¡ |
| | | - ä»
群主 / 管çåå
¥å£å¯è¾¾ï¼å±ç¤ºå½å群ä¸å
¨é¨ç³è¯·ï¼å«å·²å¤çï¼ |
| | | - 顶鍿æ°ä¸æ¡å¡çåçªåºï¼å¸¦ç³è¯·çç±ï¼ï¼å
¶ä½æ id ååºç´§åå表 |
| | | - åæ / æç»èµ° groupRequestStore ç actionï¼å¤ç忬尿´æ° handleResult 让æé®è½¬ç°æ |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | title="è¿ç¾¤ç³è¯·" |
| | | width="560px" |
| | | :footer="null" |
| | | :mask-closable="false" |
| | | class="im-group-request-list__dialog" |
| | | > |
| | | <Spin :spinning="loading" wrapper-class-name="w-full"> |
| | | <div class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1"> |
| | | <!-- 空æ --> |
| | | <Empty v-if="!loading && list.length === 0" description="ææ è¿ç¾¤ç³è¯·" /> |
| | | |
| | | <!-- é¡¶é¨å¡çï¼ææ°ä¸æ¡ --> |
| | | <div |
| | | v-if="latest" |
| | | class="flex flex-col gap-2.5 p-3.5 rounded-[10px] border border-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]" |
| | | > |
| | | <div class="flex items-center gap-3"> |
| | | <UserAvatar |
| | | :url="latest.userAvatar" |
| | | :name="latest.userNickname" |
| | | :size="44" |
| | | :clickable="false" |
| | | /> |
| | | <div class="flex-1 min-w-0"> |
| | | <div |
| | | class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]" |
| | | > |
| | | {{ latest.userNickname || `ç¨æ· ${latest.userId}` }} |
| | | </div> |
| | | <div |
| | | class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <template v-if="latest.inviterUserId"> |
| | | éè¿ |
| | | <span class="text-[var(--ant-color-primary)]"> |
| | | {{ latest.inviterNickname || `ç¨æ· ${latest.inviterUserId}` }} |
| | | </span> |
| | | çé请è¿ç¾¤ |
| | | </template> |
| | | <template v-else>ç³è¯·å å
¥</template> |
| | | </div> |
| | | </div> |
| | | <span |
| | | v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED" |
| | | class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | å·²åæ |
| | | </span> |
| | | <span |
| | | v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED" |
| | | class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | å·²æç» |
| | | </span> |
| | | <div v-else class="flex gap-1.5 flex-shrink-0"> |
| | | <button |
| | | class="im-group-request-list__btn im-group-request-list__btn--primary" |
| | | :disabled="actingId === latest.id" |
| | | @click="handleAgree(latest)" |
| | | > |
| | | 确认 |
| | | </button> |
| | | <button |
| | | class="im-group-request-list__btn im-group-request-list__btn--ghost" |
| | | :disabled="actingId === latest.id" |
| | | @click="handleRefuse(latest)" |
| | | > |
| | | æç» |
| | | </button> |
| | | </div> |
| | | </div> |
| | | <!-- ç³è¯·çç±ï¼éè¯·åºæ¯æ¾ç¤ºé请人 + çè¨ï¼ä¸»å¨ç³è¯·æ¾ç¤ºç³è¯·äºº + çè¨ --> |
| | | <div |
| | | v-if="latest.applyContent" |
| | | class="px-3 py-2 rounded-md text-[13px] leading-[1.5] break-all bg-[var(--ant-color-fill-secondary)] text-[var(--ant-color-text)]" |
| | | > |
| | | <span class="text-[var(--ant-color-primary)]"> |
| | | {{ |
| | | latest.inviterUserId |
| | | ? latest.inviterNickname || `ç¨æ· ${latest.inviterUserId}` |
| | | : latest.userNickname || `ç¨æ· ${latest.userId}` |
| | | }}ï¼ |
| | | </span> |
| | | {{ latest.applyContent }} |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- åå²çº¿ï¼ä»
å¨ææ´æ©ç³è¯·æ¶åºç° --> |
| | | <div |
| | | v-if="histories.length > 0" |
| | | class="flex items-center justify-center mt-1.5 -mb-0.5 text-12px text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | <span>以ä¸ä¸ºæ´æ©çç³è¯·</span> |
| | | </div> |
| | | |
| | | <!-- åå²ç³è¯·å表 --> |
| | | <div |
| | | v-for="item in histories" |
| | | :key="item.id" |
| | | class="flex flex-col gap-2.5 px-3.5 py-2.5 rounded-[10px] border border-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]" |
| | | > |
| | | <div class="flex items-center gap-3"> |
| | | <UserAvatar |
| | | :url="item.userAvatar" |
| | | :name="item.userNickname" |
| | | :size="40" |
| | | :clickable="false" |
| | | /> |
| | | <div class="flex-1 min-w-0"> |
| | | <div |
| | | class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]" |
| | | > |
| | | {{ item.userNickname || `ç¨æ· ${item.userId}` }} |
| | | </div> |
| | | <div |
| | | class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <template v-if="item.inviterUserId"> |
| | | éè¿ |
| | | <span class="text-[var(--ant-color-primary)]"> |
| | | {{ item.inviterNickname || `ç¨æ· ${item.inviterUserId}` }} |
| | | </span> |
| | | çé请è¿ç¾¤ |
| | | </template> |
| | | <template v-else>ç³è¯·å å
¥</template> |
| | | </div> |
| | | </div> |
| | | <span |
| | | v-if="item.handleResult === ImGroupRequestHandleResult.AGREED" |
| | | class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | å·²åæ |
| | | </span> |
| | | <span |
| | | v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED" |
| | | class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | å·²æç» |
| | | </span> |
| | | <div v-else class="flex gap-1.5 flex-shrink-0"> |
| | | <button |
| | | class="im-group-request-list__btn im-group-request-list__btn--primary" |
| | | :disabled="actingId === item.id" |
| | | @click="handleAgree(item)" |
| | | > |
| | | 确认 |
| | | </button> |
| | | <button |
| | | class="im-group-request-list__btn im-group-request-list__btn--ghost" |
| | | :disabled="actingId === item.id" |
| | | @click="handleRefuse(item)" |
| | | > |
| | | æç» |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Spin> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* èªç»æé®ï¼è´´è¿å¾®ä¿¡å°è¯ä¸¸æ ·å¼ï¼ä¸ :disabledã:hover:not(:disabled) ç伪类å å modifier ç±»çç»åéæ©å¨åå¨ class éææ¬é«ï¼ç SCSS */ |
| | | .im-group-request-list__btn { |
| | | flex-shrink: 0; |
| | | min-width: 56px; |
| | | height: 28px; |
| | | padding: 0 12px; |
| | | font-size: 13px; |
| | | border-radius: 4px; |
| | | cursor: pointer; |
| | | border: 1px solid transparent; |
| | | transition: |
| | | background-color 0.15s, |
| | | border-color 0.15s, |
| | | color 0.15s; |
| | | } |
| | | .im-group-request-list__btn:disabled { |
| | | opacity: 0.6; |
| | | cursor: not-allowed; |
| | | } |
| | | |
| | | .im-group-request-list__btn--primary { |
| | | color: #fff; |
| | | background-color: var(--ant-color-primary); |
| | | border-color: var(--ant-color-primary); |
| | | } |
| | | .im-group-request-list__btn--primary:hover:not(:disabled) { |
| | | background-color: var(--ant-color-primary-hover); |
| | | border-color: var(--ant-color-primary-hover); |
| | | } |
| | | |
| | | .im-group-request-list__btn--ghost { |
| | | color: var(--ant-color-text); |
| | | background-color: var(--ant-color-bg-container); |
| | | border-color: var(--ant-color-border); |
| | | } |
| | | .im-group-request-list__btn--ghost:hover:not(:disabled) { |
| | | color: var(--ant-color-primary); |
| | | border-color: var(--ant-color-primary); |
| | | } |
| | | </style> |
| | | |
| | | <style> |
| | | /* el-dialog å
é¨ body éè¿ teleport 渲æå° bodyï¼scoped éä¸å°ï¼çé scoped å
¨å±è¦ç */ |
| | | .im-group-request-list__dialog .el-dialog__body { |
| | | padding: 12px 20px 8px; |
| | | background-color: var(--ant-color-fill-secondary); |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup generic="T"> |
| | | import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue' |
| | | |
| | | defineOptions({ name: 'ImPagedScroller' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | itemKey?: string // ä¸å¡ id åæ®µåï¼å¦ 'userId' / 'id'ï¼ï¼ä¸ä¼ / åæ®µå¼é string|number æ¶åé idx |
| | | items: T[] // å
¨éæ°æ® |
| | | pageSize?: number // æ¯é¡µæ¸²ææ¡æ° |
| | | threshold?: number // è·åºå¤å° px 触åä¸ä¸é¡µ |
| | | }>(), |
| | | { |
| | | itemKey: undefined, |
| | | pageSize: 30, |
| | | threshold: 30 |
| | | } |
| | | ) |
| | | |
| | | /** è§£ææ¯æ¡ item ç :keyï¼caller ä¼ itemKey åæå段åï¼æ æ / 缺失åéç´¢å¼ï¼é¿å
ä¼ éåæ®µæ¶å
¨è¡¨ undefined key */ |
| | | function resolveItemKey(item: T, idx: number): number | string { |
| | | if (!props.itemKey || item == null || typeof item !== 'object') { |
| | | return idx |
| | | } |
| | | const value = (item as Record<string, unknown>)[props.itemKey] |
| | | return typeof value === 'string' || typeof value === 'number' ? value : idx |
| | | } |
| | | |
| | | const scrollbarRef = useTemplateRef<HTMLDivElement>('scrollbarRef') |
| | | const page = ref(1) |
| | | |
| | | const displayItems = computed(() => { |
| | | const limit = Math.min(page.value * props.pageSize, props.items.length) |
| | | return props.items.slice(0, limit) |
| | | }) |
| | | |
| | | const allLoaded = computed(() => displayItems.value.length >= props.items.length) |
| | | |
| | | /** ä»
å½è¶
è¿ä¸é¡µæ¶ææ¾ç¤ºãå·²å°åºé¨ãï¼é¿å
çå表ä¹åºç°è¿æ¡æç¤º */ |
| | | const showFooter = computed(() => allLoaded.value && props.items.length > props.pageSize) |
| | | |
| | | let wrapEl: HTMLElement | null = null |
| | | |
| | | onMounted(() => { |
| | | wrapEl = scrollbarRef.value |
| | | wrapEl?.addEventListener('scroll', onScroll) |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | wrapEl?.removeEventListener('scroll', onScroll) |
| | | }) |
| | | |
| | | /** åæ¢æ°æ®æºï¼å¦åä¼è¯ï¼æ¶éç½®å页ï¼é¿å
æ°åè¡¨æ²¿ç¨æ§ pageï¼é¦å±åºç°ç©ºæ®µ */ |
| | | watch( |
| | | () => props.items, |
| | | () => { |
| | | page.value = 1 |
| | | } |
| | | ) |
| | | |
| | | /** æ»å°è·åº threshold å
æ¶èªå¢ pageï¼æ©åºä¸ä¸æ®µåç */ |
| | | function onScroll(e: Event) { |
| | | const el = e.target as HTMLElement |
| | | if (el.scrollTop + el.clientHeight < el.scrollHeight - props.threshold) { |
| | | return |
| | | } |
| | | if (allLoaded.value) { |
| | | return |
| | | } |
| | | page.value++ |
| | | } |
| | | |
| | | defineExpose({ |
| | | /** æå¨æ»å°é¡¶é¨ */ |
| | | scrollTop: () => { |
| | | if (wrapEl) { |
| | | wrapEl.scrollTop = 0 |
| | | } |
| | | }, |
| | | /** æå¨æ»å°åºé¨ */ |
| | | scrollBottom: () => { |
| | | if (wrapEl) { |
| | | wrapEl.scrollTop = wrapEl.scrollHeight |
| | | } |
| | | } |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | å页å¢éæ»å¨å®¹å¨ |
| | | - æ»å°åºé¨èªå¨ page++ï¼ç´å°å
¨é¨æ¸²æå® |
| | | - éè¿ slot æ´é²æ¯ä¸é¡¹ï¼è®©è°ç¨æ¹èªå·±å³å®æ¸²æ |
| | | --> |
| | | <div ref="scrollbarRef" class="w-full h-full overflow-y-auto"> |
| | | <slot |
| | | v-for="(item, idx) in displayItems" |
| | | :item="item" |
| | | :index="idx" |
| | | :key="resolveItemKey(item, idx)" |
| | | ></slot> |
| | | <div |
| | | v-if="showFooter" |
| | | class="py-3 text-xs text-center text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | å·²å°åºé¨ |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation } from '../../types' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Input, message } from 'ant-design-vue' |
| | | |
| | | import { ImConversationType } from '../../../utils/constants' |
| | | import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation' |
| | | import { GroupAvatar } from '../group' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImConversationPickerPanel' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** å
¨éä¼è¯å表 */ |
| | | conversations: Conversation[] |
| | | /** éè keyï¼ä»åé / å·²é / æè¿è½¬åéé½åé¤ï¼ä¸è½è½¬ååèªå·±ãæ¨èåçèªèº«çä¼è¯çï¼ */ |
| | | hideKeys?: string[] |
| | | /** 已鿰ä¸éï¼ä¸ä¼ æ <=0 æ¶ä¸é */ |
| | | maxSize?: number |
| | | /** æè¿è½¬åä¼è¯ key å表ï¼å±ç¤ºå¨å·¦æ 顶鍿¨ªå头ååº */ |
| | | recentForwardConversationKeys?: string[] |
| | | /** å·²éä¼è¯ keyï¼v-modelï¼ï¼key ç± getConversationKey çæ */ |
| | | selectedKeys: string[] |
| | | /** æ¯å¦å±ç¤ºãå建è天ãå
¥å£ */ |
| | | showCreateChat?: boolean |
| | | }>(), |
| | | { |
| | | recentForwardConversationKeys: () => [], |
| | | hideKeys: () => [], |
| | | maxSize: 0, |
| | | showCreateChat: false |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | createChat: [] |
| | | /** ç¨æ·å¨ãæè¿è½¬åãæ®µè¿å
¥ç§»é¤æ¨¡å¼åç¹ Ãï¼ä¸å¡å£³æ¶å°åè° conversationStore.removeRecentForwardConversationKey è½ç */ |
| | | removeRecent: [key: string] |
| | | 'update:selectedKeys': [value: string[]] |
| | | }>() |
| | | |
| | | const keyword = ref('') |
| | | const recentRemoveMode = ref(false) // ãæè¿è½¬åãæ®µæ¯å¦å¤äºç§»é¤æ¨¡å¼ï¼true æ¶å¤´åå³ä¸è§å à ä¸ååå¾é |
| | | |
| | | /** å
¨éä¼è¯ç keyâConversation æ å°ï¼å·²é / æè¿è½¬å忥å
±ç¨ï¼é¿å
æ¯æ¬¡ O(N) æ« */ |
| | | const byKey = computed(() => { |
| | | const map = new Map<string, Conversation>() |
| | | for (const conversation of props.conversations) { |
| | | map.set(getConversationKey(conversation), conversation) |
| | | } |
| | | return map |
| | | }) |
| | | |
| | | /** éèéåï¼æ¯æ¬¡è¿æ»¤å¤ç¨ */ |
| | | const hideSet = computed(() => new Set(props.hideKeys)) |
| | | |
| | | /** å·²ééåï¼åå½¢æç¤ºå¨ isSelected èµ° set å¿«æ¥ */ |
| | | const selectedSet = computed(() => new Set(props.selectedKeys)) |
| | | |
| | | /** åéä¼è¯ï¼åé¤ hideKeys */ |
| | | const candidateConversations = computed(() => |
| | | props.conversations.filter((c) => !hideSet.value.has(getConversationKey(c))) |
| | | ) |
| | | |
| | | /** å·¦æ å±ç¤ºå表ï¼å¨åéåºç¡ä¸æ keyword è¿æ»¤ */ |
| | | const shownConversations = computed(() => |
| | | filterConversationsByKeyword(candidateConversations.value, keyword.value) |
| | | ) |
| | | |
| | | /** æè¿è½¬åçä¼è¯å¯¹è±¡å表ï¼ä» recentForwardConversationKeys 忥ï¼åé¤ hide / ä¸åå¨ç key */ |
| | | const recentForwardConversations = computed(() => |
| | | props.recentForwardConversationKeys |
| | | .map((key) => byKey.value.get(key)) |
| | | .filter((c): c is Conversation => c != null && !hideSet.value.has(getConversationKey(c))) |
| | | ) |
| | | |
| | | /** æ¯å¦å±ç¤ºãæè¿è½¬åãæ®µï¼keyword 为空 + ææ°æ®æ¶æå±ç¤ºï¼æç´¢æ¶è®©ä½ */ |
| | | const showRecentSection = computed( |
| | | () => !keyword.value.trim() && recentForwardConversations.value.length > 0 |
| | | ) |
| | | |
| | | /** å·²éä¼è¯åè¡¨ï¼æ selectedKeys æ°ç»é¡ºåºï¼å³ç¹å»é¡ºåºï¼åæ¥ï¼è¿æ»¤ hideSet é¿å
ç¶ç»ä»¶å¨æéèçä¼è¯ä»å¨å³ä¾§æ¸²æ / æäº¤ */ |
| | | const selectedConversations = computed(() => |
| | | props.selectedKeys |
| | | .map((key) => byKey.value.get(key)) |
| | | .filter( |
| | | (conversation): conversation is Conversation => |
| | | conversation != null && !hideSet.value.has(getConversationKey(conversation)) |
| | | ) |
| | | ) |
| | | |
| | | /** 峿 æ 颿æ¡ï¼åéãåéç»ããå¤éãåå«åéç»ã */ |
| | | const sendTitle = computed(() => (props.selectedKeys.length > 1 ? 'åå«åéç»' : 'åéç»')) |
| | | |
| | | /** æ¯å¦å·²éä¸ï¼å·¦æ åå½¢æç¤ºå¨ / æè¿è½¬å头åè§æ å
±ç¨ */ |
| | | function isSelected(conversation: Conversation): boolean { |
| | | return selectedSet.value.has(getConversationKey(conversation)) |
| | | } |
| | | |
| | | /** ãæè¿è½¬åã头åç¹å»ï¼ç§»é¤æ¨¡å¼ä¸ä¸åå¾éï¼ç§»é¤ç± Ã è§æ å¤çï¼ */ |
| | | function handleRecentTileClick(conversation: Conversation) { |
| | | if (recentRemoveMode.value) { |
| | | return |
| | | } |
| | | handleToggle(conversation) |
| | | } |
| | | |
| | | /** 忢é䏿ï¼å·¦æ row / æè¿è½¬å头å / 峿 à 移é¤é½èµ°è¿é */ |
| | | function handleToggle(conversation: Conversation) { |
| | | const key = getConversationKey(conversation) |
| | | const next = [...props.selectedKeys] |
| | | const index = next.indexOf(key) |
| | | if (index === -1) { |
| | | // ç¶ç»ä»¶æ è®°éèçä¼è¯å³ä¾¿æè·¯å¾è§¦è¾¾ä¹ä¸åºå
¥é |
| | | if (hideSet.value.has(key)) { |
| | | return |
| | | } |
| | | if (props.maxSize > 0 && next.length >= props.maxSize) { |
| | | message.error(`æå¤éæ© ${props.maxSize} 个ä¼è¯`) |
| | | return |
| | | } |
| | | next.push(key) |
| | | } else { |
| | | next.splice(index, 1) |
| | | } |
| | | emit('update:selectedKeys', next) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | ä¼è¯éæ©é¢æ¿ï¼ç¨äºæ¨èåç / è½¬åæ¶æ¯ç"éå·²æä¼è¯"åºæ¯ |
| | | - å·¦ï¼æç´¢ + æè¿è½¬å横å头å + å建è天å
¥å£ + æè¿è天å表ï¼åå½¢å¾éï¼ |
| | | - å³ï¼å·²éæ°æ é¢ + å·²éä¼è¯åè¡¨ï¼æç¹å»é¡ºåºï¼+ footer slot |
| | | - Panel ä¸å¸¦ el-dialog 壳ï¼dialog ç±ä¸å¡å£³ææ |
| | | - footer slot 渲æå¨å³æ å·²éåè¡¨ä¸æ¹ï¼ä¸å¡å£³æ¾é¢è§å¡ / çè¨ / æäº¤æé® |
| | | --> |
| | | <div class="flex h-full"> |
| | | <!-- å·¦æ --> |
| | | <div |
| | | class="flex flex-col w-[280px] border-r border-r-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <!-- æç´¢æ¡ --> |
| | | <div class="flex-shrink-0 px-3 py-2"> |
| | | <Input v-model:value="keyword" placeholder="æç´¢" allow-clear> |
| | | <template #prefix> |
| | | <Icon icon="ant-design:search-outlined" /> |
| | | </template> |
| | | </Input> |
| | | </div> |
| | | |
| | | <div class="flex-1"> |
| | | <!-- æè¿è½¬å横å头ååºï¼keyword 为空 + ææè¿è½¬åæ°æ®æ¶å±ç¤º --> |
| | | <template v-if="showRecentSection"> |
| | | <div class="flex justify-between items-center pl-3 pr-2 pb-1.5"> |
| | | <span class="text-13px text-[var(--ant-color-text-secondary)]">æè¿è½¬å</span> |
| | | <span |
| | | class="px-1 cursor-pointer text-13px text-[var(--ant-color-primary)] hover:opacity-80" |
| | | @click="recentRemoveMode = !recentRemoveMode" |
| | | > |
| | | {{ recentRemoveMode ? '宿' : 'ç§»é¤' }} |
| | | </span> |
| | | </div> |
| | | <div |
| | | class="flex gap-2 pl-3 pr-2 pt-1 pb-2 overflow-x-auto im-conversation-picker__recent" |
| | | > |
| | | <div |
| | | v-for="conversation in recentForwardConversations" |
| | | :key="getConversationKey(conversation)" |
| | | class="flex flex-col flex-shrink-0 gap-1 items-center" |
| | | :class="{ 'cursor-pointer': !recentRemoveMode }" |
| | | @click="handleRecentTileClick(conversation)" |
| | | > |
| | | <div class="relative"> |
| | | <GroupAvatar |
| | | v-if="conversation.type === ImConversationType.GROUP" |
| | | :group-id="conversation.targetId" |
| | | :url="conversation.avatar" |
| | | :name="conversation.name" |
| | | :size="36" |
| | | /> |
| | | <UserAvatar |
| | | v-else |
| | | :url="conversation.avatar" |
| | | :name="conversation.name" |
| | | :size="36" |
| | | :clickable="false" |
| | | /> |
| | | <!-- ç§»é¤æ¨¡å¼ï¼å³ä¸è§ à åè§æ ï¼ç¹å»æè¿æ¡ key ä» recentForwardConversationKeys å æ --> |
| | | <span |
| | | v-if="recentRemoveMode" |
| | | class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full cursor-pointer bg-[var(--ant-color-fill-dark)] text-[var(--ant-color-text)]" |
| | | @click.stop="emit('removeRecent', getConversationKey(conversation))" |
| | | > |
| | | <Icon icon="ant-design:close-outlined" :size="10" /> |
| | | </span> |
| | | <!-- éç§»é¤æ¨¡å¼ï¼å³ä¸è§åå½¢å¾éæç¤ºå¨ï¼æªéç°ç©ºå¿åãéä¸ç»¿åºç½å¯¹å¾ --> |
| | | <span |
| | | v-else |
| | | class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full transition-colors" |
| | | :class=" |
| | | isSelected(conversation) |
| | | ? 'bg-[#07c160] border border-solid border-[#07c160]' |
| | | : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]' |
| | | " |
| | | > |
| | | <Icon |
| | | v-if="isSelected(conversation)" |
| | | icon="ant-design:check-outlined" |
| | | :size="10" |
| | | color="#fff" |
| | | /> |
| | | </span> |
| | | </div> |
| | | <span |
| | | class="overflow-hidden max-w-[48px] text-12px truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ conversation.name }} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- å建è天å
¥å£ï¼keyword 为空 + showCreateChat=true æ¶å±ç¤º --> |
| | | <div |
| | | v-if="showCreateChat && !keyword.trim()" |
| | | class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--ant-color-fill)]" |
| | | @click="emit('createChat')" |
| | | > |
| | | <span |
| | | class="flex flex-shrink-0 justify-center items-center w-8 h-8 rounded-full bg-[var(--ant-color-fill)] text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <Icon icon="ant-design:plus-outlined" :size="16" /> |
| | | </span> |
| | | <span class="text-sm text-[var(--ant-color-text)]">å建è天</span> |
| | | </div> |
| | | |
| | | <!-- æè¿è天åç»æ é¢ --> |
| | | <div class="px-3 pb-1.5 text-13px text-[var(--ant-color-text-secondary)]">æè¿è天</div> |
| | | |
| | | <!-- ä¼è¯å表 --> |
| | | <div |
| | | v-for="conversation in shownConversations" |
| | | :key="getConversationKey(conversation)" |
| | | class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--ant-color-fill)]" |
| | | @click="handleToggle(conversation)" |
| | | > |
| | | <!-- åå½¢å¾éæç¤ºå¨ï¼æªéç°è²ç©ºå¿åï¼éä¸å®å¿å¾®ä¿¡ç»¿ + ç½å¯¹å¾ --> |
| | | <span |
| | | class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors" |
| | | :class=" |
| | | isSelected(conversation) |
| | | ? 'bg-[#07c160] border border-solid border-[#07c160]' |
| | | : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]' |
| | | " |
| | | > |
| | | <Icon |
| | | v-if="isSelected(conversation)" |
| | | icon="ant-design:check-outlined" |
| | | :size="12" |
| | | color="#fff" |
| | | /> |
| | | </span> |
| | | <GroupAvatar |
| | | v-if="conversation.type === ImConversationType.GROUP" |
| | | :group-id="conversation.targetId" |
| | | :url="conversation.avatar" |
| | | :name="conversation.name" |
| | | :size="32" |
| | | /> |
| | | <UserAvatar |
| | | v-else |
| | | :url="conversation.avatar" |
| | | :name="conversation.name" |
| | | :size="32" |
| | | :clickable="false" |
| | | /> |
| | | <span |
| | | class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ conversation.name }} |
| | | </span> |
| | | </div> |
| | | |
| | | <!-- 空æ --> |
| | | <div |
| | | v-if="shownConversations.length === 0" |
| | | class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ keyword ? 'æ²¡ææ»¡è¶³æ¡ä»¶çä¼è¯' : 'ææ ä¼è¯' }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 峿 --> |
| | | <div class="flex flex-col flex-1 min-w-0"> |
| | | <!-- æ é¢ï¼é 0/1ãåéç»ããå¤ä¸ªãåå«åéç»ãï¼ä¸å¾®ä¿¡ææ¡ä¸è´ï¼ --> |
| | | <div |
| | | class="flex-shrink-0 px-4 py-3 border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | {{ sendTitle }} |
| | | </div> |
| | | |
| | | <!-- å·²éé¢è§ï¼æ selectedKeys æ°ç»é¡ºåºï¼ç¹å»é¡ºåºï¼å±ç¤º --> |
| | | <div class="flex-1"> |
| | | <div |
| | | v-for="conversation in selectedConversations" |
| | | :key="getConversationKey(conversation)" |
| | | class="flex gap-2.5 items-center px-4 py-2" |
| | | > |
| | | <GroupAvatar |
| | | v-if="conversation.type === ImConversationType.GROUP" |
| | | :group-id="conversation.targetId" |
| | | :url="conversation.avatar" |
| | | :name="conversation.name" |
| | | :size="32" |
| | | /> |
| | | <UserAvatar |
| | | v-else |
| | | :url="conversation.avatar" |
| | | :name="conversation.name" |
| | | :size="32" |
| | | :clickable="false" |
| | | /> |
| | | <span |
| | | class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ conversation.name }} |
| | | </span> |
| | | <Icon |
| | | icon="ant-design:close-outlined" |
| | | :size="14" |
| | | class="flex-shrink-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]" |
| | | @click="handleToggle(conversation)" |
| | | /> |
| | | </div> |
| | | <div |
| | | v-if="selectedConversations.length === 0" |
| | | class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | ä»å·¦ä¾§éæ©å¥½åæç¾¤è |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ä¸å¡å£³å¡é¢è§å¡ / çè¨ / æäº¤æé®çä½ç½® --> |
| | | <div |
| | | v-if="$slots.footer" |
| | | class="flex-shrink-0 border-t border-t-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <slot name="footer"></slot> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* æ¨ªåæ»å¨æ¡åçªä¸ç¹é¿å
å è§è§ï¼èµ° ::-webkit-scrollbar æµè§å¨ä¼ªå
ç´ */ |
| | | .im-conversation-picker__recent::-webkit-scrollbar { |
| | | height: 4px; |
| | | } |
| | | .im-conversation-picker__recent::-webkit-scrollbar-thumb { |
| | | background-color: var(--ant-color-border); |
| | | border-radius: 2px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { FriendLite } from '../../types' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Input, message } from 'ant-design-vue' |
| | | |
| | | import { PagedScroller } from '..' |
| | | import { useFriendBuckets } from '../../composables/useFriendBuckets' |
| | | import { useSelectedItems } from '../../composables/useSelectedItems' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImFriendPickerPanel' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** ç¦ç¨ idï¼å表éå±ç¤ºç½®ç°ãä¸å¯å¾éãä¸è®¡å
¥å·²éæ°ï¼å
¸åï¼é请å
¥ç¾¤æ¶å·²å¨ç¾¤æåï¼ */ |
| | | disabledIds?: number[] |
| | | /** å
¨é好åå表 */ |
| | | friends: FriendLite[] |
| | | /** éè idï¼ä¸å±ç¤ºï¼hide > locked > disabledï¼ */ |
| | | hideIds?: number[] |
| | | /** éå® idï¼é»è®¤å¾éãä¸å¯åæ¶ã计å
¥å·²éæ°ï¼å
¸åï¼ç§èä¾§ +建群éå®å¯¹æ¹ï¼ */ |
| | | lockedIds?: number[] |
| | | /** 已鿰ä¸éï¼ä¸ä¼ æ <=0 æ¶ä¸é */ |
| | | maxSize?: number |
| | | /** å·²é好å idï¼v-modelï¼ï¼ææ°ç»é¡ºåºå³ä¸ºç¹å»é¡ºåº */ |
| | | selectedIds: number[] |
| | | }>(), |
| | | { |
| | | lockedIds: () => [], |
| | | disabledIds: () => [], |
| | | hideIds: () => [], |
| | | maxSize: 0 |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | 'update:selectedIds': [value: number[]] |
| | | }>() |
| | | |
| | | const keyword = ref('') |
| | | |
| | | /** id â friend æ å°ï¼å·²é忥 / 䏿å¤å®å
±ç¨ï¼é¿å
æ¯æ¬¡ O(N) æ« */ |
| | | const byId = computed(() => { |
| | | const map = new Map<number, FriendLite>() |
| | | for (const friend of props.friends) { |
| | | map.set(friend.id, friend) |
| | | } |
| | | return map |
| | | }) |
| | | |
| | | /** 䏿 id éåï¼æ¯æ¬¡è¿æ»¤å¤ç¨ */ |
| | | const hideSet = computed(() => new Set(props.hideIds)) |
| | | const lockedSet = computed(() => new Set(props.lockedIds)) |
| | | const disabledSet = computed(() => new Set(props.disabledIds)) |
| | | const selectedSet = computed(() => new Set(props.selectedIds)) |
| | | |
| | | /** åé好åï¼åé¤ hideIdsï¼hide ä¼å
级æé«ï¼ */ |
| | | const candidates = computed(() => |
| | | props.friends.filter((friend) => !hideSet.value.has(friend.id)) |
| | | ) |
| | | |
| | | /** å§æ useFriendBucketsï¼æç´¢è§åå¤ç¨ï¼å·¦ä¾§åè¡¨ææ»å¨å页渲æ */ |
| | | const { filtered } = useFriendBuckets(candidates, keyword) |
| | | |
| | | /** 已鿰 + å·²é好åå表ï¼ä¸æä¼å
级 + é¡ºåºæ¼æ¥ç± useSelectedItems ç»ä¸æ¿æ
*/ |
| | | const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>( |
| | | () => props.selectedIds, |
| | | () => props.lockedIds, |
| | | () => props.disabledIds, |
| | | () => props.hideIds, |
| | | byId |
| | | ) |
| | | |
| | | /** æ¯å¦è¢«éå® */ |
| | | function isLocked(friend: FriendLite): boolean { |
| | | return lockedSet.value.has(friend.id) |
| | | } |
| | | |
| | | /** æ¯å¦è¢«ç¦ç¨ï¼locked / hide 已被åç½®è¿æ»¤ï¼å©ä¸çæç® disabled */ |
| | | function isDisabled(friend: FriendLite): boolean { |
| | | return !lockedSet.value.has(friend.id) && disabledSet.value.has(friend.id) |
| | | } |
| | | |
| | | /** æ¯å¦éä¸ï¼locked è§ä¸ºæ°¸è¿éä¸ */ |
| | | function isSelected(friend: FriendLite): boolean { |
| | | return selectedSet.value.has(friend.id) |
| | | } |
| | | |
| | | /** åå½¢å¾éæç¤ºå¨ç classï¼éä¸ / éå®èµ°ç»¿åºï¼ç¦ç¨ç°åºï¼æªé空å¿å */ |
| | | function getCheckClass(friend: FriendLite): string { |
| | | if (isLocked(friend) || isSelected(friend)) { |
| | | return 'bg-[#07c160] border border-solid border-[#07c160]' |
| | | } |
| | | if (isDisabled(friend)) { |
| | | return 'bg-[var(--ant-color-fill)] border border-solid border-[var(--ant-color-border)]' |
| | | } |
| | | return 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]' |
| | | } |
| | | |
| | | /** 忢é䏿ï¼locked / disabled ä¸ååºï¼å³æ Ã ç§»é¤ / è¡ click é½èµ°è¿é */ |
| | | function handleToggle(friend: FriendLite) { |
| | | if (isLocked(friend) || isDisabled(friend)) { |
| | | return |
| | | } |
| | | const next = [...props.selectedIds] |
| | | const index = next.indexOf(friend.id) |
| | | if (index === -1) { |
| | | if (props.maxSize > 0 && selectedCount.value >= props.maxSize) { |
| | | message.error(`æå¤éæ© ${props.maxSize} ä½å¥½å`) |
| | | return |
| | | } |
| | | next.push(friend.id) |
| | | } else { |
| | | next.splice(index, 1) |
| | | } |
| | | emit('update:selectedIds', next) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 好å鿩颿¿ï¼ç¨äºãæ°å»ºç¾¤è / é请好å / æ¨èæ¶å建è天ãç好åéæ©åºæ¯ |
| | | - å·¦ï¼æç´¢ + 好åå表ï¼åå½¢å¾éï¼ |
| | | - å³ï¼å·²éæ°æ é¢ + å·²é好ååè¡¨ï¼æç¹å»é¡ºåºï¼ |
| | | - Panel ä¸å¸¦ el-dialog 壳ï¼dialog ç±ä¸å¡å£³ææ |
| | | - 䏿è¯ä¹ï¼hide > locked > disabledï¼è¯¦è§ contractï¼ |
| | | --> |
| | | <div class="flex h-full"> |
| | | <!-- å·¦æ --> |
| | | <div |
| | | class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <!-- æç´¢æ¡ --> |
| | | <div class="flex-shrink-0 px-3 py-2"> |
| | | <Input v-model:value="keyword" placeholder="æç´¢å¥½å" allow-clear> |
| | | <template #prefix> |
| | | <Icon icon="ant-design:search-outlined" /> |
| | | </template> |
| | | </Input> |
| | | </div> |
| | | |
| | | <div class="flex-1 min-h-0"> |
| | | <PagedScroller v-if="filtered.length > 0" :items="filtered" :page-size="30" item-key="id"> |
| | | <template #default="{ item }"> |
| | | <div |
| | | :key="(item as FriendLite).id" |
| | | class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--ant-color-fill)]" |
| | | :class="{ |
| | | 'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(item as FriendLite) |
| | | }" |
| | | @click="handleToggle(item as FriendLite)" |
| | | > |
| | | <!-- åå½¢å¾éæç¤ºå¨ï¼æªéç°è²ç©ºå¿åï¼éä¸å®å¿å¾®ä¿¡ç»¿ + ç½å¯¹å¾ï¼éå® / ç¦ç¨èµ°ç°åº --> |
| | | <span |
| | | class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors" |
| | | :class="getCheckClass(item as FriendLite)" |
| | | > |
| | | <Icon |
| | | v-if="isSelected(item as FriendLite) || isLocked(item as FriendLite)" |
| | | icon="ant-design:check-outlined" |
| | | :size="12" |
| | | color="#fff" |
| | | /> |
| | | </span> |
| | | <UserAvatar |
| | | :id="(item as FriendLite).id" |
| | | :url="(item as FriendLite).avatar" |
| | | :name="(item as FriendLite).nickname" |
| | | :size="36" |
| | | :clickable="false" |
| | | /> |
| | | <!-- è¡å
ååï¼å¤æ³¨ä¼å
ï¼å表éä¸éå¤å±ç¤ºæµç§° --> |
| | | <span |
| | | class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ (item as FriendLite).displayName || (item as FriendLite).nickname }} |
| | | </span> |
| | | </div> |
| | | </template> |
| | | </PagedScroller> |
| | | <div v-else class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"> |
| | | {{ keyword ? '没æå¹é
ç好å' : 'ææ å¥½å' }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 峿 --> |
| | | <div class="flex flex-col flex-1 min-w-0"> |
| | | <!-- æ é¢ï¼å·²éæ°ï¼é«åº¦å¯¹é½å·¦ä¾§ input defaultï¼32pxï¼ï¼ä¿è¯ä¸¤ä¾§ç¬¬ä¸é¡¹èµ·ç¹åæ°´å¹³ --> |
| | | <div |
| | | class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | 已鿩 {{ selectedCount }} 个好å |
| | | </div> |
| | | |
| | | <!-- å·²éé¢è§ï¼æ selectedIds + lockedIds æ°ç»é¡ºåºï¼ç¹å»é¡ºåºï¼å±ç¤º --> |
| | | <div class="flex-1"> |
| | | <div |
| | | v-for="friend in selectedFriends" |
| | | :key="friend.id" |
| | | class="flex gap-2.5 items-center px-4 py-2" |
| | | > |
| | | <UserAvatar |
| | | :id="friend.id" |
| | | :url="friend.avatar" |
| | | :name="friend.nickname" |
| | | :size="36" |
| | | :clickable="false" |
| | | /> |
| | | <span |
| | | class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ friend.displayName || friend.nickname }} |
| | | </span> |
| | | <!-- éå®é¡¹ä¸æ¸²æ Ãï¼é¿å
误以为å¯ç§»é¤ --> |
| | | <Icon |
| | | v-if="!isLocked(friend)" |
| | | icon="ant-design:close-outlined" |
| | | :size="14" |
| | | class="flex-shrink-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]" |
| | | @click="handleToggle(friend)" |
| | | /> |
| | | </div> |
| | | <div |
| | | v-if="selectedFriends.length === 0" |
| | | class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | 请ä»å·¦ä¾§éæ©å¥½å |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ä¸å¡å£³å¡é¢å¤å
容çä½ç½®ï¼FriendPickerPanel 主æµåºæ¯ä¸éè¦ footer --> |
| | | <div |
| | | v-if="$slots.footer" |
| | | class="flex-shrink-0 border-t border-t-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <slot name="footer"></slot> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../group' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { CommonStatusEnum } from '#/packages/constants/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Input, message } from 'ant-design-vue' |
| | | |
| | | import { PagedScroller } from '..' |
| | | import { useSelectedItems } from '../../composables/useSelectedItems' |
| | | import { GroupMemberGrid, GroupMemberItem } from '../group' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImGroupMemberPickerPanel' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** ç¦ç¨ userIdï¼å表éå±ç¤ºç½®ç°ãä¸å¯å¾éãä¸è®¡å
¥å·²éæ° */ |
| | | disabledIds?: number[] |
| | | /** éè userIdï¼ä¸å±ç¤ºï¼hide > locked > disabledï¼ */ |
| | | hideIds?: number[] |
| | | /** éå® userIdï¼é»è®¤å¾éãä¸å¯åæ¶ã计å
¥å·²éæ° */ |
| | | lockedIds?: number[] |
| | | /** 已鿰ä¸éï¼ä¸ä¼ æ <=0 æ¶ä¸é */ |
| | | maxSize?: number |
| | | /** 群æåå表 */ |
| | | members: GroupMemberLite[] |
| | | /** å·²éåºå±ç¤ºå½¢æï¼é»è®¤ list 对é½å¾®ä¿¡æ°è§è§ */ |
| | | selectedDisplay?: 'grid' | 'list' |
| | | /** å·²é userIdï¼v-modelï¼ï¼ææ°ç»é¡ºåºå³ä¸ºç¹å»é¡ºåº */ |
| | | selectedIds: number[] |
| | | }>(), |
| | | { |
| | | lockedIds: () => [], |
| | | disabledIds: () => [], |
| | | hideIds: () => [], |
| | | maxSize: 0, |
| | | selectedDisplay: 'list' |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | 'update:selectedIds': [value: number[]] |
| | | }>() |
| | | |
| | | const keyword = ref('') |
| | | |
| | | /** userId â member æ å°ï¼å·²é忥 / 䏿å¤å®å
±ç¨ */ |
| | | const byId = computed(() => { |
| | | const map = new Map<number, GroupMemberLite>() |
| | | for (const member of props.members) { |
| | | map.set(member.userId, member) |
| | | } |
| | | return map |
| | | }) |
| | | |
| | | const hideSet = computed(() => new Set(props.hideIds)) |
| | | const lockedSet = computed(() => new Set(props.lockedIds)) |
| | | const disabledSet = computed(() => new Set(props.disabledIds)) |
| | | const selectedSet = computed(() => new Set(props.selectedIds)) |
| | | |
| | | /** å½åå±ç¤ºçæåï¼åé¤ hideIdsãåé¤å·²é群ï¼DISABLEï¼ãæå
³é®å大å°åæ å
³è¿æ»¤ */ |
| | | const shownMembers = computed(() => { |
| | | const keywordLower = keyword.value.trim().toLowerCase() |
| | | return props.members.filter( |
| | | (member) => |
| | | !hideSet.value.has(member.userId) && |
| | | member.status !== CommonStatusEnum.DISABLE && |
| | | (!keywordLower || member.showName.toLowerCase().includes(keywordLower)) |
| | | ) |
| | | }) |
| | | |
| | | /** 已鿰 + å·²éæåå表ï¼ä¸æä¼å
级 + é¡ºåºæ¼æ¥ç± useSelectedItems ç»ä¸æ¿æ
*/ |
| | | const { selectedCount, selectedItems: selectedMembers } = useSelectedItems<GroupMemberLite>( |
| | | () => props.selectedIds, |
| | | () => props.lockedIds, |
| | | () => props.disabledIds, |
| | | () => props.hideIds, |
| | | byId |
| | | ) |
| | | |
| | | /** æ¯å¦è¢«éå® */ |
| | | function isLocked(member: GroupMemberLite): boolean { |
| | | return lockedSet.value.has(member.userId) |
| | | } |
| | | |
| | | /** æ¯å¦è¢«ç¦ç¨ï¼locked / hide 已被åç½®è¿æ»¤ï¼å©ä¸çæç® disabled */ |
| | | function isDisabled(member: GroupMemberLite): boolean { |
| | | return !lockedSet.value.has(member.userId) && disabledSet.value.has(member.userId) |
| | | } |
| | | |
| | | /** æ¯å¦éä¸ï¼locked è§ä¸ºæ°¸è¿éä¸ */ |
| | | function isSelected(member: GroupMemberLite): boolean { |
| | | return selectedSet.value.has(member.userId) |
| | | } |
| | | |
| | | /** åå½¢å¾éæç¤ºå¨ç class */ |
| | | function getCheckClass(member: GroupMemberLite): string { |
| | | if (isLocked(member) || isSelected(member)) { |
| | | return 'bg-[#07c160] border border-solid border-[#07c160]' |
| | | } |
| | | if (isDisabled(member)) { |
| | | return 'bg-[var(--ant-color-fill)] border border-solid border-[var(--ant-color-border)]' |
| | | } |
| | | return 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]' |
| | | } |
| | | |
| | | /** 忢é䏿ï¼locked / disabled ä¸ååºï¼å³æ à / è¡ click é½èµ°è¿é */ |
| | | function handleToggle(member: GroupMemberLite) { |
| | | if (isLocked(member) || isDisabled(member)) { |
| | | return |
| | | } |
| | | const next = [...props.selectedIds] |
| | | const index = next.indexOf(member.userId) |
| | | if (index === -1) { |
| | | if (props.maxSize > 0 && selectedCount.value >= props.maxSize) { |
| | | message.error(`æå¤éæ© ${props.maxSize} 使å`) |
| | | return |
| | | } |
| | | next.push(member.userId) |
| | | } else { |
| | | next.splice(index, 1) |
| | | } |
| | | emit('update:selectedIds', next) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 群æå鿩颿¿ï¼ç¨äºãç§»é¤æå / 设置管çå / 转让群主 / ç¦è¨æåãçåºæ¯ |
| | | - å·¦ï¼æç´¢ + 群æåå表ï¼åå½¢å¾éï¼ |
| | | - å³ï¼å·²éæ°æ é¢ + å·²éæåï¼list / grid 两ç§å½¢æå¯é
ï¼é»è®¤ list 对é½å¾®ä¿¡æ°è§è§ï¼ |
| | | - Panel ä¸å¸¦ el-dialog 壳ï¼dialog ç±ä¸å¡å£³ææ |
| | | - 䏿è¯ä¹ï¼hide > locked > disabled |
| | | --> |
| | | <div class="flex h-full"> |
| | | <!-- å·¦æ --> |
| | | <div |
| | | class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <!-- æç´¢æ¡ --> |
| | | <div class="flex-shrink-0 px-3 py-2"> |
| | | <Input v-model:value="keyword" placeholder="æç´¢æå" allow-clear> |
| | | <template #prefix> |
| | | <Icon icon="ant-design:search-outlined" /> |
| | | </template> |
| | | </Input> |
| | | </div> |
| | | |
| | | <div class="flex-1 min-h-0"> |
| | | <PagedScroller :items="shownMembers" :page-size="30" item-key="userId"> |
| | | <template #default="{ item }"> |
| | | <GroupMemberItem |
| | | :member="item as GroupMemberLite" |
| | | :height="46" |
| | | @click="handleToggle(item as GroupMemberLite)" |
| | | > |
| | | <span |
| | | class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors" |
| | | :class="getCheckClass(item as GroupMemberLite)" |
| | | > |
| | | <Icon |
| | | v-if="isSelected(item as GroupMemberLite) || isLocked(item as GroupMemberLite)" |
| | | icon="ant-design:check-outlined" |
| | | :size="12" |
| | | color="#fff" |
| | | /> |
| | | </span> |
| | | </GroupMemberItem> |
| | | </template> |
| | | </PagedScroller> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 峿 --> |
| | | <div class="flex flex-col flex-1 min-w-0"> |
| | | <!-- æ é¢ï¼å·²éæ°ï¼é«åº¦å¯¹é½å·¦ä¾§ input defaultï¼32pxï¼ --> |
| | | <div |
| | | class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | 已鿩 {{ selectedCount }} 使å |
| | | </div> |
| | | |
| | | <div class="flex-1"> |
| | | <!-- list å½¢æï¼çºµåè¡ï¼æ¯è¡å¸¦ à 移é¤ï¼locked 䏿¸²æ Ãï¼ --> |
| | | <template v-if="selectedDisplay === 'list'"> |
| | | <div |
| | | v-for="member in selectedMembers" |
| | | :key="member.userId" |
| | | class="flex gap-2.5 items-center px-4 py-2" |
| | | > |
| | | <UserAvatar |
| | | :id="member.userId" |
| | | :url="member.avatar" |
| | | :name="member.nickname" |
| | | :size="36" |
| | | :clickable="false" |
| | | /> |
| | | <span |
| | | class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ member.showName }} |
| | | </span> |
| | | <Icon |
| | | v-if="!isLocked(member)" |
| | | icon="ant-design:close-outlined" |
| | | :size="14" |
| | | class="flex-shrink-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]" |
| | | @click="handleToggle(member)" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- grid å½¢æï¼å®«æ ¼é¢è§ï¼é locked æåå³ä¸è§å å à 移é¤ï¼locked 䏿¸²æï¼ --> |
| | | <div v-else class="flex flex-wrap p-2.5"> |
| | | <GroupMemberGrid |
| | | v-for="member in selectedMembers" |
| | | :key="member.userId" |
| | | :member="member" |
| | | > |
| | | <Icon |
| | | v-if="!isLocked(member)" |
| | | icon="ant-design:close-circle-filled" |
| | | :size="16" |
| | | class="absolute top-0 right-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]" |
| | | @click="handleToggle(member)" |
| | | /> |
| | | </GroupMemberGrid> |
| | | </div> |
| | | |
| | | <div |
| | | v-if="selectedMembers.length === 0" |
| | | class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | 请ä»å·¦ä¾§éæ©æå |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // 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; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { CallParticipantVM } from './rtc-call-participant-tile.vue' |
| | | |
| | | import { computed, ref, watch } from 'vue' |
| | | |
| | | import { useIntervalFn } from '@vueuse/core' |
| | | import { message } from 'ant-design-vue' |
| | | import { Track } from 'livekit-client' |
| | | |
| | | import { |
| | | acceptCall, |
| | | cancelCall, |
| | | inviteCall, |
| | | leaveCall, |
| | | noAnswerCallCheck, |
| | | rejectCall |
| | | } from '#/api/im/rtc' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '#/views/im/utils/config' |
| | | import { |
| | | ImConversationType, |
| | | ImRtcCallMediaType, |
| | | ImRtcCallStage |
| | | } from '#/views/im/utils/constants' |
| | | import { getSenderAvatar, getSenderDisplayName } from '#/views/im/utils/user' |
| | | |
| | | import { useLiveKitRoom } from '../../composables/useLiveKitRoom' |
| | | import { useRtcStore } from '../../store/rtcStore' |
| | | import RtcCallIncoming from './rtc-call-incoming.vue' |
| | | import RtcCallInviting from './rtc-call-inviting.vue' |
| | | import RtcCallMemberPickerDialog from './rtc-call-member-picker-dialog.vue' |
| | | import RtcCallRunning from './rtc-call-running.vue' |
| | | |
| | | defineOptions({ name: 'ImRtcCallContainer' }) |
| | | |
| | | const rtcStore = useRtcStore() |
| | | const lk = useLiveKitRoom() |
| | | |
| | | const memberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>() |
| | | const connecting = ref(false) |
| | | const accepting = ref(false) |
| | | const rejecting = ref(false) |
| | | const cancelling = ref(false) |
| | | const hangingUp = ref(false) |
| | | |
| | | // ==================== è§å¾æ¨¡å ==================== |
| | | |
| | | /** å½åæ¯å¦è§é¢éè¯ */ |
| | | const isVideo = computed(() => { |
| | | const t = |
| | | rtcStore.call?.mediaType || |
| | | rtcStore.incomingPayload?.mediaType || |
| | | ImRtcCallMediaType.VOICE |
| | | return t === ImRtcCallMediaType.VIDEO |
| | | }) |
| | | |
| | | /** å½åæ¯å¦ç¾¤éè¯ï¼å³å®æµ®å¨çªå¤§å° */ |
| | | const isGroup = computed( |
| | | () => |
| | | (rtcStore.call?.conversationType ?? |
| | | rtcStore.incomingPayload?.conversationType) === ImConversationType.GROUP |
| | | ) |
| | | |
| | | /** åå§æå头æ¯å¦æå¼ï¼ç¾¤éè¯é»è®¤å
¨é¨å
³éï¼è¿å
¥åç¨æ·ä¸»å¨å¼ */ |
| | | const initialCamera = computed(() => { |
| | | if (rtcStore.call?.conversationType === ImConversationType.GROUP) { |
| | | return false |
| | | } |
| | | return isVideo.value |
| | | }) |
| | | |
| | | /** æ¬ç«¯è§é¢æµï¼ä¼å
ScreenShareï¼å±å
±æ¶ä¹éºåºï¼ï¼æ å Cameraï¼æ¾å¼è®¢é
screenShareEnabled / cameraEnabled 触åéç® */ |
| | | const localStream = computed<MediaStream | null>(() => { |
| | | // 触æ¸ååºå¼ä¾èµï¼ç¡®ä¿åå±å
±äº« / æå头å computed éæ°æ±å¼ï¼pickStream å
é¨ç¨æ®é Map ç¼åï¼èªèº«ä¸ååºï¼ |
| | | void lk.screenShareEnabled.value |
| | | void lk.cameraEnabled.value |
| | | const lp = lk.localParticipant.value |
| | | if (!lp) { |
| | | return null |
| | | } |
| | | return lk.pickStream(lp, Track.Source.ScreenShare) || lk.pickStream(lp, Track.Source.Camera) |
| | | }) |
| | | |
| | | /** è¿ç«¯è§é¢æµï¼ä»
1v1 ç¨ï¼ï¼ä¼å
ScreenShareï¼æ åå Camera */ |
| | | const remoteVideoStream = computed<MediaStream | null>(() => { |
| | | if (isGroup.value) { |
| | | return null |
| | | } |
| | | for (const rp of lk.remoteParticipants.value) { |
| | | const screen = lk.pickStream(rp, Track.Source.ScreenShare) |
| | | if (screen) { |
| | | return screen |
| | | } |
| | | const camera = lk.pickStream(rp, Track.Source.Camera) |
| | | if (camera) { |
| | | return camera |
| | | } |
| | | } |
| | | return null |
| | | }) |
| | | |
| | | /** è¿ç«¯é³é¢æµï¼ä»
1v1 ç¨ï¼ */ |
| | | const remoteAudioStream = computed<MediaStream | null>(() => { |
| | | if (isGroup.value) { |
| | | return null |
| | | } |
| | | for (const rp of lk.remoteParticipants.value) { |
| | | const stream = lk.pickStream(rp, Track.Source.Microphone) |
| | | if (stream) { |
| | | return stream |
| | | } |
| | | } |
| | | return null |
| | | }) |
| | | |
| | | /** 群éè¯ç½æ ¼ç¨ï¼èªå·± + è¿ç«¯å¨æ¿ + å¾
å å
¥æåï¼æµç§° / 头åèµ° user.ts helper èªå¨å¤ç self / 群æå / 好å / å
åº */ |
| | | const participants = computed<CallParticipantVM[]>(() => { |
| | | const call = rtcStore.call |
| | | if (!call) { |
| | | return [] |
| | | } |
| | | const conversationType = call.conversationType |
| | | const targetId = call.groupId ?? 0 |
| | | const myId = getCurrentUserId() |
| | | const result: CallParticipantVM[] = [{ |
| | | userId: myId, |
| | | nickname: getSenderDisplayName(myId, conversationType, targetId), |
| | | avatar: getSenderAvatar(myId, conversationType, targetId) || undefined, |
| | | isLocal: true, |
| | | videoStream: localStream.value |
| | | }] |
| | | |
| | | // å·²å å
¥çè¿ç«¯ï¼å®é
æ¨æµï¼å±å¹å
±äº«å¨ç½æ ¼éç¬å 该æåçæ ¼åï¼æ åé级 Camera |
| | | const joined = new Set<number>() |
| | | for (const rp of lk.remoteParticipants.value) { |
| | | const userId = Number(rp.identity) |
| | | if (Number.isNaN(userId)) { |
| | | continue |
| | | } |
| | | joined.add(userId) |
| | | result.push({ |
| | | userId, |
| | | nickname: getSenderDisplayName(userId, conversationType, targetId), |
| | | avatar: getSenderAvatar(userId, conversationType, targetId) || undefined, |
| | | isLocal: false, |
| | | videoStream: |
| | | lk.pickStream(rp, Track.Source.ScreenShare) || lk.pickStream(rp, Track.Source.Camera), |
| | | audioStream: lk.pickStream(rp, Track.Source.Microphone) |
| | | }) |
| | | } |
| | | |
| | | // 群éè¯ï¼æªå å
¥ç被é请人ä½ä¸º pending å ä½ï¼å·²éåº / å·²æç»çäººä¸æ¸²æ |
| | | if (conversationType === ImConversationType.GROUP) { |
| | | const inviteeIds = call.inviteeIds || [] |
| | | for (const userId of inviteeIds) { |
| | | if (userId === myId || joined.has(userId) || rtcStore.isUserLeft(userId)) { |
| | | continue |
| | | } |
| | | result.push({ |
| | | userId, |
| | | nickname: getSenderDisplayName(userId, ImConversationType.GROUP, targetId), |
| | | avatar: getSenderAvatar(userId, ImConversationType.GROUP, targetId) || undefined, |
| | | isLocal: false, |
| | | pending: true |
| | | }) |
| | | } |
| | | } |
| | | return result |
| | | }) |
| | | |
| | | // ==================== LiveKit è¿æ¥ ==================== |
| | | |
| | | /** è¿å
¥ LiveKit æ¿é´å¹¶æ³¨å离å¼åè°ï¼INVITING 主å«é¢è¿åè¢«å« accept åè¿å
¥å
±ç¨ */ |
| | | async function connectLiveKit(livekitUrl: string, token: string) { |
| | | // å¹çï¼lk.connect å
é¨è¿å
¥åå°±æ room.value èµå¼ï¼é空表示已ç»å¨è¿æ¥æå·²è¿æ¥ï¼stage 夿¬¡åæ¢æ¶éå¤è§¦åä¹è·³è¿ |
| | | if (lk.room.value || connecting.value) { |
| | | return |
| | | } |
| | | connecting.value = true |
| | | try { |
| | | // å
注ååè°ï¼å connectï¼ä¿¡ä»¤æ¡æè¿ç¨ä¼å³æ¶æ¨é已卿¿åä¸è
ï¼ä¸å¡ handler å¿
é¡»å
就绪 |
| | | lk.onDisconnected(() => handlePeerDisconnected()) |
| | | lk.onParticipantConnected(maybeEnterRunning) |
| | | lk.onParticipantDisconnected((userId) => rtcStore.markUserLeft(userId)) |
| | | await lk.connect(livekitUrl, token, { audio: true, video: initialCamera.value }) |
| | | // å
åºï¼connect æé´è¥å·²æè¿ç«¯å¨æ¿ï¼äºä»¶å¯è½å¨ handler 注åå已触åï¼ä¸»å¨åå° RUNNING |
| | | if (lk.remoteParticipants.value.length > 0) { |
| | | maybeEnterRunning() |
| | | } |
| | | } finally { |
| | | connecting.value = false |
| | | } |
| | | } |
| | | |
| | | /** 主å«ç«¯ï¼ä» INVITING åå° RUNNINGï¼å
¶å®é¶æ®µä¸å¤ç */ |
| | | function maybeEnterRunning() { |
| | | if (rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call) { |
| | | rtcStore.enterRunning(rtcStore.call) |
| | | } |
| | | } |
| | | |
| | | watch( |
| | | () => rtcStore.stage, |
| | | async (stage) => { |
| | | if ( |
| | | stage === ImRtcCallStage.INVITING && |
| | | rtcStore.call?.token && |
| | | rtcStore.call?.livekitUrl |
| | | ) { |
| | | try { |
| | | await connectLiveKit(rtcStore.call.livekitUrl, rtcStore.call.token) |
| | | } catch (error) { |
| | | console.error('[Call] connect 失败', { room: rtcStore.call?.room }, error) |
| | | message.error('éè¯è¿æ¥å¤±è´¥') |
| | | await handleCancel() |
| | | } |
| | | } |
| | | if (stage === ImRtcCallStage.IDLE) { |
| | | await lk.disconnect() |
| | | } |
| | | } |
| | | ) |
| | | |
| | | /** 被å«ç«¯ accept å伿¿å° tokenï¼è¿éçå¬ stage + token åå触åè¿æ¥ */ |
| | | watch( |
| | | () => [rtcStore.stage, rtcStore.call?.token], |
| | | async ([stage, token], [prevStage]) => { |
| | | if ( |
| | | stage === ImRtcCallStage.RUNNING && |
| | | prevStage !== ImRtcCallStage.RUNNING && |
| | | token && |
| | | !lk.isConnected.value && |
| | | rtcStore.call?.livekitUrl |
| | | ) { |
| | | try { |
| | | await connectLiveKit(rtcStore.call.livekitUrl, token as string) |
| | | } catch (error) { |
| | | console.error('[Call] accept connect 失败', { room: rtcStore.call?.room }, error) |
| | | message.error('éè¯è¿æ¥å¤±è´¥') |
| | | // å端 accept å·²å JOINEDï¼åç«¯è¿æ¥å¤±è´¥éè° leave åæ»ï¼é¿å
åç«¯è®°å½æ®çå¿çº¿ |
| | | if (rtcStore.call?.room) { |
| | | leaveCall(rtcStore.call.room).catch(() => undefined) |
| | | } |
| | | rtcStore.reset() |
| | | } |
| | | } |
| | | } |
| | | ) |
| | | |
| | | // ==================== éè¯çå½å¨æ ==================== |
| | | |
| | | /** 主å«åæ¶é请 */ |
| | | async function handleCancel() { |
| | | if (cancelling.value) { |
| | | return |
| | | } |
| | | cancelling.value = true |
| | | const room = rtcStore.call?.room |
| | | try { |
| | | if (room) { |
| | | await cancelCall(room) |
| | | } |
| | | await lk.disconnect() |
| | | rtcStore.reset() |
| | | } finally { |
| | | cancelling.value = false |
| | | } |
| | | } |
| | | |
| | | /** è¢«å«æç»æ¥çµ */ |
| | | async function handleReject() { |
| | | if (rejecting.value) { |
| | | return |
| | | } |
| | | rejecting.value = true |
| | | const payload = rtcStore.incomingPayload |
| | | try { |
| | | if (payload?.room) { |
| | | await rejectCall(payload.room) |
| | | // æ¬ç«¯å
è¡ä»è¶åæ¡ç§»é¤èªå·±ï¼å
çå端 RTC_CALL(REJECTED) æ¨åï¼ç§èåºæ¯ store å
é¨ no-op |
| | | rtcStore.applyParticipantRejected({ |
| | | room: payload.room, |
| | | conversationType: payload.conversationType, |
| | | groupId: payload.groupId, |
| | | operatorUserId: getCurrentUserId() |
| | | }) |
| | | } |
| | | rtcStore.reset() |
| | | } finally { |
| | | rejecting.value = false |
| | | } |
| | | } |
| | | |
| | | /** è¢«å«æ¥å¬æ¥çµ */ |
| | | async function handleAccept() { |
| | | if (accepting.value) { |
| | | return |
| | | } |
| | | const payload = rtcStore.incomingPayload |
| | | if (!payload) return |
| | | accepting.value = true |
| | | try { |
| | | const data = await acceptCall(payload.room) |
| | | rtcStore.enterRunning(data) |
| | | } finally { |
| | | accepting.value = false |
| | | } |
| | | } |
| | | |
| | | /** éè¯ä¸ææ */ |
| | | async function handleHangup() { |
| | | if (hangingUp.value) { |
| | | return |
| | | } |
| | | hangingUp.value = true |
| | | const call = rtcStore.call |
| | | try { |
| | | if (call?.room) { |
| | | await leaveCall(call.room) |
| | | // æ¬ç«¯å
è¡ä»è¶åæ¡ç§»é¤èªå·±ï¼å
çå端 RTC_PARTICIPANT_DISCONNECTED æ¨åï¼ç§èåºæ¯ store å
é¨ no-opï¼æ´éè¯ç± END å
³æ |
| | | rtcStore.applyParticipantDisconnected({ |
| | | room: call.room, |
| | | userId: getCurrentUserId(), |
| | | conversationType: call.conversationType, |
| | | groupId: call.groupId |
| | | }) |
| | | } |
| | | await lk.disconnect() |
| | | rtcStore.reset() |
| | | } finally { |
| | | hangingUp.value = false |
| | | } |
| | | } |
| | | |
| | | /** LiveKit Room å¼å¸¸æå¼ï¼å¤è§äºç½ç»ä¸æ */ |
| | | function handlePeerDisconnected() { |
| | | if (!rtcStore.isActive) { |
| | | return |
| | | } |
| | | const room = rtcStore.call?.room |
| | | // ç» RTC_CALL_END WebSocket æ¨éä¸ä¸ªå°çªå£ï¼ç§èè¶
æ¶ / 䏻卿æçåºæ¯ä¸ï¼å端 endSession ä¼å
æ¨ RTC_CALL_ENDï¼ |
| | | // 让å端æä¸å¡è¯ä¹ï¼"å¯¹æ¹æªæ¥å¬" / "已忶" çï¼resetï¼é¿å
éæä¸å¡æå¼ toast æãéè¯å·²æå¼ã |
| | | setTimeout(() => { |
| | | if (!rtcStore.isActive) { |
| | | return |
| | | } |
| | | // 䏿¥ç¦»å¼æ¿é´ |
| | | if (room) { |
| | | leaveCall(room).catch(() => undefined) |
| | | } |
| | | // æ¸
çæ¬å°éè¯ç¶æ |
| | | message.warning('éè¯å·²æå¼') |
| | | rtcStore.reset() |
| | | }, 100) |
| | | } |
| | | |
| | | // ==================== æ¯éè¶
æ¶å
åº ==================== |
| | | |
| | | /** éè¯åæ´»æé´ï¼INVITING / INCOMING / RUNNINGï¼å¨ææ§è§¦åå端æ«è¯¥ room çè¶
æ¶ INVITINGï¼ä¿æ timer æ¯ä¸ºäº inviteCall è¿½å æ°äººåä¹è½è¦çï¼éå¼ç±å端é
ç½®å³å®ï¼å端åªè´è´£ trigger */ |
| | | const { resume: resumeNoAnswerTimer, pause: pauseNoAnswerTimer } = useIntervalFn( |
| | | triggerNoAnswerCallCheck, RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS, { immediate: false } |
| | | ) |
| | | watch( |
| | | () => rtcStore.isActive, |
| | | (active) => (active ? resumeNoAnswerTimer() : pauseNoAnswerTimer()), |
| | | { immediate: true } |
| | | ) |
| | | |
| | | /** æ¬å°ä»æ pending æè°ï¼INVITING / RUNNING å callãINCOMING å incomingPayloadï¼æ¥å£éé»é误 fire-and-forget */ |
| | | function triggerNoAnswerCallCheck() { |
| | | const source = rtcStore.call ?? rtcStore.incomingPayload |
| | | if (!source?.room || !source.inviteeIds?.length) { |
| | | return |
| | | } |
| | | noAnswerCallCheck(source.room).catch(() => undefined) |
| | | } |
| | | |
| | | // ==================== è®¾å¤æ§å¶ ==================== |
| | | |
| | | async function toggleMic() { |
| | | await lk.setMicEnabled(!lk.micEnabled.value) |
| | | } |
| | | async function toggleCamera() { |
| | | await lk.setCameraEnabled(!lk.cameraEnabled.value) |
| | | } |
| | | function toggleSpeaker() { |
| | | lk.setSpeakerEnabled(!lk.speakerEnabled.value) |
| | | } |
| | | |
| | | /** åå±å¹å
±äº«ï¼æµè§å¨å¼¹åçãéæ©å
±äº«å
容ãå¯¹è¯æ¡ï¼ç¨æ·åæ¶æ¶ä¼æéï¼UI ä¸å¼¹æç¤º */ |
| | | async function handleScreenShare() { |
| | | const enabled = !lk.screenShareEnabled.value |
| | | try { |
| | | await lk.setScreenShareEnabled(enabled) |
| | | } catch (error: any) { |
| | | // ç¨æ·åæ¶éæ©ï¼ä¸å½ä½é误ï¼å
¶å®å¼å¸¸ææ¥å¿ |
| | | if (error?.name !== 'NotAllowedError' && error?.message !== 'permission denied') { |
| | | console.warn('[Call] screenShare åæ¢å¤±è´¥', { enabled }, error) |
| | | } |
| | | } |
| | | } |
| | | |
| | | // ==================== æ·»å æå ==================== |
| | | |
| | | /** æå¼ãæ·»å æåãå¼¹çªï¼å ä½ç¾¤éè¯ + æ¥éä¸ç¶ææå
许 */ |
| | | function openAddMember() { |
| | | const call = rtcStore.call |
| | | if (!call?.groupId) { |
| | | return |
| | | } |
| | | memberPickerRef.value?.open({ |
| | | groupId: call.groupId, |
| | | mode: 'add', |
| | | excludeUserIds: participants.value.map((p) => p.userId) |
| | | }) |
| | | } |
| | | |
| | | /** picker é宿åï¼èµ° invite 追å é请æ¥å£ï¼åç«¯æ¨ RTC_INVITE ç»æ°æå */ |
| | | async function handleAddMemberSuccess(userIds: number[]) { |
| | | const call = rtcStore.call |
| | | if (!call?.room || userIds.length === 0) { |
| | | return |
| | | } |
| | | await inviteCall({ room: call.room, inviteeIds: userIds }) |
| | | // 忥æ¬å° inviteeIdsï¼è®©æ°æåç«å³ä½ä¸º pending å ä½åºç°å¨ç½æ ¼é |
| | | rtcStore.appendInvitees(userIds) |
| | | message.success('å·²åéé请') |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- éè¯é¶æ®µå¯¹åºå¼¹çªï¼INVITING / INCOMING / RUNNING ä¸éä¸äºæ¥ --> |
| | | <template v-if="rtcStore.isActive"> |
| | | <!-- 主å«ç«¯ï¼çå¾
å¯¹æ¹æ¥å¬ --> |
| | | <RtcCallInviting |
| | | v-if="rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call" |
| | | :peer-nickname="rtcStore.peerNickname" |
| | | :peer-avatar="rtcStore.peerAvatar" |
| | | :is-group="isGroup" |
| | | :is-video="isVideo" |
| | | :mic-enabled="lk.micEnabled.value" |
| | | :camera-enabled="lk.cameraEnabled.value" |
| | | :speaker-enabled="lk.speakerEnabled.value" |
| | | :local-stream="localStream" |
| | | @cancel="handleCancel" |
| | | @toggle-mic="toggleMic" |
| | | @toggle-camera="toggleCamera" |
| | | @toggle-speaker="toggleSpeaker" |
| | | /> |
| | | <!-- 被å«ç«¯ï¼æ¥çµåé --> |
| | | <RtcCallIncoming |
| | | v-else-if="rtcStore.stage === ImRtcCallStage.INCOMING" |
| | | :payload="rtcStore.incomingPayload" |
| | | :is-group="isGroup" |
| | | :accepting="accepting" |
| | | :rejecting="rejecting" |
| | | @accept="handleAccept" |
| | | @reject="handleReject" |
| | | /> |
| | | <!-- éè¯è¿è¡ä¸ï¼1v1 è§é¢ / è¯é³ + 群éè¯å®«æ ¼ --> |
| | | <RtcCallRunning |
| | | v-else-if="rtcStore.stage === ImRtcCallStage.RUNNING && rtcStore.call" |
| | | :is-group="isGroup" |
| | | :is-video="isVideo" |
| | | :mic-enabled="lk.micEnabled.value" |
| | | :camera-enabled="lk.cameraEnabled.value" |
| | | :speaker-enabled="lk.speakerEnabled.value" |
| | | :screen-share-enabled="lk.screenShareEnabled.value" |
| | | :reconnecting="lk.reconnecting.value" |
| | | :started-at="rtcStore.startedAt" |
| | | :participants="participants" |
| | | :peer-nickname="rtcStore.peerNickname" |
| | | :peer-avatar="rtcStore.peerAvatar" |
| | | :local-stream="localStream" |
| | | :remote-video-stream="remoteVideoStream" |
| | | :remote-audio-stream="remoteAudioStream" |
| | | :hanging-up="hangingUp" |
| | | @hangup="handleHangup" |
| | | @toggle-mic="toggleMic" |
| | | @toggle-camera="toggleCamera" |
| | | @toggle-speaker="toggleSpeaker" |
| | | @toggle-screen-share="handleScreenShare" |
| | | @add-member="openAddMember" |
| | | /> |
| | | </template> |
| | | <!-- éè¯ä¸ãæ·»å æåãé人弹çªï¼æå¨ isActive å¤ï¼é¿å
stage 忢ç¬é´å¼¹çªè¢«å¸è½½ --> |
| | | <RtcCallMemberPickerDialog ref="memberPickerRef" @success="handleAddMemberSuccess" /> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { useMediaStreamElement } from '../../composables/useMediaStreamElement' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | const props = defineProps<{ |
| | | cameraEnabled: boolean |
| | | isGroup?: boolean |
| | | isVideo: boolean |
| | | localStream?: MediaStream | null // æ¬å°è§é¢æµï¼è§é¢å¼å«é¢è§éºåº |
| | | micEnabled: boolean |
| | | peerAvatar?: string |
| | | peerNickname?: string |
| | | speakerEnabled: boolean |
| | | }>() |
| | | |
| | | defineEmits<{ |
| | | cancel: [] |
| | | toggleCamera: [] |
| | | toggleMic: [] |
| | | toggleSpeaker: [] |
| | | }>() |
| | | |
| | | const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- 主å«çå¾
å¯¹æ¹æ¥å¬çæ¬æµ®çªï¼1v1 ç§è 320Ã540ï¼ç¾¤éè¯åå¤§çª 720Ã560 --> |
| | | <div |
| | | class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[1000] flex flex-col text-white bg-gradient-to-b from-[#2a2a2c] to-[#1a1a1c]" |
| | | :class="isGroup ? 'w-[720px] h-[560px]' : 'w-[320px] h-[540px]'" |
| | | > |
| | | <div class="flex relative flex-1 justify-center items-center"> |
| | | <!-- è§é¢å¼å«ï¼èªå·±æå头é¢è§éºåºï¼å¯¹æ¹å¤´åæ¬æµ®é¡¶é¨ --> |
| | | <video |
| | | v-if="isVideo && localStream" |
| | | :ref="setLocalVideoRef" |
| | | class="absolute inset-0 object-cover w-full h-full scale-x-[-1]" |
| | | autoplay |
| | | muted |
| | | playsinline |
| | | ></video> |
| | | <div |
| | | class="flex relative z-[1] flex-col gap-4 items-center" |
| | | :class="{ 'self-start mt-16': isVideo }" |
| | | > |
| | | <UserAvatar |
| | | :url="peerAvatar" |
| | | :name="peerNickname" |
| | | :size="96" |
| | | radius="8px" |
| | | :clickable="false" |
| | | /> |
| | | <div class="text-[17px] font-medium">{{ peerNickname || '对æ¹' }}</div> |
| | | <div class="text-13px text-white/60">çå¾
å¯¹æ¹æ¥åé请â¦â¦</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- åºé¨æä½åºï¼éº¦å
é£ / åæ¶ / (æå头 | æ¬å£°å¨) --> |
| | | <div class="flex flex-shrink-0 gap-4 justify-around items-center pt-4 px-5 pb-5"> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none" |
| | | @click="$emit('toggleMic')" |
| | | > |
| | | <!-- Iconify é mic æéé³åä½ï¼speaker / camera 没æåæºéé³åä½ï¼off æå tabler:*-off 表达æçº¿ --> |
| | | <span |
| | | class="flex justify-center items-center w-12 h-12 rounded-full" |
| | | :class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ micEnabled ? '麦å
é£å·²å¼' : '麦å
é£å·²å
³' }} |
| | | </span> |
| | | </div> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none" |
| | | @click="$emit('cancel')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-12 h-12 text-white rounded-full bg-[#f04a4a]" |
| | | > |
| | | <Icon icon="ant-design:phone-outlined" :size="22" class="rotate-[135deg]" /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap">åæ¶</span> |
| | | </div> |
| | | <div |
| | | v-if="isVideo" |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none" |
| | | @click="$emit('toggleCamera')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-12 h-12 rounded-full" |
| | | :class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ cameraEnabled ? 'æå头已å¼' : 'æå头已å
³' }} |
| | | </span> |
| | | </div> |
| | | <div |
| | | v-else |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none" |
| | | @click="$emit('toggleSpeaker')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-12 h-12 rounded-full" |
| | | :class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ speakerEnabled ? 'æ¬å£°å¨å·²å¼' : 'æ¬å£°å¨å·²å
³' }} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../group' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { Button, Modal } from 'ant-design-vue' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { GroupMemberPickerPanel } from '../picker' |
| | | |
| | | defineOptions({ name: 'ImRtcCallMemberPickerDialog' }) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** éå®ç¹å®æï¼æºå¸¦éä¸ç userId å表 */ |
| | | success: [selectedIds: number[]] |
| | | }>() |
| | | |
| | | type PickerMode = 'add' | 'invite' |
| | | |
| | | const groupStore = useGroupStore() |
| | | |
| | | const visible = ref(false) // å¼¹çªæ¾é |
| | | const groupId = ref(0) // å½å群ç¼å·ï¼open æ¶ç±è°ç¨æ¹ä¼ å
¥ |
| | | const mode = ref<PickerMode>('invite') // å¼¹çªç¨éï¼invite=å起群éè¯éé请人 / add=éè¯ä¸è¿½å æå |
| | | const excludeUserIds = ref<number[]>([]) // ç½®ç°ç userId å表ï¼add åºæ¯æå·²å¨éè¯å
ç人ç¦ç¨ |
| | | const selectedIds = ref<number[]>([]) // å½åéä¸ç userId å表ï¼GroupMemberPickerPanel v-model ç»è¿æ¥ |
| | | |
| | | /** æ é¢ï¼æç¨é忢 */ |
| | | const title = computed(() => (mode.value === 'add' ? 'æ·»å æå' : 'éæ©æå')) |
| | | |
| | | /** 群æåå表ï¼ä» groupStore ç°åï¼map æ GroupMemberLite */ |
| | | const members = computed<GroupMemberLite[]>(() => { |
| | | const group = groupStore.getGroup(groupId.value) |
| | | return (group?.members || []).map((member) => ({ |
| | | userId: member.userId, |
| | | nickname: member.nickname, |
| | | showName: member.displayUserName || member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status, |
| | | role: member.role |
| | | })) |
| | | }) |
| | | |
| | | /** èªå·±ä¸åºç°å¨é项é */ |
| | | const hideIds = computed<number[]>(() => { |
| | | const myId = getCurrentUserId() |
| | | return myId ? [myId] : [] |
| | | }) |
| | | |
| | | /** å·²å¨éè¯å
çæåç½®ç° */ |
| | | const disabledIds = computed<number[]>(() => excludeUserIds.value) |
| | | |
| | | /** æ¯å¦å¯æäº¤ï¼è³å°é 1 个 */ |
| | | const canSubmit = computed(() => selectedIds.value.length > 0) |
| | | |
| | | /** æå¼å¼¹çªï¼excludeUserIds ç¨äºãæ·»å æåãæ¶æå·²å¨éè¯å
çäººç½®ç° */ |
| | | function open(opts: { excludeUserIds?: number[]; groupId: number; mode?: PickerMode; }) { |
| | | groupId.value = opts.groupId |
| | | mode.value = opts.mode || 'invite' |
| | | excludeUserIds.value = opts.excludeUserIds || [] |
| | | selectedIds.value = [] |
| | | visible.value = true |
| | | } |
| | | defineExpose({ open }) // æä¾ open æ¹æ³ï¼ç¨äºæå¼å¼¹çª |
| | | |
| | | /** ç¹å®æï¼emit éä¸ ID å表ç»ç¶çº§ + å
³éå¼¹çªï¼æäº¤æé® disabled å·²ä¿è¯ selectedIds é空 */ |
| | | function handleOk() { |
| | | emit('success', [...selectedIds.value]) |
| | | visible.value = false |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | éè¯æå鿩弹çªï¼å起群éè¯æ¶éé请人 / éè¯ä¸æ·»å æåï¼ |
| | | éæ© UI å§æ GroupMemberPickerPanelï¼èªå·± hideãå·²å¨éè¯å
çæå disabled |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | :title="title" |
| | | width="720px" |
| | | :mask-closable="false" |
| | | class="im-picker-dialog" |
| | | > |
| | | <div class="h-[480px]"> |
| | | <GroupMemberPickerPanel |
| | | v-model:selected-ids="selectedIds" |
| | | :members="members" |
| | | :disabled-ids="disabledIds" |
| | | :hide-ids="hideIds" |
| | | /> |
| | | </div> |
| | | <template #footer> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button type="primary" :disabled="!canSubmit" @click="handleOk">宿</Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | @use '../picker/picker-dialog' as picker; |
| | | |
| | | /* :deep ç©¿é el-dialog å
é¨ç±»ï¼å¤ç¨ picker å
Œ
± mixin */ |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { useMediaStreamElement } from '../../composables/useMediaStreamElement' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | export interface CallParticipantVM { |
| | | userId: number |
| | | nickname: string |
| | | avatar?: string |
| | | isLocal: boolean |
| | | videoStream?: MediaStream | null |
| | | audioStream?: MediaStream | null |
| | | /** çå¾
å å
¥ï¼UI æ¾ç¤ºä¸ç¹å¨ç» */ |
| | | pending?: boolean |
| | | } |
| | | |
| | | const props = defineProps<{ |
| | | participant: CallParticipantVM |
| | | /** æ¬å£°å¨å¼å
³ï¼ä¸º false æ¶éé³è¯¥æ ¼åçè¿ç«¯é³é¢ */ |
| | | speakerEnabled: boolean |
| | | }>() |
| | | |
| | | const setVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream) |
| | | const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 群éè¯å®«æ ¼éçå个åä¸è
æ ¼åï¼ |
| | | ä¼å
渲æè¿ç«¯è§é¢ï¼æ videoStream æ¶éºæ»¡ï¼ï¼æ è§é¢é级渲æå¤´å + ååè¶åï¼ |
| | | pending=true æ¶å å ãæ¥å
¥ä¸ãä¸ç¹å¨ç»å ä½ï¼ç¨äºè¢«éè¯·äººæªæ¥éåçå 使¸²æï¼ |
| | | è¿ç«¯é³é¢éè¿éè audio ææ¾ï¼æ¬ç«¯ isLocal 䏿¸²æé¿å
åå£°ï¼ |
| | | --> |
| | | <div |
| | | class="flex relative overflow-hidden justify-center items-center w-full h-full rounded-md bg-[#2a2a2c]" |
| | | > |
| | | <!-- è§é¢å¯ç¨ï¼æ¸²æ videoï¼å¦å渲æå¤´åæé»è®¤å ä½ --> |
| | | <video |
| | | v-if="participant.videoStream" |
| | | :ref="setVideoRef" |
| | | class="object-cover w-full h-full" |
| | | autoplay |
| | | playsinline |
| | | :muted="participant.isLocal" |
| | | ></video> |
| | | <div v-else class="flex justify-center items-center w-full h-full"> |
| | | <UserAvatar |
| | | :url="participant.avatar" |
| | | :name="participant.nickname" |
| | | :size="64" |
| | | radius="50%" |
| | | :clickable="false" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- è¿ç«¯é³é¢ï¼éè¿ audio å
ç´ ææ¾ï¼æ¬ç«¯éé³é¿å
åå£°ï¼æ¬å£°å¨å
³éæ¶æ´ä½éé³ --> |
| | | <audio |
| | | v-if="participant.audioStream && !participant.isLocal" |
| | | :ref="setAudioRef" |
| | | autoplay |
| | | :muted="!speakerEnabled" |
| | | ></audio> |
| | | |
| | | <!-- å·¦ä¸è§ååè¶å --> |
| | | <div |
| | | class="flex absolute bottom-3 left-3 gap-1.5 items-center py-[3px] pr-2.5 pl-[3px] text-13px text-white rounded-full bg-black/45 max-w-[calc(100%-60px)]" |
| | | > |
| | | <UserAvatar |
| | | :url="participant.avatar" |
| | | :name="participant.nickname" |
| | | :size="22" |
| | | radius="50%" |
| | | :clickable="false" |
| | | /> |
| | | <span class="truncate min-w-0">{{ participant.nickname }}</span> |
| | | </div> |
| | | |
| | | <!-- çå¾
对æ¹å å
¥ï¼ä¸ç¹å¨ç» +ãæ¥å
¥ä¸ãææ¡ï¼ä½ç½®å¨æ ¼åä¸å¿ä¸æ¹ 60px --> |
| | | <div |
| | | v-if="participant.pending" |
| | | class="flex absolute left-1/2 flex-col gap-1.5 items-center -translate-x-1/2 -translate-y-1/2 top-[calc(50%+60px)]" |
| | | > |
| | | <div class="flex gap-1.5"> |
| | | <span |
| | | v-for="i in 3" |
| | | :key="i" |
| | | class="tile-dot w-1.5 h-1.5 rounded-full bg-white/60" |
| | | :style="{ animationDelay: `${(i - 1) * 0.2}s` }" |
| | | ></span> |
| | | </div> |
| | | <span class="text-xs text-white/70">æ¥å
¥ä¸</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ä¸ç¹æ·¡å
¥æ·¡åºå¨ç»ï¼@keyframes å¿
é¡» CSS å®ä¹ */ |
| | | .tile-dot { |
| | | animation: tile-dot 1.4s infinite ease-in-out both; |
| | | } |
| | | @keyframes tile-dot { |
| | | 0%, |
| | | 80%, |
| | | 100% { |
| | | opacity: 0.3; |
| | | } |
| | | 40% { |
| | | opacity: 1; |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, onUnmounted, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { formatCallDuration } from '#/views/im/utils/time' |
| | | |
| | | import { useMediaStreamElement } from '../../composables/useMediaStreamElement' |
| | | import { UserAvatar } from '../user' |
| | | import RtcCallParticipantTile, { type CallParticipantVM } from './rtc-call-participant-tile.vue' |
| | | |
| | | const props = defineProps<{ |
| | | cameraEnabled: boolean |
| | | hangingUp?: boolean |
| | | /** æ¯å¦ç¾¤éè¯ï¼å³å®ç½æ ¼ / åç¹å¸å± + æµ®çªå¤§å° */ |
| | | isGroup: boolean |
| | | isVideo: boolean |
| | | localStream?: MediaStream | null |
| | | micEnabled: boolean |
| | | /** ç½æ ¼è§å¾ç¨ï¼ææåä¸è
ï¼å«èªå·±ï¼ */ |
| | | participants: CallParticipantVM[] |
| | | peerAvatar?: string |
| | | /** 1v1 è§å¾ç¨ */ |
| | | peerNickname?: string |
| | | /** æ¯å¦å¤äºç½ç»éè¿ä¸ï¼æ¾ç¤ºé¡¶é¨é»è²æç¤ºæ¡ */ |
| | | reconnecting?: boolean |
| | | remoteAudioStream?: MediaStream | null |
| | | remoteVideoStream?: MediaStream | null |
| | | /** æ¯å¦æ£å¨å±å¹å
±äº«ï¼æé®é«äº® + ææ¡åæ¢ */ |
| | | screenShareEnabled?: boolean |
| | | /** æ¬å£°å¨å¼å
³ï¼true æ¶æ£å¸¸ææ¾è¿ç«¯é³é¢ï¼false æ¶ææè¿ç«¯ audio å
ç´ éé³ */ |
| | | speakerEnabled: boolean |
| | | startedAt: number |
| | | }>() |
| | | |
| | | defineEmits<{ |
| | | addMember: [] |
| | | hangup: [] |
| | | toggleCamera: [] |
| | | toggleMic: [] |
| | | toggleScreenShare: [] |
| | | toggleSpeaker: [] |
| | | }>() |
| | | |
| | | /** ç½æ ¼åæ°ï¼æäººæ°èªéåºï¼è¿å UnoCSS class åé¢é让 JIT æ«æå¨éæè¯å« */ |
| | | const gridColsClass = computed(() => { |
| | | const n = props.participants.length |
| | | if (n <= 1) return 'grid-cols-1' |
| | | if (n <= 4) return 'grid-cols-2' |
| | | return 'grid-cols-3' |
| | | }) |
| | | |
| | | const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream) |
| | | const setRemoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream) |
| | | const setRemoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream) |
| | | |
| | | /** 1v1 è§é¢ï¼æ¯å¦æè¿ç«¯è§é¢æµ */ |
| | | const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream) |
| | | |
| | | const now = ref(Date.now()) // éè¯æ¶é¿ï¼ä»
1v1 è¯é³è§å¾éè¦å±ç¤ºï¼å
¶å®è§å¾ä¸å¯ tick |
| | | let tick = 0 |
| | | watch( |
| | | () => props.isGroup || props.isVideo, |
| | | (suppressTick) => { |
| | | if (suppressTick) { |
| | | if (tick) { |
| | | clearInterval(tick) |
| | | tick = 0 |
| | | } |
| | | return |
| | | } |
| | | now.value = Date.now() |
| | | tick = window.setInterval(() => { |
| | | now.value = Date.now() |
| | | }, 1000) |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | onUnmounted(() => { |
| | | if (tick) { |
| | | clearInterval(tick) |
| | | } |
| | | }) |
| | | |
| | | /** éè¯æ¶é¿ MM:SS / HH:MM:SS */ |
| | | const formattedDuration = computed(() => |
| | | formatCallDuration(Math.floor((now.value - props.startedAt) / 1000)) |
| | | ) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- éè¯è¿è¡ä¸çæ¬æµ®çªï¼1v1 ç§è 320Ã540ï¼ç¾¤éè¯åå¤§çª 720Ã560 --> |
| | | <div |
| | | class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[1000] flex flex-col text-white bg-[#1a1a1c]" |
| | | :class="isGroup ? 'w-[720px] h-[560px]' : 'w-[320px] h-[540px]'" |
| | | > |
| | | <!-- éè¿ä¸æ¨ªå¹
ï¼ç½ç»æå¨æ¶æ¾ç¤ºï¼ç´å° Reconnected äºä»¶æ¸
é¤ --> |
| | | <div |
| | | v-if="reconnecting" |
| | | class="inline-flex absolute top-3 left-1/2 z-10 gap-2 items-center px-3.5 py-1.5 text-13px text-[#ffd45e] rounded-full -translate-x-1/2 bg-[rgba(255,196,0,0.18)]" |
| | | > |
| | | <span class="reconnect-dot w-2 h-2 rounded-full bg-[#ffd45e]"></span> |
| | | ç½ç»ä¸ä½³ï¼æ£å¨éè¿â¦â¦ |
| | | </div> |
| | | <div class="flex relative flex-1 justify-center items-center"> |
| | | <!-- 群éè¯ï¼ç½æ ¼å¸å±ï¼åæ°é人æ°èªéåº --> |
| | | <div |
| | | v-if="isGroup" |
| | | class="grid gap-1 p-1 w-full h-full content-stretch" |
| | | :class="gridColsClass" |
| | | > |
| | | <RtcCallParticipantTile |
| | | v-for="participant in participants" |
| | | :key="participant.userId" |
| | | :participant="participant" |
| | | :speaker-enabled="speakerEnabled" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- 1v1 è§é¢ï¼è¿ç«¯éºåº + æ¬å°ç¼©ç¥ --> |
| | | <template v-else-if="isVideo"> |
| | | <div v-show="hasRemoteVideo" class="absolute inset-0"> |
| | | <video |
| | | :ref="setRemoteVideoRef" |
| | | class="object-cover w-full h-full" |
| | | autoplay |
| | | playsinline |
| | | ></video> |
| | | </div> |
| | | <div v-if="!hasRemoteVideo" class="flex z-[1] flex-col gap-4 items-center"> |
| | | <UserAvatar |
| | | :url="peerAvatar" |
| | | :name="peerNickname" |
| | | :size="96" |
| | | radius="8px" |
| | | :clickable="false" |
| | | /> |
| | | <div class="text-[17px] font-medium">{{ peerNickname }}</div> |
| | | <div class="text-13px text-white/60">çå¾
对æ¹å¼å¯æå头â¦â¦</div> |
| | | </div> |
| | | <div |
| | | v-if="localStream" |
| | | class="absolute top-4 right-4 z-[2] overflow-hidden w-30 rounded-lg aspect-[9/16] bg-[#333]" |
| | | > |
| | | <video |
| | | :ref="setLocalVideoRef" |
| | | class="object-cover w-full h-full scale-x-[-1]" |
| | | autoplay |
| | | muted |
| | | playsinline |
| | | ></video> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- 1v1 è¯é³ï¼å¤´å + æ¶é¿ --> |
| | | <template v-else> |
| | | <div class="flex z-[1] flex-col gap-4 items-center"> |
| | | <UserAvatar |
| | | :url="peerAvatar" |
| | | :name="peerNickname" |
| | | :size="96" |
| | | radius="8px" |
| | | :clickable="false" |
| | | /> |
| | | <div class="text-[17px] font-medium">{{ peerNickname }}</div> |
| | | <div class="text-13px text-white/60">{{ formattedDuration }}</div> |
| | | </div> |
| | | </template> |
| | | <audio |
| | | v-if="!isGroup && remoteAudioStream" |
| | | :ref="setRemoteAudioRef" |
| | | autoplay |
| | | :muted="!speakerEnabled" |
| | | ></audio> |
| | | </div> |
| | | |
| | | <!-- åºé¨æä½åºï¼éº¦å
é£ / æ¬å£°å¨ / æå头 / (群èï¼å
±äº«å±å¹ / æ·»å æå) / ææ --> |
| | | <div |
| | | class="flex flex-shrink-0 gap-3 justify-around items-center pt-4 px-4 pb-5 bg-black/20" |
| | | > |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('toggleMic')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-[52px] h-[52px] rounded-full" |
| | | :class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ micEnabled ? '麦å
é£å·²å¼' : '麦å
é£å·²å
³' }} |
| | | </span> |
| | | </div> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('toggleSpeaker')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-[52px] h-[52px] rounded-full" |
| | | :class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ speakerEnabled ? 'æ¬å£°å¨å·²å¼' : 'æ¬å£°å¨å·²å
³' }} |
| | | </span> |
| | | </div> |
| | | <!-- æå头æé®ï¼ç§èè§é¢åºå®æ¾ç¤ºï¼ç¾¤èä¸è®ºèµ·å§ mediaType 齿¾ç¤ºï¼è®©ç¨æ·æéå¼ --> |
| | | <div |
| | | v-if="isVideo || isGroup" |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('toggleCamera')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-[52px] h-[52px] rounded-full" |
| | | :class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ cameraEnabled ? 'æå头已å¼' : 'æå头已å
³' }} |
| | | </span> |
| | | </div> |
| | | <!-- 群éè¯ææï¼å
±äº«å±å¹ / æ·»å æåï¼å
±äº«ä¸æé®é«äº® + ææ¡æ¢æã忢å
±äº«ã --> |
| | | <template v-if="isGroup"> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('toggleScreenShare')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-[52px] h-[52px] rounded-full" |
| | | :class="screenShareEnabled ? 'bg-[#07c160] text-white' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="screenShareEnabled ? 'ant-design:laptop-outlined' : 'tabler:device-laptop-off'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ screenShareEnabled ? '忢å
±äº«' : 'å
±äº«å±å¹' }} |
| | | </span> |
| | | </div> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('addMember')" |
| | | > |
| | | <span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-white/15"> |
| | | <Icon icon="ant-design:plus-outlined" :size="22" /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap">æ·»å æå</span> |
| | | </div> |
| | | </template> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | :class="{ 'opacity-60 pointer-events-none': hangingUp }" |
| | | @click="$emit('hangup')" |
| | | > |
| | | <span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-[#f04a4a]"> |
| | | <Icon icon="ant-design:phone-outlined" :size="22" class="rotate-[135deg]" /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap">ææ</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* éè¿å°ç¹æ·¡å
¥æ·¡åºï¼@keyframes å¿
é¡» CSS å®ä¹ */ |
| | | .reconnect-dot { |
| | | animation: reconnect-pulse 1s ease-in-out infinite; |
| | | } |
| | | @keyframes reconnect-pulse { |
| | | 0%, |
| | | 100% { |
| | | opacity: 0.3; |
| | | } |
| | | 50% { |
| | | opacity: 1; |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, ref, watch } from 'vue' |
| | | |
| | | import { DICT_TYPE } from '#/packages/constants/src' |
| | | import { getDictLabel } from '#/packages/effects/hooks/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { message, Popover } from 'ant-design-vue' |
| | | |
| | | import { getActiveCall, joinCall } from '#/api/im/rtc' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { useGroupCallMembers } from '../../composables/useGroupCallMembers' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { useRtcStore } from '../../store/rtcStore' |
| | | import { UserAvatar } from '../user' |
| | | |
| | | defineOptions({ name: 'ImRtcGroupCallBanner' }) |
| | | |
| | | const props = defineProps<{ |
| | | groupId: number |
| | | }>() |
| | | |
| | | const rtcStore = useRtcStore() |
| | | const groupStore = useGroupStore() |
| | | |
| | | const popoverVisible = ref(false) |
| | | |
| | | /** å½åç¾¤çæ´»è·éè¯ï¼rtcStore ç»´æ¤ï¼åä¸è
å å
¥ / 离å¼éç¥å¢å joinedUserIdsï¼éè¯ç»æç§»é¤ */ |
| | | const activeCall = computed(() => rtcStore.getGroupCall(props.groupId)) |
| | | |
| | | /** è¶åæ¡ææ¡ï¼ææåï¼å·²å å
¥ + æ¥å
¥ä¸ï¼å带人æ°ï¼åå§ 0 人æ¶åªæ¾ç¤ºåªä½ç±»å */ |
| | | const pillText = computed(() => { |
| | | const media = getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, activeCall.value?.mediaType) |
| | | const count = memberList.value.length |
| | | return count > 0 ? `æ£å¨${media}éè¯ï¼${count} 人ï¼` : `æ£å¨${media}éè¯` |
| | | }) |
| | | |
| | | /** |
| | | * åå°ç¾¤ / éè¯ room ååæ¶æä¸æ¬¡ææ°åä¸è
åè¡¨ï¼ |
| | | * 两个触ååºæ¯ï¼1ï¼ç¨æ·åç¾¤ï¼æ¬ç«¯å¯è½æ²¡æè¯¥ç¾¤éè¯çææ°ç¼åï¼2ï¼åä¸è
éç¥é¦æ¬¡å¡«å
ååªå«æ¬æ¬¡å å
¥è
ï¼ç¼ºåå²å å
¥è
ï¼ |
| | | * ç¨ [groupId, room] åæºçå¬ + 已填å
å®å«ï¼é¿å
å群 / 馿¬¡å¡«å
触åç忬¡é夿å |
| | | */ |
| | | watch( |
| | | () => |
| | | [ |
| | | props.groupId, |
| | | activeCall.value?.room, |
| | | groupStore.isGroupActiveCallExpired(props.groupId) |
| | | ] as const, |
| | | async ([groupId, room], oldValues) => { |
| | | if (!groupId) { |
| | | return |
| | | } |
| | | |
| | | if (!activeCall.value) { |
| | | if (!groupStore.isGroupActiveCallExpired(groupId)) { |
| | | return |
| | | } |
| | | try { |
| | | const data = await getActiveCall(groupId) |
| | | if (data) { |
| | | rtcStore.setGroupCall(data, true) |
| | | } else { |
| | | rtcStore.removeGroupCall(groupId) |
| | | } |
| | | } catch (error) { |
| | | console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error) |
| | | } |
| | | return |
| | | } |
| | | |
| | | // å³çæ¯å¦éè¦æåï¼è¡¥é½æ¬å°å·²æéè¯ï¼æ²¡ææ¬å°éè¯æ¶æç¾¤ç¼åè¿æç¶æææ¢æµä¸æ¬¡ |
| | | const groupChanged = !oldValues || oldValues[0] !== groupId |
| | | const roomChanged = oldValues && oldValues[1] !== room |
| | | const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 |
| | | const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId) |
| | | if ( |
| | | !activeCallExpired && |
| | | (rtcStore.isGroupCallParticipantsLoaded(groupId, room) || |
| | | (!groupChanged && !roomChanged && participantsLoaded)) |
| | | ) { |
| | | return |
| | | } |
| | | |
| | | // æææ°åä¸è
åå storeï¼æ¥å£è¿å空 â è¯¥ç¾¤å·²æ æ´»è·éè¯ï¼ç§»é¤æ¬å°ç¼å |
| | | try { |
| | | const data = await getActiveCall(groupId) |
| | | if (data) { |
| | | rtcStore.setGroupCall(data, true) |
| | | } else { |
| | | rtcStore.removeGroupCall(groupId) |
| | | } |
| | | } catch (error) { |
| | | console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error) |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | /** å¨éè¯ä¸çæåï¼å·²å å
¥ï¼+ æ¥å
¥ä¸çæåï¼å·²éè¯·æªæ¥éï¼ */ |
| | | const memberList = useGroupCallMembers(computed(() => props.groupId)) |
| | | |
| | | /** æ¬ç«¯æ¯å¦æ£å¨è¯¥æ¿é´éè¯ï¼å¤äº INVITING / RUNNINGï¼ */ |
| | | const isInThisCall = computed( |
| | | () => rtcStore.isActive && rtcStore.call?.room === activeCall.value?.room |
| | | ) |
| | | |
| | | /** |
| | | * æå¡ç«¯æ¯å¦è®°å½æå·²å å
¥ï¼å·æ°å LiveKit è¿æ¥å·²æä½ webhook è¿æ²¡æ status æ 为 LEFT æ¶ä»ä¸º trueï¼ |
| | | * ç¨äºææé®ææ¡åå°ãéæ°å å
¥ãï¼ä½ä¸ disable æé® |
| | | */ |
| | | const serverSaysJoined = computed(() => { |
| | | const myId = getCurrentUserId() |
| | | return activeCall.value?.joinedUserIds?.includes(myId) ?? false |
| | | }) |
| | | |
| | | /** å å
¥æé®ç¦ç¨ï¼ä»
卿¬ç«¯å®é
ææ LiveKit è¿æ¥æ¶ç¦ç¨ */ |
| | | const joinDisabled = computed(() => isInThisCall.value) |
| | | |
| | | /** å å
¥æé®ææ¡ï¼æ¬ç«¯è¿ç â å·²å¨éè¯ä¸ï¼æå¡ç«¯è¿æ®çæä½æ¬ç«¯æäº â éæ°å å
¥ï¼å
¶å® â å å
¥ */ |
| | | const joinLabel = computed(() => { |
| | | if (isInThisCall.value) return 'å·²å¨éè¯ä¸' |
| | | if (serverSaysJoined.value) return 'éæ°å å
¥' |
| | | return 'å å
¥' |
| | | }) |
| | | |
| | | /** 主å¨å å
¥ï¼è° invite å½ä¸å·²æ call æ¿ tokenï¼rtcStore æ status èªå¨è¿ RUNNING */ |
| | | async function handleJoin() { |
| | | const call = activeCall.value |
| | | if (!call || joinDisabled.value) { |
| | | return |
| | | } |
| | | if (rtcStore.isActive) { |
| | | message.warning('æ¨æ£å¨éè¯ä¸') |
| | | return |
| | | } |
| | | popoverVisible.value = false |
| | | const data = await joinCall(call.room) |
| | | rtcStore.startInviting(data) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- ä»
å½è¯¥ç¾¤ææ´»è·éè¯æ¶æ¾ç¤ºï¼ç¹å»è¶åæ¡å±å¼ popover çå¨éè¯æå + å å
¥ --> |
| | | <div v-if="activeCall" class="flex-shrink-0 px-4 pb-2 bg-[var(--ant-color-fill-secondary)]"> |
| | | <Popover |
| | | v-model:open="popoverVisible" |
| | | placement="bottomLeft" |
| | | :overlay-style="{ width: '280px' }" |
| | | trigger="click" |
| | | > |
| | | <!-- è¶åæ¡æ¬ä½ï¼çµè¯å¾æ + ææ¡ï¼å«äººæ°ï¼+ å³ç®å¤´ --> |
| | | <div |
| | | class="inline-flex gap-2 items-center px-2.5 py-1 text-13px rounded-full cursor-pointer select-none transition-colors duration-150 bg-[var(--ant-color-success-bg)] text-[var(--ant-color-text)] hover:bg-[var(--ant-color-success-bg-hover)]" |
| | | > |
| | | <span |
| | | class="inline-flex flex-shrink-0 justify-center items-center w-[18px] h-[18px] text-white rounded-full bg-[#07c160]" |
| | | > |
| | | <Icon icon="ant-design:phone-filled" :size="14" /> |
| | | </span> |
| | | <span class="font-medium">{{ pillText }}</span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="12" |
| | | class="text-[var(--ant-color-text-secondary)]" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- å±å¼é¢æ¿ï¼éè¯æåï¼å«æ¥å
¥ä¸ï¼å¤´å横æ + å å
¥æé® --> |
| | | <template #content> |
| | | <div class="flex flex-col gap-4 items-center pt-2 pb-1"> |
| | | <div class="flex flex-wrap gap-1.5 justify-center max-w-[240px]"> |
| | | <UserAvatar |
| | | v-for="member in memberList" |
| | | :key="member.userId" |
| | | :url="member.avatar" |
| | | :name="member.nickname" |
| | | :size="40" |
| | | radius="6px" |
| | | :clickable="false" |
| | | :class="{ 'opacity-50': member.pending }" |
| | | :title="member.pending ? `${member.nickname}ï¼æ¥å
¥ä¸ï¼` : member.nickname" |
| | | /> |
| | | <!-- 馿¬¡å¡«å
æ¶æ¿å
å¯è½ææ¶ 0 人ï¼å å
¥åç± ParticipantConnected äºä»¶è¿½å --> |
| | | <div |
| | | v-if="memberList.length === 0" |
| | | class="p-3 text-13px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | ææ æåå¨éè¯ |
| | | </div> |
| | | </div> |
| | | <!-- æ¬ç«¯å¨éè¯å
æ¶ç½®ç°ãå·²å¨éè¯ä¸ãï¼æå¡ç«¯æ®çæä½æ¬ç«¯è¿æ¥æäºæ¾ç¤ºãéæ°å å
¥ãï¼å·æ°é¡µé¢ååºæ¯ï¼ --> |
| | | <button |
| | | class="w-[200px] h-9 text-sm font-medium rounded-lg cursor-pointer border-none bg-[#f1f1f3] text-[#1a1a1c] transition-colors duration-150 disabled:cursor-not-allowed disabled:text-[#999] hover:[&:not(:disabled)]:bg-[#e7e7ea]" |
| | | :disabled="joinDisabled" |
| | | @click="handleJoin" |
| | | > |
| | | {{ joinLabel }} |
| | | </button> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { useUserStore } from '#/packages/stores/src' |
| | | |
| | | import { Badge } from 'ant-design-vue' |
| | | |
| | | import { useConversationStore } from '../store/conversationStore' |
| | | import { useFriendStore } from '../store/friendStore' |
| | | import { useImUiStore } from '../store/uiStore' |
| | | import { UserAvatar } from './user' |
| | | |
| | | defineOptions({ name: 'ImToolBar' }) |
| | | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const userStore = useUserStore() |
| | | const conversationStore = useConversationStore() |
| | | const friendStore = useFriendStore() |
| | | const uiStore = useImUiStore() |
| | | |
| | | /** æ¶æ¯ Tab ç红ç¹ï¼ææéå
ææ°ä¼è¯çæªè¯»æ»å */ |
| | | const totalUnread = computed(() => conversationStore.getTotalUnreadCount) |
| | | /** éè®¯å½ Tab ç红ç¹ï¼æªå¤ç好åç³è¯·æ°ï¼æ¥æ¶æ¹=æï¼ */ |
| | | const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount) |
| | | |
| | | const tabs = [ |
| | | { name: 'ImHomeConversation', icon: 'ep:chat-round' }, |
| | | { name: 'ImHomeContact', icon: 'mingcute:contacts-line' } |
| | | ] // 两个主 Tabï¼ç¨è·¯ç± name èé pathï¼é¿å
åç¼ / åµå¥è°æ´å失æ |
| | | |
| | | // å½åè·¯ç±æ¯å¦å½ä¸ Tabï¼ç´æ¥æ¯å¯¹ route.name |
| | | const isActive = (name: string) => route.name === name |
| | | |
| | | // 忢 Tabï¼å½åå·²é䏿¶ï¼æ¶æ¯ Tab 触å"æ»å¨å°ä¸ä¸ä¸ªæªè¯»"ï¼å¯¹é½å¾®ä¿¡ PCï¼ï¼å
¶å® Tab æ å¨ä½ |
| | | const goTab = (name: string) => { |
| | | if (route.name === name) { |
| | | if (name === 'ImHomeConversation') { |
| | | uiStore.requestNextUnreadJump() |
| | | } |
| | | return |
| | | } |
| | | router.push({ name }) |
| | | } |
| | | |
| | | // 跳转个人ä¸å¿ï¼è·¯ç± name=Profileï¼ |
| | | const goProfile = () => router.push({ name: 'Profile' }) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | ToolBarï¼IM 左侧工å
·æ |
| | | å¸å±ï¼é¡¶é¨å¤´å â ä¸é´ä¸ Tabï¼æ¶æ¯/好å/群èï¼â åºé¨è®¾ç½® |
| | | --> |
| | | <div |
| | | class="flex flex-col items-center w-14 pt-4 pb-3 gap-2 flex-shrink-0 select-none bg-[#2b2b2b]" |
| | | > |
| | | <!-- é¡¶é¨ç¨æ·å¤´åï¼ç¹å»è·³ä¸ªäººä¸å¿ï¼èµ° UserAvatar ç»ä¸é¦å / åå¸é
è²è§å --> |
| | | <div class="mb-2 cursor-pointer" @click="goProfile"> |
| | | <UserAvatar |
| | | :url="userStore.userInfo?.avatar" |
| | | :name="userStore.userInfo?.nickname" |
| | | :size="36" |
| | | :clickable="false" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- ä¸é´ä¸ Tab --> |
| | | <div class="flex flex-col items-center gap-2 flex-1 w-full"> |
| | | <div |
| | | v-for="item in tabs" |
| | | :key="item.name" |
| | | class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10" |
| | | :class="{ 'bg-white/15 text-white': isActive(item.name) }" |
| | | @click="goTab(item.name)" |
| | | > |
| | | <Badge |
| | | v-if="item.name === 'ImHomeConversation' && totalUnread > 0" |
| | | :count="totalUnread" |
| | | :max="99" |
| | | class="tool-bar__badge" |
| | | > |
| | | <Icon :icon="item.icon" class="tool-bar__icon" /> |
| | | </Badge> |
| | | <Badge |
| | | v-else-if="item.name === 'ImHomeContact' && unhandledRequestCount > 0" |
| | | :count="unhandledRequestCount" |
| | | :max="99" |
| | | class="tool-bar__badge" |
| | | > |
| | | <Icon :icon="item.icon" class="tool-bar__icon" /> |
| | | </Badge> |
| | | <Icon v-else :icon="item.icon" class="tool-bar__icon" /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- åºé¨è®¾ç½®æé®ï¼ç¹å»è·³ä¸ªäººä¸å¿ --> |
| | | <div class="flex flex-col items-center gap-2 w-full"> |
| | | <div |
| | | class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10" |
| | | @click="goProfile" |
| | | > |
| | | <Icon icon="ant-design:setting-outlined" class="tool-bar__icon" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .tool-bar__icon { |
| | | width: 22px; |
| | | height: 22px; |
| | | } |
| | | |
| | | /* è°æ´ AntD Badge å³ä¸è§çº¢ç¹ä½ç½® */ |
| | | .tool-bar__badge :deep(.ant-badge-count) { |
| | | top: 4px; |
| | | right: 8px; |
| | | border: none; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation, FriendLite } from '../../types' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, Input, message, Modal } from 'ant-design-vue' |
| | | |
| | | import { createGroup } from '#/api/im/group' |
| | | import { CardBubble } from '#/views/im/home/components/card' |
| | | |
| | | import { ImContentType, ImConversationType, isGroupConversation } from '../../../utils/constants' |
| | | import { getConversationKey } from '../../../utils/conversation' |
| | | import { buildDefaultGroupName } from '../../../utils/group' |
| | | import { type CardTarget, serializeMessage } from '../../../utils/message' |
| | | import { getGroupDisplayName, isGroupQuit } from '../../../utils/user' |
| | | import { useMessageSender } from '../../composables/useMessageSender' |
| | | import { FacePicker } from '../../pages/conversation/components/input' |
| | | import { useConversationStore } from '../../store/conversationStore' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { ConversationPickerPanel } from '../picker' |
| | | import { FriendPickerPanel } from '../picker' |
| | | |
| | | defineOptions({ name: 'ImRecommendCardDialog' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | const { sendRaw, send } = useMessageSender() |
| | | |
| | | const visible = ref(false) |
| | | const target = ref<CardTarget | null>(null) |
| | | const view = ref<'contact' | 'conversation'>('conversation') // å½åè§å¾ï¼é»è®¤ä¼è¯éæ©ï¼ãå建è天ãå
¥å£åå°å¥½åéæ© |
| | | const selectedKeys = ref<string[]>([]) |
| | | const selectedFriendIds = ref<number[]>([]) |
| | | const leaveMessage = ref('') |
| | | const sending = ref(false) |
| | | const emojiVisible = ref(false) // 表æ
颿¿æ¾éï¼å³ä¾§ smile icon 忢 |
| | | |
| | | defineExpose({ |
| | | /** æå¼æ¨èå¼¹çªï¼reset â çå â visible=true */ |
| | | open(opts: { target: CardTarget }) { |
| | | target.value = opts.target |
| | | view.value = 'conversation' |
| | | selectedKeys.value = [] |
| | | selectedFriendIds.value = [] |
| | | leaveMessage.value = '' |
| | | emojiVisible.value = false |
| | | sending.value = false |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** å¼¹çªæ é¢ï¼ä¼è¯è§å¾æ target ç±»ååææ¡ï¼å¥½åè§å¾åºå®ä¸ºã鿩好åã */ |
| | | const headerTitle = computed(() => { |
| | | if (view.value === 'contact') { |
| | | return '鿩好å' |
| | | } |
| | | return isGroupConversation(target.value?.targetType) ? 'æè¿ä¸ªç¾¤æ¨èç»æå' : 'æä»æ¨èç»æå' |
| | | }) |
| | | |
| | | /** åéä¼è¯ï¼ä» store æ¿æåºåçå表ï¼hide ç± Panel æ¥ hideKeys è¿æ»¤ï¼ï¼åå²é群群ä¸å¯è¢«æ¨èéä¸ï¼éäºå端ä¹ä¼æï¼ */ |
| | | const candidateConversations = computed<Conversation[]>(() => |
| | | conversationStore.getSortedConversationList.filter( |
| | | (conversation) => |
| | | !( |
| | | conversation.type === ImConversationType.GROUP && |
| | | isGroupQuit(groupStore.getGroup(conversation.targetId)) |
| | | ) |
| | | ) |
| | | ) |
| | | |
| | | /** éè keyï¼ä¸è½æåçæ¨ååçæ¬èº«çä¼è¯ï¼ç¨æ·åçé¿å
èªæ¨ã群åçé¿å
æ¨åè¯¥ç¾¤ï¼ */ |
| | | const hideKeys = computed<string[]>(() => { |
| | | const t = target.value |
| | | if (!t) { |
| | | return [] |
| | | } |
| | | return [getConversationKey({ type: t.targetType, targetId: t.targetId })] |
| | | }) |
| | | |
| | | /** 好åè§å¾åéå表ï¼ç´æ¥å¤ç¨ friendStore Lite è§å¾ */ |
| | | const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList) |
| | | |
| | | /** æéä¸ç emoji æ¼å°çè¨æ«å°¾ï¼FacePicker èªèº«è´è´£å
³é颿¿ */ |
| | | function handleEmojiSelect(emoji: string) { |
| | | leaveMessage.value = `${leaveMessage.value}${emoji}` |
| | | } |
| | | |
| | | /** åå°å¥½åè§å¾ï¼æ¸
æä¹åå¨ä¼è¯è§å¾è¾å
¥ççè¨ï¼é¿å
å¨ä¸å¯è§è¾å
¥æ¡éæçè¨éé»åå°æ°ç¾¤ */ |
| | | function handleSwitchToContact() { |
| | | view.value = 'contact' |
| | | leaveMessage.value = '' |
| | | emojiVisible.value = false |
| | | } |
| | | |
| | | /** |
| | | * 确认åéï¼ä¼è¯è§å¾ï¼ï¼æ¯ä¸ªéä¸ä¼è¯å
å CARDï¼CARD æååæåçè¨ï¼ä¿è¯ãå
çå°åçãçé¡ºåºæå¾ï¼ |
| | | * |
| | | * ææ¡èåï¼å
¨é¨æåã已转åããå
¨é¨å¤±è´¥ã转å失败ï¼AãBããé¨å失败ã已转åï¼ä½ XãY 失败ãï¼ |
| | | * å¤±è´¥çæ¶æ¯ä»¥ FAILED ç¶æçå¨å¯¹åºä¼è¯æ°æ³¡éï¼å¯å³é®éè¯ |
| | | */ |
| | | async function handleSend() { |
| | | const card = target.value |
| | | if (!card?.targetId || selectedKeys.value.length === 0) { |
| | | return |
| | | } |
| | | const byKey = new Map(candidateConversations.value.map((c) => [getConversationKey(c), c])) |
| | | const targets = selectedKeys.value |
| | | .map((key) => byKey.get(key)) |
| | | .filter((c): c is Conversation => c != null) |
| | | if (targets.length === 0) { |
| | | return |
| | | } |
| | | const cardContent = serializeMessage({ ...card }) |
| | | const leaveText = leaveMessage.value.trim() |
| | | sending.value = true |
| | | try { |
| | | const tasks = targets.map(async (conversation) => { |
| | | const cardOk = await sendRaw(ImContentType.CARD, cardContent, { conversation }) |
| | | if (!cardOk) { |
| | | return { conversation, ok: false } |
| | | } |
| | | const ok = leaveText ? await send(leaveText, { conversation }) : true |
| | | return { conversation, ok } |
| | | }) |
| | | const results = await Promise.all(tasks) |
| | | const failedNames = results.filter((r) => !r.ok).map((r) => r.conversation.name || 'æªå½åä¼è¯') |
| | | // æå½ä¸çç®æ æ¨å°æè¿è½¬åå表ï¼é¨åå¤±è´¥ä¹æ¨ï¼ç¨æ·ç"æå¾"å·²è¡¨è¾¾ï¼ |
| | | conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c))) |
| | | if (failedNames.length === 0) { |
| | | message.success('已转å') |
| | | } else if (failedNames.length === targets.length) { |
| | | message.error(`转å失败ï¼${failedNames.join('ã')}`) |
| | | } else { |
| | | message.warning(`已转åï¼ä½ ${failedNames.join('ã')} 失败`) |
| | | } |
| | | visible.value = false |
| | | } finally { |
| | | sending.value = false |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 好åè§å¾åéï¼å
建群ï¼åæ¶é请æé好åï¼â ç»æ°ç¾¤ååç â åçè¨ â å
³å¼¹çª |
| | | * |
| | | * è·ä¼è¯è§å¾çå·®å«ï¼å
è¦ createGroup æ¿å° groupIdï¼ä¹åæé ä¸ä¸ª GROUP ç±»åç conversation å¯¹è±¡ç» sendRaw ç¨ |
| | | * ï¼sendRaw å
é¨ä¼èªå¨ insertMessage ææ°ç¾¤ç»è®°è¿ storeï¼æè¿è½¬åå表ä¹è½æ£å¸¸æ¨ï¼ |
| | | */ |
| | | async function handleCreateGroupAndSend() { |
| | | const card = target.value |
| | | if (!card?.targetId || selectedFriendIds.value.length === 0) { |
| | | return |
| | | } |
| | | const byId = new Map(friends.value.map((f) => [f.id, f])) |
| | | const members = selectedFriendIds.value |
| | | .map((id) => byId.get(id)) |
| | | .filter((f): f is FriendLite => f != null) |
| | | if (members.length === 0) { |
| | | return |
| | | } |
| | | sending.value = true |
| | | try { |
| | | const memberUserIds = members.map((m) => m.id) |
| | | const name = buildDefaultGroupName(members) |
| | | const group = await createGroup({ name, memberUserIds, joinApproval: false }) |
| | | if (!group?.id) { |
| | | throw new Error('åå»ºç¾¤å¤±è´¥ï¼æªè¿å群ç¼å·') |
| | | } |
| | | // upsert è¿ groupStoreï¼ç䏿¬¡ fetchGroupList |
| | | groupStore.upsertGroup({ |
| | | id: group.id, |
| | | name: group.name, |
| | | avatar: group.avatar, |
| | | notice: group.notice, |
| | | ownerUserId: group.ownerUserId |
| | | }) |
| | | // ç»æ°ç¾¤æé ä¸ä¸ªä¸´æ¶ conversation å¯¹è±¡ç» sendRaw ç¨ï¼sendRaw å
é¨ä¼èªå¨ insertMessage ç»è®° |
| | | const newConversation: Conversation = { |
| | | type: ImConversationType.GROUP, |
| | | targetId: group.id, |
| | | name: getGroupDisplayName(group) || name, |
| | | avatar: group.avatar || '', |
| | | unreadCount: 0, |
| | | lastContent: '', |
| | | lastSendTime: 0 |
| | | } |
| | | const cardOk = await sendRaw(ImContentType.CARD, serializeMessage({ ...card }), { |
| | | conversation: newConversation |
| | | }) |
| | | if (!cardOk) { |
| | | message.warning('群已å建ï¼ä½åçåé失败ï¼è¯·ç¨åå¨ç¾¤ééè¯') |
| | | visible.value = false |
| | | return |
| | | } |
| | | const leaveText = leaveMessage.value.trim() |
| | | if (leaveText) { |
| | | await send(leaveText, { conversation: newConversation }) |
| | | } |
| | | conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)]) |
| | | message.success('å·²å建群èå¹¶åé') |
| | | visible.value = false |
| | | } finally { |
| | | sending.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | æåçæ¨èç»æåï¼ç¨æ· / 群éç¨ï¼ |
| | | - dialog å£³ç±æ¬ç»ä»¶ææï¼éæ© UI å§æ ConversationPickerPanel / FriendPickerPanel |
| | | - view='conversation'ï¼éå·²æä¼è¯ååçï¼é»è®¤è§å¾ï¼ |
| | | - view='contact'ï¼ä»ãå建è天ãå
¥å£è¿å
¥ï¼é好å建群åååçï¼ä¸å¡å£³å±åè§å¾ï¼ä¸¤ä¸ª Panel äºä¸ç¥éå¯¹æ¹ |
| | | - é 1 èµ°ãåéããå¤ä¸ªèµ°ãåå«åé(n)ãææ¡ï¼ä¸å¾®ä¿¡ä¸è´ |
| | | - å¤±è´¥çæ¶æ¯ä»¥ FAILED ç¶æçå¨å¯¹åºä¼è¯æ°æ³¡éï¼ä¾å³é®éè¯ |
| | | - 坹夿¥å£ï¼ref + open({ target })ï¼ä¸åèµ° v-model |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | width="720px" |
| | | :mask-closable="false" |
| | | :footer="view === 'conversation' ? null : undefined" |
| | | class="im-picker-dialog im-recommend-dialog" |
| | | > |
| | | <template #header> |
| | | <div class="flex gap-2 items-center"> |
| | | <Icon |
| | | v-if="view === 'contact'" |
| | | icon="ant-design:arrow-left-outlined" |
| | | :size="16" |
| | | class="cursor-pointer text-[var(--ant-color-text-secondary)] transition-colors duration-150 hover:text-[var(--ant-color-primary)]" |
| | | @click="view = 'conversation'" |
| | | /> |
| | | <span class="text-base text-[var(--ant-color-text)]"> |
| | | {{ headerTitle }} |
| | | </span> |
| | | </div> |
| | | </template> |
| | | |
| | | <div class="h-[480px]"> |
| | | <!-- ä¼è¯è§å¾ï¼éå·²æä¼è¯åé --> |
| | | <ConversationPickerPanel |
| | | v-if="view === 'conversation'" |
| | | v-model:selected-keys="selectedKeys" |
| | | :conversations="candidateConversations" |
| | | :recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys" |
| | | :hide-keys="hideKeys" |
| | | :show-create-chat="true" |
| | | @create-chat="handleSwitchToContact" |
| | | @remove-recent="conversationStore.removeRecentForwardConversationKey" |
| | | > |
| | | <template #footer> |
| | | <div class="flex flex-col gap-3 px-4 py-3"> |
| | | <!-- åçé¢è§å¡ --> |
| | | <CardBubble v-if="target" :card="target" /> |
| | | |
| | | <!-- çè¨ï¼åè¡ï¼ï¼å³ä¾§è¡¨æ
æé®è§¦å FacePickerï¼éä¸ emoji æ¼å°æ«å°¾ --> |
| | | <div class="relative"> |
| | | <Input v-model:value="leaveMessage" :maxlength="100" placeholder="ç»æåçè¨"> |
| | | <template #suffix> |
| | | <Icon |
| | | icon="ant-design:smile-outlined" |
| | | :size="18" |
| | | class="cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-primary)]" |
| | | @click.stop="emojiVisible = !emojiVisible" |
| | | /> |
| | | </template> |
| | | </Input> |
| | | <FacePicker |
| | | v-model:visible="emojiVisible" |
| | | mode="emoji" |
| | | class="bottom-full right-0 mb-2" |
| | | @select-emoji="handleEmojiSelect" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æä½æé®ï¼é 0/1 æ¾ç¤ºãåéããå¤ä¸ªæ¾ç¤ºãåå«åé(n)ã --> |
| | | <div class="flex gap-2 justify-end"> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="sending" |
| | | :disabled="selectedKeys.length === 0" |
| | | @click="handleSend" |
| | | > |
| | | {{ selectedKeys.length > 1 ? `åå«åéï¼${selectedKeys.length}ï¼` : 'åé' }} |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </ConversationPickerPanel> |
| | | |
| | | <!-- 好åè§å¾ï¼é好å建群ååé --> |
| | | <FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" /> |
| | | </div> |
| | | |
| | | <!-- 好åè§å¾ç dialog footerï¼å»ºç¾¤å¹¶åé --> |
| | | <template v-if="view === 'contact'" #footer> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="sending" |
| | | :disabled="selectedFriendIds.length === 0" |
| | | @click="handleCreateGroupAndSend" |
| | | > |
| | | å建群èå¹¶åé |
| | | </Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | @use '../picker/picker-dialog' as picker; |
| | | |
| | | /* :deep ç©¿é el-dialog å
é¨ç±»ï¼å¤ç¨ picker å
Œ
± mixin */ |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { User } from '../../types' |
| | | |
| | | import { computed } from 'vue' |
| | | |
| | | import { Image } from 'ant-design-vue' |
| | | |
| | | import { ImFriendAddSource } from '../../../utils/constants' |
| | | import { getAvatarBgColor, getAvatarText } from '../../../utils/user' |
| | | import { useImUiStore } from '../../store/uiStore' |
| | | |
| | | defineOptions({ name: 'ImUserAvatar', inheritAttrs: false }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | addSource?: number // å 好忥æºï¼ç¹å¤´åå¼¹ UserInfoCard æ¶éä¼ ç» FriendAddDialogï¼é»è®¤ 1=æç´¢ï¼ |
| | | addSourceExtra?: string // å 好忥æºéå ï¼addSource=2ï¼ç¾¤èï¼æ¶ä¼ 群åï¼è¯æ¯æ¼ä¸ºãææ¯ XX 群ç YYã |
| | | clickable?: boolean // æ¯å¦ç¹å»å¼¹åº UserInfoCardï¼é»è®¤ true |
| | | id?: number | string // ç¨æ· idï¼ä¼ äºæè½ç¹å¼åç |
| | | name?: string // ç¨æ·åï¼è²å¡æå + åçå¤ç¨ï¼ |
| | | previewable?: boolean // æ¯å¦ç¹å¤´åç´æ¥æ¾å¤§é¢è§ï¼å¼å¯åå¿½ç¥ clickableï¼ä¸åå¼¹åç |
| | | previewZIndex?: number // é¢è§å± z-indexï¼æ¾å¨é« z-index å¼¹å±ï¼å¦ UserInfoCardï¼éæ¶éæå¨æ¬é« |
| | | radius?: string // åè§ï¼æ¯æ CSS é¿åº¦ï¼é»è®¤ 15% æ¹åå°åè§ï¼åèå¾®ä¿¡ï¼ |
| | | size?: number // 尺寸ï¼pxï¼ï¼æ£æ¹å½¢ï¼ä¼å
äº width/height |
| | | url?: string // 头åå¾ç URLï¼ä¸ºç©ºæ¶èµ°è²å¡æå |
| | | user?: User // é¢å¤çç¨æ·ä¿¡æ¯ï¼ä¼ äºç¹å»å°±ä¸ç¨ç°ææ¥å£ï¼å¼¹åçç¨ï¼ |
| | | }>(), |
| | | { |
| | | addSourceExtra: undefined, |
| | | clickable: true, |
| | | id: undefined, |
| | | name: undefined, |
| | | previewable: false, |
| | | previewZIndex: 2000, |
| | | radius: '15%', |
| | | size: 42, |
| | | url: undefined, |
| | | user: undefined, |
| | | addSource: ImFriendAddSource.SEARCH |
| | | } |
| | | ) |
| | | |
| | | const uiStore = useImUiStore() |
| | | |
| | | const imgStyle = computed(() => ({ |
| | | width: `${props.size}px`, |
| | | height: `${props.size}px`, |
| | | borderRadius: props.radius |
| | | })) |
| | | |
| | | const textStyle = computed(() => ({ |
| | | width: `${props.size}px`, |
| | | height: `${props.size}px`, |
| | | fontSize: `${Math.floor(props.size * (avatarText.value.length > 1 ? 0.34 : 0.42))}px`, |
| | | background: textColor.value, |
| | | borderRadius: props.radius |
| | | })) |
| | | |
| | | /** è²å¡é¦åï¼ä¸æå 1 个åãè±æ / æä¸åå 2 ä¸ªåæ¯ */ |
| | | const avatarText = computed(() => getAvatarText(props.name)) |
| | | |
| | | /** è²å¡åºè²ï¼ææµç§° charCode åå¸åè°è²æ¿è² */ |
| | | const textColor = computed(() => getAvatarBgColor(props.name)) |
| | | |
| | | /** 头åç¹å»ï¼previewable èµ° el-image é¢è§ä¸å¼¹åçï¼å¦åæ user / id ä»»ä¸å
¥åæå¼åç */ |
| | | function handleClick(e: MouseEvent) { |
| | | if (props.previewable) { |
| | | return |
| | | } |
| | | if (!props.clickable) { |
| | | return |
| | | } |
| | | // æ
åµä¸ï¼æé¢ä¼ user ä¿¡æ¯ï¼å°±ç´æ¥ç¨ï¼ç䏿¬¡æ¥å£ |
| | | if (props.user) { |
| | | uiStore.openUserInfoCardAtEvent(props.user, e, props.addSource, props.addSourceExtra) |
| | | return |
| | | } |
| | | // æ
åµäºï¼æ é¢ä¼ user ä¿¡æ¯ï¼æå¼åçï¼ä¼ æå°å¿
è¦ä¿¡æ¯ï¼id + æµç§° + 头åï¼ï¼ä½ç½®å¨é¼ æ å³ä¾§ |
| | | const numId = Number(props.id) |
| | | if (!numId || numId <= 0) { |
| | | return |
| | | } |
| | | uiStore.openUserInfoCardAtEvent( |
| | | { |
| | | id: numId, |
| | | nickname: props.name, |
| | | avatar: props.url |
| | | }, |
| | | e, |
| | | props.addSource, |
| | | props.addSourceExtra |
| | | ) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | éç¨ç¨æ·å¤´åç»ä»¶ |
| | | - æ url æ¶å±ç¤ºå¾çï¼æ url æ¶å±ç¤ºè²å¡ + é¦åæ¯/é¦å |
| | | - ç¹å»é»è®¤è§¦å UserInfoCardï¼clickableï¼ |
| | | - previewable=true æ¶æ¹ä¸ºç¹å¤´åç´æ¥æ¾å¤§é¢è§ï¼ç¨äºåç / 详æ
页ç大头åä½ï¼ |
| | | --> |
| | | <div |
| | | class="relative inline-flex" |
| | | :style="{ cursor: clickable && !previewable ? 'pointer' : 'default' }" |
| | | v-bind="$attrs" |
| | | @click="handleClick" |
| | | > |
| | | <Image |
| | | v-if="url && previewable" |
| | | class="block overflow-hidden" |
| | | :src="url" |
| | | :preview="{ src: url, zIndex: previewZIndex }" |
| | | :style="imgStyle" |
| | | /> |
| | | <img |
| | | v-else-if="url" |
| | | class="block overflow-hidden object-cover" |
| | | :src="url" |
| | | :style="imgStyle" |
| | | loading="lazy" |
| | | :alt="name || 'avatar'" |
| | | /> |
| | | <div |
| | | v-else |
| | | class="flex items-center justify-center text-white font-medium select-none" |
| | | :style="textStyle" |
| | | > |
| | | {{ avatarText }} |
| | | </div> |
| | | <!-- å
许å¤é¨æå
¥è£
饰ï¼å¦ç¾¤èè§æ --> |
| | | <slot></slot> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, onMounted, onUnmounted } from 'vue' |
| | | import { useRouter } from 'vue-router' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { ImConversationType } from '../../../utils/constants' |
| | | import { getFriendDisplayName } from '../../../utils/user' |
| | | import { useConversationStore } from '../../store/conversationStore' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { useImUiStore } from '../../store/uiStore' |
| | | import UserInfo, { type UserInfoRelation } from './user-info.vue' |
| | | |
| | | defineOptions({ name: 'ImUserInfoCard' }) |
| | | |
| | | const uiStore = useImUiStore() |
| | | const conversationStore = useConversationStore() |
| | | const friendStore = useFriendStore() |
| | | const router = useRouter() |
| | | |
| | | const card = computed(() => uiStore.userInfoCard) |
| | | const user = computed(() => card.value.user) |
| | | |
| | | const isSelf = computed(() => { |
| | | const myId = getCurrentUserId() |
| | | return !!user.value?.id && user.value.id === myId |
| | | }) |
| | | const isActiveFriend = computed(() => { |
| | | if (!user.value?.id || isSelf.value) { |
| | | return false |
| | | } |
| | | return friendStore.isActiveFriend(user.value.id) |
| | | }) |
| | | const relation = computed<UserInfoRelation>(() => { |
| | | if (!user.value) { |
| | | return 'readonly' |
| | | } |
| | | if (isSelf.value) { |
| | | return 'self' |
| | | } |
| | | if (isActiveFriend.value) { |
| | | return 'friend' |
| | | } |
| | | return 'stranger' |
| | | }) |
| | | |
| | | const remark = computed(() => { |
| | | if (!isActiveFriend.value || !user.value?.id) { |
| | | return undefined |
| | | } |
| | | return friendStore.getFriend(user.value.id)?.displayName || '' |
| | | }) |
| | | |
| | | /** å
³éåçï¼ç¹å»é®ç½© / Esc / UserInfo æä¸æ¥çå 餿åäºä»¶ */ |
| | | function handleClose() { |
| | | uiStore.closeUserInfoCard() |
| | | } |
| | | |
| | | /** é®çäºä»¶ï¼Esc é®å
³éåç */ |
| | | function handleKeydown(e: KeyboardEvent) { |
| | | if (e.key === 'Escape' && card.value.show) { |
| | | handleClose() |
| | | } |
| | | } |
| | | |
| | | onMounted(() => window.addEventListener('keydown', handleKeydown)) |
| | | onUnmounted(() => window.removeEventListener('keydown', handleKeydown)) |
| | | |
| | | /** åæ¶æ¯ï¼UserInfo æä¸æ¥ç"åæ¶æ¯"ï¼ç±æ¬ç»ä»¶æ¥åå openConversation + è·³æ¶æ¯ Tab + å
³æµ®å± */ |
| | | function handleSendMessage() { |
| | | if (!user.value) { |
| | | return |
| | | } |
| | | // å friendStore éçææ°å¤æ³¨ / å
ææ°ï¼é¿å
æ°å»ºä¼è¯ç¨è¿ææ°æ® |
| | | const friend = friendStore.getFriend(user.value.id) |
| | | const conversationName = friend |
| | | ? getFriendDisplayName(friend) |
| | | : user.value.nickname || '' |
| | | conversationStore.openConversation( |
| | | user.value.id, |
| | | ImConversationType.PRIVATE, |
| | | conversationName, |
| | | user.value.avatar || '', |
| | | { silent: !!friend?.silent } |
| | | ) |
| | | // 跳转ä¼è¯ Tabï¼å¦æä¸å¨çè¯ï¼ï¼å¹¶ä¸ä¸ç®¡å½åè·¯ç±æ¯å¥é½å
åå°ä¼è¯å表页 |
| | | if (router.currentRoute.value.name !== 'ImHomeConversation') { |
| | | router.push({ name: 'ImHomeConversation' }) |
| | | } |
| | | uiStore.closeUserInfoCard() |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | ç¨æ·åçæµ®å± |
| | | - ä»
æ¿æ
"æµ®å±å®ä½ + å
³éé»è¾ï¼ç¹é®ç½© / Escï¼"ï¼åçè§è§èµ° <UserInfo>ï¼ä¸ contact 详æ
å
±ç¨ä¸ä»½ç»ä»¶ |
| | | - 触åï¼useImUiStore.openUserInfoCard(user, position)ï¼æ¬ç»ä»¶è®¢é
storeï¼å
¨å±åªæä¸ä»½å®ä¾ |
| | | - å
³ç³»æç± isSelf / isActiveFriend æ´¾ç relation prop éå° UserInfoï¼å é¤ / å 好å / 夿³¨è½åºé½å¨ UserInfo å
éç¯ |
| | | --> |
| | | <teleport to="body"> |
| | | <div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose"> |
| | | <div |
| | | class="fixed w-80 p-4 bg-[var(--ant-color-bg-elevated)] rounded-md shadow-xl" |
| | | :style="{ left: `${card.position.x }px`, top: `${card.position.y }px` }" |
| | | @click.stop |
| | | > |
| | | <UserInfo |
| | | :user="user" |
| | | :display-name="remark" |
| | | :relation="relation" |
| | | :preview-z-index="10000" |
| | | :add-source="card.addSource" |
| | | :add-source-extra="card.addSourceExtra" |
| | | @chat="handleSendMessage" |
| | | @deleted="handleClose" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </teleport> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { User } from '../../types' |
| | | |
| | | import { computed, h, nextTick, ref, watch } from 'vue' |
| | | |
| | | import { confirm } from '#/packages/effects/common-ui/src' |
| | | import { DICT_TYPE } from '#/packages/constants/src' |
| | | import { getDictLabel } from '#/packages/effects/hooks/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, Checkbox, Dropdown, Input, Menu, message } from 'ant-design-vue' |
| | | |
| | | import { getSimpleUser, type SystemUserApi } from '#/api/system/user' |
| | | import { formatDate } from '#/views/im/utils/time' |
| | | |
| | | import { ImFriendAddSource } from '../../../utils/constants' |
| | | import { toUserCardTarget } from '../../../utils/message' |
| | | import { getGenderColor, getGenderIcon } from '../../../utils/user' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { FriendAddDialog } from '../friend' |
| | | import RecommendCardDialog from './recommend-card-dialog.vue' |
| | | import UserAvatar from './user-avatar.vue' |
| | | |
| | | defineOptions({ name: 'ImUserInfo' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** å 好忥æºï¼é»è®¤ SEARCHï¼åè§ ImFriendAddSource */ |
| | | addSource?: number |
| | | /** æ¥æºé带信æ¯ï¼addSource=GROUPï¼ç¾¤èï¼æ¶ä¼ 群åï¼ç¨äºãææ¯ XX 群ç YYãé¢å¡«è¯æ¯ */ |
| | | addSourceExtra?: string |
| | | /** 夿³¨ï¼ä»
relation=friend æ¶ææï¼ç©ºä¸² â æ¾ç¤º"æ·»å 夿³¨"å ä½ */ |
| | | displayName?: string |
| | | /** UserAvatar é¢è§å± z-indexï¼æ¾å¨é« z-index æµ®å±ï¼å¦ UserInfoCardï¼ééæå¨æ¬é« */ |
| | | previewZIndex?: number |
| | | relation?: UserInfoRelation |
| | | /** èµ·æç¨æ·ï¼è³å°è¦æ idï¼æ§å« / é¨é¨ç±ç»ä»¶æ id ææåå¹¶è¡¥é½ */ |
| | | user: null | User |
| | | }>(), |
| | | { |
| | | addSource: ImFriendAddSource.SEARCH, |
| | | addSourceExtra: '', |
| | | displayName: '', |
| | | previewZIndex: 2000, |
| | | relation: 'readonly' |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** ç¨æ·ç¹"åæ¶æ¯"ï¼å¯¼èª / å
³æµ®å±çåºæ¯ç¸å
³å¨ä½ç±ç¶çº§æ¿æ
ï¼æ¯å¦ UserInfoCard è¦ closeï¼ */ |
| | | chat: [user: User] |
| | | /** å é¤è系人æååéç¥ç¶çº§ï¼confirm + è° store é½å¨æ¬ç»ä»¶å
åå®ï¼ï¼ç¶çº§å
³æµ®å± / æ¸
éä¸ç */ |
| | | deleted: [user: User] |
| | | /** 夿³¨è½åºæååéç¥ç¶çº§ï¼ç¨äºåæ¥ç¶çº§ææçæ¬å° FriendLite / Friend 坿¬ï¼å¦ contact 页ç selectionï¼ */ |
| | | saved: [value: string] |
| | | }>() |
| | | |
| | | /** |
| | | * å
³ç³»æï¼å³å®å¤æ³¨è¡ / å³ä¸ "..." èå / åºé¨å¨ä½åºçå
容 |
| | | * - friend: 夿³¨ï¼å¯ç¼è¾ï¼+ "..." å é¤èå + 3 徿 å¨ä½ |
| | | * - stranger: åæé®"å 为好å" |
| | | * - self: åæé®"ä¸è½åèªå·±è天" disabled |
| | | * - readonly: 䏿¸²æå¤æ³¨ / èå / å¨ä½åºï¼ä»
å±ç¤ºå¤´é¨ä¿¡æ¯ |
| | | */ |
| | | export type UserInfoRelation = 'friend' | 'readonly' | 'self' | 'stranger' |
| | | |
| | | const friendStore = useFriendStore() |
| | | |
| | | const full = ref<null | User>(props.user) // èµ·æ user + getSimpleUser åå¹¶åç宿´å¯¹è±¡ï¼æ§å« / é¨é¨è¡¥é½ç¨ï¼ |
| | | |
| | | /** 主æ é¢ï¼å¤æ³¨ä¼å
ï¼å¥½ååºæ¯ï¼ï¼å
¶æ¬¡åæµç§° */ |
| | | const headerName = computed(() => props.displayName || full.value?.nickname || '') |
| | | |
| | | const deptText = computed(() => full.value?.deptName || '-') |
| | | |
| | | const genderIcon = computed(() => getGenderIcon(full.value?.sex)) |
| | | const genderColor = computed(() => getGenderColor(full.value?.sex)) |
| | | |
| | | /** 好åå
³ç³»è®°å½ï¼æ¥æº / æ·»å æ¶é´ / æ¯å¦æé»ä»è¿éåï¼ä»
friend æä¸æææä¹ï¼ */ |
| | | const friendInfo = computed(() => |
| | | props.user?.id ? friendStore.getFriend(props.user.id) : undefined |
| | | ) |
| | | |
| | | /** æ¯å¦å·²æé»ï¼èå项ãå å
¥é»åå / ç§»åºé»ååãæè¿ä¸ªåæ¢ */ |
| | | const isBlocked = computed(() => !!friendInfo.value?.blocked) |
| | | |
| | | const editingRemark = ref(false) // 夿³¨å
èç¼è¾ï¼editingRemark æ§å¶è¾å
¥æï¼user 忢æ¶ç±ä¸é¢ç watch å¤ä½é¿å
èææ³æ¼ |
| | | const remarkInput = ref('') |
| | | const remarkInputRef = ref<null | { focus: () => void; select?: () => void }>(null) |
| | | |
| | | /** |
| | | * user.id ååçç»ä¸å¤çï¼ |
| | | * 1. èµ·æç¨ prop å
åºé¦å±ï¼full = props.userï¼ï¼å getSimpleUser å½ä¸ååå¹¶æ¿æ¢ |
| | | * 2. 顺便å¤ä½å¤æ³¨ç¼è¾æï¼é¿å
ä¸ä¸ä¸ªç¨æ·çèè¾å
¥æ³æ¼å°ä¸ä¸ä¸ª |
| | | * 3. ç«æç¨ id æ¯å¯¹ä¸¢å¼éæ§ååº |
| | | */ |
| | | watch( |
| | | () => props.user?.id, |
| | | async (id) => { |
| | | full.value = props.user |
| | | editingRemark.value = false |
| | | if (!id) { |
| | | return |
| | | } |
| | | const data = (await getSimpleUser(id)) as User |
| | | if (props.user?.id !== id) { |
| | | return |
| | | } |
| | | full.value = { ...props.user, ...data } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | /** 夿³¨è¡ç¹å»ï¼è¿ç¼è¾æ + æå½å夿³¨çè¿è¾å
¥æ¡ï¼ä¸ä¸å¸§æç¦ç¹ / å
¨éäº¤ç» el-input */ |
| | | async function handleRowClick() { |
| | | if (editingRemark.value) { |
| | | return |
| | | } |
| | | remarkInput.value = props.displayName || '' |
| | | editingRemark.value = true |
| | | await nextTick() |
| | | remarkInputRef.value?.focus() |
| | | remarkInputRef.value?.select?.() |
| | | } |
| | | |
| | | /** |
| | | * ä¿å夿³¨ï¼ |
| | | * 1. éå
¥ä¿æ¤ââblur + Enter åæ¶è§¦ååªèµ°ä¸æ¬¡ï¼ç¼è¾æå
å¤ä½ï¼ |
| | | * 2. æ ååè·³è¿å端è°ç¨ + 䏿 savedï¼é¿å
ç¶çº§è¯¯åæ¥ |
| | | * 3. è½åºæå / 失败é½å¨ç»ä»¶å
èªéï¼æåæ saved 让ç¶çº§åæ¥æ¬å°å¯æ¬ï¼å¤±è´¥çç¼è¾æå¤çå±ç¤ºå¼ä¸å¨ |
| | | */ |
| | | async function saveRemark() { |
| | | if (!editingRemark.value) { |
| | | return |
| | | } |
| | | const userId = props.user?.id |
| | | if (!userId) { |
| | | editingRemark.value = false |
| | | return |
| | | } |
| | | const next = remarkInput.value.trim() |
| | | editingRemark.value = false |
| | | if (next === (props.displayName || '')) { |
| | | return |
| | | } |
| | | await friendStore.setFriendDisplayName(userId, next) |
| | | message.success('å·²æ´æ°å¤æ³¨') |
| | | emit('saved', next) |
| | | } |
| | | |
| | | function cancelEditRemark() { |
| | | editingRemark.value = false |
| | | } |
| | | |
| | | /** åæ¶æ¯ï¼å¯¼èª / å
³æµ®å±è¿äº"ä¸å¡ä¾§"å¨ä½äº¤ç»ç¶çº§ï¼æ¬ç»ä»¶åªè´è´£éç¥ */ |
| | | function handleChat() { |
| | | if (!props.user) { |
| | | return |
| | | } |
| | | emit('chat', props.user) |
| | | } |
| | | |
| | | /** å ä½æç¤ºï¼è¯é³ / è§é¢è天è½åå°æªæ¥å
¥ï¼å
以"å¼åä¸"å好æç¤º */ |
| | | function handleComingSoon(featureName: string) { |
| | | message.info(`${featureName} åè½å¼åä¸`) |
| | | } |
| | | |
| | | // ==================== æ·»å 好å / å é¤å¥½å ==================== |
| | | |
| | | const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // å 好åå¼¹çª refï¼handleAddFriend è° open({ presetUser, addSource, addSourceExtra }) 触å |
| | | |
| | | const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // æ¨èåçå¼¹çª refï¼handleRecommend è°ç¨ open({ target }) æå¼ |
| | | /** æä»æ¨èç»æåï¼å¼¹ RecommendCardDialog éç®æ ä¼è¯ */ |
| | | function handleRecommend() { |
| | | if (!props.user?.id) { |
| | | return |
| | | } |
| | | const target = toUserCardTarget(full.value) |
| | | if (!target) { |
| | | return |
| | | } |
| | | recommendDialogRef.value?.open({ target }) |
| | | } |
| | | |
| | | /** å 为好åï¼å¼¹ FriendAddDialogï¼å¸¦é¢å¡«ç¨æ·ï¼ï¼è®©ç¨æ·å¡«ç³è¯·çç± + 夿³¨åååç³è¯· */ |
| | | function handleAddFriend() { |
| | | if (!props.user?.id) { |
| | | return |
| | | } |
| | | const presetUser: SystemUserApi.UserSimple = { |
| | | id: props.user.id, |
| | | nickname: props.user.nickname, |
| | | avatar: props.user.avatar, |
| | | sex: props.user.sex, |
| | | deptId: props.user.deptId, |
| | | deptName: props.user.deptName |
| | | } as SystemUserApi.UserSimple |
| | | friendAddDialogRef.value?.open({ |
| | | presetUser, |
| | | addSource: props.addSource, |
| | | addSourceExtra: props.addSourceExtra |
| | | }) |
| | | } |
| | | |
| | | /** å å
¥é»ååï¼confirm â friendStore.blockFriendï¼å端 FRIEND_BLOCK æ¨å°æ¶ç± dispatcher 忥å¤ç«¯ */ |
| | | async function handleBlock() { |
| | | if (!props.user?.id) { |
| | | return |
| | | } |
| | | const target = props.user |
| | | try { |
| | | await confirm(`ç¡®å®å°ã${target.nickname || ''}ãå å
¥é»åååï¼`, 'å å
¥é»åå') |
| | | } catch { |
| | | return |
| | | } |
| | | await friendStore.blockFriend(target.id) |
| | | message.success('å·²å å
¥é»åå') |
| | | } |
| | | |
| | | /** ç§»åºé»ååï¼æä½æ¸©åä¸å¼¹ confirmï¼å端 FRIEND_UNBLOCK æ¨å°æ¶ç± dispatcher 忥å¤ç«¯ */ |
| | | async function handleUnblock() { |
| | | if (!props.user?.id) { |
| | | return |
| | | } |
| | | await friendStore.unblockFriend(props.user.id) |
| | | message.success('已移åºé»åå') |
| | | } |
| | | |
| | | /** å é¤è系人ï¼å¼¹èªå®ä¹ç¡®è®¤ï¼å«ãåæ¶æ¸
空è天记å½ãé项ï¼â friendStore.deleteFriend â éç¥ç¶çº§å
³æµ®å± / æ¸
éä¸ */ |
| | | async function handleDeleteFriend() { |
| | | if (!props.user?.id) { |
| | | return |
| | | } |
| | | const target = props.user |
| | | const clearConversation = ref(true) |
| | | try { |
| | | await confirm({ |
| | | cancelText: 'åæ¶', |
| | | confirmText: 'å é¤', |
| | | content: h('div', { class: 'flex flex-col gap-3 text-sm' }, [ |
| | | h('div', `ç¡®å®å é¤å¥½åã${target.nickname || ''}ã?`), |
| | | h( |
| | | Checkbox, |
| | | { |
| | | checked: clearConversation.value, |
| | | 'onUpdate:checked': (value: boolean) => { |
| | | clearConversation.value = value |
| | | } |
| | | }, |
| | | () => 'åæ¶æ¸
空è天记å½' |
| | | ) |
| | | ]), |
| | | icon: 'warning', |
| | | title: 'å é¤è系人' |
| | | }) |
| | | } catch { |
| | | return |
| | | } |
| | | await friendStore.deleteFriend(target.id, clearConversation.value) |
| | | message.success('å·²å é¤å¥½å') |
| | | emit('deleted', target) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | ç¨æ·åçèªå
å«ç»ä»¶ï¼æµ®å± / è¡å
éç¨ï¼ |
| | | - ç± UserInfoCardï¼æµ®å±ï¼å contact/index.vueï¼è¡å
ï¼å
±ç¨ï¼UserInfoCard æå®æ¾è¿ teleport æµ®å±ï¼contact ç´æ¥ mount å°å³æ |
| | | - å
³ç³»æç± relation prop å³å®ï¼friend / stranger / self / readonlyï¼ï¼å¯¹åºå³ä¸ "..." èå + åºé¨å¨ä½åºä¸¤åé½å
å卿¬ç»ä»¶ |
| | | - 夿³¨ / å é¤è系人 / å 为好åç store æä½é½å¨æ¬ç»ä»¶å
éç¯ï¼ç¶çº§ä»
çå¬ chat / deleted / saved çéç¥åå¯¼èª / å
³æµ®å± / 忥坿¬ |
| | | --> |
| | | <div> |
| | | <!-- é¡¶é¨ï¼å¤´åï¼å·¦ï¼ + åå + æ§å«å¾æ + æµç§° / é¨é¨ï¼å³ï¼ + å³ä¸ "..." èåï¼ä»
friend æï¼ --> |
| | | <div class="flex gap-3 items-start"> |
| | | <UserAvatar |
| | | :id="full?.id" |
| | | :url="full?.avatar" |
| | | :name="full?.nickname" |
| | | :size="56" |
| | | :clickable="false" |
| | | previewable |
| | | :preview-z-index="previewZIndex" |
| | | /> |
| | | <div class="flex-1 min-w-0"> |
| | | <div class="flex items-center gap-1.5"> |
| | | <span |
| | | class="text-lg font-semibold leading-snug truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ headerName }} |
| | | </span> |
| | | <!-- æ§å«å°å¾æ ï¼ç·èã女ç²ï¼æªç¥ / 0 ä¸å±ç¤ºï¼å¯¹é½å¾®ä¿¡çç½åæ³ --> |
| | | <Icon |
| | | v-if="genderIcon" |
| | | :icon="genderIcon" |
| | | :size="16" |
| | | :color="genderColor" |
| | | class="flex-shrink-0" |
| | | /> |
| | | </div> |
| | | <div class="mt-2 space-y-1 text-13px text-[var(--ant-color-text-secondary)]"> |
| | | <!-- ä»
å½å¤æ³¨å·²è®¾æ¶å±ç¤ºæµç§°å¯è¡ï¼æªè®¾ç½®æ¶ä¸»æ é¢å°±æ¯ nicknameï¼é¿å
éå¤ --> |
| | | <div v-if="displayName" class="truncate">æµç§°ï¼{{ full?.nickname }}</div> |
| | | <div class="truncate">é¨é¨ï¼{{ deptText }}</div> |
| | | </div> |
| | | </div> |
| | | <!-- å³ä¸ "..." èåï¼ä»
friend æå±ç¤ºï¼å«ãå å
¥/ç§»åºé»ååã + ãå é¤è系人ã --> |
| | | <div v-if="relation === 'friend'" class="flex-shrink-0"> |
| | | <Dropdown :trigger="['click']" placement="bottomRight" overlay-class-name="im-user-info__more-menu"> |
| | | <div |
| | | class="flex items-center justify-center w-7 h-7 rounded cursor-pointer hover:bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <Icon icon="ep:more-filled" :size="18" class="text-[var(--ant-color-text-secondary)]" /> |
| | | </div> |
| | | <template #overlay> |
| | | <Menu> |
| | | <!-- æä»æ¨èç»æåï¼ä»¥ä¸ªäººåçæ¶æ¯å½¢å¼åå°éä¸çä¼è¯ --> |
| | | <Menu.Item @click="handleRecommend">æä»æ¨èç»æå</Menu.Item> |
| | | <!-- æé» / ç§»åºé»ååï¼æ friendInfo.blocked åæ¢ææ¡ --> |
| | | <Menu.Item v-if="!isBlocked" divided @click="handleBlock"> |
| | | å å
¥é»åå |
| | | </Menu.Item> |
| | | <Menu.Item v-else divided @click="handleUnblock">ç§»åºé»åå</Menu.Item> |
| | | <Menu.Item divided @click="handleDeleteFriend"> |
| | | <span class="text-[var(--ant-color-error)]">å é¤è系人</span> |
| | | </Menu.Item> |
| | | </Menu> |
| | | </template> |
| | | </Dropdown> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 夿³¨è¡ï¼ä»
friend æå±ç¤ºï¼æªç¼è¾æ¶æ´è¡å¯ç¹ï¼ç¼è¾æç¨ el-input è¡å
æ¿æ¢ value å --> |
| | | <template v-if="relation === 'friend'"> |
| | | <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div> |
| | | <div |
| | | class="flex gap-5 items-center px-1.5 py-1.5 text-sm" |
| | | :class=" |
| | | !editingRemark |
| | | ? 'group cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill-tertiary)]' |
| | | : '' |
| | | " |
| | | @click="handleRowClick" |
| | | > |
| | | <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">夿³¨</span> |
| | | <Input |
| | | v-if="editingRemark" |
| | | ref="remarkInputRef" |
| | | v-model:value="remarkInput" |
| | | size="small" |
| | | :maxlength="20" |
| | | placeholder="æ·»å 夿³¨" |
| | | class="flex-1" |
| | | @click.stop |
| | | @blur="saveRemark" |
| | | @keyup.enter="saveRemark" |
| | | @keyup.esc="cancelEditRemark" |
| | | /> |
| | | <template v-else> |
| | | <span |
| | | class="flex-1 min-w-0 truncate" |
| | | :class=" |
| | | displayName |
| | | ? 'text-[var(--ant-color-text)]' |
| | | : 'text-[var(--ant-color-text-placeholder)]' |
| | | " |
| | | > |
| | | {{ displayName || 'æ·»å 夿³¨' }} |
| | | </span> |
| | | <!-- é»è®¤ opacity:0 å ä½é¿å
hover æ¶å
¶å®åä½ç§»ï¼ä»
ç¶è¡ hover æ¶æµ®ç° --> |
| | | <Icon |
| | | icon="ant-design:edit-outlined" |
| | | :size="14" |
| | | class="flex-shrink-0 text-[var(--ant-color-text-secondary)] opacity-0 group-hover:opacity-100 transition-opacity" |
| | | /> |
| | | </template> |
| | | </div> |
| | | |
| | | <!-- ==================== æ´å¤ä¿¡æ¯ï¼æ¥æº / æ·»å æ¶é´ï¼å¯¹é½å¾®ä¿¡ãæåèµæãåå ==================== --> |
| | | <template v-if="friendInfo?.addSource || friendInfo?.addTime"> |
| | | <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div> |
| | | <div v-if="friendInfo?.addSource" class="flex gap-5 items-center px-1.5 py-1.5 text-sm"> |
| | | <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">æ¥æº</span> |
| | | <span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]"> |
| | | {{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, friendInfo.addSource) }} |
| | | </span> |
| | | </div> |
| | | <div v-if="friendInfo?.addTime" class="flex gap-5 items-center px-1.5 py-1.5 text-sm"> |
| | | <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">æ·»å æ¶é´</span> |
| | | <span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]"> |
| | | {{ formatDate(new Date(friendInfo.addTime), 'YYYY-MM-DD') }} |
| | | </span> |
| | | </div> |
| | | </template> |
| | | </template> |
| | | |
| | | <!-- å¨ä½åºï¼å¥½å = 3 徿 ï¼éç人 = å 为好åæé®ï¼èªå·± = disabledï¼readonly 䏿¸²æ --> |
| | | <template v-if="relation !== 'readonly'"> |
| | | <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div> |
| | | <div v-if="relation === 'friend'" class="flex justify-around"> |
| | | <!-- ä¸è¿å¾æ æé®ï¼å¾ä¸åä¸ã主è²ä½ä¸ºå¯ç¹æç¤ºï¼hover éä¸éæåº¦ååé¦ --> |
| | | <div |
| | | class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75" |
| | | @click="handleChat" |
| | | > |
| | | <Icon icon="ant-design:message-outlined" :size="22" /> |
| | | <span>åæ¶æ¯</span> |
| | | </div> |
| | | <div |
| | | class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75" |
| | | @click="handleComingSoon('è¯é³è天')" |
| | | > |
| | | <Icon icon="ant-design:phone-outlined" :size="22" /> |
| | | <span>è¯é³è天</span> |
| | | </div> |
| | | <div |
| | | class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75" |
| | | @click="handleComingSoon('è§é¢è天')" |
| | | > |
| | | <Icon icon="ant-design:video-camera-outlined" :size="22" /> |
| | | <span>è§é¢è天</span> |
| | | </div> |
| | | </div> |
| | | <div v-else-if="relation === 'self'" class="flex justify-center"> |
| | | <Button type="primary" disabled>ä¸è½åèªå·±è天</Button> |
| | | </div> |
| | | <div v-else class="flex justify-center"> |
| | | <Button type="primary" @click="handleAddFriend">å 为好å</Button> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- å 好åå¼¹çªï¼æºå¸¦é¢å¡«ç¨æ·è·³è¿æç´¢æ¥éª¤ï¼ç´æ¥è¿ç³è¯·è¡¨åï¼çç±æ addSource åºåè¯æ¯ï¼ --> |
| | | <FriendAddDialog ref="friendAddDialogRef" /> |
| | | |
| | | <!-- æä»æ¨èç»æåå¼¹çªï¼ä»
friend æä¸åºç°å
¥å£ --> |
| | | <RecommendCardDialog ref="recommendDialogRef" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style> |
| | | /* |
| | | é scopedï¼el-dropdown ç䏿èåèµ° teleport å° bodyï¼scoped éä¸å°ã |
| | | UserInfoCard æµ®å±ç¨ z-9998ï¼è¦æè¿åæ¬å°æ´é«ï¼é»è®¤ --ant-z-index-popup-base ~2050 ä¼è¢«é®ç½©åä½ï¼ã |
| | | */ |
| | | .im-user-info__more-menu { |
| | | z-index: 10000 !important; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { FriendLite } from '../types' |
| | | |
| | | import { computed, type ComputedRef, type Ref } from 'vue' |
| | | |
| | | /** 忝忡¶ç»æï¼letter å 'A'-'Z' æå
åº '#'ï¼list æ¡¶å
ææ¼é³ / ååèªç¶åº */ |
| | | export interface FriendBucket { |
| | | letter: string |
| | | list: FriendLite[] |
| | | } |
| | | |
| | | /** ååæ¡¶ / æåºé®ï¼å¤æ³¨æ¼é³ä¼å
â æµç§°æ¼é³ â ååæ¬èº«ï¼å
åºè±æ / æ°åï¼ */ |
| | | function getSortKey(friend: FriendLite): string { |
| | | return ( |
| | | friend.displayNamePinyin || |
| | | friend.nicknamePinyin || |
| | | (friend.displayName || friend.nickname || '').toLowerCase() |
| | | ) |
| | | } |
| | | |
| | | /** ååæ¡¶åæ¯ï¼æ¼é³é¦åæ¯å¤§åï¼é忝ï¼çº¯ç¬¦å· / æ°å / 䏿ï¼å
åº '#' */ |
| | | function getBucketLetter(friend: FriendLite): string { |
| | | const first = getSortKey(friend).charAt(0) |
| | | return /^[a-zA-Z]$/.test(first) ? first.toUpperCase() : '#' |
| | | } |
| | | |
| | | /** æ¼é³é¦åæ¯æ¼æ¥ï¼ãlao zhangãâ 'lz'ï¼æ¯æãè¾ lz æèå¼ ã */ |
| | | function pinyinInitials(pinyin?: string): string { |
| | | if (!pinyin) { |
| | | return '' |
| | | } |
| | | return pinyin |
| | | .split(' ') |
| | | .map((word) => word.charAt(0)) |
| | | .join('') |
| | | } |
| | | |
| | | /** |
| | | * 好åå表çæç´¢ + 忝忡¶ |
| | | * |
| | | * - æç´¢å¹é
ï¼å¤æ³¨ / æµç§° / å
¨æ¼ï¼å»ç©ºæ ¼ï¼/ é¦åæ¯ä»»ä¸å½ä¸ |
| | | * - åæ¡¶è§åï¼A-Z + '#' å
åºï¼æ¡¶å
æ getSortKey èªç¶åº |
| | | * |
| | | * é讯å½é¡µ FriendList ä¸éæ©ç±»å¼¹çª FriendPickerPanel å
±ç¨ï¼é¿å
è§ååèªå®ç°èµ°å |
| | | */ |
| | | export function useFriendBuckets( |
| | | friends: ComputedRef<FriendLite[]> | Ref<FriendLite[]>, |
| | | keyword: Ref<string> |
| | | ): { |
| | | buckets: ComputedRef<FriendBucket[]> |
| | | filtered: ComputedRef<FriendLite[]> |
| | | } { |
| | | const filtered = computed(() => { |
| | | const keywordLower = keyword.value.trim().toLowerCase() |
| | | if (!keywordLower) { |
| | | return friends.value |
| | | } |
| | | return friends.value.filter((friend) => { |
| | | const nicknamePinyin = friend.nicknamePinyin || '' |
| | | const displayNamePinyin = friend.displayNamePinyin || '' |
| | | // å
¨æ¼æç´¢å»æç©ºæ ¼ï¼è®©ãlaozhangãä¹è½å½ä¸ãlao zhangã |
| | | return ( |
| | | (friend.nickname || '').toLowerCase().includes(keywordLower) || |
| | | (friend.displayName || '').toLowerCase().includes(keywordLower) || |
| | | nicknamePinyin.replaceAll(/\s/g, '').includes(keywordLower) || |
| | | displayNamePinyin.replaceAll(/\s/g, '').includes(keywordLower) || |
| | | pinyinInitials(nicknamePinyin).includes(keywordLower) || |
| | | pinyinInitials(displayNamePinyin).includes(keywordLower) |
| | | ) |
| | | }) |
| | | }) |
| | | |
| | | const buckets = computed<FriendBucket[]>(() => { |
| | | const map = new Map<string, FriendLite[]>() |
| | | for (const friend of filtered.value) { |
| | | const letter = getBucketLetter(friend) |
| | | const bucket = map.get(letter) ?? [] |
| | | bucket.push(friend) |
| | | map.set(letter, bucket) |
| | | } |
| | | const letters = [...map.keys()].toSorted((a, b) => { |
| | | // '#' æ°¸è¿ææ«å°¾ï¼A-Z èµ° localeCompare |
| | | if (a === '#') { |
| | | return 1 |
| | | } |
| | | if (b === '#') { |
| | | return -1 |
| | | } |
| | | return a.localeCompare(b) |
| | | }) |
| | | return letters.map((letter) => ({ |
| | | letter, |
| | | list: (map.get(letter) ?? []).toSorted((a, b) => |
| | | getSortKey(a).localeCompare(getSortKey(b)) |
| | | ) |
| | | })) |
| | | }) |
| | | |
| | | return { filtered, buckets } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { computed, type ComputedRef, type Ref } from 'vue' |
| | | |
| | | import { ImConversationType } from '../../utils/constants' |
| | | import { getSenderAvatar, getSenderDisplayName } from '../../utils/user' |
| | | import { useRtcStore } from '../store/rtcStore' |
| | | |
| | | /** 群éè¯æåè§å¾æ¨¡åï¼å·²å å
¥ + æ¥å
¥ä¸ï¼pending 头å UI åéæï¼joined ä¸éæ */ |
| | | export interface GroupCallMember { |
| | | userId: number |
| | | nickname: string |
| | | avatar?: string |
| | | pending: boolean |
| | | } |
| | | |
| | | /** |
| | | * 群éè¯æåå表 computedï¼joined å¨åï¼æª joined ç invitee å¨åï¼ |
| | | * ç¼åä¸ºç©ºï¼æ¥çµé¦å±æ¸²ææ©äº syncGroupActiveCall ååï¼æ¶ç¨ fallback 主å«å
åºï¼é¿å
ç©ºç½ |
| | | * |
| | | * @param groupId 群ç¼å· |
| | | * @param fallbackInviterId å
åºä¸»å«ï¼ç¼å为空æ¶å¡«ä¸ä¸ªå¤´åï¼æ 记为已å å
¥èé pending |
| | | */ |
| | | export function useGroupCallMembers( |
| | | groupId: Ref<number | undefined>, |
| | | fallbackInviterId?: Ref<number | undefined> |
| | | ): ComputedRef<GroupCallMember[]> { |
| | | const rtcStore = useRtcStore() |
| | | return computed(() => { |
| | | const gid = groupId.value |
| | | if (!gid) { |
| | | return [] |
| | | } |
| | | const groupCall = rtcStore.getGroupCall(gid) |
| | | const joinedIds = groupCall?.joinedUserIds ?? [] |
| | | const inviteeIds = groupCall?.inviteeIds ?? [] |
| | | const joinedSet = new Set(joinedIds) |
| | | const orderedIds = [...joinedIds, ...inviteeIds.filter((id) => !joinedSet.has(id))] |
| | | if (orderedIds.length > 0) { |
| | | return orderedIds.map((userId) => toVM(userId, gid, !joinedSet.has(userId))) |
| | | } |
| | | const fallback = fallbackInviterId?.value |
| | | return fallback ? [toVM(fallback, gid, false)] : [] |
| | | }) |
| | | } |
| | | |
| | | /** æ userId ç¿»è¯æè§å¾æ¨¡åï¼ç»ä¸èµ° user.ts helper è§£ææµç§° / 头å */ |
| | | function toVM(userId: number, groupId: number, pending: boolean): GroupCallMember { |
| | | return { |
| | | userId, |
| | | nickname: getSenderDisplayName(userId, ImConversationType.GROUP, groupId), |
| | | avatar: getSenderAvatar(userId, ImConversationType.GROUP, groupId) || undefined, |
| | | pending |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { computed, ref, shallowRef } from 'vue' |
| | | |
| | | import { |
| | | ConnectionQuality, |
| | | type LocalParticipant, |
| | | type Participant, |
| | | type RemoteParticipant, |
| | | Room, |
| | | RoomEvent, |
| | | Track, |
| | | VideoPresets |
| | | } from 'livekit-client' |
| | | |
| | | type ParticipantEventHandler = (userId: number) => void |
| | | |
| | | /** LiveKit Room è¿æ¥ / è®¾å¤ / äºä»¶çèå°è£
ï¼UI ç»ä»¶åªå
³å¿ååºå¼ç¶æ */ |
| | | export function useLiveKitRoom() { |
| | | /** Room å®ä¾ï¼æ¨¡åå
é¨ç¶æï¼ä¸å¯¹å¤æ´é²ï¼é¿å
è°ç¨æ¹è¯¯å */ |
| | | const _room = shallowRef<null | Room>(null) |
| | | /** åªè¯» room å¼ç¨ï¼è°ç¨æ¹ä»
ç¨äºå¹çå¤å® */ |
| | | const room = computed(() => _room.value) |
| | | /** æ¬å°åä¸è
ï¼è¿æ¥æååèµå¼ */ |
| | | const localParticipant = shallowRef<LocalParticipant | null>(null) |
| | | /** è¿ç«¯åä¸è
å表ï¼ParticipantConnected / Disconnected æ¶å·æ°ï¼shallowRef é¿å
Vue 深度代ç SDK class å
é¨ */ |
| | | const remoteParticipants = shallowRef<RemoteParticipant[]>([]) |
| | | /** è¿æ¥ç¶æ */ |
| | | const isConnected = ref(false) |
| | | /** è¿æ¥è´¨é */ |
| | | const connectionQuality = ref<ConnectionQuality>(ConnectionQuality.Unknown) |
| | | /** 麦å
é£å¼å
³ */ |
| | | const micEnabled = ref(true) |
| | | /** æå头å¼å
³ */ |
| | | const cameraEnabled = ref(false) |
| | | /** æ¬å£°å¨å¼å
³ï¼æµè§å¨æ ç³»ç»çº§ APIï¼éè¿ audio å
ç´ muted 屿§å®ç°è¿ç«¯é³é¢éé³ */ |
| | | const speakerEnabled = ref(true) |
| | | /** å±å¹å
±äº«å¼å
³ */ |
| | | const screenShareEnabled = ref(false) |
| | | /** å½åæ¯å¦å¤äºãéè¿ä¸ãï¼ç¬ææ¶ UI æ¾ç¤ºæç¤ºèä¸å¼ºå¶ç»æéè¯ */ |
| | | const reconnecting = ref(false) |
| | | /** è¿ç«¯æå¼è®¢é
è
ï¼éè¯ç»ææ¶ç»ä¸æ¸
空 */ |
| | | const disconnectedHandlers = new Set<() => void>() |
| | | /** æ¿å
æäººå å
¥è®¢é
è
ï¼ä¸»å«ç«¯ç¨äºä» INVITING åå° RUNNING */ |
| | | const participantConnectedHandlers = new Set<ParticipantEventHandler>() |
| | | /** æ¿å
æäººç¦»å¼è®¢é
è
ï¼ç¨äºæ userId æ 记为ãå·²éåºãä» pending å ä½ä¸ç§»é¤ */ |
| | | const participantDisconnectedHandlers = new Set<ParticipantEventHandler>() |
| | | |
| | | /** 忥è¿ç«¯åä¸è
å表å°ååºå¼æ°ç» */ |
| | | function syncRemotes(r: Room) { |
| | | remoteParticipants.value = [...r.remoteParticipants.values()] |
| | | } |
| | | |
| | | /** è¿æ¥ LiveKit Serverï¼audio / video æ§å¶åå§é»è®¤å¼å
³ */ |
| | | async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) { |
| | | // æ°è¿æ¥åå
æå¼æ§ Roomï¼ä¿çæ¬æ¬¡æ³¨åçäºä»¶åè° |
| | | if (_room.value) { |
| | | await disconnectRoom(false) |
| | | } |
| | | const r = new Room({ |
| | | // ææ ¼å尺寸èªå¨é simulcast å± |
| | | adaptiveStream: true, |
| | | // æªè®¢é
çå±å¨æååï¼èçä¸è¡ |
| | | dynacast: true, |
| | | // ééå辨ç 720pï¼ç¡®ä¿å¤§æ ¼åæ¸
æ° |
| | | videoCaptureDefaults: { |
| | | resolution: VideoPresets.h720.resolution |
| | | }, |
| | | // åå¸ç¼ç ä¸é 1.5 Mbps / 30fpsï¼ä¿çé»è®¤ simulcast ä¸å±ï¼180p / 360p / 720pï¼ |
| | | publishDefaults: { |
| | | videoEncoding: { |
| | | maxBitrate: 1_500_000, |
| | | maxFramerate: 30, |
| | | priority: 'high' |
| | | }, |
| | | // å±å¹å
±äº«ç ç 3 Mbpsï¼æåç颿¸
æ° |
| | | screenShareEncoding: { |
| | | maxBitrate: 3_000_000, |
| | | maxFramerate: 15, |
| | | priority: 'medium' |
| | | } |
| | | } |
| | | }) |
| | | _room.value = r |
| | | |
| | | r.on(RoomEvent.ParticipantConnected, (rp) => { |
| | | syncRemotes(r) |
| | | const userId = parseUserId(rp.identity) |
| | | if (userId != null) { |
| | | participantConnectedHandlers.forEach((cb) => cb(userId)) |
| | | } |
| | | }) |
| | | .on(RoomEvent.ParticipantDisconnected, (rp) => { |
| | | syncRemotes(r) |
| | | // 离å¼çåä¸è
ç¼åæ¸
æï¼é¿å
䏿¬¡å sid éè¿å½ä¸å¤±æå¼ç¨ |
| | | for (const key of streamCache.keys()) { |
| | | if (key.startsWith(`${rp.sid}:`)) { |
| | | streamCache.delete(key) |
| | | } |
| | | } |
| | | const userId = parseUserId(rp.identity) |
| | | if (userId != null) { |
| | | participantDisconnectedHandlers.forEach((cb) => cb(userId)) |
| | | } |
| | | }) |
| | | .on(RoomEvent.TrackSubscribed, () => syncRemotes(r)) |
| | | .on(RoomEvent.TrackUnsubscribed, () => syncRemotes(r)) |
| | | // mute / unmute 让 pickStream ç isMuted çè·¯éç®ï¼video å
ç´ è½è§£ç» srcObject è䏿¯å¡æåä¸å¸§ |
| | | .on(RoomEvent.TrackMuted, () => syncRemotes(r)) |
| | | .on(RoomEvent.TrackUnmuted, () => syncRemotes(r)) |
| | | .on(RoomEvent.ConnectionQualityChanged, (quality) => { |
| | | connectionQuality.value = quality |
| | | }) |
| | | // ç¬æ â æ¾ç¤ºãéè¿ä¸ãï¼ä¸å
³éè¯çªï¼ç± SDK å
é¨éè¿æºå¶æ¢å¤ |
| | | .on(RoomEvent.Reconnecting, () => { |
| | | reconnecting.value = true |
| | | }) |
| | | .on(RoomEvent.Reconnected, () => { |
| | | reconnecting.value = false |
| | | }) |
| | | // éè¿å¤±è´¥ / 䏻卿 / 被踢æ¶è§¦åæ¸
ç |
| | | .on(RoomEvent.Disconnected, () => { |
| | | isConnected.value = false |
| | | reconnecting.value = false |
| | | disconnectedHandlers.forEach((cb) => cb()) |
| | | }) |
| | | |
| | | // é¢ç getUserMedia ä¸ WebSocket æ¡æå¹¶è¡ï¼ç 100ï½300ms 串è¡å»¶è¿ï¼ |
| | | // æ¿å°ç stream ä»
ç¨äºè§¦åæéå¼¹çª + 设å¤å°±ç»ªï¼æ¡æå®æåç± LiveKit å
é¨éæ°è¯·æ±è®¾å¤åå¸è½¨ |
| | | const warmup = prewarmMedia(opts) |
| | | // å»ºç« WebSocket 信令 + WebRTC åªä½ééï¼å®æå localParticipant å¯ç¨ï¼å·²å¨æ¿åä¸è
ä¼éè¿ ParticipantConnected äºä»¶æ¹éæ¨é |
| | | await r.connect(url, token) |
| | | // æé´è¢«å¤é¨ disconnect æ¿æ¢ï¼ä¸æ¢åç» publishï¼é¿å
æåå¤´è¢«éæ°å¯ç¨ |
| | | if (_room.value !== r) { |
| | | return |
| | | } |
| | | localParticipant.value = r.localParticipant |
| | | isConnected.value = true |
| | | |
| | | // é¢çç»æä¸ç´æ¥åå¸ï¼é¿å
SDK ä¸å¤é¨ track çå½å¨æçº ç¼ ï¼ï¼ä»
çå¾
æé就绪ååèµ°æ å setXxxEnabled |
| | | await warmup |
| | | if (_room.value !== r) { |
| | | return |
| | | } |
| | | // 麦å
é£ä¸æå头æéç¸äºç¬ç«ï¼å¹¶è¡å¯ç¨åå¸ |
| | | const inits: Promise<unknown>[] = [] |
| | | if (opts.audio) { |
| | | inits.push(r.localParticipant.setMicrophoneEnabled(true)) |
| | | } |
| | | if (opts.video) { |
| | | inits.push(r.localParticipant.setCameraEnabled(true)) |
| | | } |
| | | if (inits.length > 0) { |
| | | await Promise.all(inits) |
| | | } |
| | | micEnabled.value = !!opts.audio |
| | | cameraEnabled.value = !!opts.video |
| | | |
| | | // å
åºåæ¥ä¸æ¬¡è¿ç«¯å表ï¼r.connect æé´ ParticipantConnected äºä»¶å¯è½å¨ handler ç»å®å触å被åï¼å¯¼è´é¦å±æ¼äºº |
| | | syncRemotes(r) |
| | | } |
| | | |
| | | /** æå触åæéå¼¹çª + 设å¤å¤èµ·ï¼ä¸²è¡å»¶è¿å¨ r.connect æé´ä¸èµ·è·ï¼å¤±è´¥éé»ï¼è¿æ¥åä¼åè¯ä¸æ¬¡ï¼ */ |
| | | async function prewarmMedia(opts: { audio?: boolean; video?: boolean }): Promise<void> { |
| | | if (!opts.audio && !opts.video) { |
| | | return |
| | | } |
| | | try { |
| | | const stream = await navigator.mediaDevices.getUserMedia({ |
| | | audio: !!opts.audio, |
| | | video: !!opts.video |
| | | }) |
| | | // æ¿å°æéå³å¯ï¼ç«å³åæææ track éæ¾è®¾å¤ï¼æ£å¼åå¸èµ° SDK æµç¨éæ°è¯·æ± |
| | | stream.getTracks().forEach((t) => t.stop()) |
| | | } catch { |
| | | // ç¨æ·æç» / 设å¤å ç¨çå¼å¸¸ï¼äº¤ç»åç» setXxxEnabled 忬¡å°è¯æ¥é |
| | | } |
| | | } |
| | | |
| | | /** å麦å
é£ */ |
| | | async function setMicEnabled(enabled: boolean) { |
| | | if (!_room.value) { |
| | | return |
| | | } |
| | | await _room.value.localParticipant.setMicrophoneEnabled(enabled) |
| | | micEnabled.value = enabled |
| | | } |
| | | |
| | | /** åæå头 */ |
| | | async function setCameraEnabled(enabled: boolean) { |
| | | if (!_room.value) { |
| | | return |
| | | } |
| | | await _room.value.localParticipant.setCameraEnabled(enabled) |
| | | cameraEnabled.value = enabled |
| | | } |
| | | |
| | | /** 忬声å¨ï¼ä»
åååºå¼ç¶æï¼å®é
éé³ç±æ¨¡æ¿ä¸ audio å
ç´ :muted ç»å®çæ */ |
| | | function setSpeakerEnabled(enabled: boolean) { |
| | | speakerEnabled.value = enabled |
| | | } |
| | | |
| | | /** |
| | | * åå±å¹å
±äº«ï¼ |
| | | * |
| | | * æµè§å¨ä¼å¼¹åçãéæ©å
±äº«å
容ãå¯¹è¯æ¡ï¼ç¨æ·å¨å¼¹çªéç¹åæ¶æ¶ setScreenShareEnabled 伿éï¼æè·å¹¶æç¶æå¤ä½å SDK çå®é
å¼ |
| | | */ |
| | | async function setScreenShareEnabled(enabled: boolean) { |
| | | if (!_room.value) return |
| | | try { |
| | | await _room.value.localParticipant.setScreenShareEnabled(enabled) |
| | | screenShareEnabled.value = enabled |
| | | } catch (error) { |
| | | // ç¨æ·å¨æµè§å¨åçå¯¹è¯æ¡éåæ¶éæ©ï¼ä¸å½ä½é误 |
| | | screenShareEnabled.value = _room.value.localParticipant.isScreenShareEnabled |
| | | throw error |
| | | } |
| | | } |
| | | |
| | | /** 注åãè¿ç«¯è¿æ¥å¼å¸¸æå¼ãåè°ï¼è¿ååæ³¨å彿° */ |
| | | function onDisconnected(cb: () => void): () => void { |
| | | disconnectedHandlers.add(cb) |
| | | return () => disconnectedHandlers.delete(cb) |
| | | } |
| | | |
| | | /** 注åãæ¿å
æäººå å
¥ãåè°ï¼è¿ååæ³¨å彿° */ |
| | | function onParticipantConnected(cb: ParticipantEventHandler): () => void { |
| | | participantConnectedHandlers.add(cb) |
| | | return () => participantConnectedHandlers.delete(cb) |
| | | } |
| | | |
| | | /** 注åãæ¿å
æäººç¦»å¼ãåè°ï¼è¿ååæ³¨å彿° */ |
| | | function onParticipantDisconnected(cb: ParticipantEventHandler): () => void { |
| | | participantDisconnectedHandlers.add(cb) |
| | | return () => participantDisconnectedHandlers.delete(cb) |
| | | } |
| | | |
| | | /** identity æ¯åç«¯ç¾ token æ¶å¡ç userId å符串ï¼è½¬ number è¿åï¼éæ°åï¼å
¼å®¹æ§å
åºï¼è¿å null */ |
| | | function parseUserId(identity: string): null | number { |
| | | const id = Number(identity) |
| | | return Number.isNaN(id) ? null : id |
| | | } |
| | | |
| | | /** |
| | | * MediaStream ç¼åï¼key 为 `${participantSid}:${source}`ï¼value 为 `{ track, stream }`ï¼ |
| | | * å䏿¡ MediaStreamTrack å¤ç¨åä¸ä¸ª MediaStreamï¼é¿å
<video>.srcObject åå¤éæå¯¼è´è§£ç 管线é建ï¼è§é¢éªçï¼ï¼ |
| | | * track å¼ç¨åæ¢ï¼éæ°è®¢é
/ åæµï¼æ¶æéæ°å»ºå¹¶æ¿æ¢ |
| | | */ |
| | | const streamCache = new Map<string, { stream: MediaStream; track: MediaStreamTrack; }>() |
| | | |
| | | /** |
| | | * ååä¸è
æå®æ¥æºç轨éå¹¶æå
为 MediaStreamï¼ |
| | | * åä¸è
èµ° unknown æ¯å 为ååºå¼ç³»ç»ä¼ä¸¢å¤± livekit-client çç±»ç¾åï¼å½æ°å
æå¨ cast ååä¸è
ç±»åï¼ |
| | | * å½ä¸ç¼åè¿ååä¸ MediaStream å¼ç¨ï¼ä¸æ¸¸ watch / srcObject æ ééæï¼ |
| | | * 轨é被 muteï¼æ¬ç«¯å
³æå头 / è¿ç«¯å
³æåå¤´ï¼æ¶è¿å nullï¼è®© video å
ç´ è§£ç» srcObject è䏿¯å¡å¨æåä¸å¸§ |
| | | */ |
| | | function pickStream(participant: unknown, source: Track.Source): MediaStream | null { |
| | | const p = participant as Participant |
| | | const pub = p.getTrackPublication(source) |
| | | if (!pub || pub.isMuted) { |
| | | return null |
| | | } |
| | | const track = pub.track?.mediaStreamTrack |
| | | if (!track) { |
| | | return null |
| | | } |
| | | const key = `${p.sid}:${source}` |
| | | const cached = streamCache.get(key) |
| | | if (cached && cached.track === track) { |
| | | return cached.stream |
| | | } |
| | | const stream = new MediaStream([track]) |
| | | streamCache.set(key, { track, stream }) |
| | | return stream |
| | | } |
| | | |
| | | /** æå¼å½å Roomï¼clearHandlers 为 true æ¶åæ¥æ¸
çå¤é¨æ³¨åçäºä»¶åè° */ |
| | | async function disconnectRoom(clearHandlers: boolean) { |
| | | // æ¸
çéè¯ç»æåä¸åå¤ç¨ç订é
åè° |
| | | if (clearHandlers) { |
| | | disconnectedHandlers.clear() |
| | | participantConnectedHandlers.clear() |
| | | participantDisconnectedHandlers.clear() |
| | | } |
| | | // æ¸
çé³è§é¢è½¨éç¼å |
| | | streamCache.clear() |
| | | if (_room.value) { |
| | | // å¸è½½ Room äºä»¶å¹¶æå¼è¿æ¥ |
| | | _room.value.removeAllListeners() |
| | | await _room.value.disconnect() |
| | | _room.value = null |
| | | } |
| | | // éç½®è¿æ¥å设å¤ç¶æ |
| | | localParticipant.value = null |
| | | remoteParticipants.value = [] |
| | | isConnected.value = false |
| | | reconnecting.value = false |
| | | micEnabled.value = true |
| | | cameraEnabled.value = false |
| | | speakerEnabled.value = true |
| | | screenShareEnabled.value = false |
| | | } |
| | | |
| | | /** 䏻卿å¼ï¼éè¯ç»æç»ä¸è° */ |
| | | async function disconnect() { |
| | | await disconnectRoom(true) |
| | | } |
| | | |
| | | return { |
| | | room, |
| | | localParticipant, |
| | | remoteParticipants, |
| | | isConnected, |
| | | connectionQuality, |
| | | micEnabled, |
| | | cameraEnabled, |
| | | speakerEnabled, |
| | | screenShareEnabled, |
| | | reconnecting, |
| | | connect, |
| | | disconnect, |
| | | setMicEnabled, |
| | | setCameraEnabled, |
| | | setSpeakerEnabled, |
| | | setScreenShareEnabled, |
| | | pickStream, |
| | | onDisconnected, |
| | | onParticipantConnected, |
| | | onParticipantDisconnected |
| | | } |
| | | } |
| | | |
| | | export type ImLiveKitRoom = ReturnType<typeof useLiveKitRoom> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { Conversation, Message } from '../types' |
| | | |
| | | import type { AxiosProgressEvent } from '#/api/infra/file' |
| | | |
| | | import { isOpenableUrl } from '#/packages/utils/src' |
| | | |
| | | import { message } from 'ant-design-vue' |
| | | |
| | | import { uploadFile } from '#/api/infra/file' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { |
| | | MESSAGE_FILE_MAX_MB, |
| | | MESSAGE_IMAGE_MAX_MB, |
| | | MESSAGE_VIDEO_MAX_MB, |
| | | MESSAGE_VOICE_MAX_MB |
| | | } from '../../utils/config' |
| | | import { ImContentType, ImMessageStatus } from '../../utils/constants' |
| | | import { getConversationKey } from '../../utils/conversation' |
| | | import { |
| | | type AudioMessage, |
| | | BLOB_URL_PREFIX, |
| | | type FileMessage, |
| | | generateClientMessageId, |
| | | type ImageMessage, |
| | | parseMessage, |
| | | type QuoteMessage, |
| | | serializeMessage, |
| | | type VideoMessage, |
| | | withQuotePayload |
| | | } from '../../utils/message' |
| | | import { useConversationStore } from '../store/conversationStore' |
| | | import { useMessageStore } from '../store/messageStore' |
| | | import { useMessageSender } from './useMessageSender' |
| | | import { useMuteOverlay } from './useMuteOverlay' |
| | | |
| | | type UploadProgressEvent = Parameters<NonNullable<AxiosProgressEvent>>[0] |
| | | |
| | | /** åæ¡åªä½ payload èåï¼è¦ç IMAGE / FILE / VOICE / VIDEO åç§ï¼ */ |
| | | export type MediaPayload = AudioMessage | FileMessage | ImageMessage | VideoMessage |
| | | |
| | | /** |
| | | * åªä½ç¹å®çå
æ°æ®ä¸ä¸æï¼é¦å / éä¼ å
±ç¨å
¥åï¼ä¸å type å
³å¿ä¸ååæ®µ |
| | | * |
| | | * - voiceDurationï¼è¯é³æ¶é¿ï¼ç§ï¼ï¼é¦åç± VoiceRecorder ç»ï¼éä¼ ä»æ§ AudioMessage.duration å |
| | | * - videoProbeï¼è§é¢å
ä¿¡æ¯ï¼é¦åç± probeVideoFile è§£åºï¼éä¼ ä»æ§ VideoMessage ç´æ¥æ·åæ®µï¼ |
| | | * - videoCoverUrlï¼è§é¢å°é¢çå® URLï¼å ä½é¶æ®µä¸è®¾ï¼é¿å
ä¼ blob å½ poster å¨é¨åæµè§å¨éåï¼ï¼commit é¶æ®µç± cover ä¸ä¼ ç»æå¡«å
¥ï¼éä¼ æ¶ä»æ§ VideoMessage.coverUrl å¤ç¨ï¼æ§å¼è¥æ¯ blob ä¼è¢«è·³è¿ |
| | | */ |
| | | export interface MediaTypeContext { |
| | | voiceDuration?: number |
| | | videoProbe?: { duration?: number; height?: number; width?: number; } |
| | | videoCoverUrl?: string |
| | | } |
| | | |
| | | export interface MediaTypeHandler { |
| | | /** 䏿åï¼ä»
æ¥å¿ç¨ï¼æ¿ä»£ä¹åæ£è½ 9 å¤ç kind åç¬¦ä¸²ï¼ */ |
| | | kind: string |
| | | /** ç± file + url + context çæ payloadï¼å 使¶ url æ¯ blob URLï¼commit æ¶æ¯çå® url */ |
| | | build: (file: File, url: string, context: MediaTypeContext) => MediaPayload |
| | | /** éä¼ åºæ¯ï¼ä»æ§ content æå contextï¼è®©éä¼ ä¸éè¦éå probe / éå½è¯é³ï¼ */ |
| | | extractResendContext: (oldContent: string) => MediaTypeContext |
| | | } |
| | | |
| | | /** åªä½ç±»å注å表ï¼image / file / voice / video åèªç kind + é¦å / éä¼ å
±ç¨ç build / extract */ |
| | | export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = { |
| | | [ImContentType.IMAGE]: { |
| | | kind: 'å¾ç', |
| | | build: (_file, url) => ({ url }) as ImageMessage, |
| | | extractResendContext: () => ({}) |
| | | }, |
| | | [ImContentType.FILE]: { |
| | | kind: 'æä»¶', |
| | | build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage, |
| | | extractResendContext: () => ({}) |
| | | }, |
| | | [ImContentType.VOICE]: { |
| | | kind: 'è¯é³', |
| | | build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage, |
| | | extractResendContext: (oldContent) => { |
| | | const old = parseMessage<AudioMessage>(oldContent) |
| | | return { voiceDuration: old?.duration ?? 0 } |
| | | } |
| | | }, |
| | | [ImContentType.VIDEO]: { |
| | | kind: 'è§é¢', |
| | | build: (file, url, context) => |
| | | ({ |
| | | url, |
| | | coverUrl: context.videoCoverUrl, |
| | | duration: context.videoProbe?.duration, |
| | | width: context.videoProbe?.width, |
| | | height: context.videoProbe?.height, |
| | | size: file.size |
| | | }) as VideoMessage, |
| | | extractResendContext: (oldContent) => { |
| | | const old = parseMessage<VideoMessage>(oldContent) |
| | | // æ§ coverUrl æ¯ blob 说æä¸ä¼ æå¤±è´¥ï¼cover æ²¡ä¼ æåï¼ï¼ä¸å¤ç¨ï¼çå® URL ç´æ¥å¤ç¨ï¼ç䏿¬¡å°é¢ä¸ä¼ |
| | | const reuseCover = |
| | | old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined |
| | | return { |
| | | videoProbe: { duration: old?.duration, width: old?.width, height: old?.height }, |
| | | videoCoverUrl: reuseCover |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** 忬¡åªä½ä¸ä¼ çå
¥åï¼image / file / voice èµ° uploadAndSendMediaï¼video èµ°ä½å± helper èªè¡ç»è£
ï¼ */ |
| | | export interface UploadAndSendMediaOptions { |
| | | file: File |
| | | /** å¯¹é½ ImContentTypeï¼mediaTypeHandlers å¿
é¡»æå¯¹åºé¡¹ */ |
| | | type: number |
| | | /** åªä½ç¹å®çå
æ°æ®ï¼å¦è¯é³æ¶é¿ / è§é¢å
ä¿¡æ¯ï¼ï¼ä¸ä¼ æç©ºå¯¹è±¡å¤ç */ |
| | | context?: MediaTypeContext |
| | | /** å¼ç¨æ¶æ¯ï¼è¥æï¼ï¼åè¿ payload.quote */ |
| | | quote?: QuoteMessage |
| | | /** éå®èµ·å§ä¼è¯ï¼ä¸ä¼ æé´ä¼è¯åèµ°åæ¾å¼åé */ |
| | | conversation: Conversation |
| | | /** éè¯å·²æå 使¶æ¯æ¶å¤ç¨ç客æ·ç«¯æ¶æ¯ç¼å· */ |
| | | existingClientMessageId?: string |
| | | } |
| | | |
| | | /** |
| | | * åªä½ä¸ä¼ + åé composableï¼image / file / voice / video å
±ç¨åºå± helperï¼ |
| | | * |
| | | * ä¸ useMessageSender.sendRaw çãå
å请æ±å ackãä¸åï¼åªä½é¾è·¯å¿
é¡»ãå
å ä½åä¸ä¼ ãï¼ |
| | | * 1. ç«å³ insertMessage åå
¥å 使¶æ¯ï¼status=SENDINGãcontent ç¨ blob URLã_localFile å
åç Fileï¼ |
| | | * 2. uploadFile ä¸ä¼ ï¼onUploadProgress åè° patchMessage æ´æ° uploadProgressï¼UI 宿¶æ¾ç¤ºè¿åº¦æ¡ |
| | | * 3. ä¸ä¼ æååç¨çå® url éç contentï¼patchMessage æ¿æ¢ï¼æ§ blob URL ç± store èªå¨ revoke |
| | | * 4. èµ° sendRaw(existingClientMessageId) å¤ç¨å ä½åé请æ±ï¼é¿å
é夿å
¥ä¸¤æ¡ |
| | | * |
| | | * ä»»æå¤±è´¥ææ¶æ¯ç¶æç½® FAILEDï¼MessageItem ä¸ç¹éè¯åèµ°ä¸æ¬¡æ¬å½æ°ï¼_localFile è¿å¨å
åå°±è¡ï¼ |
| | | */ |
| | | /** æå
å®¹ç±»åæ å°ä½ç§¯ä¸éï¼MBï¼ï¼æªè¯å«ç±»åè¿å 0 表示ä¸é */ |
| | | function resolveMediaMaxMb(type: number): number { |
| | | switch (type) { |
| | | case ImContentType.FILE: { |
| | | return MESSAGE_FILE_MAX_MB |
| | | } |
| | | case ImContentType.IMAGE: { |
| | | return MESSAGE_IMAGE_MAX_MB |
| | | } |
| | | case ImContentType.VIDEO: { |
| | | return MESSAGE_VIDEO_MAX_MB |
| | | } |
| | | case ImContentType.VOICE: { |
| | | return MESSAGE_VOICE_MAX_MB |
| | | } |
| | | default: { |
| | | return 0 |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** æ ¡éªåªä½æä»¶å¤§å°æ¯å¦è¶
è¿å
容类åä¸éï¼è¶
é触å warn å¹¶è¿å falseï¼è°ç¨æ¹ä¸åºè¿å
¥å ä½ / ä¸ä¼ é¾è·¯ */ |
| | | export function ensureMediaSizeWithinLimit( |
| | | file: File, |
| | | type: number, |
| | | warn: (text: string) => void |
| | | ): boolean { |
| | | const maxMb = resolveMediaMaxMb(type) |
| | | if (maxMb && file.size > maxMb * 1024 * 1024) { |
| | | warn(`æä»¶å¤§å°è¶
è¿ä¸é ${maxMb}MBï¼è¯·å缩ååå`) |
| | | return false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | export const useMediaUploader = () => { |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const muteOverlay = useMuteOverlay() |
| | | const { sendRaw } = useMessageSender() |
| | | |
| | | /** |
| | | * ç«å³åå
¥åªä½å 使¶æ¯ï¼ä½å± helperï¼image/file/voice èµ° uploadAndSendMedia å
è£
ï¼video ç´æ¥ç¨æ¬å½æ°ï¼ |
| | | * |
| | | * ç¨ createObjectURL(file) çæä¸´æ¶ blob URL åç» buildContentï¼å ä½ status=SENDING + uploadProgress=0ï¼ |
| | | * file æå¨ _localFile ä¸ä¾å¤±è´¥éè¯æ¶éèµ°ä¸ä¼ |
| | | */ |
| | | const insertMediaPlaceholder = (opts: { |
| | | buildContent: (blobUrl: string) => string |
| | | conversation: Conversation |
| | | existingClientMessageId?: string |
| | | file: File |
| | | type: number |
| | | }): { blobUrl: string; clientMessageId: string; } => { |
| | | const { conversation } = opts |
| | | const blobUrl = URL.createObjectURL(opts.file) |
| | | const clientMessageId = opts.existingClientMessageId || generateClientMessageId() |
| | | if (opts.existingClientMessageId) { |
| | | messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { |
| | | content: opts.buildContent(blobUrl), |
| | | status: ImMessageStatus.SENDING, |
| | | uploadProgress: 0, |
| | | _localFile: opts.file |
| | | }) |
| | | return { clientMessageId, blobUrl } |
| | | } |
| | | const placeholder: Message = { |
| | | clientMessageId, |
| | | type: opts.type, |
| | | content: opts.buildContent(blobUrl), |
| | | status: ImMessageStatus.SENDING, |
| | | sendTime: Date.now(), |
| | | senderId: getCurrentUserId(), |
| | | targetId: conversation.targetId, |
| | | selfSend: true, |
| | | uploadProgress: 0, |
| | | _localFile: opts.file |
| | | } |
| | | void messageStore.insertMessage( |
| | | { |
| | | type: conversation.type, |
| | | targetId: conversation.targetId, |
| | | name: conversation.name || String(conversation.targetId), |
| | | avatar: conversation.avatar || '' |
| | | }, |
| | | placeholder |
| | | ).catch(() => undefined) |
| | | return { clientMessageId, blobUrl } |
| | | } |
| | | |
| | | /** |
| | | * æå 使¶æ¯ç½®ä¸º FAILEDï¼ä¸ä¼ 失败 / ä¼è¯åèµ° / ç¦è¨æå°ç¹ çåºæ¯ç»ä¸æ¶å°¾ï¼ |
| | | * |
| | | * åæ¶æ¸
æ uploadProgress ââ 失败å MessageItem ç isUploading ä¸åå½ä¸ï¼è¿åº¦é®ç½© / æä»¶ç¹å»ç¦ç¨ / è¯é³ loading æå¶ é½åæ¥è§£é¤ï¼ |
| | | * _localFile ä¿çï¼è®©ç¨æ·ç¹éè¯å¯ä»¥èµ° uploadAndSendMedia éä¼ |
| | | */ |
| | | const markMediaFailed = ( |
| | | conversationType: number, |
| | | targetId: number, |
| | | clientMessageId: string |
| | | ): void => { |
| | | messageStore.patchMessage(conversationType, targetId, clientMessageId, { |
| | | status: ImMessageStatus.FAILED, |
| | | uploadProgress: undefined |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * çæ axios `onUploadProgress` åè°ï¼ç¨ closure ç¼å䏿¬¡ç¾åæ¯ï¼æªååç´æ¥ return ä¸è¿ store |
| | | * |
| | | * XHR onProgress 大æä»¶ä¸æ¯ç§è§¦å 10-50 次ï¼ä½ Math.round åç¾åæ¯æå¤§ééå¤ï¼ä¸ç§å
å¯è½åå æ¬¡åä¸ä¸ªæ°åï¼ï¼ |
| | | * 卿ºå¤´å»éï¼è½çæ store ç find + Object.assign + Vue reactivity 触åé¾ |
| | | */ |
| | | const createUploadProgressHandler = (conversation: Conversation, clientMessageId: string) => { |
| | | let lastPercent = -1 |
| | | return (event: UploadProgressEvent): void => { |
| | | if (!event.total) { |
| | | return |
| | | } |
| | | const percent = Math.round((event.loaded / event.total) * 100) |
| | | if (percent === lastPercent) { |
| | | return |
| | | } |
| | | lastPercent = percent |
| | | messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { |
| | | uploadProgress: percent |
| | | }) |
| | | } |
| | | } |
| | | |
| | | /** ååªä½ç±»å䏿åï¼ä»
æ¥å¿ç¨ï¼ï¼æªæ³¨å type éå为éç¨ãåªä½ã */ |
| | | const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? 'åªä½' |
| | | |
| | | /** |
| | | * æ type å handlerï¼ç¼ºåæéï¼ç¨åºé误éä¸å¨è¿ä¸å¤ï¼ |
| | | * |
| | | * è°ç¨æ¹æ¿å°çæ¯ `MediaTypeHandler`ï¼é undefinedï¼ï¼ä¸åéè¦ `!` æè¨ã |
| | | * ä»
ç»ãç¡®å® type å¨è¡¨éãçè°ç¨æ¹ç¨ ââ image/file/voice/video åç±»å
¥å£ï¼éç¨ dispatcher ä»å¯ç¨ `mediaTypeHandlers[type]?.` optional chain |
| | | */ |
| | | const requireMediaHandler = (type: number): MediaTypeHandler => { |
| | | const handler = mediaTypeHandlers[type] |
| | | if (!handler) { |
| | | throw new Error(`[IM] æªæ³¨åçåªä½ç±»å ${type}`) |
| | | } |
| | | return handler |
| | | } |
| | | |
| | | /** |
| | | * ä¸ä¼ 宿åçæ¶å£æ ¡éªï¼ä¼è¯ä»æ¯å 使¶éå®çé£ä¸ª + å½åæªè¢«ç¦è¨ï¼ä»»ä¸ä¸æ»¡è¶³ markMediaFailed + è¿å false |
| | | * |
| | | * image / file / voice / video é¾è·¯é½è¦å¨ãæ¿å°çå® url åãè° sendRaw ä¹åãè¿ä¸éè¿ä¸¤é |
| | | */ |
| | | const verifyMediaUploadStillAllowed = ( |
| | | conversation: Conversation, |
| | | startKey: string, |
| | | type: number, |
| | | clientMessageId: string |
| | | ): boolean => { |
| | | const activeConversation = conversationStore.activeConversation |
| | | if (!activeConversation || getConversationKey(activeConversation) !== startKey) { |
| | | console.warn(`[IM] ${getMediaKind(type)}ä¸ä¼ æé´åæ¢äºä¼è¯ï¼æ¾å¼åé`, { startKey }) |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return false |
| | | } |
| | | if (muteOverlay.value) { |
| | | console.warn(`[IM] ${getMediaKind(type)}ä¸ä¼ æé´è¢«ç¦è¨ï¼æ¾å¼åé`, { startKey }) |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return false |
| | | } |
| | | return true |
| | | } |
| | | |
| | | /** |
| | | * å ä½å®æåç¨çå® url æ¿æ¢ contentï¼åèµ° sendRaw 宿åé |
| | | * |
| | | * ä¸ä¼ æå â patch content â sendRaw å¤ç¨ existingClientMessageIdï¼store å
é¨ revoke æ§ blob URL |
| | | */ |
| | | const commitMediaPlaceholder = async (opts: { |
| | | clientMessageId: string |
| | | conversation: Conversation |
| | | realContent: string |
| | | type: number |
| | | }): Promise<void> => { |
| | | messageStore.patchMessage( |
| | | opts.conversation.type, |
| | | opts.conversation.targetId, |
| | | opts.clientMessageId, |
| | | { content: opts.realContent } |
| | | ) |
| | | // æ¾å¼ä¼ conversation èéä¾èµ sendRaw å
é¨å activeï¼ |
| | | // verifyMediaUploadStillAllowed ä¸ sendRaw ä¹é´åå¨å¾®ç§çªå£ï¼æé´ç¨æ·åä¼è¯ä¹è½ä¿è¯åå°åä¼è¯ |
| | | await sendRaw(opts.type, opts.realContent, { |
| | | existingClientMessageId: opts.clientMessageId, |
| | | targetId: opts.conversation.targetId, |
| | | conversation: opts.conversation |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * ä¸ä¼ åªä½æä»¶å¹¶åéæ¶æ¯ï¼é«å±å
¥å£ï¼image / file / voice ç¨ï¼video èµ°ä½å± helper èªè¡ç»è£
ï¼ |
| | | * |
| | | * @returns å 使¶æ¯ç clientMessageIdï¼è°ç¨æ¹æéç¨äºåç» patch / ç§»é¤ï¼ä¸ä¼ 失败æ¶å ä½ä»ä¿ç为 FAILED æï¼ |
| | | */ |
| | | const uploadAndSendMedia = async (opts: UploadAndSendMediaOptions): Promise<string> => { |
| | | const { conversation } = opts |
| | | const handler = mediaTypeHandlers[opts.type] |
| | | if (!handler) { |
| | | console.warn('[IM] uploadAndSendMedia æ¶å°æªæ³¨åçåªä½ç±»å', { type: opts.type }) |
| | | return '' |
| | | } |
| | | // ä½ç§¯ä¸éæ¦æªï¼å¤§æä»¶æµè§å¨å
æªå¸§ / è§£ç å¯è´ OOMï¼è¶
éç´æ¥ warningï¼ä¸è¿å
¥å ä½ / ä¸ä¼ é¾è·¯ |
| | | if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) { |
| | | return '' |
| | | } |
| | | const startKey = getConversationKey(conversation) |
| | | const context = opts.context ?? {} |
| | | const buildContent = (url: string): string => |
| | | serializeMessage(withQuotePayload(handler.build(opts.file, url, context), opts.quote)) |
| | | |
| | | // 1. ç«å³å ä½ |
| | | const { clientMessageId } = insertMediaPlaceholder({ |
| | | file: opts.file, |
| | | type: opts.type, |
| | | conversation, |
| | | buildContent, |
| | | existingClientMessageId: opts.existingClientMessageId |
| | | }) |
| | | |
| | | // 2. ä¸ä¼ ï¼è¿åº¦åè° patch uploadProgressï¼å¤±è´¥ä¿ç _localFile ä¾éè¯ |
| | | let url: string | undefined |
| | | try { |
| | | url = await uploadFile( |
| | | { file: opts.file }, |
| | | createUploadProgressHandler(conversation, clientMessageId) |
| | | ) |
| | | } catch (error) { |
| | | console.error(`[IM] ${handler.kind}ä¸ä¼ 失败`, error) |
| | | } |
| | | if (!url) { |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return clientMessageId |
| | | } |
| | | if (!isOpenableUrl(url)) { |
| | | console.warn(`[IM] ${handler.kind}ä¸ä¼ è¿åäºä¸æ¯ææå¼ç URL`, { url }) |
| | | message.warning('ä¸ä¼ è¿åçæä»¶å°å䏿¯ææå¼') |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return clientMessageId |
| | | } |
| | | |
| | | // 3. ä¸ä¼ æé´ä¼è¯åæ¢ / ç¨æ·ç»åº / 被ç¦è¨ï¼ä»»ä¸æ
åµé½æ¾å¼åéï¼å ä½ç½® FAILED |
| | | if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) { |
| | | return clientMessageId |
| | | } |
| | | |
| | | // 4. patch content + sendRaw æ¶å°¾ |
| | | await commitMediaPlaceholder({ |
| | | type: opts.type, |
| | | conversation, |
| | | clientMessageId, |
| | | realContent: buildContent(url) |
| | | }) |
| | | return clientMessageId |
| | | } |
| | | |
| | | return { |
| | | uploadAndSendMedia, |
| | | insertMediaPlaceholder, |
| | | markMediaFailed, |
| | | commitMediaPlaceholder, |
| | | createUploadProgressHandler, |
| | | verifyMediaUploadStillAllowed, |
| | | getMediaKind, |
| | | requireMediaHandler |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { Message } from '../types' |
| | | |
| | | import { computed, reactive } from 'vue' |
| | | |
| | | /** |
| | | * æ¶æ¯å¤éæ¨¡å¼ |
| | | * |
| | | * 模å级åä¾ reactive stateï¼MessagePanel åæ å
å¤å¤å
±äº«ï¼ |
| | | * - MessageItem 渲æå¾é + 忢å¾é |
| | | * - MessageMultiSelectBar åºæ 读é䏿° |
| | | * - MessageForwardDialog 转åæåå exit |
| | | * - MessagePanel watch activeConversation åä¼è¯ exit |
| | | */ |
| | | const state = reactive({ |
| | | active: false, |
| | | /** å·²é clientMessageId åè¡¨ï¼æéä¸é¡ºåºä¿åº */ |
| | | selectedClientMessageIds: [] as string[] |
| | | }) |
| | | |
| | | /** å·²é clientMessageId éåï¼MessageItem 大é has æ¥è¯¢èµ°å®ï¼é¿å
array.includes O(N²) */ |
| | | const selectedIdSet = computed(() => new Set(state.selectedClientMessageIds)) |
| | | |
| | | /** è¿å
¥å¤é模å¼ï¼å¯é带åå§å¾é项 */ |
| | | function enter(initialMessage?: Message) { |
| | | state.active = true |
| | | state.selectedClientMessageIds = initialMessage ? [initialMessage.clientMessageId] : [] |
| | | } |
| | | |
| | | /** éåºå¤éæ¨¡å¼ */ |
| | | function exit() { |
| | | state.active = false |
| | | state.selectedClientMessageIds = [] |
| | | } |
| | | |
| | | /** åæ¢ææ¡æ¶æ¯çé䏿 */ |
| | | function toggle(message: Message) { |
| | | const ids = state.selectedClientMessageIds |
| | | const index = ids.indexOf(message.clientMessageId) |
| | | if (index === -1) { |
| | | ids.push(message.clientMessageId) |
| | | } else { |
| | | ids.splice(index, 1) |
| | | } |
| | | } |
| | | |
| | | export function useMessageMultiSelect() { |
| | | return { state, selectedIdSet, enter, exit, toggle } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { Message } from '../types' |
| | | |
| | | import type { ImChannelMessageApi } from '#/api/im/message/channel' |
| | | import type { ImGroupMessageApi } from '#/api/im/message/group' |
| | | import type { ImPrivateMessageApi } from '#/api/im/message/private' |
| | | |
| | | import { watch } from 'vue' |
| | | |
| | | import { pullChannelMessageList as apiPullChannelMessageList } from '#/api/im/message/channel' |
| | | import { pullGroupMessageList as apiPullGroupMessageList } from '#/api/im/message/group' |
| | | import { getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId, pullPrivateMessageList as apiPullPrivateMessageList } from '#/api/im/message/private' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { buildChannelConversationStub } from '../../utils/channel' |
| | | import { |
| | | MESSAGE_GROUP_PULL_SIZE, |
| | | MESSAGE_PRIVATE_PULL_SIZE, |
| | | MESSAGE_PRIVATE_READ_ENABLED |
| | | } from '../../utils/config' |
| | | import { |
| | | ImContentType, |
| | | ImConversationType, |
| | | ImMessageStatus, |
| | | isFriendChatTip, |
| | | isFriendNotification |
| | | } from '../../utils/constants' |
| | | import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message' |
| | | import { runMinIdPull } from '../../utils/pull' |
| | | import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user' |
| | | import { useConversationStore } from '../store/conversationStore' |
| | | import { useFriendStore } from '../store/friendStore' |
| | | import { useGroupRequestStore } from '../store/groupRequestStore' |
| | | import { useGroupStore } from '../store/groupStore' |
| | | import { type PulledMessage, useMessageStore } from '../store/messageStore' |
| | | import { useRtcStore } from '../store/rtcStore' |
| | | import { useImWebSocketStore } from '../store/websocketStore' |
| | | |
| | | /** ä¸ç±»æ¶æ¯ pull æ¥å£è¿åçåå§ VO èåç±»åï¼runMinIdPull åªé id æ¨è¿æ¸¸æ ï¼å
·ä½ååå¨ applyPage å
æç±»å cast */ |
| | | type PulledRawMessage = ImChannelMessageApi.ChannelMessageRespVO | ImGroupMessageApi.GroupMessageRespVO | ImPrivateMessageApi.PrivateMessageRespVO |
| | | |
| | | /** |
| | | * æ¶æ¯å¢éæåï¼ç»å½åå页æå离线æé´çæ°æ¶æ¯ |
| | | * |
| | | * 设计è¦ç¹ï¼ |
| | | * 1. åæ¶æåç§è + 群èï¼ä½¿ç¨åèªç `minId` 游æ ï¼privateMessageMaxId / groupMessageMaxIdï¼ |
| | | * 2. åç«¯ä¸æ¬¡æå¤è¿å size æ¡ï¼å端æ minId æç»ç¿»é¡µï¼ç´å°æ¥å£è¿å空åè¡¨ä¸ºæ¢ |
| | | * 3. æåæé´ conversationStore.loading=trueï¼ |
| | | * - conversationStore è·³è¿æ¹éæä¹
åï¼é¿å
é¢ç¹åå
¥å¡é¡¿ |
| | | * - websocketStore ææ°æ¥ç WS æ®éæ¶æ¯ä¸¢è¿ç¼å²åºï¼ç循ç¯ç»æåç»ä¸åæ¾ |
| | | * 4. WebSocket éè¿åä¼å触å䏿¬¡æåï¼è¡¥é½æç½æé´éè¿çæ¶æ¯ |
| | | */ |
| | | export const useMessagePuller = () => { |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const wsStore = useImWebSocketStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | const groupRequestStore = useGroupRequestStore() |
| | | const rtcStore = useRtcStore() |
| | | const currentUserId = getCurrentUserId() |
| | | |
| | | /** å¤æè¯·æ±æ¯å¦è¢«ä¸»å¨åæ¶ */ |
| | | const isAbortError = (e: unknown): boolean => { |
| | | const error = e as { code?: string; message?: string; name?: string; } |
| | | return ( |
| | | error?.name === 'CanceledError' || |
| | | error?.code === 'ERR_CANCELED' || |
| | | error?.message === 'canceled' |
| | | ) |
| | | } |
| | | |
| | | /** ç§èä¼è¯å½å±ï¼èªå·±åçç®"åç» receiverId çä¼è¯"ï¼å¦åç®"åéæ¹çä¼è¯"ï¼curry currentUserId è¿éå
åå° 3 å¤è°ç¨æ¹çæ ·æ¿ */ |
| | | const getPrivatePeerId = (message: ImPrivateMessageApi.PrivateMessageRespVO) => |
| | | getPrivateMessagePeerId(message, currentUserId) |
| | | |
| | | /** æå¡ç«¯ç§èæ¶æ¯ -> æ¬å° Messageï¼targetId æ¯ä¼è¯ä¸»é®ï¼å¯¹ç«¯ userIdï¼ */ |
| | | const convertPrivateMessage = (message: ImPrivateMessageApi.PrivateMessageRespVO): Message => { |
| | | return { |
| | | id: message.id, |
| | | clientMessageId: message.clientMessageId || generateClientMessageId(), |
| | | type: message.type, |
| | | content: message.content, |
| | | status: message.status, |
| | | receiptStatus: message.receiptStatus, |
| | | sendTime: new Date(message.sendTime).getTime(), |
| | | senderId: message.senderId, |
| | | targetId: getPrivatePeerId(message), |
| | | selfSend: message.senderId === currentUserId |
| | | } |
| | | } |
| | | |
| | | /** æå¡ç«¯ç¾¤èæ¶æ¯ -> æ¬å° Message */ |
| | | const convertGroupMessage = (message: ImGroupMessageApi.GroupMessageRespVO): Message => { |
| | | return { |
| | | id: message.id, |
| | | clientMessageId: message.clientMessageId || generateClientMessageId(), |
| | | type: message.type, |
| | | content: message.content, |
| | | status: message.status, |
| | | sendTime: new Date(message.sendTime).getTime(), |
| | | senderId: message.senderId, |
| | | targetId: message.groupId, |
| | | selfSend: message.senderId === currentUserId, |
| | | atUserIds: message.atUserIds || [], |
| | | receiverUserIds: message.receiverUserIds || [], |
| | | receiptStatus: message.receiptStatus, |
| | | readCount: message.readCount |
| | | } |
| | | } |
| | | |
| | | /** æå¡ç«¯é¢éæ¶æ¯ -> æ¬å° Message */ |
| | | const convertChannelMessage = (message: ImChannelMessageApi.ChannelMessageRespVO): Message => { |
| | | return { |
| | | id: message.id, |
| | | clientMessageId: message.clientMessageId || generateClientMessageId(), |
| | | type: message.type, |
| | | content: message.content, |
| | | status: ImMessageStatus.NORMAL, // é¢éæ æ¤åï¼æä¸ºæ£å¸¸ |
| | | receiptStatus: message.receiptStatus, // é¢é已读æï¼DONE 已读 / PENDING æªè¯» |
| | | sendTime: new Date(message.sendTime).getTime(), |
| | | senderId: 0, // ç³»ç»ä¸åï¼æ åé人 |
| | | targetId: message.channelId, // ä¼è¯å½å±å°é¢éç¼å· |
| | | selfSend: false, |
| | | materialId: message.materialId // 详æ
页æå¯ææ¬ç¨ |
| | | } |
| | | } |
| | | |
| | | /** é¢éï¼ä¼è¯å½å±å° channelIdï¼name / avatar æç¨å ä½ï¼å°æ¥æ¥å
¥ channelStore ååå¡«çå¼ */ |
| | | const convertChannelConversation = (message: ImChannelMessageApi.ChannelMessageRespVO) => |
| | | buildChannelConversationStub(message.channelId) |
| | | |
| | | /** ç§èï¼ä¼è¯å½å±å°å¯¹ç«¯ userId */ |
| | | const convertPrivateConversation = (message: ImPrivateMessageApi.PrivateMessageRespVO) => { |
| | | const targetId = getPrivatePeerId(message) |
| | | const friend = friendStore.getFriend(targetId) |
| | | return { |
| | | type: ImConversationType.PRIVATE, |
| | | targetId, |
| | | name: friend ? getFriendDisplayName(friend) : String(targetId), // ä¼è¯å表 / 顶鍿 é¢å±ç¤ºï¼å¥½å夿³¨ > ç宿µç§° |
| | | avatar: friend?.avatar || '', |
| | | silent: friend?.silent |
| | | } |
| | | } |
| | | |
| | | /** 群èï¼ä¼è¯å½å±å° groupId */ |
| | | const convertGroupConversation = (message: ImGroupMessageApi.GroupMessageRespVO) => { |
| | | const group = groupStore.getGroup(message.groupId) |
| | | return { |
| | | type: ImConversationType.GROUP, |
| | | targetId: message.groupId, |
| | | name: group ? getGroupDisplayName(group) : String(message.groupId), |
| | | avatar: group?.avatar || '', |
| | | silent: group?.silent |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * åç±»åæåç¦»çº¿æ¶æ¯ï¼ç¿»é¡µ / minId æ¸¸æ æ¨è¿ / 空页åç± runMinIdPull è´è´£ï¼è¿éåªåæ¥å£åæ¯ + éæ¡ä¸å¡åå |
| | | * ï¼æ¤å / 好åéç¥ / æ®éæ¶æ¯ï¼+ å
¥åºã |
| | | * |
| | | * åæ¶è¯ä¹ä¸¤å±å®å«ï¼ç» isActive ä¼ å
¥ runMinIdPullï¼ä»»ä¸ä¸çå³ä¸¢å¼æ¬æ¹ä¸å
¥åºãåæ¢ç¿»é¡µï¼é¿å
æ§ session ååºè½å°æ° storeï¼ |
| | | * 1. startEpochï¼cancelPull() éå¢ pullEpochï¼ç¦»å¼ IM / åè´¦å·æ¶è·³åº |
| | | * 2. startUserIdï¼æ¯æ¹ await 忝坹å½åç»å½ userIdï¼é²å¾¡ logout / å¤ tab ä¸ç¨æ·å·²åä½ cancelPull æªè§¦å |
| | | */ |
| | | const pullByType = async ( |
| | | conversationType: number, |
| | | startMinId: number, |
| | | startEpoch: number, |
| | | startUserId: number, |
| | | signal: AbortSignal |
| | | ) => { |
| | | // ç§è / 群è / é¢éåèªä¸å¥æ¥å£ï¼æ conversationType 忝è°åº¦ã翻页æºå¶ï¼minId 游æ / ç©ºé¡µå¤æ / 鲿»ç¿»ï¼äº¤ç» runMinIdPull |
| | | const isPrivate = conversationType === ImConversationType.PRIVATE |
| | | const isChannel = conversationType === ImConversationType.CHANNEL |
| | | const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE |
| | | const isStillValid = () => |
| | | !signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId |
| | | await runMinIdPull<PulledRawMessage>({ |
| | | initialMinId: startMinId, |
| | | pageSize: size, |
| | | isActive: isStillValid, |
| | | fetchPage: ({ minId, size }) => { |
| | | if (isPrivate) { |
| | | return apiPullPrivateMessageList({ minId, size }, signal) |
| | | } |
| | | if (isChannel) { |
| | | return apiPullChannelMessageList({ minId, size }, signal) |
| | | } |
| | | return apiPullGroupMessageList({ minId, size }, signal) |
| | | }, |
| | | applyPage: async (list, nextMinId) => { |
| | | const pulledMessages: PulledMessage[] = [] |
| | | // éæ¡ dispatchï¼åæ¶æ¯èµ°æ¹é insertï¼RECALL ä¿¡å·èµ°æ¹é recall æåæ¹å
å·² insert çåæ¶æ¯æ´æ°ä¸ºæ¤åæç¤ºã |
| | | // å端æ id ååºè¿åï¼ä¸ä¿¡å· id ä¸å® > åæ¶æ¯ idï¼å
æ´æ° status åæä¿¡å·ï¼ï¼æä»¥åæ¶æ¯ä¸å®å
å°ãrecallMessage æ¾å¾å° |
| | | for (const raw of list) { |
| | | if (isChannel) { |
| | | const message = raw as ImChannelMessageApi.ChannelMessageRespVO |
| | | pulledMessages.push({ |
| | | kind: 'insert', |
| | | conversationInfo: convertChannelConversation(message), |
| | | message: convertChannelMessage(message) |
| | | }) |
| | | continue |
| | | } |
| | | if (isPrivate) { |
| | | const message = raw as ImPrivateMessageApi.PrivateMessageRespVO |
| | | // ç¹æ®ï¼æ¤åæ¶æ¯çå¤ç |
| | | if (message.type === ImContentType.RECALL) { |
| | | pulledMessages.push({ |
| | | kind: 'recall', |
| | | conversationType: ImConversationType.PRIVATE, |
| | | targetId: getPrivatePeerId(message), |
| | | recallSignalContent: message.content |
| | | }) |
| | | continue |
| | | } |
| | | // ç¹æ®ï¼åå²å¥½åäºä»¶åªè¿åèå¤©æ°æ³¡ï¼å¥½åä¸»æ°æ®ç±å¥½åå¢éè¡¥å¿åæ¥ |
| | | // ä»
FRIEND_ADD / FRIEND_DELETE æä½ä¸ºä¼è¯æ°æ³¡å
¥æ¶æ¯å表 |
| | | if (isFriendNotification(message.type) && !isFriendChatTip(message.type)) { |
| | | continue |
| | | } |
| | | // å
¶å®æ¶æ¯æ£å¸¸å
¥ä¼è¯æ¶æ¯å表 |
| | | pulledMessages.push({ |
| | | kind: 'insert', |
| | | conversationInfo: convertPrivateConversation(message), |
| | | message: convertPrivateMessage(message) |
| | | }) |
| | | } else { |
| | | const message = raw as ImGroupMessageApi.GroupMessageRespVO |
| | | // ç¹æ®ï¼æ¤åæ¶æ¯çå¤ç |
| | | if (message.type === ImContentType.RECALL) { |
| | | pulledMessages.push({ |
| | | kind: 'recall', |
| | | conversationType: ImConversationType.GROUP, |
| | | targetId: message.groupId, |
| | | recallSignalContent: message.content |
| | | }) |
| | | continue |
| | | } |
| | | pulledMessages.push({ |
| | | kind: 'insert', |
| | | conversationInfo: convertGroupConversation(message), |
| | | message: convertGroupMessage(message) |
| | | }) |
| | | } |
| | | } |
| | | // å
¥åº + æ¨è¿ messageMaxIdï¼nextMinId ä¸ºç©ºï¼æ¬æ¹æ ææ idï¼æ¶ä¸æ¨è¿æ¸¸æ ï¼ä¸æ§é»è¾ä¸è´ |
| | | await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | /** å䏿¶å»åªå
è®¸ä¸æ¬¡ pullï¼index.vue çæå¨è°ç¨ä¸éè¿ watch 触åå¯è½å¹¶åï¼å
±ç¨åä¸ä¸ª promise å³å¯å»é */ |
| | | let pullPromise: null | Promise<void> = null |
| | | let pullAbortController: AbortController | null = null |
| | | |
| | | /** |
| | | * 馿¬¡ pull æ¯å¦å·²å®æãä»
å¨ç½® true åï¼isConnected watch æä¼è§¦å pullã |
| | | * 鲿¢ socket onopen æ¯ friendStore/groupStore 颿å
å°è¾¾æ¶ï¼watcher æ¢è·é ææ¶æ¯æå
¥æ©äºä¼è¯å
æ°æ®å¯è§ |
| | | */ |
| | | let initialPulled = false |
| | | |
| | | /** |
| | | * pull 轮次计æ°ï¼åè´¦å· / ç¦»å¼ IM æ¶ cancelPull() éå¢ï¼æ§ pullByType å¾ªç¯æ epoch èªæ£åè·³åº |
| | | * é¿å
æ§ session çæ¥å£ååºå¨æ° session è½å°ï¼é æè·¨è´¦å·æ¶æ¯æ³æ¼ |
| | | * |
| | | * 注æï¼æ®éæè¿ï¼WS çæï¼ä¸åæ¶ pullââç½ç»æå¨ / æå¡ç«¯éå¯é½å±äºæ¬è´¦å·æ£å¸¸çå½å¨æï¼ |
| | | * åæ¶ä¼å¯¼è´é¦æè¢«ä¸æå initialPulled æ°¸è¿åå¨ falseï¼åç»éè¿ watcher ä¸åè¡¥æ |
| | | */ |
| | | let pullEpoch = 0 |
| | | |
| | | /** æ¾å¼åæ¶ï¼ä»
ç± index.vue onUnmountedï¼ç¦»å¼ IM / åè´¦å· / è·¯ç±è·³åºï¼è°ç¨ */ |
| | | const cancelPull = () => { |
| | | pullEpoch++ |
| | | pullAbortController?.abort() |
| | | pullAbortController = null |
| | | // æ§ promise ä»å¨ finally é¶æ®µè·ï¼ä½ epoch å®å«å·²é»æåç»å¯ä½ç¨ï¼è¿éç«å»è®© pullPromise = null 让æ°ä¸è½®å¯éå
¥ |
| | | pullPromise = null |
| | | // åæ¥ä¸¢å¼ WS ç¼å²å¸§ï¼æ§ pull å·²ä¸ä¼ flushBufferï¼è¥ä¸æ¸
䏿¬¡è¿ IM ç¬¬ä¸æ¬¡ pullOnce ä¼ææ§ session çå¸§åæ¾è¿æ° store |
| | | wsStore.discardBuffer() |
| | | } |
| | | |
| | | /** |
| | | * ç¶æäºä»¶è¡¥å¿ï¼å¥½å / 好åç³è¯·èµ°å¢éï¼ç¾¤å表å群ç³è¯·çº¢ç¹èµ°å¿«ç
§å·æ° |
| | | * |
| | | * é¦ç»ä¸»æ°æ®ç± index.vue 驱å¨ï¼éè¿æ¶å store 已就ä½ï¼å¤è·¯ allSettled å¹¶åäºä¸å½±åï¼å路失败ä»
è®°æ¥å¿ã |
| | | * 群æåä¸åå
¨å±å¢é忥ï¼éè¿åªæ è®°æ¬å°ç¾¤æå cache è¿æï¼è¿å
¥ç¾¤ä¼è¯ææåå表æ¶åæ groupId å·æ°ã |
| | | */ |
| | | const pullStateEvents = async (): Promise<void> => { |
| | | // 1. æ¸
çè¿æ¥çº§ç¼å |
| | | messageStore.clearPrivateReadMaxIdCache() |
| | | rtcStore.clearGroupCallCache() |
| | | groupStore.markAllGroupActiveCallsExpired() |
| | | groupStore.markAllGroupInfoExpired() |
| | | groupStore.markAllGroupMembersExpired() |
| | | // 2. å¹¶åè¡¥å¿è¿ç«¯ç¶æ |
| | | const results = await Promise.allSettled([ |
| | | friendStore.pullFriends(), |
| | | friendStore.pullFriendRequests(), |
| | | conversationStore.pullConversationReads(), |
| | | groupStore.fetchGroupList(true), |
| | | groupRequestStore.pullGroupRequests(), |
| | | groupRequestStore.fetchUnhandledGroupRequestList() |
| | | ]) |
| | | for (const result of results) { |
| | | if (result.status === 'rejected') { |
| | | console.warn('[IM] ç¶æäºä»¶å¢éè¡¥å¿å¤±è´¥', result.reason) |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** æ§è¡ä¸æ¬¡å
¨éå¢éæåï¼éå
¥å®å
¨ï¼è¿è¡ä¸å次è°ç¨å¤ç¨åä¸ä¸ª promiseï¼ */ |
| | | const pullOnce = (): Promise<void> => { |
| | | if (!currentUserId) { |
| | | return Promise.resolve() |
| | | } |
| | | if (pullPromise) { |
| | | return pullPromise |
| | | } |
| | | const startEpoch = pullEpoch |
| | | // å¯å¨æ¶çç¨æ·å¿«ç
§ï¼pullByType æ¯æ¹ await 忝坹å½åç»å½ç¨æ·ï¼è´¦å·åäºç«å»ä¸¢å¼ |
| | | const startUserId = currentUserId |
| | | const abortController = new AbortController() |
| | | pullAbortController = abortController |
| | | // æ¬è½® pull ä»å±äºå½å sessionï¼epoch æªæ¼ + ç¨æ·æªåï¼ä»»ä½å¨æ° store ç¶æçå¯ä½ç¨é½è¦å
è¿è¿éå
³ |
| | | const isCurrentPull = () => |
| | | !abortController.signal.aborted && |
| | | pullEpoch === startEpoch && |
| | | getCurrentUserId() === startUserId |
| | | pullPromise = (async () => { |
| | | try { |
| | | // æ§ puller å¨ cancelPull æªè§¦åçå¼å¸¸è·¯å¾ä¸åè¿æ¥æ¶ï¼å
äºä»»ä½å¯ä½ç¨éåºï¼é¿å
æ±¡ææ° session ç loading |
| | | if (!isCurrentPull()) { |
| | | return |
| | | } |
| | | conversationStore.loading = true |
| | | let messagePullSucceeded = false |
| | | try { |
| | | // å¹¶åæåç§è + 群è + é¢éæ¶æ¯ï¼éä½åå§å è½½èæ¶ |
| | | await Promise.all([ |
| | | pullByType( |
| | | ImConversationType.PRIVATE, |
| | | messageStore.privateMessageMaxId, |
| | | startEpoch, |
| | | startUserId, |
| | | abortController.signal |
| | | ), |
| | | pullByType( |
| | | ImConversationType.GROUP, |
| | | messageStore.groupMessageMaxId, |
| | | startEpoch, |
| | | startUserId, |
| | | abortController.signal |
| | | ), |
| | | pullByType( |
| | | ImConversationType.CHANNEL, |
| | | messageStore.channelMessageMaxId, |
| | | startEpoch, |
| | | startUserId, |
| | | abortController.signal |
| | | ) |
| | | ]) |
| | | messagePullSucceeded = true |
| | | } catch (error) { |
| | | if (isAbortError(error)) { |
| | | return |
| | | } |
| | | console.error('[IM] æåç¦»çº¿æ¶æ¯å¤±è´¥:', error) |
| | | } finally { |
| | | // ä»å±æ¬è½®æå¤ä½ loadingï¼æ§è½®è¢« cancel / åè´¦å·æ¶ç±æ°ä¸è½®èªç®¡ï¼é¿å
è¦çæ° session ç true |
| | | if (isCurrentPull()) { |
| | | conversationStore.loading = false |
| | | } |
| | | } |
| | | |
| | | // åæ¶ / åè´¦å·åè·³è¿ flushBuffer / æåº / 已读ä½ç½®è¡¥é½ |
| | | if (!isCurrentPull()) { |
| | | return |
| | | } |
| | | if (!messagePullSucceeded) { |
| | | return |
| | | } |
| | | |
| | | // åæ¾ WebSocket å¨ loading æé´æ¶å°çç¼å²æ¶æ¯ |
| | | const buffered = wsStore.flushBuffer() |
| | | const replayPersistPromises: Promise<void>[] = [] |
| | | for (const item of buffered) { |
| | | if (item.conversationType === ImConversationType.PRIVATE) { |
| | | replayPersistPromises.push(wsStore.handlePrivateMessage(item.payload)) |
| | | } else if (item.conversationType === ImConversationType.CHANNEL) { |
| | | replayPersistPromises.push(wsStore.handleChannelMessage(item.payload)) |
| | | } else { |
| | | replayPersistPromises.push(wsStore.handleGroupMessage(item.payload)) |
| | | } |
| | | } |
| | | await Promise.all(replayPersistPromises) |
| | | |
| | | // pull + replay é½å®æååæåºï¼é¿å
åæ¾æ¶æ¯æä¹±é¡ºåº |
| | | conversationStore.sortConversationList() |
| | | |
| | | // éè¿ / å·å¯å¨åè¡¥é½å½åæ¿æ´»ç§èä¼è¯çã对æ¹å·²è¯»ä½ç½®ã |
| | | // 离线æé´éè¿ç RECEIPT æ¨éä¼è¢«è¿éè¡¥åï¼å
¶ä»ç§èä¼è¯çç¨æ·ç¹å¼æ¶ç± index.vue ç watch 触å |
| | | // ç§è已读å
³éæ¶è·³è¿ï¼é¿å
æå°å·²ç¦ç¨æ¥å£è§¦åé误æ¥å¿ |
| | | const active = conversationStore.activeConversation |
| | | if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) { |
| | | try { |
| | | const maxReadId = await apiGetPrivateMaxReadMessageId( |
| | | active.targetId, |
| | | abortController.signal |
| | | ) |
| | | if (!isCurrentPull()) { |
| | | return |
| | | } |
| | | messageStore.updatePrivateReadMaxId(active.targetId, maxReadId) |
| | | if (maxReadId) { |
| | | messageStore.applyMessageReadReceipt({ |
| | | conversationType: ImConversationType.PRIVATE, |
| | | targetId: active.targetId, |
| | | privateReadMaxId: maxReadId |
| | | }) |
| | | } |
| | | } catch (error) { |
| | | if (isAbortError(error)) { |
| | | return |
| | | } |
| | | console.warn('[IM] æå对æ¹å·²è¯»ä½ç½®å¤±è´¥', error) |
| | | } |
| | | } |
| | | } finally { |
| | | // ä»å±æ¬è½®ï¼æ£å¸¸å®æé¦æï¼epoch çä½ userId åäºï¼æ¸
pullPromise é²å¡æ»ã䏿 馿ï¼epoch æ¼ï¼cancelPull å·²æ¸
ï¼no-op |
| | | if (isCurrentPull()) { |
| | | pullPromise = null |
| | | initialPulled = true |
| | | if (pullAbortController === abortController) { |
| | | pullAbortController = null |
| | | } |
| | | } else if (pullEpoch === startEpoch) { |
| | | pullPromise = null |
| | | if (pullAbortController === abortController) { |
| | | pullAbortController = null |
| | | } |
| | | } |
| | | } |
| | | })() |
| | | return pullPromise |
| | | } |
| | | |
| | | /** |
| | | * æç½æé´ WS æ¶ä¸å°æ¨éï¼éè¿åæ¢è¦æ minId è¡¥é½æ¶æ¯ï¼ä¹è¦æ update_time + id è¡¥é½å¥½å / 群 / 群ç³è¯·ç¶æã |
| | | * 馿¬¡è¿æ¥ç± index.vue æ¾å¼é©±å¨ï¼pullOnce ææ¶æ¯ + å store 馿ï¼ï¼è¿éä»
è¦çä¹åçéè¿ã |
| | | * éè¿æ¶ store 已就ä½ï¼pullStateEvents ä¸ pullOnce å¹¶åå³å¯ï¼æ éãå
å°±ä½åææ¶æ¯ãçé¦ç»é¡ºåºçº¦æã |
| | | */ |
| | | watch( |
| | | () => wsStore.isConnected, |
| | | (isConnected) => { |
| | | if (isConnected && initialPulled) { |
| | | void pullOnce() |
| | | void pullStateEvents() |
| | | } |
| | | } |
| | | ) |
| | | |
| | | return { pullOnce, cancelPull, convertPrivateMessage, convertGroupMessage } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { Conversation, Message } from '../types' |
| | | |
| | | import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel' |
| | | import { |
| | | readGroupMessages as apiReadGroupMessages, |
| | | recallGroupMessage as apiRecallGroupMessage, |
| | | sendGroupMessage as apiSendGroupMessage |
| | | } from '#/api/im/message/group' |
| | | import { |
| | | getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId, |
| | | readPrivateMessages as apiReadPrivateMessages, |
| | | recallPrivateMessage as apiRecallPrivateMessage, |
| | | sendPrivateMessage as apiSendPrivateMessage |
| | | } from '#/api/im/message/private' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { MESSAGE_GROUP_READ_ENABLED, MESSAGE_PRIVATE_READ_ENABLED } from '../../utils/config' |
| | | import { ImContentType, ImConversationType, ImMessageStatus } from '../../utils/constants' |
| | | import { getClientConversationId } from '../../utils/db' |
| | | import { |
| | | generateClientMessageId, |
| | | type QuoteMessage, |
| | | serializeMessage, |
| | | type TextMessage, |
| | | withQuotePayload |
| | | } from '../../utils/message' |
| | | import { useConversationStore } from '../store/conversationStore' |
| | | import { useMessageStore } from '../store/messageStore' |
| | | |
| | | /** éææ¬æ¶æ¯çæ©å±é项ï¼éç¨ï¼ */ |
| | | interface SendExtOptions { |
| | | atUserIds?: number[] // 群è @ çç¨æ·ç¼å·å表 |
| | | receipt?: boolean // æ¯å¦éè¦ç¾¤åæ§ï¼é»è®¤ falseï¼ |
| | | targetId?: number // è¦çé»è®¤ç targetId |
| | | /** |
| | | * æ¾å¼æå®ç®æ ä¼è¯ï¼è½¬å / åçæ¨èåºæ¯ï¼ |
| | | * |
| | | * ä¸ä¼ æ¶é»è®¤å conversationStore.activeConversationï¼ä¼ å
¥æ¶ææ¬å¼åé + ä¹è§æ´æ°å°å¯¹åºä¼è¯ï¼ |
| | | * ä¸è¦æ±è¯¥ä¼è¯å½åæ¯æ¿æ´»ç¶æï¼éååç»ãéå½åä¼è¯ãçå¤ä¸ªç®æ ï¼ |
| | | */ |
| | | conversation?: Conversation |
| | | /** 被å¼ç¨æ¶æ¯ï¼å¯éï¼ï¼åè¿ content.quote ç¨äºä¹è§æ¸²æï¼æå¡ç«¯æ quote.messageId 忥éç®è¦ç */ |
| | | quote?: QuoteMessage |
| | | /** |
| | | * å¤ç¨å·²åå¨çæ¬å°å 使¶æ¯ clientMessageIdï¼åªä½ä¸ä¼ åºæ¯ï¼ |
| | | * |
| | | * åªä½ä¸ä¼ é¾è·¯å¨è¯·æ±æå¡ç«¯åå·²ç» insertMessage äºå ä½ï¼å¸¦ blob URL + è¿åº¦æ¡ï¼ï¼ |
| | | * è¿éè·³è¿ buildLocalMessage / insertMessageï¼ç´æ¥æ¿è¿ä¸ª id èµ° ackMessage æ¶å°¾ï¼é¿å
é夿å
¥ä¸¤æ¡ |
| | | */ |
| | | existingClientMessageId?: string |
| | | } |
| | | |
| | | /** |
| | | * æ¶æ¯åé / æ¤å / 已读 ç»åå¼é»è¾ |
| | | * |
| | | * 设计è¦ç¹ï¼ |
| | | * 1. ç§è / ç¾¤èæ¥å£ç¾åå¯¹ç§°ï¼æ conversation.type 忝è°åº¦ï¼å·®å¼å¨åæ¯å
鍿¶å |
| | | * 2. åéèµ°ãä¹è§æ´æ°ãï¼å
insertMessage åå
¥ SENDING å ä½ï¼è¯·æ±æå ackMessage æ´æ°ä¸º NORMALï¼å¤±è´¥æ´æ°ä¸º FAILED |
| | | * 3. æ¤åä¸åä¹è§æ´æ°ï¼æå¡ç«¯éè¿ WebSocket RECALL äºä»¶åä¼ ï¼ç± websocketStore ç»ä¸æ´æ°ç¶æï¼é¿å
ç½ç»å¤±è´¥åä¸å¯åé |
| | | * 4. å·²è¯»ä¸æ¥ï¼æ¬ç«¯ç«å»æ¸
æªè¯»æ°å¹¶è®°å½æ¬å°è¯»ä½ç½®ï¼æ¥å£å¤±è´¥ä»
è®°å½æ¥å¿ |
| | | */ |
| | | export const useMessageSender = () => { |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | |
| | | /** æé æ¬å°ä¹è§æ¶æ¯å¯¹è±¡ */ |
| | | const buildLocalMessage = (opts: { |
| | | atUserIds?: number[] |
| | | clientMessageId: string |
| | | content: string |
| | | targetId: number |
| | | type: number |
| | | }): Message => { |
| | | return { |
| | | clientMessageId: opts.clientMessageId, |
| | | type: opts.type, |
| | | content: opts.content, |
| | | status: ImMessageStatus.SENDING, |
| | | sendTime: Date.now(), |
| | | senderId: getCurrentUserId(), |
| | | targetId: opts.targetId, |
| | | selfSend: true, |
| | | atUserIds: opts.atUserIds |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * åéä»»æç±»åçæ¶æ¯ï¼åºå±å®ç°ï¼ |
| | | * 1. ææ¬ãå¾çãæä»¶ãè¯é³çé½èµ°è¿é |
| | | * 2. type / content ç±è°ç¨æ¹æé |
| | | * 3. è¿åå¼ï¼æå true / 失败 falseï¼å¤±è´¥æ¶æ¬å°å ä½å·²æ FAILEDï¼ï¼åæ°ç¼ºå¤±çæ æ³åéçåºæ¯ä¹è¿ false |
| | | * 转å / åçæ¨èçåºæ¯æè¿åå¼å³å®æ¯å¦ç»§ç»åç»å¨ä½ï¼å¦æçè¨æ¶ä»
å¨åçæååååçè¨ï¼ |
| | | */ |
| | | const sendRaw = async ( |
| | | type: number, |
| | | content: string, |
| | | options?: SendExtOptions |
| | | ): Promise<boolean> => { |
| | | // 1. åæ°æ ¡éªï¼ä¼å
ç¨æ¾å¼ä¼ å
¥ç conversationï¼è½¬ååºæ¯ï¼ï¼å¦ååæ¿æ´»ä¼è¯ |
| | | const conversation = options?.conversation ?? conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return false |
| | | } |
| | | const realTarget = options?.targetId || conversation.targetId |
| | | if (!realTarget) { |
| | | return false |
| | | } |
| | | |
| | | // 2. åå¤ clientMessageIdï¼åªä½ä¸ä¼ é¾è·¯å¨ step 1 å·²ç» insertMessage å ä½ï¼è¿éç´æ¥å¤ç¨ idï¼å
¶ä½åºæ¯èµ°é»è®¤ä¹è§æå
¥ |
| | | let clientMessageId: string |
| | | if (options?.existingClientMessageId) { |
| | | clientMessageId = options.existingClientMessageId |
| | | // å ä½è¥å·²è¢«å é¤ï¼ä¸ä¼ æé´ç¨æ·å³é®å é¤ / æ¤å / removeMessage çï¼åæ¾å¼åéï¼ |
| | | // å¦å sendRaw ä»ä¼ææ¶æ¯æ¨å°æå¡ç«¯ï¼å¯¼è´"æ¬å°æ æ°æ³¡ / 对æ¹å´æ¶å°ä¸æ¡" |
| | | const stillExists = messageStore |
| | | .getMessageList(conversation.type, realTarget) |
| | | .some((message) => message.clientMessageId === clientMessageId && !message._ackMerging) |
| | | if (!stillExists) { |
| | | return false |
| | | } |
| | | } else { |
| | | clientMessageId = generateClientMessageId() |
| | | const message = buildLocalMessage({ |
| | | clientMessageId, |
| | | content, |
| | | targetId: realTarget, |
| | | type, |
| | | atUserIds: options?.atUserIds |
| | | }) |
| | | const conversationInfo = { |
| | | type: conversation.type, |
| | | targetId: realTarget, |
| | | name: conversation.name || String(realTarget), |
| | | avatar: conversation.avatar || '' |
| | | } |
| | | void messageStore.insertMessage(conversationInfo, message).catch(() => undefined) |
| | | } |
| | | |
| | | // 3. åé请æ±ï¼æä¼è¯ç±»åååå°ä¸åæ¥å£ï¼æåå ackMessage æ´æ°ä¸º NORMALï¼å¤±è´¥æ´æ°ä¸º FAILED |
| | | try { |
| | | if (conversation.type === ImConversationType.PRIVATE) { |
| | | const data = await apiSendPrivateMessage({ |
| | | clientMessageId, |
| | | receiverId: realTarget, |
| | | type, |
| | | content |
| | | }) |
| | | void messageStore |
| | | .ackMessage(conversation.type, realTarget, clientMessageId, { |
| | | id: data.id, |
| | | sendTime: new Date(data.sendTime).getTime(), |
| | | status: data.status, |
| | | receiptStatus: data.receiptStatus, |
| | | content: data.content |
| | | }) |
| | | .catch(() => undefined) |
| | | } else if (conversation.type === ImConversationType.GROUP) { |
| | | const data = await apiSendGroupMessage({ |
| | | clientMessageId, |
| | | groupId: realTarget, |
| | | type, |
| | | content, |
| | | atUserIds: options?.atUserIds, |
| | | receipt: options?.receipt |
| | | }) |
| | | void messageStore |
| | | .ackMessage(conversation.type, realTarget, clientMessageId, { |
| | | id: data.id, |
| | | sendTime: new Date(data.sendTime).getTime(), |
| | | status: data.status, |
| | | receiptStatus: data.receiptStatus, |
| | | readCount: data.readCount, |
| | | content: data.content |
| | | }) |
| | | .catch(() => undefined) |
| | | } |
| | | return true |
| | | } catch (error) { |
| | | console.error('[IM] æ¶æ¯åé失败', { type, realTarget, clientMessageId }, error) |
| | | void messageStore |
| | | .ackMessage(conversation.type, realTarget, clientMessageId, { |
| | | status: ImMessageStatus.FAILED |
| | | }) |
| | | .catch(() => undefined) |
| | | return false |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * åéææ¬æ¶æ¯ï¼æå¸¸ç¨çå¿«æ·å
¥å£ï¼ï¼message-input.vue ææ¬å车走è¿é |
| | | * è¿åå¼ï¼æå true / 失败 false / ç©ºææ¬ falseï¼ä¸ sendRaw 对é½ï¼è½¬ååºæ¯æè¿åå¼å¤æï¼ |
| | | */ |
| | | const send = async (text: string, options?: SendExtOptions): Promise<boolean> => { |
| | | if (!text.trim()) { |
| | | return false |
| | | } |
| | | const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote) |
| | | return sendRaw(ImContentType.TEXT, serializeMessage(payload), options) |
| | | } |
| | | |
| | | /** |
| | | * æ¤åææ¡æ¶æ¯ |
| | | * 1. æå¡ç«¯ä¼éè¿ WebSocket RECALL äºä»¶åä¼ ï¼æ¬ç«¯ UI ç± websocketStore ç»ä¸æ´æ° |
| | | * 2. æ¤å¤ä¸åä¹è§æ¤åï¼é¿å
ç½ç»å¤±è´¥åç¶æä¸å¯åé |
| | | */ |
| | | const recall = async (message: Message) => { |
| | | // åæ°æ ¡éªï¼æ¬å°å 使¶æ¯ä¸è½æ¤å |
| | | if (!message.id) { |
| | | return |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | // ç§è / ç¾¤èæ¥å£ç¾åä¸è´ï¼æä¼è¯ç±»ååå |
| | | const isPrivate = conversation.type === ImConversationType.PRIVATE |
| | | try { |
| | | await (isPrivate ? apiRecallPrivateMessage(message.id) : apiRecallGroupMessage(message.id)) |
| | | } catch (error) { |
| | | console.error('[IM] æ¤å失败', { messageId: message.id, type: conversation.type }, error) |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 触åå½åä¼è¯çå·²è¯»ä¸æ¥ï¼åä¼è¯ / è¿å
¥é¡µé¢æ¶è°ç¨ï¼ |
| | | * 1. æ¬ç«¯ç«å»æ¸
æªè¯»æ°å¹¶æ¨è¿è¯»ä½ç½® |
| | | * 2. 已读ä½ç½®åå·²å è½½æ¶æ¯åä¼è¯æ«æ¡æ¶æ¯çæå¤§æå¡ç«¯ id |
| | | */ |
| | | const readActive = async () => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | let loadedMaxMessageId = 0 |
| | | for (const message of messageStore.getMessages( |
| | | getClientConversationId(conversation.type, conversation.targetId) |
| | | )) { |
| | | if (message.id && message.id > loadedMaxMessageId) { |
| | | loadedMaxMessageId = message.id |
| | | } |
| | | } |
| | | const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) |
| | | const readReported = conversationStore.isReportedReadPositionCovered( |
| | | conversation.type, |
| | | conversation.targetId, |
| | | maxMessageId |
| | | ) |
| | | if (readReported) { |
| | | conversationStore.markConversationRead(conversation.type, conversation.targetId) |
| | | return |
| | | } |
| | | const isPrivate = conversation.type === ImConversationType.PRIVATE |
| | | const isGroup = conversation.type === ImConversationType.GROUP |
| | | const isChannel = conversation.type === ImConversationType.CHANNEL |
| | | // æ¬å°æ è®°å·²è¯»ï¼æªè¯»æ°æ¸
é¶ï¼UI ç«å»ååºï¼ |
| | | conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId) |
| | | if (!maxMessageId) { |
| | | return |
| | | } |
| | | // æ¥å£è°ç¨ï¼æä¼è¯ç±»åååï¼å¹¶æå¯¹åºå·²è¯»å¼å
³æ§å¶ |
| | | if (!isPrivate && !isGroup && !isChannel) { |
| | | return |
| | | } |
| | | if (isPrivate && !MESSAGE_PRIVATE_READ_ENABLED) { |
| | | return |
| | | } |
| | | if (isGroup && !MESSAGE_GROUP_READ_ENABLED) { |
| | | return |
| | | } |
| | | try { |
| | | if (isPrivate) { |
| | | await apiReadPrivateMessages(conversation.targetId, maxMessageId) |
| | | } else if (isGroup) { |
| | | await apiReadGroupMessages(conversation.targetId, maxMessageId) |
| | | } else { |
| | | await apiReadChannelMessages(conversation.targetId, maxMessageId) |
| | | } |
| | | conversationStore.markConversationReadReported( |
| | | conversation.type, |
| | | conversation.targetId, |
| | | maxMessageId |
| | | ) |
| | | } catch (error) { |
| | | console.error( |
| | | '[IM] æ 记已读失败', |
| | | { type: conversation.type, targetId: conversation.targetId, maxMessageId }, |
| | | error |
| | | ) |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * æåã对æ¹å·²è¯»å°æåªæ¡æ¶æ¯ãå¹¶è¡¥é½æ¬å°ç¶æ |
| | | * |
| | | * 1. 弥补离线 / å¤ç«¯æé´éè¿ç RECEIPT æ¨éï¼è¿å
¥ç§èä¼è¯ææçº¿éè¿åè°ä¸æ¬¡ï¼ |
| | | * æå¯¹æ¹ maxReadId 忥尿¬å°æ¶æ¯ statusï¼é¿å
å¯¹æ¹ææè¯»äºãæ¬ç«¯å´ä»æ¾ç¤ºæªè¯» |
| | | * 2. ä»
ç§è使ç¨ï¼ç¾¤è已读ä½ç½®å¨æ¯æ¡æ¶æ¯ç readCount / receiptStatus åæ®µï¼ç¦»çº¿æåèªå¸¦å |
| | | */ |
| | | const syncPrivateReadStatus = async (peerId: number) => { |
| | | if (!peerId) { |
| | | return |
| | | } |
| | | // ç§è已读å
³éï¼è·³è¿å¯¹æ¹å·²è¯»ä½ç½®åæ¥ï¼é¿å
æ è°æ¥å£è°ç¨ |
| | | if (!MESSAGE_PRIVATE_READ_ENABLED) { |
| | | return |
| | | } |
| | | const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId) |
| | | if (cachedMaxReadId !== undefined) { |
| | | if (cachedMaxReadId > 0) { |
| | | messageStore.applyMessageReadReceipt({ |
| | | conversationType: ImConversationType.PRIVATE, |
| | | targetId: peerId, |
| | | privateReadMaxId: cachedMaxReadId |
| | | }) |
| | | } |
| | | return |
| | | } |
| | | try { |
| | | // æå对æ¹å·²è¯»å°çæå¤§æ¶æ¯ id |
| | | const maxReadId = await apiGetPrivateMaxReadMessageId(peerId) |
| | | messageStore.updatePrivateReadMaxId(peerId, maxReadId) |
| | | if (!maxReadId) { |
| | | return |
| | | } |
| | | // applyMessageReadReceipt å
鍿 ⤠maxReadId çæ¬ç«¯æ¶æ¯åæ§æ´æ°ä¸º DONE |
| | | messageStore.applyMessageReadReceipt({ |
| | | conversationType: ImConversationType.PRIVATE, |
| | | targetId: peerId, |
| | | privateReadMaxId: maxReadId |
| | | }) |
| | | } catch (error) { |
| | | console.warn('[IM] æå对æ¹å·²è¯»ä½ç½®å¤±è´¥', { peerId }, error) |
| | | } |
| | | } |
| | | |
| | | return { send, sendRaw, recall, readActive, syncPrivateReadStatus } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { computed, type ComputedRef, onScopeDispose, ref } from 'vue' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { ImConversationType, ImGroupMemberRole } from '../../utils/constants' |
| | | import { isGroupQuit } from '../../utils/user' |
| | | import { useConversationStore } from '../store/conversationStore' |
| | | import { useGroupStore } from '../store/groupStore' |
| | | |
| | | export type MuteOverlayInfo = { icon: string; text: string; } |
| | | |
| | | /** |
| | | * 模å级å
±äº« now tick + 订é
计æ°ï¼ |
| | | * MessageItem v-for æ¶æ¯æ¡æ¶æ¯é½ä¼ useMuteOverlay()ï¼åå®ä¾èªèµ· timer ä¼åæå ç¾ä¸ª 30s 宿¶å¨ï¼ |
| | | * æ¹ææ¨¡å级å
±äº«åææè®¢é
è
å
±ç¨ä¸ä»½ setIntervalï¼è®¢é
æ°æ¸
é¶æ¶ä¹æ¸
æ timerï¼é¿å
å
å䏿¶éæ¼ç§» |
| | | */ |
| | | const sharedNow = ref(Date.now()) |
| | | let sharedTickTimer: null | number = null |
| | | let subscriberCount = 0 |
| | | |
| | | function subscribeNowTick(): void { |
| | | subscriberCount++ |
| | | if (!sharedTickTimer) { |
| | | sharedTickTimer = window.setInterval(() => { |
| | | sharedNow.value = Date.now() |
| | | }, 30_000) |
| | | } |
| | | } |
| | | |
| | | function unsubscribeNowTick(): void { |
| | | subscriberCount-- |
| | | if (subscriberCount <= 0) { |
| | | subscriberCount = 0 |
| | | if (sharedTickTimer) { |
| | | window.clearInterval(sharedTickTimer) |
| | | sharedTickTimer = null |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * å½åæ¿æ´»ä¼è¯çç¦è¨ / å°ç¦ç¶æï¼éç¾¤èææ ç¦è¨æ¶è¿å null |
| | | * |
| | | * ä¼å
级ï¼å°ç¦ > å
¨ç¾¤ç¦è¨ï¼ç¾¤ä¸» / 管çåè±å
ï¼> æåç¦è¨ |
| | | * |
| | | * MessageInput é¡¶é¨ overlay / handleResend éè¯ / uploadAndSendMedia ä¸ä¼ å®åé½å
±ç¨ä¸ä»½ï¼ |
| | | * é¿å
ãè¾å
¥æ¡æ¦äºä½éè¯ç»è¿ããä¸ä¼ æé´è¢«ç¦è¨ä½ sendRaw ä»ååºå»ãä¹ç±»çä¸ä¸è´ |
| | | */ |
| | | export function useMuteOverlay(): ComputedRef<MuteOverlayInfo | null> { |
| | | const conversationStore = useConversationStore() |
| | | const groupStore = useGroupStore() |
| | | |
| | | // 订é
模å级 tickï¼scope 鿝æ¶å订é
ï¼æåä¸ä¸ªè®¢é
è
éåºå timer ä¹è·çæ¸
|
| | | subscribeNowTick() |
| | | onScopeDispose(unsubscribeNowTick) |
| | | |
| | | return computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return null |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | if (!group) { |
| | | return null |
| | | } |
| | | // åå²é群群ï¼å·²é群åªè½æ¥çåå²ï¼ç¦æ¢åéï¼ææ¬ / å¾ç / æä»¶ / è¯é³ / éè¯å
±ç¨è¿ä¸å±æ¦æªï¼ |
| | | if (isGroupQuit(group)) { |
| | | return { text: 'ä½ å·²éåºç¾¤èï¼ä»
坿¥çå岿¶æ¯', icon: 'ant-design:logout-outlined' } |
| | | } |
| | | const myId = getCurrentUserId() |
| | | // 群å°ç¦ï¼ç®¡çåå°æä½ï¼ææäººä¸å¯åé |
| | | if (group.banned) { |
| | | return { text: '该群已被管çåå°ç¦ï¼æ æ³åéæ¶æ¯', icon: 'ant-design:stop-outlined' } |
| | | } |
| | | // å
¨ç¾¤ç¦è¨ï¼ç¾¤ä¸»èµ° ownerUserId æ¯è¾ç´æ¥è±å
ï¼å
¶å®äººéè¦æåå表å è½½å®æè½åºå管çå vs æ®éæå |
| | | // - å è½½å® + ææ¯ç®¡çå â è±å
|
| | | // - å è½½å® + æä¸æ¯ç®¡çåï¼å«å·²é群ï¼â æ¦ |
| | | // - å è½½æªå® â 䏿¾ç¤º overlayï¼å端å
åºæç»æ®éæåï¼é¿å
误æ¦ç®¡çå |
| | | if (group.mutedAll && myId !== group.ownerUserId && group.membersLoaded) { |
| | | const myMember = group.members?.find((m) => m.userId === myId) |
| | | if (myMember?.role !== ImGroupMemberRole.ADMIN) { |
| | | return { text: 'å
¨ç¾¤ç¦è¨ä¸ï¼ææ¶æ æ³åéæ¶æ¯', icon: 'ant-design:audio-muted-outlined' } |
| | | } |
| | | } |
| | | // æåç¦è¨ï¼muteEndTime 卿ªæ¥æç®ï¼ç¨ååºå¼ sharedNow æ¯å¯¹ï¼å°æåä¸ä¸ä¸ª tick 就让 overlay æ¶å¤± |
| | | const myMember = group.members?.find((m) => m.userId === myId) |
| | | if (myMember?.muteEndTime) { |
| | | const endTime = new Date(myMember.muteEndTime) |
| | | if (endTime.getTime() > sharedNow.value) { |
| | | const pad = (n: number) => n.toString().padStart(2, '0') |
| | | const timeStr = |
| | | `${pad(endTime.getMonth() + 1)}-${pad(endTime.getDate())} ` + |
| | | `${pad(endTime.getHours())}:${pad(endTime.getMinutes())}` |
| | | return { |
| | | text: `æ¨å·²è¢«ç¦è¨ï¼è§£é¤æ¶é´ï¼${timeStr}`, |
| | | icon: 'ant-design:audio-muted-outlined' |
| | | } |
| | | } |
| | | } |
| | | return null |
| | | }) |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { computed, type ComputedRef, type Ref } from 'vue' |
| | | |
| | | /** |
| | | * 䏿鿩颿¿çã已鿰 + å·²é项åè¡¨ãæ´¾ç |
| | | * |
| | | * - 䏿ä¼å
级ï¼ä¸ dialog-picker-contract 对é½ï¼ï¼hide > locked > disabled |
| | | * - hideï¼æ°¸è¿åé¤ï¼ä¸è®¡æ° / ä¸è¿å·²éå表 |
| | | * - lockedï¼é»è®¤å¾é + è®¡æ° + ä¼å
æå¨åé¢ï¼å³ä½¿è¢«å¤é¨å¡è¿ disabledIds ä¹èåº |
| | | * - disabledï¼ä»
è¿æ»¤ selectedIdsï¼ä¸è®¡æ° / ä¸è¿å·²éå表 |
| | | * - 顺åºï¼lockedIds å¨åï¼selectedIds ç´§éï¼ææ°ç»é¡ºåºå³ä¸ºãç¹å»é¡ºåºã |
| | | * |
| | | * FriendPickerPanel / GroupMemberPickerPanel å
±ç¨ï¼é¿å
两侧 25 è¡åæ computed ååä¸ä»½ï¼ |
| | | * Panel å
é¨ç isLocked / isDisabled / isSelected çæ¨¡æ¿å¤å®å½æ°ä»åèªç»´æ¤ï¼æ¬ composable åªæ¿æ
æ´¾çé |
| | | */ |
| | | export function useSelectedItems<T>( |
| | | selectedIds: () => readonly number[], |
| | | lockedIds: () => readonly number[], |
| | | disabledIds: () => readonly number[], |
| | | hideIds: () => readonly number[], |
| | | byId: ComputedRef<Map<number, T>> | Ref<Map<number, T>> |
| | | ): { |
| | | selectedCount: ComputedRef<number> |
| | | selectedItems: ComputedRef<T[]> |
| | | } { |
| | | const hideSet = computed(() => new Set(hideIds())) |
| | | const disabledSet = computed(() => new Set(disabledIds())) |
| | | |
| | | const selectedCount = computed(() => { |
| | | const merged = new Set<number>() |
| | | for (const id of selectedIds()) { |
| | | if (hideSet.value.has(id) || disabledSet.value.has(id)) { |
| | | continue |
| | | } |
| | | merged.add(id) |
| | | } |
| | | // locked ä»
被 hide è¿æ»¤ï¼å¥çº¦é locked èè¿ disabledï¼ç¡®ä¿éå®é¡¹å§ç»è®¡å
¥ |
| | | for (const id of lockedIds()) { |
| | | if (hideSet.value.has(id)) { |
| | | continue |
| | | } |
| | | merged.add(id) |
| | | } |
| | | return merged.size |
| | | }) |
| | | |
| | | const selectedItems = computed(() => { |
| | | const seen = new Set<number>() |
| | | const result: T[] = [] |
| | | // locked å¨åï¼ä»
被 hide è¿æ»¤ |
| | | for (const id of lockedIds()) { |
| | | if (seen.has(id) || hideSet.value.has(id)) { |
| | | continue |
| | | } |
| | | const item = byId.value.get(id) |
| | | if (item) { |
| | | seen.add(id) |
| | | result.push(item) |
| | | } |
| | | } |
| | | // selectedIds ç´§éï¼é¢å¤è¿æ»¤ disabled |
| | | for (const id of selectedIds()) { |
| | | if (seen.has(id) || disabledSet.value.has(id) || hideSet.value.has(id)) { |
| | | continue |
| | | } |
| | | const item = byId.value.get(id) |
| | | if (item) { |
| | | seen.add(id) |
| | | result.push(item) |
| | | } |
| | | } |
| | | return result |
| | | }) |
| | | |
| | | return { selectedCount, selectedItems } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { ref } from 'vue' |
| | | |
| | | /** |
| | | * è¯é³ææ¾å
¨å±äºæ¥ |
| | | * |
| | | * 模å级åä¾ï¼å䏿¶å»æå¤åªæä¸æ¡è¯é³å¨æï¼æ°ç¹çè¯é³åææ§ç |
| | | * ä¸å¤å
¥å£å
±äº«åä¸ææ¾æï¼ |
| | | * - MessageBubbleï¼æ°æ³¡ç¹å» toggle å½åæ¡ |
| | | * - MessagePanelï¼åä¼è¯ stop() |
| | | * - MessageHistory / MessageMergeDetailDialogï¼å
³éæ¶ stop() |
| | | * |
| | | * key ç± MessageBubble å¨ setup é Symbol() çæå®ä¾çº§å¯ä¸èº«ä»½ï¼ä¸åæ¥æºï¼ä¸»é¢æ¿ / å岿½å± / |
| | | * å并详æ
ï¼çå䏿¡è¯é³ url å¨ä¸åæ°æ³¡é乿¯ä¸å keyï¼é¿å
ä¸å¤å¸è½½è¯¯åå¦ä¸å¤ä»å¯è§çææ¾ã |
| | | */ |
| | | export type VoiceKey = symbol |
| | | |
| | | interface VoiceTask { |
| | | key: VoiceKey |
| | | url: string |
| | | audio: HTMLAudioElement |
| | | } |
| | | |
| | | const currentTask = ref<null | VoiceTask>(null) |
| | | |
| | | /** |
| | | * æ¾å¼åæ¢ |
| | | * |
| | | * - ä¸ä¼ keyï¼å¼ºåï¼åä¼è¯ / å
³å¼¹çªç¨ï¼ |
| | | * - ä¼ keyï¼ä»
å½å½å task æ¯è¯¥ key æ¶æåï¼æ°æ³¡å¸è½½å
åºç¨ï¼é¿å
误åå«äººï¼ |
| | | */ |
| | | function stop(key?: VoiceKey) { |
| | | const task = currentTask.value |
| | | if (!task) { |
| | | return |
| | | } |
| | | if (key !== undefined && task.key !== key) { |
| | | return |
| | | } |
| | | task.audio.pause() |
| | | // removeAttribute('src') + load() æ¯ W3C æ¨èçéæ¾å§¿å¿ï¼ä¸ä¼è§¦å空 src å 载导è´ç error äºä»¶ï¼ |
| | | // ä¹è½è®©æµè§å¨ç«å³éæ¾åºå± decoder bufferï¼æ¯ audio.src = '' æ´å¹²å |
| | | task.audio.removeAttribute('src') |
| | | task.audio.load() |
| | | currentTask.value = null |
| | | } |
| | | |
| | | /** |
| | | * åæ¢ææ¾ |
| | | * |
| | | * - å key åç¹ï¼åæ |
| | | * - åå°æ° keyï¼åæ§èµ·æ° |
| | | */ |
| | | function play(key: VoiceKey, url: string) { |
| | | if (!url) { |
| | | return |
| | | } |
| | | if (currentTask.value?.key === key) { |
| | | stop(key) |
| | | return |
| | | } |
| | | stop() |
| | | const audio = new Audio(url) |
| | | const task: VoiceTask = { key, url, audio } |
| | | /** ææ¾ç»æ / å¼å¸¸æ¸
æ ï¼åªæ¸
å½åä»»å¡ï¼é¿å
被åç»æ°ä»»å¡çåè°è¯¯æ¸
*/ |
| | | const finalize = () => { |
| | | if (currentTask.value === task) { |
| | | currentTask.value = null |
| | | } |
| | | } |
| | | audio.addEventListener('ended', finalize, { once: true }) |
| | | audio.addEventListener('error', finalize, { once: true }) |
| | | currentTask.value = task |
| | | audio.play().catch(finalize) |
| | | } |
| | | |
| | | export function useVoicePlayer() { |
| | | /** æå® key æ¯å¦æ£å¨ææ¾ */ |
| | | function isPlaying(key: VoiceKey): boolean { |
| | | return currentTask.value?.key === key |
| | | } |
| | | return { isPlaying, play, stop } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation } from './types' |
| | | |
| | | import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' |
| | | import { useRoute } from 'vue-router' |
| | | |
| | | import { preferences } from '#/packages/preferences/src' |
| | | |
| | | import { ImConversationType } from '../utils/constants' |
| | | import { initDb, stopRequests, StorageKeys } from '../utils/db' |
| | | import { ContextMenu, ToolBar } from './components' |
| | | import { GroupInfoCard } from './components/group' |
| | | import { RtcCallContainer } from './components/rtc' |
| | | import { UserInfoCard } from './components/user' |
| | | import { useMessagePuller } from './composables/useMessagePuller' |
| | | import { useMessageSender } from './composables/useMessageSender' |
| | | import { useVoicePlayer } from './composables/useVoicePlayer' |
| | | import { useChannelStore } from './store/channelStore' |
| | | import { useConversationStore } from './store/conversationStore' |
| | | import { useFaceStore } from './store/faceStore' |
| | | import { useFriendStore } from './store/friendStore' |
| | | import { useGroupRequestStore } from './store/groupRequestStore' |
| | | import { useGroupStore } from './store/groupStore' |
| | | import { useMessageStore } from './store/messageStore' |
| | | import { useImWebSocketStore } from './store/websocketStore' |
| | | |
| | | defineOptions({ name: 'ImIndex' }) |
| | | |
| | | const route = useRoute() |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const webSocketStore = useImWebSocketStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | const groupRequestStore = useGroupRequestStore() |
| | | const faceStore = useFaceStore() |
| | | const channelStore = useChannelStore() |
| | | const { pullOnce, cancelPull } = useMessagePuller() |
| | | const { readActive, syncPrivateReadStatus } = useMessageSender() |
| | | const voicePlayer = useVoicePlayer() |
| | | const childRouteReady = ref(false) // åè·¯ç±æ¯å¦å¯æè½½ |
| | | |
| | | /** åå§åï¼å
忬å°ç¼å让é¦å±ç«å³æ¸²æï¼åè¿ç«¯å·æ°ææ°æ°æ®ï¼æåå»ºå®æ¶éä¿¡æç¦»çº¿æ¶æ¯ */ |
| | | onMounted(async () => { |
| | | // 0.1 ç³»ç»è¡¨æ
å
åå°é¢æï¼ç¬ç«é¾è·¯ä¸é¦å± IDB / è¿ç«¯æåå¹¶åï¼æ¶é¤è¡¨æ
颿¿é¦æ¬¡å±å¼ç½å±ï¼å¤±è´¥ä»
è®°æ¥å¿ï¼ä¸é»å¡ä¸»æµç¨ |
| | | void faceStore.ensureFacePackList().catch((error) => console.warn('[IM] åå°é¢æè¡¨æ
å
失败', error)) |
| | | // 1.1 æ´æ®µ loading=true 黿ä¼è¯å表æå¨åç + WebSocket æ®éæ¶æ¯è¿ç¼å²ï¼é¿å
connect å° pullOnce ä¹é´æ¶å°ç宿¶æ¶æ¯æ¨è¿ maxId å¯¼è´ pull è·³è¿æçº¿ç§¯åæ¶æ¯ |
| | | conversationStore.loading = true |
| | | try { |
| | | // 1.2 æå¼å½åç¨æ· IM DB |
| | | await initDb() |
| | | // 1.3 å¤ä¸ª store å¹¶åä» IDB è¯»åæ¬å°ç¼å |
| | | const cacheResults = await Promise.all([ |
| | | conversationStore.loadConversationList(), |
| | | messageStore.loadMessageCursorList(), |
| | | friendStore.loadFriendData(), |
| | | groupStore.loadGroupList(), |
| | | channelStore.loadChannelList(), |
| | | groupRequestStore.loadGroupRequestList() |
| | | ]) |
| | | const hasFriendRows = cacheResults[2] |
| | | const hasGroupRows = cacheResults[3] |
| | | const hasChannelRows = cacheResults[4] |
| | | groupStore.markAllGroupActiveCallsExpired() |
| | | groupStore.markAllGroupMembersExpired() |
| | | childRouteReady.value = true |
| | | // 1.4 æç®¡ççç¾¤ä¸æªå¤çå 群ç³è¯·çº¢ç¹ï¼é¦ç»ç¨ unhandled-listï¼æå¡ç«¯ç´æ¥è¿æ»¤æªå¤çï¼è¯ä¹ç²¾åãå¯å¨è½»ï¼ï¼ |
| | | // pullGroupRequests åªå¨éè¿ / åç»è¡¥å¿æ¶è·ï¼è§ useMessagePuller.pullStateEventsï¼ï¼ä¸è¿é¦ç»ä¸»é¾è·¯ |
| | | void groupRequestStore |
| | | .fetchUnhandledGroupRequestList() |
| | | .catch((error) => console.warn('[IM] æåæªå¤çå 群ç³è¯·å¤±è´¥', error)) |
| | | |
| | | // 2. 好åä¸»æ°æ®æ¢å¤èµ° pullï¼ç¾¤å表ç¨å¿«ç
§å·æ°ï¼è¦ç离线æé´èªå·±çå
¥ç¾¤ / éç¾¤ç¶æåå |
| | | // 2.1 æç¼åï¼å¼æ¥èæ¯å¢éå·æ°ï¼å¤±è´¥ä»
è®°æ¥å¿ï¼IDB æ°æ®å·²ç»å¤æé¦å±ï¼pullOnce ä¹è½æ£å¸¸å
¥åºï¼ |
| | | // 2.2 æ ç¼åï¼é¦ç» / åè´¦å·ååï¼ï¼å¿
é¡» await + 失败æåºä¸ææ¬è½® onMountedï¼ |
| | | // å¦å pullOnce ä¼ç¨ senderId æ°åç»ä¼è¯èµ·åè½å° IDB åç»åºæ¬æ æ³èªæï¼æ ç¼å忝并å Promise.all çä¸ä¸ª RTT |
| | | const requiredFetches: Promise<unknown>[] = [] |
| | | if (hasFriendRows) { |
| | | void friendStore.pullFriends().catch((error) => console.warn('[IM] åå°å¢éæå¥½å失败', error)) |
| | | } else { |
| | | requiredFetches.push(friendStore.pullFriends()) |
| | | } |
| | | if (hasGroupRows) { |
| | | void groupStore.fetchGroupList(true).catch((error) => console.warn('[IM] åå°å·æ°ç¾¤å表失败', error)) |
| | | } else { |
| | | requiredFetches.push(groupStore.fetchGroupList(true)) |
| | | } |
| | | // 2.3 é¢éæ å¢é pull æ¥å£ï¼ç»§ç»èµ° list |
| | | if (hasChannelRows) { |
| | | void channelStore.fetchChannelList().catch((error) => console.warn('[IM] åå°å·é¢éå表失败', error)) |
| | | } else { |
| | | requiredFetches.push(channelStore.fetchChannelList()) |
| | | } |
| | | // 2.4 æ§è¡å è½½ |
| | | if (requiredFetches.length > 0) { |
| | | await Promise.all(requiredFetches) |
| | | } |
| | | |
| | | // 2.5 好åç³è¯·å¢éè¡¥å¿ï¼é¦ç»ä¹è¦è·ï¼ç¦»çº¿æé´å¥½åç³è¯·åæ´ä¸ä¼å½±å好å主表 |
| | | void friendStore |
| | | .pullFriendRequests() |
| | | .catch((error) => console.warn('[IM] åå°å¢éæå¥½åç³è¯·å¤±è´¥', error)) |
| | | |
| | | // 3. ä¼è¯è¯»ä½ç½®å
è¡¥å¿ï¼æ¶æ¯å
¥åºæ¶å¯ç´æ¥è¿æ»¤å·²è¯»å岿¶æ¯ |
| | | await conversationStore |
| | | .pullConversationReads() |
| | | .catch((error) => console.warn('[IM] æåä¼è¯è¯»ä½ç½®å¤±è´¥', error)) |
| | | |
| | | // 4. 宿¶éä¿¡ï¼å»º WebSocket é¿è¿æ¥ + æç¦»çº¿æ¶æ¯ï¼pullOnce finally æ loading å½ä½ï¼ |
| | | webSocketStore.connect() |
| | | await pullOnce() |
| | | |
| | | // 5. é»è®¤éä¸ç¬¬ä¸ä¸ªä¼è¯ï¼è¥ç½®é¡¶åç»å¤äºæå æï¼éè·³è¿è¢«æå éèç置顶项ï¼é¿å
èªå¨å±å¼æå |
| | | const sorted = conversationStore.getSortedConversationList |
| | | const firstVisible = pickFirstVisibleConversation(sorted) |
| | | if (firstVisible && !conversationStore.activeConversation) { |
| | | conversationStore.setActiveConversation(firstVisible) |
| | | } |
| | | } catch (error) { |
| | | // 1. é¦æå¤±è´¥ï¼æå¨å¤ä½ loadingï¼pullOnce 没è·å°ï¼å®ç finally å
ä¸å°è¿éï¼ï¼å¦ååç»ä¼è¯å表åå
¥å
¨è¢«æ© return 黿 |
| | | // 2. WebSocket ä¸å¨è¿é disconnectââè·¯ç±ç¦»å¼ä¼èµ° onUnmounted èªç¶æ¸
çï¼ç¨æ·ä¹å¯ä»¥å·æ°éè¯ |
| | | conversationStore.loading = false |
| | | console.error('[IM] åå§å失败', error) |
| | | } |
| | | }) |
| | | |
| | | /** |
| | | * éé¦å±èªå¨æ¿æ´»çä¼è¯ï¼ç½®é¡¶åç»æå æ¶è·³è¿è¢«æå éèç置顶项ï¼é¿å
æ¿æ´»å被èªå¨é¡¶ä¸æ¥ |
| | | * |
| | | * æå æå¤å®åªçç¨æ·å¼å
³ï¼é置顶 / ææªè¯»ç置顶项å§ç»å¯è§ï¼å
¨æ¯å¯æå 置顶æ¶åéå° sorted[0] å
åº |
| | | */ |
| | | function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | undefined { |
| | | if (sorted.length === 0) { |
| | | return undefined |
| | | } |
| | | const pinnedExpanded = |
| | | localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true' |
| | | if (pinnedExpanded) { |
| | | return sorted[0] |
| | | } |
| | | return sorted.find((c) => !c.top || (!c.silent && (c.unreadCount || 0) > 0)) ?? sorted[0] |
| | | } |
| | | |
| | | /** æ ç¾å
³éå flush è稿éåï¼debounce é»è®¤ trail-edge 触åï¼æå䏿¬¡è¾å
¥å¯è½è¿åå¨éåé */ |
| | | function onBeforeUnload() { |
| | | conversationStore.flushConversationDraftSave() |
| | | } |
| | | window.addEventListener('beforeunload', onBeforeUnload) |
| | | |
| | | /** ç¦»å¼ IM 主壳ï¼åæ¶ pullãæå¼ WebSocketãä¿åè稿ã忢è¯é³ãè§£ç» unloadï¼å¹¶ç»æå½å IM session */ |
| | | onUnmounted(() => { |
| | | cancelPull() |
| | | webSocketStore.disconnect() |
| | | conversationStore.flushConversationDraftSave() |
| | | faceStore.clear() |
| | | // 模å级åä¾ audio ä¸ä¼éè§å¾å¸è½½èªå¨åï¼ä¸»å¨åæé¿å
åè·¯ç±åè¯é³ç»§ç»å |
| | | voicePlayer.stop() |
| | | window.removeEventListener('beforeunload', onBeforeUnload) |
| | | // 忢å½å IM session å¹¶æ¸
çå store å
å |
| | | void stopRequests() |
| | | }) |
| | | |
| | | /** |
| | | * å½åä¼è¯åæ¢ï¼æ¬å°æ¸
é¶æªè¯» + 䏿¥å端已读 + ç§èè¡¥"对æ¹å·²è¯»å°åªæ¡" |
| | | * |
| | | * type+targetId ä¸èµ·çå¬ï¼ç§èä¸ç¾¤è id åå·æ¶åæ¢ä¹è½è§¦åï¼å
¶å®ä¼è¯å·²è¯»ç¶æç± WebSocket |
| | | * READ / RECEIPT äºä»¶è¢«å¨åæ¥ãç§èè¡¥ä¸æ¬¡æå¯¹æ¹å·²è¯»ä½ç½®ï¼å¼¥è¡¥ç¦»çº¿ / å¤ç«¯æ¼æç RECEIPT æ¨é |
| | | */ |
| | | watch( |
| | | () => [ |
| | | conversationStore.activeConversation?.type, |
| | | conversationStore.activeConversation?.targetId |
| | | ], |
| | | async ([type, targetId]) => { |
| | | if (!targetId) { |
| | | return |
| | | } |
| | | // æ¬å°æ¸
é¶æªè¯» + 䏿¥å端已读ï¼è®©å
¶å®ç«¯ / å¯¹æ¹ UI 忥 |
| | | await readActive() |
| | | // ç§èè¡¥ä¸æ¬¡"对æ¹å·²è¯»å°åªæ¡"ï¼å¼¥è¡¥ç¦»çº¿ / å¤ç«¯æ¼æç RECEIPT æ¨é |
| | | if (type === ImConversationType.PRIVATE) { |
| | | void syncPrivateReadStatus(targetId) |
| | | } |
| | | } |
| | | ) |
| | | |
| | | /** |
| | | * æµè§å¨æ ç¾ title æ¼ä¸æªè¯»æ°åç¼ï¼ä¾ï¼(63æ¡æªè¯»)èéæºç |
| | | * |
| | | * è·¯ç±åæ¢æ¶ router.afterEach ä¼è° useTitle éç½® titleï¼ç¨ nextTick æå¨å®ä¹ååè¦ç |
| | | * ä¸å¹¶çå¬ route.fullPathï¼IM åè·¯ç±åæ¢ï¼æ¶æ¯ / é讯å½ï¼ä¹è½éæ°å ä¸åç¼ |
| | | */ |
| | | watch( |
| | | [() => conversationStore.getTotalUnreadCount, () => route.fullPath], |
| | | ([count]) => { |
| | | nextTick(() => { |
| | | const base = preferences.app.name |
| | | document.title = count > 0 ? `(${count > 99 ? '99+' : count}æ¡æªè¯»)${base}` : base |
| | | }) |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | IM å¤å±å®¹å¨ï¼è天模åçå
¨å±æ²æµ¸å¼å£³ |
| | | - 左侧 ToolBarï¼å¤´å + ä¸ Tabï¼æ¶æ¯/好å/群èï¼+ åºé¨è®¾ç½® |
| | | - å³ä¾§ <router-view>ï¼æè·¯ç±æ¸²æ MessagePage / FriendPage / GroupPage |
| | | - æè½½å
¨å±å¼¹å±ï¼UserInfoCard / GroupInfoCard / ContextMenu |
| | | --> |
| | | <div |
| | | class="im-home flex w-full h-full overflow-hidden bg-[var(--ant-color-bg-layout)] text-[var(--ant-color-text)]" |
| | | > |
| | | <ToolBar /> |
| | | <!-- |
| | | keep-alive ç¼åå页é¢ï¼ |
| | | - å Tab ä¸é建ç»ä»¶ï¼MessagePanel æ»å¨ä½ç½®ãè¾å
¥æ¡è稿ç UI ç¶æä¸ä¸¢ |
| | | - Vue 3 é keep-alive ä¸è½ç´æ¥å
<router-view>ï¼ä¼æè¦åï¼ï¼å¿
须走 v-slot æ¿ Component |
| | | --> |
| | | <router-view v-if="childRouteReady" v-slot="{ Component }"> |
| | | <keep-alive> |
| | | <component :is="Component" /> |
| | | </keep-alive> |
| | | </router-view> |
| | | <div v-else class="flex-1 bg-[var(--ant-color-bg-container)]"></div> |
| | | |
| | | <!-- å
¨å±å¼¹å±ï¼ç± useImUiStore ç»ä¸è°åº¦ --> |
| | | <UserInfoCard /> |
| | | <GroupInfoCard /> |
| | | <ContextMenu /> |
| | | |
| | | <!-- 宿¶éè¯æµ®å±ï¼çå¬ rtcStore å
¨å±ç¶æï¼å¯å¨ä»»æ IM åé¡µå¼¹åº --> |
| | | <RtcCallContainer /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | :global(:root) { |
| | | --im-border-color-lighter: #e8eaed; |
| | | --im-resize-line-color: #d8dde5; |
| | | |
| | | /* |
| | | * antd 4.x ä¸è¾åº --ant-color-* å
¨å± CSS åéï¼ä»
React antd ç theme.cssVar æ¯æï¼ï¼ |
| | | * è IM è天端大éç´æ¥ä½¿ç¨ var(--ant-color-*)ãæ¤å¤ç¨ Vben design token å
åºå®ä¹ï¼ |
| | | * æ å°è§åä¸ useAntdDesignTokens åç» antd ç token å®å
¨ä¸è´ï¼é主é¢ï¼äº®/æï¼èªå¨åæ¢ã |
| | | * 缺失æ¶è¿äºåé为空 â èæ¯éæãè¾¹æ¡/å¡«å
失æï¼å
¸åï¼è¡¨æ
颿¿éåºè¾å
¥æ¡å 使åï¼ã |
| | | */ |
| | | --ant-color-bg-container: hsl(var(--card)); |
| | | --ant-color-bg-elevated: hsl(var(--popover)); |
| | | --ant-color-bg-layout: hsl(var(--background-deep)); |
| | | --ant-color-border: hsl(var(--border)); |
| | | --ant-color-border-secondary: hsl(var(--border)); |
| | | --ant-color-text: hsl(var(--foreground)); |
| | | --ant-color-text-secondary: hsl(var(--foreground) / 65%); |
| | | --ant-color-text-placeholder: hsl(var(--foreground) / 45%); |
| | | --ant-color-text-disabled: hsl(var(--foreground) / 30%); |
| | | /* |
| | | * fill ç³»åï¼æµ
è²ä¸ç¨ Element Plus 飿 ¼çãå·è°æµ
ç°å®è²ãèéåéææ·±è²ï¼ |
| | | * å¦å颿¿ï¼ä¼è¯å表 / æ¶æ¯é¢æ¿çï¼å å¨ç°åºä¸ä¼æ¾èåæï¼å Vue3+EP çå¹²åç½å·®è·ææ¾ã |
| | | * åå¼ä¾ç»ä»¶å®é
ç¨æ³ï¼secondary å¤ç¨äºé¢æ¿åºï¼åææµ
ï¼å¯¹é½ EP --el-bg-color-page #f5f7faï¼ï¼ |
| | | * tertiary å¤ç¨äº hover / 徿 åï¼æ¯é¢æ¿ç¥æ·±ï¼ä¿è¯ hover å¯è§ï¼ãæ·±è²å¨ .dark éå¦è¡è¦çã |
| | | */ |
| | | --ant-color-fill: #e2e6ec; |
| | | --ant-color-fill-secondary: #f4f6f9; |
| | | --ant-color-fill-tertiary: #eaedf2; |
| | | --ant-color-fill-dark: #d5dae2; |
| | | --ant-color-primary: hsl(var(--primary)); |
| | | --ant-color-primary-hover: hsl(var(--primary) / 80%); |
| | | --ant-color-primary-bg: hsl(var(--primary) / 12%); |
| | | --ant-color-primary-bg-hover: hsl(var(--primary) / 18%); |
| | | --ant-color-info: hsl(var(--primary)); |
| | | --ant-color-success: hsl(var(--success)); |
| | | --ant-color-success-bg: hsl(var(--success) / 12%); |
| | | --ant-color-success-bg-hover: hsl(var(--success) / 18%); |
| | | --ant-color-warning: hsl(var(--warning)); |
| | | --ant-color-warning-bg: hsl(var(--warning) / 12%); |
| | | --ant-color-warning-bg-hover: hsl(var(--warning) / 18%); |
| | | --ant-color-error: hsl(var(--destructive)); |
| | | } |
| | | |
| | | :global(.dark) { |
| | | --im-border-color-lighter: rgb(255 255 255 / 12%); |
| | | --im-resize-line-color: rgb(255 255 255 / 18%); |
| | | |
| | | /* æ·±è²ä¸ fill åå°ãåºäºåæ¯è²çåéæäº®è²ãï¼å 卿·±åºä¸å¾å°èªç¶çå¾®äº®è¡¨é¢ */ |
| | | --ant-color-fill: hsl(var(--foreground) / 12%); |
| | | --ant-color-fill-secondary: hsl(var(--foreground) / 8%); |
| | | --ant-color-fill-tertiary: hsl(var(--foreground) / 4%); |
| | | --ant-color-fill-dark: hsl(var(--foreground) / 18%); |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { FriendLite } from '../../types' |
| | | |
| | | import { ref, toRef } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { FriendItem } from '../../components/friend' |
| | | import { useFriendBuckets } from '../../composables/useFriendBuckets' |
| | | |
| | | defineOptions({ name: 'ImContactFriendList' }) |
| | | |
| | | const props = defineProps<{ |
| | | activeId?: number |
| | | friends: FriendLite[] |
| | | keyword: string |
| | | }>() |
| | | |
| | | const emit = defineEmits<{ |
| | | chat: [friend: FriendLite] |
| | | delete: [friend: FriendLite] |
| | | select: [friend: FriendLite] |
| | | }>() |
| | | |
| | | const expanded = ref(true) |
| | | |
| | | const { filtered, buckets } = useFriendBuckets(toRef(props, 'friends'), toRef(props, 'keyword')) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | éè®¯å½ - 好ååç» |
| | | - èªæ²»ï¼æå ç¶æ + å
³é®åè¿æ»¤ + 忝忡¶ æ¬ç»ä»¶å
éç¯ |
| | | - 忝忡¶ / æ¼é³æç´¢å§æ useFriendBucketsï¼ä¸éæ©ç±»å¼¹çª FriendPickerPanel å
±ç¨ä¸ä»½è§å |
| | | - é䏿ç±ç¶çº§ activeId éä¼ ï¼chat / delete éä¼ å°ç¶çº§èµ° store æ¹é |
| | | --> |
| | | <div> |
| | | <!-- æå åç»å¤´ï¼åå·å¯¹é½å¾®ä¿¡ PCï¼15pxï¼ï¼hover æµ
åºè²åé¦ --> |
| | | <div |
| | | class="flex gap-2 items-center px-3.5 py-2.5 cursor-pointer select-none text-15px text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]" |
| | | @click="expanded = !expanded" |
| | | > |
| | | <Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" /> |
| | | <span class="flex-1">好å</span> |
| | | <span class="text-sm text-[var(--ant-color-text-secondary)]">{{ filtered.length }}</span> |
| | | </div> |
| | | <div v-show="expanded"> |
| | | <template v-for="bucket in buckets" :key="bucket.letter"> |
| | | <!-- 忝忡¶ headerï¼æµ
åº + å°åå·ï¼ä½ä¸ºå¥½åå表å
é¨åé --> |
| | | <div |
| | | class="pt-1 pb-0.5 px-3.5 text-12px text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | {{ bucket.letter }} |
| | | </div> |
| | | <FriendItem |
| | | v-for="friend in bucket.list" |
| | | :key="friend.id" |
| | | :friend="friend" |
| | | :active="activeId === friend.id" |
| | | @click="emit('select', friend)" |
| | | @chat="emit('chat', $event)" |
| | | @delete="emit('delete', $event)" |
| | | /> |
| | | </template> |
| | | <div |
| | | v-if="filtered.length === 0" |
| | | class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ keyword ? '没æå¹é
ç好å' : 'ææ å¥½å' }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { FriendRequest, User } from '../../types' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { prompt } from '#/packages/effects/common-ui/src' |
| | | import { DICT_TYPE } from '#/packages/constants/src' |
| | | import { getDictLabel } from '#/packages/effects/hooks/src' |
| | | |
| | | import { Button, Input, message } from 'ant-design-vue' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { ImFriendRequestHandleResult } from '../../../utils/constants' |
| | | import { UserAvatar } from '../../components/user' |
| | | import { UserInfo } from '../../components/user' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | |
| | | defineOptions({ name: 'ImContactFriendRequestDetail' }) |
| | | |
| | | const props = defineProps<{ |
| | | request: FriendRequest |
| | | }>() |
| | | |
| | | const emit = defineEmits<{ |
| | | chat: [peerUserId: number] |
| | | }>() |
| | | |
| | | const friendStore = useFriendStore() |
| | | |
| | | /** å½åç»å½ç¨æ·ç¼å·ï¼ç¨ computed å
ä¸å±ï¼åè´¦å·åé wsCache éåï¼é¿å
顶屿±å¼å¨ keep-alive å®ä¾éæææ§ id */ |
| | | const currentUserId = computed(() => getCurrentUserId()) |
| | | |
| | | /** æ¯ä¸æ¯æåèµ·çï¼fromUserId === currentUserIdï¼ */ |
| | | const iSentIt = computed(() => props.request.fromUserId === currentUserId.value) |
| | | |
| | | /** æ¯å¦ãå·²æç»ãæï¼æ¨¡æ¿éå¤å¤ç¨å°ï¼computed 䏿¬¡çå¾å°å¤åæä¸¾æ¯å¯¹ */ |
| | | const refused = computed( |
| | | () => props.request.handleResult === ImFriendRequestHandleResult.REFUSED |
| | | ) |
| | | |
| | | /** æ¯å¦ãå·²éè¿ãæï¼è½¬èµ° UserInfo 好å详æ
å
¥å£ */ |
| | | const agreed = computed( |
| | | () => props.request.handleResult === ImFriendRequestHandleResult.AGREED |
| | | ) |
| | | |
| | | /** 对端çç¨æ·ç¼å· / æµç§° / 头å */ |
| | | const peerUserId = computed(() => |
| | | iSentIt.value ? props.request.toUserId : props.request.fromUserId |
| | | ) |
| | | const peerNickname = computed(() => |
| | | iSentIt.value |
| | | ? props.request.toNickname || String(props.request.toUserId) |
| | | : props.request.fromNickname || String(props.request.fromUserId) |
| | | ) |
| | | const peerAvatar = computed(() => |
| | | iSentIt.value ? props.request.toAvatar : props.request.fromAvatar |
| | | ) |
| | | |
| | | /** éç» UserInfo çæå°ç¨æ·ä¿¡æ¯ï¼UserInfo å
é¨ä¼æ id è° getSimpleUser è¡¥é½æ§å« / é¨é¨ */ |
| | | const peerUser = computed<User>(() => ({ |
| | | id: peerUserId.value, |
| | | nickname: peerNickname.value, |
| | | avatar: peerAvatar.value |
| | | })) |
| | | |
| | | // åèªç loading ç¨äºæé® spinner æ¾ç¤ºï¼processing æ¯è·¨æé®äºæ¥éï¼é¿å
åæ / æç»å¹¶åæäº¤åä¸ç³è¯· |
| | | const agreeing = ref(false) |
| | | const refusing = ref(false) |
| | | const processing = ref(false) |
| | | |
| | | /** åæç³è¯·ï¼äºæ¥é + ç¶æäºæ¬¡æ ¡éªï¼é¿å
å¹¶å / æå¡ç«¯å·²å¤çå忬¡æäº¤ */ |
| | | async function handleAgree() { |
| | | if (processing.value) { |
| | | return |
| | | } |
| | | if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) { |
| | | return |
| | | } |
| | | processing.value = true |
| | | agreeing.value = true |
| | | try { |
| | | await friendStore.agreeFriendRequest(props.request.id) |
| | | message.success('å·²åæå¥½åç³è¯·') |
| | | } finally { |
| | | agreeing.value = false |
| | | processing.value = false |
| | | } |
| | | } |
| | | |
| | | /** æç»ç³è¯·ï¼å¼¹ prompt æ¶éå¯éæç»çç±ï¼ç¹åæ¶å䏿¢ï¼ï¼éåè° store è½åº + æç¤º */ |
| | | async function handleRefuse() { |
| | | if (processing.value) { |
| | | return |
| | | } |
| | | if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) { |
| | | return |
| | | } |
| | | // 1. å¼¹ prompt æ¶éæç»çç±ï¼æå¤ 255 åï¼ï¼ç¨æ·ç¹ãåæ¶ãä¼ rejectï¼ä¸æ¢åç»æµç¨ |
| | | let handleContent: string | undefined |
| | | try { |
| | | const result = await prompt<string>({ |
| | | beforeClose(scope) { |
| | | if (!scope.isConfirm) { |
| | | return |
| | | } |
| | | if ((scope.value || '').length > 255) { |
| | | message.error('æå¤ 255 个å符') |
| | | return false |
| | | } |
| | | }, |
| | | cancelText: 'åæ¶', |
| | | component: Input.TextArea, |
| | | componentProps: { |
| | | allowClear: true, |
| | | maxlength: 255, |
| | | placeholder: 'ä¸å¡«åä¸åç¥å¯¹æ¹åå ', |
| | | rows: 3 |
| | | }, |
| | | content: '', |
| | | defaultValue: '', |
| | | confirmText: 'æç»', |
| | | modelPropName: 'value', |
| | | title: 'æç»å¥½åç³è¯·' |
| | | }) |
| | | handleContent = result || undefined |
| | | } catch { |
| | | return |
| | | } |
| | | // 2. prompt æé´ç¶æå¯è½è¢«è·¨ç«¯æ¹æ AGREED / REFUSEDï¼åæ ¡éªä¸æ¬¡é¿å
éå¤æäº¤ |
| | | if (processing.value) { |
| | | return |
| | | } |
| | | if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) { |
| | | return |
| | | } |
| | | processing.value = true |
| | | refusing.value = true |
| | | try { |
| | | await friendStore.refuseFriendRequest(props.request.id, handleContent) |
| | | message.success('å·²æç»å¥½åç³è¯·') |
| | | } finally { |
| | | refusing.value = false |
| | | processing.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | æ°çæå详æ
颿¿ |
| | | - å·²éè¿ â ç´æ¥èµ° UserInfo 好å详æ
ï¼è·é讯å½éç¹å¼å¥½åçä½éªå®å
¨ä¸è´ |
| | | - æªå¤ç / å·²æç» â ç³è¯·æé¢æ¿ï¼å¤´å + ç³è¯·/æç»å¯¹è¯æ°æ³¡ + æ¥æº + æä½æé® |
| | | --> |
| | | <div v-if="agreed" class="flex justify-center pt-12 px-6"> |
| | | <div class="w-full max-w-[320px]"> |
| | | <UserInfo |
| | | :user="peerUser" |
| | | :display-name="friendStore.getFriend(peerUserId)?.displayName || ''" |
| | | relation="friend" |
| | | @chat="emit('chat', peerUserId)" |
| | | /> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-else class="flex flex-col items-center px-6 pt-12"> |
| | | <UserAvatar |
| | | :id="peerUserId" |
| | | :url="peerAvatar" |
| | | :name="peerNickname" |
| | | :size="64" |
| | | :clickable="false" |
| | | /> |
| | | <div class="mt-3 text-base font-semibold text-[var(--ant-color-text)]"> |
| | | {{ peerNickname }} |
| | | </div> |
| | | |
| | | <!-- ç³è¯·çç±åï¼ä»¿å¾®ä¿¡ç°åºæ°æ³¡ï¼æç³è¯·æ¹èº«ä»½åç¼ï¼é¿ææ¬ break-words æè¡ --> |
| | | <div |
| | | v-if="request.applyContent" |
| | | class="w-full max-w-[420px] mt-6 px-3.5 py-3 rounded-md bg-[var(--ant-color-fill-secondary)] text-13px text-[var(--ant-color-text)] break-words" |
| | | > |
| | | <span class="text-[var(--ant-color-text-secondary)]"> |
| | | {{ iSentIt ? 'æ' : peerNickname }}: |
| | | </span> |
| | | <span class="ml-1">{{ request.applyContent }}</span> |
| | | </div> |
| | | <!-- æ¥æºè¡ï¼label å·¦ãvalue å³ï¼å¯¹é½å¾®ä¿¡ãæ¥æºãè¡ --> |
| | | <div |
| | | v-if="request.addSource" |
| | | class="w-full max-w-[420px] mt-3 flex items-center text-13px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <span class="w-16 flex-shrink-0 whitespace-nowrap">æ¥æº</span> |
| | | <span class="text-[var(--ant-color-text)]"> |
| | | {{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, request.addSource) }} |
| | | </span> |
| | | </div> |
| | | <!-- æç»çç±ï¼label å·¦ãvalue å³ï¼é¿ææ¬ break-words èªå¨æè¡ --> |
| | | <div |
| | | v-if="refused && request.handleContent" |
| | | class="w-full max-w-[420px] mt-3 flex items-start text-13px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <span class="w-16 flex-shrink-0 whitespace-nowrap">æç»çç±</span> |
| | | <span class="flex-1 min-w-0 break-words text-[var(--ant-color-text)]"> |
| | | {{ request.handleContent }} |
| | | </span> |
| | | </div> |
| | | |
| | | <!-- æä½æé® --> |
| | | <div class="w-full max-w-[420px] mt-8 flex justify-center"> |
| | | <!-- æåèµ· + çå¾
ä¸ï¼ç¦ç¨ãçå¾
对æ¹éªè¯ã --> |
| | | <Button |
| | | v-if="iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED" |
| | | disabled |
| | | > |
| | | çå¾
对æ¹éªè¯ |
| | | </Button> |
| | | <!-- å«äººå æ + çå¾
ä¸ï¼åæ / æç» --> |
| | | <template v-if="!iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED"> |
| | | <Button @click="handleRefuse" :loading="refusing" :disabled="processing">æç»</Button> |
| | | <Button type="primary" @click="handleAgree" :loading="agreeing" :disabled="processing"> |
| | | åæ |
| | | </Button> |
| | | </template> |
| | | <!-- å·²æç»ï¼å ä½ç¦ç¨æé® --> |
| | | <Button v-if="refused" disabled>å·²æç»</Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { FriendLite, FriendRequest, Group, GroupLite, User } from '../../types' |
| | | |
| | | import { computed, onMounted, ref, watch } from 'vue' |
| | | import { useRouter } from 'vue-router' |
| | | |
| | | import { confirm } from '#/packages/effects/common-ui/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Input, message } from 'ant-design-vue' |
| | | |
| | | import { ImConversationType } from '../../../utils/constants' |
| | | import { StorageKeys } from '../../../utils/db' |
| | | import { getFriendDisplayName, getGroupDisplayName, isGroupQuit } from '../../../utils/user' |
| | | import { ResizableAside } from '../../components' |
| | | import { UserInfo } from '../../components/user' |
| | | import { useConversationStore } from '../../store/conversationStore' |
| | | import { useFriendStore } from '../../store/friendStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import FriendList from './friend-list.vue' |
| | | import FriendRequestDetail from './friend-request-detail.vue' |
| | | import FriendRequestList from './friend-request-list.vue' |
| | | import GroupDetail from './group-detail.vue' |
| | | import GroupList from './group-list.vue' |
| | | |
| | | defineOptions({ name: 'ImContactPage' }) |
| | | |
| | | const router = useRouter() |
| | | const conversationStore = useConversationStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | |
| | | /** ç¨ type å¤å«é䏿¯å¥½å / 群è / 好åç³è¯· */ |
| | | type Selection = |
| | | | { friend: FriendLite; type: 'friend'; } |
| | | | { group: GroupLite; type: 'group'; } |
| | | | { request: FriendRequest; type: 'request'; } |
| | | |
| | | const selection = ref<null | Selection>(null) |
| | | const keyword = ref('') |
| | | |
| | | /** éä¸ç³è¯·è¯¦æ
ï¼è¯¦æ
ç¨ store éçææ°å¯æ¬ï¼åæ / æç»åç¶æä¼åï¼ */ |
| | | const currentRequest = computed<FriendRequest>(() => { |
| | | const req = selection.value?.type === 'request' ? selection.value.request : null |
| | | if (!req) { |
| | | return {} as FriendRequest |
| | | } |
| | | return friendStore.getFriendRequest(req.id) || req |
| | | }) |
| | | |
| | | /** æç¸å
³çç³è¯·å表ï¼ç¨ friendStore éç宿¶å¯æ¬ï¼ä¾¿äºéç¥å°è¾¾åèªå¨å·æ°ï¼ */ |
| | | const friendRequests = computed<FriendRequest[]>(() => friendStore.friendRequests) |
| | | |
| | | /** 好åå表çå±ç¤ºå¿«ç
§ï¼é带å端ç®å¥½çæ¼é³ï¼ç» FriendList å忝忡¶ / æ¼é³æç´¢ */ |
| | | const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList) |
| | | |
| | | const groups = computed<GroupLite[]>(() => |
| | | // é讯å½åªå±ç¤ºå½åä»å¨ç¾¤çï¼å·²é群åå²ç¾¤åªçå¨ store é便¶æ¯å±ç¤ºç¾¤å / 头åï¼ä¸è¿éè®¯å½ |
| | | groupStore.groups |
| | | .filter((group: Group) => !isGroupQuit(group)) |
| | | .map((group: Group) => ({ |
| | | id: group.id, |
| | | name: group.name, |
| | | showGroupName: getGroupDisplayName(group), // ä¼å
ç¨ç¾¤å¤æ³¨ groupRemarkï¼æ²¡è®¾ç½®æ¶åè½å°å群åï¼é¿å
ç¹"è¿å
¥ç¾¤è"æ¶æå·²åæ¥ç夿³¨ä¼è¯åå·ååå |
| | | showImage: group.avatar, |
| | | showImageThumb: group.avatar, |
| | | memberCount: group.memberCount |
| | | })) |
| | | ) |
| | | |
| | | /** |
| | | * store å表å忶忥 selection æçå¯¹è±¡å¯æ¬ï¼å¯¹ç«¯æ¨é / 跨端å¨ä½æ¹ store åï¼å³ä¾§è¯¦æ
è½è·ä¸ï¼ |
| | | * - å½ä¸åæ¿æ¢ä¸ºææ°å¼ç¨ï¼èµæ / 夿³¨ / ç³è¯·ç¶æåæ´ç«å»åæ |
| | | * - æ¾ä¸å°ï¼å é¤ / æç» / å·²éè¿çè®©è®°å½æ¶å¤±ï¼åç½® null æ¶èµ·è¯¦æ
|
| | | */ |
| | | watch( |
| | | friends, |
| | | (list) => { |
| | | const selected = selection.value |
| | | if (selected?.type !== 'friend') { |
| | | return |
| | | } |
| | | const fresh = list.find((friend) => friend.id === selected.friend.id) |
| | | if (!fresh) { |
| | | selection.value = null |
| | | } else if (fresh !== selected.friend) { |
| | | selection.value = { type: 'friend', friend: fresh } |
| | | } |
| | | }, |
| | | { deep: true } |
| | | ) |
| | | watch( |
| | | groups, |
| | | (list) => { |
| | | const selected = selection.value |
| | | if (selected?.type !== 'group') { |
| | | return |
| | | } |
| | | const fresh = list.find((group) => group.id === selected.group.id) |
| | | if (!fresh) { |
| | | selection.value = null |
| | | } else if (fresh !== selected.group) { |
| | | selection.value = { type: 'group', group: fresh } |
| | | } |
| | | }, |
| | | { deep: true } |
| | | ) |
| | | watch( |
| | | friendRequests, |
| | | (list) => { |
| | | const selected = selection.value |
| | | if (selected?.type !== 'request') { |
| | | return |
| | | } |
| | | const fresh = list.find((request) => request.id === selected.request.id) |
| | | if (!fresh) { |
| | | selection.value = null |
| | | } else if (fresh !== selected.request) { |
| | | selection.value = { type: 'request', request: fresh } |
| | | } |
| | | }, |
| | | { deep: true } |
| | | ) |
| | | |
| | | const friendUser = computed<null | User>(() => { |
| | | if (selection.value?.type !== 'friend') { |
| | | return null |
| | | } |
| | | const friend = selection.value.friend |
| | | return { |
| | | id: friend.id, |
| | | nickname: friend.nickname, |
| | | avatar: friend.avatar |
| | | } |
| | | }) |
| | | |
| | | onMounted(async () => { |
| | | await Promise.all([ |
| | | friendStore.fetchFriendList(), |
| | | friendStore.fetchFriendRequestList(), |
| | | groupStore.fetchGroupList() |
| | | ]) |
| | | }) |
| | | |
| | | /** éä¸å¥½å â åå°å¥½å详æ
*/ |
| | | function handleSelectFriend(friend: FriendLite) { |
| | | selection.value = { type: 'friend', friend } |
| | | } |
| | | |
| | | /** éä¸ç¾¤è â åå°ç¾¤è¯¦æ
*/ |
| | | function handleSelectGroup(group: GroupLite) { |
| | | selection.value = { type: 'group', group } |
| | | } |
| | | |
| | | /** éä¸å¥½åç³è¯· â åå°ãæ°çæåã详æ
*/ |
| | | function handleSelectRequest(request: FriendRequest) { |
| | | selection.value = { type: 'request', request } |
| | | } |
| | | |
| | | /** ç³è¯·è¯¦æ
éç¹ãåæ¶æ¯ãï¼ç´æ¥è¿ä¸å¯¹ç«¯çç§èä¼è¯ */ |
| | | function handleChatPeer(peerUserId: number) { |
| | | const friend = friendStore.getFriend(peerUserId) |
| | | const conversationName = friend ? getFriendDisplayName(friend) : String(peerUserId) |
| | | conversationStore.openConversation( |
| | | peerUserId, |
| | | ImConversationType.PRIVATE, |
| | | conversationName, |
| | | friend?.avatar || '', |
| | | { silent: !!friend?.silent } |
| | | ) |
| | | router.push({ name: 'ImHomeConversation' }) |
| | | } |
| | | |
| | | /** è¿å
¥ä¸è¯¥å¥½åçç§èä¼è¯ */ |
| | | function handleChatFriend(friend: FriendLite) { |
| | | // ä» friendStore 忥夿³¨ + å
ææ°ï¼é¿å
æ°å»ºä¼è¯ç¨è¿ææ°æ® |
| | | const entry = friendStore.getFriend(friend.id) |
| | | const conversationName = entry ? getFriendDisplayName(entry) : friend.nickname |
| | | conversationStore.openConversation( |
| | | friend.id, |
| | | ImConversationType.PRIVATE, |
| | | conversationName, |
| | | friend.avatar || '', |
| | | { silent: !!entry?.silent } |
| | | ) |
| | | router.push({ name: 'ImHomeConversation' }) |
| | | } |
| | | |
| | | /** è¿å
¥è¯¥ç¾¤ç群èä¼è¯ */ |
| | | function handleChatGroup(group: GroupLite) { |
| | | const entry = groupStore.getGroup(group.id) |
| | | conversationStore.openConversation( |
| | | group.id, |
| | | ImConversationType.GROUP, |
| | | group.showGroupName || group.name || '', |
| | | group.showImage || group.showImageThumb || '', |
| | | { silent: !!entry?.silent } |
| | | ) |
| | | router.push({ name: 'ImHomeConversation' }) |
| | | } |
| | | |
| | | /** å é¤å¥½åï¼äºæ¬¡ç¡®è®¤ â store è½åº â æ¸
空å½åéä¸ */ |
| | | async function handleDeleteFriend(friend: FriendLite) { |
| | | try { |
| | | await confirm(`ç¡®å®å é¤å¥½åã${friend.nickname}ãåï¼`, 'å é¤è系人') |
| | | // friendStore.deleteFriend å
é¨å·²ç»çº§èæ¸
ç对åºç§èä¼è¯ |
| | | await friendStore.deleteFriend(friend.id) |
| | | if (selection.value?.type === 'friend' && selection.value.friend.id === friend.id) { |
| | | selection.value = null |
| | | } |
| | | message.success('å·²å é¤å¥½å') |
| | | } catch {} |
| | | } |
| | | |
| | | /** 夿³¨å·²ä¿åï¼UserInfo å
é¨å·²ç»èµ°å® friendStore è½åº + æç¤ºï¼æ¬ä¾§åªè´è´£åæ¥ selection æçæ§ FriendLite 坿¬ */ |
| | | function onRemarkSaved(displayName: string) { |
| | | if (selection.value?.type === 'friend') { |
| | | selection.value.friend.displayName = displayName |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | éè®¯å½ Tab æ´é¡µï¼åè微信 PC é讯å½ï¼ |
| | | - å·¦ï¼æç´¢ + GroupList / FriendList 两个æå åç»ï¼å¥½åæåæ¯åæ¡¶ï¼ |
| | | - å³ï¼éä¸é¡¹ç详æ
ï¼å¥½åèµ°å
±äº« <UserInfo>ï¼relation=friendï¼ï¼ç¾¤èèµ° GroupDetail |
| | | - æ¬é¡µä»
åï¼éä¸åå + æ°æ®æºè½¬æ¢ + è·¨ç»ä»¶äºä»¶è½ store |
| | | --> |
| | | <div class="flex flex-1 h-full min-w-0 bg-[var(--ant-color-bg-container)]"> |
| | | <ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth"> |
| | | <!-- é¡¶é¨ï¼ä»
æç´¢æ¡ï¼h-14 䏿¶æ¯ Tab é¡¶é¨å¯¹é½ï¼é¿å
åæ¢æ¶æç´¢æ¡ä¸ä¸æå¨ --> |
| | | <div |
| | | class="flex flex-shrink-0 items-center h-14 px-4 border-b border-b-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <Input v-model:value="keyword" placeholder="æç´¢" allow-clear class="flex-1"> |
| | | <template #prefix> |
| | | <Icon icon="ant-design:search-outlined" /> |
| | | </template> |
| | | </Input> |
| | | </div> |
| | | |
| | | <!-- å表主ä½ï¼æ FriendRequestList / GroupList / FriendList ä¸ä¸ªåç»ä»¶ï¼åèªç®¡çæå + è¿æ»¤ï¼æ¬é¡µåªéä¼ é䏿 --> |
| | | <!-- overflow-y-autoï¼èç³»äººå¤æ¶æ¬åè¡¨å¯æ»å¨ï¼ç¶ ResizableAside 䏿便»å¨ï¼ï¼å¯¹é½ Vue3 ç el-scrollbar --> |
| | | <div class="flex-1 overflow-y-auto"> |
| | | <FriendRequestList |
| | | :requests="friendRequests" |
| | | :active-id="selection?.type === 'request' ? selection.request.id : undefined" |
| | | @select="handleSelectRequest" |
| | | /> |
| | | <GroupList |
| | | :groups="groups" |
| | | :keyword="keyword" |
| | | :active-id="selection?.type === 'group' ? selection.group.id : undefined" |
| | | @select="handleSelectGroup" |
| | | /> |
| | | <FriendList |
| | | :friends="friends" |
| | | :keyword="keyword" |
| | | :active-id="selection?.type === 'friend' ? selection.friend.id : undefined" |
| | | @select="handleSelectFriend" |
| | | @chat="handleChatFriend" |
| | | @delete="handleDeleteFriend" |
| | | /> |
| | | </div> |
| | | </ResizableAside> |
| | | |
| | | <!-- å³ä¾§è¯¦æ
åº --> |
| | | <div class="flex-1 min-w-0"> |
| | | <!-- 空æï¼å ä½å¾æ + æç¤ºææ¡ --> |
| | | <div |
| | | v-if="!selection" |
| | | class="flex flex-col items-center justify-center h-full gap-3 text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <Icon |
| | | icon="ant-design:contacts-outlined" |
| | | :size="64" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | <span class="text-sm">å¨å·¦ä¾§éæ©å¥½åæç¾¤èæ¥ç详æ
</span> |
| | | </div> |
| | | <!-- 好å详æ
--> |
| | | <div v-else-if="selection.type === 'friend'" class="flex justify-center pt-12 px-6"> |
| | | <div class="w-full max-w-[320px]"> |
| | | <UserInfo |
| | | :user="friendUser" |
| | | :display-name="selection.friend.displayName || ''" |
| | | relation="friend" |
| | | @chat="handleChatFriend(selection.friend)" |
| | | @deleted="selection = null" |
| | | @saved="onRemarkSaved" |
| | | /> |
| | | </div> |
| | | </div> |
| | | <!-- 群详æ
--> |
| | | <GroupDetail |
| | | v-else-if="selection.type === 'group'" |
| | | :group="selection.group" |
| | | @chat="handleChatGroup" |
| | | /> |
| | | <!-- æ°çæå - ç³è¯·è¯¦æ
--> |
| | | <FriendRequestDetail |
| | | v-else-if="selection.type === 'request'" |
| | | :request="currentRequest" |
| | | @chat="handleChatPeer" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../../../../components/group' |
| | | import type { Conversation, GroupLite } from '../../../../types' |
| | | |
| | | import { computed, ref, watch } from 'vue' |
| | | |
| | | import { confirm } from '#/packages/effects/common-ui/src' |
| | | import { CommonStatusEnum } from '#/packages/constants/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, Drawer, Input, message, Popover, Switch } from 'ant-design-vue' |
| | | |
| | | import { |
| | | dissolveGroup, |
| | | muteAll, |
| | | updateGroup |
| | | } from '#/api/im/group' |
| | | import { quitGroup, updateGroupMember } from '#/api/im/group/member' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants' |
| | | import { toGroupCardTarget } from '#/views/im/utils/message' |
| | | import { isGroupQuit } from '#/views/im/utils/user' |
| | | |
| | | import { GroupAdminSetDialog } from '../../../../components/group' |
| | | import { GroupMemberAddDialog } from '../../../../components/group' |
| | | import { GroupMemberGrid } from '../../../../components/group' |
| | | import { GroupMemberRemoveDialog } from '../../../../components/group' |
| | | import { GroupOwnerTransferDialog } from '../../../../components/group' |
| | | import { GroupRequestListDialog } from '../../../../components/group' |
| | | import { RecommendCardDialog } from '../../../../components/user' |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | |
| | | defineOptions({ name: 'ImConversationGroupSide' }) |
| | | |
| | | // 大群é»è®¤åªå±ç¤ºå N 个æåï¼4Ã4 å®«æ ¼ â 2 个ç¦çé¢çç»"æ·»å / ç§»åº"æé®ï¼ |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | conversation?: Conversation | null // å½åä¼è¯ï¼ç¨äºè¯» / åå
ææ°ãç½®é¡¶ç¶æ |
| | | group?: GroupLite & { groupRemark?: string; notice?: string; remarkNickName?: string; } // å½å群信æ¯ï¼å¯ç©ºï¼æ æ¿æ´»ç¾¤ä¼è¯æ¶ï¼ |
| | | members?: GroupMemberLite[] |
| | | modelValue?: boolean // æ½å±æ¯å¦æå¼ï¼v-modelï¼ |
| | | }>(), |
| | | { |
| | | conversation: null, |
| | | group: undefined, |
| | | modelValue: false, |
| | | members: () => [] |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | openHistory: [] // ç¹å» "æ¥æ¾è天å
容" è¡ â ç¶ç»ä»¶æå¼ MessageHistory å¼¹çª |
| | | reload: [friendIds?: number[]] // é请 / ç§»é¤ / ä¿®æ¹ç¾¤èµæåï¼ç¶ç»ä»¶éæ°æç¾¤æ°æ®ï¼éè¯·æ°æååºæ¯éåº friendIds 让ä¸å±å¯éç²¾åå·æ° |
| | | 'update:modelValue': [value: boolean] |
| | | }>() |
| | | |
| | | const MEMBER_PREVIEW_COUNT = 14 |
| | | const conversationStore = useConversationStore() |
| | | const groupStore = useGroupStore() |
| | | |
| | | const visible = computed({ |
| | | get: () => props.modelValue, |
| | | set: (v) => emit('update:modelValue', v) |
| | | }) |
| | | |
| | | // ==================== è§è² / æåå±ç¤º ==================== |
| | | |
| | | const searchText = ref('') |
| | | const showAllMembers = ref(false) |
| | | |
| | | const inviteDialogRef = ref<InstanceType<typeof GroupMemberAddDialog>>() // é请好åå
¥ç¾¤å¼¹çª refï¼handleOpenInvite è° open({ groupId }) æå¼ |
| | | /** æå¼é请好åå
¥ç¾¤å¼¹çª */ |
| | | function handleOpenInvite() { |
| | | if (!props.group?.id) { |
| | | return |
| | | } |
| | | inviteDialogRef.value?.open({ groupId: props.group.id }) |
| | | } |
| | | |
| | | const myId = computed(() => getCurrentUserId()) |
| | | /** åå²é群群ï¼ç¦ææç¾¤æä½å
¥å£ï¼é请 / ç§»åº / æ¹èµæ / ç¦è¨ / å®¡æ¹ / éåºçï¼ï¼åªä¿çå±ç¤ºï¼props.group æ¯ GroupLite æ joinStatusï¼å store åå
¨é */ |
| | | const isQuitGroup = computed(() => { |
| | | const id = props.group?.id |
| | | return id != null && isGroupQuit(groupStore.getGroup(id)) |
| | | }) |
| | | const isOwner = computed( |
| | | () => !isQuitGroup.value && props.group != null && props.group.ownerId === myId.value |
| | | ) |
| | | /** å½åç¨æ·å¨ç¾¤éçè§è²ï¼æ¥èª props.members ç me è¡ï¼ï¼ç¨äºå¤å®æ¯å¦å¯ç§»åºä»äºº */ |
| | | const myRole = computed(() => props.members.find((m) => m.userId === myId.value)?.role) |
| | | /** 群主æç®¡çåï¼å¨æ½å±éæ "ç§»åºç¾¤æå" å
¥å£ï¼åå²é群群ä¸å¾è§ä¸ºæ æé */ |
| | | const isOwnerOrAdmin = computed( |
| | | () => |
| | | !isQuitGroup.value && |
| | | (myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN) |
| | | ) |
| | | |
| | | /** æé¤å·²é群æå + å
³é®åè¿æ»¤ï¼æè§è²æåºï¼ç¾¤ä¸»â管çåâæ®éæåï¼åè§è²æ userId 稳å®ï¼ */ |
| | | const visibleMembers = computed(() => { |
| | | return props.members |
| | | .filter( |
| | | (member) => |
| | | member.status !== CommonStatusEnum.DISABLE && |
| | | (member.showName || '').includes(searchText.value) |
| | | ) |
| | | .toSorted((a, b) => { |
| | | const roleA = a.role ?? ImGroupMemberRole.NORMAL |
| | | const roleB = b.role ?? ImGroupMemberRole.NORMAL |
| | | return roleA === roleB ? a.userId - b.userId : roleA - roleB |
| | | }) |
| | | }) |
| | | |
| | | /** æå è§åï¼æç´¢ / å·²å±å¼ æ¶ä¸æå ï¼å
¶ä½åªåå N 个 */ |
| | | const moreMembersHidden = computed( |
| | | () => |
| | | !searchText.value && !showAllMembers.value && visibleMembers.value.length > MEMBER_PREVIEW_COUNT |
| | | ) |
| | | const displayMembers = computed(() => |
| | | moreMembersHidden.value |
| | | ? visibleMembers.value.slice(0, MEMBER_PREVIEW_COUNT) |
| | | : visibleMembers.value |
| | | ) |
| | | |
| | | // æ½å±å
³éæ¶æ¸
ææååºä¸´æ¶æï¼æç´¢å
³é®åãæ¥çæ´å¤å±å¼ï¼ |
| | | watch(visible, (v) => { |
| | | if (!v) { |
| | | searchText.value = '' |
| | | showAllMembers.value = false |
| | | } |
| | | }) |
| | | |
| | | // ==================== 群信æ¯ç¼è¾ ==================== |
| | | |
| | | const namePopoverVisible = ref(false) |
| | | const noticePopoverVisible = ref(false) |
| | | const remarkPopoverVisible = ref(false) |
| | | const groupRemarkPopoverVisible = ref(false) |
| | | const editName = ref('') |
| | | const editNotice = ref('') |
| | | const editRemark = ref('') |
| | | const editGroupRemark = ref('') |
| | | |
| | | // popover å¼¹åºæ¶æå½åå¼çè¿ç¼è¾æï¼é¿å
䏿¬¡æªä¿åçèå¼ |
| | | watch(namePopoverVisible, (v) => { |
| | | if (v) editName.value = props.group?.name || '' |
| | | }) |
| | | watch(noticePopoverVisible, (v) => { |
| | | if (v) editNotice.value = props.group?.notice || '' |
| | | }) |
| | | watch(remarkPopoverVisible, (v) => { |
| | | if (v) editRemark.value = props.group?.remarkNickName || '' |
| | | }) |
| | | watch(groupRemarkPopoverVisible, (v) => { |
| | | if (v) editGroupRemark.value = props.group?.groupRemark || '' |
| | | }) |
| | | |
| | | // æ½å±å
³éæ¶æ¸
æææ popoverï¼é¿å
䏿¬¡æå¼ä»å¼¹ç |
| | | watch(visible, (v) => { |
| | | if (!v) { |
| | | namePopoverVisible.value = false |
| | | noticePopoverVisible.value = false |
| | | remarkPopoverVisible.value = false |
| | | groupRemarkPopoverVisible.value = false |
| | | } |
| | | }) |
| | | |
| | | /** 群主ï¼ä¿å群åï¼èµ° /im/group/updateï¼ï¼trim å空å符串ææäº¤ï¼ä¸ saveGroupRemark è¡ä¸ºå¯¹é½ */ |
| | | async function saveName() { |
| | | if (!props.group) { |
| | | return |
| | | } |
| | | const trimmed = editName.value.trim() |
| | | if (!trimmed) { |
| | | message.warning('群åç§°ä¸è½ä¸ºç©º') |
| | | return |
| | | } |
| | | await updateGroup({ id: props.group.id, name: trimmed }) |
| | | namePopoverVisible.value = false |
| | | message.success('ä¿åæå') |
| | | emit('reload') |
| | | } |
| | | |
| | | /** 群主ï¼ä¿å群å
Œ */ |
| | | async function saveNotice() { |
| | | if (!props.group) { |
| | | return |
| | | } |
| | | await updateGroup({ id: props.group.id, notice: editNotice.value }) |
| | | noticePopoverVisible.value = false |
| | | message.success('ä¿åæå') |
| | | emit('reload') |
| | | } |
| | | |
| | | /** 群主ï¼åæ¢ãè¿ç¾¤å®¡æ¹ãå¼å
³ï¼å¼å¯åææãç³è¯·ããé请ãè·¯å¾é½é群主 / 管çååæ */ |
| | | async function handleJoinApprovalChange(value: boolean | number | string) { |
| | | if (!props.group) { |
| | | return |
| | | } |
| | | await updateGroup({ id: props.group.id, joinApproval: !!value }) |
| | | message.success('ä¿åæå') |
| | | emit('reload') |
| | | } |
| | | |
| | | /** 任使åï¼ä¿åç¾¤å¤æ³¨ï¼ä»
èªå·±å¯è§ï¼ä¼æ¿æ¢ä¼è¯å表 / é¡¶é¨ç¾¤åå±ç¤ºï¼ */ |
| | | async function saveGroupRemark() { |
| | | if (!props.group) { |
| | | return |
| | | } |
| | | await updateGroupMember({ |
| | | groupId: props.group.id, |
| | | groupRemark: editGroupRemark.value.trim() |
| | | }) |
| | | groupRemarkPopoverVisible.value = false |
| | | message.success('ä¿åæå') |
| | | emit('reload') |
| | | } |
| | | |
| | | /** 任使åï¼ä¿åèªå·±å¨ç¾¤éçæµç§°ï¼èµ° /im/group-member/updateï¼ */ |
| | | async function saveRemark() { |
| | | if (!props.group) { |
| | | return |
| | | } |
| | | await updateGroupMember({ |
| | | groupId: props.group.id, |
| | | displayUserName: editRemark.value |
| | | }) |
| | | remarkPopoverVisible.value = false |
| | | message.success('ä¿åæå') |
| | | emit('reload') |
| | | } |
| | | |
| | | // ==================== å¼å
³åæ¢ ==================== |
| | | |
| | | /** |
| | | * æ¶æ¯å
ææ°ï¼æ¬å° conversationStore ç«å³åï¼å端 /silent 弿¥åæ¥ï¼å¤±è´¥åæ»æ¬å° |
| | | * |
| | | * ä¸ ConversationItem å³é®èåç"æ¶æ¯å
ææ°"è¯ä¹ä¸è´ï¼åºå«ä»
å¨ UI å
¥å£ |
| | | */ |
| | | function onMutedChange(value: boolean | number | string) { |
| | | if (!props.conversation) { |
| | | return |
| | | } |
| | | const next = !!value |
| | | const { type, targetId } = props.conversation |
| | | conversationStore.setConversationSilent(type, targetId, next) |
| | | groupStore.setGroupSilent(targetId, next).catch((error) => { |
| | | console.error('[IM ConversationGroupSide] setGroupSilent 失败', { targetId }, error) |
| | | conversationStore.setConversationSilent(type, targetId, !next) |
| | | }) |
| | | } |
| | | |
| | | /** 置顶è天ï¼çº¯æ¬å° conversationStore æåºæï¼æ åç«¯åæ®µï¼ */ |
| | | function onTopChange(value: boolean | number | string) { |
| | | if (!props.conversation) { |
| | | return |
| | | } |
| | | conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value) |
| | | } |
| | | |
| | | // ==================== å
¨ç¾¤ç¦è¨ ==================== |
| | | |
| | | /** å½å群æ¯å¦å
¨ç¾¤ç¦è¨ */ |
| | | const currentMutedAll = computed(() => { |
| | | if (!props.group) { |
| | | return false |
| | | } |
| | | return groupStore.getGroup(props.group.id)?.mutedAll ?? false |
| | | }) |
| | | |
| | | /** å
¨ç¾¤ç¦è¨å¼å
³åæ¢ */ |
| | | async function onMuteAllChange(value: boolean | number | string) { |
| | | if (!props.group) { |
| | | return |
| | | } |
| | | const newValue = !!value |
| | | await muteAll({ id: props.group.id, mutedAll: newValue }) |
| | | message.success(newValue ? 'å·²å¼å¯å
¨ç¾¤ç¦è¨' : 'å·²å
³éå
¨ç¾¤ç¦è¨') |
| | | emit('reload') |
| | | } |
| | | |
| | | // ==================== è¿ç¾¤å®¡æ¹ ==================== |
| | | |
| | | const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>() // è¿ç¾¤ç³è¯·åè¡¨å¼¹çª refï¼handleOpenRequestList è° open({ groupId }) 触å |
| | | |
| | | /** æå¼å½å群çè¿ç¾¤ç³è¯·å表 */ |
| | | function handleOpenRequestList() { |
| | | if (!props.group?.id) { |
| | | return |
| | | } |
| | | requestListDialogRef.value?.open({ groupId: props.group.id }) |
| | | } |
| | | |
| | | // ==================== å享群åç ==================== |
| | | |
| | | const recommendCardDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // å享群åçå¼¹çª refï¼handleShareGroupCard è°ç¨ open({ target }) æå¼ |
| | | |
| | | /** å享群åçï¼æå½å群ä½ä¸ºåçæ¶æ¯æ¨èç»å
¶ä»ä¼è¯ */ |
| | | function handleShareGroupCard() { |
| | | const target = toGroupCardTarget(props.group) |
| | | if (!target) { |
| | | return |
| | | } |
| | | recommendCardDialogRef.value?.open({ target }) |
| | | } |
| | | |
| | | // ==================== éåºç¾¤è ==================== |
| | | |
| | | /** éåºç¾¤èï¼æ®éæåå
¥å£ï¼ç¾¤ä¸»éåºèµ°"è§£æ£ç¾¤"æ¯å¦ä¸æ¡è·¯å¾ï¼è¿éä¸å¤çï¼ */ |
| | | async function handleQuit() { |
| | | if (!props.group) { |
| | | return |
| | | } |
| | | // äºæ¬¡ç¡®è®¤ |
| | | try { |
| | | await confirm('éåºç¾¤èåå°ä¸åæ¥æ¶ç¾¤éçæ¶æ¯ï¼ç¡®è®¤éåºåï¼', '确认éåº') |
| | | } catch { |
| | | return |
| | | } |
| | | const groupId = props.group.id |
| | | await quitGroup(groupId) |
| | | // æ¬å°ç«å³ååºï¼å
æ self.member ç½® DISABLEï¼è®© GroupInfo ç isMember æ¶æï¼ï¼åæ¸
ä¼è¯ + 群 store |
| | | if (myId.value) { |
| | | groupStore.updateGroupMemberStatus(groupId, myId.value, CommonStatusEnum.DISABLE) |
| | | } |
| | | conversationStore.removeConversation(ImConversationType.GROUP, groupId) |
| | | groupStore.removeGroup(groupId) |
| | | message.success('å·²éåºç¾¤è') |
| | | visible.value = false |
| | | } |
| | | |
| | | /** è§£æ£ç¾¤èï¼ä»
群主å
¥å£ï¼ */ |
| | | async function handleDissolve() { |
| | | if (!props.group) { |
| | | return |
| | | } |
| | | try { |
| | | await confirm('è§£æ£åæææåå°è¢«ç§»åºï¼ä¸æ æ³æ¢å¤ï¼ç¡®è®¤è§£æ£åï¼', '确认解æ£') |
| | | } catch { |
| | | return |
| | | } |
| | | const groupId = props.group.id |
| | | await dissolveGroup(groupId) |
| | | conversationStore.removeConversation(ImConversationType.GROUP, groupId) |
| | | groupStore.removeGroup(groupId) |
| | | message.success('群è已解æ£') |
| | | visible.value = false |
| | | } |
| | | |
| | | // ==================== 群主æä½ ==================== |
| | | // ç§»é¤ç¾¤æåï¼ç¾¤ä¸» / 管çåå¯è§ï¼+ 设置群管çåï¼ä»
群主ï¼+ 群主管çæè½¬è®©ï¼ä»
ç¾¤ä¸»ï¼ |
| | | |
| | | const removeDialogRef = ref<InstanceType<typeof GroupMemberRemoveDialog>>() // ç§»é¤ç¾¤æåå¼¹çª ref |
| | | const adminSetDialogRef = ref<InstanceType<typeof GroupAdminSetDialog>>() // 设置群管çåå¼¹çª ref |
| | | const ownerTransferDialogRef = ref<InstanceType<typeof GroupOwnerTransferDialog>>() // è½¬è®©ç¾¤ä¸»å¼¹çª ref |
| | | |
| | | // ---------- ç§»é¤ç¾¤æå ---------- |
| | | |
| | | /** æå¼ç§»é¤ç¾¤æåå¼¹çªï¼å§ç»éè群主ï¼ç®¡çåè§è§é¢å¤éèå
¶å®ç®¡çåï¼ç®¡çåä¸è½ç§»åºç®¡çåï¼ */ |
| | | function handleOpenRemove() { |
| | | if (!props.group?.id) { |
| | | return |
| | | } |
| | | const hideIds: number[] = [] |
| | | if (props.group.ownerId) { |
| | | hideIds.push(props.group.ownerId) |
| | | } |
| | | if (myRole.value === ImGroupMemberRole.ADMIN) { |
| | | props.members |
| | | .filter((m) => m.role === ImGroupMemberRole.ADMIN) |
| | | .forEach((m) => hideIds.push(m.userId)) |
| | | } |
| | | removeDialogRef.value?.open({ |
| | | groupId: props.group.id, |
| | | members: props.members, |
| | | hideIds |
| | | }) |
| | | } |
| | | |
| | | // ---------- 设置群管çå ---------- |
| | | |
| | | /** æå¼è®¾ç½®ç¾¤ç®¡çåå¼¹çªï¼å½å管çåé»è®¤å¾éï¼ç¾¤ä¸»ä»åéééè */ |
| | | function handleOpenAdminSet() { |
| | | if (!props.group?.id) { |
| | | return |
| | | } |
| | | // è¿æ»¤å·²é群æåï¼é¿å
maxSize åé¢è¢«éèæåå ç¨å¯¼è´æ æ³æ°å¢ç®¡çå |
| | | const currentAdminIds = props.members |
| | | .filter( |
| | | (member) => |
| | | member.role === ImGroupMemberRole.ADMIN && member.status !== CommonStatusEnum.DISABLE |
| | | ) |
| | | .map((member) => member.userId) |
| | | const hideIds = props.group.ownerId ? [props.group.ownerId] : [] |
| | | adminSetDialogRef.value?.open({ |
| | | groupId: props.group.id, |
| | | members: props.members, |
| | | currentAdminIds, |
| | | hideIds |
| | | }) |
| | | } |
| | | |
| | | // ---------- 群主管çæè½¬è®© ---------- |
| | | |
| | | /** æå¼è½¬è®©ç¾¤ä¸»å¼¹çªï¼å½åç¨æ·ä»åéééèï¼ä¸è½è½¬ç»èªå·±ï¼ */ |
| | | function handleOpenTransferOwner() { |
| | | if (!props.group?.id) { |
| | | return |
| | | } |
| | | ownerTransferDialogRef.value?.open({ |
| | | groupId: props.group.id, |
| | | members: props.members, |
| | | hideIds: [myId.value] |
| | | }) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- è天颿¿å³ä¾§ç¾¤ä¿¡æ¯æ½å±ï¼æåå®«æ ¼ + ç¾¤ä¿¡æ¯ + å¼å
³ + éåºç¾¤èï¼æ´ä½å¯¹é½å¾®ä¿¡ PC --> |
| | | <Drawer |
| | | v-model:open="visible" |
| | | :closable="false" |
| | | placement="right" |
| | | width="380px" |
| | | root-class-name="im-conversation-group-side__modal" |
| | | > |
| | | <div v-if="group" class="flex flex-col h-full bg-[var(--ant-color-bg-container)]"> |
| | | <!-- ä¸é¨ï¼å¯æ»å¨å
å®¹åº --> |
| | | <div class="flex-1 overflow-y-auto bg-[var(--ant-color-fill-secondary)]"> |
| | | <!-- ==================== 群æååº ==================== --> |
| | | <div class="px-4 pt-4 pb-[10px] bg-[var(--ant-color-bg-container)]"> |
| | | <Input v-model:value="searchText" placeholder="æç´¢ç¾¤æå" allow-clear> |
| | | <template #prefix> |
| | | <Icon |
| | | icon="ant-design:search-outlined" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </template> |
| | | </Input> |
| | | |
| | | <div class="flex flex-wrap gap-x-1 gap-y-[14px] mt-[14px]"> |
| | | <GroupMemberGrid |
| | | v-for="member in displayMembers" |
| | | :key="member.userId" |
| | | :member="member" |
| | | :size="50" |
| | | clickable |
| | | :group-name="group.name" |
| | | /> |
| | | |
| | | <!-- æ·»å ï¼ä»»ä½æåé½è½é请ï¼åå²é群群éèï¼ --> |
| | | <div |
| | | v-if="!isQuitGroup" |
| | | class="im-conversation-group-side__tile-wrap flex flex-col items-center w-[66px] cursor-pointer" |
| | | title="é请好åå
¥ç¾¤" |
| | | @click="handleOpenInvite" |
| | | > |
| | | <div class="im-conversation-group-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200"> |
| | | <Icon icon="ant-design:plus-outlined" /> |
| | | </div> |
| | | <div class="mt-1.5 text-12px leading-[1.5] text-[var(--ant-color-text)] text-center">æ·»å </div> |
| | | </div> |
| | | |
| | | <!-- ç§»åºï¼ç¾¤ä¸»æç®¡çåï¼ç®¡çååªè½ç§»åºæ®éæåï¼ç±åç«¯æ ¡éªï¼ --> |
| | | <div |
| | | v-if="isOwnerOrAdmin" |
| | | class="im-conversation-group-side__tile-wrap flex flex-col items-center w-[66px] cursor-pointer" |
| | | title="ç§»åºç¾¤æå" |
| | | @click="handleOpenRemove" |
| | | > |
| | | <div class="im-conversation-group-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200"> |
| | | <Icon icon="ant-design:minus-outlined" /> |
| | | </div> |
| | | <div class="mt-1.5 text-12px leading-[1.5] text-[var(--ant-color-text)] text-center">ç§»åº</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 大群æå ï¼é»è®¤åªå±ç¤ºå N 个ï¼ç¹ "æ¥çæ´å¤" å
¨å±å¼ï¼æç´¢æ¶ä¸æå ï¼ --> |
| | | <div |
| | | v-if="moreMembersHidden" |
| | | class="flex items-center justify-center gap-1 mt-[10px] pt-1.5 pb-0.5 text-12px text-[var(--ant-color-text-secondary)] cursor-pointer transition-colors duration-150 hover:text-[var(--ant-color-primary)]" |
| | | @click="showAllMembers = true" |
| | | > |
| | | æ¥çæ´å¤ |
| | | <Icon icon="ant-design:down-outlined" :size="10" /> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | |
| | | <!-- ==================== ç¾¤ä¿¡æ¯ ==================== --> |
| | | <!-- label å¨ä¸ãvalue å¨ä¸ï¼çºµåå å ï¼å¯¹é½å¾®ä¿¡ PC 设计ï¼ï¼åªæ "群å
¬å" å 为å
容é¿å > chevron --> |
| | | <div class="bg-[var(--ant-color-bg-container)]"> |
| | | <!-- 群èåç§°ï¼ç¾¤ä¸»å¯æ¹ï¼ --> |
| | | <Popover |
| | | v-if="isOwner" |
| | | v-model:open="namePopoverVisible" |
| | | trigger="click" |
| | | placement="leftTop" |
| | | :overlay-style="{ width: '280px' }" |
| | | > |
| | | <div |
| | | class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群èåç§°</span> |
| | | <span class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] truncate">{{ group.name }}</span> |
| | | </div> |
| | | <template #content> |
| | | <div class="flex flex-col gap-2"> |
| | | <Input v-model:value="editName" :maxlength="20" placeholder="请è¾å
¥ç¾¤èåç§°" /> |
| | | <div class="flex justify-end gap-2"> |
| | | <Button size="small" @click="namePopoverVisible = false">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="saveName">ä¿å</Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | <div |
| | | v-else |
| | | class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 transition-colors duration-150" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群èåç§°</span> |
| | | <span class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] truncate">{{ group.name }}</span> |
| | | </div> |
| | | |
| | | <!-- 群å
¬åï¼ç¾¤ä¸»å¯æ¹ï¼ï¼å
容å¯è½å¾é¿ï¼å > chevron 表示å¯å±å¼ç¼è¾ --> |
| | | <Popover |
| | | v-if="isOwner" |
| | | v-model:open="noticePopoverVisible" |
| | | trigger="click" |
| | | placement="leftTop" |
| | | :overlay-style="{ width: '320px' }" |
| | | > |
| | | <div |
| | | class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | <div class="flex items-center justify-between gap-2"> |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群å
Œ</span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | <span |
| | | v-if="group.notice" |
| | | class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] line-clamp-2" |
| | | > |
| | | {{ group.notice }} |
| | | </span> |
| | | <span v-else class="text-13px text-[var(--ant-color-text-placeholder)] leading-[1.6]">æªè®¾ç½®</span> |
| | | </div> |
| | | <template #content> |
| | | <div class="flex flex-col gap-2"> |
| | | <Input.TextArea |
| | | v-model:value="editNotice" |
| | | :rows="4" |
| | | :maxlength="1024" |
| | | show-count |
| | | placeholder="请è¾å
¥ç¾¤å
Œ" |
| | | /> |
| | | <div class="flex justify-end gap-2"> |
| | | <Button size="small" @click="noticePopoverVisible = false">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="saveNotice">ä¿å</Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | <div |
| | | v-else |
| | | class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 transition-colors duration-150" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群å
Œ</span> |
| | | <span |
| | | v-if="group.notice" |
| | | class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] line-clamp-2" |
| | | > |
| | | {{ group.notice }} |
| | | </span> |
| | | <span v-else class="text-13px text-[var(--ant-color-text-placeholder)] leading-[1.6]">æªè®¾ç½®</span> |
| | | </div> |
| | | |
| | | <!-- 夿³¨ï¼ä»
èªå·±å¯è§ï¼ä¿åå伿¿æ¢ä¼è¯å表 / é¡¶é¨ç¾¤åå±ç¤ºï¼ï¼åå²é群群éèï¼æ¹å¤æ³¨èµ° updateGroupMemberï¼å·²é群ä¼è¢«å端æ --> |
| | | <Popover |
| | | v-if="!isQuitGroup" |
| | | v-model:open="groupRemarkPopoverVisible" |
| | | trigger="click" |
| | | placement="leftTop" |
| | | :overlay-style="{ width: '280px' }" |
| | | > |
| | | <div |
| | | class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">夿³¨</span> |
| | | <span |
| | | v-if="group.groupRemark" |
| | | class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] line-clamp-2" |
| | | > |
| | | {{ group.groupRemark }} |
| | | </span> |
| | | <span v-else class="text-13px text-[var(--ant-color-text-placeholder)] leading-[1.6]"> |
| | | 群èç夿³¨ä»
èªå·±å¯è§ |
| | | </span> |
| | | </div> |
| | | <template #content> |
| | | <div class="flex flex-col gap-2"> |
| | | <Input.TextArea |
| | | v-model:value="editGroupRemark" |
| | | :rows="3" |
| | | :maxlength="64" |
| | | show-count |
| | | placeholder="ä»
èªå·±å¯è§" |
| | | /> |
| | | <div class="flex justify-end gap-2"> |
| | | <Button size="small" @click="groupRemarkPopoverVisible = false">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="saveGroupRemark">ä¿å</Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | |
| | | <!-- æå¨æ¬ç¾¤çæµç§°ï¼ä»»ä½æåé½è½æ¹èªå·±çï¼ï¼åå²é群群éèï¼èµ° updateGroupMemberï¼å·²é群ä¼è¢«å端æ --> |
| | | <Popover |
| | | v-if="!isQuitGroup" |
| | | v-model:open="remarkPopoverVisible" |
| | | trigger="click" |
| | | placement="leftTop" |
| | | :overlay-style="{ width: '280px' }" |
| | | > |
| | | <div |
| | | class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">æå¨æ¬ç¾¤çæµç§°</span> |
| | | <span |
| | | v-if="group.remarkNickName" |
| | | class="text-13px text-[var(--ant-color-text)] break-all leading-[1.6] truncate" |
| | | > |
| | | {{ group.remarkNickName }} |
| | | </span> |
| | | <span v-else class="text-13px text-[var(--ant-color-text-placeholder)] leading-[1.6]">ç¹å»è®¾ç½®</span> |
| | | </div> |
| | | <template #content> |
| | | <div class="flex flex-col gap-2"> |
| | | <Input v-model:value="editRemark" :maxlength="20" placeholder="请è¾å
¥æ¬ç¾¤æµç§°" /> |
| | | <div class="flex justify-end gap-2"> |
| | | <Button size="small" @click="remarkPopoverVisible = false">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="saveRemark">ä¿å</Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | </div> |
| | | |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | |
| | | <!-- ==================== æ¥æ¾è天å
容 ==================== --> |
| | | <!-- ç¹å» â ç¶ç»ä»¶æå¼ MessageHistory å¼¹çª --> |
| | | |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | |
| | | <div class="bg-[var(--ant-color-bg-container)]"> |
| | | <div |
| | | class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | @click="emit('openHistory')" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">æ¥æ¾è天å
容</span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | <!-- å享群åçï¼å¼¹ RecommendCardDialogï¼æå½å群ä½ä¸ºåçæ¶æ¯æ¨èç»å
¶ä»ä¼è¯ --> |
| | | <div |
| | | v-if="group" |
| | | class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | @click="handleShareGroupCard" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">å享群åç</span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | |
| | | <!-- ==================== å¼å
³é¡¹ ==================== --> |
| | | <div class="bg-[var(--ant-color-bg-container)]"> |
| | | <div class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150"> |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">æ¶æ¯å
ææ°</span> |
| | | <Switch :checked="!!conversation?.silent" @change="onMutedChange" /> |
| | | </div> |
| | | <div class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150"> |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">置顶è天</span> |
| | | <Switch :checked="!!conversation?.top" @change="onTopChange" /> |
| | | </div> |
| | | <!-- å
¨ç¾¤ç¦è¨ï¼ä»
群主æç®¡çåå¯æä½ --> |
| | | <div v-if="isOwnerOrAdmin" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150"> |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">å
¨ç¾¤ç¦è¨</span> |
| | | <Switch :checked="!!currentMutedAll" @change="onMuteAllChange" /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ==================== è¿ç¾¤å®¡æ¹ ==================== --> |
| | | <!-- åç¬ä¸æ®µï¼ç¾¤ä¸»å¼å
³ + ç´§è·ã- è¿ç¾¤ç³è¯·ãå项ï¼ä¸å¾®ä¿¡ç¾¤ç®¡çå¸å±å¯¹é½ --> |
| | | <template v-if="isOwner || (isOwnerOrAdmin && !!group.joinApproval)"> |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | <div class="bg-[var(--ant-color-bg-container)]"> |
| | | <!-- è¿ç¾¤å®¡æ¹ï¼ä»
ç¾¤ä¸»å¯æä½ï¼å¼å¯åæ®éæåçãç³è¯·ããé请ãè·¯å¾é½é群主 / 管çååæï¼ç¾¤ä¸» / 管çåé请ç´è¿ --> |
| | | <div v-if="isOwner" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150"> |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">è¿ç¾¤éè¦ç¾¤ä¸» / 群管ç确认</span> |
| | | <Switch :checked="!!group.joinApproval" @change="handleJoinApprovalChange" /> |
| | | </div> |
| | | <!-- è¿ç¾¤ç³è¯·å项ï¼ä»
å½å¼å¯å®¡æ¹ + å½åç¨æ·æ¯ owner / admin æ¶åºç°ï¼ç¹å»è¿å表 dialog --> |
| | | <div |
| | | v-if="isOwnerOrAdmin && !!group.joinApproval" |
| | | class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | @click="handleOpenRequestList" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">- è¿ç¾¤ç³è¯·</span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- ==================== 群主æä½ ==================== --> |
| | | <!-- ä»
群主å¯è§ï¼å«ç®¡çå设置 + 群主管çæè½¬è®© --> |
| | | <template v-if="isOwner"> |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | <div class="bg-[var(--ant-color-bg-container)]"> |
| | | <div |
| | | class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | @click="handleOpenAdminSet" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群管çå</span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | <div |
| | | class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | @click="handleOpenTransferOwner" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">群主管çæè½¬è®©</span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | |
| | | <!-- ==================== åºé¨ï¼éåº / è§£æ£ç¾¤èï¼åå²é群群éèï¼å·²é群æ éåéï¼ ==================== --> |
| | | <div |
| | | v-if="!isQuitGroup" |
| | | class="flex-shrink-0 px-4 pt-[14px] pb-[18px] bg-[var(--ant-color-bg-container)] border-t border-t-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <!-- 群主ï¼è§£æ£ç¾¤è --> |
| | | <Button |
| | | v-if="isOwner" |
| | | class="w-full !h-9 text-14px" |
| | | danger |
| | | @click="handleDissolve" |
| | | > |
| | | è§£æ£ç¾¤è |
| | | </Button> |
| | | <!-- é群主ï¼éåºç¾¤è --> |
| | | <Button |
| | | v-else |
| | | class="w-full !h-9 text-14px" |
| | | danger |
| | | @click="handleQuit" |
| | | > |
| | | éåºç¾¤è |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ==================== åå¯¹è¯æ¡ ==================== --> |
| | | <!-- éè¯·æ°æå / éæåç§»é¤ --> |
| | | <GroupMemberAddDialog ref="inviteDialogRef" @reload="(ids) => $emit('reload', ids)" /> |
| | | <GroupMemberRemoveDialog ref="removeDialogRef" @reload="$emit('reload')" /> |
| | | |
| | | <!-- 群主æä½ï¼ç®¡çå设置ï¼ä¸ä¸ªå¼¹çªåå¹¶å¢ / å ï¼æäº¤æ¶ diffï¼+ 群主管çæè½¬è®© --> |
| | | <GroupAdminSetDialog ref="adminSetDialogRef" @reload="$emit('reload')" /> |
| | | <GroupOwnerTransferDialog ref="ownerTransferDialogRef" @reload="$emit('reload')" /> |
| | | |
| | | <!-- è¿ç¾¤ç³è¯·å表ï¼ä»
å½å¼å¯å®¡æ¹ + å½åç¨æ·æ¯ owner / admin æ¶å
¥å£å¯è§ï¼ --> |
| | | <GroupRequestListDialog ref="requestListDialogRef" /> |
| | | |
| | | <!-- å享群åçï¼æå½å群ä½ä¸ºåçæ¶æ¯æ¨èç»å
¶ä»ä¼è¯ --> |
| | | <RecommendCardDialog ref="recommendCardDialogRef" /> |
| | | </Drawer> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ãæ·»å / ç§»åºãç¦çï¼hover æ¶èå¨å
é¨ icon-tile 走主è²ï¼è·¨åå
ç´ ç hover è卿 æ³ç¨åå
ç´ å·¥å
·ç±»è¡¨è¾¾ */ |
| | | .im-conversation-group-side__tile-wrap:hover .im-conversation-group-side__icon-tile { |
| | | color: var(--ant-color-primary); |
| | | border-color: var(--ant-color-primary); |
| | | background-color: var(--ant-color-primary-bg); |
| | | } |
| | | |
| | | /* :deep ç©¿é Icon å
é¨ svgï¼ el-icon å
¨å± color å¨æè²æ¨¡å¼ä¸è¢«ä¸»é¢çè¿ï¼é fill å°å½åè² */ |
| | | .im-conversation-group-side__icon-tile :deep(svg) { |
| | | fill: currentColor !important; |
| | | } |
| | | |
| | | /* ç¸é»ä¿¡æ¯è¡å åéçº¿ï¼ ç¸é»å
å¼éæ©å¨æ æ³ç¨å·¥å
·ç±»è¡¨è¾¾ */ |
| | | .im-conversation-group-side__row + .im-conversation-group-side__row { |
| | | border-top: 1px solid var(--im-border-color-lighter); |
| | | } |
| | | </style> |
| | | |
| | | <!-- AntD Drawer è¢«ä¼ éåºå½å scoped è¾¹çï¼è¿éé root-class-name åæé»è®¤ padding --> |
| | | <style> |
| | | .im-conversation-group-side__modal .ant-drawer-body { |
| | | padding: 0; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation } from '../../../../types' |
| | | |
| | | import { computed } from 'vue' |
| | | |
| | | import { confirm } from '#/packages/effects/common-ui/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Tag } from 'ant-design-vue' |
| | | |
| | | import { buildRecallTip } from '#/views/im/utils/conversation' |
| | | import { formatConversationTime } from '#/views/im/utils/time' |
| | | import { getSenderDisplayName } from '#/views/im/utils/user' |
| | | |
| | | import { ImContentType, ImConversationType, isNormalMessage } from '../../../../../utils/constants' |
| | | import { GroupAvatar } from '../../../../components/group' |
| | | import { UserAvatar } from '../../../../components/user' |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useFriendStore } from '../../../../store/friendStore' |
| | | import { useGroupRequestStore } from '../../../../store/groupRequestStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | import { useImUiStore } from '../../../../store/uiStore' |
| | | |
| | | defineOptions({ name: 'ImConversationItem' }) |
| | | |
| | | // å¨ä¸æåï¼dayjs ç day() è¿å 0-6ï¼0=卿¥ï¼ï¼é¡¹ç®æ²¡å
¨å±è£
dayjs/locale/zh-cnï¼æ¬å°æ å°é¿å
å¼å¯ä½ç¨ |
| | | const props = defineProps<{ |
| | | conversation: Conversation |
| | | }>() |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | const groupRequestStore = useGroupRequestStore() |
| | | const uiStore = useImUiStore() |
| | | |
| | | const isActive = computed( |
| | | () => |
| | | conversationStore.activeConversation?.targetId === props.conversation.targetId && |
| | | conversationStore.activeConversation?.type === props.conversation.type |
| | | ) |
| | | |
| | | /** |
| | | * å½åä¼è¯çè稿快ç
§ï¼å卿¶å表æ¾ç¤º [è稿] åç¼ + ææ¬ï¼çæ sender åç¼å @æ 红å |
| | | * å½åæå¼çä¼è¯ä¸å±ç¤ºè稿ï¼è¾å
¥å
容已ç»å¨ç¼è¾å¨éå¯è§ï¼åè¡¨åæ¾ç¤º [è稿] åèåä½ |
| | | */ |
| | | const draft = computed(() => { |
| | | if (isActive.value) { |
| | | return undefined |
| | | } |
| | | return conversationStore.getConversationDraft(props.conversation) |
| | | }) |
| | | |
| | | const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP) |
| | | |
| | | /** æå䏿¡æ¶æ¯åéè
çå±ç¤ºåï¼å®æ¶ç® + å¿«ç
§ fallbackï¼getSenderDisplayName ç®ä¸åºæ¶å
åºï¼ */ |
| | | const lastSenderDisplayName = computed(() => { |
| | | const senderId = props.conversation.lastSenderId |
| | | if (!senderId) { |
| | | return '' |
| | | } |
| | | return getSenderDisplayName( |
| | | senderId, |
| | | props.conversation.type, |
| | | props.conversation.targetId, |
| | | props.conversation.lastSenderDisplayName |
| | | ) |
| | | }) |
| | | |
| | | /** 群è + ææååéè
+ æå䏿¡æ¯æ®éæ¶æ¯æ¶ï¼æ¾ç¤ºåéè
åç¼ï¼FRIEND_* / GROUP_* / RECALL / è稿æä¸å¸¦åç¼ï¼ */ |
| | | const showSendName = computed(() => { |
| | | if (draft.value) { |
| | | return false |
| | | } |
| | | if (!isGroup.value) { |
| | | return false |
| | | } |
| | | if (!props.conversation.lastSenderId) { |
| | | return false |
| | | } |
| | | const lastType = props.conversation.lastMessageType |
| | | return lastType != null && isNormalMessage(lastType) |
| | | }) |
| | | |
| | | /** å表å±ç¤ºææ¡ï¼è稿ä¼å
ï¼å¯¹é½å¾®ä¿¡ PCï¼æè稿æ¶çææå䏿¡é¢è§ï¼â æ¤å宿¶ç® â lastContent å
åº */ |
| | | const lastContentDisplay = computed(() => { |
| | | if (draft.value) { |
| | | return draft.value.plain |
| | | } |
| | | if ( |
| | | props.conversation.lastMessageType === ImContentType.RECALL && |
| | | props.conversation.lastSenderId != null |
| | | ) { |
| | | return buildRecallTip( |
| | | props.conversation.lastSenderId, |
| | | !!props.conversation.lastSelfSend, |
| | | props.conversation.type, |
| | | props.conversation.targetId, |
| | | props.conversation.lastSenderDisplayName |
| | | ) |
| | | } |
| | | return props.conversation.lastContent |
| | | }) |
| | | |
| | | /** ä¼è¯å表 "[è稿]" / "@ æ" / "@ å
¨ä½æå" 红åæç¤ºï¼è稿ä¼å
ï¼å¯¹é½å¾®ä¿¡ PCï¼ */ |
| | | const atText = computed(() => { |
| | | if (draft.value) { |
| | | return '[è稿]' |
| | | } |
| | | if (props.conversation.atMe) { |
| | | return '[æäºº@æ]' |
| | | } |
| | | if (props.conversation.atAll) { |
| | | return '[@å
¨ä½æå]' |
| | | } |
| | | return '' |
| | | }) |
| | | |
| | | /** å
ææ°ä¼è¯æªè¯»æ¡æ°ææ¡ */ |
| | | const mutedUnreadText = computed(() => { |
| | | if (!props.conversation.silent || props.conversation.unreadCount <= 0) { |
| | | return '' |
| | | } |
| | | const count = props.conversation.unreadCount > 99 ? '99+' : props.conversation.unreadCount |
| | | return `[${count}æ¡]` |
| | | }) |
| | | |
| | | /** ç¾¤èæªå¤çå 群ç³è¯·çº¢ååç¼ï¼store å·²ç»æãæç®¡çç群ãè¿æ»¤è¿ï¼count > 0 å³å¯æ¾ç¤º */ |
| | | const requestText = computed(() => { |
| | | if (!isGroup.value) { |
| | | return '' |
| | | } |
| | | const count = groupRequestStore.getUnhandledGroupRequestCountMap.get(props.conversation.targetId) ?? 0 |
| | | return count > 0 ? `[${count}æ¡è¿ç¾¤ç³è¯·]` : '' |
| | | }) |
| | | |
| | | /** ç¹å»åä¼è¯ */ |
| | | function handleClick() { |
| | | conversationStore.setActiveConversation(props.conversation) |
| | | } |
| | | |
| | | /** åæ¢ç½®é¡¶ */ |
| | | function handleTop() { |
| | | conversationStore.setConversationTop( |
| | | props.conversation.type, |
| | | props.conversation.targetId, |
| | | !props.conversation.top |
| | | ) |
| | | } |
| | | |
| | | /** 忢å
ææ°ï¼ä¹è§ UIï¼å
æ¬å°åæ¢ï¼èåç«å³å
³ï¼åç«¯å¤±è´¥åæ» conversation ç¶æï¼ */ |
| | | function handleMuted() { |
| | | const next = !props.conversation.silent |
| | | const { type, targetId } = props.conversation |
| | | conversationStore.setConversationSilent(type, targetId, next) |
| | | const sync = |
| | | type === ImConversationType.PRIVATE |
| | | ? friendStore.setFriendSilent(targetId, next) |
| | | : groupStore.setGroupSilent(targetId, next) |
| | | sync.catch((error) => { |
| | | console.error('[IM] 忢å
ææ°å¤±è´¥', error) |
| | | conversationStore.setConversationSilent(type, targetId, !next) |
| | | }) |
| | | } |
| | | |
| | | /** å é¤ä¼è¯ï¼äºæ¬¡ç¡®è®¤å软å */ |
| | | async function handleDelete() { |
| | | try { |
| | | await confirm(`ç¡®å®å é¤ä¸ã${props.conversation.name}ãçä¼è¯åï¼`, 'å é¤ä¼è¯') |
| | | conversationStore.removeConversation(props.conversation.type, props.conversation.targetId) |
| | | } catch {} |
| | | } |
| | | |
| | | /** å³é®èåï¼ç½®é¡¶ / å
ææ° / å é¤ */ |
| | | function handleContextMenu(e: MouseEvent) { |
| | | uiStore.openContextMenu( |
| | | { x: e.clientX, y: e.clientY }, |
| | | [ |
| | | { key: 'TOP', name: props.conversation.top ? 'åæ¶ç½®é¡¶' : '置顶' }, |
| | | { key: 'MUTED', name: props.conversation.silent ? 'å
è®¸æ¶æ¯éç¥' : 'æ¶æ¯å
ææ°' }, |
| | | { key: 'DELETE', name: 'å é¤', divided: true, danger: true } |
| | | ], |
| | | (item) => { |
| | | switch (item.key) { |
| | | case 'DELETE': { |
| | | void handleDelete() |
| | | |
| | | break; |
| | | } |
| | | case 'MUTED': { |
| | | handleMuted() |
| | | |
| | | break; |
| | | } |
| | | case 'TOP': { |
| | | handleTop() |
| | | |
| | | break; |
| | | } |
| | | // No default |
| | | } |
| | | } |
| | | ) |
| | | } |
| | | |
| | | </script> |
| | | |
| | | <template> |
| | | <div |
| | | class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class="{ '!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': isActive }" |
| | | :data-conversation-key="`${conversation.type}-${conversation.targetId}`" |
| | | @click="handleClick" |
| | | @contextmenu.prevent="handleContextMenu" |
| | | > |
| | | <!-- 头å + æªè¯»æç¤º --> |
| | | <div class="relative"> |
| | | <GroupAvatar |
| | | v-if="isGroup" |
| | | :group-id="conversation.targetId" |
| | | :url="conversation.avatar" |
| | | :name="conversation.name" |
| | | :size="40" |
| | | /> |
| | | <UserAvatar |
| | | v-else |
| | | :url="conversation.avatar" |
| | | :name="conversation.name" |
| | | :size="40" |
| | | :clickable="false" |
| | | /> |
| | | <!-- æ°åå¾½æ ï¼éå
ææ°ä¸ææªè¯»æ¶æ¾ç¤ºå
·ä½æ¡æ° --> |
| | | <span |
| | | v-show="!conversation.silent && conversation.unreadCount > 0" |
| | | class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1.5 text-11px leading-[18px] text-white text-center bg-[#f56c6c] border border-solid border-white dark:border-[var(--ant-color-bg-container)] rounded-full box-border whitespace-nowrap" |
| | | > |
| | | {{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }} |
| | | </span> |
| | | <!-- å°çº¢ç¹ï¼å
ææ°ä¸ææªè¯»æ¶æç¤ºå卿°æ¶æ¯ --> |
| | | <span |
| | | v-show="conversation.silent && conversation.unreadCount > 0" |
| | | class="absolute -top-1 -right-1 w-2.5 h-2.5 bg-[#f56c6c] border border-solid border-white dark:border-[var(--ant-color-bg-container)] rounded-full box-border" |
| | | ></span> |
| | | </div> |
| | | |
| | | <div class="flex-1 min-w-0"> |
| | | <div class="flex items-center justify-between"> |
| | | <span class="flex flex-1 items-center gap-1 min-w-0"> |
| | | <span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]"> |
| | | {{ conversation.name }} |
| | | </span> |
| | | <Tag |
| | | v-if="isGroup" |
| | | type="primary" |
| | | size="small" |
| | | class="conversation-item__tag flex-shrink-0 !h-[18px] !px-1 !leading-4" |
| | | > |
| | | 群 |
| | | </Tag> |
| | | </span> |
| | | <span class="flex-shrink-0 ml-1 text-12px text-[var(--ant-color-text-secondary)]"> |
| | | {{ formatConversationTime(conversation.lastSendTime) }} |
| | | </span> |
| | | </div> |
| | | <div class="flex items-center mt-1 leading-5"> |
| | | <!-- è¿ç¾¤ç³è¯·çº¢ååç¼ï¼ç¾¤ä¸» / 管çåçå°èªå·±ç®¡çç群ä¸è¿ææªå¤çç³è¯·æ¶æ¾ç¤º --> |
| | | <span |
| | | v-if="requestText" |
| | | class="conversation-item__prefix flex-shrink overflow-hidden text-12px text-[#c70b0b] truncate whitespace-nowrap" |
| | | > |
| | | {{ requestText }} |
| | | </span> |
| | | <!-- @红åæç¤ºï¼atMe ä¼å
äº atAll --> |
| | | <span |
| | | v-if="atText" |
| | | class="conversation-item__prefix flex-shrink overflow-hidden text-12px text-[#c70b0b] truncate whitespace-nowrap" |
| | | > |
| | | {{ atText }} |
| | | </span> |
| | | <!-- å
ææ°æªè¯»æ¡æ° --> |
| | | <span |
| | | v-if="mutedUnreadText" |
| | | class="flex-shrink-0 mr-1 text-12px text-[var(--ant-color-text-secondary)] whitespace-nowrap" |
| | | > |
| | | {{ mutedUnreadText }} |
| | | </span> |
| | | <!-- 群èæå䏿¡åéè
åç¼ï¼æ lastSenderId + å½åä¼è¯ä¸ä¸æå®æ¶ç®åå --> |
| | | <span |
| | | v-if="showSendName" |
| | | class="conversation-item__sender flex-shrink overflow-hidden text-12px text-[var(--ant-color-text-secondary)] truncate whitespace-nowrap" |
| | | > |
| | | {{ lastSenderDisplayName }}: |
| | | </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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation, Friend } from '../../../../types' |
| | | |
| | | import { computed, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, Drawer, Input, message, Popover, Spin, Switch } from 'ant-design-vue' |
| | | |
| | | import { useConversationStore } from '#/views/im/home/store/conversationStore' |
| | | import { useFriendStore } from '#/views/im/home/store/friendStore' |
| | | import { useGroupStore } from '#/views/im/home/store/groupStore' |
| | | import { ImConversationType } from '#/views/im/utils/constants' |
| | | import { getFriendDisplayName, getGroupDisplayName } from '#/views/im/utils/user' |
| | | |
| | | import { GroupCreateDialog } from '../../../../components/group' |
| | | import { UserAvatar } from '../../../../components/user' |
| | | |
| | | defineOptions({ name: 'ImConversationPrivateSide' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | conversation?: Conversation | null // å½åä¼è¯ï¼å置顶 / å
ææ°æï¼ |
| | | friend?: Friend // 对æ¹å¥½åä¿¡æ¯ï¼å头å / æµç§°ï¼ |
| | | modelValue?: boolean // æ½å±å¼å
³ï¼v-modelï¼ |
| | | }>(), |
| | | { |
| | | conversation: null, |
| | | friend: undefined, |
| | | modelValue: false |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | openHistory: [] // ç¹å» "æ¥æ¾è天å
容" è¡ â ç¶ç»ä»¶æå¼ MessageHistory å¼¹çª |
| | | 'update:modelValue': [value: boolean] |
| | | }>() |
| | | |
| | | const visible = computed({ |
| | | get: () => props.modelValue, |
| | | set: (value) => emit('update:modelValue', value) |
| | | }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | |
| | | /** tile æ ç¾ / åç»è天çé¢ç¨çå±ç¤ºåï¼å¤æ³¨ä¼å
*/ |
| | | const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : '')) |
| | | |
| | | const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // å起群èå¼¹çª refï¼handleOpenCreateGroup è° open({ lockedIds }) éå®å¯¹æ¹ |
| | | |
| | | /** æå¼å起群èå¼¹çªï¼æå¯¹æ¹é»è®¤å¾ä¸ä¸ä¸å¯åæ¶ï¼å¯¹åºå¾®ä¿¡"åºäºç§èå起群è" */ |
| | | function handleOpenCreateGroup() { |
| | | const lockedIds = props.friend ? [props.friend.friendUserId] : [] |
| | | createGroupDialogRef.value?.open({ lockedIds }) |
| | | } |
| | | |
| | | const displayNamePopoverVisible = ref(false) |
| | | const editDisplayName = ref('') |
| | | |
| | | // popover å¼¹åºæ¶æå½å夿³¨çè¿ç¼è¾æï¼é¿å
䏿¬¡æªä¿åçèå¼ |
| | | watch(displayNamePopoverVisible, (open) => { |
| | | if (open) { |
| | | editDisplayName.value = props.friend?.displayName || '' |
| | | } |
| | | }) |
| | | |
| | | // æ½å±å
³éæ¶æè¿æ²¡æ¶æç popover ä¸å¹¶æ¸
æï¼é¿å
䏿¬¡æå¼éªä¸ä¸ |
| | | watch(visible, (open) => { |
| | | if (!open) { |
| | | displayNamePopoverVisible.value = false |
| | | } |
| | | }) |
| | | |
| | | /** 夿³¨ popover ç¹å»ä¿å */ |
| | | async function handleSaveDisplayName() { |
| | | if (!props.friend) { |
| | | return |
| | | } |
| | | await friendStore.setFriendDisplayName(props.friend.friendUserId, editDisplayName.value) |
| | | displayNamePopoverVisible.value = false |
| | | message.success('ä¿åæå') |
| | | } |
| | | |
| | | /** |
| | | * åå
ææ°ï¼ä¹è§åå conversationStore + friendStoreï¼åç«¯å¤±è´¥åæ» conversation ç¶æï¼ä¿æä¸ ConversationItem.handleMuted ä¸è´ |
| | | */ |
| | | function handleMutedChange(value: boolean | number | string) { |
| | | if (!props.conversation) { |
| | | return |
| | | } |
| | | const next = !!value |
| | | const { type, targetId } = props.conversation |
| | | conversationStore.setConversationSilent(type, targetId, next) |
| | | if (type !== ImConversationType.PRIVATE) { |
| | | return |
| | | } |
| | | friendStore.setFriendSilent(targetId, next).catch((error) => { |
| | | console.error('[IM ConversationPrivateSide] 忢å
ææ°å¤±è´¥', { targetId }, error) |
| | | conversationStore.setConversationSilent(type, targetId, !next) |
| | | }) |
| | | } |
| | | |
| | | /** å置顶ï¼çº¯æ¬å° conversationStore æåºæï¼æ åç«¯åæ®µï¼ */ |
| | | function handleTopChange(value: boolean | number | string) { |
| | | if (!props.conversation) { |
| | | return |
| | | } |
| | | conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value) |
| | | } |
| | | |
| | | /** 群å建æåï¼è·³å°æ°ç¾¤ä¼è¯ + å
³ææ¬ä¾§æ½å±ï¼è®©ç¨æ·ä¸æ³¨æ°ç¾¤ */ |
| | | function handleGroupCreated(groupId: number) { |
| | | const group = groupStore.getGroup(groupId) |
| | | if (!group) { |
| | | return |
| | | } |
| | | conversationStore.openConversation( |
| | | groupId, |
| | | ImConversationType.GROUP, |
| | | getGroupDisplayName(group), |
| | | group.avatar || '', |
| | | { silent: !!group.silent } |
| | | ) |
| | | visible.value = false |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | ç§èä¾§è¾¹æ½å± |
| | | - æ´ä½ç»æå¯¹é½ ConversationGroupSideï¼å®«æ ¼ + ä¿¡æ¯è¡ + å¼å
³ |
| | | - é¡¶é¨å¥½åå®«æ ¼ + "+" tileï¼ç¹ + è°èµ· GroupCreateDialog å¹¶éå®å¯¹æ¹ï¼å¯¹é½å¾®ä¿¡"åºäºç§èå起群è" |
| | | - "æ¸
空è天记å½"æé®å¨ WeChat éæï¼ä½ç®ååç«¯æ²¡å»ºæ¶æ¯æ¸
空è½åï¼å
ä¸å é¿å
åååå |
| | | --> |
| | | <Drawer |
| | | v-model:open="visible" |
| | | :closable="false" |
| | | placement="right" |
| | | width="340px" |
| | | root-class-name="im-conversation-private-side__modal" |
| | | > |
| | | <!-- friend ç¼ºå¤±åºæ¯ï¼éç人ä¼è¯åæå¼ / 好忰æ®è¿æ²¡è¡¥æå°ï¼ç©ºç½ä¼è®©ç¨æ·ä»¥ä¸ºæ½å±åäºï¼ç»ä¸ªå è½½å ä½ --> |
| | | <div |
| | | v-if="!friend" |
| | | class="flex flex-col items-center justify-center h-full text-13px text-[var(--ant-color-text-placeholder)] bg-[var(--ant-color-bg-container)]" |
| | | > |
| | | <Spin tip="å è½½ä¸..." /> |
| | | </div> |
| | | <div v-else class="flex flex-col h-full bg-[var(--ant-color-bg-container)]"> |
| | | <div class="flex-1 overflow-y-auto bg-[var(--ant-color-fill-secondary)]"> |
| | | <!-- 好åå®«æ ¼ï¼å tile + "+" tileï¼å¯¹é½ GroupSide è§è§ï¼è®©ä¸¤ç§æ½å±çèµ·æ¥æ¯ä¸å®¶ç --> |
| | | <div class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--ant-color-bg-container)]"> |
| | | <div class="flex flex-col items-center w-[66px]"> |
| | | <UserAvatar |
| | | :id="friend.friendUserId" |
| | | :url="friend.avatar" |
| | | :name="friend.nickname" |
| | | :size="50" |
| | | /> |
| | | <div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate"> |
| | | {{ displayName }} |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- + tileï¼ç¹å»è°èµ· GroupCreateDialogï¼æå¯¹æ¹ id ä½ä¸º lockedIds ä¼ å
¥ --> |
| | | <div |
| | | class="im-conversation-private-side__tile-wrap-clickable flex flex-col items-center w-[66px] cursor-pointer" |
| | | title="å起群è" |
| | | @click="handleOpenCreateGroup" |
| | | > |
| | | <div class="im-conversation-private-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200"> |
| | | <Icon icon="ant-design:plus-outlined" /> |
| | | </div> |
| | | <div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">æ·»å </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | |
| | | <!-- 夿³¨ï¼ä»
èªå·±å¯è§ï¼ï¼ç¹å»å¼¹ popover ç¼è¾ï¼ä¿ååç«å³å·æ°æ¬æ½å± + ä¼è¯å表å±ç¤ºå --> |
| | | <div class="bg-[var(--ant-color-bg-container)]"> |
| | | <Popover |
| | | v-model:open="displayNamePopoverVisible" |
| | | trigger="click" |
| | | placement="leftTop" |
| | | :overlay-style="{ width: '280px' }" |
| | | > |
| | | <div |
| | | class="im-conversation-private-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">夿³¨</span> |
| | | <span |
| | | v-if="friend.displayName" |
| | | class="text-13px leading-[1.6] text-[var(--ant-color-text)] break-all line-clamp-2" |
| | | > |
| | | {{ friend.displayName }} |
| | | </span> |
| | | <span v-else class="text-13px leading-[1.6] text-[var(--ant-color-text-placeholder)]"> |
| | | 好å夿³¨ä»
èªå·±å¯è§ |
| | | </span> |
| | | </div> |
| | | <template #content> |
| | | <div class="flex flex-col gap-2"> |
| | | <Input |
| | | v-model:value="editDisplayName" |
| | | :maxlength="16" |
| | | show-count |
| | | placeholder="请è¾å
¥å¤æ³¨å" |
| | | /> |
| | | <div class="flex justify-end gap-2"> |
| | | <Button size="small" @click="displayNamePopoverVisible = false">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="handleSaveDisplayName"> |
| | | ä¿å |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | </div> |
| | | |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | |
| | | <!-- æ¥æ¾è天å
容 --> |
| | | <div class="bg-[var(--ant-color-bg-container)]"> |
| | | <div |
| | | class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | @click="emit('openHistory')" |
| | | > |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">æ¥æ¾è天å
容</span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="flex-shrink-0 h-[10px]"></div> |
| | | |
| | | <!-- å¼å
³é¡¹ --> |
| | | <div class="bg-[var(--ant-color-bg-container)]"> |
| | | <div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150"> |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">æ¶æ¯å
ææ°</span> |
| | | <Switch :checked="!!conversation?.silent" @change="handleMutedChange" /> |
| | | </div> |
| | | <div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150"> |
| | | <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">置顶è天</span> |
| | | <Switch :checked="!!conversation?.top" @change="handleTopChange" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- åå¯¹è¯æ¡ï¼å起群èï¼éå®å¯¹æ¹ä¸ºå·²éï¼ --> |
| | | <GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" /> |
| | | </Drawer> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ã+ã tileï¼ hover æ¶èå¨å
é¨ icon-tile 走主è²ï¼ è·¨åå
ç´ ç hover è卿 æ³ç¨åå
ç´ å·¥å
·ç±»è¡¨è¾¾ */ |
| | | .im-conversation-private-side__tile-wrap-clickable:hover .im-conversation-private-side__icon-tile { |
| | | color: var(--ant-color-primary); |
| | | border-color: var(--ant-color-primary); |
| | | background-color: var(--ant-color-primary-bg); |
| | | } |
| | | |
| | | /* :deep ç©¿é Icon å
é¨ svgï¼ el-icon å
¨å± color å¨æè²æ¨¡å¼ä¸è¢«ä¸»é¢çè¿ï¼é fill å°å½åè² */ |
| | | .im-conversation-private-side__icon-tile :deep(svg) { |
| | | fill: currentColor !important; |
| | | } |
| | | |
| | | /* ç¸é»ä¿¡æ¯è¡å åéçº¿ï¼ ç¸é»å
å¼éæ©å¨æ æ³ç¨å·¥å
·ç±»è¡¨è¾¾ */ |
| | | .im-conversation-private-side__row + .im-conversation-private-side__row { |
| | | border-top: 1px solid var(--im-border-color-lighter); |
| | | } |
| | | </style> |
| | | |
| | | <!-- å GroupSideï¼antd Drawer ä¼ éå° body å scoped data-v è½ä¸å° body ä¸ï¼é root-class-name ä½ç¥å
éæ©å¨åå
¨å±è§ååæé»è®¤ padding --> |
| | | <style> |
| | | .im-conversation-private-side__modal .ant-drawer-body { |
| | | padding: 0; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | export * from './conversation'; |
| | | export * from './input'; |
| | | export * from './message'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { ImFacePackApi } from '#/api/im/face/pack' |
| | | import type { ImFaceUserItemApi } from '#/api/im/face/userItem' |
| | | |
| | | import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { message, Tooltip } from 'ant-design-vue' |
| | | |
| | | import { uploadFile } from '#/api/infra/file' |
| | | import { useFaceStore } from '#/views/im/home/store/faceStore' |
| | | import { IM_EMOJI_LIST } from '#/views/im/utils/emoji' |
| | | import { probeImageSize } from '#/views/im/utils/image' |
| | | |
| | | defineOptions({ name: 'ImFacePicker' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** fullï¼emoji + 个人表æ
+ ç³»ç»å
ï¼è天主è¾å
¥ç¨ï¼ï¼emojiï¼ä»
emojiï¼çè¨ / è¯è®ºåºæ¯ï¼ */ |
| | | mode?: FacePickerMode |
| | | visible: boolean |
| | | }>(), |
| | | { mode: 'full' } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** éä¸ Unicode emojiï¼å¦ ðï¼ï¼è°ç¨æ¹åºæå
¥å°è¾å
¥æ¡èµ° TEXT éé */ |
| | | selectEmoji: [emoji: string] |
| | | /** éä¸è¡¨æ
è´´å¾ï¼è°ç¨æ¹åºèµ° FACE æ¶æ¯åé */ |
| | | selectFace: [face: { height: number; name?: string; url: string; width: number; }] |
| | | 'update:visible': [value: boolean] |
| | | }>() |
| | | |
| | | /** 颿¿æ¨¡å¼ */ |
| | | type FacePickerMode = 'emoji' | 'full' |
| | | |
| | | const rootRef = useTemplateRef<HTMLDivElement>('rootRef') |
| | | const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef') |
| | | |
| | | const faceStore = useFaceStore() |
| | | |
| | | // tab æ è¯å¸¸éï¼pack:N ç±»ç¨ packTabKey() æ¼åºï¼é¿å
æ£è½å符串åé¢é |
| | | const FACE_TAB = { |
| | | EMOJI: 'emoji', |
| | | MINE: 'mine' |
| | | } as const |
| | | const packTabKey = (packId: number) => `pack:${packId}` |
| | | |
| | | const activeTab = ref<string>(FACE_TAB.EMOJI) // å½åæ¿æ´»ç tab |
| | | |
| | | /** æ¯å¦å®æ´æ¨¡å¼ï¼å«ä¸ªäºº / ç³»ç»å
tabï¼ */ |
| | | const isFullMode = computed(() => props.mode === 'full') |
| | | |
| | | const uploading = ref(false) // ä¸ä¼ 䏿 è®°ï¼é¿å
è¿ç»ç¹å»è§¦åå¹¶åä¸ä¼ |
| | | |
| | | /** é emoji åç¬¦ï¼æå°è¾å
¥æ¡ï¼éå®ä¸å
³é¢æ¿ï¼æ¹ä¾¿ç¨æ·è¿åå¤ä¸ª */ |
| | | function handleSelectEmoji(emoji: string) { |
| | | emit('selectEmoji', emoji) |
| | | } |
| | | |
| | | /** é个人表æ
ï¼ç´æ¥åï¼ç¹å®å
³é¢æ¿ï¼å¯¹é½å¾®ä¿¡ */ |
| | | function handleSelectFaceUserItem(item: ImFaceUserItemApi.FaceUserItem) { |
| | | emit('selectFace', { url: item.url, width: item.width, height: item.height, name: item.name }) |
| | | emit('update:visible', false) |
| | | } |
| | | |
| | | /** éç³»ç»è¡¨æ
å
å
表æ
ï¼ç´æ¥åï¼ç¹å®å
³é¢æ¿ */ |
| | | function handleSelectPackItem(item: ImFacePackApi.FacePackUserItem) { |
| | | emit('selectFace', { url: item.url, width: item.width, height: item.height, name: item.name }) |
| | | emit('update:visible', false) |
| | | } |
| | | |
| | | /** é¿æ / å³é®å é¤ä¸ªäººè¡¨æ
*/ |
| | | async function handleDeleteUserItem(item: ImFaceUserItemApi.FaceUserItem) { |
| | | if (!confirm('确认å é¤è¯¥è¡¨æ
ï¼')) { |
| | | return |
| | | } |
| | | await faceStore.removeFaceUserItem(item.id) |
| | | } |
| | | |
| | | /** ç¹ + 触åæä»¶éæ© */ |
| | | function onUploadClick() { |
| | | uploadInputRef.value?.click() |
| | | } |
| | | |
| | | /** æä»¶éå®å³ä¸ä¼ ï¼æåååå
¥ faceStore 个人表æ
å表 */ |
| | | async function onUploadPicked(e: Event) { |
| | | const input = e.target as HTMLInputElement |
| | | const file = input.files?.[0] |
| | | input.value = '' |
| | | if (!file) { |
| | | return |
| | | } |
| | | uploading.value = true |
| | | let size: { height: number; width: number; } |
| | | try { |
| | | size = await probeImageSize(file) |
| | | } catch (error) { |
| | | console.warn('[IM] è§£æä¸ªäººè¡¨æ
失败', error) |
| | | message.error('å¾çè§£æå¤±è´¥') |
| | | uploading.value = false |
| | | return |
| | | } |
| | | |
| | | try { |
| | | const url = await uploadFile({ file }) |
| | | if (!url) { |
| | | message.error('ä¸ä¼ 失败') |
| | | return |
| | | } |
| | | const payload = { url, width: size.width, height: size.height } |
| | | await faceStore.addFaceUserItem(payload) |
| | | } finally { |
| | | uploading.value = false |
| | | } |
| | | } |
| | | |
| | | /** 颿¿å±å¼æ¶ææ°æ® + æå
¨å± clickï¼mode='emoji' æ¶ä¸æç³»ç»å
/ 个人表æ
*/ |
| | | watch( |
| | | () => props.visible, |
| | | (visible) => { |
| | | if (visible) { |
| | | document.addEventListener('click', handleDocumentClick) |
| | | if (isFullMode.value) { |
| | | // ç³»ç»å
é常已被 home onMounted 颿è¿ï¼ensureXxx å
é¨ promise ç¼åé¿å
éå¤è¯·æ± |
| | | void faceStore.ensureFacePackList() |
| | | void faceStore.ensureFaceUserItemList() |
| | | } |
| | | } else { |
| | | document.removeEventListener('click', handleDocumentClick) |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | /** ç¹å»é¢æ¿å¤é¨å
³é */ |
| | | function handleDocumentClick(e: MouseEvent) { |
| | | if (!props.visible || !rootRef.value) { |
| | | return |
| | | } |
| | | if (!rootRef.value.contains(e.target as Node)) { |
| | | emit('update:visible', false) |
| | | } |
| | | } |
| | | |
| | | onUnmounted(() => { |
| | | document.removeEventListener('click', handleDocumentClick) |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 表æ
颿¿ï¼å¤ tabï¼ï¼emoji / 个人表æ
/ N 个系ç»è¡¨æ
å
|
| | | - 对é½å¾®ä¿¡ PCï¼åºé¨ tab æ 忢颿¿å
容ï¼emoji ä¿æ Unicodeï¼ä»ç± TEXT ééåéï¼ |
| | | - 个人表æ
/ ç³»ç»è¡¨æ
èµ° FACE å
容类åï¼éè¿ selectFace äºä»¶ç±è°ç¨æ¹èµ° sendRaw åé |
| | | - mode='emoji' æ¶åªæ¾ç¤º emoji tab + éèåºé¨ tab æ ï¼ç»çè¨ / è¯è®ºè¿ç±»åªåææ¬çåºæ¯ç¨ |
| | | - å®ä½ç±è°ç¨æ¹å³å®ï¼é常浮å¨è¡¨æ
æé®ä¸æ¹ï¼ |
| | | --> |
| | | <div |
| | | v-if="visible" |
| | | ref="rootRef" |
| | | class="im-popover-arrow absolute z-100 flex flex-col w-[380px] rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]" |
| | | @click.stop |
| | | > |
| | | <!-- 主å
容åºï¼é«åº¦åºå® + å tab ç¨ v-show 忢ï¼é¿å
æ¯æ¬¡å tab é建 scrollbar é ææ»å¨ä½ç½®ä¸¢å¤± --> |
| | | <div class="relative h-[300px] overflow-hidden"> |
| | | <!-- emoji ç½æ ¼ --> |
| | | <div v-show="activeTab === FACE_TAB.EMOJI" style="height: 300px; overflow-y: auto"> |
| | | <div class="grid grid-cols-10 gap-0.5 p-2"> |
| | | <button |
| | | v-for="emoji in IM_EMOJI_LIST" |
| | | :key="emoji" |
| | | class="p-1 text-xl leading-none bg-transparent border-none rounded cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | type="button" |
| | | @click="handleSelectEmoji(emoji)" |
| | | > |
| | | {{ emoji }} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 个人表æ
ï¼5 åæ¹æ ¼ï¼æ ååæ ç¾ï¼æ«å°¾ã+ãä¸ä¼ --> |
| | | <div v-if="isFullMode" v-show="activeTab === FACE_TAB.MINE" style="height: 300px; overflow-y: auto"> |
| | | <div class="grid grid-cols-5 gap-2 p-3"> |
| | | <!-- ä¸ä¼ å
¥å£åºå®æ¾ç¬¬ä¸æ ¼ï¼dashed border ä¸è¡¨æ
æ ¼ååºåè§è§è¯ä¹ï¼å¯¹é½ el-upload è§æ --> |
| | | <button |
| | | class="aspect-square flex items-center justify-center rounded-md cursor-pointer transition-colors border border-dashed border-[var(--ant-color-border)] bg-transparent text-[var(--ant-color-text-placeholder)] hover:border-[var(--ant-color-primary)] hover:text-[var(--ant-color-primary)] disabled:cursor-not-allowed disabled:text-[var(--ant-color-text-disabled)]" |
| | | type="button" |
| | | :disabled="uploading" |
| | | :title="uploading ? 'ä¸ä¼ ä¸â¦' : 'ä¸ä¼ å¾çå°ä¸ªäººè¡¨æ
'" |
| | | @click="onUploadClick" |
| | | > |
| | | <Icon |
| | | :icon="uploading ? 'eos-icons:bubble-loading' : 'ant-design:plus-outlined'" |
| | | :size="22" |
| | | /> |
| | | </button> |
| | | <div |
| | | v-for="item in faceStore.faceUserItems" |
| | | :key="item.id" |
| | | class="im-face-grid-cell group relative aspect-square flex items-center justify-center rounded-md bg-[var(--ant-color-fill-tertiary)] cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :title="item.name || 'ç¹å»åé / å³é®å é¤'" |
| | | @click="handleSelectFaceUserItem(item)" |
| | | @contextmenu.prevent="handleDeleteUserItem(item)" |
| | | > |
| | | <img |
| | | :src="item.url" |
| | | :alt="item.name || '表æ
'" |
| | | class="max-w-full max-h-full object-contain" |
| | | draggable="false" |
| | | /> |
| | | </div> |
| | | <div |
| | | v-if="faceStore.faceUserItems.length === 0" |
| | | class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | è¿æ²¡æä¸ªäººè¡¨æ
ï¼ç¹ + ä¸ä¼ / å¨è天éé¿ææ¶æ¯æ·»å |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ç³»ç»è¡¨æ
å
ï¼5 åæ¹æ ¼ + 䏿¹è¡¨æ
åï¼æ name æ¶ä¸æ¾ç¤ºæ ç¾ï¼ä¿æé«åº¦ä¸è´ï¼ --> |
| | | <template v-if="isFullMode"> |
| | | <div |
| | | v-for="pack in faceStore.facePacks" |
| | | v-show="activeTab === packTabKey(pack.id)" |
| | | :key="pack.id" |
| | | style="height: 300px; overflow-y: auto" |
| | | > |
| | | <div class="grid grid-cols-5 gap-2 p-3"> |
| | | <div |
| | | v-for="item in pack.items" |
| | | :key="item.id" |
| | | class="im-face-grid-cell flex flex-col items-center gap-1 cursor-pointer rounded-md p-1 transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :title="item.name || ''" |
| | | @click="handleSelectPackItem(item)" |
| | | > |
| | | <div |
| | | class="aspect-square w-full flex items-center justify-center bg-[var(--ant-color-fill-tertiary)] rounded" |
| | | > |
| | | <img |
| | | :src="item.url" |
| | | :alt="item.name || '表æ
'" |
| | | class="max-w-full max-h-full object-contain" |
| | | draggable="false" |
| | | /> |
| | | </div> |
| | | <div |
| | | class="h-4 text-xs text-[var(--ant-color-text-secondary)] truncate w-full text-center leading-4" |
| | | > |
| | | {{ item.name || '' }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | v-if="pack.items.length === 0" |
| | | class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | 该表æ
å
ææ è¡¨æ
|
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | |
| | | <!-- åºé¨ tab æ ï¼[ emoji / 个人 / ç³»ç»å
1..N ]ï¼mode='emoji' æ¶éè --> |
| | | <div |
| | | v-if="isFullMode" |
| | | class="flex flex-shrink-0 items-center gap-1 px-2 py-1.5 border-t border-t-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <Tooltip title="Emoji 表æ
" placement="top" :mouse-enter-delay="300"> |
| | | <button |
| | | class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class=" |
| | | activeTab === FACE_TAB.EMOJI |
| | | ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]' |
| | | : 'text-[var(--ant-color-text)]' |
| | | " |
| | | type="button" |
| | | @click="activeTab = FACE_TAB.EMOJI" |
| | | > |
| | | <Icon icon="ant-design:smile-outlined" :size="18" /> |
| | | </button> |
| | | </Tooltip> |
| | | <Tooltip title="个人表æ
" placement="top" :mouse-enter-delay="300"> |
| | | <button |
| | | class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class=" |
| | | activeTab === FACE_TAB.MINE |
| | | ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]' |
| | | : 'text-[var(--ant-color-text)]' |
| | | " |
| | | type="button" |
| | | @click="activeTab = FACE_TAB.MINE" |
| | | > |
| | | <Icon icon="ant-design:heart-outlined" :size="18" /> |
| | | </button> |
| | | </Tooltip> |
| | | <Tooltip |
| | | v-for="pack in faceStore.facePacks" |
| | | :key="pack.id" |
| | | :title="pack.name" |
| | | placement="top" |
| | | :mouse-enter-delay="300" |
| | | > |
| | | <button |
| | | class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class=" |
| | | activeTab === packTabKey(pack.id) |
| | | ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]' |
| | | : 'text-[var(--ant-color-text)]' |
| | | " |
| | | type="button" |
| | | @click="activeTab = packTabKey(pack.id)" |
| | | > |
| | | <img |
| | | v-if="pack.icon" |
| | | :src="pack.icon" |
| | | :alt="pack.name" |
| | | class="w-[18px] h-[18px] object-contain" |
| | | draggable="false" |
| | | /> |
| | | <Icon v-else icon="ant-design:appstore-outlined" :size="18" /> |
| | | </button> |
| | | </Tooltip> |
| | | </div> |
| | | |
| | | <!-- éèçæä»¶éæ©å¨ï¼ç¹ + æ¶è§¦åï¼ --> |
| | | <input |
| | | v-if="isFullMode" |
| | | ref="uploadInputRef" |
| | | type="file" |
| | | accept="image/*" |
| | | hidden |
| | | @change="onUploadPicked" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* åºé¨å°ä¸è§ï¼æå触å徿 ï¼ä»¿å¾®ä¿¡ PC æ°æ³¡æéï¼left å移对åºè¡¨æ
æé®ï¼å·¥å
·æ 1st iconï¼ */ |
| | | .im-popover-arrow::after { |
| | | content: ''; |
| | | position: absolute; |
| | | top: calc(100% - 1px); |
| | | left: 10px; |
| | | border-style: solid; |
| | | border-width: 6px 6px 0 6px; |
| | | border-color: var(--ant-color-bg-container) transparent transparent transparent; |
| | | filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08)); |
| | | } |
| | | |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, ref, useTemplateRef, watch } from 'vue' |
| | | |
| | | import { CommonStatusEnum } from '#/packages/constants/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '#/views/im/utils/constants' |
| | | |
| | | import { GroupMember, type GroupMemberLite } from '../../../../components/group' |
| | | |
| | | defineOptions({ name: 'ImMentionPicker' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | canAtAll?: boolean // å½åç¨æ·æ¯å¦è½ @ å
¨åï¼ç¾¤ä¸» / 管çåï¼ï¼ç¶ç»ä»¶æè§è²ç®å¥½ä¼ å
¥ |
| | | members: GroupMemberLite[] // å½å群çæåå表 |
| | | // æµ®å±ä½ç½®ï¼x æ¨ªåæ + top / bottom äºéä¸ï¼bottom é宿¶ picker 䏿²¿è´´ @ 䏿¹ï¼ |
| | | position?: { bottom?: number; top?: number; x: number; } |
| | | searchText?: string // @ åè¾å
¥çè¿æ»¤ææ¬ |
| | | visible: boolean // æ¯å¦æ¾ç¤º |
| | | }>(), |
| | | { |
| | | searchText: '', |
| | | position: () => ({ x: 0, bottom: 0 }) |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | select: [member: GroupMemberLite] |
| | | 'update:visible': [value: boolean] |
| | | }>() |
| | | |
| | | const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef') |
| | | const activeIdx = ref(0) |
| | | |
| | | /** å½åç»å½ç¨æ· idï¼æååè¡¨è¿æ»¤æèªå·±ï¼ */ |
| | | const selfUserId = computed(() => getCurrentUserId()) |
| | | |
| | | /** |
| | | * èæ"ææäºº"项ï¼ç¾¤ä¸» / 管çåï¼canAtAll=trueï¼+ å
³é®åå½ä¸"ææäºº"åç¼æ¶åå¨ |
| | | * |
| | | * MessageInput èµ° token data-id æ¶é atUserIdsï¼ä¸ä¾èµææ¡åç¬¦ä¸²ï¼ |
| | | * è¿éç userId / ææ¡é½ä» im/utils/constants åï¼é¿å
æ£è½ |
| | | */ |
| | | const allItem = computed<GroupMemberLite | null>(() => { |
| | | if (!props.canAtAll) { |
| | | return null |
| | | } |
| | | if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) { |
| | | return null |
| | | } |
| | | // @ææäºº æ¯ä¸ªä¼ªæåï¼nickname ç» IM_AT_ALL_NICKNAME 让头å :name è¡ä¸ºå¯¹é½æ®éæå |
| | | return { |
| | | userId: IM_AT_ALL_USER_ID, |
| | | showName: IM_AT_ALL_NICKNAME, |
| | | nickname: IM_AT_ALL_NICKNAME |
| | | } |
| | | }) |
| | | |
| | | /** çæåï¼è¿æ»¤èªå·± / é群 / ä¸å¹é
å
³é®åï¼ä¸æªææ°éï¼æµ®å± max-height + el-scrollbar ææ»å¨ */ |
| | | const memberItems = computed<GroupMemberLite[]>(() => |
| | | props.members.filter( |
| | | (member) => |
| | | member.userId !== selfUserId.value && |
| | | member.status !== CommonStatusEnum.DISABLE && |
| | | !!member.showName && |
| | | member.showName.startsWith(props.searchText) |
| | | ) |
| | | ) |
| | | |
| | | /** é®ç导èªä¸ pickActive èµ°çæå¹³å表ï¼allItem å¨åãmemberItems å¨å */ |
| | | const showMembers = computed<GroupMemberLite[]>(() => { |
| | | return allItem.value ? [allItem.value, ...memberItems.value] : memberItems.value |
| | | }) |
| | | |
| | | /** åéå表ååï¼ç¨æ·è¾å
¥å
³é®è¯å¨è¿æ»¤ï¼â éç½®é«äº®å°é¦é¡¹ + æ»åé¡¶ */ |
| | | watch(showMembers, (list) => { |
| | | activeIdx.value = list.length > 0 ? 0 : -1 |
| | | scrollToTop() |
| | | }) |
| | | |
| | | /** æµ®å±éæ°æå¼ â éç½®é«äº® + æ»åé¡¶ï¼é¿å
䏿¬¡çä¸é´ç¶ææ®çï¼ */ |
| | | watch( |
| | | () => props.visible, |
| | | (v) => { |
| | | if (v) { |
| | | activeIdx.value = showMembers.value.length > 0 ? 0 : -1 |
| | | scrollToTop() |
| | | } |
| | | } |
| | | ) |
| | | |
| | | /** el-scrollbar 没æ´é² scrollToï¼ç´æ¥æ¿å
é¨ wrap è° scrollTop */ |
| | | function scrollToTop() { |
| | | const scrollWrap = scrollRef.value |
| | | if (scrollWrap) { |
| | | scrollWrap.scrollTop = 0 |
| | | } |
| | | } |
| | | |
| | | /** é®çä¸ä¸å¯¼èªæ¶æé«äº®é¡¹æ»å°å¯è§åºï¼è¶
åºåºè¾¹ä¸æ¨ãè¶
åºé¡¶è¾¹ä¸æï¼å¦åä¸å¨ */ |
| | | function scrollToActive() { |
| | | const scrollWrap = scrollRef.value |
| | | if (!scrollWrap) { |
| | | return |
| | | } |
| | | const itemHeight = 40 |
| | | const activeOffsetTop = activeIdx.value * itemHeight |
| | | if (activeOffsetTop + itemHeight > scrollWrap.scrollTop + scrollWrap.clientHeight) { |
| | | scrollWrap.scrollTop = activeOffsetTop + itemHeight - scrollWrap.clientHeight |
| | | } else if (activeOffsetTop < scrollWrap.scrollTop) { |
| | | scrollWrap.scrollTop = activeOffsetTop |
| | | } |
| | | } |
| | | |
| | | /** éä¸ä¸é¡¹ï¼emit ç» MessageInput è½ tokenï¼åæ¶å
³ææµ®å± */ |
| | | function handleSelect(member: GroupMemberLite) { |
| | | emit('select', member) |
| | | emit('update:visible', false) |
| | | } |
| | | |
| | | // æ´é²ç»ç¶ç»ä»¶çé®çå¯¼èªæ¹æ³ |
| | | defineExpose({ |
| | | moveUp() { |
| | | if (activeIdx.value > 0) { |
| | | activeIdx.value-- |
| | | scrollToActive() |
| | | } |
| | | }, |
| | | moveDown() { |
| | | if (activeIdx.value < showMembers.value.length - 1) { |
| | | activeIdx.value++ |
| | | scrollToActive() |
| | | } |
| | | }, |
| | | pickActive() { |
| | | const member = showMembers.value[activeIdx.value] |
| | | if (activeIdx.value >= 0 && member) { |
| | | handleSelect(member) |
| | | } |
| | | }, |
| | | hasCandidates: () => showMembers.value.length > 0 |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | @ æåéæ©æµ®å±ï¼å¯¹é½å¾®ä¿¡ PCï¼ææäººå¨é¡¶ + 群æååç» + åºé¨ä¸è§æéï¼ |
| | | - ç¶ç»ä»¶éè¿ v-model:visible æ§å¶æ¾éï¼searchText è¿æ»¤ |
| | | - ç¶ç»ä»¶éè¿ ref è° moveUp / moveDown / pickActive å®ç°é®çä¸ä¸ + Enter éä¸ |
| | | - @select åå°è¢«éä¸çæå |
| | | --> |
| | | <div |
| | | v-show="visible && showMembers.length > 0" |
| | | class="message-input__mention-picker !fixed z-100 w-50 rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]" |
| | | :style="{ |
| | | left: `${position.x }px`, |
| | | top: position.top != null ? `${position.top }px` : 'auto', |
| | | bottom: position.bottom != null ? `${position.bottom }px` : 'auto' |
| | | }" |
| | | > |
| | | <div ref="scrollRef" style="max-height: 300px; overflow-y: auto"> |
| | | <!-- ææäººï¼èæé¡¹ï¼ä»
群主å¯éï¼èè²æ¹å + 群ä½å¾æ ï¼è·ä¸é¢çæå头ååºå --> |
| | | <div |
| | | v-if="allItem" |
| | | class="flex items-center gap-2.5 px-[5px] h-10 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class="{ 'bg-[#e1eaf7] dark:bg-[var(--ant-color-primary-bg)]': activeIdx === 0 }" |
| | | @click.stop="handleSelect(allItem)" |
| | | > |
| | | <div |
| | | class="flex items-center justify-center w-[30px] h-[30px] rounded text-white bg-[var(--ant-color-primary)] flex-shrink-0" |
| | | > |
| | | <Icon icon="ep:user-filled" :size="18" /> |
| | | </div> |
| | | <span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]"> |
| | | {{ allItem.showName }} |
| | | </span> |
| | | </div> |
| | | |
| | | <!-- 群æå section headerï¼åªæ"ææäºº + çæå"两边é½å¨æ¶æåºç°ï¼é¿å
å䏿¥æºæ¶æ¾å¾å¤ä½ --> |
| | | <div |
| | | v-if="allItem && memberItems.length > 0" |
| | | class="px-2 pt-2 pb-1 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | 群æå |
| | | </div> |
| | | |
| | | <!-- çæåè¡ --> |
| | | <GroupMember |
| | | v-for="(member, idx) in memberItems" |
| | | :key="member.userId" |
| | | :member="member" |
| | | :height="40" |
| | | :active="activeIdx === (allItem ? idx + 1 : idx)" |
| | | :clickable="false" |
| | | @click.stop="handleSelect(member)" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- ä¸è§æéï¼picker å¨ @ 䏿¹ï¼position.top éå®ï¼â ç®å¤´æä¸è´´é¡¶ï¼picker å¨ @ 䏿¹ï¼position.bottom éå®ï¼â ç®å¤´æä¸è´´åº --> |
| | | <div |
| | | class="absolute left-4 w-3 h-3 rotate-45 bg-[var(--ant-color-bg-container)]" |
| | | :class="position.top != null ? '-top-1.5' : '-bottom-1.5'" |
| | | ></div> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../../../../components/group' |
| | | |
| | | import type { Conversation } from '#/views/im/home/types' |
| | | |
| | | import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { isOpenableUrl } from '#/packages/utils/src' |
| | | |
| | | import { Button, DropdownButton, Menu, message, Tooltip } from 'ant-design-vue' |
| | | |
| | | import { uploadFile } from '#/api/infra/file' |
| | | import { |
| | | ensureMediaSizeWithinLimit, |
| | | useMediaUploader |
| | | } from '#/views/im/home/composables/useMediaUploader' |
| | | import { useMessageSender } from '#/views/im/home/composables/useMessageSender' |
| | | import { useMuteOverlay } from '#/views/im/home/composables/useMuteOverlay' |
| | | import { useConversationStore } from '#/views/im/home/store/conversationStore' |
| | | import { useFriendStore } from '#/views/im/home/store/friendStore' |
| | | import { useGroupStore } from '#/views/im/home/store/groupStore' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '#/views/im/utils/config' |
| | | import { ImContentType, ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants' |
| | | import { getConversationKey } from '#/views/im/utils/conversation' |
| | | import { |
| | | type FaceMessage, |
| | | type QuoteMessage, |
| | | serializeMessage, |
| | | withQuotePayload |
| | | } from '#/views/im/utils/message' |
| | | import { getMemberDisplayName } from '#/views/im/utils/user' |
| | | |
| | | import { ReplyPreview } from '../message' |
| | | import FacePicker from './face-picker.vue' |
| | | import MentionPicker from './mention-picker.vue' |
| | | import VoiceRecorder from './voice-recorder.vue' |
| | | |
| | | defineOptions({ name: 'ImMessageInput' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const groupStore = useGroupStore() |
| | | const friendStore = useFriendStore() |
| | | const { send, sendRaw } = useMessageSender() |
| | | const { |
| | | uploadAndSendMedia, |
| | | insertMediaPlaceholder, |
| | | markMediaFailed, |
| | | commitMediaPlaceholder, |
| | | createUploadProgressHandler, |
| | | verifyMediaUploadStillAllowed, |
| | | requireMediaHandler |
| | | } = useMediaUploader() |
| | | const muteOverlay = useMuteOverlay() // ç¦è¨ / å°ç¦è¦çå± |
| | | |
| | | const editorRef = useTemplateRef<HTMLDivElement>('editorRef') |
| | | const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef') |
| | | const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef') |
| | | const videoInputRef = useTemplateRef<HTMLInputElement>('videoInputRef') |
| | | const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef') |
| | | |
| | | // ==================== ææ¬ / åé ==================== |
| | | const canSend = ref(false) // editor æ¯å¦æå¯åéå
容ï¼contenteditable 没 v-modelï¼é input äºä»¶ä¸»å¨åæ¥ |
| | | |
| | | /** ç»´æ¤ canSend + data-emptyï¼æèµ· placeholderï¼ï¼ä¸åè稿ï¼restoreDraftToEditor å¤ç¨é¿å
åæµ */ |
| | | function applyEditorUiState(editor: HTMLDivElement) { |
| | | const raw = editor.textContent || '' |
| | | // canSend æ trim å夿ï¼ç©ºæ ¼ / æ¢è¡ä¸ç®å¯åéå
容ï¼ï¼ç¦è¨æç´æ¥ç¦ç¨ |
| | | canSend.value = !!raw.trim() && !!conversationStore.activeConversation && !muteOverlay.value |
| | | // data-empty æåå§å
容夿ï¼ç¨æ·æ²ä¸ä¸ªç©ºæ ¼ä¹è¦è®© placeholder éèï¼é¿å
è§è§å å |
| | | // ç¨å±æ§"åå¨ / 缺失"èé 'true'/'false' å符串ï¼CSS [data-empty]::before å½ä¸å³å¯ï¼ |
| | | // æ¯ [data-empty='true'] ç´è§ï¼æµè§å¨å 空åç <br> â :empty ä¸å½ä¸ï¼æä»¥å¿
é¡» JS ç»´æ¤ |
| | | if (raw) { |
| | | delete editor.dataset.empty |
| | | } else { |
| | | editor.dataset.empty = '' |
| | | } |
| | | } |
| | | |
| | | /** ç¨æ·ç¼è¾å
¥å£çç»ä¸æ¶å°¾ï¼UI ç¶æåæ¥ + è稿åå storeï¼å表ç«å³åº [è稿] åç¼ï¼ */ |
| | | function syncEditorState() { |
| | | const editor = editorRef.value |
| | | if (!editor) { |
| | | return |
| | | } |
| | | applyEditorUiState(editor) |
| | | syncDraftToStore(editor) |
| | | } |
| | | |
| | | /** ç¦è¨ç¶æå忶忥åéæé® */ |
| | | watch(muteOverlay, () => { |
| | | const editor = editorRef.value |
| | | if (editor) { |
| | | applyEditorUiState(editor) |
| | | } |
| | | }) |
| | | |
| | | /** æ editor å½åå
容åå°ä¼è¯è稿ï¼plain ç± collectFromEditor æ¿ï¼ä¸åéæ¶åæºé¿å
å表ä¸å®åä¸ä¸è´ */ |
| | | function syncDraftToStore(editor: HTMLDivElement) { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | // collectFromEditor å·² trimï¼plain ä¸ºç©ºæ¶ store å
鍿 clearConversationDraft å¤ç |
| | | // reply éä¼ å½åå¿«ç
§ï¼setConversationDraft æ¯æ´å¯¹è±¡æ¿æ¢ï¼ä¸è¯»æ§ reply ä¼è®©ç¨æ·æ¯æ²ä¸ä¸ªé®å°±æå¼ç¨æ¡æ¦æ |
| | | const { text } = collectFromEditor(editor) |
| | | const existing = conversationStore.getConversationDraft(conversation) |
| | | conversationStore.setConversationDraft(conversation, { |
| | | html: editor.innerHTML, |
| | | plain: text, |
| | | reply: existing?.reply |
| | | }) |
| | | } |
| | | |
| | | /** åä¼è¯æ¶æ store éçè稿è¿åå° editorï¼åªæ´ UI ä¸ååè稿ï¼é¿å
storeâeditorâstore åæµ */ |
| | | function restoreDraftToEditor() { |
| | | const editor = editorRef.value |
| | | if (!editor) { |
| | | return |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | const draft = conversation ? conversationStore.getConversationDraft(conversation) : undefined |
| | | editor.innerHTML = draft?.html || '' |
| | | applyEditorUiState(editor) |
| | | // æå
æ ç§»å°æ«å°¾ï¼è®©ç¨æ·æ¥çè¾å
¥ï¼ç©ºå
å®¹ç´æ¥ focus å³å¯ |
| | | if (draft?.html) { |
| | | placeCaretAtEnd(editor) |
| | | } |
| | | } |
| | | |
| | | /** æå
æ æ¾å° contenteditable å
ç´ çæ«å°¾ââååæè稿çä¼è¯æ¶å
æ èªç¶è½å¨å°¾é¨ï¼å¯¹é½å¾®ä¿¡ */ |
| | | function placeCaretAtEnd(el: HTMLElement) { |
| | | const range = document.createRange() |
| | | range.selectNodeContents(el) |
| | | range.collapse(false) |
| | | const sel = window.getSelection() |
| | | if (!sel) { |
| | | return |
| | | } |
| | | sel.removeAllRanges() |
| | | sel.addRange(range) |
| | | } |
| | | |
| | | /** |
| | | * èµ° DOM æ editor å
容æ¼å text + atUserIds |
| | | * |
| | | * èç¹åæ¯ï¼ |
| | | * 1. text èç¹ï¼ç´æ¥æ¼ textContentï¼è¿ç¨ä¸æ»¤æ ZWSPï¼token é¦ä½éç¹ç¨ï¼ä¸è¿åéå
å®¹ï¼ |
| | | * 2. brï¼æ¼ \nï¼Shift+Enter èµ° execCommand('insertLineBreak') 产ç©ï¼ |
| | | * 3. span[data-id]ï¼æ¼ token æ¾ç¤ºææ¬ + æ dataset.id æ¶å° atUserIdsï¼ä¸éå½ span å
é¨ï¼ |
| | | * 4. divï¼æµè§å¨å¨ contenteditable éé»è®¤æ¢è¡å®¹å¨ï¼åç½® \n åéå½åèç¹ |
| | | * 5. å
¶ä»å
ç´ ï¼éä¼ ï¼éå½åèç¹ |
| | | * |
| | | * atUserIds èµ° Set å»éï¼ç¨æ· @ å¼ ä¸ä¸¤æ¬¡æ¶ atUserIds åªåºç°ä¸æ¬¡ï¼trim æ«å°¾ç©ºç½ |
| | | */ |
| | | function collectFromEditor(root: HTMLElement): { atUserIds: number[]; text: string; } { |
| | | const userIds: number[] = [] |
| | | let text = '' |
| | | |
| | | function walk(node: Node) { |
| | | if (node.nodeType === Node.TEXT_NODE) { |
| | | text += (node.textContent || '').replaceAll('â', '') |
| | | return |
| | | } |
| | | if (node.nodeType !== Node.ELEMENT_NODE) { |
| | | return |
| | | } |
| | | const el = node as HTMLElement |
| | | const tag = el.tagName.toLowerCase() |
| | | if (tag === 'br') { |
| | | text += '\n' |
| | | return |
| | | } |
| | | if (tag === 'span' && el.dataset.id) { |
| | | text += el.textContent || '' |
| | | const id = Number(el.dataset.id) |
| | | if (!Number.isNaN(id)) { |
| | | userIds.push(id) |
| | | } |
| | | return |
| | | } |
| | | if (tag === 'div') { |
| | | if (text && !text.endsWith('\n')) { |
| | | text += '\n' |
| | | } |
| | | el.childNodes.forEach((child) => walk(child)) |
| | | return |
| | | } |
| | | el.childNodes.forEach((child) => walk(child)) |
| | | } |
| | | |
| | | // ç´æ¥ä» root.childNodes å¼å§ï¼é¿å
æ root æ¬èº«ä¹å½å
ç´ å¤çï¼è½ç¶ç®å没æç¹æ®æ ·å¼ï¼ä½ä»¥é²æªæ¥æ¹å¨ï¼ |
| | | root.childNodes.forEach((child) => walk(child)) |
| | | return { |
| | | text: text.trim(), |
| | | atUserIds: [...new Set(userIds)] |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * åéï¼ä» DOM æ¶ text + atUserIds â æ¸
空ç¼è¾å¨ â èµ° useMessageSender.send |
| | | * |
| | | * 1. é²å¾¡ï¼canSend æ¯ falseï¼trim å空 / æ²¡æ¿æ´»ä¼è¯ï¼æ editor 没 mount â ç´æ¥ return |
| | | * 2. æ¶éï¼DOM walk æ¿å°è¦åéçææ¬ + atUserIds |
| | | * 3. äºæ¬¡é²å¾¡ï¼collectFromEditor èµ° trimï¼å¯è½æ¯ syncEditorState æ´ä¸¥æ ¼ï¼ä¾å¦å
¨ ZWSPï¼ï¼ä»ç©ºå°± return |
| | | * 4. æ¸
空 + åæ¥ç¶æï¼å
æ¸
innerHTML å syncEditorState 让 placeholder / canSend ä¸èµ·åå½ |
| | | * ï¼é¡ºåºå¾éè¦ï¼å
æ¸
å syncï¼å¦å sync çå°æ§å
容ä¼è¯¯å¤ï¼ |
| | | * 5. ä¸éï¼atUserIds é空æä¼ ï¼é¿å
å空æ°ç»ï¼quote ç± clearConversationDraft åæåï¼ç¡®ä¿å¼ç¨æ¡ç«å³æ¶å¤± |
| | | */ |
| | | async function handleSend(options?: { receipt?: boolean }) { |
| | | const editor = editorRef.value |
| | | if (!canSend.value || !editor || muteOverlay.value) { |
| | | return |
| | | } |
| | | const { text, atUserIds } = collectFromEditor(editor) |
| | | if (!text) { |
| | | return |
| | | } |
| | | // 1. æ quote 忏
空 editor + å½åä¼è¯è稿ï¼å
å« replyï¼ï¼syncEditorState å / reply é½ä¸ºç©º |
| | | // store å
é¨ä¼èªå¨æ¸
çï¼ä½æ¾å¼ clearConversationDraft è½ç«å³åæ¥ãä¸ä¾èµ debounce æ¶åºï¼å表ä¸ç [è稿] ç«å³æ¶å¤± |
| | | const replyQuote = replyTarget.value |
| | | editor.innerHTML = '' |
| | | if (conversationStore.activeConversation) { |
| | | conversationStore.clearConversationDraft(conversationStore.activeConversation) |
| | | } |
| | | syncEditorState() |
| | | // 2. åé |
| | | await send(text, { |
| | | atUserIds: atUserIds.length > 0 ? atUserIds : undefined, |
| | | receipt: options?.receipt, |
| | | quote: replyQuote |
| | | }) |
| | | } |
| | | |
| | | /** åéæé®ä¸æèåæä½ */ |
| | | function handleSendMenuClick({ key }: { key: number | string }) { |
| | | if (key === 'receipt') { |
| | | handleSend({ receipt: true }) |
| | | } |
| | | } |
| | | |
| | | // ==================== éåº / æå
¥ ==================== |
| | | /** |
| | | * 䏿¬¡è½å¨ editor å
ç selectionï¼ç¦ç¹è¢«è¡¨æ
颿¿ç夺走æ¶ç¨æ¥åå°åæå
¥ç¹ï¼ |
| | | * |
| | | * çå¬ document.selectionchange æ¯ editor.@blur æ´å¯é ï¼blur æ¶ selection å·²ç»ç§»èµ° |
| | | */ |
| | | let savedRange: null | Range = null |
| | | |
| | | /** |
| | | * èµ° native execCommandï¼ä¿çæµè§å¨åç undo æ |
| | | * |
| | | * execCommand å¨ lib.dom é被æ @deprecatedï¼IDE æ¾ç¤ºå é¤çº¿ï¼ï¼ä½ 'insertText' / |
| | | * 'insertLineBreak' 没æçä»·ç W3C æ åæ¿ä»£ââRange/Selection èªå·±æ¼ DOM ä¼è®© Ctrl+Z 失æã |
| | | * éä¸å°è¿éè°ç¨ï¼è°ç¨ç¹ä¸å¿
æ£è½ ts-expect-errorï¼ä¸å¼å
¥ eslint disableï¼é¡¹ç®å½å |
| | | * @typescript-eslint v7 没æ no-deprecated è§åï¼å äºä¹æ æï¼åè让 lint æ¥"è§åä¸åå¨"ï¼ |
| | | */ |
| | | function nativeExec(command: 'insertLineBreak' | 'insertText', value?: string) { |
| | | document.execCommand(command, false, value) |
| | | } |
| | | |
| | | /** |
| | | * document selectionchange çå¬ï¼æè½å¨ editor å
ç selection ç¼åå° savedRangeï¼ |
| | | * insertText å¨ç¦ç¹è¢«å·èµ°åç¨å®æå
æ æ¢å¤å°åæå
¥ç¹ |
| | | */ |
| | | function onSelectionChange() { |
| | | const editor = editorRef.value |
| | | const sel = window.getSelection() |
| | | if (!editor || !sel || sel.rangeCount === 0) { |
| | | return |
| | | } |
| | | const range = sel.getRangeAt(0) |
| | | if (editor.contains(range.startContainer)) { |
| | | savedRange = range.cloneRange() |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * ç¹å» editor / picker å¤é¨æ¶å
³ææµ®å±ï¼é¿å
è¾å
¥ @keyword åç¨æ·ç¹å«å¤æµ®å±ä¸æ¶å¤± |
| | | * |
| | | * ç¨ mousedown èé clickï¼click å¨æäºæµè§å¨é picker å
ç´ æ¶å¤±ååä¸å°å targetï¼ä¼è¢«åæ |
| | | */ |
| | | function onDocMousedown(e: MouseEvent) { |
| | | if (!mentionVisible.value) { |
| | | return |
| | | } |
| | | const target = e.target as Node | null |
| | | if (!target) { |
| | | return |
| | | } |
| | | if (editorRef.value?.contains(target)) { |
| | | return |
| | | } |
| | | // picker æ¯ fixed å®ä½çå
å¼èç¹ï¼ä¸å¨ editor åæ éï¼ç¨ç±»åå®ä½ |
| | | const pickerEl = document.querySelector('.message-input__mention-picker') |
| | | if (pickerEl?.contains(target)) { |
| | | return |
| | | } |
| | | closeMention() |
| | | } |
| | | |
| | | onMounted(() => { |
| | | document.addEventListener('selectionchange', onSelectionChange) |
| | | document.addEventListener('mousedown', onDocMousedown) |
| | | restoreDraftToEditor() |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | document.removeEventListener('selectionchange', onSelectionChange) |
| | | document.removeEventListener('mousedown', onDocMousedown) |
| | | }) |
| | | |
| | | /** |
| | | * åä¼è¯æ¶è¿å对æ¹çèç¨¿å° editor |
| | | * |
| | | * 忥å
³ @ / 表æ
/ è¯é³æµ®å±å¹¶æ¸
savedRangeï¼ |
| | | * - mentionRange / savedRange æ§å¼ç¨è¿æåä¸ä¸ä¼è¯ç DOM èç¹ï¼ä¸æ¸
䏿¬¡æ token ä¼è½éä½ç½® |
| | | * - è¯é³å½å¶å¼¹çªä¿çæ¶ï¼å½å®è§¦åç onVoiceSend ä¼è¯»å½å activeConversationï¼æè¯é³åå°æ°ä¼è¯ |
| | | */ |
| | | watch( |
| | | () => |
| | | conversationStore.activeConversation |
| | | ? getConversationKey(conversationStore.activeConversation) |
| | | : null, |
| | | () => { |
| | | closeMention() |
| | | emojiVisible.value = false |
| | | voiceVisible.value = false |
| | | savedRange = null |
| | | restoreDraftToEditor() |
| | | } |
| | | ) |
| | | |
| | | /** |
| | | * æå符串æå
¥å
æ å¤ï¼emoji 颿¿çåºæ¯è°ç¨ï¼ |
| | | * |
| | | * 1. editor 没æç´æ¥è¿å |
| | | * 2. ç¦ç¹åå° editor + æ savedRange æ¢å¤æå½å selectionï¼emoji 颿¿å·ç¦ç¹åè¿è½ååä½ï¼ |
| | | * 3. nativeExec æææ¬ï¼ä¿çæµè§å¨åç undo æ |
| | | * 4. 忥 canSend / placeholder |
| | | */ |
| | | function insertText(str: string) { |
| | | const editor = editorRef.value |
| | | if (!editor) { |
| | | return |
| | | } |
| | | editor.focus() |
| | | if (savedRange) { |
| | | const sel = window.getSelection() |
| | | if (sel) { |
| | | sel.removeAllRanges() |
| | | sel.addRange(savedRange) |
| | | } |
| | | } |
| | | // 1. nativeExec æææ¬ï¼ä¿çæµè§å¨åç undo æ |
| | | nativeExec('insertText', str) |
| | | // 2. 忥 canSend / placeholder |
| | | syncEditorState() |
| | | } |
| | | |
| | | /** |
| | | * ç²è´´å¤ç |
| | | * |
| | | * 1. ä¼å
æ« clipboardData.items æ¾æä»¶ç±»åæ¡ç®ï¼æªå¾ãæå
¥çå¾ç / æä»¶çï¼ |
| | | * - image/* â èµ° IMAGE ä¸ä¼ åé |
| | | * - å
¶å® file â èµ° FILE ä¸ä¼ åé |
| | | * - 䏿¬¡ç²è´´åªå¤ç第ä¸ä¸ªæä»¶ï¼é¿å
䏿¬¡ç²è´´ååºå¤æ¡æ¶æ¯ |
| | | * 2. 没æä»¶åèµ° textï¼å¥æå¤é¨æ ·å¼ / èæ¬ï¼é¿å
å¤ç« inline style 污æ editor |
| | | * ï¼contenteditable é»è®¤ç²è´´ä¼å¸¦ HTMLï¼æä»¥æ¨¡æ¿ä¸ @paste.prevent æ¦æªï¼ |
| | | */ |
| | | function onPaste(e: ClipboardEvent) { |
| | | // 1. ä¼å
æ« clipboardData.items æ¾æä»¶ç±»åæ¡ç®ï¼æªå¾ãæå
¥çå¾ç / æä»¶çï¼ |
| | | const items = e.clipboardData?.items |
| | | if (items?.length) { |
| | | for (const item of items) { |
| | | if (!item) { |
| | | continue |
| | | } |
| | | if (item.kind !== 'file') { |
| | | continue |
| | | } |
| | | const file = item.getAsFile() |
| | | if (!file) { |
| | | continue |
| | | } |
| | | if (item.type.startsWith('image/')) { |
| | | void uploadAndSendImage(file) |
| | | } else { |
| | | void uploadAndSendFile(file) |
| | | } |
| | | return |
| | | } |
| | | } |
| | | // 2. 没æä»¶åèµ° textï¼å¥æå¤é¨æ ·å¼ / èæ¬ï¼é¿å
å¤ç« inline style 污æ editor |
| | | const text = e.clipboardData?.getData('text/plain') || '' |
| | | if (text) { |
| | | nativeExec('insertText', text) |
| | | // @paste.prevent é»æäºæµè§å¨é»è®¤ input äºä»¶ï¼éæå¨åæ¥è稿 / canSendï¼ä¸ insertText() è·¯å¾ä¸è´ |
| | | syncEditorState() |
| | | } |
| | | } |
| | | |
| | | /** ç¼è¾å¨å
容ååçç»ä¸å
¥å£ï¼å
忥 canSend / placeholderï¼åå¤ @ æµ®å±æ¯å¦è¦å±å¼ */ |
| | | function onInput() { |
| | | syncEditorState() |
| | | detectAtMention() |
| | | } |
| | | |
| | | // ==================== å¼ç¨ / åå¤ ==================== |
| | | |
| | | /** å½åä¼è¯çãæ£å¨åå¤ã对象ï¼ä»ä¼è¯è稿派ç */ |
| | | const replyTarget = computed<QuoteMessage | undefined>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return undefined |
| | | } |
| | | return conversationStore.getConversationDraft(conversation)?.reply |
| | | }) |
| | | |
| | | /** æ¸
æå½å reply ä½ä¿çæ£æè稿ï¼ç¹ à å
³é / åéå³å°è¿è¡æ¶è° */ |
| | | function clearConversationReplyDraft() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | conversationStore.clearConversationReplyDraft(conversation) |
| | | } |
| | | |
| | | /** åèµ°å½å reply å¿«ç
§ï¼æä¸æ¬¡æ¸
䏿¬¡ï¼ï¼åªä½ä¸ä¼ é¾è·¯å¨å¨æåç»ä¸è°å®æ¿ quote */ |
| | | function consumeReply(): QuoteMessage | undefined { |
| | | const quote = replyTarget.value |
| | | if (quote) { |
| | | clearConversationReplyDraft() |
| | | } |
| | | return quote |
| | | } |
| | | |
| | | // ==================== 表æ
==================== |
| | | const emojiVisible = ref(false) |
| | | /** åæ¢è¡¨æ
颿¿ï¼æå¼æ¶äºæ¥å
³æè¯é³é¢æ¿ */ |
| | | function toggleEmoji() { |
| | | emojiVisible.value = !emojiVisible.value |
| | | if (emojiVisible.value) { |
| | | voiceVisible.value = false |
| | | } |
| | | } |
| | | |
| | | /** éä¸è¡¨æ
è´´å¾ï¼æ¼ FaceMessage payload ç´æ¥èµ° sendRaw åéï¼quote å¤ç¨å½å reply å¿«ç
§ï¼ */ |
| | | async function onSelectFace(face: { height: number; name?: string; url: string; width: number; }) { |
| | | if (muteOverlay.value) { |
| | | return |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | const replyQuote = consumeReply() |
| | | const payload = withQuotePayload<FaceMessage>( |
| | | { url: face.url, width: face.width, height: face.height, name: face.name }, |
| | | replyQuote |
| | | ) |
| | | await sendRaw(ImContentType.FACE, serializeMessage(payload), { conversation }) |
| | | } |
| | | |
| | | // ==================== @ æåéæ©ï¼ç¾¤èï¼ ==================== |
| | | const isGroup = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.GROUP |
| | | ) |
| | | |
| | | /** ä» groupStore 读å½åæ¿æ´»ç¾¤çæåï¼åä¼è¯æ¶ç± MessagePanel é¢æï¼ */ |
| | | const groupMembers = computed<GroupMemberLite[]>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return [] |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return (group?.members || []).map((member) => { |
| | | const friend = friendStore.getFriend(member.userId) |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status |
| | | } |
| | | }) |
| | | }) |
| | | |
| | | /** å½åç¨æ·æ¯å¦è½ @ å
¨åï¼ç¾¤ä¸» + 管çåé½å
许ï¼å¯¹é½å¾®ä¿¡ PCï¼admin ä¹è½ @ ææäººï¼ */ |
| | | const canAtAll = computed<boolean>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return false |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | if (!group) { |
| | | return false |
| | | } |
| | | const myId = getCurrentUserId() |
| | | if (!myId) { |
| | | return false |
| | | } |
| | | if (group.ownerUserId === myId) { |
| | | return true |
| | | } |
| | | return group.members?.find((member) => member.userId === myId)?.role === ImGroupMemberRole.ADMIN |
| | | }) |
| | | |
| | | const mentionVisible = ref(false) |
| | | const mentionSearchText = ref('') |
| | | /** æµ®å±å®ä½ï¼x æ¯å·¦è¾¹è·ï¼top / bottom äºéä¸ââ bottom éå®ï¼picker 䏿²¿è´´ @ï¼æ¯é»è®¤ï¼ |
| | | * 䏿¹æ¾ä¸ä¸æ¶éå为 top éå®ï¼picker 䏿²¿è´´ @ 䏿¹ï¼ */ |
| | | const mentionPosition = ref<{ bottom?: number; top?: number; x: number; }>({ x: 0, bottom: 0 }) |
| | | |
| | | /** MentionPicker ç容å¨å®½åº¦ï¼ä¸ç»ä»¶éç w-50 对é½ï¼ï¼ç¨äºè§å£å³æ²¿åå¼¹ï¼ |
| | | * é«åº¦ä¸åç¨å¸¸éç®ä½ç½®ââbottom éå®å picker å
容å¤å¯¡é½ä¸å½±å䏿²¿ä½ç½®ï¼èªç¶è´´ @ */ |
| | | const MENTION_WIDTH = 200 |
| | | const MENTION_MIN_FIT_ABOVE = 120 // 䏿¹å©ä½ç©ºé´è³å°è¿ä¹å¤ææ¾ä¸æ¹ï¼å¦åç¿»å°ä¸æ¹ï¼é¿å
picker 被è§å£é¡¶ / é¡¶é¨ chat header åæï¼ |
| | | |
| | | /** å½å @ å
³é®è¯å¨ editor éçèå´ï¼onMentionSelect ç¨å®å®ä½å é¤ + æå
¥ token */ |
| | | let mentionRange: null | Range = null |
| | | |
| | | /** å
³éæµ®å± + æ¸
æ rangeï¼é¿å
䏿¬¡æ®çç range 被ä¸ä¸æ¬¡ onMentionSelect è¯¯ç¨ */ |
| | | function closeMention() { |
| | | mentionVisible.value = false |
| | | mentionRange = null |
| | | } |
| | | |
| | | /** å¨å
æ å½åææ¬èç¹éååæ¾ @keywordï¼å½ä¸åå±å¼æµ®å± */ |
| | | function detectAtMention() { |
| | | if (!isGroup.value) { |
| | | closeMention() |
| | | return |
| | | } |
| | | const sel = window.getSelection() |
| | | if (!sel || sel.rangeCount === 0) { |
| | | closeMention() |
| | | return |
| | | } |
| | | const range = sel.getRangeAt(0) |
| | | if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) { |
| | | closeMention() |
| | | return |
| | | } |
| | | const node = range.startContainer |
| | | const offset = range.startOffset |
| | | const before = (node.textContent || '').slice(0, offset) |
| | | // ç´æ¥æ¾æè¿ç @ï¼ä¸éå¶åç½®å符ï¼å¯¹é½å¾®ä¿¡"ä¸æç´§è´´ @ ä¹è½èæ³"ï¼ä½ 好@å¼ ä¸ãï¼@å¼ ä¸ é½è§¦åï¼ï¼ |
| | | // ä»£ä»·æ¯ email-like "test@example.com" ä¹ä¼è§¦åï¼ä½è天è¾å
¥éç² email çæ¦çä½ï¼ |
| | | // ä¸ç¨æ·æ Esc å³å¯å
³æµ®å±ï¼å
åºç± [^\s@] ä¿è¯ä¸æ¦è¾å
¥ç©ºæ ¼ / 第äºä¸ª @ å°±åä¸ |
| | | const match = before.match(/@([^\s@]*)$/) |
| | | if (!match) { |
| | | closeMention() |
| | | return |
| | | } |
| | | const keyword = match[1] ?? '' |
| | | const atOffset = offset - keyword.length - 1 |
| | | mentionRange = document.createRange() |
| | | mentionRange.setStart(node, atOffset) |
| | | mentionRange.setEnd(node, offset) |
| | | mentionSearchText.value = keyword |
| | | // éå®å¨ @ ç¬¦å·æ¬èº«ï¼èéå½å caretââå¦åç¨æ·æ¯å¤æ²ä¸ä¸ªåæµ®å±å°±è·çå³ç§»ï¼"é£"ï¼ |
| | | positionMention(node, atOffset) |
| | | mentionVisible.value = true |
| | | } |
| | | |
| | | /** |
| | | * æµ®å±ä½ç½®ï¼é»è®¤ bottom éå®ï¼picker 䏿²¿è´´ @ 䏿¹ 8pxï¼ï¼ä¸æ¹ä¸å¤æç¿»æ top éå® |
| | | * |
| | | * 1. è®¡ç® @ å符å±å¹åæ rect |
| | | * 2. 横åï¼picker å·¦è¾¹å¯¹é½ @ï¼è¶è¿è§å£å³æ²¿åå·¦æ¨ï¼è³å°ç 8px çç½ |
| | | * 3. 纵åï¼ä¸æ¹å©ä½ ⥠MENTION_MIN_FIT_ABOVE èµ° bottom éå®ï¼ä¸ä¾èµ picker å®é
é«åº¦ï¼ |
| | | * æ 论 1 é¡¹è¿æ¯ N 项 picker 䏿²¿é½è´´ @ï¼ï¼ä¸å¤åç¿»å° @ 䏿¹èµ° top éå® |
| | | */ |
| | | function positionMention(node: Node, atOffset: number) { |
| | | // 1. è®¡ç® @ å符å±å¹åæ rect |
| | | const anchor = document.createRange() |
| | | anchor.setStart(node, atOffset) |
| | | anchor.collapse(true) |
| | | const rect = anchor.getBoundingClientRect() |
| | | // 2. 横åï¼picker å·¦è¾¹å¯¹é½ @ï¼è¶è¿è§å£å³æ²¿åå·¦æ¨ï¼è³å°ç 8px çç½ |
| | | const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8)) |
| | | // 3. 纵åï¼ä¸æ¹å©ä½ ⥠MENTION_MIN_FIT_ABOVE èµ° bottom éå® |
| | | mentionPosition.value = rect.top >= MENTION_MIN_FIT_ABOVE ? { x: left, bottom: window.innerHeight - rect.top + 8 } : { x: left, top: rect.bottom + 8 }; |
| | | } |
| | | |
| | | /** editor å
鍿»å¨æ¶åæ¥æµ®å±ä½ç½®ï¼å¤è¡ + è§¦åæ»å¨æ¡åºæ¯ï¼ */ |
| | | function onEditorScroll() { |
| | | if (!mentionVisible.value || !mentionRange) { |
| | | return |
| | | } |
| | | positionMention(mentionRange.startContainer, mentionRange.startOffset) |
| | | } |
| | | |
| | | function onMentionSelect(member: GroupMemberLite) { |
| | | const editor = editorRef.value |
| | | if (!editor || !mentionRange) { |
| | | return |
| | | } |
| | | // å @keywordï¼æå
¥ contenteditable=false ç tokenï¼ |
| | | // å 餿¶æ´æ®µæ¶é¤ + ä¸ä¼è¢«å
æ æç©¿ï¼data-id æ¯åç» collectFromEditor æ¶ atUserIds çé©å |
| | | // token ææ¬åºå®èµ°ç宿µç§°ï¼ç¾¤éæææåçå°çåé¢éä¸è´ï¼é¿å
æä¾§ç好å夿³¨ / 群æµç§°æ±¡æåéææ¬ |
| | | mentionRange.deleteContents() |
| | | const span = document.createElement('span') |
| | | span.className = 'mention-token' |
| | | span.dataset.id = String(member.userId) |
| | | span.contentEditable = 'false' |
| | | span.textContent = `@${member.nickname || member.showName}` |
| | | mentionRange.insertNode(span) |
| | | // token å¨ editor é¦ä½æ¶ï¼contenteditable=false è¾¹ç¼ä¼è®©å
æ æ æ³æªå° token å |
| | | // è¡¥ä¸ä¸ªé¶å®½ç©ºæ ¼å½éç¹ï¼DOM walk æ¶ä¼è¢«æ»¤æï¼ä¸è¿å
¥åéå
容 |
| | | const prev = span.previousSibling |
| | | if (!prev || (prev.nodeType === Node.TEXT_NODE && !prev.textContent)) { |
| | | span.parentNode?.insertBefore(document.createTextNode('\u200B'), span) |
| | | } |
| | | // å¨ token åè¡¥ä¸ä¸ª NBSPï¼è®©å
æ å¯ä»¥ç»§ç»è¾å
¥ï¼NBSP æ¯æ®éç©ºæ ¼æ´ç¨³ï¼é¿å
被æµè§å¨æå |
| | | const space = document.createTextNode('\u00A0') |
| | | span.parentNode?.insertBefore(space, span.nextSibling) |
| | | // å
æ ç§»å° NBSP ä¹å |
| | | const sel = window.getSelection() |
| | | if (sel) { |
| | | const newRange = document.createRange() |
| | | newRange.setStartAfter(space) |
| | | newRange.collapse(true) |
| | | sel.removeAllRanges() |
| | | sel.addRange(newRange) |
| | | } |
| | | closeMention() |
| | | editor.focus() |
| | | syncEditorState() |
| | | } |
| | | |
| | | /** |
| | | * é®çäºä»¶åå |
| | | * |
| | | * 1. mention æµ®å±æå¼æ¶ |
| | | * 1.1 â/â ç§»å¨é«äº® |
| | | * 1.2 Enter æåé â éä¸ï¼æ åé fall through å°ä¸é¢çåé忝ï¼é¿å
æ Enter 没ååº |
| | | * 1.3 Esc å
³æµ®å± |
| | | * 2. Shift+Enter æ¢è¡ï¼å¼ºå¶èµ° brï¼æµè§å¨é»è®¤ä¼æ divï¼DOM walk æ¼æ¥æ´å¤æï¼ |
| | | * 3. æ®é Enter åéï¼IME composition æé´ä¸è§¦åï¼é¿å
éè¯è¢«è¯¯åï¼ |
| | | */ |
| | | function onKeydown(e: KeyboardEvent) { |
| | | // 1. mention æµ®å±æå¼æ¶ |
| | | if (mentionVisible.value) { |
| | | if (e.key === 'ArrowUp') { |
| | | e.preventDefault() |
| | | mentionRef.value?.moveUp() |
| | | return |
| | | } |
| | | if (e.key === 'ArrowDown') { |
| | | e.preventDefault() |
| | | mentionRef.value?.moveDown() |
| | | return |
| | | } |
| | | if (e.key === 'Enter' && !e.isComposing && mentionRef.value?.hasCandidates()) { |
| | | e.preventDefault() |
| | | mentionRef.value?.pickActive() |
| | | return |
| | | } |
| | | if (e.key === 'Escape') { |
| | | closeMention() |
| | | return |
| | | } |
| | | } |
| | | // 2. Shift+Enter æ¢è¡ï¼å¼ºå¶èµ° brï¼æµè§å¨é»è®¤ä¼æ divï¼DOM walk æ¼æ¥æ´å¤æï¼ |
| | | if (e.key === 'Enter' && e.shiftKey && !e.isComposing) { |
| | | e.preventDefault() |
| | | nativeExec('insertLineBreak') |
| | | syncEditorState() |
| | | return |
| | | } |
| | | // 3. æ®é Enter åéï¼IME composition æé´ä¸è§¦åï¼é¿å
éè¯è¢«è¯¯åï¼ |
| | | if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) { |
| | | e.preventDefault() |
| | | handleSend() |
| | | } |
| | | } |
| | | |
| | | // ==================== å¾ç / æä»¶ / è¯é³ ä¸ä¼ ==================== |
| | | |
| | | /** ä¸ä¼ åçç»ä¸æ¦æªï¼ç¦è¨æ / æ æ¿æ´»ä¼è¯ç´æ¥æ¾å¼ï¼è¿åå½å conversation ä¸æèµ°ç quote */ |
| | | function prepareMediaUpload(): undefined | { conversation: Conversation; quote?: QuoteMessage } { |
| | | if (muteOverlay.value) { |
| | | return undefined |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return undefined |
| | | } |
| | | return { conversation, quote: consumeReply() } |
| | | } |
| | | |
| | | /** ä¸ä¼ å¹¶åé IMAGE æ¶æ¯ï¼å ä½ + è¿åº¦ + çå® url ack ç± useMediaUploader å¤çï¼ */ |
| | | async function uploadAndSendImage(file: File) { |
| | | const context = prepareMediaUpload() |
| | | if (!context) { |
| | | return |
| | | } |
| | | await uploadAndSendMedia({ |
| | | file, |
| | | type: ImContentType.IMAGE, |
| | | quote: context.quote, |
| | | conversation: context.conversation |
| | | }) |
| | | } |
| | | |
| | | /** ä¸ä¼ å¹¶åé FILE æ¶æ¯ï¼payload ç± mediaTypeHandlers[FILE] èªå¨æ¼ url + name + size */ |
| | | async function uploadAndSendFile(file: File) { |
| | | // æä»¶ååæåä¸ä¸ª "." ä¹åçé¨åï¼æ åç¼ / 空åç¬¦ä¸²é½æ"æªç¥"æ¾è¡ |
| | | const extension = file.name.split('.').pop()?.toLowerCase() ?? '' |
| | | if (extension && DANGEROUS_FILE_EXTENSIONS.includes(extension)) { |
| | | message.warning(`ä¸å
许åé .${extension} ç±»åæä»¶`) |
| | | return |
| | | } |
| | | const context = prepareMediaUpload() |
| | | if (!context) { |
| | | return |
| | | } |
| | | await uploadAndSendMedia({ |
| | | file, |
| | | type: ImContentType.FILE, |
| | | quote: context.quote, |
| | | conversation: context.conversation |
| | | }) |
| | | } |
| | | |
| | | /** å¾çéå®å³ä¸ä¼ + åé IMAGE æ¶æ¯ï¼ä¸æ¾å
¥ editorï¼ç± useMediaUploader æ¥ç®¡å ä½ / è¿åº¦ / ackï¼ */ |
| | | async function onImagePicked(e: Event) { |
| | | const input = e.target as HTMLInputElement |
| | | const file = input.files?.[0] |
| | | input.value = '' |
| | | if (file) { |
| | | await uploadAndSendImage(file) |
| | | } |
| | | } |
| | | |
| | | /** æä»¶éå®å³ä¸ä¼ + åé FILE æ¶æ¯ï¼æºå¸¦åå§ name / size å
æ°æ®ï¼ */ |
| | | async function onFilePicked(e: Event) { |
| | | const input = e.target as HTMLInputElement |
| | | const file = input.files?.[0] |
| | | input.value = '' |
| | | if (file) { |
| | | await uploadAndSendFile(file) |
| | | } |
| | | } |
| | | |
| | | // ==================== è¯é³ ==================== |
| | | const voiceVisible = ref(false) |
| | | /** æå¼è¯é³å½å¶é¢æ¿ï¼äºæ¥å
³æè¡¨æ
颿¿ */ |
| | | function openVoice() { |
| | | voiceVisible.value = true |
| | | emojiVisible.value = false |
| | | } |
| | | /** åéå½é³æ¶æ¯ */ |
| | | async function onVoiceSend(payload: { |
| | | blob: Blob |
| | | duration: number |
| | | extension: string |
| | | mimeType: string |
| | | }) { |
| | | const context = prepareMediaUpload() |
| | | if (!context) { |
| | | return |
| | | } |
| | | const file = new File([payload.blob], `voice-${Date.now()}.${payload.extension}`, { |
| | | type: payload.mimeType || payload.blob.type |
| | | }) |
| | | await uploadAndSendMedia({ |
| | | file, |
| | | type: ImContentType.VOICE, |
| | | quote: context.quote, |
| | | conversation: context.conversation, |
| | | context: { voiceDuration: payload.duration } |
| | | }) |
| | | } |
| | | |
| | | // ==================== è§é¢ ==================== |
| | | type VideoProbe = { |
| | | cover?: Blob |
| | | duration?: number |
| | | height?: number |
| | | width?: number |
| | | } |
| | | |
| | | const VIDEO_COVER_MAX_DIM = 720 // å°é¢æé¿è¾¹ capï¼è天å表éçè§é¢å°é¢æ²¡å¿
è¦åè§é¢å辨çï¼4K å尺寸 jpeg 1-3MB 太浪费 |
| | | |
| | | /** |
| | | * å è½½è§é¢æ¬å°é¢è§ï¼ä¸æ¬¡æ§æ¿å° metadataï¼duration / 宽é«ï¼+ é¦å¸§å°é¢ blob |
| | | * |
| | | * ä¸ä¸ª video å
ç´ ä¸²ä¸¤ä»¶äºæ¯ä¸ºäºé¿å
éå¤ decodeï¼metadata è§£å®åç´æ¥ seek é¦å¸§åæªå¾ã |
| | | * æªå¾å¤±è´¥ä¸æå¼å¸¸ï¼åªè®© cover 缺失ï¼ä¿è¯ä¸»æµç¨ä»è½ä¸ä¼ è§é¢æ¬ä½ã |
| | | * |
| | | * finally 鿾弿å¼ç¨æ¯å 为ï¼ä»
revokeObjectURL ä¸è¶³ä»¥è®© video decoder ç«å³éæ¾ï¼ |
| | | * é¨åæµè§å¨çæ¬ä¸ 4K è§é¢è§£ç buffer 坿»çæ°å MB å ç§å°åå ç§ï¼è¿åå æ¡ä¼ç´¯è®¡æ¾å¤§ã |
| | | */ |
| | | async function probeVideoFile(file: File): Promise<VideoProbe> { |
| | | // 1. åå¤ç¦»å± video |
| | | // 1.1 muted + preload=metadataï¼åªä¸è½½æä»¶å¤´ï¼ä¸é¢å è½½æ´æ¡æµ |
| | | const objectUrl = URL.createObjectURL(file) |
| | | const video = document.createElement('video') |
| | | video.preload = 'metadata' |
| | | video.muted = true |
| | | video.src = objectUrl |
| | | try { |
| | | // 1.2 ç metadata å è½½ï¼è§£åº duration / 宽髿æ seek + æªå¾ç便® |
| | | await new Promise<void>((resolve, reject) => { |
| | | video.addEventListener('loadedmetadata', () => resolve(), { once: true }) |
| | | video.addEventListener('error', () => reject(new Error('video metadata load error')), { |
| | | once: true |
| | | }) |
| | | }) |
| | | // 1.3 æ½å
ä¿¡æ¯ï¼duration å¶æ NaNï¼æå°æ°æåæä»¶ï¼ï¼è½¯å¤ç为 undefined |
| | | const meta = { |
| | | duration: Number.isFinite(video.duration) ? Math.round(video.duration) : undefined, |
| | | width: video.videoWidth || undefined, |
| | | height: video.videoHeight || undefined |
| | | } |
| | | |
| | | // 2. æªé¦å¸§å°é¢ï¼ç¬ç« tryï¼å¤±è´¥ä»
é级 cover 为空ï¼ä¸å½±å metaï¼ |
| | | let cover: Blob | undefined |
| | | try { |
| | | // 2.1 ç® seek æ¶é´ï¼0.1s é¿å¼å¸¸è§ç纯é»é¦å¸§ï¼æ¶é¿ < 0.2s çæçè§é¢éå为 0 |
| | | const seekTo = video.duration > 0.2 ? 0.1 : 0 |
| | | // 2.2 seek + 3s è¶
æ¶ï¼currentTime 设为å½åå¼ï¼è¬å¦å·²ç»æ¯ 0 çæçè§é¢ï¼ |
| | | // é¨åæµè§å¨ä¸è§¦å onseekedï¼promise ä¼ä¸ç´ pending 塿»æ´æ¡é¾è·¯ |
| | | await new Promise<void>((resolve, reject) => { |
| | | const timer = setTimeout(() => reject(new Error('video seek timeout')), 3000) |
| | | video.addEventListener('seeked', () => { |
| | | clearTimeout(timer) |
| | | resolve() |
| | | }, { once: true }) |
| | | video.addEventListener( |
| | | 'error', |
| | | () => { |
| | | clearTimeout(timer) |
| | | reject(new Error('video seek error')) |
| | | }, |
| | | { once: true } |
| | | ) |
| | | video.currentTime = seekTo |
| | | }) |
| | | // 2.3 ç¦»å± canvas çæ¯ç¼©æ¾ï¼é¿è¾¹ cap 720ï¼VIDEO_COVER_MAX_DIMï¼ |
| | | const canvas = document.createElement('canvas') |
| | | const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight)) |
| | | canvas.width = Math.round(video.videoWidth * ratio) |
| | | canvas.height = Math.round(video.videoHeight * ratio) |
| | | const context = canvas.getContext('2d') |
| | | if (context && canvas.width && canvas.height) { |
| | | // 2.4 å½å帧ç»å° canvas â toBlob æ¿ jpegï¼0.8 è´¨éæ¯è天å°é¢å¸¸ç¨çç¹ |
| | | context.drawImage(video, 0, 0, canvas.width, canvas.height) |
| | | cover = |
| | | (await new Promise<Blob | null>((resolve) => |
| | | canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8) |
| | | )) ?? undefined |
| | | // 2.5 æåéæ¾ canvas backing storeï¼4K å尺寸 33MBï¼ï¼å«ç GC |
| | | canvas.width = 0 |
| | | canvas.height = 0 |
| | | } |
| | | } catch (error) { |
| | | console.warn('[IM] è§é¢å°é¢æªå失败', error) |
| | | } |
| | | return { ...meta, cover } |
| | | } finally { |
| | | // 3. æ¾å¼éæ¾ video èµæº |
| | | // 3.1 revoke æ¬å° objectUrl |
| | | URL.revokeObjectURL(objectUrl) |
| | | // 3.2 触å unloadï¼è®© decoder buffer ç«å³éæ¾ |
| | | video.removeAttribute('src') |
| | | video.load() |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * ä¸ä¼ å¹¶åé VIDEO æ¶æ¯ |
| | | * |
| | | * 1. ç«å³å ä½ï¼blob URL æ¢å½ video src ä¹å½ posterï¼æµè§å¨ä¼æ¿é¦å¸§ç»å¶ coverï¼status=SENDING + è¿åº¦æ¡ |
| | | * 2. probe ä¸è§é¢ä¸ä¼ åæ¥èµ·è·ï¼å°é¢ä¸ä¼ ç probe åº cover åä¸è§é¢ä¸ä¼ ç«é |
| | | * ï¼probe è§£ç + å°é¢ä¸ä¼ é常被è§é¢ä¸ä¼ æ¶é¿å®å
¨é®è½ï¼ä½æèçå ç¾ ms èµ·æ¥ï¼ |
| | | * 3. è§é¢æ¬ä½ä¸ä¼ å¿
é¡»æåï¼æ¿ä¸å° url å°±æå ä½ç½® FAILEDï¼å°é¢æ¯é¦ä¸æ·»è±ï¼å¤±è´¥ä»
æ¥å¿ |
| | | * 4. è§é¢é¾è·¯èæ¶é¿ï¼ä¸ä¼ æé´ç¨æ·åä¼è¯åæ¾å¼åéï¼é¿å
è½å°é误ä¼è¯éï¼ï¼åèµ°åå忥ä¸ç®ååï¼key ä»ç¸çï¼ |
| | | */ |
| | | async function uploadAndSendVideo(file: File) { |
| | | if (!ensureMediaSizeWithinLimit(file, ImContentType.VIDEO, message.warning)) { |
| | | return |
| | | } |
| | | const context = prepareMediaUpload() |
| | | if (!context) { |
| | | return |
| | | } |
| | | const { conversation } = context |
| | | const replyQuote = context.quote |
| | | const startKey = getConversationKey(conversation) |
| | | |
| | | // 1. ç«å³å ä½ï¼url èµ° blob 让 <video src> æé¦åèæ¸²æï¼coverUrl ä¸è®¾ blob |
| | | // ï¼<video poster> æå¾
å¾çèµæºï¼ä¼ video blob å¨é¨åæµè§å¨ä¼éåæé»åºï¼ä¸æ¯ç¨³å®è¡ä¸ºï¼ |
| | | // cover ç probe 弿¥åºçå® URL åç± commit é¶æ®µä¸èµ· patchï¼_localFile ç file ä¾å¤±è´¥éè¯ |
| | | // payload æ¼è£
èµ° mediaTypeHandlers[VIDEO].build ä¸ commit é¶æ®µå
±äº«åä¸ä»½é»è¾ |
| | | const videoHandler = requireMediaHandler(ImContentType.VIDEO) |
| | | const buildPlaceholderContent = (blobUrl: string): string => |
| | | serializeMessage(withQuotePayload(videoHandler.build(file, blobUrl, {}), replyQuote)) |
| | | const { clientMessageId } = insertMediaPlaceholder({ |
| | | file, |
| | | type: ImContentType.VIDEO, |
| | | conversation, |
| | | buildContent: buildPlaceholderContent |
| | | }) |
| | | |
| | | // 2. ä¸è·¯å¹¶è¡èµ·è·ï¼probe ä¸ä¸¤æ¡ä¸ä¼ æ ä¾èµï¼å°é¢ä¸ä¼ ç probe åº cover åç«å³æ¥åï¼ |
| | | // 2.1 è§é¢æ¬ä½ä¸ä¼ ï¼async IIFE å
ä¸å±è®© await æ¾å¼å¯è§ï¼lint ä¸åè¯¯å¤ floating promiseï¼ï¼ |
| | | // 失败å
åºä¸º url=undefinedï¼ç± step 3 æ¿ä¸å° url æ¶æ¶å°¾ |
| | | const videoUploadPromise: Promise<string | undefined> = (async () => { |
| | | try { |
| | | return await uploadFile( |
| | | { file }, |
| | | createUploadProgressHandler(conversation, clientMessageId) |
| | | ) |
| | | } catch (error) { |
| | | console.warn('[IM] è§é¢æ¬ä½ä¸ä¼ 失败', error) |
| | | return undefined |
| | | } |
| | | })() |
| | | // 2.2 probe æ¿å
ä¿¡æ¯ + å°é¢ blobï¼è§£ç 失败é级为空 probeï¼ä¸é»æè§é¢ä¸ä¼ |
| | | const probePromise = probeVideoFile(file).catch((error): VideoProbe => { |
| | | console.warn('[IM] è§é¢å
ä¿¡æ¯å 载失败ï¼é级为ä»
url + size', error) |
| | | return {} |
| | | }) |
| | | // 2.3 å°é¢ä¸ä¼ ï¼ç probe.cover åºæ¥åæ¥åèµ·è·ï¼ä¸è§é¢ä¸ä¼ ç«éï¼å¤±è´¥é级 coverUrl 为空 |
| | | const coverUploadPromise = probePromise.then(async (probe) => { |
| | | if (!probe.cover) { |
| | | return { probe, coverUrl: undefined as string | undefined } |
| | | } |
| | | try { |
| | | const coverUrl = await uploadFile({ |
| | | file: new File([probe.cover], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' }) |
| | | }) |
| | | return { probe, coverUrl } |
| | | } catch (error) { |
| | | console.warn('[IM] è§é¢å°é¢ä¸ä¼ 失败', error) |
| | | return { probe, coverUrl: undefined as string | undefined } |
| | | } |
| | | }) |
| | | |
| | | // 3. æ¶å£æ ¡éª |
| | | // 3.1 ç两æ¡ä¸ä¼ é¾è·¯æ±å |
| | | const [videoRes, { probe, coverUrl }] = await Promise.all([ |
| | | videoUploadPromise, |
| | | coverUploadPromise |
| | | ]) |
| | | // 3.2 è§é¢æ¬ä½æ²¡ urlï¼å ä½ç½® FAILEDï¼è®©ç¨æ·å³å®éè¯ / å é¤ï¼_localFile å¨å
åéï¼ |
| | | const url = videoRes |
| | | if (!url) { |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return |
| | | } |
| | | if (!isOpenableUrl(url)) { |
| | | console.warn('[IM] è§é¢ä¸ä¼ è¿åäºä¸æ¯ææå¼ç URL', { url }) |
| | | message.warning('ä¸ä¼ è¿åçè§é¢å°å䏿¯ææå¼') |
| | | markMediaFailed(conversation.type, conversation.targetId, clientMessageId) |
| | | return |
| | | } |
| | | const safeCoverUrl = coverUrl && isOpenableUrl(coverUrl) ? coverUrl : undefined |
| | | if (coverUrl && !safeCoverUrl) { |
| | | console.warn('[IM] è§é¢å°é¢ä¸ä¼ è¿åäºä¸æ¯ææå¼ç URL', { coverUrl }) |
| | | } |
| | | // 3.3 ä¸ä¼ åä¼è¯æ ¡éª + muteOverlay 夿¥ï¼ä¸ useMediaUploader.uploadAndSendMedia åä¸éï¼ |
| | | if ( |
| | | !verifyMediaUploadStillAllowed(conversation, startKey, ImContentType.VIDEO, clientMessageId) |
| | | ) { |
| | | return |
| | | } |
| | | |
| | | // 4. æ¼çå® VideoMessage payloadï¼patch è¿å ä½ + èµ° sendRaw å¤ç¨å ä½åé |
| | | const realContent = serializeMessage( |
| | | withQuotePayload( |
| | | videoHandler.build(file, url, { |
| | | videoProbe: { duration: probe.duration, width: probe.width, height: probe.height }, |
| | | videoCoverUrl: safeCoverUrl |
| | | }), |
| | | replyQuote |
| | | ) |
| | | ) |
| | | await commitMediaPlaceholder({ |
| | | type: ImContentType.VIDEO, |
| | | conversation, |
| | | clientMessageId, |
| | | realContent |
| | | }) |
| | | } |
| | | |
| | | /** è§é¢éå®å³ä¸ä¼ + åé VIDEO æ¶æ¯ï¼ä¸æ¾å
¥ editorï¼ç¬ç«é¾è·¯ï¼probe + åä¸ä¼ ï¼æç»èµ° commitMediaPlaceholder æ¶å°¾ï¼ */ |
| | | async function onVideoPicked(e: Event) { |
| | | const input = e.target as HTMLInputElement |
| | | const file = input.files?.[0] |
| | | input.value = '' |
| | | if (file) { |
| | | await uploadAndSendVideo(file) |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | å¤å±åºè²ä¸æ¶æ¯æµï¼bg-color-pageï¼ä¿æä¸è´ï¼è®©"æ¶æ¯ â è¾å
¥"æ è²å·®è¿æ¸¡ï¼ |
| | | padding ç»å
å±ç½å¡çå¼å¸ç©ºé´ï¼å¡çèªå¸¦è¾¹æ¡å°±å¤åºåè¾å
¥åºï¼ä¸åéè¦ä¸æ¡ border-t |
| | | --> |
| | | <div class="relative bg-[var(--ant-color-bg-layout)] px-3 pt-2 pb-3"> |
| | | <!-- ç¦è¨ / å°ç¦è¦çå±ï¼ä¼å
级 å°ç¦ > å
¨ç¾¤ç¦è¨ > æåç¦è¨ --> |
| | | <div |
| | | v-if="muteOverlay" |
| | | class="message-input__mute-overlay absolute top-2 right-3 bottom-3 left-3 z-20 flex items-center justify-center gap-2 rounded-lg border border-solid text-sm" |
| | | :class=" |
| | | muteOverlay.icon === 'ant-design:stop-outlined' |
| | | ? 'message-input__mute-overlay--error' |
| | | : 'message-input__mute-overlay--warning' |
| | | " |
| | | > |
| | | <Icon :icon="muteOverlay.icon" :size="18" /> |
| | | <span>{{ muteOverlay.text }}</span> |
| | | </div> |
| | | <!-- å
å±ç½è²åè§å¡ç = editor + å·¥å
·æ ï¼border + rounded 模æå¾®ä¿¡ãè¾å
¥æ¡ãè¾¹ç --> |
| | | <div |
| | | class="relative flex flex-col bg-[var(--ant-color-bg-container)] rounded-lg border border-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <!-- |
| | | è¾å
¥åºå¨ä¸ï¼contenteditable divï¼å代 textareaï¼å¯¹é½å¾®ä¿¡ PCï¼è¾å
¥åºå¨ä¸ï¼æä½å¨ä¸ï¼ |
| | | - 让 @ æµ®å±è½æ¿å°çå®å
æ rectï¼textarea æ¿ä¸å°ï¼ |
| | | - 让 @ æå以 <span data-id> token èç¹åå¨ï¼å token å³å idï¼é¿å
stale atUserIds |
| | | - placeholder éè¿ data-empty + ::before 模æï¼contenteditable 没æåç placeholderï¼ |
| | | --> |
| | | <div |
| | | ref="editorRef" |
| | | class="message-input__editor relative min-h-[120px] max-h-[200px] overflow-y-auto py-3.5 px-4 text-sm leading-normal outline-none whitespace-pre-wrap break-words" |
| | | contenteditable="true" |
| | | data-placeholder="æ Enter åéï¼Shift+Enter æ¢è¡" |
| | | data-empty="" |
| | | role="textbox" |
| | | @keydown="onKeydown" |
| | | @input="onInput" |
| | | @scroll.passive="onEditorScroll" |
| | | @paste.prevent="onPaste" |
| | | ></div> |
| | | |
| | | <!-- å¼ç¨é¢è§æ¡ --> |
| | | <ReplyPreview |
| | | v-if="replyTarget" |
| | | :quote="replyTarget" |
| | | closable |
| | | class="mx-3 mb-1.5" |
| | | @close="clearConversationReplyDraft" |
| | | /> |
| | | |
| | | <!-- |
| | | åºé¨å·¥å
·æ ï¼å·¦ä¾§æä½å¾æ + å³ä¾§åéæé®ï¼å¯¹é½å¾®ä¿¡ PCï¼æä½å¾æ ç»ä¸æ¾åºé¨ï¼ |
| | | - relative ç» FacePicker æä¾ absolute éç¹ï¼picker ç¨ bottom-full åä¸å¼¹åº |
| | | - 徿 ç»ä¸ 30Ã30 ç¹å»åºï¼18px icon + p-1.5ï¼ï¼gap-1 让é´è·è´´åå¾®ä¿¡è§æ |
| | | - border-t å¨ç¼è¾åºä¸å·¥å
·æ ä¹é´ç»ä¸æ¡ä¸ card è¾¹æ¡åè²çç»çº¿ |
| | | --> |
| | | <div |
| | | class="relative flex items-center justify-between gap-2 px-3 py-2 border-t border-t-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <div class="flex items-center gap-1"> |
| | | <!-- |
| | | ææ icon ç»ä¸èµ° Iconifyï¼ant-design outlined ç³»åï¼ï¼ |
| | | - è§è§é£æ ¼æ´æ¥è¿å¾®ä¿¡ PCï¼çº¿æ§ãåè§ï¼æ¯ Element Plus å
ç½®çæ´è½»éï¼ |
| | | - ç¬è¸ / å¾ç / æä»¶å¤¹ / 麦å
é£ åæºï¼é¿å
ä¸ä¸ªèµ° ep ä¸ä¸ªèµ° antd è§è§å²è£ |
| | | - å¤å± span å¤ç¨ .message-input__tool ç padding / hover æ ·å¼ï¼scoped CSS ç :deep(svg) ä»è½å½ä¸ |
| | | --> |
| | | <Tooltip title="表æ
" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click.stop="toggleEmoji" |
| | | > |
| | | <Icon icon="ant-design:smile-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | <Tooltip title="åéå¾ç" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click="imageInputRef?.click()" |
| | | > |
| | | <Icon icon="ant-design:picture-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | <Tooltip title="åéæä»¶" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click="fileInputRef?.click()" |
| | | > |
| | | <Icon icon="ant-design:folder-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | <Tooltip title="è¯é³æ¶æ¯" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click.stop="openVoice" |
| | | > |
| | | <Icon icon="ant-design:audio-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | <Tooltip title="åéè§é¢" placement="top"> |
| | | <span |
| | | class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | @click="videoInputRef?.click()" |
| | | > |
| | | <Icon icon="ant-design:video-camera-outlined" :size="18" /> |
| | | </span> |
| | | </Tooltip> |
| | | </div> |
| | | |
| | | <!-- 群è + 群已读å¼å¯ï¼åéæé® + â¼ ä¸æèåï¼ç¹ä¸»æé®æ®éåé / ç¹ â¼ éãåéåæ§æ¶æ¯ãï¼ï¼å¯¹é½å¾®ä¿¡ PC --> |
| | | <!-- 注æï¼ant-design-vue çåè£æé®æ¯ DropdownButtonï¼é Dropdown ç split-button 屿§ï¼é£æ¯ Element Plus åæ³ï¼ --> |
| | | <DropdownButton |
| | | v-if="isGroup && MESSAGE_GROUP_READ_ENABLED" |
| | | type="primary" |
| | | :disabled="!canSend" |
| | | @click="handleSend()" |
| | | > |
| | | å é |
| | | <template #overlay> |
| | | <Menu @click="handleSendMenuClick"> |
| | | <Menu.Item key="receipt">åéåæ§æ¶æ¯</Menu.Item> |
| | | </Menu> |
| | | </template> |
| | | </DropdownButton> |
| | | <!-- ç§èæç¾¤å·²è¯»å
³éï¼æ®éåéæé®ï¼æ ç¾¤åæ§å
¥å£ï¼ --> |
| | | <Button v-else type="primary" :disabled="!canSend" @click="handleSend()"> |
| | | å é |
| | | </Button> |
| | | |
| | | <!-- 表æ
颿¿ï¼bottom-full 让 picker 䏿²¿è´´å·¥å
·æ é¡¶é¨ï¼åä¸å¼¹åºï¼å¯¹é½å·¥å
·æ 左侧é¦å¾æ ï¼ --> |
| | | <FacePicker |
| | | v-model:visible="emojiVisible" |
| | | class="bottom-full left-3 mb-2" |
| | | @select-emoji="insertText" |
| | | @select-face="onSelectFace" |
| | | /> |
| | | |
| | | <!-- è¯é³å½å¶é¢æ¿ï¼ä¸è¡¨æ
颿¿åå¤å·¥å
·æ ï¼bottom-full åä¸å¼¹åºï¼é¿å
离触åç麦å
é£å¾æ è¿è¿ --> |
| | | <VoiceRecorder v-model="voiceVisible" class="bottom-full left-3 mb-2" @send="onVoiceSend" /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- @ éæ©æµ®å±ï¼ç¾¤èæå¯ç¨ --> |
| | | <MentionPicker |
| | | ref="mentionRef" |
| | | v-model:visible="mentionVisible" |
| | | :position="mentionPosition" |
| | | :members="groupMembers" |
| | | :search-text="mentionSearchText" |
| | | :can-at-all="canAtAll" |
| | | @select="onMentionSelect" |
| | | /> |
| | | |
| | | <!-- éèçæä»¶éæ©å¨ --> |
| | | <input ref="imageInputRef" type="file" accept="image/*" hidden @change="onImagePicked" /> |
| | | <input ref="fileInputRef" type="file" hidden @change="onFilePicked" /> |
| | | <input ref="videoInputRef" type="file" accept="video/*" hidden @change="onVideoPicked" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* el-icon å
¨å±è§å .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em} ä¼å
级æ´é«ï¼ |
| | | ç¨åé¢éæ©å¨ + !important éæ»ï¼é¢è²å Element Plus 主é¢åéï¼æè²èªå¨åå°æµ
ç° */ |
| | | .message-input__tool, |
| | | .message-input__tool:deep(svg) { |
| | | font-size: 18px !important; |
| | | color: var(--ant-color-text) !important; |
| | | fill: currentColor !important; |
| | | } |
| | | .message-input__tool:hover, |
| | | .message-input__tool:hover:deep(svg) { |
| | | color: var(--ant-color-primary) !important; |
| | | } |
| | | |
| | | .message-input__mute-overlay--warning { |
| | | color: #d48806; |
| | | background: #fff7e6; |
| | | border-color: #ffd591; |
| | | } |
| | | |
| | | .message-input__mute-overlay--error { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | border-color: #ffa39e; |
| | | } |
| | | |
| | | /* 注æï¼å¿
é¡»æ .dark ä¸ç®æ ç±»æ´ä½æ¾è¿ :global()ï¼å¦å Vue scoped ç¼è¯ä¼æ |
| | | `:global(.dark) .xxx` é误å¡ç¼©æè£¸ `.dark`ï¼å¯¼è´è¿äºé¢è²è¢«ç´æ¥å·å° <html> ä¸ |
| | | ï¼æè²ä¸æ´é¡µå红/åæé»ï¼ãBEM ç±»åå¯ä¸ï¼æ´ä½ global ä¸ä¼è¯¯ä¼¤å
¶å®å
ç´ ã */ |
| | | :global(.dark .message-input__mute-overlay--warning) { |
| | | color: #ffd666; |
| | | background: rgb(77 56 21 / 88%); |
| | | border-color: #ad6800; |
| | | } |
| | | |
| | | :global(.dark .message-input__mute-overlay--error) { |
| | | color: #ff7875; |
| | | background: rgb(91 33 33 / 88%); |
| | | border-color: #a8071a; |
| | | } |
| | | |
| | | /* ç¨ data-empty èé :emptyï¼æµè§å¨å¨å 空åä¼çä¸ <br>ï¼:empty ä¸å½ä¸ï¼data-empty ç± syncEditorState ç»´æ¤ */ |
| | | .message-input__editor[data-empty]::before { |
| | | content: attr(data-placeholder); |
| | | color: var(--ant-color-text-placeholder); |
| | | pointer-events: none; |
| | | position: absolute; |
| | | } |
| | | |
| | | /* @ token 走主è²é«äº®ï¼contenteditable=false 让 backspace æ´æ®µå è䏿¯éå符 */ |
| | | .message-input__editor :deep(.mention-token) { |
| | | color: var(--ant-color-primary); |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | export { default as MessageForwardDialog } from './message-forward-dialog.vue'; |
| | | export { default as MessageMergeDetailDialog } from './message-merge-detail-dialog.vue'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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') |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation, FriendLite, Message } from '#/views/im/home/types' |
| | | |
| | | import { computed, reactive, ref } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, Input, message, Modal } from 'ant-design-vue' |
| | | |
| | | import { createGroup } from '#/api/im/group' |
| | | import { ConversationPickerPanel } from '#/views/im/home/components/picker' |
| | | import { FriendPickerPanel } from '#/views/im/home/components/picker' |
| | | import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect' |
| | | import { useMessageSender } from '#/views/im/home/composables/useMessageSender' |
| | | import { useConversationStore } from '#/views/im/home/store/conversationStore' |
| | | import { useFriendStore } from '#/views/im/home/store/friendStore' |
| | | import { useGroupStore } from '#/views/im/home/store/groupStore' |
| | | import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config' |
| | | import { |
| | | ImContentType, |
| | | ImConversationType, |
| | | ImForwardMode, |
| | | type ImForwardModeValue |
| | | } from '#/views/im/utils/constants' |
| | | import { getConversationKey, summarizeMessageContent } from '#/views/im/utils/conversation' |
| | | import { buildDefaultGroupName } from '#/views/im/utils/group' |
| | | import { |
| | | buildMergeMessagePayload, |
| | | removeQuotePayload, |
| | | serializeMessage |
| | | } from '#/views/im/utils/message' |
| | | import { getGroupDisplayName, isGroupQuit } from '#/views/im/utils/user' |
| | | |
| | | import { FacePicker } from '../../input' |
| | | |
| | | defineOptions({ name: 'ImMessageForwardDialog' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | const { sendRaw, send } = useMessageSender() |
| | | const multiSelect = useMessageMultiSelect() |
| | | |
| | | const state = reactive({ |
| | | mode: ImForwardMode.SINGLE as ImForwardModeValue, |
| | | messages: [] as Message[], |
| | | sourceConversation: null as Conversation | null |
| | | }) |
| | | const visible = ref(false) |
| | | const view = ref<'contact' | 'conversation'>('conversation') // å½åè§å¾ï¼é»è®¤ä¼è¯éæ©ï¼ãå建è天ãå
¥å£åå°å¥½åéæ© |
| | | const selectedKeys = ref<string[]>([]) |
| | | const selectedFriendIds = ref<number[]>([]) |
| | | const leaveMessage = ref('') |
| | | const sending = ref(false) |
| | | const emojiVisible = ref(false) // emoji picker æ¾éï¼å³ä¾§ç¬è¸æé®åæ¢ |
| | | |
| | | defineExpose({ |
| | | /** æå¼è½¬åå¼¹çªï¼reset â çå â visible=true */ |
| | | open(opts: { messages: Message[]; mode: ImForwardModeValue; sourceConversation: Conversation }) { |
| | | state.mode = opts.mode |
| | | state.messages = opts.messages |
| | | state.sourceConversation = opts.sourceConversation |
| | | view.value = 'conversation' |
| | | selectedKeys.value = [] |
| | | selectedFriendIds.value = [] |
| | | leaveMessage.value = '' |
| | | emojiVisible.value = false |
| | | sending.value = false |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** å¼¹çªæ é¢ï¼ä¼è¯è§å¾æ mode åºåãéæ¡ / å并转åãï¼å¥½åè§å¾åºå®ä¸ºã鿩好åã */ |
| | | const headerTitle = computed(() => { |
| | | if (view.value === 'contact') { |
| | | return '鿩好å' |
| | | } |
| | | return state.mode === ImForwardMode.MERGE ? 'å并转å' : 'éæ¡è½¬å' |
| | | }) |
| | | |
| | | /** 确认æé®ææ¡ï¼åéãåéããå¤éãåå«åé(n)ã */ |
| | | const confirmButtonText = computed(() => |
| | | selectedKeys.value.length > 1 ? `åå«åéï¼${selectedKeys.value.length}ï¼` : 'åé' |
| | | ) |
| | | |
| | | /** åéä¼è¯ï¼ä» store æ¿æåºåçå表ï¼è½¬åååä¼è¯ä¹å
许ï¼ä¸å¾®ä¿¡ä¸è´ï¼ï¼å
¬ä¼å· / é¢éååæ¶æ¯ä¸æ¥å转åï¼ä»åééåé¤ */ |
| | | const candidateConversations = computed<Conversation[]>(() => |
| | | conversationStore.getSortedConversationList.filter( |
| | | (conversation) => |
| | | conversation.type !== ImConversationType.CHANNEL && |
| | | // åå²é群群ä¸å¯è¢«è½¬åéä¸ï¼éäºå端ä¹ä¼æï¼ |
| | | !( |
| | | conversation.type === ImConversationType.GROUP && |
| | | isGroupQuit(groupStore.getGroup(conversation.targetId)) |
| | | ) |
| | | ) |
| | | ) |
| | | |
| | | /** 好åè§å¾åéå表ï¼ç´æ¥å¤ç¨ friendStore Lite è§å¾ */ |
| | | const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList) |
| | | |
| | | /** åå°å¥½åè§å¾ï¼æ¸
æä¹åå¨ä¼è¯è§å¾è¾å
¥ççè¨ï¼é¿å
å¨ä¸å¯è§è¾å
¥æ¡éæçè¨éé»åå°æ°ç¾¤ */ |
| | | function handleSwitchToContact() { |
| | | view.value = 'contact' |
| | | leaveMessage.value = '' |
| | | emojiVisible.value = false |
| | | } |
| | | |
| | | /** éä¸ emojiï¼æ¼å°çè¨æ«å°¾ï¼FacePicker èªèº«è´è´£å
³é颿¿ */ |
| | | function handleEmojiSelect(emoji: string) { |
| | | leaveMessage.value = `${leaveMessage.value}${emoji}` |
| | | } |
| | | |
| | | /** åå¹¶ payload + åºåå contentï¼merge 模å¼ä¸ä¸æ¬¡æé ï¼é¢è§ / åéå
±ç¨ */ |
| | | const mergeBundle = computed(() => { |
| | | if ( |
| | | state.mode !== ImForwardMode.MERGE || |
| | | !state.sourceConversation || |
| | | state.messages.length === 0 |
| | | ) { |
| | | return null |
| | | } |
| | | const payload = buildMergeMessagePayload(state.messages, state.sourceConversation) |
| | | return { payload, content: serializeMessage(payload) } |
| | | }) |
| | | |
| | | /** å并模å¼é¢è§ï¼ä» messages å N æ¡æ´¾çã{æµç§°}ï¼{æè¦}ã */ |
| | | const mergePreview = computed(() => { |
| | | const payload = mergeBundle.value?.payload |
| | | if (!payload) { |
| | | return null |
| | | } |
| | | const lines = payload.messages |
| | | .slice(0, MESSAGE_MERGE_PREVIEW_LINES) |
| | | .map((item) => `${item.senderNickname}ï¼${summarizeMessageContent(item)}`) |
| | | return { title: payload.title, lines } |
| | | }) |
| | | |
| | | /** éæ¡æ¨¡å¼é¢è§ï¼åå N æ¡æè¦ */ |
| | | const singlePreviewLines = computed(() => |
| | | state.messages.slice(0, MESSAGE_MERGE_PREVIEW_LINES).map((m) => summarizeMessageContent(m)) |
| | | ) |
| | | |
| | | /** å¾
åéçéæ¡æ¶æ¯ï¼å¥ç¦» quote 䏿¬¡ï¼åéå¤ç®æ æ¶å¤ç¨ */ |
| | | const cleanedSinglePayloads = computed(() => |
| | | state.messages.map((m) => ({ type: m.type, content: removeQuotePayload(m.content) })) |
| | | ) |
| | | |
| | | /** |
| | | * ç»åä¸ªç®æ åéè½¬åæ¶æ¯ï¼merge 䏿¬¡ sendRawãsingle ææ¶é´åºéæ¡ sendRaw |
| | | * |
| | | * ä»»ä¸åæ¶æ¯å¤±è´¥å³åæ¢åç»ï¼è¿å false 让ä¸å±è·³è¿çè¨ |
| | | */ |
| | | async function forwardToTarget(target: Conversation): Promise<boolean> { |
| | | if (state.mode === ImForwardMode.MERGE) { |
| | | const content = mergeBundle.value?.content |
| | | if (!content) { |
| | | return false |
| | | } |
| | | return sendRaw(ImContentType.MERGE, content, { conversation: target }) |
| | | } |
| | | for (const payload of cleanedSinglePayloads.value) { |
| | | const ok = await sendRaw(payload.type, payload.content, { conversation: target }) |
| | | if (!ok) { |
| | | return false |
| | | } |
| | | } |
| | | return true |
| | | } |
| | | |
| | | /** |
| | | * 确认åéï¼åæ¡æ¨¡å¼éæ¡å¾ªç¯ï¼å并模å¼å䏿¡ MergeMessage |
| | | * |
| | | * ææ¡èåï¼å
¨é¨æåã已转åããå
¨é¨å¤±è´¥ã转å失败ï¼AãBããé¨å失败ã已转åï¼ä½ XãY 失败ãï¼ |
| | | * çè¨åªå¨è¯¥ç®æ çè½¬åæ¶æ¯å
¨é¨æååæè¿½å ï¼é¿å
éåº |
| | | */ |
| | | async function handleSend() { |
| | | if (selectedKeys.value.length === 0) { |
| | | return |
| | | } |
| | | if (state.messages.length === 0) { |
| | | message.warning('没æå¯è½¬åçæ¶æ¯') |
| | | return |
| | | } |
| | | // 忥已é conversation å¯¹è±¡ï¼æ selectedKeys æ°ç»é¡ºåºï¼å³ç¹å»é¡ºåºï¼ |
| | | const candidates = candidateConversations.value |
| | | const byKey = new Map(candidates.map((c) => [getConversationKey(c), c])) |
| | | const targets = selectedKeys.value |
| | | .map((key) => byKey.get(key)) |
| | | .filter((c): c is Conversation => c != null) |
| | | if (targets.length === 0) { |
| | | return |
| | | } |
| | | const leaveText = leaveMessage.value.trim() |
| | | sending.value = true |
| | | try { |
| | | const tasks = targets.map(async (target) => { |
| | | const forwardOk = await forwardToTarget(target) |
| | | if (!forwardOk) { |
| | | return { target, ok: false } |
| | | } |
| | | const ok = leaveText ? await send(leaveText, { conversation: target }) : true |
| | | return { target, ok } |
| | | }) |
| | | const results = await Promise.all(tasks) |
| | | const failedNames = results.filter((r) => !r.ok).map((r) => r.target.name || 'æªå½åä¼è¯') |
| | | // å½ä¸çç®æ ç»ä¸æ¨å°æè¿è½¬åå表ï¼é¨åå¤±è´¥ä¹æ¨ï¼ç¨æ·ç"æå¾"å·²è¡¨è¾¾ï¼ |
| | | conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c))) |
| | | if (failedNames.length === 0) { |
| | | message.success('已转å') |
| | | } else if (failedNames.length === targets.length) { |
| | | message.error(`转å失败ï¼${failedNames.join('ã')}`) |
| | | } else { |
| | | message.warning(`已转åï¼ä½ ${failedNames.join('ã')} 失败`) |
| | | } |
| | | if (multiSelect.state.active) { |
| | | multiSelect.exit() |
| | | } |
| | | visible.value = false |
| | | } finally { |
| | | sending.value = false |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 好åè§å¾åéï¼å
建群ï¼åæ¶é请æé好åï¼â ç»æ°ç¾¤å¤ç¨ forwardToTarget 转å â åçè¨ â å
³å¼¹çª |
| | | * |
| | | * è·ä¼è¯è§å¾çå·®å«ï¼å
è¦ createGroup æ¿å° groupIdï¼ä¹åæé GROUP ä¸´æ¶ conversation åç»å·²æç forwardToTarget |
| | | * ï¼sendRaw å
é¨ä¼èªå¨ insertMessage ææ°ç¾¤ç»è®°è¿ storeï¼æè¿è½¬åå表ä¹è½æ£å¸¸æ¨ï¼ |
| | | */ |
| | | async function handleCreateGroupAndSend() { |
| | | if (selectedFriendIds.value.length === 0) { |
| | | return |
| | | } |
| | | if (state.messages.length === 0) { |
| | | message.warning('没æå¯è½¬åçæ¶æ¯') |
| | | return |
| | | } |
| | | const byId = new Map(friends.value.map((f) => [f.id, f])) |
| | | const members = selectedFriendIds.value |
| | | .map((id) => byId.get(id)) |
| | | .filter((f): f is FriendLite => f != null) |
| | | if (members.length === 0) { |
| | | return |
| | | } |
| | | sending.value = true |
| | | try { |
| | | const memberUserIds = members.map((m) => m.id) |
| | | const name = buildDefaultGroupName(members) |
| | | const group = await createGroup({ name, memberUserIds, joinApproval: false }) |
| | | if (!group?.id) { |
| | | throw new Error('åå»ºç¾¤å¤±è´¥ï¼æªè¿å群ç¼å·') |
| | | } |
| | | // upsert è¿ groupStoreï¼ç䏿¬¡ fetchGroupList |
| | | groupStore.upsertGroup({ |
| | | id: group.id, |
| | | name: group.name, |
| | | avatar: group.avatar, |
| | | notice: group.notice, |
| | | ownerUserId: group.ownerUserId |
| | | }) |
| | | // ç»æ°ç¾¤æé ä¸ä¸ªä¸´æ¶ conversation å¯¹è±¡ç» forwardToTarget ç¨ï¼sendRaw å
é¨ä¼èªå¨ insertMessage ç»è®° |
| | | const newConversation: Conversation = { |
| | | type: ImConversationType.GROUP, |
| | | targetId: group.id, |
| | | name: getGroupDisplayName(group) || name, |
| | | avatar: group.avatar || '', |
| | | unreadCount: 0, |
| | | lastContent: '', |
| | | lastSendTime: 0 |
| | | } |
| | | const forwardOk = await forwardToTarget(newConversation) |
| | | if (forwardOk) { |
| | | const leaveText = leaveMessage.value.trim() |
| | | if (leaveText) { |
| | | await send(leaveText, { conversation: newConversation }) |
| | | } |
| | | conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)]) |
| | | message.success('å·²å建群è并转å') |
| | | } else { |
| | | message.warning('群已å建ï¼ä½æ¶æ¯è½¬å失败ï¼è¯·ç¨åå¨ç¾¤ééè¯') |
| | | } |
| | | // ç»ä¸éå¤é + å
³å¼¹çªï¼æå / 失败é½è¦éæºä¼è¯çå¤éæï¼é¿å
éç |
| | | if (multiSelect.state.active) { |
| | | multiSelect.exit() |
| | | } |
| | | visible.value = false |
| | | } finally { |
| | | sending.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | è½¬åæ¶æ¯ï¼éæ¡ / åå¹¶ï¼ï¼éç®æ ä¼è¯ + çè¨åæ¹éåé |
| | | - dialog 壳æ¬ç»ä»¶ææï¼éæ© UI å§æ ConversationPickerPanel / FriendPickerPanel |
| | | - view='conversation'ï¼éå·²æä¼è¯åéï¼é»è®¤è§å¾ï¼ |
| | | - view='contact'ï¼ä»ãå建è天ãå
¥å£è¿å
¥ï¼é好å建群å转åï¼ä¸å¡å£³å±åè§å¾ |
| | | - footer slot å¡é¢è§å¡ï¼åå¹¶ / éæ¡ä¸åè§è§ï¼+ çè¨ + æäº¤æé® |
| | | - 坹夿¥å£æ²¿ç¨ï¼ref + open({ mode, messages, sourceConversation }) |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | width="720px" |
| | | :mask-closable="false" |
| | | :footer="view === 'conversation' ? null : undefined" |
| | | class="im-picker-dialog im-forward-dialog" |
| | | > |
| | | <template #header> |
| | | <div class="flex gap-2 items-center"> |
| | | <Icon |
| | | v-if="view === 'contact'" |
| | | icon="ant-design:arrow-left-outlined" |
| | | :size="16" |
| | | class="cursor-pointer im-forward-dialog__back" |
| | | @click="view = 'conversation'" |
| | | /> |
| | | <span class="text-base text-[var(--ant-color-text)]"> |
| | | {{ headerTitle }} |
| | | </span> |
| | | </div> |
| | | </template> |
| | | |
| | | <div class="h-[480px]"> |
| | | <!-- ä¼è¯è§å¾ï¼éå·²æä¼è¯è½¬å --> |
| | | <ConversationPickerPanel |
| | | v-if="view === 'conversation'" |
| | | v-model:selected-keys="selectedKeys" |
| | | :conversations="candidateConversations" |
| | | :recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys" |
| | | :show-create-chat="true" |
| | | @create-chat="handleSwitchToContact" |
| | | @remove-recent="conversationStore.removeRecentForwardConversationKey" |
| | | > |
| | | <template #footer> |
| | | <div class="flex flex-col gap-3 px-4 py-3"> |
| | | <!-- å并模å¼é¢è§ï¼ã[è天记å½] æ é¢ + æè¦å表ãé¢è§å¡ --> |
| | | <div |
| | | v-if="state.mode === ImForwardMode.MERGE && mergePreview" |
| | | class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <div |
| | | class="px-3 py-2 text-sm font-medium truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ mergePreview.title }} |
| | | </div> |
| | | <div class="flex flex-col px-3 pb-2 gap-0.5"> |
| | | <div |
| | | v-for="(line, idx) in mergePreview.lines" |
| | | :key="idx" |
| | | class="text-12px text-[var(--ant-color-text-secondary)] truncate" |
| | | > |
| | | {{ line }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | èå¤©è®°å½ |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- éæ¡æ¨¡å¼é¢è§ï¼æ¶æ¯æ° + 馿¡æè¦ --> |
| | | <div |
| | | v-else-if="state.mode === ImForwardMode.SINGLE && singlePreviewLines.length > 0" |
| | | class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <div class="flex flex-col px-3 py-2 gap-0.5"> |
| | | <div |
| | | v-for="(line, idx) in singlePreviewLines" |
| | | :key="idx" |
| | | class="text-13px text-[var(--ant-color-text)] truncate" |
| | | > |
| | | {{ line }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | å
± {{ state.messages.length }} æ¡æ¶æ¯ |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- çè¨ï¼åè¡ï¼ï¼å³ä¾§è¡¨æ
æé®è§¦å FacePickerï¼éä¸ emoji æ¼å°æ«å°¾ --> |
| | | <div class="relative"> |
| | | <Input v-model:value="leaveMessage" :maxlength="100" placeholder="ç»æåçè¨"> |
| | | <template #suffix> |
| | | <Icon |
| | | icon="ant-design:smile-outlined" |
| | | :size="18" |
| | | class="cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-primary)]" |
| | | @click.stop="emojiVisible = !emojiVisible" |
| | | /> |
| | | </template> |
| | | </Input> |
| | | <FacePicker |
| | | v-model:visible="emojiVisible" |
| | | mode="emoji" |
| | | class="bottom-full right-0 mb-2" |
| | | @select-emoji="handleEmojiSelect" |
| | | /> |
| | | </div> |
| | | |
| | | <div class="flex gap-2 justify-end"> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="sending" |
| | | :disabled="selectedKeys.length === 0" |
| | | @click="handleSend" |
| | | > |
| | | {{ confirmButtonText }} |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </ConversationPickerPanel> |
| | | |
| | | <!-- 好åè§å¾ï¼é好å建群å转å --> |
| | | <FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" /> |
| | | </div> |
| | | |
| | | <!-- 好åè§å¾ç dialog footerï¼å»ºç¾¤å¹¶è½¬å --> |
| | | <template v-if="view === 'contact'" #footer> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="sending" |
| | | :disabled="selectedFriendIds.length === 0" |
| | | @click="handleCreateGroupAndSend" |
| | | > |
| | | å建群èå¹¶åé |
| | | </Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | /* å¤ç¨éæ©ç±»å¼¹çªçå
Œ
± mixinï¼ å
鍿¯ :deep ç©¿é el-dialog å
é¨ header / body çæ ·å¼ï¼æ æ³æ¿æ¢ä¸ºå·¥å
·ç±» */ |
| | | @use '#/views/im/home/components/picker/picker-dialog' as picker; |
| | | |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Message } from '../../../../types' |
| | | |
| | | import { computed, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, message } from 'ant-design-vue' |
| | | |
| | | import { unpinGroupMessage as apiUnpinGroupMessage } from '#/api/im/group' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants' |
| | | import { resolveConversationLastContent } from '#/views/im/utils/conversation' |
| | | import { getSenderDisplayName, isGroupQuit } from '#/views/im/utils/user' |
| | | |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | |
| | | defineOptions({ name: 'ImGroupPinnedMessage' }) |
| | | |
| | | const props = defineProps<{ |
| | | /** å½å群ç¼å·ï¼èªè¡ä» groupStore æ¿å®æ´ Groupï¼è·éååºå¼ï¼ */ |
| | | groupId: number |
| | | }>() |
| | | |
| | | const emit = defineEmits<{ |
| | | /** ç¹å»ç½®é¡¶æ¶æ¯ â ç¶çº§ MessagePanel æ»å¨å®ä½å°åæ¶æ¯ä½ç½® */ |
| | | locate: [messageId: number] |
| | | }>() |
| | | |
| | | const groupStore = useGroupStore() |
| | | |
| | | /** å½å群ï¼å« pinnedMessagesï¼ */ |
| | | const group = computed(() => groupStore.getGroup(props.groupId)) |
| | | |
| | | const expanded = ref(false) |
| | | const removingId = ref<null | number>(null) |
| | | |
| | | // å群æ¶éç½®å±å¼ / ç§»é¤ä¸ç¶æï¼æ¬å° ref ä¸è·é groupIdï¼å¦åä¸ä¸ç¾¤"å±å¼"æ"ç§»é¤ä¸"ä¼å¸¦å°æ°ç¾¤ |
| | | watch( |
| | | () => props.groupId, |
| | | () => { |
| | | expanded.value = false |
| | | removingId.value = null |
| | | } |
| | | ) |
| | | |
| | | /** å½åç¾¤ç½®é¡¶æ¶æ¯å表ï¼ç´æ¥èµ° group.valueï¼è·éååºå¼ï¼ */ |
| | | const pinnedMessages = computed<Message[]>(() => group.value?.pinnedMessages ?? []) |
| | | |
| | | /** é¡¶é¨è¶åå±ç¤ºçææ°ä¸æ¡ï¼å³å表æå䏿¡ï¼pin 顺åºè¿½å ï¼ */ |
| | | const latest = computed<Message | null>( |
| | | () => pinnedMessages.value[pinnedMessages.value.length - 1] ?? null |
| | | ) |
| | | |
| | | /** å½åç¨æ·æ¯å¦ç¾¤ä¸» / 管çåï¼å³å®æ¯å¦æ¾ç¤ºãç§»é¤ãå
¥å£ï¼ */ |
| | | const canManage = computed(() => { |
| | | // åå²éç¾¤ç¾¤ï¼æ¬å°ç¼åæ®çæ¶ä¹ä¸ç»ãç§»é¤ãå
¥å£ |
| | | if (isGroupQuit(group.value)) { |
| | | return false |
| | | } |
| | | const myId = getCurrentUserId() |
| | | const role = group.value?.members?.find((m) => m.userId === myId)?.role |
| | | return role === ImGroupMemberRole.OWNER || role === ImGroupMemberRole.ADMIN |
| | | }) |
| | | |
| | | /** é¡¶é¨è¶åç¹å»ï¼åæ¡ç´æ¥è·³è½¬åæ¶æ¯ä½ç½®ï¼å¤æ¡åæ¢å±å¼ / æå */ |
| | | function handleTopClick() { |
| | | if (!latest.value) { |
| | | return |
| | | } |
| | | if (pinnedMessages.value.length === 1) { |
| | | handleLocate(latest.value) |
| | | return |
| | | } |
| | | expanded.value = !expanded.value |
| | | } |
| | | |
| | | /** ç¹å»ç½®é¡¶æ¶æ¯è¡ â 触å跳转 + æ¶èµ·å¼¹åºå± */ |
| | | function handleLocate(message: Message) { |
| | | if (!message.id) { |
| | | return |
| | | } |
| | | emit('locate', message.id) |
| | | expanded.value = false |
| | | } |
| | | |
| | | /** ç½®é¡¶æ¶æ¯åé人æ¾ç¤ºå */ |
| | | function getSenderName(message: Message): string { |
| | | return group.value |
| | | ? getSenderDisplayName(message.senderId, ImConversationType.GROUP, group.value.id) |
| | | : '' |
| | | } |
| | | |
| | | /** ç½®é¡¶æ¶æ¯é¢è§ææ¬ï¼å¤ç¨ä¼è¯æå䏿¡æè¦é»è¾ï¼[å¾ç] / [æä»¶] / ææ¬çï¼ */ |
| | | function getPreview(message: Message): string { |
| | | return group.value |
| | | ? resolveConversationLastContent(message, ImConversationType.GROUP, group.value.id) |
| | | : '' |
| | | } |
| | | |
| | | /** ç§»é¤ç½®é¡¶ï¼è°å端 APIï¼loading æé´ç¦æ¢éå¤ç¹ï¼å端广æ GROUP_MESSAGE_UNPIN ç± dispatcher èªå¨åæ¥æ¬å° */ |
| | | async function handleRemove(pinnedMessage: Message) { |
| | | if (!group.value || !pinnedMessage.id || removingId.value !== null) { |
| | | return |
| | | } |
| | | removingId.value = pinnedMessage.id |
| | | try { |
| | | await apiUnpinGroupMessage({ id: group.value.id, messageId: pinnedMessage.id }) |
| | | message.success('å·²åæ¶ç½®é¡¶') |
| | | } finally { |
| | | removingId.value = null |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- 群èç½®é¡¶æ¶æ¯ï¼ä»
群è + æç½®é¡¶æ¶æ¾ç¤ºï¼æ¬æå¨ç¾¤è头é¨ä¸æ¹å·¦ä¸è§ï¼ä¸å æ´è¡ï¼å¯¹é½å¾®ä¿¡ PCï¼ --> |
| | | <div |
| | | v-if="latest" |
| | | class="im-group-pinned-message relative flex flex-shrink-0 flex-col items-start px-4 pt-1.5 pb-2 bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <!-- é¡¶é¨è¶åï¼åæ¡ç¹å»è·³è½¬ï¼å¤æ¡æå ç¹å»å±å¼ï¼å¤æ¡å±å¼ç¹å»æå --> |
| | | <div |
| | | class="flex items-center gap-1.5 w-[360px] px-3 py-1.5 rounded-[10px] text-13px text-[var(--ant-color-text)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_2px_rgba(0,0,0,0.04)] cursor-pointer hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | @click="handleTopClick" |
| | | > |
| | | <Icon |
| | | icon="ant-design:pushpin-outlined" |
| | | :size="14" |
| | | class="flex-shrink-0 text-[var(--ant-color-warning)]" |
| | | /> |
| | | <span class="flex-shrink-0 text-[var(--ant-color-text-secondary)]">{{ getSenderName(latest) }}ï¼</span> |
| | | <span class="flex-1 min-w-0 truncate">{{ getPreview(latest) }}</span> |
| | | <!-- åæ¡ï¼ç§»é¤æé®ï¼å¤æ¡æå ï¼å
± N æ¡ï¼å¤æ¡å±å¼ï¼æ¶èµ·ç®å¤´ --> |
| | | <Button |
| | | v-if="pinnedMessages.length === 1 && canManage" |
| | | type="link" |
| | | size="small" |
| | | :loading="removingId === latest.id" |
| | | class="flex-shrink-0 !h-auto !p-0 text-13px" |
| | | @click.stop="handleRemove(latest)" |
| | | > |
| | | ç§»é¤ |
| | | </Button> |
| | | <template v-else-if="pinnedMessages.length > 1"> |
| | | <span class="flex-shrink-0 text-[var(--ant-color-text-secondary)] text-12px"> |
| | | å
± {{ pinnedMessages.length }} æ¡ |
| | | </span> |
| | | <Icon |
| | | :icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'" |
| | | :size="11" |
| | | class="flex-shrink-0 text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </template> |
| | | </div> |
| | | |
| | | <!-- 夿¡å±å¼ï¼æµ
è²é¢æ¿å
è£¹å®æ´åè¡¨ï¼æ¯æ¡ç¬ç«è¶åï¼ç¹å»è·³è½¬å°å¯¹åºæ¶æ¯ä½ç½® --> |
| | | <div |
| | | v-if="pinnedMessages.length > 1 && expanded" |
| | | class="im-group-pinned-message__list absolute top-full left-1.5 z-10 flex flex-col gap-2.5 w-[380px] p-3 rounded-xl bg-[var(--ant-color-bg-container)] shadow-[0_6px_16px_rgba(0,0,0,0.12)]" |
| | | style="margin-top: -1px" |
| | | > |
| | | <div |
| | | v-for="msg in pinnedMessages" |
| | | :key="msg.id" |
| | | class="flex items-center gap-1.5 w-full px-3 py-1.5 rounded-[10px] text-13px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-secondary)] cursor-pointer hover:bg-[var(--ant-color-bg-container)]" |
| | | @click="handleLocate(msg)" |
| | | > |
| | | <Icon |
| | | icon="ant-design:pushpin-outlined" |
| | | :size="14" |
| | | class="flex-shrink-0 text-[var(--ant-color-warning)]" |
| | | /> |
| | | <span class="flex-shrink-0 text-[var(--ant-color-text-secondary)]"> |
| | | {{ getSenderName(msg) }}ï¼ |
| | | </span> |
| | | <span class="flex-1 min-w-0 truncate">{{ getPreview(msg) }}</span> |
| | | <Button |
| | | v-if="canManage" |
| | | type="link" |
| | | size="small" |
| | | :loading="removingId === msg.id" |
| | | class="flex-shrink-0 !h-auto !p-0 text-13px" |
| | | @click.stop="handleRemove(msg)" |
| | | > |
| | | ç§»é¤ |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* å¼¹åºå±æä¸çä¸è§ç®å¤´ï¼èµ° ::before + 4 è¾¹ border é
è²ç»ï¼é¢è²è·å¼¹åºå± background ä¸è´ */ |
| | | .im-group-pinned-message__list::before { |
| | | content: ''; |
| | | position: absolute; |
| | | top: -8px; |
| | | left: 184px; |
| | | width: 0; |
| | | height: 0; |
| | | border-left: 8px solid transparent; |
| | | border-right: 8px solid transparent; |
| | | border-bottom: 8px solid var(--ant-color-bg-container); |
| | | filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04)); |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { ImGroupMemberRole } from '#/views/im/utils/constants' |
| | | |
| | | import { GroupRequestListDialog } from '../../../../components/group' |
| | | import { useGroupRequestStore } from '../../../../store/groupRequestStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | |
| | | defineOptions({ name: 'ImGroupRequestPending' }) |
| | | |
| | | const props = defineProps<{ |
| | | groupId: number |
| | | }>() |
| | | |
| | | const groupStore = useGroupStore() |
| | | const groupRequestStore = useGroupRequestStore() |
| | | |
| | | const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>() // ç³è¯·åè¡¨å¼¹çª refï¼handleOpen è° open({ groupId }) 触å |
| | | |
| | | /** æå¼å½å群çè¿ç¾¤ç³è¯·å表 */ |
| | | function handleOpen() { |
| | | requestListDialogRef.value?.open({ groupId: props.groupId }) |
| | | } |
| | | |
| | | /** å½å群ï¼å« ownerUserId / membersï¼ */ |
| | | const group = computed(() => groupStore.getGroup(props.groupId)) |
| | | |
| | | /** å½åç¨æ·å¨ç¾¤éçè§è²ï¼ä¼å
ç¨ group.membersï¼æå è½½æªå°æ¶åéå° ownerUserId ç´å¤ */ |
| | | const myRole = computed(() => { |
| | | const myId = getCurrentUserId() |
| | | if (group.value?.ownerUserId === myId) { |
| | | return ImGroupMemberRole.OWNER |
| | | } |
| | | return group.value?.members?.find((m) => m.userId === myId)?.role |
| | | }) |
| | | |
| | | /** ä»
群主 / 管çåå¯è§ */ |
| | | const canManage = computed( |
| | | () => myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN |
| | | ) |
| | | |
| | | /** å½å群æªå¤çç³è¯·æ°ï¼ä» store æ´¾ç */ |
| | | const pendingCount = computed(() => groupRequestStore.getUnhandledGroupRequestCount(props.groupId)) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 群顶é¨ãå¾
å¤çå 群ç³è¯·ã横å¹
|
| | | - ä»
å½ç»å½ç¨æ·æ¯è¯¥ç¾¤ owner / admin ä¸è¯¥ç¾¤ä¸æªå¤çç³è¯·æ° > 0 æ¶æ¾ç¤º |
| | | - count ä» groupRequestStore æ´¾çï¼å
¨å±åï¼ï¼æ¬ç«¯å¤ç / WS éç¥å°è¾¾å store èªå¨æ´æ° |
| | | - ç¹å»æ¨ªå¹
æå¼ GroupRequestListDialogï¼å«åå²å·²å¤çè®°å½ï¼ï¼ä¸åå°±å°å±å¼ |
| | | --> |
| | | <div |
| | | v-if="canManage && pendingCount > 0" |
| | | class="flex flex-shrink-0 flex-col items-start px-4 pt-1.5 pb-2 bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <div |
| | | class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-13px text-[var(--ant-color-text)] bg-[var(--ant-color-bg-container)] cursor-pointer shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-colors hover:bg-[var(--ant-color-fill-tertiary)]" |
| | | @click="handleOpen" |
| | | > |
| | | <Icon |
| | | icon="ant-design:user-add-outlined" |
| | | :size="14" |
| | | class="im-group-request-pending__icon flex-shrink-0 text-[var(--ant-color-success)]" |
| | | /> |
| | | <span class="flex-1 min-w-0 truncate"> æ°è¿ç¾¤ç³è¯·ï¼{{ pendingCount }}ï¼ </span> |
| | | <Icon |
| | | icon="ant-design:right-outlined" |
| | | :size="11" |
| | | class="flex-shrink-0 text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- ç³è¯·å表 dialogï¼å¤ç¨åä¸ç»ä»¶ï¼é¿å
群管ç颿¿ä¸ä¼è¯é¡¶é¨ååä¸ä»½ --> |
| | | <GroupRequestListDialog ref="requestListDialogRef" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* :deep ç©¿é Icon åç»ä»¶ DOMï¼å¼ºå¶ svg èµ° currentColor åºå¯¹æè²æ¨¡å¼ el-icon å
¨å±è²è¦ç */ |
| | | .im-group-request-pending__icon :deep(svg) { |
| | | fill: currentColor !important; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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'; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { openSafeUrl } from '#/packages/utils/src' |
| | | |
| | | import { Modal, Spin } from 'ant-design-vue' |
| | | |
| | | import { getChannelMaterial } from '#/api/im/channel/material' |
| | | import { useChannelStore } from '#/views/im/home/store/channelStore' |
| | | import { useConversationStore } from '#/views/im/home/store/conversationStore' |
| | | import { ImConversationType } from '#/views/im/utils/constants' |
| | | import { type MaterialMessage, parseMessage } from '#/views/im/utils/message' |
| | | |
| | | const props = defineProps<{ |
| | | content: string |
| | | }>() |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const channelStore = useChannelStore() |
| | | |
| | | /** å½åæ¯å¦å¨å
¬ä¼å· / é¢éä¼è¯éï¼å³å®èµ°å¤§å¡çè¿æ¯ç´§å转åå¡ç */ |
| | | const isChannelView = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL |
| | | ) |
| | | |
| | | /** ååºåå content JSON 为 payload 对象 */ |
| | | const payload = computed<MaterialMessage>( |
| | | () => parseMessage<MaterialMessage>(props.content) ?? {} |
| | | ) |
| | | |
| | | /** æ¥æºé¢éï¼ç´§åå¡åºé¨æ¸²æå¤´å + åç§° */ |
| | | const sourceChannel = computed(() => |
| | | payload.value.channelId ? channelStore.getChannel(payload.value.channelId) : undefined |
| | | ) |
| | | |
| | | const detailVisible = ref(false) |
| | | const detailLoading = ref(false) |
| | | const detailHtml = ref('') |
| | | |
| | | // ç¹å»è¡ä¸ºï¼url é空跳å¤é¾ï¼ä¸ºç©ºåæ payload.materialId æå¯ææ¬æ£æï¼å
¨å± dialog 渲æ |
| | | const onClick = async () => { |
| | | if (payload.value.url) { |
| | | openSafeUrl(payload.value.url) |
| | | return |
| | | } |
| | | if (!payload.value.materialId) { |
| | | return |
| | | } |
| | | detailVisible.value = true |
| | | detailLoading.value = true |
| | | detailHtml.value = '' |
| | | try { |
| | | const material = await getChannelMaterial(payload.value.materialId) |
| | | detailHtml.value = material?.content ?? '' |
| | | } catch (error) { |
| | | console.error('[Material] æåæ£æå¤±è´¥', error) |
| | | } finally { |
| | | detailLoading.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- å
¬ä¼å·ä¼è¯å
大å¡çï¼å¯¹é½å¾®ä¿¡å
¬ä¼å·å徿å¡ï¼å°é¢ 9:5 + 䏿¹ç½åºå ç²æ 颿¡ï¼ --> |
| | | <div |
| | | v-if="isChannelView" |
| | | class="material-card cursor-pointer w-full overflow-hidden rounded-lg bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]" |
| | | @click="onClick" |
| | | > |
| | | <img v-if="payload.coverUrl" class="block w-full h-[200px] object-cover" :src="payload.coverUrl" /> |
| | | <div class="px-3.5 py-3 text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2"> |
| | | {{ payload.title || '(æ æ é¢)' }} |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ç§è / 群èé被转åçç´ æç´§åå¡çï¼æ é¢ + æè¦å¨å·¦ãå°å°é¢å¨å³ãåºé¨é¢é头å + åç§°ï¼å¯¹é½å¾®ä¿¡å
¬ä¼å·è½¬åå¡ï¼ --> |
| | | <div |
| | | v-else |
| | | class="material-card cursor-pointer flex flex-col w-[260px] px-3.5 pt-3 pb-2.5 rounded-lg bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]" |
| | | @click="onClick" |
| | | > |
| | | <div class="flex gap-2.5 items-start"> |
| | | <div class="flex flex-1 flex-col gap-1.5 min-w-0"> |
| | | <div class="text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2 break-all"> |
| | | {{ payload.title || '(æ æ é¢)' }} |
| | | </div> |
| | | <div |
| | | v-if="payload.summary" |
| | | class="text-12px leading-[1.5] text-[var(--ant-color-text-secondary)] line-clamp-2 break-all" |
| | | > |
| | | {{ payload.summary }} |
| | | </div> |
| | | </div> |
| | | <img |
| | | v-if="payload.coverUrl" |
| | | class="flex-shrink-0 w-[60px] h-[60px] object-cover rounded bg-[var(--ant-color-fill-secondary)]" |
| | | :src="payload.coverUrl" |
| | | /> |
| | | </div> |
| | | <div class="flex items-center gap-1.5 mt-2.5 pt-2 border-t border-t-solid border-[var(--im-border-color-lighter)] text-12px text-[var(--ant-color-text-secondary)]"> |
| | | <img |
| | | v-if="sourceChannel?.avatar" |
| | | class="w-4 h-4 rounded-full object-cover flex-shrink-0" |
| | | :src="sourceChannel.avatar" |
| | | /> |
| | | <Icon v-else icon="ep:promotion" :size="14" /> |
| | | <span class="truncate">{{ sourceChannel?.name || 'é¢éæ¶æ¯' }}</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 坿æ¬è¯¦æ
ï¼å
¨å±å¼¹çªæéæè½½ï¼å
³éåéæ¾å¯ææ¬ DOM --> |
| | | <Modal |
| | | v-model:open="detailVisible" |
| | | :title="payload.title || '详æ
'" |
| | | :footer="null" |
| | | width="100vw" |
| | | wrap-class-name="im-material-detail-modal" |
| | | destroy-on-close |
| | | > |
| | | <Spin :spinning="detailLoading" wrapper-class-name="w-full"> |
| | | <div class="material-detail-body max-w-[720px] mx-auto px-5 pt-6 pb-20 min-h-[60vh]"> |
| | | <div class="text-[22px] font-600 leading-[1.4] text-[var(--ant-color-text)] mb-5"> |
| | | {{ payload.title || '' }} |
| | | </div> |
| | | <div |
| | | v-if="detailHtml" |
| | | class="article-content text-15px leading-[1.75] text-[var(--ant-color-text)]" |
| | | v-dompurify-html="detailHtml" |
| | | ></div> |
| | | <div |
| | | v-else-if="!detailLoading" |
| | | class="text-center text-[var(--ant-color-text-secondary)] mt-20" |
| | | > |
| | | ææ æ£æ |
| | | </div> |
| | | </div> |
| | | </Spin> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | /* hover é´å½± + transitionï¼ååä¸å¤ï¼åæ³æ´ç´§å */ |
| | | .material-card { |
| | | transition: box-shadow 0.15s ease; |
| | | |
| | | &:hover { |
| | | box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); |
| | | } |
| | | } |
| | | |
| | | /* :deep ç©¿é v-dompurify-html 渲æçå
åµ DOMï¼ç»ä¸æ§å¶å¯ææ¬éç img / p / hN æç */ |
| | | .article-content { |
| | | :deep(img) { |
| | | max-width: 100%; |
| | | height: auto; |
| | | } |
| | | |
| | | :deep(p) { |
| | | margin: 12px 0; |
| | | } |
| | | |
| | | :deep(h1), |
| | | :deep(h2), |
| | | :deep(h3) { |
| | | margin: 20px 0 12px; |
| | | font-weight: 600; |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, onBeforeUnmount } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { formatFileSize, openSafeUrl } from '#/packages/utils/src' |
| | | |
| | | import { Image } from 'ant-design-vue' |
| | | |
| | | import { CardBubble } from '#/views/im/home/components/card' |
| | | import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer' |
| | | import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config' |
| | | import { ImContentType } from '#/views/im/utils/constants' |
| | | import { summarizeMessageContent } from '#/views/im/utils/conversation' |
| | | import { |
| | | type AudioMessage, |
| | | type CardMessage, |
| | | type FaceMessage, |
| | | type FileMessage, |
| | | getFileIconInfo, |
| | | type ImageMessage, |
| | | type MentionCandidate, |
| | | type MergeMessage, |
| | | parseMessage, |
| | | parseTextSegments, |
| | | type TextMessage, |
| | | type VideoMessage |
| | | } from '#/views/im/utils/message' |
| | | import { formatSeconds } from '#/views/im/utils/time' |
| | | |
| | | import MaterialBubble from './material-bubble.vue' |
| | | import TipSegments from './tip-segments.vue' |
| | | |
| | | defineOptions({ name: 'ImMessageBubble' }) |
| | | |
| | | const props = defineProps<{ |
| | | /** æ¶æ¯ contentï¼JSON åç¬¦ä¸²ï¼ */ |
| | | content: string |
| | | /** TEXT æ°æ³¡ç @ mention åéååï¼ä¸ä¼ åææ¬éç @xxx éå为æ®éææ¬ */ |
| | | mentions?: MentionCandidate[] |
| | | /** æ¯å¦èªå·±åéï¼å½±åæ°æ³¡é
è²ï¼ç»¿åº vs ç°åºï¼ */ |
| | | selfSend?: boolean |
| | | /** å
容类åï¼å¯¹é½ ImContentType */ |
| | | type: number |
| | | /** åªä½ä¸ä¼ è¿åº¦ï¼0-100ï¼ï¼é null å³è§ä¸ºä¸ä¼ ä¸ï¼æ¸²æé®ç½© / è¿åº¦æ¡ */ |
| | | uploadProgress?: null | number |
| | | }>() |
| | | |
| | | const emit = defineEmits<{ |
| | | /** åçç¹å»ï¼è°ç¨æ¹å³å®å¼¹å¡ç / 跳群çè¡ä¸º */ |
| | | clickCard: [card: CardMessage, e: MouseEvent] |
| | | /** åå¹¶æ¶æ¯æ°æ³¡ç¹å»ï¼è°ç¨æ¹å³å®å¼ dialog ææ å
push */ |
| | | openMerge: [content: string] |
| | | }>() |
| | | |
| | | /** å type å¤å® */ |
| | | const isText = computed(() => props.type === ImContentType.TEXT) |
| | | const isImage = computed(() => props.type === ImContentType.IMAGE) |
| | | const isFile = computed(() => props.type === ImContentType.FILE) |
| | | const isVoice = computed(() => props.type === ImContentType.VOICE) |
| | | const isVideo = computed(() => props.type === ImContentType.VIDEO) |
| | | const isFace = computed(() => props.type === ImContentType.FACE) |
| | | const isCard = computed(() => props.type === ImContentType.CARD) |
| | | const isMerge = computed(() => props.type === ImContentType.MERGE) |
| | | const isMaterial = computed(() => props.type === ImContentType.MATERIAL) |
| | | |
| | | /** åªä½ä¸ä¼ ä¸ï¼uploadProgress é null å³è§ä¸ºä¸ä¼ ä¸ */ |
| | | const isUploading = computed(() => props.uploadProgress != null) |
| | | const uploadProgress = computed(() => props.uploadProgress ?? 0) |
| | | const uploadProgressText = computed(() => `${uploadProgress.value}%`) |
| | | |
| | | /** |
| | | * åä¸ parse å
¥å£ï¼content ä¸ååª parse 䏿¬¡ï¼æ type ååå°ä¸é¢ 7 个 payload |
| | | * |
| | | * åç±»å payload å
±ç¨å䏿£µ JSON æ ï¼é¿å
7 个 computed åèªé parse åä¸ä»½ content |
| | | */ |
| | | const parsedContent = computed<unknown>(() => parseMessage(props.content)) |
| | | |
| | | const textPayload = computed(() => |
| | | isText.value ? (parsedContent.value as null | TextMessage) : null |
| | | ) |
| | | |
| | | /** ææ¬æ°æ³¡ segment æ°ç»ï¼mention é«äº® + URL èªå¨è¯å« + æ®éææ¬ä¸æ®µæ¼æ¥ */ |
| | | const textSegments = computed(() => { |
| | | const content = textPayload.value?.content |
| | | if (!content) { |
| | | return [] |
| | | } |
| | | return parseTextSegments(content, props.mentions || []) |
| | | }) |
| | | const imagePayload = computed(() => |
| | | isImage.value ? (parsedContent.value as ImageMessage | null) : null |
| | | ) |
| | | const filePayload = computed(() => |
| | | isFile.value ? (parsedContent.value as FileMessage | null) : null |
| | | ) |
| | | const voicePayload = computed(() => |
| | | isVoice.value ? (parsedContent.value as AudioMessage | null) : null |
| | | ) |
| | | const videoPayload = computed(() => |
| | | isVideo.value ? (parsedContent.value as null | VideoMessage) : null |
| | | ) |
| | | const cardPayload = computed(() => |
| | | isCard.value ? (parsedContent.value as CardMessage | null) : null |
| | | ) |
| | | const mergePayload = computed(() => |
| | | isMerge.value ? (parsedContent.value as MergeMessage | null) : null |
| | | ) |
| | | |
| | | /** åå¹¶æ¶æ¯å
åµå N æ¡æ´¾çã{æµç§°}ï¼{æè¦}ã */ |
| | | const mergePreviewLines = computed(() => { |
| | | if (!mergePayload.value) { |
| | | return [] |
| | | } |
| | | return mergePayload.value.messages |
| | | .slice(0, MESSAGE_MERGE_PREVIEW_LINES) |
| | | .map((item) => `${item.senderNickname}ï¼${summarizeMessageContent(item)}`) |
| | | }) |
| | | |
| | | const FACE_DIMENSION_MAX = 2048 // 表æ
payloadï¼éæ³å®½é«æ´¾çæ undefinedï¼è®© <img> èµ° CSS max-w / max-h å
åº |
| | | const facePayload = computed(() => { |
| | | if (!isFace.value) { |
| | | return null |
| | | } |
| | | const raw = parsedContent.value as FaceMessage | null |
| | | if (!raw) { |
| | | return null |
| | | } |
| | | const sanitize = (v: number | undefined) => |
| | | v && v > 0 && v <= FACE_DIMENSION_MAX ? v : undefined |
| | | return { ...raw, width: sanitize(raw.width), height: sanitize(raw.height) } |
| | | }) |
| | | |
| | | /** æä»¶å¾æ + é
è²ï¼ææ©å±ååå */ |
| | | const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name)) |
| | | |
| | | /** ææ¬ / æä»¶ / è¯é³æ°æ³¡çæ´ä½ classï¼å« selfSend é
è² + ::before ä¸è§ç side classï¼ */ |
| | | function bubbleClass(variant: 'file' | 'text' | 'voice'): string[] { |
| | | const isSelf = props.selfSend |
| | | const side = isSelf ? 'message-bubble--self' : 'message-bubble--other' |
| | | switch (variant) { |
| | | case 'file': { |
| | | return [ |
| | | side, |
| | | 'message-bubble--file', |
| | | isSelf |
| | | ? 'bg-[#95ec69] border-[var(--ant-color-border-secondary)]' |
| | | : 'bg-[var(--ant-color-bg-container)] border-[var(--ant-color-border-secondary)] hover:border-[#409eff]' |
| | | ] |
| | | } |
| | | case 'text': { |
| | | return [ |
| | | side, |
| | | 'message-bubble--text', |
| | | isSelf |
| | | ? 'text-black bg-[#95ec69]' |
| | | : 'text-[var(--ant-color-text)]' |
| | | ] |
| | | } |
| | | case 'voice': { |
| | | return [side, 'message-bubble--voice', isSelf ? 'bg-[#95ec69]' : ''] |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** æä»¶ç¹å» â æ°çªå£ä¸è½½ï¼ä¸ä¼ ä¸è·³è¿ */ |
| | | function handleFileClick() { |
| | | if (isUploading.value || !filePayload.value?.url) { |
| | | return |
| | | } |
| | | openSafeUrl(filePayload.value.url) |
| | | } |
| | | |
| | | const voicePlayer = useVoicePlayer() // è¯é³ç¹å»ï¼æç®¡ç» useVoicePlayer å
¨å±äºæ¥ææ¾ï¼æ°ç¹çè¯é³ä¼åææ§ç |
| | | /** |
| | | * å®ä¾çº§å¯ä¸ææ¾ keyï¼æ¯ä¸ª MessageBubble å®ä¾ç¬ç«ä¸ä»½ |
| | | * |
| | | * ä¸ç¨ url å½ key æ¯ä¸ºäºé¿å
ã䏻颿¿ / å岿½å± / å并详æ
å䏿¡è¯é³ãå
±äº«èº«ä»½ï¼é£æ ·ä¸å¤æ°æ³¡ä¼ |
| | | * åæ¶æ¾ç¤ºææ¾æï¼ä¸ä»»ä½ä¸å¤å¸è½½é½ä¼ stop æå«å¤ä»å¯è§çææ¾ |
| | | */ |
| | | const voiceKey = Symbol('im-message-bubble-voice') |
| | | const voicePlaying = computed(() => voicePlayer.isPlaying(voiceKey)) |
| | | function handleVoiceClick() { |
| | | const url = voicePayload.value?.url |
| | | if (!url) { |
| | | return |
| | | } |
| | | voicePlayer.play(voiceKey, url) |
| | | } |
| | | |
| | | /** æ°æ³¡å¸è½½å
åºï¼ä¼ key 让 stop èªå·±å¤å«ãæ¯ä¸æ¯æãï¼ä¸ä¼è¯¯ä¼¤å«äººçææ¾ */ |
| | | onBeforeUnmount(() => { |
| | | voicePlayer.stop(voiceKey) |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- ææ¬ï¼æ segment 渲æï¼mention é«äº®å¯ç¹å»ãURL èªå¨è¯å«æå¯ç¹å»é¾æ¥ --> |
| | | <div |
| | | v-if="isText && textPayload" |
| | | class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg" |
| | | :class="bubbleClass('text')" |
| | | > |
| | | <TipSegments :segments="textSegments" /> |
| | | </div> |
| | | |
| | | <!-- å¾çï¼el-image å
ç½®é¢è§ï¼ä¸ä¼ ä¸åéæé®ç½© --> |
| | | <div v-else-if="isImage && imagePayload" class="relative inline-block"> |
| | | <Image |
| | | class="max-w-[220px] rounded cursor-zoom-in" |
| | | :src="imagePayload.thumbnailUrl || imagePayload.url" |
| | | :preview="isUploading ? false : { src: imagePayload.url }" |
| | | /> |
| | | <div |
| | | v-if="isUploading" |
| | | class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none" |
| | | > |
| | | {{ uploadProgressText }} |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- æä»¶ï¼æä»¶å + 大å°é å·¦ã彩è²å¤§å¾æ è´´å³ï¼ä¸ä¼ 䏿䏿¡è¿åº¦æ¡ --> |
| | | <div |
| | | v-else-if="isFile && filePayload" |
| | | class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border border-solid rounded transition-colors" |
| | | :class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']" |
| | | @click="handleFileClick" |
| | | > |
| | | <div class="flex-1 min-w-0"> |
| | | <div class="overflow-hidden text-sm font-medium truncate text-[var(--ant-color-text)]"> |
| | | {{ filePayload.name }} |
| | | </div> |
| | | <div class="mt-1 text-12px text-[var(--ant-color-text-secondary)]"> |
| | | {{ formatFileSize(filePayload.size) }} |
| | | </div> |
| | | <div v-if="isUploading" class="flex gap-2 items-center mt-1.5"> |
| | | <div class="overflow-hidden flex-1 h-1 rounded bg-[var(--ant-color-fill-dark)]"> |
| | | <div |
| | | class="h-full bg-[var(--ant-color-primary)] transition-[width] duration-150" |
| | | :style="{ width: `${uploadProgress }%` }" |
| | | ></div> |
| | | </div> |
| | | <span class="text-11px text-[var(--ant-color-text-secondary)] tabular-nums"> |
| | | {{ uploadProgressText }} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | <Icon :icon="fileIconInfo.icon" :color="fileIconInfo.color" :size="40" class="flex-shrink-0" /> |
| | | </div> |
| | | |
| | | <!-- è¯é³ --> |
| | | <div |
| | | v-else-if="isVoice && voicePayload" |
| | | class="relative flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer" |
| | | :class="bubbleClass('voice')" |
| | | @click="handleVoiceClick" |
| | | > |
| | | <Icon |
| | | icon="ant-design:audio-outlined" |
| | | :size="18" |
| | | class="message-bubble__voice-icon" |
| | | :class="{ 'im-voice-playing': voicePlaying }" |
| | | /> |
| | | <span class="text-13px text-[var(--ant-color-text)]"> |
| | | {{ formatSeconds(voicePayload.duration) }} |
| | | </span> |
| | | </div> |
| | | |
| | | <!-- è§é¢ï¼åç controls å
嵿æ¾ï¼poster èµ°å端å°é¢ï¼ä¸ä¼ ä¸åéæé®ç½© --> |
| | | <div v-else-if="isVideo && videoPayload?.url" class="relative inline-block"> |
| | | <video |
| | | class="max-w-[280px] max-h-[320px] rounded bg-black" |
| | | :src="videoPayload.url" |
| | | :poster="videoPayload.coverUrl" |
| | | :controls="!isUploading" |
| | | preload="metadata" |
| | | ></video> |
| | | <div |
| | | v-if="isUploading" |
| | | class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none" |
| | | > |
| | | {{ uploadProgressText }} |
| | | </div> |
| | | </div> |
| | | <!-- è§é¢ä½ payload è§£æå¤±è´¥ / 没 urlï¼é级å±ç¤º --> |
| | | <div |
| | | v-else-if="isVideo" |
| | | class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | [è§é¢æ¶æ¯] |
| | | </div> |
| | | |
| | | <!-- 表æ
è´´å¾ï¼è£¸ <img> ä¸å¥æ°æ³¡ --> |
| | | <div v-else-if="isFace && facePayload" class="inline-block"> |
| | | <img |
| | | :src="facePayload.url" |
| | | :alt="facePayload.name || '表æ
'" |
| | | :title="facePayload.name || ''" |
| | | :width="facePayload.width" |
| | | :height="facePayload.height" |
| | | class="block max-w-[160px] max-h-[160px] object-contain" |
| | | draggable="false" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- åç --> |
| | | <CardBubble |
| | | v-else-if="isCard && cardPayload" |
| | | :card="cardPayload" |
| | | clickable |
| | | @click="(e: MouseEvent) => emit('clickCard', cardPayload!, e)" |
| | | /> |
| | | |
| | | <!-- åå¹¶è½¬åæ°æ³¡ï¼title + æè¦é¢è§ + åºé¨ãè天记å½ãæ ç¾ --> |
| | | <div |
| | | v-else-if="isMerge && mergePayload" |
| | | class="flex flex-col w-[260px] rounded-md overflow-hidden cursor-pointer bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border)] hover:border-[#409eff]" |
| | | @click="emit('openMerge', content)" |
| | | > |
| | | <div class="px-3 py-2 text-sm font-medium text-[var(--ant-color-text)] truncate"> |
| | | {{ mergePayload.title }} |
| | | </div> |
| | | <div class="flex flex-col gap-0.5 px-3 pb-2"> |
| | | <div |
| | | v-for="(line, idx) in mergePreviewLines" |
| | | :key="idx" |
| | | class="text-12px text-[var(--ant-color-text-secondary)] truncate" |
| | | > |
| | | {{ line }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | èå¤©è®°å½ |
| | | </div> |
| | | </div> |
| | | <!-- åå¹¶æ¶æ¯è§£æå¤±è´¥å
åº --> |
| | | <div |
| | | v-else-if="isMerge" |
| | | class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | [è天记å½] |
| | | </div> |
| | | |
| | | <!-- é¢éç´ æï¼å¾æå¡çï¼ç¹å»æå¯ææ¬ / è·³å¤é¾ --> |
| | | <MaterialBubble v-else-if="isMaterial" :content="props.content" /> |
| | | |
| | | <!-- æªç¥ç±»åé级 --> |
| | | <div |
| | | v-else |
| | | class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | [䏿¯æçå
容类å] |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* æ°æ³¡å°¾å·´å°ä¸è§æå对åºå¤´åï¼å¯¹æ¹å¨å·¦ãèªå·±å¨å³ï¼ï¼èµ° ::before + 4 è¾¹ border é
è²ç»ï¼éæ 3 è¾¹ + å®è² 1 è¾¹ï¼ |
| | | é¢è²ä¸æ°æ³¡èæ¯å¯¹åºï¼ç 1px è§è§åè¿å»ï¼çä¸å¼ å¾ç */ |
| | | .message-bubble--other::before, |
| | | .message-bubble--self::before { |
| | | content: ''; |
| | | position: absolute; |
| | | top: 12px; |
| | | width: 0; |
| | | height: 0; |
| | | border-style: solid; |
| | | } |
| | | .message-bubble--other { |
| | | --im-message-bubble-other-bg: #f5f5f5; |
| | | } |
| | | .message-bubble--other.message-bubble--text, |
| | | .message-bubble--other.message-bubble--voice { |
| | | background-color: var(--im-message-bubble-other-bg); |
| | | } |
| | | .message-bubble--other::before { |
| | | left: -5px; |
| | | border-width: 5px 6px 5px 0; |
| | | border-color: transparent var(--im-message-bubble-other-bg) transparent transparent; |
| | | } |
| | | .message-bubble--self::before { |
| | | right: -5px; |
| | | border-width: 5px 0 5px 6px; |
| | | border-color: transparent transparent transparent #95ec69; |
| | | } |
| | | |
| | | /* æ´ä½æ¾è¿ :global()ï¼é¿å
Vue scoped æ `:global(.dark) .xxx` å¡ç¼©æè£¸ `.dark` èæåéå·å° <html> */ |
| | | :global(.dark .message-bubble--other) { |
| | | --im-message-bubble-other-bg: rgb(255 255 255 / 12%); |
| | | } |
| | | |
| | | /* :deep ç©¿é scoped åç»ä»¶ DOMï¼el-icon å¨æè²æ¨¡å¼ä¸å
¨å± color 被 .el-icon{color:var(--color)} å¹²æ°ï¼æ voice 徿 fill éæ» */ |
| | | .message-bubble__voice-icon :deep(svg) { |
| | | fill: #606266 !important; |
| | | } |
| | | .message-bubble__voice-icon.im-voice-playing :deep(svg) { |
| | | fill: #409eff !important; |
| | | } |
| | | |
| | | /* @keyframes éè¦ SCSS 声æï¼ææ¾ä¸çèå²å¨ç» */ |
| | | .im-voice-playing { |
| | | animation: im-voice-icon-pulse 0.8s infinite; |
| | | } |
| | | |
| | | @keyframes im-voice-icon-pulse { |
| | | 0%, |
| | | 100% { |
| | | transform: scale(1); |
| | | } |
| | | 50% { |
| | | transform: scale(1.15); |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Message } from '#/views/im/home/types' |
| | | |
| | | import { computed, inject, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { useUserStore } from '#/packages/stores/src' |
| | | |
| | | import { Button, Calendar, Input, Modal, Popover, Tag } from 'ant-design-vue' |
| | | import dayjs from 'dayjs' |
| | | |
| | | import { getGroupMessageList as apiGetGroupMessageList } from '#/api/im/message/group' |
| | | import { getPrivateMessageList as apiGetPrivateMessageList } from '#/api/im/message/private' |
| | | import { useMessagePuller } from '#/views/im/home/composables/useMessagePuller' |
| | | import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer' |
| | | import { |
| | | ImContentType, |
| | | ImConversationType, |
| | | isFriendChatTip, |
| | | isGroupNotification |
| | | } from '#/views/im/utils/constants' |
| | | import { |
| | | buildFacePreviewText, |
| | | buildRecallTip, |
| | | buildRecallTipSegments, |
| | | getConversationKey |
| | | } from '#/views/im/utils/conversation' |
| | | import { getClientConversationId } from '#/views/im/utils/db' |
| | | import { |
| | | resolveFriendNotificationSegments, |
| | | resolveFriendNotificationText, |
| | | resolveGroupNotificationSegments |
| | | } from '#/views/im/utils/message' |
| | | import { |
| | | type CardMessage, |
| | | type FaceMessage, |
| | | type FileMessage, |
| | | getCardLabelInfo, |
| | | type MergeMessage, |
| | | parseMessage, |
| | | type TextMessage |
| | | } from '#/views/im/utils/message' |
| | | import { formatHistoryTime } from '#/views/im/utils/time' |
| | | import { |
| | | getMemberDisplayName, |
| | | getSenderDisplayName, |
| | | getSenderRealNickname |
| | | } from '#/views/im/utils/user' |
| | | |
| | | import { GroupMember, type GroupMemberLite } from '../../../../components/group' |
| | | import { UserAvatar } from '../../../../components/user' |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useFriendStore } from '../../../../store/friendStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | import { useMessageStore } from '../../../../store/messageStore' |
| | | import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys' |
| | | import MessageBubble from './message-bubble.vue' |
| | | import TipSegments from './tip-segments.vue' |
| | | |
| | | defineOptions({ name: 'ImMessageHistory' }) |
| | | |
| | | const emit = defineEmits<{ |
| | | // å岿¶æ¯è¡ä¸ç"å®ä½"æé®ï¼éç¥ç¶ç»ä»¶ MessagePanel æ»å°å¯¹åºæ¶æ¯ä½ç½® + å
³æèªå·± |
| | | locate: [messageId: number] |
| | | }>() |
| | | |
| | | const userStore = useUserStore() |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const groupStore = useGroupStore() |
| | | const friendStore = useFriendStore() |
| | | const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY) |
| | | const voicePlayer = useVoicePlayer() |
| | | const { convertPrivateMessage, convertGroupMessage } = useMessagePuller() |
| | | |
| | | const visible = ref(false) |
| | | |
| | | defineExpose({ |
| | | /** æå¼å岿¶æ¯æ½å± */ |
| | | open() { |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | const conversation = computed(() => conversationStore.activeConversation) |
| | | const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP) |
| | | const allMessages = computed<Message[]>(() => |
| | | conversation.value |
| | | ? messageStore.getMessages( |
| | | getClientConversationId(conversation.value.type, conversation.value.targetId) |
| | | ) |
| | | : [] |
| | | ) |
| | | |
| | | /** åæ¡æ¶æ¯çåé人æ¾ç¤ºåï¼æ¸²ææ¶æ conversation ä¸ä¸æèµ° WeChat ä¼å
çº§å®æ¶ç® */ |
| | | function senderDisplayNameOf(message: Message): string { |
| | | return getSenderDisplayName( |
| | | message.senderId, |
| | | conversation.value?.type ?? 0, |
| | | conversation.value?.targetId ?? 0 |
| | | ) |
| | | } |
| | | |
| | | /** 群广æäºä»¶ segments çæååè§£æå¨ï¼æå½åä¼è¯ targetId èµ° getSenderDisplayName */ |
| | | function resolveGroupMemberName(message: Message): (userId: number) => string { |
| | | return (id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0) |
| | | } |
| | | |
| | | /** åæ¡æ¶æ¯çåé人ç宿µç§°ï¼ç» UserAvatar è²å¡ / alt ç¨ï¼æ°¸è¿æ¯ nickname 䏿ºå¤æ³¨ */ |
| | | function senderRealNicknameOf(message: Message): string { |
| | | return getSenderRealNickname( |
| | | message.senderId, |
| | | conversation.value?.type ?? 0, |
| | | conversation.value?.targetId ?? 0 |
| | | ) |
| | | } |
| | | |
| | | /** åæ¡æ¤åæ¶æ¯ç tip ææ¡ï¼buildRecallTip å
鍿 conversation ä¸ä¸æå®æ¶ç® sender å */ |
| | | function recallTipOf(message: Message): string { |
| | | return buildRecallTip( |
| | | message.senderId, |
| | | message.selfSend, |
| | | conversation.value?.type ?? 0, |
| | | conversation.value?.targetId ?? 0 |
| | | ) |
| | | } |
| | | |
| | | /** åæ¡æ¤åæ¶æ¯ç tip segmentsï¼sender åæ®µæå¯ç¹å» mention */ |
| | | function recallTipSegmentsOf(message: Message) { |
| | | return buildRecallTipSegments( |
| | | message.senderId, |
| | | message.selfSend, |
| | | conversation.value?.type ?? 0, |
| | | conversation.value?.targetId ?? 0 |
| | | ) |
| | | } |
| | | |
| | | // ==================== æ é¢ ==================== |
| | | |
| | | /** |
| | | * å¼¹çªæ é¢ï¼åè微信 PC |
| | | * - ç§èï¼ä¸"X"çèå¤©è®°å½ |
| | | * - 群èï¼"X 群"çè天记å½(N) |
| | | */ |
| | | const title = computed(() => { |
| | | if (!conversation.value) { |
| | | return 'è天记å½' |
| | | } |
| | | const name = conversation.value.name |
| | | if (isGroup.value) { |
| | | return `"${name}"çè天记å½(${allMessages.value.length})` |
| | | } |
| | | return `ä¸"${name}"çè天记å½` |
| | | }) |
| | | |
| | | // ==================== æç´¢ + çé chip ==================== |
| | | |
| | | /** å½åæ¿æ´»çç鿡件 ââ å chip 模å¼ï¼åæ¶åª 1 个ï¼ï¼ç¹å» tab è½æ°å¼ï¼Ã æ¸
空 */ |
| | | type ActiveFilter = |
| | | | { day: string; kind: 'date'; } // YYYY-MM-DD |
| | | | { kind: 'file' } |
| | | | { kind: 'image' } |
| | | | { kind: 'member'; nickname: string; userId: number; } |
| | | | { kind: 'voice' } |
| | | |
| | | const keyword = ref('') |
| | | const activeFilter = ref<ActiveFilter | null>(null) |
| | | |
| | | /** chip ææ¡ï¼æ¥æ / 群æå å¤å¸¦å¼ï¼å
¶ä»ç´æ¥ tab å */ |
| | | const filterChipLabel = computed(() => { |
| | | if (!activeFilter.value) { |
| | | return '' |
| | | } |
| | | switch (activeFilter.value.kind) { |
| | | case 'date': { |
| | | return `æ¥æï¼${activeFilter.value.day}` |
| | | } |
| | | case 'file': { |
| | | return 'æä»¶' |
| | | } |
| | | case 'image': { |
| | | return 'å¾ç' |
| | | } |
| | | case 'member': { |
| | | return `@${activeFilter.value.nickname}` |
| | | } |
| | | case 'voice': { |
| | | return 'è¯é³' |
| | | } |
| | | default: { |
| | | return '' |
| | | } |
| | | } |
| | | }) |
| | | |
| | | /** ç¹ tab è½çéï¼å kind éå¤ç¹å» â å½ toggle å
³æï¼é¿å
è¿·æï¼ */ |
| | | function setFilter(filter: ActiveFilter) { |
| | | if ( |
| | | activeFilter.value?.kind === filter.kind && |
| | | filter.kind !== 'date' && |
| | | filter.kind !== 'member' |
| | | ) { |
| | | activeFilter.value = null |
| | | return |
| | | } |
| | | activeFilter.value = filter |
| | | } |
| | | |
| | | /** chip à å
³é / éç½® */ |
| | | function clearFilter() { |
| | | activeFilter.value = null |
| | | } |
| | | |
| | | // ==================== æ¥æ popover ==================== |
| | | |
| | | const datePopoverVisible = ref(false) |
| | | const datePickerValue = ref(dayjs()) |
| | | |
| | | /** æ¥æ popover ç¡®å®ï¼æ Date â YYYY-MM-DD è½å° activeFilterï¼å
³ popover */ |
| | | function onDateConfirm() { |
| | | if (!datePickerValue.value) { |
| | | datePopoverVisible.value = false |
| | | return |
| | | } |
| | | const day = datePickerValue.value.format('YYYY-MM-DD') |
| | | activeFilter.value = { kind: 'date', day } |
| | | datePopoverVisible.value = false |
| | | } |
| | | |
| | | // ==================== 群æå popover ==================== |
| | | |
| | | const memberPopoverVisible = ref(false) |
| | | const memberSearchKeyword = ref('') |
| | | |
| | | /** 群æå picker å表ï¼ä» groupStore æ + éé
GroupMemberLite + å
³é®åè¿æ»¤ */ |
| | | const filteredMembersForPicker = computed<GroupMemberLite[]>(() => { |
| | | if (!isGroup.value || !conversation.value) { |
| | | return [] |
| | | } |
| | | const group = groupStore.getGroup(conversation.value.targetId) |
| | | const all = (group?.members || []).map((member) => { |
| | | const friend = friendStore.getFriend(member.userId) |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status |
| | | } |
| | | }) |
| | | const trimmedKeyword = memberSearchKeyword.value.trim() |
| | | if (!trimmedKeyword) { |
| | | return all |
| | | } |
| | | return all.filter((member) => member.showName.includes(trimmedKeyword)) |
| | | }) |
| | | |
| | | /** 群æå picker éæ©ï¼è½ activeFilter + å
³ popover + æ¸
æç´¢è¯ */ |
| | | function onMemberSelect(member: GroupMemberLite) { |
| | | activeFilter.value = { |
| | | kind: 'member', |
| | | userId: member.userId, |
| | | nickname: member.showName |
| | | } |
| | | memberPopoverVisible.value = false |
| | | memberSearchKeyword.value = '' |
| | | } |
| | | |
| | | // ==================== åè¡¨è¿æ»¤ ==================== |
| | | |
| | | /** activeFilter å½ä¸ï¼é»è®¤æ çéæ¶å
¨é¨å½ä¸ */ |
| | | function matchesActiveFilter(message: Message): boolean { |
| | | if (!activeFilter.value) { |
| | | return true |
| | | } |
| | | switch (activeFilter.value.kind) { |
| | | case 'date': { |
| | | return dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day |
| | | } |
| | | case 'file': { |
| | | return message.type === ImContentType.FILE |
| | | } |
| | | case 'image': { |
| | | return message.type === ImContentType.IMAGE |
| | | } |
| | | case 'member': { |
| | | return message.senderId === activeFilter.value.userId |
| | | } |
| | | case 'voice': { |
| | | return message.type === ImContentType.VOICE |
| | | } |
| | | default: { |
| | | return true |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * å½åå表ï¼å
activeFilter è¿æ»¤ãå keyword 模ç³å½ä¸ãæå reverseï¼ææ°å¨åï¼ |
| | | * |
| | | * å
³é®åå½ä¸èµ° textSnippetOf ââ ææ¬æ¿åæãåªä½æ¿"[å¾ç]"çå ä½è¯ãæä»¶æ¿æä»¶å |
| | | */ |
| | | const currentList = computed<Message[]>(() => { |
| | | const trimmedKeyword = keyword.value.trim() |
| | | let list = allMessages.value.filter((message) => matchesActiveFilter(message)) |
| | | if (trimmedKeyword) { |
| | | list = list.filter((message) => textSnippetOf(message).includes(trimmedKeyword)) |
| | | } |
| | | return list.toReversed() |
| | | }) |
| | | |
| | | // ==================== å è½½æ´æ©æ¶æ¯ ==================== |
| | | |
| | | const HISTORY_PAGE_SIZE = 50 |
| | | const loadingMore = ref(false) |
| | | const hasMore = ref(true) |
| | | |
| | | /** |
| | | * å è½½æ´æ©æ¶æ¯ï¼æ¿å½åææ©ä¸æ¡ id ä½ maxIdï¼ä¸å«ï¼ï¼è° list æ¥å£æä¸é¡µ + convert + prepend |
| | | * |
| | | * - æªå¯¹æ¥ list æ¥å£ç type / keyword / sender è¿æ»¤åæ°ï¼åç«¯åªæ¯æ maxId + limit 游æ åé¡µï¼ |
| | | * tab çéå¨å端åï¼æ°æ®æ¥åå°æ¬å°åè¿æ»¤ï¼ |
| | | * - æ¬å°å ä½è·³è¿ï¼åç«¯æ²¡æ³æ messageId æ¥ |
| | | * - è¿åæ°é < limit è§ä¸ºå°é¡¶ |
| | | */ |
| | | async function loadEarlier() { |
| | | // éå
¥ / å°é¡¶ / æ ä¼è¯ æ©éï¼é¿å
éå¤è¯·æ±æå¨ conversation 忢é´é触å |
| | | if (loadingMore.value || !hasMore.value || !conversation.value) { |
| | | return |
| | | } |
| | | // ä»
PRIVATE / GROUP èµ°å页æ¥å£ï¼CHANNEL åå广æã没æ list æ¥å£ï¼è½å° else ä¼è¯¯è°ç§èæ¥å£ï¼receiverId ä¼ channelIdï¼ |
| | | const requestedType = conversation.value.type |
| | | if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) { |
| | | return |
| | | } |
| | | // å¿«ç
§å½åä¼è¯ä¸»é®ï¼await æé´ç¨æ·åèµ° / å
³é颿¿æ¶ä¸¢å¼ååºï¼é¿å
æ§ä¼è¯åå²è¢« prepend å°æ°ä¼è¯é æä¸²å· |
| | | const requestedKey = getConversationKey(conversation.value) |
| | | const requestedTargetId = conversation.value.targetId |
| | | const requestedIsGroup = requestedType === ImConversationType.GROUP |
| | | |
| | | loadingMore.value = true |
| | | try { |
| | | // ç® maxIdï¼ä¸å«ï¼ä½ä¸ºå端游æ ï¼ï¼åå½åä¼è¯æ¬å°ç¼åéææ©ä¸æ¡æå¡ç«¯ idï¼ |
| | | // æ¬å°ä¹è§å 使¶æ¯æ²¡ææå¡ç«¯ idï¼è¦åé¤ |
| | | // å
¨æ¯å ä½ / åè¡¨ä¸ºç©ºæ¶ reduce 䏿´æ°åå¼ï¼POSITIVE_INFINITYï¼ï¼è½¬æ undefined â åç«¯ä»ææ°æ |
| | | let earliestId = Number.POSITIVE_INFINITY |
| | | for (const message of allMessages.value) { |
| | | if (message.id && message.id > 0) { |
| | | earliestId = Math.min(earliestId, message.id) |
| | | } |
| | | } |
| | | const maxId = Number.isFinite(earliestId) ? earliestId : undefined |
| | | |
| | | // è°å端 list æ¥å£ï¼ç§è / ç¾¤èæ¥å£ç¾åä¸åï¼åæ¯è°åº¦ï¼è¿åç»æç¨ useMessagePuller |
| | | // æ´é²ç convert 彿°è½¬ææ¬å° Messageï¼ä¸ puller åä¸ä»½å段æ å°ï¼é¿å
åæ§ï¼ |
| | | let earlier: Message[] = [] |
| | | let pageLength = 0 |
| | | if (requestedIsGroup) { |
| | | const list = await apiGetGroupMessageList({ |
| | | groupId: requestedTargetId, |
| | | maxId, |
| | | limit: HISTORY_PAGE_SIZE |
| | | }) |
| | | earlier = (list || []).map((message) => convertGroupMessage(message)) |
| | | pageLength = list?.length ?? 0 |
| | | } else { |
| | | const list = await apiGetPrivateMessageList({ |
| | | receiverId: requestedTargetId, |
| | | maxId, |
| | | limit: HISTORY_PAGE_SIZE |
| | | }) |
| | | earlier = (list || []).map((message) => convertPrivateMessage(message)) |
| | | pageLength = list?.length ?? 0 |
| | | } |
| | | |
| | | // await æé´ active å¯è½è¢«å¤é¨ç½® null / æ¢ä¸»é®ï¼ç´æ¥ä¸¢å¼ååºï¼ä¸æ´æ° hasMoreï¼æ§ä¼è¯å°é¡¶ä¸ä»£è¡¨æ°ä¼è¯å°é¡¶ï¼ä¹ä¸ prepend |
| | | if (!conversation.value || getConversationKey(conversation.value) !== requestedKey) { |
| | | return |
| | | } |
| | | |
| | | // è¿åæ°é < limit è§ä¸ºå°é¡¶ ââ å
³é"å è½½æ´æ©"æé®ï¼é¿å
åç»ç¹å»ç©ºè·æ¥å£ |
| | | if (pageLength < HISTORY_PAGE_SIZE) { |
| | | hasMore.value = false |
| | | } |
| | | // åå¹¶å° messageStoreï¼prependMessageList å
é¨å»é + ååºåå¹¶ + è½ IndexedDBï¼ |
| | | // 主è天颿¿ç messages æ¯åä¸ä»½å¼ç¨ï¼èæ¶æ¯ä¹ä¼ä¸èµ·åºç°å¨ä¸»é¢æ¿éï¼ç¬¦åé¢æï¼ |
| | | messageStore.prependMessageList(requestedType, requestedTargetId, earlier) |
| | | } finally { |
| | | loadingMore.value = false |
| | | } |
| | | } |
| | | |
| | | // ==================== å¼¹çªæå¼ reset ==================== |
| | | |
| | | /** å¼¹çªæå¼æ¶æä¸æ¬¡ç chip / æç´¢ / å è½½ç¶æé½æ¸
å¹²åï¼é¿å
䏿¬¡çç¶ææ®çè¿·æ */ |
| | | function onDialogOpen() { |
| | | activeFilter.value = null |
| | | keyword.value = '' |
| | | hasMore.value = true |
| | | datePopoverVisible.value = false |
| | | memberPopoverVisible.value = false |
| | | memberSearchKeyword.value = '' |
| | | datePickerValue.value = dayjs() |
| | | // æ¬å°æ æ¶æ¯æ¶ç«å³æä¸æ¬¡ï¼maxId=undefined 仿æ°å¼å§ï¼ï¼é¿å
æ°è®¾å¤ / ç¼åæ¸
åå¼¹çªåªæ¾ç¤º"ææ æ¶æ¯" |
| | | if (allMessages.value.length === 0 && hasMore.value && conversation.value) { |
| | | void loadEarlier() |
| | | } |
| | | } |
| | | |
| | | /** æ½å±å
³éæ¶å¤ä½ + åè¯é³ */ |
| | | watch(visible, (value) => { |
| | | if (!value) { |
| | | activeFilter.value = null |
| | | keyword.value = '' |
| | | voicePlayer.stop() |
| | | } |
| | | }) |
| | | |
| | | /** |
| | | * æ½å±å¼çæ¶å¤é¨åäº active conversationï¼dialog ç title / å表 / isGroup å
¨é¨è·çæ° conversation èµ°ï¼ |
| | | * è¿éæå页æä¸å¹¶éç½®ï¼å¦åæ§ä¼è¯æ®çç loadingMore=true / hasMore=false ä¼è®©æ°ä¼è¯"å è½½æ´æ©"æé®å¤±æ |
| | | */ |
| | | watch(conversation, () => { |
| | | loadingMore.value = false |
| | | hasMore.value = true |
| | | }) |
| | | |
| | | // ==================== helper ==================== |
| | | |
| | | /** å头å urlï¼èªå·±ç¨ userStoreï¼ç¾¤éæ¥ groupStore æåï¼ç§èç¨ conversation.avatar */ |
| | | function getAvatar(message: Message): string { |
| | | if (message.selfSend) { |
| | | return userStore.userInfo?.avatar || '' |
| | | } |
| | | if (!conversation.value) { |
| | | return '' |
| | | } |
| | | if (isGroup.value) { |
| | | const group = groupStore.getGroup(conversation.value.targetId) |
| | | return group?.members?.find((member) => member.userId === message.senderId)?.avatar || '' |
| | | } |
| | | return conversation.value.avatar || '' |
| | | } |
| | | |
| | | /** å
³é®åå½ä¸ææ¬ï¼ææ¬ç±»è¿ååæãæä»¶è¿åæä»¶åï¼å©äºææä»¶åæï¼ãå
¶ä»è¿åå ä½è¯ */ |
| | | function textSnippetOf(message: Message): string { |
| | | if (isFriendChatTip(message.type)) { |
| | | return resolveFriendNotificationText(message) |
| | | } |
| | | switch (message.type) { |
| | | case ImContentType.CARD: { |
| | | const card = parseMessage<CardMessage>(message.content) |
| | | return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}` |
| | | } |
| | | case ImContentType.FACE: { |
| | | return buildFacePreviewText(parseMessage<FaceMessage>(message.content)) |
| | | } |
| | | case ImContentType.FILE: { |
| | | return parseMessage<FileMessage>(message.content)?.name ?? '[æä»¶]' |
| | | } |
| | | case ImContentType.IMAGE: { |
| | | return '[å¾ç]' |
| | | } |
| | | case ImContentType.MERGE: { |
| | | const merge = parseMessage<MergeMessage>(message.content) |
| | | return merge?.title ?? '[è天记å½]' |
| | | } |
| | | case ImContentType.RECALL: { |
| | | return recallTipOf(message) |
| | | } |
| | | case ImContentType.TEXT: { |
| | | return parseMessage<TextMessage>(message.content)?.content ?? '' |
| | | } |
| | | case ImContentType.VIDEO: { |
| | | return '[è§é¢]' |
| | | } |
| | | case ImContentType.VOICE: { |
| | | return '[è¯é³]' |
| | | } |
| | | default: { |
| | | return '' |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * å®ä½å°è天ä½ç½®ï¼emit ç» MessagePanel èµ° scrollIntoView + çæé«äº®ï¼åå
³æèªå·± |
| | | * messageId === 0ï¼æ¬å°å 使¶æ¯ï¼è·³è¿ââè¿æ²¡æ¿å°çå® idï¼DOM 䏿²¡æ³ querySelector |
| | | */ |
| | | function locateMessage(messageId: number) { |
| | | if (!messageId) { |
| | | return |
| | | } |
| | | emit('locate', messageId) |
| | | visible.value = false |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | å岿¶æ¯å¼¹çªï¼å¯¹é½å¾®ä¿¡ PCï¼ |
| | | - å±
ä¸ dialogï¼title "ä¸ãXãè天记å½" / "ãX 群ãè天记å½(N)" |
| | | - é»è®¤æ çéï¼å
¨éååºå±ç¤ºï¼ç¹å» tab è½çé â æç´¢æ¡å·¦ä¾§åº chip Ã å¯æ¸
|
| | | - æä»¶ / å¾ç / 龿¥ï¼ç´æ¥è½çéï¼æ¥æ / 群æåï¼å¼¹ popover äºæ¬¡éæ©åè½ |
| | | - "å è½½æ´æ©æ¶æ¯"ï¼å表åºé¨æé®ï¼ç¹å»è° /im/message/{private,group}/list æ¿ä¸é¡µ + prepend |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | :title="title" |
| | | width="640px" |
| | | :footer="null" |
| | | class="im-message-history__dialog" |
| | | @after-open-change="(open: boolean) => open && onDialogOpen()" |
| | | > |
| | | <div class="flex flex-col gap-3 h-[520px]"> |
| | | <!-- æç´¢åºï¼activeFilter å卿¶å·¦ä¾§åº chip Ã å¯æ¸
ï¼å¦å纯æç´¢æ¡ --> |
| | | <div |
| | | class="im-message-history__searchbar flex items-center gap-2 px-3 py-1.5 rounded-md bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <Tag |
| | | v-if="activeFilter" |
| | | closable |
| | | size="small" |
| | | class="im-message-history__chip flex-shrink-0" |
| | | @close="clearFilter" |
| | | > |
| | | {{ filterChipLabel }} |
| | | </Tag> |
| | | <Icon |
| | | v-else |
| | | icon="ant-design:search-outlined" |
| | | class="text-[var(--ant-color-text-secondary)]" |
| | | /> |
| | | <input |
| | | v-model="keyword" |
| | | type="text" |
| | | placeholder="æç´¢" |
| | | class="im-message-history__search-input flex-1 min-w-0 bg-transparent border-none outline-none text-sm text-[var(--ant-color-text)]" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- Tab è¡ï¼æä»¶ / å¾ç / è¯é³ / æ¥æ(popover) / 群æå(popover, ä»
群è)ï¼åºé¨ä¸æ¡åå²çº¿æ tab åºè·æ¶æ¯å表åå¼ï¼å¯¹é½å¾®ä¿¡è§æ --> |
| | | <div |
| | | class="flex gap-5 px-2 pb-2 text-sm flex-shrink-0 text-[#1989fa] border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'file' }" |
| | | @click="setFilter({ kind: 'file' })" |
| | | > |
| | | æä»¶ |
| | | </span> |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'image' }" |
| | | @click="setFilter({ kind: 'image' })" |
| | | > |
| | | å¾ç |
| | | </span> |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'voice' }" |
| | | @click="setFilter({ kind: 'voice' })" |
| | | > |
| | | è¯é³ |
| | | </span> |
| | | <!-- æ¥æï¼el-popover å
el-calendarï¼ç¡®è®¤åæè½çé --> |
| | | <Popover |
| | | v-model:open="datePopoverVisible" |
| | | trigger="click" |
| | | placement="bottom" |
| | | :overlay-style="{ width: '320px' }" |
| | | > |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'date' }" |
| | | > |
| | | æ¥æ |
| | | </span> |
| | | <template #content> |
| | | <div class="im-message-history__date-panel"> |
| | | <div class="px-2 pt-1 pb-2 text-13px font-medium text-[var(--ant-color-text)]"> |
| | | éæ©å鿥æ |
| | | </div> |
| | | <Calendar |
| | | v-model:value="datePickerValue" |
| | | :fullscreen="false" |
| | | class="im-message-history__calendar" |
| | | /> |
| | | <div class="flex gap-2 justify-end px-2 pt-2"> |
| | | <Button size="small" @click="datePopoverVisible = false">åæ¶</Button> |
| | | <Button size="small" type="primary" @click="onDateConfirm">ç¡®å®</Button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | <!-- 群æåï¼ä»
群èï¼popover å
èªå¸¦æç´¢ + GroupMember å表 --> |
| | | <Popover |
| | | v-if="isGroup" |
| | | v-model:open="memberPopoverVisible" |
| | | trigger="click" |
| | | placement="bottom" |
| | | :overlay-style="{ width: '320px' }" |
| | | > |
| | | <span |
| | | class="im-message-history__tab cursor-pointer" |
| | | :class="{ 'im-message-history__tab--active': activeFilter?.kind === 'member' }" |
| | | > |
| | | 群æå |
| | | </span> |
| | | <template #content> |
| | | <div> |
| | | <Input v-model:value="memberSearchKeyword" placeholder="æç´¢ç¾¤æå" size="small"> |
| | | <template #prefix> |
| | | <Icon icon="ant-design:search-outlined" /> |
| | | </template> |
| | | </Input> |
| | | <div class="max-h-[360px] overflow-y-auto mt-2"> |
| | | <GroupMember |
| | | v-for="member in filteredMembersForPicker" |
| | | :key="member.userId" |
| | | :member="member" |
| | | :height="44" |
| | | :clickable="false" |
| | | class="cursor-pointer hover:bg-[var(--ant-color-fill)]" |
| | | @click="onMemberSelect(member)" |
| | | /> |
| | | <div |
| | | v-if="filteredMembersForPicker.length === 0" |
| | | class="py-6 text-12px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | 没æå¹é
çæå |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | </div> |
| | | |
| | | <!-- æ¶æ¯å表 --> |
| | | <div class="flex-1 overflow-y-auto"> |
| | | <template v-for="message in currentList" :key="message.id || message.clientMessageId"> |
| | | <!-- 好åä¼è¯äºä»¶ï¼FRIEND_ADD / FRIEND_DELETEï¼ï¼å±
ä¸ç°è²ï¼ä¸æå¤´å / senderï¼ |
| | | è·ä¸»è天颿¿é MessageItem çæ¸²æè¯ä¹å¯¹é½ --> |
| | | <div |
| | | v-if="isFriendChatTip(message.type)" |
| | | class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <TipSegments :segments="resolveFriendNotificationSegments(message)" /> |
| | | </div> |
| | | |
| | | <!-- 群广æäºä»¶ï¼è·å¥½åäºä»¶åç°è²æ ·å¼ï¼mention 段æç¹å»å¼¹ UserInfoCard --> |
| | | <div |
| | | v-else-if="isGroupNotification(message.type)" |
| | | class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <TipSegments |
| | | :segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- æ®éæ¶æ¯è¡ --> |
| | | <div |
| | | v-else |
| | | class="im-message-history__row flex gap-3 items-start px-1 py-3 border-b border-b-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <UserAvatar |
| | | :url="getAvatar(message)" |
| | | :name="senderRealNicknameOf(message)" |
| | | :size="36" |
| | | :clickable="false" |
| | | /> |
| | | <div class="flex-1 min-w-0"> |
| | | <div |
| | | class="flex justify-between items-start text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <span class="font-medium text-[var(--ant-color-text)]"> |
| | | {{ senderDisplayNameOf(message) }} |
| | | </span> |
| | | <span class="im-message-history__meta relative flex-shrink-0"> |
| | | <span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span> |
| | | <!-- å®ä½å°è天ä½ç½®ï¼absolute æµ®å¨æ¶é´ä¸æ¹ï¼è¡ hover ææ¾ç¤ºï¼ |
| | | ä¸åä¸å³ä¾§æ flex æçï¼é¿å
éèæ¶å ä½è®©"æ"åå
容ä¹é´ç空ï¼ï¼ |
| | | ä»
æçå® id çæ¶æ¯ææ¯æï¼æ¬å°å 使¶æ¯ä¸è¡ï¼ --> |
| | | <span |
| | | v-if="message.id && message.id > 0" |
| | | class="im-message-history__locate" |
| | | @click="locateMessage(message.id!)" |
| | | > |
| | | å®ä½å°è天ä½ç½® |
| | | </span> |
| | | </span> |
| | | </div> |
| | | |
| | | <div class="mt-1.5"> |
| | | <!-- æ¤åï¼åç¬èµ°ç°è² tipï¼sender åæ®µå¯ç¹å» --> |
| | | <div |
| | | v-if="message.type === ImContentType.RECALL" |
| | | class="text-sm italic text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="recallTipSegmentsOf(message)" /> |
| | | </div> |
| | | <!-- å
¶å®ç±»åèµ° MessageBubble å¤ç¨ä¸»èå¤©æ°æ³¡ --> |
| | | <MessageBubble |
| | | v-else |
| | | :type="message.type" |
| | | :content="message.content" |
| | | @open-merge="openMergeDetail?.($event)" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <div |
| | | v-if="currentList.length === 0" |
| | | class="py-10 text-center text-13px text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ keyword || activeFilter ? '没æå¹é
çæ¶æ¯' : 'ææ æ¶æ¯' }} |
| | | </div> |
| | | |
| | | <!-- å è½½æ´æ©æ¶æ¯ï¼å表åºé¨ triggerï¼reverse åææ©ç卿ä¸ï¼æé®æ¾åºé¨æ´èªç¶ï¼ï¼ |
| | | filter å½ä¸ 0 æ¡æ¶ä»ä¿ç ââ å è½½æ´æ©å¯è½å¸¦åå¹é
å
容 --> |
| | | <div |
| | | v-if="hasMore && allMessages.length > 0" |
| | | class="py-3 text-center border-t border-t-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <Button :loading="loadingMore" type="link" @click="loadEarlier"> |
| | | å è½½æ´æ©æ¶æ¯ |
| | | </Button> |
| | | </div> |
| | | <!-- "æ²¡ææ´æ©" åªå¨å·²æå¹é
项æ¶é²åºï¼é¿å
åä¸é¢"没æå¹é
çæ¶æ¯"ç©ºæææ¡éå --> |
| | | <div |
| | | v-else-if="!hasMore && currentList.length > 0" |
| | | class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | æ²¡ææ´æ©çæ¶æ¯äº |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Modal> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* :deep ç©¿é el-tag åç»ä»¶ DOMï¼æç´¢åº chip ç¦æ hover é¢è²è¿æ¸¡ / Ã å¾æ 卿ï¼é¿å
å¨æç´¢åºéææå¨æ */ |
| | | .im-message-history__chip, |
| | | .im-message-history__chip :deep(.el-tag__close) { |
| | | transition: none !important; |
| | | animation: none !important; |
| | | } |
| | | |
| | | /* ãå®ä½å°è天ä½ç½®ãæé®ï¼ç¶è¡ hover ææ¾ç¤ºï¼èµ° .parent:hover .child è·¨å
ç´ ç¶æèå¨ */ |
| | | .im-message-history__locate { |
| | | position: absolute; |
| | | top: 100%; |
| | | right: 0; |
| | | margin-top: 4px; |
| | | display: none; |
| | | white-space: nowrap; |
| | | color: #1989fa; |
| | | cursor: pointer; |
| | | transition: color 0.15s; |
| | | } |
| | | .im-message-history__row:hover .im-message-history__locate { |
| | | display: block; |
| | | } |
| | | .im-message-history__locate:hover { |
| | | color: #146fc7; |
| | | } |
| | | |
| | | /* ::after 伪å
ç´ ç»æ¿æ´»ä¸å线ï¼tab é»è®¤èè²è¡å
æåï¼æ¿æ´»æå ä¸å线 */ |
| | | .im-message-history__tab { |
| | | position: relative; |
| | | padding: 4px 2px; |
| | | color: #1989fa; |
| | | transition: color 0.15s; |
| | | } |
| | | .im-message-history__tab:hover { |
| | | color: #2f81d4; |
| | | } |
| | | .im-message-history__tab--active::after { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | height: 2px; |
| | | background: #1989fa; |
| | | border-radius: 1px; |
| | | } |
| | | |
| | | /* :deep ç©¿é antd Calendarï¼fullscreen=false å¡ç模å¼ï¼å DOMï¼åä¸åå¡è¿ 320 popover */ |
| | | .im-message-history__calendar :deep(.ant-picker-calendar-header) { |
| | | padding: 4px 8px; |
| | | } |
| | | .im-message-history__calendar :deep(.ant-picker-content) { |
| | | font-size: 12px; |
| | | } |
| | | .im-message-history__calendar :deep(.ant-picker-cell) { |
| | | padding: 1px 0; |
| | | } |
| | | .im-message-history__calendar :deep(.ant-picker-cell .ant-picker-calendar-date) { |
| | | height: 28px; |
| | | margin: 0 2px; |
| | | padding: 2px 4px 0; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../../../../components/group' |
| | | import type { Message } from '../../../../types' |
| | | |
| | | import { computed, inject } from 'vue' |
| | | |
| | | import { confirm } from '#/packages/effects/common-ui/src' |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { useUserStore } from '#/packages/stores/src' |
| | | |
| | | import { useClipboard } from '@vueuse/core' |
| | | import { message as antdMessage, Tag } from 'ant-design-vue' |
| | | |
| | | import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '#/api/im/group' |
| | | import { removeGroupMember } from '#/api/im/group/member' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { |
| | | MESSAGE_GROUP_READ_ENABLED, |
| | | MESSAGE_PRIVATE_READ_ENABLED, |
| | | MESSAGE_RECALL_WINDOW_MS, |
| | | MESSAGE_TIME_TIP_GAP_MS |
| | | } from '#/views/im/utils/config' |
| | | import { |
| | | ImContentType, |
| | | ImConversationType, |
| | | ImForwardMode, |
| | | ImFriendAddSource, |
| | | ImGroupMemberRole, |
| | | ImMessageReceiptStatus, |
| | | ImMessageStatus, |
| | | isFriendChatTip, |
| | | isGroupNotification, |
| | | isMediaMessageType, |
| | | isNormalMessage, |
| | | isRtcCallTip |
| | | } from '#/views/im/utils/constants' |
| | | import { buildRecallTipSegments } from '#/views/im/utils/conversation' |
| | | import { |
| | | buildQuoteFromMessage, |
| | | type CardMessage, |
| | | extractAddableFace, |
| | | getQuoteFromMessage, |
| | | type MentionCandidate, |
| | | parseMessage, |
| | | type TextMessage |
| | | } from '#/views/im/utils/message' |
| | | import { |
| | | parseRtcCallPayload, |
| | | resolveFriendNotificationSegments, |
| | | resolveGroupNotificationSegments, |
| | | resolveRtcCallPrivateBubbleText, |
| | | resolveRtcCallTipSegments |
| | | } from '#/views/im/utils/message' |
| | | import { formatTimeTip } from '#/views/im/utils/time' |
| | | import { |
| | | getMemberDisplayName, |
| | | getMentionCandidates, |
| | | getSenderDisplayName, |
| | | getSenderRealNickname, |
| | | isGroupQuit |
| | | } from '#/views/im/utils/user' |
| | | |
| | | import { UserAvatar } from '../../../../components/user' |
| | | import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader' |
| | | import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect' |
| | | import { useMessageSender } from '../../../../composables/useMessageSender' |
| | | import { useMuteOverlay } from '../../../../composables/useMuteOverlay' |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useFaceStore } from '../../../../store/faceStore' |
| | | import { useFriendStore } from '../../../../store/friendStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | import { useMessageStore } from '../../../../store/messageStore' |
| | | import { useImUiStore } from '../../../../store/uiStore' |
| | | import { |
| | | IM_FORWARD_DIALOG_KEY, |
| | | IM_MERGE_DETAIL_DIALOG_KEY, |
| | | IM_RTC_REDIAL_KEY |
| | | } from './forward/keys' |
| | | import MessageBubble from './message-bubble.vue' |
| | | import MessageReadStatus from './message-read-status.vue' |
| | | import ReplyPreview from './reply-preview.vue' |
| | | import TipSegments from './tip-segments.vue' |
| | | |
| | | defineOptions({ name: 'ImMessageItem' }) |
| | | |
| | | const props = defineProps<{ |
| | | message: Message |
| | | /** å表ä¸çä¸ä¸æ¡æ¶æ¯ï¼ç¨äºå¤ææ¯å¦è¦å¨å½åæ¶æ¯ä¸æ¹æ¸²ææ¶é´åéæ¡ï¼ä¸ä¼ æåè¡¨ç¬¬ä¸æ¡å¤ç */ |
| | | prevMessage?: Message |
| | | }>() |
| | | |
| | | const emit = defineEmits<{ |
| | | /** å¼ç¨åç¹å» â MessagePanel æ»å®ä½ + é«äº® */ |
| | | locate: [messageId: number] |
| | | /** ç¦è¨ï¼éè¦ç¶ç»ä»¶æå¼æ¶é¿éæ©å¼¹çª */ |
| | | mute: [groupId: number, userId: number, displayName: string] |
| | | /** æ°æ®åæ´åå·æ°ç¾¤ä¿¡æ¯ */ |
| | | reload: [] |
| | | }>() |
| | | |
| | | // ==================== Stores / Hooks ==================== |
| | | |
| | | const userStore = useUserStore() |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const groupStore = useGroupStore() |
| | | const friendStore = useFriendStore() |
| | | const faceStore = useFaceStore() |
| | | const uiStore = useImUiStore() |
| | | const { recall, sendRaw } = useMessageSender() |
| | | const { uploadAndSendMedia } = useMediaUploader() |
| | | const muteOverlay = useMuteOverlay() |
| | | // legacy:true å
¼å®¹ HTTP ç¯å¢ï¼æ²¡æ navigator.clipboard æ¶éçº§å° execCommand |
| | | const { copy: copyToClipboard } = useClipboard({ legacy: true }) |
| | | |
| | | // ==================== å
容类å夿 ==================== |
| | | |
| | | /** æ¯å¦å¨å½åæ¶æ¯ä¸æ¹æ¸²ææ¶é´åéæ¡ï¼åè¡¨ç¬¬ä¸æ¡ / è·ä¸ä¸æ¡è¶
è¿éå¼ï¼ç¼º sendTime 䏿¸²æï¼é¢éç´ ææ¯æ¡é½æ¾ç¤º */ |
| | | const shouldShowTimeTip = computed(() => { |
| | | if (!props.message.sendTime) { |
| | | return false |
| | | } |
| | | if (isMaterial.value) { |
| | | return true |
| | | } |
| | | if (!props.prevMessage?.sendTime) { |
| | | return true |
| | | } |
| | | return props.message.sendTime - props.prevMessage.sendTime > MESSAGE_TIME_TIP_GAP_MS |
| | | }) |
| | | |
| | | /** ä»
MessageItem èªèº«ä»è¦ç¨å°ç type å¤å®ï¼å
¶å®åæ¯å·²ä¸æ²å° MessageBubbleï¼ */ |
| | | const isVoice = computed(() => props.message.type === ImContentType.VOICE) |
| | | const isMerge = computed(() => props.message.type === ImContentType.MERGE) |
| | | /** |
| | | * é¢éç´ æå¨ãé¢éä¼è¯ãå
仿微信å
¬ä¼å·æ ·å¼ï¼å±
ä¸ + æ 头åï¼ï¼ |
| | | * ç§è / 群èé被转åè¿æ¥çç´ ææ selfSend èµ°æ åæ°æ³¡å¸å±ï¼èªå·±å³ã对æ¹å·¦ã带头åï¼ |
| | | */ |
| | | const isMaterial = computed( |
| | | () => |
| | | props.message.type === ImContentType.MATERIAL && |
| | | conversationStore.activeConversation?.type === ImConversationType.CHANNEL |
| | | ) |
| | | |
| | | /** å½åæ¯å¦å¨å
¬ä¼å· / é¢éä¼è¯å
ï¼éå¶å³é®èååªå±ç¤ºè½¬å / å é¤ */ |
| | | const isChannelConversation = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL |
| | | ) |
| | | |
| | | // ==================== äºä»¶æ¶æ¯ï¼æ¤å / 好å / 群广æï¼ ==================== |
| | | // è¿ä¸ç±»ä¸èµ°æ®éæ°æ³¡ï¼æ¸²ææå±
ä¸ç°è² tipï¼å¤æ + ææ¡é
对æ¾ä¸èµ·ï¼æ°å¢ç¬¬åç±»äºä»¶åªé卿¬åæ¹å® |
| | | |
| | | /** æ¯å¦å·²æ¤åï¼pull / WS 两路é½ä¼è° recallMessage æåæ¶æ¯æ´æ°ä¸º type=RECALLï¼æ¸²æåªéè¯å« type */ |
| | | const isRecall = computed(() => props.message.type === ImContentType.RECALL) |
| | | |
| | | /** æ¤åæç¤º segmentsï¼ä¾èµ activeConversation 宿¶ç® sender å */ |
| | | const recallTipSegments = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return buildRecallTipSegments( |
| | | props.message.senderId, |
| | | props.message.selfSend, |
| | | conversation?.type ?? 0, |
| | | conversation?.targetId ?? 0 |
| | | ) |
| | | }) |
| | | |
| | | /** æ¯å¦ä¼è¯å
好åäºä»¶æ°æ³¡ï¼FRIEND_ADD / FRIEND_DELETEï¼ */ |
| | | const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type)) |
| | | |
| | | /** 好åäºä»¶ segments */ |
| | | const friendChatTipSegments = computed(() => resolveFriendNotificationSegments(props.message)) |
| | | |
| | | /** æ¯å¦ç¾¤å¹¿æäºä»¶ï¼GROUP_CREATE..GROUP_BANNED 段ä½ï¼æé¤ GROUP_MEMBER_SETTING_UPDATE 个人信å·ï¼ */ |
| | | const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type)) |
| | | |
| | | /** 群广æäºä»¶ segments */ |
| | | const groupNotificationSegments = computed(() => |
| | | resolveGroupNotificationSegments(props.message, (id: number) => |
| | | getSenderDisplayName(id, ImConversationType.GROUP, props.message.targetId ?? 0) |
| | | ) |
| | | ) |
| | | |
| | | /** ç§è RTC_CALL_END èµ°ãåæ°æ³¡ãï¼å·¦å³åå¸ + çµè¯å¾æ + ææ¡ï¼ï¼éç§èåºæ¯ä¸º null */ |
| | | const rtcCallEndPrivatePayload = computed(() => { |
| | | if (props.message.type !== ImContentType.RTC_CALL_END) { |
| | | return null |
| | | } |
| | | const payload = parseRtcCallPayload(props.message.content) |
| | | return payload?.conversationType === ImConversationType.PRIVATE ? payload : null |
| | | }) |
| | | |
| | | /** æ¯å¦ç§èéè¯æ°æ³¡ */ |
| | | const isRtcCallPrivateBubbleMessage = computed(() => rtcCallEndPrivatePayload.value !== null) |
| | | |
| | | /** ç§èéè¯æ°æ³¡ææ¡ï¼æ operatorUserId æ¯å¦å½åç¨æ·åºåï¼å¯¹é½å¾®ä¿¡ä¸¤ç«¯ä¸åè§è§ï¼ */ |
| | | const rtcCallPrivateBubbleText = computed(() => |
| | | resolveRtcCallPrivateBubbleText(rtcCallEndPrivatePayload.value) |
| | | ) |
| | | |
| | | /** æ¯å¦ä¼è¯å
群éè¯äºä»¶å±
ä¸ tip */ |
| | | const isRtcCallTipMessage = computed(() => { |
| | | if (!isRtcCallTip(props.message.type)) { |
| | | return false |
| | | } |
| | | const payload = parseRtcCallPayload(props.message.content) |
| | | if (payload?.conversationType !== ImConversationType.GROUP) { |
| | | return false |
| | | } |
| | | return !isRtcCallPrivateBubbleMessage.value |
| | | }) |
| | | |
| | | /** éè¯äºä»¶ segmentsï¼ä»
群è tip ç¨ */ |
| | | const rtcCallTipSegments = computed(() => resolveRtcCallTipSegments(props.message)) |
| | | |
| | | // ==================== æ¶æ¯å
容解æ / payload ==================== |
| | | |
| | | /** å¼ç¨å¯¹è±¡ï¼æ°æ³¡å
åµå
¥å±ç¤ºï¼éå¼ç¨æ¶æ¯è¿å nullï¼æ¨¡æ¿ v-if 䏿¸²æ */ |
| | | const quote = computed(() => getQuoteFromMessage(props.message.content)) |
| | | |
| | | const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY) // MessagePanel 注å
¥çå¼¹çªè§¦å彿° |
| | | const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY) |
| | | const redialRtcCall = inject(IM_RTC_REDIAL_KEY) |
| | | |
| | | /** ç§è RTC_CALL_END æ°æ³¡ç¹å»ï¼ç¨å款 mediaType 鿍 */ |
| | | function handleRtcCallBubbleClick() { |
| | | const mediaType = rtcCallEndPrivatePayload.value?.mediaType |
| | | if (mediaType == null) { |
| | | return |
| | | } |
| | | redialRtcCall?.(mediaType) |
| | | } |
| | | |
| | | const multiSelect = useMessageMultiSelect() // å¤é模å¼ï¼æ¨¡å级åä¾ composable |
| | | |
| | | /** åå¹¶æ¶æ¯æ°æ³¡ç¹å»ï¼æå¼è¯¦æ
å¼¹çªï¼åµå¥åå¹¶ç±å¼¹çªå
é¨ push æ ï¼ */ |
| | | function handleMergeOpen(content: string) { |
| | | openMergeDetail?.(content) |
| | | } |
| | | |
| | | /** ææ¬æ°æ³¡ @ mention åéååï¼ä»
ç¾¤æ¶æ¯ææï¼æ atUserIds åæ¥ç¾¤æåç宿µç§°ï¼é TEXT ä¸èµ° store 读ï¼è®© getMentionCandidates ç´æ¥è¿å稳å®ç©ºæ°ç» */ |
| | | const textMentions = computed<MentionCandidate[]>(() => { |
| | | if (props.message.type !== ImContentType.TEXT) { |
| | | return getMentionCandidates(undefined, null) |
| | | } |
| | | return getMentionCandidates(props.message.atUserIds, conversationStore.activeConversation) |
| | | }) |
| | | |
| | | /** åçç¹å»ï¼ç¨æ·åçå¼¹ UserInfoCardï¼ç¾¤åçå¼¹ GroupInfoCardï¼å
¶å® targetTypeï¼å«æ¹å
èæ°æ®ï¼å¿½ç¥ */ |
| | | function handleCardClick(card: CardMessage, e: MouseEvent) { |
| | | if (!card?.targetId) { |
| | | return |
| | | } |
| | | if (card.targetType === ImConversationType.PRIVATE) { |
| | | uiStore.openUserInfoCardAtEvent( |
| | | { id: card.targetId, nickname: card.name, avatar: card.avatar }, |
| | | e, |
| | | ImFriendAddSource.CARD |
| | | ) |
| | | return |
| | | } |
| | | if (card.targetType === ImConversationType.GROUP) { |
| | | uiStore.openGroupInfoCardAtEvent( |
| | | { |
| | | id: card.targetId, |
| | | name: card.name, |
| | | showImage: card.avatar, |
| | | memberCount: card.memberCount |
| | | }, |
| | | e |
| | | ) |
| | | } |
| | | } |
| | | |
| | | /** åªä½ä¸ä¼ ä¸ï¼MessageBubble èªæ¸²æé®ç½© / è¿åº¦æ¡ï¼å¤å±åªç¨ä½ showSendingLoading å¤å® */ |
| | | const isUploading = computed(() => props.message.uploadProgress != null) |
| | | |
| | | /** |
| | | * æ¯å¦å¨æ°æ³¡å°¾é¨æ¾ç¤ºãåéä¸ãloading 转å |
| | | * |
| | | * å¾ç / è§é¢ / æä»¶æ°æ³¡å
åµå·²æè¿åº¦åé¦ï¼é®ç½© / è¿åº¦æ¡ï¼ï¼å¤å± loading ä¸åå å ï¼ |
| | | * è¯é³æ°æ³¡åªæéº¦å
é£ + æ¶é¿ï¼æ å
åµè¿åº¦ï¼å¿
é¡»ä¿çå¤å± loading è®©ç¨æ·æç¥æ£å¨åé |
| | | */ |
| | | const showSendingLoading = computed( |
| | | () => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value) |
| | | ) |
| | | |
| | | // ==================== åé人 / 已读 / @ ==================== |
| | | |
| | | /** 群è + å¯¹æ¹æ¶æ¯ æ¶ï¼å¨æ°æ³¡ä¸æ¹æ¾ç¤ºåéè
æµç§° */ |
| | | const showSenderName = computed(() => { |
| | | if (props.message.selfSend) { |
| | | return false |
| | | } |
| | | return conversationStore.activeConversation?.type === ImConversationType.GROUP |
| | | }) |
| | | |
| | | /** åéè
头åï¼ç§èç conversation.avatar å°±æ¯å¯¹æ¹å¤´åï¼openConversation å
¥å约å®ï¼ */ |
| | | const senderAvatar = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || props.message.selfSend) { |
| | | return '' |
| | | } |
| | | if (conversation.type === ImConversationType.GROUP) { |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || '' |
| | | } |
| | | return conversation.avatar || '' |
| | | }) |
| | | |
| | | /** 头åè²å¡ fallback ææ¬ï¼æ°¸è¿æ¯ç宿µç§°ï¼ä¸æºå¤æ³¨ */ |
| | | const senderRealNickname = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return getSenderRealNickname( |
| | | props.message.senderId, |
| | | conversation?.type ?? 0, |
| | | conversation?.targetId ?? 0 |
| | | ) |
| | | }) |
| | | |
| | | /** æ°æ³¡ä¸æ¹åé人æ¾ç¤ºåï¼ä»
群èå¯¹æ¹æ¶æ¯æ¾ç¤ºï¼ï¼å¥½å夿³¨ > ç¾¤å¤æ³¨ > ç宿µç§° */ |
| | | const senderDisplayName = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return getSenderDisplayName( |
| | | props.message.senderId, |
| | | conversation?.type ?? 0, |
| | | conversation?.targetId ?? 0 |
| | | ) |
| | | }) |
| | | |
| | | /** ç§èã已读 / æªè¯»ãæï¼ä»
对èªå·±åéçç§èæ¶æ¯å±ç¤ºï¼ç§è已读å
¨å±å
³éæ¶ä¸åå±ç¤ºï¼ */ |
| | | const privateReadLabel = computed(() => { |
| | | if (!MESSAGE_PRIVATE_READ_ENABLED) { |
| | | return '' |
| | | } |
| | | if (!props.message.selfSend) { |
| | | return '' |
| | | } |
| | | if (conversationStore.activeConversation?.type !== ImConversationType.PRIVATE) { |
| | | return '' |
| | | } |
| | | if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) { |
| | | return '已读' |
| | | } |
| | | if (props.message.receiptStatus === ImMessageReceiptStatus.PENDING) { |
| | | return 'æªè¯»' |
| | | } |
| | | return '' |
| | | }) |
| | | |
| | | /** æ¯å¦éè¦æ¾ç¤ºç¾¤åæ§ popoverï¼èªå·±åçç¾¤æ¶æ¯ä¸å端å¼å¯äºåæ§ï¼NO_RECEIPT 表示åéæ¶æªè¦æ±åæ§ï¼ä¸æ¸²æï¼ç¾¤å·²è¯»å
¨å±å
³éæ¶ç»ä¸ä¸å±ç¤ºï¼ */ |
| | | const showGroupReadStatus = computed(() => { |
| | | if (!MESSAGE_GROUP_READ_ENABLED) { |
| | | return false |
| | | } |
| | | if (!props.message.selfSend) { |
| | | return false |
| | | } |
| | | if (conversationStore.activeConversation?.type !== ImConversationType.GROUP) { |
| | | return false |
| | | } |
| | | const status = props.message.receiptStatus |
| | | if (status === undefined || status === null) { |
| | | return false |
| | | } |
| | | return status !== ImMessageReceiptStatus.NO_RECEIPT |
| | | }) |
| | | |
| | | /** å½å群æåï¼ä¾ MessageReadStatus è®¡ç®æªè¯»ååï¼æªå è½½å®æ¶å
åºç©ºæ°ç»ä¸æ¸²æï¼ */ |
| | | const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return [] |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return (group?.members || []).map((member) => { |
| | | const friend = friendStore.getFriend(member.userId) |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status |
| | | } |
| | | }) |
| | | }) |
| | | |
| | | /** æ¯å¦ @æï¼ç¾¤æ¶æ¯å±ç¤ºå°å¾½æ ï¼ */ |
| | | const isAtMe = computed(() => { |
| | | const myId = getCurrentUserId() |
| | | if (!myId) { |
| | | return false |
| | | } |
| | | return (props.message.atUserIds || []).includes(myId) |
| | | }) |
| | | |
| | | // ==================== å³é®èå / æä½ ==================== |
| | | |
| | | // å³é®èå key 常éï¼push 端ååå端ä»åä¸å¤åï¼typo ç¼è¯æå°±è½æ |
| | | const MENU_KEYS = { |
| | | COPY: 'COPY', |
| | | REPLY: 'REPLY', |
| | | FORWARD: 'FORWARD', |
| | | MULTI_SELECT: 'MULTI_SELECT', |
| | | PIN: 'PIN', |
| | | ADD_TO_FACE: 'ADD_TO_FACE', |
| | | MUTE: 'MUTE', |
| | | UNMUTE: 'UNMUTE', |
| | | KICK: 'KICK', |
| | | RECALL: 'RECALL', |
| | | DELETE: 'DELETE' |
| | | } as const |
| | | type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS] |
| | | |
| | | /** |
| | | * å³é®èåé¡¹ï¼ |
| | | * - å¼ç¨ï¼å·²è½åº + æªæ¤åçæ¶æ¯å¯å¼ç¨ï¼å¼ç¨ååå
¥ä¼è¯è稿 |
| | | * - æ¤å / å é¤ï¼äºæ¥ï¼èªå·±åé + å·²è½åº + æªæ¤å + 2 åéå
æ¾ç¤ºãæ¤åãï¼æ¨æå¡å¨ï¼ï¼å
¶å®æ¾ç¤ºãå é¤ãï¼ä»
æ¬å°æ¸
ï¼ |
| | | * |
| | | * 好åäºä»¶æ°æ³¡æä¸å¼¹èå |
| | | */ |
| | | async function handleContextMenu(e: MouseEvent) { |
| | | if (isFriendChatTipMessage.value) { |
| | | return |
| | | } |
| | | // å¤é模å¼ä¸ä¸å¼¹èåï¼å³é®ç¹å»å°±å½åæ¢å¾éï¼ä¸åå»è¡ä¸ºä¸è´ |
| | | if (isInMultiSelect.value) { |
| | | if (canForward.value) { |
| | | multiSelect.toggle(props.message) |
| | | } |
| | | return |
| | | } |
| | | |
| | | type MenuItem = { |
| | | danger?: boolean |
| | | disabled?: boolean |
| | | divided?: boolean |
| | | icon?: string |
| | | key: MenuKey |
| | | name: string |
| | | } |
| | | // é¢éï¼å
¬ä¼å·ï¼ä¼è¯ååæ¶æ¯ï¼åªä¿ç转å + æ¬å°å é¤ï¼ä¸æ¯æå¤å¶ / å¼ç¨ / å¤é / æ¤å / 群管çæä½ |
| | | if (isChannelConversation.value) { |
| | | const channelItems: MenuItem[] = [] |
| | | if (canForward.value) { |
| | | channelItems.push({ |
| | | key: MENU_KEYS.FORWARD, |
| | | name: '转å', |
| | | icon: 'ant-design:share-alt-outlined' |
| | | }) |
| | | } |
| | | channelItems.push({ |
| | | key: MENU_KEYS.DELETE, |
| | | name: 'å é¤', |
| | | icon: 'ant-design:delete-outlined', |
| | | divided: true, |
| | | danger: true |
| | | }) |
| | | uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, channelItems, async (item) => { |
| | | if (item.key === MENU_KEYS.FORWARD) { |
| | | handleForward() |
| | | } else if (item.key === MENU_KEYS.DELETE) { |
| | | handleDelete() |
| | | } |
| | | }) |
| | | return |
| | | } |
| | | |
| | | const items: MenuItem[] = [] |
| | | // ãå¤å¶ãï¼ä»
ææ¬æ¶æ¯æ¯æï¼æ¾å¨ç¬¬ä¸é¡¹ï¼å¯¹é½å¾®ä¿¡æ¡é¢å³é®ä¹ æ¯ |
| | | if (props.message.type === ImContentType.TEXT) { |
| | | items.push({ |
| | | key: MENU_KEYS.COPY, |
| | | name: 'å¤å¶', |
| | | icon: 'ant-design:copy-outlined' |
| | | }) |
| | | } |
| | | // ãå¼ç¨ãï¼å·²è½åº + æªæ¤å + éå并转åï¼MERGE å
åµå¿«ç
§å¨å¼ç¨é¢è§éæ æ³é级å±ç¤º |
| | | if (!!props.message.id && !isRecall.value && !isMerge.value) { |
| | | items.push({ |
| | | key: MENU_KEYS.REPLY, |
| | | name: 'å¼ç¨', |
| | | icon: 'bxs:quote-alt-left' |
| | | }) |
| | | } |
| | | // ã转åããå¤éãï¼å·²è½åº + æ®éæ¶æ¯ + æªæ¤åï¼è§¦å ForwardDialog / è¿å
¥å¤éæ¨¡å¼ |
| | | if (canForward.value) { |
| | | items.push({ |
| | | key: MENU_KEYS.FORWARD, |
| | | name: '转å', |
| | | icon: 'ant-design:share-alt-outlined' |
| | | }, { |
| | | key: MENU_KEYS.MULTI_SELECT, |
| | | name: 'å¤é', |
| | | icon: 'ant-design:check-square-outlined' |
| | | }) |
| | | } |
| | | // ã置顶ãï¼ä»
群è + æ®éæ¶æ¯ + å·²è½åº + æªæ¤å + 群主æç®¡çåï¼å·²ç½®é¡¶ä¸åå±ç¤ºï¼ç±ç½®é¡¶é¢æ¿çãç§»é¤ãå
¥å£æ¿æ¥ |
| | | // 䏿¬å°é¢å¤ä¸éï¼è®©åç«¯æ ¡éªï¼è¶
éæ¶éè¿ error toast åé¦ |
| | | if (canPin.value) { |
| | | items.push({ |
| | | key: MENU_KEYS.PIN, |
| | | name: '置顶', |
| | | icon: 'ant-design:pushpin-outlined' |
| | | }) |
| | | } |
| | | // ãæ·»å å°è¡¨æ
ãï¼FACE / IMAGE æ¶æ¯ + å·²è½åº + æªæ¤åï¼åå
¥ä¸ªäººè¡¨æ
å
ï¼å¯¹ç
§å¾®ä¿¡ãæ·»å å°è¡¨æ
ã |
| | | if (canAddToFace.value) { |
| | | items.push({ |
| | | key: MENU_KEYS.ADD_TO_FACE, |
| | | name: 'æ·»å å°è¡¨æ
', |
| | | icon: 'ant-design:smile-outlined' |
| | | }) |
| | | } |
| | | // ãç¦è¨ / è§£ç¦ / ç§»é¤ãï¼ç¾¤è + éèªå·±æ¶æ¯ + ææ¯ç¾¤ä¸»æç®¡çå |
| | | if (currentGroup.value && !props.message.selfSend && canManageSender.value) { |
| | | const senderMember = currentGroup.value.members?.find( |
| | | (m) => m.userId === props.message.senderId |
| | | ) |
| | | const isMuted = senderMember?.muteEndTime && new Date(senderMember.muteEndTime) > new Date() |
| | | if (isMuted) { |
| | | items.push({ |
| | | key: MENU_KEYS.UNMUTE, |
| | | name: 'è§£é¤ç¦è¨', |
| | | icon: 'ant-design:audio-outlined', |
| | | divided: true |
| | | }) |
| | | } else { |
| | | items.push({ |
| | | key: MENU_KEYS.MUTE, |
| | | name: 'ç¦è¨', |
| | | icon: 'ant-design:audio-muted-outlined', |
| | | divided: true |
| | | }) |
| | | } |
| | | items.push({ |
| | | key: MENU_KEYS.KICK, |
| | | name: 'ç§»é¤', |
| | | icon: 'ant-design:user-delete-outlined', |
| | | danger: true |
| | | }) |
| | | } |
| | | // ãæ¤å / å é¤ãäºéä¸ï¼ |
| | | // - èªå·±åé + å·²è½åº + æªæ¤å + 卿¤åçªå£å
â æ¤åï¼æ¨æå¡å¨ææ¶æ¯æç½® RECALLï¼ |
| | | // - å
¶å®ï¼å¯¹æ¹æ¶æ¯ / å·²æ¤å / è¶
åºæ¤åçªå£ï¼â å é¤ï¼ä»
æ¬å°æ¸
ï¼ä¸å¨åç«¯ï¼ |
| | | // divided æè¿ä¸é¡¹åä¸é¢çãå¼ç¨ãéå¼ï¼danger æ¾çº¢å¯¹é½å¾®ä¿¡ |
| | | const canRecall = |
| | | props.message.selfSend && |
| | | !!props.message.id && |
| | | !isRecall.value && |
| | | Date.now() - props.message.sendTime <= MESSAGE_RECALL_WINDOW_MS |
| | | if (canRecall) { |
| | | items.push({ |
| | | key: MENU_KEYS.RECALL, |
| | | name: 'æ¤å', |
| | | icon: 'ant-design:undo-outlined', |
| | | divided: true, |
| | | danger: true |
| | | }) |
| | | } else { |
| | | items.push({ |
| | | key: MENU_KEYS.DELETE, |
| | | name: 'å é¤', |
| | | icon: 'ant-design:delete-outlined', |
| | | divided: true, |
| | | danger: true |
| | | }) |
| | | } |
| | | |
| | | // æè忏²æäº¤ç»å
¨å± uiStoreï¼åä¾ï¼é¿å
æ¯æ¡æ¶æ¯é½æä¸ä»½èå DOMï¼ |
| | | const menuHandlers: Record<MenuKey, () => Promise<void> | void> = { |
| | | [MENU_KEYS.COPY]: handleCopy, |
| | | [MENU_KEYS.REPLY]: handleReply, |
| | | [MENU_KEYS.FORWARD]: handleForward, |
| | | [MENU_KEYS.MULTI_SELECT]: handleEnterMultiSelect, |
| | | [MENU_KEYS.PIN]: handlePin, |
| | | [MENU_KEYS.ADD_TO_FACE]: handleAddToFace, |
| | | [MENU_KEYS.MUTE]: handleMute, |
| | | [MENU_KEYS.UNMUTE]: handleUnmute, |
| | | [MENU_KEYS.KICK]: handleKick, |
| | | [MENU_KEYS.RECALL]: handleRecall, |
| | | [MENU_KEYS.DELETE]: handleDelete |
| | | } |
| | | uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => { |
| | | await menuHandlers[item.key as MenuKey]?.() |
| | | }) |
| | | } |
| | | |
| | | /** æ¯å¦å¯ãæ·»å å°è¡¨æ
ãï¼FACE / IMAGE æ¶æ¯ + å·²è½åº + æªæ¤åï¼GIF / éå¾é½å
è®¸ï¼ */ |
| | | const canAddToFace = computed(() => { |
| | | if (isRecall.value || !props.message.id) { |
| | | return false |
| | | } |
| | | return extractAddableFace(props.message) !== null |
| | | }) |
| | | |
| | | /** æ·»å å°ä¸ªäººè¡¨æ
ï¼ä» message æ½ url + 尺寸 + name åå
¥ä¸ªäººè¡¨æ
åº */ |
| | | async function handleAddToFace() { |
| | | const payload = extractAddableFace(props.message) |
| | | if (!payload) { |
| | | return |
| | | } |
| | | const data = await faceStore.addFaceUserItem(payload) |
| | | if (data) { |
| | | antdMessage.success('已添å å°è¡¨æ
') |
| | | } |
| | | } |
| | | |
| | | /** å½åæ¿æ´»ä¼è¯å¯¹åºç群ï¼ç§èåºæ¯ä¸º undefinedï¼ */ |
| | | const currentGroup = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return undefined |
| | | } |
| | | return groupStore.getGroup(conversation.targetId) |
| | | }) |
| | | |
| | | /** å½åç¨æ·å¨è¯¥ç¾¤éçè§è²ï¼ç§èæé群æå â undefined */ |
| | | const myGroupRole = computed(() => { |
| | | const myId = getCurrentUserId() |
| | | return currentGroup.value?.members?.find((m) => m.userId === myId)?.role |
| | | }) |
| | | |
| | | /** æ¯å¦å¯ç®¡çè¯¥æ¶æ¯åéäººï¼æçè§è²é«äºç®æ è§è²ï¼ç¾¤ä¸» > 管çå > æ®éæåï¼ï¼ç®æ è§è²æªç¥æ¶ä¸å±ç¤º */ |
| | | const canManageSender = computed(() => { |
| | | // åå²éç¾¤ç¾¤ï¼æ¬å°å¯è½æ®çæåç¼åï¼æ¾å¼æé¤ï¼é¿å
å³é®ä»åºãç¦è¨ / ç§»é¤ã |
| | | if (!currentGroup.value || isGroupQuit(currentGroup.value) || !myGroupRole.value) { |
| | | return false |
| | | } |
| | | const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId) |
| | | // æåç¼åä¸å®æ´æ¶ä¸å±ç¤ºç®¡çæä½ï¼çæåæ°æ®è¡¥é½åå夿 |
| | | if (!senderMember?.role) { |
| | | return false |
| | | } |
| | | // è§è²æ°å¼ï¼1=群主 2=管çå 3=æ®éæåï¼æ°å¼è¶å°æéè¶é« |
| | | return myGroupRole.value < senderMember.role |
| | | }) |
| | | |
| | | /** æ¯å¦å
许转å / å¤éï¼æ®éæ¶æ¯ + å·²è½åº + æªæ¤åï¼æ¬å°å ä½ / æ¤å / äºä»¶æ¶æ¯ä¸å¾ä¸å¯ */ |
| | | const canForward = computed( |
| | | () => isNormalMessage(props.message.type) && !!props.message.id && !isRecall.value |
| | | ) |
| | | |
| | | /** å¤éæ¨¡å¼æ¯å¦æ¿æ´»ï¼åæ¢ä¼è¯ç± index.vue ä¸»å¨ exitMultiSelect */ |
| | | const isInMultiSelect = computed(() => multiSelect.state.active) |
| | | |
| | | /** å½åæ¶æ¯æ¯å¦å·²å¾é */ |
| | | const isMessageChecked = computed(() => { |
| | | if (!isInMultiSelect.value) { |
| | | return false |
| | | } |
| | | return multiSelect.selectedIdSet.value.has(props.message.clientMessageId) |
| | | }) |
| | | |
| | | /** å¤é模å¼ä¸ç¹å»æ°æ³¡ï¼å¯è½¬åçæ¶æ¯åæ¢å¾éï¼äºä»¶ / æ¤å / å ä½çä¸ååºï¼ç´æ¥åæç¹å»é¿å
触åå¾çé¢è§çï¼ */ |
| | | function handleMultiSelectClick(e: MouseEvent) { |
| | | if (!isInMultiSelect.value) { |
| | | return |
| | | } |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | if (!canForward.value) { |
| | | return |
| | | } |
| | | multiSelect.toggle(props.message) |
| | | } |
| | | |
| | | /** æ¯å¦å
许置顶ï¼å·²ç½®é¡¶æ¶æ¯ä¸åå±ç¤ºèå项ï¼ç±ç½®é¡¶é¢æ¿çãç§»é¤ãå
¥å£æ¿æ¥ï¼ï¼ç¾¤è + æ®éæ¶æ¯ + å·²è½åº + æªæ¤å + 群主æç®¡çå + æªç½®é¡¶ */ |
| | | const canPin = computed( |
| | | () => |
| | | !!currentGroup.value && |
| | | !isGroupQuit(currentGroup.value) && |
| | | isNormalMessage(props.message.type) && |
| | | !!props.message.id && |
| | | !isRecall.value && |
| | | (myGroupRole.value === ImGroupMemberRole.OWNER || |
| | | myGroupRole.value === ImGroupMemberRole.ADMIN) && |
| | | !currentGroup.value.pinnedMessages?.some((m) => m.id === props.message.id) |
| | | ) |
| | | |
| | | /** ç½®é¡¶æ¶æ¯ï¼äºæ¬¡ç¡®è®¤ â è°å端 pin-messageï¼å端广æ GROUP_MESSAGE_PINï¼æ¬ç«¯ dispatcher æææ° pinnedMessages */ |
| | | async function handlePin() { |
| | | const group = currentGroup.value |
| | | if (!group || !props.message.id) { |
| | | return |
| | | } |
| | | try { |
| | | await confirm('å°å¨å½å群æåçè天ä¸ç½®é¡¶', 'ç½®é¡¶æ¶æ¯') |
| | | await apiPinGroupMessage({ id: group.id, messageId: props.message.id }) |
| | | antdMessage.success('已置顶') |
| | | } catch {} |
| | | } |
| | | |
| | | /** å¤å¶ææ¬æ¶æ¯ï¼è§£åº content åæ®µåå
¥åªè´´æ¿ï¼æç¤ºãå
容已å¤å¶å°åªè´´æ¿ã */ |
| | | async function handleCopy() { |
| | | const text = parseMessage<TextMessage>(props.message.content)?.content |
| | | if (!text) { |
| | | return |
| | | } |
| | | await copyToClipboard(text) |
| | | antdMessage.success('å
容已å¤å¶å°åªè´´æ¿') |
| | | } |
| | | |
| | | /** è¿å
¥å¼ç¨æ¨¡å¼ï¼æå½åæ¶æ¯æé æ QuoteMessage åå
¥ä¼è¯è稿 */ |
| | | function handleReply() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | conversationStore.setConversationReplyDraft(conversation, buildQuoteFromMessage(props.message)) |
| | | } |
| | | |
| | | /** 转åå½åæ¶æ¯ï¼æå¼ ForwardDialogï¼åæ¡æ¨¡å¼ï¼mode=single å³åæ ·è½¬ï¼ */ |
| | | function handleForward() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | openForwardDialog?.({ |
| | | mode: ImForwardMode.SINGLE, |
| | | messages: [props.message], |
| | | sourceConversation: conversation |
| | | }) |
| | | } |
| | | |
| | | /** è¿å
¥å¤é模å¼ï¼åå§å¾éå½åæ¶æ¯ */ |
| | | function handleEnterMultiSelect() { |
| | | multiSelect.enter(props.message) |
| | | } |
| | | |
| | | /** |
| | | * æ¤åæ¶æ¯ï¼å¼¹ç¡®è®¤æ¡ â è° useMessageSender.recall â å端éè¿ WS RECALL äºä»¶æ¨åï¼ |
| | | * websocketStore æå¯¹åº message ç type æ¹æ RECALLï¼UI èªå¨åå°"XX æ¤åäºä¸æ¡æ¶æ¯" |
| | | * |
| | | * ä¸åä¹è§æ¤åï¼å¤±è´¥ / è¶
æ¶ / å端æç»æ¶æ¬ç«¯ç¶æå¯è½ä¸æå¡ç«¯æ¼ç§»ï¼ç»ä¸è®© WS 忍æç¨³ |
| | | */ |
| | | async function handleRecall() { |
| | | try { |
| | | await confirm('ç¡®å®è¦æ¤åè¿æ¡æ¶æ¯åï¼', 'æ¤åæ¶æ¯') |
| | | await recall(props.message) |
| | | } catch {} |
| | | } |
| | | |
| | | /** |
| | | * å¤±è´¥æ¶æ¯ç¹å»éè¯ |
| | | * |
| | | * - åªä½æ¶æ¯ï¼image / file / voice / videoï¼ï¼_localFile å¨å
åå°±éèµ° uploadAndSendMediaï¼éæ°ä¸ä¼ + å ä½ + è¿åº¦ï¼ |
| | | * - ææ¬æ¶æ¯ï¼å¤ç¨å clientMessageId + status åæ»å° SENDINGï¼èµ° existingClientMessageId è·¯å¾è®©æå¡ç«¯æ cmid å¹ç |
| | | * |
| | | * åªä½ç±»åè¥ _localFile 已丢ï¼çè®ºä¸ IDB æ¢å¤é¶æ®µå°±è¢« dropï¼è¿ä¸å°è¿éï¼ä¿é©èµ·è§ä»èµ°ææ¬å
åºï¼åæææ¬è·¯å¾éåï¼ |
| | | * å端æç»å¤±æ blob URL æ¶å次 FAILEDï¼ç¨æ·å¯å³é®å é¤ |
| | | * |
| | | * ä¸è¿åå receiptï¼ç¾¤åæ§æ¯åéæ¶çæ©å±é项ãä¸ä¼æä¹
åå° messageï¼å¼ºè¡çæµå¯è½ä¸åæä¸ç¬¦ï¼ |
| | | * é»è®¤æ"æ åæ§"éåï¼ç»å¤§å¤æ°åºæ¯ç¬¦å颿ï¼è¦åæ§å°±éæ°å䏿¬¡æ´ç´è§ |
| | | */ |
| | | async function handleResend() { |
| | | if (props.message.status !== ImMessageStatus.FAILED) { |
| | | return |
| | | } |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | // ç¦è¨ / å°ç¦æ¶æ¦æªï¼é¿å
éè¯ç»è¿ MessageInput ç muteOverlay åèµ°ä¸æ¬¡ sendRaw 让å端æ |
| | | if (muteOverlay.value) { |
| | | return |
| | | } |
| | | const message = props.message |
| | | const file = message._localFile |
| | | |
| | | // åªä½ç±»å + _localFile å¨ â éèµ° uploadAndSendMediaï¼type åå + æ§å
æ°æ®å¤ç¨ç»ä¸å¨ mediaTypeHandlers 表é |
| | | if (isMediaMessageType(message.type) && file) { |
| | | const handler = mediaTypeHandlers[message.type] |
| | | if (handler) { |
| | | const oldQuote = getQuoteFromMessage(message.content) ?? undefined |
| | | const context = handler.extractResendContext(message.content) |
| | | await uploadAndSendMedia({ |
| | | file, |
| | | type: message.type, |
| | | quote: oldQuote, |
| | | conversation, |
| | | context, |
| | | existingClientMessageId: message.clientMessageId |
| | | }) |
| | | return |
| | | } |
| | | } |
| | | |
| | | // ææ¬ç±»å / åªä½ç±»åä½ _localFile å·²ä¸¢ï¼æ FAILED å ä½åæ»å° SENDINGï¼å¤ç¨ clientMessageId 让æå¡ç«¯æ cmid å¹çå»é |
| | | messageStore.patchMessage(conversation.type, conversation.targetId, message.clientMessageId, { |
| | | status: ImMessageStatus.SENDING |
| | | }) |
| | | await sendRaw(message.type, message.content, { |
| | | atUserIds: message.atUserIds, |
| | | existingClientMessageId: message.clientMessageId |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * å 餿¶æ¯ï¼æ¬å°è½¯å ï¼ä»
ä» messageStore ç§»é¤ï¼ä¸è°å端 |
| | | * åºå«äº"æ¤å"ï¼æå¡ç«¯æ²¡å¨ï¼å¤ç«¯ç»å½æ¶å
¶å®å®¢æ·ç«¯ / 群éå
¶ä»äººä¾ç¶è½çå°è¿æ¡ |
| | | */ |
| | | /** ç¦è¨ï¼emit ç»ç¶ç»ä»¶æå¼æ¶é¿éæ©å¼¹çªï¼é¿å
MessageItem è¿éï¼ */ |
| | | function handleMute() { |
| | | const group = currentGroup.value |
| | | if (!group) { |
| | | return |
| | | } |
| | | const name = senderDisplayName.value || '该æå' |
| | | emit('mute', group.id, props.message.senderId, name) |
| | | } |
| | | |
| | | /** è§£é¤ç¦è¨ */ |
| | | async function handleUnmute() { |
| | | const group = currentGroup.value |
| | | if (!group) { |
| | | return |
| | | } |
| | | try { |
| | | await confirm('ç¡®å®è§£é¤è¯¥æåçç¦è¨åï¼', 'è§£é¤ç¦è¨') |
| | | await cancelMuteMember({ id: group.id, userId: props.message.senderId }) |
| | | antdMessage.success('已解é¤ç¦è¨') |
| | | emit('reload') |
| | | } catch {} |
| | | } |
| | | |
| | | /** ç§»é¤ç¾¤æå */ |
| | | async function handleKick() { |
| | | const group = currentGroup.value |
| | | if (!group) { |
| | | return |
| | | } |
| | | const name = senderDisplayName.value || '该æå' |
| | | try { |
| | | await confirm(`ç¡®å®å°ã${name}ãç§»åºç¾¤èåï¼`, 'ç§»é¤æå') |
| | | await removeGroupMember({ groupId: group.id, memberUserIds: [props.message.senderId] }) |
| | | antdMessage.success('已移é¤') |
| | | emit('reload') |
| | | } catch {} |
| | | } |
| | | |
| | | function handleDelete() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | messageStore.removeMessage(conversation.type, conversation.targetId, { |
| | | id: props.message.id, |
| | | clientMessageId: props.message.clientMessageId |
| | | }) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- æ¶é´åéæ¡ï¼åè¡¨ç¬¬ä¸æ¡ / è·ä¸ä¸æ¡è¶
è¿é弿¶å±
䏿¾ç¤ºç°è²æ¶é´ --> |
| | | <div |
| | | v-if="shouldShowTimeTip" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ formatTimeTip(message.sendTime) }} |
| | | </div> |
| | | |
| | | <!-- 好åä¼è¯äºä»¶ï¼FRIEND_ADD / FRIEND_DELETEï¼ï¼è·ç¾¤å¹¿æäºä»¶åç°è²æ ·å¼ï¼ææ¡åºå® --> |
| | | <div |
| | | v-if="isFriendChatTipMessage" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="friendChatTipSegments" /> |
| | | </div> |
| | | |
| | | <!-- 群广æäºä»¶ï¼è·å¥½åäºä»¶åç°è²æ ·å¼ï¼mention 段æç¹å»å¼¹ UserInfoCard --> |
| | | <div |
| | | v-else-if="isGroupNotificationMessage" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="groupNotificationSegments" /> |
| | | </div> |
| | | |
| | | <!-- 群éè¯äºä»¶ï¼RTC_CALL_START / RTC_CALL_ENDï¼ï¼å±
ä¸ç°è² tip ä¸¤æ®µå¼ --> |
| | | <div |
| | | v-else-if="isRtcCallTipMessage" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="rtcCallTipSegments" /> |
| | | </div> |
| | | |
| | | <!-- ç§èéè¯ç»æï¼RTC_CALL_ENDï¼ï¼ä»¿å¾®ä¿¡ãåæ°æ³¡ãï¼æ selfSend å·¦å³åå¸ï¼çµè¯å¾æ + ææ¡ --> |
| | | <div v-else-if="isRtcCallPrivateBubbleMessage" class="flex gap-2 items-start px-4 py-2"> |
| | | <div |
| | | class="flex flex-1 min-w-0 gap-2 items-start" |
| | | :class="{ 'flex-row-reverse': message.selfSend }" |
| | | > |
| | | <UserAvatar |
| | | :id="message.selfSend ? getCurrentUserId() : message.senderId" |
| | | :name="senderRealNickname" |
| | | :url="message.selfSend ? userStore.userInfo?.avatar : senderAvatar" |
| | | :size="36" |
| | | /> |
| | | <div |
| | | class="flex gap-2 items-center px-3.5 py-2 text-sm rounded-lg cursor-pointer" |
| | | :class=" |
| | | message.selfSend |
| | | ? 'text-black bg-[#95ec69]' |
| | | : 'text-[var(--ant-color-text)] bg-[var(--ant-color-fill-secondary)]' |
| | | " |
| | | @click="handleRtcCallBubbleClick" |
| | | > |
| | | <Icon icon="ant-design:phone-outlined" :size="16" class="rotate-[135deg] flex-shrink-0" /> |
| | | <span class="whitespace-nowrap">{{ rtcCallPrivateBubbleText }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- æ¤åæ¶æ¯ï¼æ´è¡ç°è² tipï¼sender åæ®µå¯ç¹å» --> |
| | | <div |
| | | v-else-if="isRecall" |
| | | class="flex items-center justify-center px-4 py-2 text-12px text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <TipSegments :segments="recallTipSegments" /> |
| | | </div> |
| | | |
| | | <!-- æ®éæ¶æ¯æ°æ³¡ --> |
| | | <div |
| | | v-else |
| | | class="flex gap-2 items-start px-4 py-2" |
| | | :class="{ 'cursor-pointer': isInMultiSelect }" |
| | | @contextmenu.prevent="handleContextMenu" |
| | | @click.capture="handleMultiSelectClick" |
| | | > |
| | | <!-- å¤éæç¤ºï¼æ°¸è¿å¨æ´è¡æå·¦ä¾§ï¼èªå·± / å¯¹æ¹æ¶æ¯ä¸è´ï¼ï¼ä¸åä¸ selfSend reverse --> |
| | | <span |
| | | v-if="isInMultiSelect" |
| | | class="flex flex-shrink-0 items-center justify-center mt-3 w-5 h-5 rounded-full transition-colors" |
| | | :class=" |
| | | isMessageChecked |
| | | ? 'bg-[#07c160]' |
| | | : 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]' |
| | | " |
| | | > |
| | | <Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" /> |
| | | </span> |
| | | |
| | | <!-- æ¶æ¯è¡ï¼å¤´å + æ°æ³¡å
鍿 selfSend reverseï¼é¢éç´ æä»¿å
¬ä¼å·æ ·å¼å±
ä¸ä¸ä¸æ¾ç¤ºå¤´å --> |
| | | <div |
| | | class="flex flex-1 min-w-0 gap-2 items-start" |
| | | :class="{ 'flex-row-reverse': message.selfSend, 'justify-center': isMaterial }" |
| | | > |
| | | <!-- 头åï¼ç¹å»å¼¹ UserInfoCard ç± UserAvatar å
鍿¿æ¥ï¼é¢éç´ ææ¶æ¯ä¸æ¾ç¤ºå¤´å --> |
| | | <UserAvatar |
| | | v-if="!isMaterial" |
| | | :id="message.selfSend ? getCurrentUserId() : message.senderId" |
| | | :name="senderRealNickname" |
| | | :url="message.selfSend ? userStore.userInfo?.avatar : senderAvatar" |
| | | :size="36" |
| | | /> |
| | | |
| | | <div |
| | | class="flex flex-col gap-0.5" |
| | | :class="[ |
| | | message.selfSend ? 'items-end' : '', |
| | | // å
¬ä¼å·ä¼è¯å
ç´ æï¼åºå® 360 宽ï¼å¯¹é½å¾®ä¿¡å
¬ä¼å·å¡çï¼ï¼å
¶å®ï¼å«ç§è / 群è转åçç´ æï¼ï¼70% ä¸éï¼æ°æ³¡èªå·±æå®½åº¦ |
| | | isMaterial ? 'w-[360px]' : 'max-w-[70%]' |
| | | ]" |
| | | > |
| | | <!-- 群èå¯¹æ¹æ¶æ¯ï¼æ°æ³¡ä¸æ¹æ¾ç¤ºåéè
æµç§° --> |
| | | <div |
| | | v-if="showSenderName" |
| | | class="mb-0.5 text-12px text-[var(--ant-color-text-secondary)] leading-tight" |
| | | > |
| | | {{ senderDisplayName }} |
| | | </div> |
| | | <div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }"> |
| | | <!-- æ¶æ¯å
å®¹ï¼æ type èµ° 9 ç±»æ°æ³¡ï¼ç»ä¸ç± MessageBubble 渲æ --> |
| | | <MessageBubble |
| | | :type="message.type" |
| | | :content="message.content" |
| | | :self-send="message.selfSend" |
| | | :upload-progress="message.uploadProgress" |
| | | :mentions="textMentions" |
| | | @click-card="handleCardClick" |
| | | @open-merge="handleMergeOpen" |
| | | /> |
| | | |
| | | <!-- ç¶æåºï¼èªå·±æ¶æ¯å±ç¤ºåéç¶æ + 已读/ç¾¤åæ§ï¼å¯¹æ¹æ¶æ¯ + @èªå·±æ¶å±ç¤º @å¾½æ --> |
| | | <div class="flex gap-1.5 items-center text-base"> |
| | | <template v-if="message.selfSend"> |
| | | <Icon |
| | | v-if="showSendingLoading" |
| | | icon="ant-design:loading-outlined" |
| | | class="im-loading-spin" |
| | | /> |
| | | <Icon |
| | | v-else-if="message.status === ImMessageStatus.FAILED" |
| | | icon="ant-design:warning-filled" |
| | | color="#f56c6c" |
| | | class="cursor-pointer" |
| | | title="åé失败ï¼ç¹å»éè¯" |
| | | @click="handleResend" |
| | | /> |
| | | <!-- 已读æï¼ç§èï¼ --> |
| | | <span |
| | | v-else-if="privateReadLabel" |
| | | class="text-12px whitespace-nowrap" |
| | | :class=" |
| | | message.receiptStatus === ImMessageReceiptStatus.DONE |
| | | ? 'text-[#409eff]' |
| | | : 'text-[var(--ant-color-text-secondary)]' |
| | | " |
| | | > |
| | | {{ privateReadLabel }} |
| | | </span> |
| | | <!-- ç¾¤åæ§ï¼ç¹å»å¼¹ popover å±ç¤ºå·²è¯» / æªè¯»æåå表 --> |
| | | <MessageReadStatus |
| | | v-else-if="showGroupReadStatus" |
| | | :message="message" |
| | | :group-id="conversationStore.activeConversation?.targetId || 0" |
| | | :group-members="groupMembersForReadStatus" |
| | | class="text-12px whitespace-nowrap text-[var(--ant-color-text-secondary)]" |
| | | /> |
| | | </template> |
| | | <!-- 群 @ ææç¤º --> |
| | | <Tag |
| | | v-if="!message.selfSend && isAtMe" |
| | | danger |
| | | size="small" |
| | | class="ml-1" |
| | | > |
| | | @æ |
| | | </Tag> |
| | | </div> |
| | | </div> |
| | | <!-- å¼ç¨åï¼æ°æ³¡ä¸æ¹ï¼selfSend æ¶ç«çº¿å¨å³ä¾§ --> |
| | | <ReplyPreview |
| | | v-if="quote" |
| | | :quote="quote" |
| | | clickable |
| | | :mirrored="message.selfSend" |
| | | class="max-w-[280px]" |
| | | @locate="emit('locate', $event)" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* @keyframes éè¦ SCSS 声æï¼SENDING ç¶æç转åå¨ç» */ |
| | | .im-loading-spin { |
| | | animation: im-loading-spin 1s linear infinite; |
| | | } |
| | | @keyframes im-loading-spin { |
| | | to { |
| | | transform: rotate(360deg); |
| | | } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { GroupMemberLite } from '../../../../components/group' |
| | | import type { GroupLite } from '../../../../types' |
| | | |
| | | import { computed, nextTick, provide, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { message, Popover, Tooltip } from 'ant-design-vue' |
| | | |
| | | import { createCall } from '#/api/im/rtc' |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | import { ImConversationType, ImRtcCallMediaType, ImRtcCallStatus } from '#/views/im/utils/constants' |
| | | import { getClientConversationId } from '#/views/im/utils/db' |
| | | import { resolveCallEndReasonText } from '#/views/im/utils/message' |
| | | import { getGroupDisplayName, getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user' |
| | | |
| | | import { GroupMuteMemberDialog } from '../../../../components/group' |
| | | import { |
| | | RtcCallMemberPickerDialog, |
| | | RtcGroupCallBanner |
| | | } from '../../../../components/rtc' |
| | | import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect' |
| | | import { useVoicePlayer } from '../../../../composables/useVoicePlayer' |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useFriendStore } from '../../../../store/friendStore' |
| | | import { useGroupStore } from '../../../../store/groupStore' |
| | | import { useMessageStore } from '../../../../store/messageStore' |
| | | import { useRtcStore } from '../../../../store/rtcStore' |
| | | import { useImUiStore } from '../../../../store/uiStore' |
| | | import { ConversationGroupSide } from '../conversation' |
| | | import { ConversationPrivateSide } from '../conversation' |
| | | import { MessageInput } from '../input' |
| | | import { MessageMultiSelectBar } from '../input' |
| | | import { MessageForwardDialog } from './forward' |
| | | import { MessageMergeDetailDialog } from './forward' |
| | | import { |
| | | IM_FORWARD_DIALOG_KEY, |
| | | IM_MERGE_DETAIL_DIALOG_KEY, |
| | | IM_RTC_REDIAL_KEY |
| | | } from './forward/keys' |
| | | import GroupPinnedMessage from './group-pinned-message.vue' |
| | | import GroupRequestPending from './group-request-pending.vue' |
| | | import MessageHistory from './message-history.vue' |
| | | import MessageItem from './message-item.vue' |
| | | |
| | | defineOptions({ name: 'ImMessagePanel' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | const friendStore = useFriendStore() |
| | | const uiStore = useImUiStore() |
| | | const groupStore = useGroupStore() |
| | | const rtcStore = useRtcStore() |
| | | const listRef = ref<HTMLElement>() |
| | | |
| | | // ==================== 转å / åå¹¶æ¶æ¯è¯¦æ
ï¼æ¬å° dialog æµ®å± ==================== |
| | | // MessageItem / MessageMultiSelectBar / MessageHistory éè¿ inject 触åï¼ä¸æå
¨å± store |
| | | |
| | | const forwardDialogRef = ref<InstanceType<typeof MessageForwardDialog>>() |
| | | const mergeDetailDialogRef = ref<InstanceType<typeof MessageMergeDetailDialog>>() |
| | | |
| | | provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts)) |
| | | provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) => mergeDetailDialogRef.value?.open(content)) |
| | | provide(IM_RTC_REDIAL_KEY, (mediaType: number) => { |
| | | if (isPrivate.value) { |
| | | void startPrivateCall(mediaType) |
| | | } |
| | | }) // ç§è RTC_CALL_END æ°æ³¡ç¹å»éæ¨ï¼MessageItem 注å
¥åè°ç¨ |
| | | |
| | | // ==================== å¤éæ¨¡å¼ ==================== |
| | | // 模å级åä¾ stateï¼composableï¼ï¼æ¬ç»ä»¶ä»
ååä¼è¯éåº + template æ¾éå¤å® |
| | | |
| | | const multiSelect = useMessageMultiSelect() |
| | | const voicePlayer = useVoicePlayer() |
| | | |
| | | /** åä¼è¯éåºå¤é + åè¯é³ï¼é¿å
ä¸ä¸ä¼è¯çå¾é / ææ¾ææ³æ¼å°æ°ä¼è¯ï¼type+targetId ä¸èµ·çå¬ï¼ç§èä¸ç¾¤è id åå·æ¶ä¹è½è§¦åï¼ */ |
| | | watch( |
| | | () => [ |
| | | conversationStore.activeConversation?.type, |
| | | conversationStore.activeConversation?.targetId |
| | | ], |
| | | () => { |
| | | multiSelect.exit() |
| | | voicePlayer.stop() |
| | | } |
| | | ) |
| | | |
| | | const messages = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return conversation |
| | | ? messageStore.getMessages(getClientConversationId(conversation.type, conversation.targetId)) |
| | | : [] |
| | | }) |
| | | const isGroup = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.GROUP |
| | | ) |
| | | const isPrivate = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.PRIVATE |
| | | ) |
| | | const isChannel = computed( |
| | | () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL |
| | | ) |
| | | |
| | | /** å½åæ¿æ´»ä¼è¯æ¯å¦åå²é群群ï¼ç¦ç¾¤éè¯ãéè群ç³è¯·æ¨ªå¹
çæä½å
¥å£ï¼è天åå²ã群å头åç
§å¸¸å±ç¤º */ |
| | | const isQuitGroup = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | return ( |
| | | conversation?.type === ImConversationType.GROUP && |
| | | isGroupQuit(groupStore.getGroup(conversation.targetId)) |
| | | ) |
| | | }) |
| | | |
| | | /** ç§èä¼è¯ä¸å¯¹ç«¯ä¸æ¯ææå¥½åï¼æ¬ç«¯ friend è®°å½ç¼ºå¤±æ DISABLEï¼ï¼åè¾¹å é¤è¯ä¹ä¸ã被对æ¹å é¤ãä¸è§¦åæ¬ç«¯æ¨ªå¹
*/ |
| | | const showNotFriendBanner = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.PRIVATE) { |
| | | return false |
| | | } |
| | | return !friendStore.isActiveFriend(conversation.targetId) |
| | | }) |
| | | |
| | | /** ç¹å»ã对æ¹è¿ä¸æ¯ä½ çæåãè¶åï¼æå¼ UserInfoCardï¼å¼å¯¼ç¨æ·éæ°æ·»å */ |
| | | function handleNotFriendClick(event: MouseEvent) { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() |
| | | uiStore.openUserInfoCard( |
| | | { |
| | | id: conversation.targetId, |
| | | nickname: conversation.name, |
| | | avatar: conversation.avatar |
| | | }, |
| | | { x: rect.left, y: rect.bottom + 4 } |
| | | ) |
| | | } |
| | | |
| | | /** |
| | | * 群è header æ¾ç¤ºç人æ°ï¼ä¼å
groupStore.memberCountï¼æ éçæåå表ï¼ï¼æ å¼ååé members.length |
| | | * |
| | | * 乿以ä¸ç´æ¥ç¨ groupMembers.value.lengthï¼æååè¡¨æ¯æéæå è½½çï¼ååå°ç¾¤æ¶æªå è½½å®ï¼ |
| | | * è groupInfo.memberCount è·ç¾¤ä¿¡æ¯ä¸èµ·æ¥ï¼è½æ´æ©æ¾ç¤ºäººæ°é¿å
"å
空å蹦" |
| | | */ |
| | | const headerMemberCount = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return 0 |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return group?.memberCount ?? group?.members?.length ?? 0 |
| | | }) |
| | | |
| | | /** é¡¶é¨å¯æ é¢ï¼ä»
å½ç¾¤å¤æ³¨ â åç¾¤åæ¶æ¾ç¤ºå群åï¼å¯¹é½å¾®ä¿¡ PC åè¡ headerï¼ */ |
| | | const headerSubtitle = computed(() => { |
| | | const remark = groupInfo.value?.groupRemark |
| | | const name = groupInfo.value?.name |
| | | return remark && name && remark !== name ? name : '' |
| | | }) |
| | | |
| | | const BOTTOM_THRESHOLD = 80 // "æ¯å¦åçå¨åºé¨"çéå¼ï¼è·ç¦»åºé¨ < 80px è§ä¸ºåºé¨ |
| | | const showJumpToBottom = ref(false) // å½åæ¯å¦å·²ä¸å¨åºé¨ï¼æ¾ç¤º"åå°åºé¨"æé®ï¼ |
| | | const newMessageCount = ref(0) // ä¸å¨åºé¨æé´ç´¯è®¡çæ°æ¶æ¯æ° |
| | | |
| | | /** |
| | | * å½åæ¿æ´»ç群详æ
ï¼ä¼å
groupStoreï¼å¸¦è¯¦ç»å段ï¼ï¼æªå è½½å®æ¶ç¨ activeConversation å
åº |
| | | * |
| | | * groupStore æ¯æéæå è½½çï¼å次è¿ç¾¤æ¶ ensureGroupData 触ååæä¼æå®æ´æ°æ®ï¼ |
| | | * å
åºå段ï¼name / avatarï¼ä¿è¯ header ä¸ä¼"éªç©º"ï¼notice / ownerId / memberCount |
| | | * å¿
é¡»ç store 就使æå¼ï¼è¿äºåæ®µå¨ conversation éæ²¡æï¼ |
| | | */ |
| | | const groupInfo = computed< |
| | | | (GroupLite & { |
| | | groupRemark?: string |
| | | notice?: string |
| | | ownerId?: number |
| | | remarkNickName?: string |
| | | }) |
| | | | undefined |
| | | >(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return undefined |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | const selfMember = group?.members?.find((member) => member.userId === getCurrentUserId()) |
| | | const showGroupName = group ? getGroupDisplayName(group) : conversation.name |
| | | return { |
| | | id: conversation.targetId, |
| | | name: group?.name || conversation.name, |
| | | showGroupName, |
| | | showImage: group?.avatar || conversation.avatar, |
| | | notice: group?.notice, |
| | | remarkNickName: selfMember?.displayUserName, |
| | | groupRemark: group?.groupRemark, |
| | | ownerId: group?.ownerUserId, |
| | | memberCount: group?.memberCount, |
| | | joinApproval: group?.joinApproval |
| | | } |
| | | }) |
| | | |
| | | /** 群æåå表ï¼ç´æ¥å groupStore ç¼åï¼map æ GroupMemberLite ç»ä¸æ¸¸æ¶è´¹ï¼@-mention / é请çï¼ */ |
| | | const groupMembers = computed<GroupMemberLite[]>(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return [] |
| | | } |
| | | const group = groupStore.getGroup(conversation.targetId) |
| | | return (group?.members || []).map((member) => { |
| | | // æ¾ç¤ºåèµ°ã好å夿³¨ > ç¾¤å¤æ³¨ > ç宿µç§°ãä¸çº§ï¼å¤´åèµ° nickname ä¿ç¨³å® |
| | | const friend = friendStore.getFriend(member.userId) |
| | | return { |
| | | userId: member.userId, |
| | | showName: getMemberDisplayName(member, friend), |
| | | nickname: member.nickname, |
| | | avatar: member.avatar, |
| | | status: member.status, |
| | | role: member.role |
| | | } |
| | | }) |
| | | }) |
| | | |
| | | /** 忢å°ç¾¤ä¼è¯æ¶åæ¥ç¾¤ä¿¡æ¯ + æå */ |
| | | async function ensureGroupData(groupId: number) { |
| | | // è¿ç¨æç¾¤ä¿¡æ¯ï¼ç¾¤å / å
¬å / 群主çå
æ°æ®ï¼ |
| | | await groupStore.fetchGroupInfo(groupId).catch((error) => { |
| | | console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error) |
| | | }) |
| | | if (isGroupQuit(groupStore.getGroup(groupId))) { |
| | | return |
| | | } |
| | | |
| | | // å
ä» IDB 忥å 载群æåï¼è®©é¦å¸§ç«å³åºæåå / 头å |
| | | await groupStore.loadGroupMemberList(groupId).catch((error) => { |
| | | console.warn('[IM MessagePanel] loadGroupMemberList 失败', { groupId }, error) |
| | | return null |
| | | }) |
| | | const group = groupStore.getGroup(groupId) |
| | | if (!group?.membersLoaded || group.membersExpired) { |
| | | groupStore.fetchGroupMemberList(groupId, true).catch((error) => { |
| | | console.warn('[IM MessagePanel] fetchGroupMemberList 失败', { groupId }, error) |
| | | }) |
| | | } |
| | | } |
| | | |
| | | /** ç¾¤ä¿¡æ¯æ½å±éç¹"å·æ°"ï¼å¼ºæä¸æ¬¡ææ°ç¾¤å
æ°æ® + 群æå */ |
| | | function reloadGroupData() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.GROUP) { |
| | | return |
| | | } |
| | | groupStore.fetchGroupInfo(conversation.targetId, true) |
| | | groupStore.fetchGroupMemberList(conversation.targetId, true) |
| | | } |
| | | |
| | | const historyDialogRef = ref<InstanceType<typeof MessageHistory>>() // å岿¶æ¯æ½å± refï¼ãè天åå²ãicon / æ½å±ãæ¥æ¾è天å
容ãå
¥å£é½è° open() 触å |
| | | const sideVisible = ref(false) // ä¿¡æ¯æ½å±å¼å
³ï¼ç¾¤è / ç§èå
±ç¨ä¸ä¸ª ref |
| | | const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>() |
| | | const callMemberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>() |
| | | const pendingMediaType = ref<null | number>(null) // 群éè¯åèµ·ï¼æåéæ©å¼¹çªæå¼æé´ä¸´æ¶ææç mediaType |
| | | |
| | | /** æ¶æ¯å³é®èåãç¦è¨ãâ æå¼æ¶é¿éæ©å¼¹çª */ |
| | | function handleMuteMember(groupId: number, userId: number, displayName: string) { |
| | | muteMemberDialogRef.value?.open(groupId, userId, displayName) |
| | | } |
| | | |
| | | /** ä¿¡æ¯æ½å±ç toggleï¼è· header ä¸ 3 ç¹å¾æ æé®å
±ç¨ */ |
| | | function toggleSide() { |
| | | sideVisible.value = !sideVisible.value |
| | | } |
| | | |
| | | const callPopoverVisible = ref(false) // ç§èéè¯å
¥å£ï¼popover 触åï¼ç¹ è¯é³ / è§é¢ ç´æ¥åèµ· |
| | | const callInviting = ref(false) // éè¯åèµ·ä¸ |
| | | async function startPrivateCall(mediaType: number) { |
| | | callPopoverVisible.value = false |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | await doInvite({ |
| | | conversationType: ImConversationType.PRIVATE, |
| | | mediaType, |
| | | inviteeIds: [conversation.targetId] |
| | | }) |
| | | } |
| | | |
| | | /** 群éè¯å
¥å£ï¼é»è®¤è¯é³ç´æ¥å¼¹é人ï¼ä¸å¾®ä¿¡ç¾¤éè¯ä¸è´ï¼è¿éè¯åç¨æ·æé弿å头 */ |
| | | function handleGroupCall() { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | pendingMediaType.value = ImRtcCallMediaType.VOICE |
| | | callMemberPickerRef.value?.open({ groupId: conversation.targetId, mode: 'invite' }) |
| | | } |
| | | |
| | | /** é人弹çªç¡®è®¤ï¼å¸¦éä¸ ID å起群éè¯ */ |
| | | async function onCallMemberPicked(selectedIds: number[]) { |
| | | const conversation = conversationStore.activeConversation |
| | | const mediaType = pendingMediaType.value |
| | | pendingMediaType.value = null |
| | | if (!conversation || mediaType == null || selectedIds.length === 0) { |
| | | return |
| | | } |
| | | await doInvite({ |
| | | conversationType: ImConversationType.GROUP, |
| | | mediaType, |
| | | groupId: conversation.targetId, |
| | | inviteeIds: selectedIds |
| | | }) |
| | | } |
| | | |
| | | /** å®é
è° create æ¥å£ï¼ç»ä¸å¤çæå / ENDEDï¼å¦å¿çº¿ç«å³ç»æï¼ */ |
| | | async function doInvite(reqVO: { |
| | | conversationType: number |
| | | groupId?: number |
| | | inviteeIds: number[] |
| | | mediaType: number |
| | | }) { |
| | | if (callInviting.value) { |
| | | return |
| | | } |
| | | if (rtcStore.isActive) { |
| | | message.warning('å½åå·²æéè¯') |
| | | return |
| | | } |
| | | callInviting.value = true |
| | | try { |
| | | const data = await createCall(reqVO) |
| | | // å端已 INSERT + ç«å³ endï¼å¦å¿çº¿ï¼ï¼toast æç¤ºï¼ä¸è¿ INVITING é¶æ®µï¼chat tip ç± RTC_CALL_END æ¨éåå
¥æ¶æ¯æµ |
| | | if (data.status === ImRtcCallStatus.ENDED) { |
| | | message.warning(resolveCallEndReasonText(data.endReason)) |
| | | return |
| | | } |
| | | // æ£å¸¸è¿å
¥ INVITING é¶æ®µï¼èµ° store é»è¾åèµ·éè¯ï¼åç»ç¶ææ´æ° / æ¶æ¯æµæ´æ°ç± RTC 模åç嬿¨éå¤ç |
| | | rtcStore.startInviting(data) |
| | | } finally { |
| | | callInviting.value = false |
| | | } |
| | | } |
| | | |
| | | /** å½åç§è对åºç好åï¼æ½å±å¤´é¨å±ç¤ºç¨ï¼ */ |
| | | const privateFriend = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || conversation.type !== ImConversationType.PRIVATE) { |
| | | return undefined |
| | | } |
| | | return friendStore.getFriend(conversation.targetId) |
| | | }) |
| | | |
| | | /** 计ç®è·ç¦»åºé¨çåç´ */ |
| | | function distanceFromBottom(): number { |
| | | const el = listRef.value |
| | | if (!el) { |
| | | return 0 |
| | | } |
| | | return el.scrollHeight - el.scrollTop - el.clientHeight |
| | | } |
| | | |
| | | /** |
| | | * æ¶æ¯å表æ»å¨äºä»¶ï¼å·æ°"æ¯å¦å¨åºé¨"ç¶æ |
| | | * - å¨åºé¨ï¼éè"åå°åºé¨"æµ®çª + æ¸
æ"æªè¯»æ°æ¶æ¯"è®¡æ° |
| | | * - ä¸å¨åºé¨ï¼æ¾ç¤º"åå°åºé¨"æµ®çªï¼æ°æ¶æ¯ä¼ç´¯è®¡å° newMessageCount |
| | | */ |
| | | function handleScroll() { |
| | | const dist = distanceFromBottom() |
| | | const atBottom = dist <= BOTTOM_THRESHOLD |
| | | showJumpToBottom.value = !atBottom |
| | | if (atBottom) { |
| | | newMessageCount.value = 0 |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * æ»å°åºé¨ï¼åä¼è¯ / æ¶å°æ°æ¶æ¯ï¼ä¸å½åå¨åºé¨ï¼/ ç¨æ·ä¸»å¨ç¹ãåå°åºé¨ã é½èµ°è¿é |
| | | * |
| | | * smooth=true èµ°å¹³æ»å¨ç»ï¼éåç¨æ·ä¸»å¨ç¹å»ï¼åå§ / èªå¨æ»å¨ç¨ autoï¼é¿å
ç¨æ·æç¥å°å¨ç»ææ½ |
| | | */ |
| | | async function scrollToBottom(smooth = false) { |
| | | // 1. æ»å°å½å scrollHeight çåºé¨ï¼å¾ç / è§é¢è¿å¨å è½½æ¶åªæ¯å¤§è´å°åºï¼ |
| | | // 1.1 ç v-for ææ°æ¶æ¯çæ£æ¸²æè¿ DOM ååç® scrollHeightï¼å¦åå·®æå䏿¡çä½ç½® |
| | | await nextTick() |
| | | if (!listRef.value) { |
| | | return |
| | | } |
| | | // 1.2 è§¦åæ»å¨ï¼smooth ä»
user 主å¨ç¹ãåå°åºé¨ãç¨ï¼åå§ / èªå¨æ»èµ° auto é¿å
å¨ç»ææ½æ |
| | | listRef.value.scrollTo({ |
| | | top: listRef.value.scrollHeight, |
| | | behavior: smooth ? 'smooth' : 'auto' |
| | | }) |
| | | newMessageCount.value = 0 |
| | | showJumpToBottom.value = false |
| | | // 1.3 è®°ä¸ãææåä¸ç scrollTopãï¼å¾ç / è§é¢å è½½å®åºé¨ä¼ä¸ç§»ï¼scrollTop 没å¨å°±è¯´æç¨æ·æ²¡æå¨æ»èµ° |
| | | // ä¸è½ç¨ distanceFromBottom 夿ï¼åºé¨ä¸ç§»ä¼è®© distance å大ï¼è¢«è¯¯å¤ä¸ºãç¨æ·æ»èµ°äºãç´æ¥æ¾å¼è¡¥æ» |
| | | const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight |
| | | |
| | | // 2. çåªä½å è½½å®åè¡¥æ»å°çå®åºé¨ |
| | | // 2.1 ç容å¨å
æªå è½½å®çå¾ç / è§é¢å
æ°æ®ï¼å è½½å®å scrollHeight ä¼å¢é¿å°çå®åºé¨ |
| | | await waitMediaSettled() |
| | | if (!listRef.value) { |
| | | return |
| | | } |
| | | // 2.2 ä»
å¨ç¨æ·æ²¡æå¨æ»èµ°æ¶ï¼scrollTop ä»è´´è¿ expectedScrollTopï¼æè¡¥æ»ï¼é¿å
çå¾
æé´ç¨æ·ä¸ç¿»è¢«ææ |
| | | if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) { |
| | | return |
| | | } |
| | | // 2.3 è¡¥æ»å°æ°åºé¨ |
| | | listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' }) |
| | | } |
| | | |
| | | /** |
| | | * çå¾
容å¨å
æªå è½½å®çå¾ç / è§é¢ï¼æå¤ç 2s 鲿¢è¶
å¤§èµæºææ´ä¸ªæ»å¨è·è¿å¡ä½ |
| | | * |
| | | * ä»
å
³å¿å
æ°æ®ï¼loadedmetadata / img.completeï¼ï¼ä¸ççæ£è§£ç ï¼å 为尺寸å¤ç® scrollHeight å°±è¡ |
| | | */ |
| | | function waitMediaSettled(): Promise<void> { |
| | | // 1. æ¶é容å¨å
æªå è½½å®çå¾ç / è§é¢ |
| | | if (!listRef.value) { |
| | | return Promise.resolve() |
| | | } |
| | | // 1.1 䏿¬¡æ« img + videoï¼æ element ç±»ååå«çãcomplete / readyStateãè¿æ»¤ pending |
| | | const pendingMedia = [...listRef.value.querySelectorAll<HTMLImageElement | HTMLVideoElement>('img, video')].filter((el) => (el instanceof HTMLImageElement ? !el.complete : el.readyState < 1)) |
| | | // 1.2 没æ pending ç´æ¥è¿åï¼çæ Promise.race / setTimeout éå
æé |
| | | if (pendingMedia.length === 0) { |
| | | return Promise.resolve() |
| | | } |
| | | |
| | | // 2. çææ pending èµæº load / errorï¼æé¿ 2s å
åº |
| | | // 2.1 æ¯ä¸ª element é½çå¬å¯¹åº loadedEvent + errorï¼ä»»ä¸è§¦åå³ resolveï¼ä¸è®©åæ¡å¤±è´¥å¡ raceï¼ |
| | | const loadAll = Promise.all( |
| | | pendingMedia.map( |
| | | (el) => |
| | | new Promise<void>((resolve) => { |
| | | const loadedEvent = el instanceof HTMLImageElement ? 'load' : 'loadedmetadata' |
| | | el.addEventListener(loadedEvent, () => resolve(), { once: true }) |
| | | el.addEventListener('error', () => resolve(), { once: true }) |
| | | }) |
| | | ) |
| | | ).then(() => undefined) |
| | | // 2.2 2s è¶
æ¶å
åºï¼é²æ¢è¶
å¤§èµæº / ç½ç»æèµ·ææ´ä¸ªæ»å¨è·è¿æ°¸ä¹
å¡ä½ |
| | | const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000)) |
| | | return Promise.race([loadAll, timeout]) |
| | | } |
| | | |
| | | /** |
| | | * å®ä½å°è天ä½ç½®ï¼MessageHistory è¡ä¸"å®ä½"æé® / æ°æ³¡å
å¼ç¨åç¹å»è§¦å |
| | | * |
| | | * 1. å
å
³æåå²å¼¹çªï¼é¿å
scroll æ¶é®æ¡ + dialog å
³éå让è天颿¿æ¿åç¦ç¹ï¼ |
| | | * 2. nextTick çå¼¹çª leave å¨ç» / å表渲æç¨³å®å忥 DOM |
| | | * 3. æ data-message-id æ¾ wrapperï¼scrollIntoView({ block: center }) è®©æ¶æ¯è½å°è§å£ä¸é¨ |
| | | * 4. å --highlight class çæé«äº®ï¼æç¤ºç¨æ·"å°±æ¯è¿æ¡" |
| | | * 5. æ¾ä¸å° wrapper(åæ¶æ¯å·²å页åºå»)æ¶å¼¹ warning æç¤º,ä¸å¾®ä¿¡"æ¶æ¯å·²ä¸å¨çªå£"è§æä¸è´ |
| | | */ |
| | | async function handleLocate(messageId: number) { |
| | | if (!messageId) { |
| | | return |
| | | } |
| | | await nextTick() |
| | | if (!listRef.value) { |
| | | return |
| | | } |
| | | const target = listRef.value.querySelector<HTMLElement>(`[data-message-id="${messageId}"]`) |
| | | if (!target) { |
| | | message.warning('åæ¶æ¯ä¸å¨è§é') |
| | | return |
| | | } |
| | | target.scrollIntoView({ behavior: 'smooth', block: 'center' }) |
| | | target.classList.add('message-panel__message-anchor--highlight') |
| | | setTimeout(() => { |
| | | target.classList.remove('message-panel__message-anchor--highlight') |
| | | }, 1600) |
| | | } |
| | | |
| | | /** |
| | | * æ¶æ¯æ°éååæ¶çæ»å¨è·è¿çç¥ |
| | | * - å½åå¨åºé¨ â èªå¨æ»å°æ°åºé¨ï¼ç¨æ·å¨"宿¶è·è¯»"ï¼ä½éªä¸è·å¾®ä¿¡ä¸è´ï¼ |
| | | * - ä¸å¨åºé¨ â 累计 newMessageCountï¼è®© sticky æµ®çªæ¾ç¤º"X æ¡æ°æ¶æ¯"ï¼è®©ç¨æ·ä¸»å¨ç¹ |
| | | */ |
| | | watch( |
| | | () => messages.value.length, |
| | | (newLen, oldLen) => { |
| | | // ä»
å¤çæ°å¢ï¼delta > 0ï¼ï¼å é¤ / æ¤å让 length åå°æ¶ä¸å¨æ»å¨ç¶æ |
| | | const delta = (newLen || 0) - (oldLen || 0) |
| | | if (delta <= 0) { |
| | | return |
| | | } |
| | | // ç¨ BOTTOM_THRESHOLDï¼80pxï¼å容差ï¼ç¨æ·ç¨å¾®å¾ä¸ç¿»å è¡å°±è§ä½"ä¸å¨åºé¨"ï¼ |
| | | // å¦åä¸ç´ auto-scroll ä¼æäººæ£å¨è¯»çå
容顶走ï¼ä½éªç³ç³ |
| | | const dist = distanceFromBottom() |
| | | if (dist <= BOTTOM_THRESHOLD) { |
| | | scrollToBottom() |
| | | } else { |
| | | newMessageCount.value += delta |
| | | showJumpToBottom.value = true |
| | | } |
| | | } |
| | | ) |
| | | |
| | | /** |
| | | * 忢ä¼è¯ï¼æ¸
空"å¨ä¸å¨åºé¨"ç¸å
³ç¶æãå¼ºå¶æ»å°åºé¨ã群ä¼è¯é¢æèµæ |
| | | * |
| | | * type+targetId ä¸èµ·çå¬ï¼ç§èä¸ç¾¤è id åå·æ¶åæ¢ä¹è½è§¦åï¼immediate:true è®©é¦æ¬¡è¿å
¥é¡µé¢å°±è½åå§å |
| | | */ |
| | | watch( |
| | | () => [ |
| | | conversationStore.activeConversation?.type, |
| | | conversationStore.activeConversation?.targetId |
| | | ], |
| | | ([type, targetId]) => { |
| | | // åä¼è¯æ¶ä¸ä¸ä¼è¯çãæªè¯»ç´¯è®¡ + æµ®çªæ¾ç¤ºãå¿
é¡»æ¸
æï¼å¦åä¼å¸¦å°æ°ä¼è¯éçèµ·æ¥å¾çªå
|
| | | newMessageCount.value = 0 |
| | | showJumpToBottom.value = false |
| | | // æ½å±éå±ç¤ºçç¾¤ä¿¡æ¯ / 好åä¿¡æ¯å±äºä¸ä¸ä¼è¯ï¼åä¼è¯æ¶ç»ä¸å
³æ |
| | | sideVisible.value = false |
| | | scrollToBottom() |
| | | // ä»
群èé¢æè¯¦æ
/ æåï¼ç§è对端å¨é¦å± fetchFriendList æ¶å°±æäºï¼ |
| | | if (targetId && type === ImConversationType.GROUP) { |
| | | ensureGroupData(targetId) |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="flex flex-1 flex-col min-w-0 bg-[var(--ant-color-fill-secondary)]"> |
| | | <template v-if="conversationStore.activeConversation"> |
| | | <!-- é¡¶é¨ headerï¼ç¬¬ä¸è¡ç¾¤å + å³ä¾§å¾æ ï¼ç¬¬äºè¡åµå
¥ç½®é¡¶æ°æ³¡ï¼ä»
群è + æç½®é¡¶ï¼ --> |
| | | <div |
| | | class="flex flex-shrink-0 flex-col bg-[var(--ant-color-fill-secondary)] border-b border-b-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <div class="flex items-center justify-between h-14 px-5"> |
| | | <span class="flex flex-col min-w-0"> |
| | | <span class="flex items-baseline gap-1.5 min-w-0"> |
| | | <span |
| | | class="overflow-hidden text-base font-medium truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ conversationStore.activeConversation?.name || '' }} |
| | | </span> |
| | | <span |
| | | v-if="isGroup && headerMemberCount > 0" |
| | | class="flex-shrink-0 text-sm text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | ({{ headerMemberCount }}) |
| | | </span> |
| | | </span> |
| | | <!-- 坿 é¢ï¼å¤æ³¨ â ç¾¤åæ¶å±ç¤ºå群åï¼æç¤ºç¨æ·å½åçå°ç䏻忝èªå·±è®¾ç夿³¨ --> |
| | | <span |
| | | v-if="headerSubtitle" |
| | | class="overflow-hidden text-xs truncate text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | {{ headerSubtitle }} |
| | | </span> |
| | | </span> |
| | | <div class="flex gap-2 items-center"> |
| | | <!-- è天åå² --> |
| | | <Tooltip title="è天åå²" placement="bottom"> |
| | | <Icon |
| | | icon="ep:chat-dot-round" |
| | | class="message-panel__header-icon cursor-pointer" |
| | | @click="historyDialogRef?.open()" |
| | | /> |
| | | </Tooltip> |
| | | <!-- éè¯å
¥å£ï¼ç§èå¼¹ãè¯é³ / è§é¢ãpopoverï¼ç¾¤èç´æ¥è¿éäººå¼¹çª --> |
| | | <Popover |
| | | v-if="isPrivate" |
| | | v-model:open="callPopoverVisible" |
| | | placement="bottomRight" |
| | | :overlay-style="{ width: '140px' }" |
| | | trigger="click" |
| | | overlay-class-name="message-panel__call-popover" |
| | | > |
| | | <Icon |
| | | icon="ant-design:phone-outlined" |
| | | class="message-panel__header-icon cursor-pointer" |
| | | /> |
| | | <template #content> |
| | | <div class="flex flex-col gap-0.5"> |
| | | <div |
| | | class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]" |
| | | @click="startPrivateCall(ImRtcCallMediaType.VOICE)" |
| | | > |
| | | <Icon icon="ant-design:phone-outlined" :size="16" /> |
| | | <span>è¯é³éè¯</span> |
| | | </div> |
| | | <div |
| | | class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]" |
| | | @click="startPrivateCall(ImRtcCallMediaType.VIDEO)" |
| | | > |
| | | <Icon icon="ant-design:video-camera-outlined" :size="16" /> |
| | | <span>è§é¢éè¯</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </Popover> |
| | | <Tooltip v-else-if="!isQuitGroup" title="éè¯" placement="bottom"> |
| | | <Icon |
| | | icon="ant-design:phone-outlined" |
| | | class="message-panel__header-icon cursor-pointer" |
| | | @click="handleGroupCall" |
| | | /> |
| | | </Tooltip> |
| | | <!-- ä¿¡æ¯æ½å±å
¥å£ï¼ç¾¤èæï¼ç§èä»
å¨å¯¹æ¹æ¯å¥½åæ¶æ¾ç¤ºï¼é好åä¼è¯æ½å±å
å®¹ä¸ºç©ºï¼æ²¡æä¹ï¼ --> |
| | | <Tooltip |
| | | v-if="isGroup || (isPrivate && !showNotFriendBanner)" |
| | | :title="isGroup ? '群èä¿¡æ¯' : 'è天信æ¯'" |
| | | placement="bottom" |
| | | > |
| | | <Icon |
| | | icon="ant-design:ellipsis-outlined" |
| | | class="message-panel__header-icon cursor-pointer" |
| | | @click="toggleSide" |
| | | /> |
| | | </Tooltip> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 群éè¯è¶åæ¡ï¼ä»
群è + è¯¥ç¾¤ææ´»è·éè¯æ¶æ¾ç¤ºï¼ç¹å»å±å¼çæå + å å
¥æé® --> |
| | | <RtcGroupCallBanner |
| | | v-if="isGroup && !isQuitGroup && conversationStore.activeConversation" |
| | | :group-id="conversationStore.activeConversation.targetId" |
| | | /> |
| | | |
| | | <!-- ç¾¤ç½®é¡¶æ¶æ¯ï¼ç¬¬äºè¡åµå
¥ headerï¼ä»
群è + æç½®é¡¶æ¶æ¾ç¤º --> |
| | | <GroupPinnedMessage |
| | | v-if="isGroup && conversationStore.activeConversation" |
| | | :group-id="conversationStore.activeConversation.targetId" |
| | | @locate="handleLocate" |
| | | /> |
| | | <!-- 群顶é¨ãå¾
å¤çå 群ç³è¯·ã横å¹
ï¼ä»
群è + owner / admin + count > 0 æ¶æ¾ç¤º --> |
| | | <GroupRequestPending |
| | | v-if="isGroup && !isQuitGroup && conversationStore.activeConversation" |
| | | :group-id="conversationStore.activeConversation.targetId" |
| | | /> |
| | | <!-- ç§èï¼å¯¹æ¹ä¸åæ¯ææå¥½åï¼æå äºå¯¹æ¹ / 仿ªå è¿ï¼å边设计ä¸ã被对æ¹å é¤ãæ¬ç«¯ friendStore 䏿´æ°æ
ä¸ä¼è§¦åï¼ï¼è¶ååµå¨ header å
ï¼è·ç¾¤ç½®é¡¶å级ï¼ï¼ç¹å»å¼¹ UserInfoCard --> |
| | | <div |
| | | v-if="showNotFriendBanner" |
| | | class="flex flex-shrink-0 items-start px-4 pb-2 bg-[var(--ant-color-fill-secondary)]" |
| | | > |
| | | <div |
| | | class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-13px cursor-pointer text-[var(--ant-color-text)] bg-[var(--ant-color-warning-bg)] transition-colors hover:bg-[var(--ant-color-warning-bg-hover)]" |
| | | @click="handleNotFriendClick" |
| | | > |
| | | <span |
| | | class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--ant-color-warning)] flex-shrink-0" |
| | | > |
| | | <Icon icon="ant-design:user-outlined" :size="11" /> |
| | | </span> |
| | | <span>对æ¹è¿ä¸æ¯ä½ çæå</span> |
| | | <Icon icon="ep:arrow-right" :size="12" class="text-[var(--ant-color-text-secondary)]" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ä¸é´ï¼æ¶æ¯å表 --> |
| | | <div |
| | | ref="listRef" |
| | | class="relative flex-1 py-2 overflow-y-auto bg-[var(--ant-color-bg-layout)]" |
| | | @scroll="handleScroll" |
| | | > |
| | | <div |
| | | v-if="messages.length === 0" |
| | | class="flex items-center justify-center h-full text-sm text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | ææ æ¶æ¯ |
| | | </div> |
| | | <!-- data-message-id ç» MessageHistory "å®ä½å°è天ä½ç½®" ç¨ï¼ç¶çº§éè¿ querySelector |
| | | æ¾å°è¿å± wrapperï¼scrollIntoView + å é«äº® classï¼æ¬å°å 使¶æ¯è·³è¿ --> |
| | | <div |
| | | v-for="(msg, index) in messages" |
| | | :key="msg.id || msg.clientMessageId" |
| | | :data-message-id="msg.id || ''" |
| | | class="message-panel__message-anchor" |
| | | > |
| | | <MessageItem |
| | | :message="msg" |
| | | :prev-message="messages[index - 1]" |
| | | @locate="handleLocate" |
| | | @mute="handleMuteMember" |
| | | @reload="reloadGroupData" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- åå°åºé¨æµ®å¨æé®ï¼æ»å¨ä¸å¨åºé¨æ¶æ¾ç¤ºï¼ --> |
| | | <transition name="message-panel__jump-fade"> |
| | | <div |
| | | v-if="showJumpToBottom" |
| | | class="message-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--ant-color-bg-elevated)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]" |
| | | @click="scrollToBottom(true)" |
| | | > |
| | | <Icon icon="ant-design:down-outlined" :size="14" /> |
| | | <span v-if="newMessageCount > 0" class="font-medium"> |
| | | {{ newMessageCount > 99 ? '99+' : newMessageCount }} æ¡æ°æ¶æ¯ |
| | | </span> |
| | | <span v-else>åå°åºé¨</span> |
| | | </div> |
| | | </transition> |
| | | </div> |
| | | |
| | | <!-- åºé¨ï¼è¾å
¥æ¡ï¼é¢éååæ¶æ¯æ éè¾å
¥æ¡ï¼ï¼å¤é模å¼åºæ ä½ä¸ºæµ®å±çå¨ä¸é¢ï¼ä¿æä¸æ¹è¾å
¥æ¡å°ºå¯¸ä¸å --> |
| | | <div v-if="!isChannel" class="relative"> |
| | | <MessageInput /> |
| | | <MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" /> |
| | | </div> |
| | | |
| | | <!-- å³ä¾§ä¿¡æ¯æ½å±ï¼ç¾¤è / ç§èåèªä¸ä»½ --> |
| | | <ConversationGroupSide |
| | | v-if="isGroup" |
| | | v-model="sideVisible" |
| | | :group="groupInfo" |
| | | :conversation="conversationStore.activeConversation" |
| | | :members="groupMembers" |
| | | @reload="reloadGroupData" |
| | | @open-history="historyDialogRef?.open()" |
| | | /> |
| | | <ConversationPrivateSide |
| | | v-else |
| | | v-model="sideVisible" |
| | | :conversation="conversationStore.activeConversation" |
| | | :friend="privateFriend" |
| | | @open-history="historyDialogRef?.open()" |
| | | /> |
| | | |
| | | <!-- å岿¶æ¯æ½å± --> |
| | | <MessageHistory ref="historyDialogRef" @locate="handleLocate" /> |
| | | |
| | | <!-- 转åå¼¹çª / åå¹¶æ¶æ¯è¯¦æ
ï¼å¨ MessagePanel åæ å
æè½½ï¼åç»ä»¶éè¿ inject 触å --> |
| | | <MessageForwardDialog ref="forwardDialogRef" /> |
| | | <MessageMergeDetailDialog ref="mergeDetailDialogRef" /> |
| | | |
| | | <!-- ç¦è¨æ¶é¿éæ©å¼¹çª --> |
| | | <GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" /> |
| | | |
| | | <!-- 群éè¯æåéæ©å¼¹çª --> |
| | | <RtcCallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" /> |
| | | </template> |
| | | <div |
| | | v-else |
| | | class="flex items-center justify-center h-full text-sm text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | <span>éæ©ä¸ä¸ªä¼è¯å¼å§è天</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* :deep ç©¿é el-icon åç»ä»¶ svgï¼el-icon å
¨å±è§å .el-icon{color:var(--color,inherit)} ä¼å
级æ´é«ï¼ |
| | | ç¨ :deep + !important éè²ï¼é¢è²å Element Plus 主é¢åéï¼æè²æ¨¡å¼èªå¨åå°æ´äº®çç° */ |
| | | .message-panel__header-icon, |
| | | .message-panel__header-icon :deep(svg) { |
| | | width: 20px; |
| | | height: 20px; |
| | | color: var(--ant-color-text) !important; |
| | | fill: currentColor !important; |
| | | transition: color 0.15s; |
| | | } |
| | | |
| | | .message-panel__header-icon { |
| | | box-sizing: content-box; |
| | | padding: 4px; |
| | | } |
| | | .message-panel__header-icon:hover, |
| | | .message-panel__header-icon:hover :deep(svg) { |
| | | color: var(--ant-color-primary) !important; |
| | | } |
| | | |
| | | /* sticky + translate å±
ä¸ï¼fit-content 宽度ä¸ä¼ææ»¡ï¼transform æ°´å¹³ -50% åç§»ï¼åæ¶ transition opacity å transform ä¸¤ä¸ªå±æ§ */ |
| | | .message-panel__jump-bottom { |
| | | transform: translateX(-50%); |
| | | transition: |
| | | opacity 0.2s, |
| | | transform 0.2s; |
| | | } |
| | | |
| | | /* JS 忢 highlight class + background-color transition èå¨ï¼MessageHistoryãå®ä½ãè·³è¿æ¥æ¶çæé«äº® 1.6s */ |
| | | .message-panel__message-anchor { |
| | | transition: background-color 0.6s ease; |
| | | } |
| | | .message-panel__message-anchor--highlight { |
| | | background-color: var(--ant-color-warning-bg); |
| | | } |
| | | |
| | | /* Vue <transition> é©åç±»ååºå®ï¼å¿
é¡» SCSS 声æï¼åå°åºé¨æé®æ·¡å
¥æ·¡åº */ |
| | | .message-panel__jump-fade-enter-active, |
| | | .message-panel__jump-fade-leave-active { |
| | | transition: |
| | | opacity 0.2s, |
| | | transform 0.2s; |
| | | } |
| | | |
| | | .message-panel__jump-fade-enter-from, |
| | | .message-panel__jump-fade-leave-to { |
| | | opacity: 0; |
| | | transform: translate(-50%, 20px); |
| | | } |
| | | </style> |
| | | |
| | | <style> |
| | | /* el-popover å
¨å±æ ·å¼ï¼ç´§è´´èåçå° paddingï¼é scoped æè½å½ä¸ popper-class */ |
| | | .message-panel__call-popover.el-popover.el-popper { |
| | | min-width: auto; |
| | | padding: 6px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Message } from '../../../../types' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { CommonStatusEnum } from '#/packages/constants/src' |
| | | |
| | | import { Popover, TabPane, Tabs } from 'ant-design-vue' |
| | | |
| | | import { getGroupReadUsers as apiGetGroupReadUsers } from '#/api/im/message/group' |
| | | |
| | | import { ImConversationType, ImMessageReceiptStatus } from '../../../../../utils/constants' |
| | | import { PagedScroller } from '../../../../components' |
| | | import { GroupMember, type GroupMemberLite } from '../../../../components/group' |
| | | import { useMessageStore } from '../../../../store/messageStore' |
| | | |
| | | defineOptions({ name: 'ImMessageReadStatus' }) |
| | | |
| | | const props = defineProps<{ |
| | | // å½å群ç¼å·ï¼ä¾ loadReadUsers ä½ä¸º /im/message/group/get-read-users çå
¥å |
| | | groupId: number |
| | | // å½åç¾¤æææåï¼å¤é¨ MessagePanel.groupMembers ä¼ å
¥ï¼æ²¡æå°±ä¼ 空æ°ç»ï¼æªè¯»å表ä¼å空ä½ä¸æ¥éï¼ |
| | | groupMembers: GroupMemberLite[] |
| | | message: Message |
| | | }>() |
| | | |
| | | const messageStore = useMessageStore() |
| | | |
| | | // popover å¼å
³ï¼show æ¶æå·²è¯»ååï¼å
³éåä¿ç readUserIds ç¼åï¼éå¼å䏿¡æ¶æ¯ä¸å请æ±ï¼ |
| | | const popVisible = ref(false) |
| | | const activeTab = ref<'read' | 'unread'>('read') |
| | | // æå¡ç«¯è¿åç"å·²è¯»è¿æ¡æ¶æ¯ç userId å表"ï¼æªè¯»é visibleMembers åå»è¿ä»½å¾å° |
| | | const readUserIds = ref<number[]>([]) |
| | | |
| | | /** |
| | | * æ ç¾ææ¡ï¼ |
| | | * - receiptStatus === DONEï¼æå¡ç«¯ç¡®è®¤å
¨å已读 â ç´æ¥æ¾ç¤º"å
¨é¨å·²è¯»"ï¼readCount ä¸åä½¿ç¨ |
| | | * - readCount > 0ï¼æ¾ç¤º"N 人已读" |
| | | * - å
¶ä»ï¼readCount = 0 æ undefinedï¼ä¸æªå° DONEï¼ï¼æ¾ç¤º"æªè¯»" |
| | | */ |
| | | const label = computed(() => { |
| | | if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) { |
| | | return 'å
¨é¨å·²è¯»' |
| | | } |
| | | const readCount = props.message.readCount || 0 |
| | | return readCount > 0 ? `${readCount} 人已读` : 'æªè¯»' |
| | | }) |
| | | |
| | | /** |
| | | * è¿æ¡æ¶æ¯"åºè¯¥è¢«è°çå°"çå¯è§æåéåï¼å·²è¯» / æªè¯»ä¸¤ä¸ª tab å
±ç¨åºåºï¼ |
| | | * |
| | | * ä¸å±è¿æ»¤ï¼ |
| | | * 1. å®åç¾¤æ¶æ¯ï¼receiverUserIds é空æ¶ï¼åªç®ååå
çæåãå°äºè¿å±ï¼æªå¨æéèå´éç人 |
| | | * ä¼è¢«éç®æ"æªè¯»"ï¼getGroupReadUsers å端已æå¯è§æ§è¿æ»¤ï¼å端å¿
é¡»é
å¥ï¼ |
| | | * 2. åéè
æ¬äººï¼èªå·±åçæ¶æ¯ç®ä¸ç®"èªå·±å·²è¯»"è¯ä¹æ¨¡ç³ï¼ç´¢æ§ä¸¤ä¸ª tab é½ä¸å±ç¤º |
| | | * 3. å·²é群ï¼status === DISABLEï¼ï¼ä»è¯»æ²¡è¯»å·²ç»ä¸å
³å¿ï¼UI ä¸å±ç¤º |
| | | */ |
| | | const visibleMembers = computed<GroupMemberLite[]>(() => { |
| | | const receiverUserIds = props.message.receiverUserIds |
| | | const isDirected = !!receiverUserIds && receiverUserIds.length > 0 |
| | | return props.groupMembers.filter( |
| | | (member) => |
| | | member.status !== CommonStatusEnum.DISABLE && |
| | | member.userId !== props.message.senderId && |
| | | (!isDirected || receiverUserIds.includes(member.userId)) |
| | | ) |
| | | }) |
| | | |
| | | /** 已读 = å¯è§æå â© readUserIds */ |
| | | const readMembers = computed(() => |
| | | visibleMembers.value.filter((member) => readUserIds.value.includes(member.userId)) |
| | | ) |
| | | |
| | | /** æªè¯» = å¯è§æå â readUserIds */ |
| | | const unreadMembers = computed(() => |
| | | visibleMembers.value.filter((member) => !readUserIds.value.includes(member.userId)) |
| | | ) |
| | | |
| | | /** |
| | | * æåå·²è¯»ç¨æ· id å表 |
| | | * |
| | | * 触åï¼el-popover @showï¼æéæå è½½ï¼é¿å
æ¯æ¡ç¾¤æ¶æ¯é½é¢æï¼ |
| | | * è·³è¿ï¼æ¬å°å 使¶æ¯ï¼id = 0ï¼è¿æ²¡æ¿å°æå¡ç«¯ idï¼ï¼åç«¯æ²¡æ³æ messageId æ¥ |
| | | * 失败ï¼ä»
æ§å¶å°åè¦ï¼readUserIds ä¿æç©ºæ°ç» â label èµ° readCount å
åºï¼ä¸é»å¡ UI |
| | | * |
| | | * æå°åååé¡ºææ readCount / receiptStatus ååå° messageStoreï¼è®© popover å¤é¢ç |
| | | * label ä¹è·çèµ°ææ°æ°ï¼ç¦»çº¿ / æ¼æ¶ RECEIPT äºä»¶æ¶æ¬å° readCount ä¼åæ§ï¼å¼¹å±éçå°"已读 5" |
| | | * ä½å¤é¢ä»æ¯"æªè¯»"ææ§äººæ°ï¼è¿é以æå¡ç«¯è¿å为åç«æ£åå» |
| | | */ |
| | | async function loadReadUsers() { |
| | | if (!props.message.id) { |
| | | return |
| | | } |
| | | try { |
| | | const userIds = await apiGetGroupReadUsers({ |
| | | groupId: props.groupId, |
| | | messageId: props.message.id |
| | | }) |
| | | readUserIds.value = userIds || [] |
| | | const readCount = readUserIds.value.length |
| | | // å
¨å¯è§æåé½å·²è¯» â æ´æ°ä¸º DONEï¼è®©å¤é¢ label ç´æ¥å½ä¸ãå
¨é¨å·²è¯»ãåæ¯ï¼ |
| | | // å¦ååªæ´æ° readCountï¼receiptStatus ç»´æä¸åï¼PENDINGï¼ |
| | | const allRead = readCount > 0 && readCount >= visibleMembers.value.length |
| | | messageStore.applyMessageReadReceipt({ |
| | | conversationType: ImConversationType.GROUP, |
| | | targetId: props.groupId, |
| | | groupMessageId: props.message.id, |
| | | readCount, |
| | | receiptStatus: allRead ? ImMessageReceiptStatus.DONE : undefined |
| | | }) |
| | | } catch (error) { |
| | | console.error('[IM] æå群已读å表失败:', error) |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | ç¾¤æ¶æ¯å·²è¯»ç¶æ |
| | | - æ ç¾å½¢æï¼å±ç¤ºãN 已读ãæãå
¨é¨å·²è¯»ãï¼ç¹å»å¼¹åº popover ç¨ tab ååºå·²è¯» / æªè¯»æå |
| | | - æ¸²ææ¡ä»¶ï¼ä»
å¨ç¾¤èãèªå·±åéãreceiptStatus !== NO_RECEIPT æ¶æåºï¼ç± MessageItem æå
³ï¼ |
| | | - 已读ååæéæï¼popover 触å show ææ /im/message/group/get-read-usersï¼é¿å
æ¯æ¡ç¾¤æ¶æ¯é½é¢æ |
| | | - æªè¯»åå = å½å群æå â èªå·± â å·²é群 â 已读éåï¼å端èåï¼å端åªå已读 userIdï¼ |
| | | --> |
| | | <Popover |
| | | v-model:open="popVisible" |
| | | placement="left" |
| | | trigger="click" |
| | | :overlay-style="{ width: '320px' }" |
| | | @open-change="(open) => open && loadReadUsers()" |
| | | > |
| | | <span |
| | | class="text-12px whitespace-nowrap cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[#409eff]" |
| | | > |
| | | {{ label }} |
| | | </span> |
| | | |
| | | <template #content> |
| | | <Tabs v-model:active-key="activeTab" centered> |
| | | <TabPane :tab="`已读(${readMembers.length})`" key="read"> |
| | | <PagedScroller :items="readMembers" :page-size="20" item-key="userId" class="h-75"> |
| | | <template #default="{ item }"> |
| | | <GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" /> |
| | | </template> |
| | | </PagedScroller> |
| | | <div |
| | | v-if="readMembers.length === 0" |
| | | class="py-5 text-12px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ groupMembers.length === 0 ? '群æåæªå è½½' : 'ææ å·²è¯»' }} |
| | | </div> |
| | | </TabPane> |
| | | <TabPane :tab="`æªè¯»(${unreadMembers.length})`" key="unread"> |
| | | <PagedScroller :items="unreadMembers" :page-size="20" item-key="userId" class="h-75"> |
| | | <template #default="{ item }"> |
| | | <GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" /> |
| | | </template> |
| | | </PagedScroller> |
| | | <div |
| | | v-if="unreadMembers.length === 0" |
| | | class="py-5 text-12px text-center text-[var(--ant-color-text-disabled)]" |
| | | > |
| | | {{ groupMembers.length === 0 ? '群æåæªå è½½' : 'å
¨é¨å·²è¯»' }} |
| | | </div> |
| | | </TabPane> |
| | | </Tabs> |
| | | </template> |
| | | </Popover> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | import { formatFileSize } from '#/packages/utils/src' |
| | | |
| | | import { CardLineLabel } from '#/views/im/home/components/card' |
| | | import { ImContentType } from '#/views/im/utils/constants' |
| | | import { getClientConversationId } from '#/views/im/utils/db' |
| | | import { |
| | | type AudioMessage, |
| | | type CardMessage, |
| | | type FaceMessage, |
| | | type FileMessage, |
| | | getFileIconInfo, |
| | | type ImageMessage, |
| | | type MaterialMessage, |
| | | parseMessage, |
| | | type QuoteMessage, |
| | | type TextMessage, |
| | | type VideoMessage |
| | | } from '#/views/im/utils/message' |
| | | import { formatSeconds } from '#/views/im/utils/time' |
| | | import { getSenderDisplayName } from '#/views/im/utils/user' |
| | | |
| | | import { useConversationStore } from '../../../../store/conversationStore' |
| | | import { useMessageStore } from '../../../../store/messageStore' |
| | | |
| | | defineOptions({ name: 'ImReplyPreview' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** æ°æ³¡å
为 true æ¯æç¹å»è·³è½¬,è¾å
¥æ¡ä¸º false */ |
| | | clickable?: boolean |
| | | /** è¾å
¥æ¡ä¸º true æ¾ç¤º à å
³éæé® */ |
| | | closable?: boolean |
| | | /** èªå·±åéçæ°æ³¡ä¸º trueï¼æç«çº¿éåå°å³ä¾§ï¼ä¸æ°æ³¡åä¾§ */ |
| | | mirrored?: boolean |
| | | quote: QuoteMessage |
| | | }>(), |
| | | { |
| | | clickable: false, |
| | | closable: false, |
| | | mirrored: false |
| | | } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | close: [] |
| | | locate: [messageId: number] |
| | | }>() |
| | | |
| | | const MAX_TEXT_PREVIEW_LEN = 60 // ææ¬æè¦å¨å¼ç¨åéå±ç¤ºçæå¤§åç¬¦æ° |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const messageStore = useMessageStore() |
| | | |
| | | /** å¨å½åä¼è¯æ¶æ¯åè¡¨éæ¥æ¾åæ¶æ¯,ä»
ç¨äºå®æ¶å¤ææ¯å¦å·²æ¤å;æè¦ / 缩ç¥å¾é½ä» quote.content ç´æ¥æ´¾ç */ |
| | | const liveMessage = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation || !props.quote.messageId) { |
| | | return undefined |
| | | } |
| | | return messageStore |
| | | .getMessages(getClientConversationId(conversation.type, conversation.targetId)) |
| | | .find((message) => message.id === props.quote.messageId) |
| | | }) |
| | | |
| | | /** å½ä¸æ¬å°ç¼åä¸ type === RECALL æå¤å®ä¸ºå·²æ¤å;ä¸å¨ç¼åçå½å¿«ç
§ä»ææ */ |
| | | const isRecalled = computed(() => liveMessage.value?.type === ImContentType.RECALL) |
| | | |
| | | /** æ¸²ææ¶å®æ¶ç®,䏿°æ³¡ä¸æ¹æ¾ç¤ºåèµ°åä¸å¥è§å,é¿å
夿³¨åæ´åå¼ç¨åéæ§ */ |
| | | const senderName = computed(() => { |
| | | const conversation = conversationStore.activeConversation |
| | | if (!conversation) { |
| | | return '' |
| | | } |
| | | return getSenderDisplayName(props.quote.senderId, conversation.type, conversation.targetId) |
| | | }) |
| | | |
| | | /** quote.content è§£æä¸æ¬¡ç¼åï¼è®©å¤ä¸ª computed å¤ç¨ï¼é¿ä¼è¯æ¯æ¡å¼ç¨æ°æ³¡å°ä¸æ¬¡ JSON.parse */ |
| | | type AnyQuotePayload = Partial< |
| | | AudioMessage & |
| | | CardMessage & |
| | | FaceMessage & |
| | | FileMessage & |
| | | ImageMessage & |
| | | MaterialMessage & |
| | | TextMessage & |
| | | VideoMessage |
| | | > |
| | | const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content)) |
| | | |
| | | const isText = computed(() => props.quote.type === ImContentType.TEXT) |
| | | const isFile = computed(() => props.quote.type === ImContentType.FILE) |
| | | const isVoice = computed(() => props.quote.type === ImContentType.VOICE) |
| | | const isCard = computed(() => props.quote.type === ImContentType.CARD) |
| | | const isFace = computed(() => props.quote.type === ImContentType.FACE) |
| | | const isMaterial = computed(() => props.quote.type === ImContentType.MATERIAL) |
| | | |
| | | /** ææ¬è¶
è¿ MAX_TEXT_PREVIEW_LEN æªæï¼é¿å
容䏿çå¼ç¨å */ |
| | | const textPreview = computed(() => { |
| | | const text = parsedPayload.value?.content ?? '' |
| | | return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.slice(0, Math.max(0, MAX_TEXT_PREVIEW_LEN))}â¦` |
| | | }) |
| | | |
| | | /** æä»¶ iconï¼ææ©å±åæè²ï¼è·ä¸»æ°æ³¡æ¸²æåæº */ |
| | | const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name)) |
| | | |
| | | /** 缩ç¥å¾ URLï¼å¾ç / è§é¢ / 表æ
è´´å¾ / é¢éç´ æå°é¢ä» quote.content ç´æ¥åï¼ä¸ä¾èµæ¬å°ç¼å */ |
| | | const thumbnailUrl = computed<string | undefined>(() => { |
| | | if (isRecalled.value) { |
| | | return undefined |
| | | } |
| | | const { type } = props.quote |
| | | if (type === ImContentType.IMAGE) { |
| | | return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url |
| | | } |
| | | if (type === ImContentType.VIDEO || type === ImContentType.MATERIAL) { |
| | | return parsedPayload.value?.coverUrl |
| | | } |
| | | if (type === ImContentType.FACE) { |
| | | return parsedPayload.value?.url |
| | | } |
| | | return undefined |
| | | }) |
| | | |
| | | /** ä»
clickable 䏿ªæ¤åæ¶è§¦å跳转 */ |
| | | function onClick() { |
| | | if (!props.clickable || isRecalled.value) { |
| | | return |
| | | } |
| | | emit('locate', props.quote.messageId) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | å¼ç¨é¢è§åï¼å¯¹é½å¾®ä¿¡ PCï¼ï¼2px ç«çº¿ä½ä¸ºãå¼ç¨ãè§è§æ è¯ + ç´§åå
容é¢è§ |
| | | - clickable=trueï¼æ°æ³¡å
ï¼ï¼ç¹å»è§¦å locate emitï¼æ¤åæç¦ç¨è·³è½¬ |
| | | - closable=trueï¼è¾å
¥æ¡ï¼ï¼å°¾é¨ à å
³éæé® |
| | | - mirrored=trueï¼èªå·±åéçæ°æ³¡ï¼ï¼ç«çº¿éåå°å³ä¾§ï¼ä¸æ°æ³¡åä¾§ |
| | | - å
容é¢è§ï¼ä¸ä¸»æ°æ³¡åæºï¼ä¸ç¼©åæã[æä»¶]/[è¯é³]ãï¼ï¼ |
| | | - ææ¬ï¼æªæå纯æå |
| | | - å¾ç / è§é¢ï¼ç¼©ç¥å¾ï¼IMAGE ç¨ thumbnailUrl/urlï¼VIDEO ç¨ coverUrlï¼ |
| | | - æä»¶ï¼file icon + æä»¶å + å¤§å° |
| | | - è¯é³ï¼audio icon + æ¶é¿ |
| | | - æ¤åï¼å½ä¸æ¬å°ç¼åä¸ type === RECALLï¼ï¼ãåæ¶æ¯å·²æ¤åãæä½ç°å |
| | | --> |
| | | <div |
| | | class="flex w-fit gap-1.5 items-center min-w-0 py-0.5 text-12px text-[var(--ant-color-text-secondary)] rounded transition-colors" |
| | | :class="[ |
| | | mirrored |
| | | ? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--ant-color-border)]' |
| | | : 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--ant-color-border)]', |
| | | { |
| | | 'cursor-pointer hover:text-[var(--ant-color-text)]': clickable && !isRecalled, |
| | | 'hover:bg-[var(--ant-color-fill-secondary)]': (clickable && !isRecalled) || closable |
| | | } |
| | | ]" |
| | | @click="onClick" |
| | | > |
| | | <span class="flex-shrink-0">{{ senderName }}:</span> |
| | | |
| | | <!-- æ¤åé级 --> |
| | | <span v-if="isRecalled" class="italic">åæ¶æ¯å·²æ¤å</span> |
| | | |
| | | <!-- ææ¬ --> |
| | | <span v-else-if="isText" class="min-w-0 line-clamp-2 break-words">{{ textPreview }}</span> |
| | | |
| | | <!-- æä»¶ï¼icon + æä»¶å + å¤§å° --> |
| | | <template v-else-if="isFile"> |
| | | <Icon :icon="fileIcon.icon" :color="fileIcon.color" :size="14" class="flex-shrink-0" /> |
| | | <span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words"> |
| | | {{ parsedPayload.name }} |
| | | </span> |
| | | <span |
| | | v-if="parsedPayload?.size" |
| | | class="flex-shrink-0 text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | {{ formatFileSize(parsedPayload.size) }} |
| | | </span> |
| | | </template> |
| | | |
| | | <!-- è¯é³ï¼audio icon + æ¶é¿ --> |
| | | <template v-else-if="isVoice"> |
| | | <Icon icon="ant-design:audio-outlined" :size="14" class="flex-shrink-0" /> |
| | | <span v-if="parsedPayload?.duration" class="flex-shrink-0"> |
| | | {{ formatSeconds(parsedPayload.duration) }} |
| | | </span> |
| | | </template> |
| | | |
| | | <!-- åç --> |
| | | <CardLineLabel |
| | | v-else-if="isCard" |
| | | :card="parsedPayload" |
| | | class="min-w-0 line-clamp-2 break-words" |
| | | /> |
| | | |
| | | <!-- 表æ
è´´å¾ï¼ç¼©ç¥å¾ + nameï¼æ name ä»
æ¾ç¤º [表æ
]ï¼ --> |
| | | <template v-else-if="isFace"> |
| | | <span class="flex-shrink-0">[表æ
]</span> |
| | | <span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words"> |
| | | {{ parsedPayload.name }} |
| | | </span> |
| | | </template> |
| | | |
| | | <!-- é¢éç´ æï¼[é¢é] + æ é¢ + å°é¢ç¼©ç¥å¾ --> |
| | | <template v-else-if="isMaterial"> |
| | | <span class="flex-shrink-0">[é¢é]</span> |
| | | <span v-if="parsedPayload?.title" class="min-w-0 line-clamp-2 break-words"> |
| | | {{ parsedPayload.title }} |
| | | </span> |
| | | </template> |
| | | |
| | | <!-- å¾ç / è§é¢ / 表æ
è´´å¾ / é¢éç´ æç¼©ç¥å¾ --> |
| | | <img |
| | | v-if="thumbnailUrl" |
| | | :src="thumbnailUrl" |
| | | class="flex-shrink-0 object-cover w-6 h-6 rounded" |
| | | alt="" |
| | | /> |
| | | |
| | | <!-- å
³éæé® --> |
| | | <button |
| | | v-if="closable" |
| | | type="button" |
| | | class="flex-shrink-0 inline-flex items-center justify-center w-4 h-4 cursor-pointer rounded-full bg-transparent border-none text-[var(--ant-color-text-secondary)] hover:bg-[var(--ant-color-fill)] hover:text-[var(--ant-color-text)]" |
| | | @click.stop="emit('close')" |
| | | > |
| | | <Icon icon="ant-design:close-outlined" :size="10" /> |
| | | </button> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | æ¶æ¯ææ¬ç段渲æï¼tip ææ¡ + TEXT æ°æ³¡å
±ç¨ |
| | | - mention 段æç¹å» â openMentionUserInfoCardAtEvent |
| | | - link 段渲æ <a target="_blank">ï¼æµè§å¨é»è®¤è¡ä¸ºæå¼æ°æ ç¾ |
| | | - text æ®µåæ ·è¾åº |
| | | --> |
| | | <script lang="ts" setup> |
| | | import type { TipSegment } from '#/views/im/utils/message' |
| | | |
| | | import { IM_AT_ALL_USER_ID } from '#/views/im/utils/constants' |
| | | import { openMentionUserInfoCardAtEvent } from '#/views/im/utils/user' |
| | | |
| | | defineOptions({ name: 'ImTipSegments' }) |
| | | |
| | | defineProps<{ |
| | | segments: TipSegment[] |
| | | }>() |
| | | |
| | | /** @å
¨ä½æåæ¯å¹¿æ mentionï¼ä»
é«äº®é
è²ï¼ä¸æå¯ç¹å»äº¤äº */ |
| | | function isClickableMention(segment: { userId: number }): boolean { |
| | | return segment.userId !== IM_AT_ALL_USER_ID |
| | | } |
| | | |
| | | /** mention 段ç¹å»ï¼fallbackName å segment ææ¬ï¼é¿å
friend / member 齿¥ä¸å°æ¶å¼¹ç©º */ |
| | | function handleMentionClick( |
| | | segment: { text: string; type: 'mention'; userId: number; }, |
| | | event: MouseEvent |
| | | ) { |
| | | if (!isClickableMention(segment)) { |
| | | return |
| | | } |
| | | openMentionUserInfoCardAtEvent(segment.userId, event, segment.text) |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <template v-for="(segment, _index) in segments" :key="_index"> |
| | | <span |
| | | v-if="segment.type === 'mention'" |
| | | class="text-[#576b95]" |
| | | :class="{ 'cursor-pointer hover:underline': isClickableMention(segment) }" |
| | | @click.stop="handleMentionClick(segment, $event)" |
| | | >{{ segment.text }}</span> |
| | | <a |
| | | v-else-if="segment.type === 'link'" |
| | | :href="segment.href" |
| | | target="_blank" |
| | | rel="noopener noreferrer" |
| | | class="text-[#576b95] hover:underline break-all" |
| | | @click.stop |
| | | >{{ segment.text }}</a> |
| | | <span v-else>{{ segment.text }}</span> |
| | | </template> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation } from '../../types' |
| | | |
| | | import { computed, nextTick, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, Dropdown, Input, Menu } from 'ant-design-vue' |
| | | |
| | | import { ImConversationType } from '../../../utils/constants' |
| | | import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation' |
| | | import { StorageKeys } from '../../../utils/db' |
| | | import { getGroupDisplayName } from '../../../utils/user' |
| | | import { ResizableAside } from '../../components' |
| | | import { FriendAddDialog } from '../../components/friend' |
| | | import { GroupCreateDialog } from '../../components/group' |
| | | import { useConversationStore } from '../../store/conversationStore' |
| | | import { useGroupStore } from '../../store/groupStore' |
| | | import { useImUiStore } from '../../store/uiStore' |
| | | import { ConversationItem, MessagePanel } from './components' |
| | | |
| | | defineOptions({ name: 'ImMessagePage' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const groupStore = useGroupStore() |
| | | const uiStore = useImUiStore() |
| | | |
| | | // ==================== ä¼è¯å表 ==================== |
| | | |
| | | const keyword = ref('') |
| | | |
| | | const sortedConversations = computed(() => conversationStore.getSortedConversationList) |
| | | |
| | | /** é¡¶é¨æç´¢æ¡è¿æ»¤ä¼è¯ï¼åªæ name 模ç³å¹é
ï¼é¿å
å½ä¸ lastContent çæ¬¡è¦åæ®µå¹²æ° */ |
| | | const filteredConversations = computed(() => |
| | | filterConversationsByKeyword(sortedConversations.value, keyword.value) |
| | | ) |
| | | |
| | | // ==================== 置顶ç¸å
³ ==================== |
| | | |
| | | const PINNED_FOLD_THRESHOLD = 3 // 置顶è¶
è¿è¯¥æ°éæ¶æ¾ç¤ºæå å
¥å£ï¼ä»¥ä¸æ°éç´æ¥éºå¼ï¼é¿å
åæ¡ç½®é¡¶å°±åºæå 头è§è§å¤ªéï¼ |
| | | |
| | | // 置顶æå å±å¼æï¼localStorage æä¹
åï¼å·æ°åä¿çç¨æ·ä¸æ¬¡çéæ©ï¼å¯¹é½å¾®ä¿¡ |
| | | const pinnedExpanded = ref( |
| | | localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true' |
| | | ) |
| | | |
| | | /** toggle + åç */ |
| | | function togglePinnedExpanded() { |
| | | pinnedExpanded.value = !pinnedExpanded.value |
| | | localStorage.setItem( |
| | | StorageKeys.localStorage.conversationPinnedExpanded, |
| | | String(pinnedExpanded.value) |
| | | ) |
| | | } |
| | | |
| | | /** 置顶ä¼è¯ï¼åç¬åçï¼ç»æå å¤´è®¡æ° + æå åºæ¸²æç¨ */ |
| | | const pinnedConversations = computed(() => filteredConversations.value.filter((c) => c.top)) |
| | | |
| | | /** é置顶ä¼è¯ï¼æå æä¸å§ç»éºå¼å¨æå 头ä¹ä¸ */ |
| | | const normalConversations = computed(() => filteredConversations.value.filter((c) => !c.top)) |
| | | |
| | | /** |
| | | * 置顶å两å ï¼visibleï¼æå 头ä¹ä¸ = æªè¯» + å½åæ¿æ´»ï¼/ foldableï¼æå 头ä¹ä¸ï¼ï¼ä¸æ¬¡ partition 宿 |
| | | * |
| | | * å½åæ¿æ´»ä¼è¯ä¹"é"å¨ visibleï¼é¿å
ç¹å¼æªè¯»ç½®é¡¶ â ç«å»è¢«è¯» â å表ä¸éªéæåæå çä½éª |
| | | */ |
| | | const pinnedGroups = computed(() => { |
| | | const visible: Conversation[] = [] |
| | | const foldable: Conversation[] = [] |
| | | for (const conversation of pinnedConversations.value) { |
| | | if (isActiveConversation(conversation) || hasUnreadBadge(conversation)) { |
| | | visible.push(conversation) |
| | | } else { |
| | | foldable.push(conversation) |
| | | } |
| | | } |
| | | return { visible, foldable } |
| | | }) |
| | | |
| | | /** æå æ¶åªæ¸² visibleï¼æªè¯» / æ¿æ´»ç©¿éï¼ï¼å±å¼æ¶æ¸²å
¨é¨ ââ å±å¼åä¸åç»ï¼é¿å
ç¹å»æå åºè·¨ç»ä¸è·³ */ |
| | | const renderedPinnedConversations = computed(() => |
| | | pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible |
| | | ) |
| | | |
| | | /** 置顶æå æ¶æ¯å¦ä¸æµ®å°æå 头ä¹ä¸ï¼ä»
以æ°åå¾½æ 为åï¼å
ææ°å³ä¾¿ææªè¯»ä¹åªå±ç¤ºå°çº¢ç¹ï¼ä¸åä¸ä¸æµ® */ |
| | | function hasUnreadBadge(conversation: Conversation): boolean { |
| | | return !conversation.silent && (conversation.unreadCount || 0) > 0 |
| | | } |
| | | |
| | | /** æ¯å¦ä¸ºå½åæ¿æ´»ä¼è¯ */ |
| | | function isActiveConversation(conversation: Conversation): boolean { |
| | | const active = conversationStore.activeConversation |
| | | return !!active && getConversationKey(active) === getConversationKey(conversation) |
| | | } |
| | | |
| | | /** |
| | | * æ¯å¦è¿å
¥"åç»æ¨¡å¼"ï¼ç¬ç«æµ
åº + å¯è½ææå 头ï¼ï¼æç´¢æ¶ä¸åç»ï¼ç¨æ·å¨æ¾äººï¼å«å让æå æ¡ä½ï¼ï¼ |
| | | * ç½®é¡¶æ° < éå¼ä¹ä¸åç»ï¼é¿å
åæ¡ç½®é¡¶å°±åºæå 头è§è§å¤ªé |
| | | */ |
| | | const showPinnedSection = computed( |
| | | () => !keyword.value.trim() && pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD |
| | | ) |
| | | |
| | | // ==================== æ·»å æå ==================== |
| | | |
| | | const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // æ·»å æåå¼¹çª refï¼å³ä¸è§ +-ä¸æãæ·»å æåãå
¥å£è° open() 触å |
| | | |
| | | // ==================== 建群ç¸å
³ ==================== |
| | | |
| | | const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // å起群èå¼¹çª refï¼handleOpenCreateGroup è° open() æå¼ |
| | | |
| | | /** æå¼å起群èå¼¹çªï¼æ éå®é¡¹çå
¨å±å
¥å£ */ |
| | | function handleOpenCreateGroup() { |
| | | createGroupDialogRef.value?.open() |
| | | } |
| | | |
| | | /** å¤ç建群æå */ |
| | | function handleGroupCreated(groupId: number) { |
| | | // GroupCreateDialog å·²ç» upsertGroup ææ°ç¾¤åè¿ storeï¼è¿éåª get + æå¼ä¼è¯ |
| | | const group = groupStore.getGroup(groupId) |
| | | if (!group) { |
| | | return |
| | | } |
| | | conversationStore.openConversation( |
| | | groupId, |
| | | ImConversationType.GROUP, |
| | | getGroupDisplayName(group), |
| | | group.avatar || '', |
| | | { silent: !!group.silent } |
| | | ) |
| | | } |
| | | |
| | | // ==================== æ»å¨å°ä¸ä¸ä¸ªæªè¯» ==================== |
| | | // å·¥å
·æ ãæ¶æ¯ãTab äºæ¬¡ç¹å» â uiStore.nextUnreadJumpNonce éå¢ â æ¬é¡µ watch æ»å¨å°ä¸ä¸æ¡æªè¯» |
| | | // å«å
ææ°ä¼è¯ï¼å°çº¢ç¹ä¹ç®æªè¯»ï¼ï¼éè¿ç»´æ¤ lastJumpedConversationKey 让è¿ç»ç¹å»é¡ºåºç©¿è¿æ´ä¸ªæªè¯»å表 |
| | | |
| | | /** 䏿¬¡å½ä¸çæªè¯»ä¼è¯ keyï¼ä¸ºç©ºæ¶ä»æªè¯»å表头å¼å§ */ |
| | | let lastJumpedConversationKey: null | string = null |
| | | |
| | | /** æ»å¨å°ä¸ä¸ä¸ªæªè¯»ä¼è¯ï¼å«å
ææ°ï¼ï¼ç®æ 被æç´¢æ¡è¿æ»¤æèå¨ç½®é¡¶æå åºæ¶å
è§£é¤æ¦æªåæ» */ |
| | | async function jumpToNextUnread() { |
| | | // å«å
ææ°çå
¨éæªè¯»ä¼è¯ï¼ç©ºåç´æ¥è¿å |
| | | const unreadList = sortedConversations.value.filter((c) => (c.unreadCount || 0) > 0) |
| | | if (unreadList.length === 0) { |
| | | return |
| | | } |
| | | // ä»ä¸æ¬¡å½ä¸é£æ¡å¾åæ¨ä¸ä½ï¼é¦æ¬¡æä¸æ¬¡ç®æ 已读å®ä¸å¨åè¡¨éæ¶ï¼refIndex=-1ï¼ä»å¤´å¼å§ |
| | | const refIndex = lastJumpedConversationKey |
| | | ? unreadList.findIndex((c) => getConversationKey(c) === lastJumpedConversationKey) |
| | | : -1 |
| | | const target = unreadList[(refIndex + 1) % unreadList.length] |
| | | if (!target) { |
| | | return |
| | | } |
| | | const key = getConversationKey(target) |
| | | lastJumpedConversationKey = key |
| | | |
| | | // ç®æ 被æç´¢å
³é®åè¿æ»¤æï¼æ¸
空 keyword 让å®éæ°è¿å
¥å¯è§å表 |
| | | if (keyword.value && !filteredConversations.value.some((c) => getConversationKey(c) === key)) { |
| | | keyword.value = '' |
| | | } |
| | | // ç®æ èå¨ç½®é¡¶æå åºï¼ä¸´æ¶å±å¼ï¼ä¸å localStorageï¼å·æ°åè¿åæå æ |
| | | const inFoldable = pinnedGroups.value.foldable.some((c) => getConversationKey(c) === key) |
| | | if (inFoldable && !pinnedExpanded.value) { |
| | | pinnedExpanded.value = true |
| | | } |
| | | |
| | | // ç keyword / pinnedExpanded ååååºå° DOM ååå»åç®æ å
ç´ |
| | | await nextTick() |
| | | const el = document.querySelector(`[data-conversation-key="${key}"]`) as HTMLElement | null |
| | | if (!el) { |
| | | return |
| | | } |
| | | // block: 'start' æç®æ ä¼è¯é¡¶å°å表å¯è§åºç¬¬ä¸è¡ï¼å¯¹é½å¾®ä¿¡"åå°ä¸ä¸ä¸ªæªè¯»=å表çç¬¬ä¸æ¡" |
| | | el.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
| | | } |
| | | |
| | | watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- æ¶æ¯ Tabï¼å·¦ä¾§ä¼è¯å表 + å³ä¾§è天颿¿ --> |
| | | <div class="flex flex-1 min-w-0 h-full"> |
| | | <!-- 左侧ä¼è¯å表ï¼å¯ææ½å®½åº¦ï¼ --> |
| | | <ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth"> |
| | | <!-- é¡¶é¨ï¼æç´¢æ¡ + "+" å·ä¸æï¼å¯¹é½å¾®ä¿¡ PCï¼å起群è / æ·»å æåï¼ï¼h-14 ä¸å³ä¾§ MessagePanel 头é¨å¯¹é½ --> |
| | | <div |
| | | class="flex flex-shrink-0 gap-2 items-center h-14 px-4 border-b border-b-solid border-[var(--im-border-color-lighter)]" |
| | | > |
| | | <Input v-model:value="keyword" placeholder="æç´¢" allow-clear class="flex-1"> |
| | | <template #prefix> |
| | | <Icon icon="ant-design:search-outlined" /> |
| | | </template> |
| | | </Input> |
| | | <Dropdown :trigger="['click']" placement="bottom"> |
| | | <Button size="small" shape="circle"> |
| | | <Icon icon="ant-design:plus-outlined" /> |
| | | </Button> |
| | | <template #overlay> |
| | | <Menu> |
| | | <Menu.Item @click="handleOpenCreateGroup"> |
| | | <span class="inline-flex items-center gap-2"> |
| | | <Icon icon="ant-design:message-outlined" :size="16" /> |
| | | <span>å起群è</span> |
| | | </span> |
| | | </Menu.Item> |
| | | <Menu.Item @click="friendAddDialogRef?.open()"> |
| | | <span class="inline-flex items-center gap-2"> |
| | | <Icon icon="ant-design:user-add-outlined" :size="16" /> |
| | | <span>æ·»å æå</span> |
| | | </span> |
| | | </Menu.Item> |
| | | </Menu> |
| | | </template> |
| | | </Dropdown> |
| | | </div> |
| | | |
| | | <!-- ä¼è¯åè¡¨ä¸»ä½ --> |
| | | <div class="flex-1 overflow-y-auto"> |
| | | <!-- 置顶ä¼è¯ï¼æ°é < é弿æç´¢ä¸ç´æ¥éºå¼ï¼å¦åè¿å
¥åç»æ¨¡å¼ï¼ç¬ç«æµ
åº + å¯éæå å¤´ï¼ --> |
| | | <template v-if="!showPinnedSection"> |
| | | <ConversationItem |
| | | v-for="conversation in pinnedConversations" |
| | | :key="getConversationKey(conversation)" |
| | | :conversation="conversation" |
| | | /> |
| | | </template> |
| | | <div v-else class="bg-[var(--ant-color-fill-secondary)]"> |
| | | <ConversationItem |
| | | v-for="conversation in renderedPinnedConversations" |
| | | :key="getConversationKey(conversation)" |
| | | :conversation="conversation" |
| | | /> |
| | | |
| | | <!-- æå å¤´ï¼æ¾å¨ç½®é¡¶åºåºé¨å¯¹é½ WeChat macï¼å±å¼ / æå æå
±ç¨ï¼ä»
å¨è¿æ"坿å "å
容ãæå½åå·²å±å¼æ¶åºç° --> |
| | | <div |
| | | v-if="pinnedGroups.foldable.length > 0 || pinnedExpanded" |
| | | class="flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors text-13px text-[var(--ant-color-text)] border-y border-y-solid border-[var(--im-border-color-lighter)] hover:bg-[var(--ant-color-fill)]" |
| | | @click="togglePinnedExpanded" |
| | | > |
| | | <span class="flex items-center gap-1.5"> |
| | | <Icon icon="ant-design:menu-outlined" :size="14" /> |
| | | {{ |
| | | pinnedExpanded |
| | | ? 'æå 置顶è天' |
| | | : `${pinnedGroups.foldable.length} 个置顶è天` |
| | | }} |
| | | </span> |
| | | <Icon |
| | | :icon="pinnedExpanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'" |
| | | :size="11" |
| | | class="text-[var(--ant-color-text-placeholder)]" |
| | | /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- æ®éä¼è¯ --> |
| | | <ConversationItem |
| | | v-for="conversation in normalConversations" |
| | | :key="getConversationKey(conversation)" |
| | | :conversation="conversation" |
| | | /> |
| | | |
| | | <div |
| | | v-if="filteredConversations.length === 0" |
| | | class="flex items-center justify-center py-10 text-sm text-[var(--ant-color-text-secondary)]" |
| | | > |
| | | {{ keyword ? 'æ²¡ææ»¡è¶³æ¡ä»¶çä¼è¯' : 'ææ ä¼è¯' }} |
| | | </div> |
| | | </div> |
| | | </ResizableAside> |
| | | |
| | | <!-- å³ä¾§è天颿¿ --> |
| | | <MessagePanel /> |
| | | |
| | | <!-- æ·»å æå / å起群èå¼¹çª --> |
| | | <FriendAddDialog ref="friendAddDialogRef" /> |
| | | <GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" /> |
| | | </div> |
| | | </template> |
| src/views/im/home/store/channelStore.ts
src/views/im/home/store/conversationStore.ts
src/views/im/home/store/faceStore.ts
src/views/im/home/store/friendStore.ts
src/views/im/home/store/groupRequestStore.ts
src/views/im/home/store/groupStore.ts
src/views/im/home/store/messageStore.ts
src/views/im/home/store/rtcStore.ts
src/views/im/home/store/uiStore.ts
src/views/im/home/store/websocketStore.ts
src/views/im/home/types/index.ts
src/views/im/manager/channel/list/components/index.ts
src/views/im/manager/channel/list/components/select.vue
src/views/im/manager/channel/list/data.ts
src/views/im/manager/channel/list/index.vue
src/views/im/manager/channel/list/modules/form.vue
src/views/im/manager/channel/material/components/index.ts
src/views/im/manager/channel/material/components/select.vue
src/views/im/manager/channel/material/data.ts
src/views/im/manager/channel/material/index.vue
src/views/im/manager/channel/material/modules/form.vue
src/views/im/manager/channel/message/index.vue
src/views/im/manager/channel/message/modules/send-form.vue
src/views/im/manager/face/pack/data.ts
src/views/im/manager/face/pack/index.vue
src/views/im/manager/face/pack/modules/form.vue
src/views/im/manager/face/pack/modules/item-drawer.vue
src/views/im/manager/face/pack/modules/item-form.vue
src/views/im/manager/face/userItem/index.vue
src/views/im/manager/friend/data.ts
src/views/im/manager/friend/index.vue
src/views/im/manager/friend/request/index.vue
src/views/im/manager/group/components/index.ts
src/views/im/manager/group/components/select-dialog.vue
src/views/im/manager/group/components/select.vue
src/views/im/manager/group/data.ts
src/views/im/manager/group/index.vue
src/views/im/manager/group/modules/ban-form.vue
src/views/im/manager/group/modules/detail.vue
src/views/im/manager/group/request/index.vue
src/views/im/manager/message/content-preview.vue
src/views/im/manager/message/data.ts
src/views/im/manager/message/group/index.vue
src/views/im/manager/message/group/modules/detail.vue
src/views/im/manager/message/index.ts
src/views/im/manager/message/private/index.vue
src/views/im/manager/message/private/modules/detail.vue
src/views/im/manager/rtc/data.ts
src/views/im/manager/rtc/index.vue
src/views/im/manager/rtc/modules/detail.vue
src/views/im/manager/sensitiveword/data.ts
src/views/im/manager/sensitiveword/index.vue
src/views/im/manager/sensitiveword/modules/form.vue
src/views/im/manager/statistics/components/distribution-chart.vue
src/views/im/manager/statistics/components/index.ts
src/views/im/manager/statistics/components/overview-cards.vue
src/views/im/manager/statistics/components/trend-chart.vue
src/views/im/manager/statistics/index.vue
src/views/im/manager/utils/format.ts
src/views/im/utils/auth.ts
src/views/im/utils/channel.ts
src/views/im/utils/config.ts
src/views/im/utils/constants.ts
src/views/im/utils/conversation.ts
src/views/im/utils/db.ts
src/views/im/utils/emoji.ts
src/views/im/utils/group.ts
src/views/im/utils/image.ts
src/views/im/utils/message.ts
src/views/im/utils/pull.ts
src/views/im/utils/time.ts
src/views/im/utils/user.ts |