From 4868d69ed87469c749bb243c03d11bef73b58075 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Wed, 6 May 2026 12:18:31 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=90=8D=E7=89=87=E6=B6=88=E6=81=AF=E7=B1=BB=E5=9E=8B=20v0.4?= =?UTF-8?q?=EF=BC=9A=E5=A2=9E=E5=8A=A0=E8=BD=AC=E5=8F=91=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/user/RecommendCardDialog.vue | 26 +++++++++++++----- .../im/home/composables/useMessageSender.ts | 27 +++++++++++++------ src/views/im/utils/constants.ts | 12 ++++++++- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/views/im/home/components/user/RecommendCardDialog.vue b/src/views/im/home/components/user/RecommendCardDialog.vue index bd091efda..a9d1ff948 100644 --- a/src/views/im/home/components/user/RecommendCardDialog.vue +++ b/src/views/im/home/components/user/RecommendCardDialog.vue @@ -293,7 +293,12 @@ function buildCardContent(user: User): string { return serializeMessage(payload) } -/** 确认发送:每个选中会话先发 CARD 再发 TEXT 留言;失败的消息会以 FAILED 状态留在对应会话气泡里供右键重试 */ +/** + * 确认发送:每个选中会话先发 CARD,CARD 成功后才发留言(保证「先看到名片」的顺序意图,CARD 失败时不发留言避免错序) + * + * 文案聚合:全部成功「已转发」、全部失败「转发失败:A、B」、部分失败「已转发,但 X、Y 失败」(具体列出失败会话名方便定位); + * 失败的消息以 FAILED 状态留在对应会话气泡里,可右键重试 + */ async function handleSend() { const user = props.user if (!user?.id || selectedKeys.value.length === 0) { @@ -305,13 +310,22 @@ async function handleSend() { sending.value = true try { const tasks = targets.map(async (target) => { - await sendRaw(ImMessageType.CARD, cardContent, { conversation: target }) - if (leaveText) { - await send(leaveText, { conversation: target }) + const cardOk = await sendRaw(ImMessageType.CARD, cardContent, { conversation: target }) + if (!cardOk) { + return { target, ok: false } } + const ok = leaveText ? await send(leaveText, { conversation: target }) : true + return { target, ok } }) - await Promise.all(tasks) - message.success('已转发') + const results = await Promise.all(tasks) + const failedNames = results.filter((r) => !r.ok).map((r) => r.target.name || '未命名会话') + 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 diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 0fc019d46..3349d5472 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -83,16 +83,22 @@ export const useMessageSender = () => { * 发送任意类型的消息(底层实现) * 1. 文本、图片、文件、语音等都走这里 * 2. type / content 由调用方构造 + * 3. 返回值:成功 true / 失败 false(失败时本地占位已标 FAILED);参数缺失等无法发送的场景也返 false + * 转发 / 名片推荐等场景按返回值决定是否继续后续动作(如有留言时仅在名片成功后再发留言) */ - const sendRaw = async (type: number, content: string, options?: SendExtOptions) => { + const sendRaw = async ( + type: number, + content: string, + options?: SendExtOptions + ): Promise => { // 1. 参数校验:优先用显式传入的 conversation(转发场景),否则取激活会话 const conversation = options?.conversation ?? conversationStore.activeConversation if (!conversation) { - return + return false } const realTarget = options?.targetId || conversation.targetId if (!realTarget) { - return + return false } // 2. 准备 clientMessageId:媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id;其余场景走默认乐观插入 @@ -106,7 +112,7 @@ export const useMessageSender = () => { (m) => m.clientMessageId === clientMessageId ) if (!stillExists) { - return + return false } } else { clientMessageId = generateClientMessageId() @@ -159,21 +165,26 @@ export const useMessageSender = () => { content: data.content }) } + return true } catch (e) { console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e) conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { status: ImMessageStatus.FAILED }) + return false } } - /** 发送文本消息(最常用的快捷入口):MessageInput.vue 文本回车走这里 */ - const send = async (text: string, options?: SendExtOptions) => { + /** + * 发送文本消息(最常用的快捷入口):MessageInput.vue 文本回车走这里 + * 返回值:成功 true / 失败 false / 空文本 false(与 sendRaw 对齐,转发场景按返回值判断) + */ + const send = async (text: string, options?: SendExtOptions): Promise => { if (!text.trim()) { - return + return false } const payload = withQuotePayload({ content: text }, options?.quote) - await sendRaw(ImMessageType.TEXT, serializeMessage(payload), options) + return sendRaw(ImMessageType.TEXT, serializeMessage(payload), options) } /** diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index 1f1d8a667..19d646443 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -69,7 +69,17 @@ export function isFriendChatTip(type: number): boolean { return type === ImMessageType.FRIEND_ADD || type === ImMessageType.FRIEND_DELETE } -/** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */ +/** + * IM 普通消息类型集合(normal vs event 二分;与后端 ImMessageTypeEnum.normal 字段语义一致) + * + * 这个集合在多处被复用,新增类型前先确认所有副作用都符合预期: + * 1. 后端发送入口校验(Im{Private,Group}MessageSendReqVO.isNormalType)—— 用户发送的消息类型必须 normal=true + * 2. 前端接收侧未读 / 提示音(websocketStore)—— normal 消息计入会话未读数 + 触发声音 + * 3. 前端会话列表 lastType / @ 标签(ConversationItem)—— 只有 normal 才算「最后一条聊天消息」 + * 4. 前端群消息置顶菜单(MessageItem.vue 的 canPin)—— normal 才允许群主 / 管理员置顶 + * + * 名片(CARD)属于「用户主动发的聊天消息」,1/2/3 都符合预期;4 同时被放开 = 群主可置顶名片,语义合理 + */ const ImMessageTypeNormals: number[] = [ ImMessageType.TEXT, ImMessageType.IMAGE,