✨ feat(im): 优化名片消息类型 v0.4:增加转发成功失败的提示

pull/881/head
YunaiV 2026-05-06 12:18:31 +08:00
parent 1ac0650984
commit 4868d69ed8
3 changed files with 50 additions and 15 deletions

View File

@ -293,7 +293,12 @@ function buildCardContent(user: User): string {
return serializeMessage(payload) return serializeMessage(payload)
} }
/** 确认发送:每个选中会话先发 CARD 再发 TEXT 留言;失败的消息会以 FAILED 状态留在对应会话气泡里供右键重试 */ /**
* 确认发送:每个选中会话先发 CARD,CARD 成功后才发留言(保证「先看到名片」的顺序意图,CARD 失败时不发留言避免错序)
*
* 文案聚合:全部成功「已转发」、全部失败「转发失败:A、B」、部分失败「已转发,但 X、Y 失败」(具体列出失败会话名方便定位);
* 失败的消息以 FAILED 状态留在对应会话气泡里,可右键重试
*/
async function handleSend() { async function handleSend() {
const user = props.user const user = props.user
if (!user?.id || selectedKeys.value.length === 0) { if (!user?.id || selectedKeys.value.length === 0) {
@ -305,13 +310,22 @@ async function handleSend() {
sending.value = true sending.value = true
try { try {
const tasks = targets.map(async (target) => { const tasks = targets.map(async (target) => {
await sendRaw(ImMessageType.CARD, cardContent, { conversation: target }) const cardOk = await sendRaw(ImMessageType.CARD, cardContent, { conversation: target })
if (leaveText) { if (!cardOk) {
await send(leaveText, { conversation: target }) return { target, ok: false }
} }
const ok = leaveText ? await send(leaveText, { conversation: target }) : true
return { target, ok }
}) })
await Promise.all(tasks) const results = await Promise.all(tasks)
message.success('已转发') 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 visible.value = false
} finally { } finally {
sending.value = false sending.value = false

View File

@ -83,16 +83,22 @@ export const useMessageSender = () => {
* 发送任意类型的消息(底层实现) * 发送任意类型的消息(底层实现)
* 1. 文本、图片、文件、语音等都走这里 * 1. 文本、图片、文件、语音等都走这里
* 2. type / content 由调用方构造 * 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<boolean> => {
// 1. 参数校验:优先用显式传入的 conversation(转发场景),否则取激活会话 // 1. 参数校验:优先用显式传入的 conversation(转发场景),否则取激活会话
const conversation = options?.conversation ?? conversationStore.activeConversation const conversation = options?.conversation ?? conversationStore.activeConversation
if (!conversation) { if (!conversation) {
return return false
} }
const realTarget = options?.targetId || conversation.targetId const realTarget = options?.targetId || conversation.targetId
if (!realTarget) { if (!realTarget) {
return return false
} }
// 2. 准备 clientMessageId:媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id;其余场景走默认乐观插入 // 2. 准备 clientMessageId:媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id;其余场景走默认乐观插入
@ -106,7 +112,7 @@ export const useMessageSender = () => {
(m) => m.clientMessageId === clientMessageId (m) => m.clientMessageId === clientMessageId
) )
if (!stillExists) { if (!stillExists) {
return return false
} }
} else { } else {
clientMessageId = generateClientMessageId() clientMessageId = generateClientMessageId()
@ -159,21 +165,26 @@ export const useMessageSender = () => {
content: data.content content: data.content
}) })
} }
return true
} catch (e) { } catch (e) {
console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e) console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e)
conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, {
status: ImMessageStatus.FAILED 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<boolean> => {
if (!text.trim()) { if (!text.trim()) {
return return false
} }
const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote) const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote)
await sendRaw(ImMessageType.TEXT, serializeMessage(payload), options) return sendRaw(ImMessageType.TEXT, serializeMessage(payload), options)
} }
/** /**

View File

@ -69,7 +69,17 @@ export function isFriendChatTip(type: number): boolean {
return type === ImMessageType.FRIEND_ADD || type === ImMessageType.FRIEND_DELETE 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[] = [ const ImMessageTypeNormals: number[] = [
ImMessageType.TEXT, ImMessageType.TEXT,
ImMessageType.IMAGE, ImMessageType.IMAGE,