feat(im):文本气泡 @ 高亮支持点击 + URL 自动识别成可点击链接

- @ 段:群消息按 atUserIds 反查群成员,候选 name 兼容历史字面量(真实昵称 / 好友备注 / 群自定义昵称),displayName 统一收敛到 nickname,让历史消息也能渲染成 @真实昵称;@ 段点击弹 UserInfoCard
- @所有人:注入 IM_AT_ALL_USER_ID 虚拟候选,对齐微信 PC 仅高亮配色不挂点击
- 同名歧义:同字面量对应多个 userId 时标记 ambiguous,parser 整段消费成普通文本,避免错绑用户
- URL:识别 http(s) / www. 起头链接,<a target="_blank"> 新标签打开;默认补 https://
- TipSegment 加 link 变体作为统一文本片段类型,TEXT 气泡与灰条 tip 共用 TipSegments 组件渲染
- MessageInput @ token 文本统一用真实昵称,不再掺好友备注 / 群自定义昵称
im
YunaiV 2026-05-08 01:23:09 +08:00
parent 094ab44094
commit 40ac2daca8
6 changed files with 276 additions and 42 deletions

View File

@ -739,12 +739,13 @@ function onMentionSelect(member: GroupMemberLite) {
} }
// @keyword contenteditable=false token // @keyword contenteditable=false token
// + 穿data-id collectFromEditor atUserIds // + 穿data-id collectFromEditor atUserIds
// token /
mentionRange.deleteContents() mentionRange.deleteContents()
const span = document.createElement('span') const span = document.createElement('span')
span.className = 'mention-token' span.className = 'mention-token'
span.dataset.id = String(member.userId) span.dataset.id = String(member.userId)
span.contentEditable = 'false' span.contentEditable = 'false'
span.textContent = `@${member.showName}` span.textContent = `@${member.nickname || member.showName}`
mentionRange.insertNode(span) mentionRange.insertNode(span)
// token editor contenteditable=false token // token editor contenteditable=false token
// DOM walk // DOM walk

View File

@ -1,11 +1,11 @@
<template> <template>
<!-- 文本 --> <!-- 文本 segment 渲染mention 高亮可点击URL 自动识别成可点击链接 -->
<div <div
v-if="isText && textPayload" v-if="isText && textPayload"
class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg" class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
:class="bubbleClass('text')" :class="bubbleClass('text')"
> >
{{ textPayload.content }} <TipSegments :segments="textSegments" />
</div> </div>
<!-- 图片el-image 内置预览上传中半透明遮罩 --> <!-- 图片el-image 内置预览上传中半透明遮罩 -->
@ -167,18 +167,21 @@ import { formatSeconds } from '@/utils/formatTime'
import { ImMessageType, MERGE_FORWARD_PREVIEW_LINES } from '@/views/im/utils/constants' import { ImMessageType, MERGE_FORWARD_PREVIEW_LINES } from '@/views/im/utils/constants'
import { import {
parseMessage, parseMessage,
parseTextSegments,
getFileIconInfo, getFileIconInfo,
type AudioMessage, type AudioMessage,
type CardMessage, type CardMessage,
type FaceMessage, type FaceMessage,
type FileMessage, type FileMessage,
type ImageMessage, type ImageMessage,
type MentionCandidate,
type MergeMessage, type MergeMessage,
type TextMessage, type TextMessage,
type VideoMessage type VideoMessage
} from '@/views/im/utils/message' } from '@/views/im/utils/message'
import { summarizeMessageContent } from '@/views/im/utils/conversation' import { summarizeMessageContent } from '@/views/im/utils/conversation'
import CardBubble from '@/views/im/home/components/card/CardBubble.vue' import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
import TipSegments from './TipSegments.vue'
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer' import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
defineOptions({ name: 'ImMessageBubble' }) defineOptions({ name: 'ImMessageBubble' })
@ -192,6 +195,8 @@ const props = defineProps<{
selfSend?: boolean selfSend?: boolean
/** 媒体上传进度0-100非 null 即视为上传中,渲染遮罩 / 进度条 */ /** 媒体上传进度0-100非 null 即视为上传中,渲染遮罩 / 进度条 */
uploadProgress?: number | null uploadProgress?: number | null
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
mentions?: MentionCandidate[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -224,6 +229,15 @@ const uploadProgressText = computed(() => `${uploadProgress.value}%`)
const parsedContent = computed<unknown>(() => parseMessage(props.content)) const parsedContent = computed<unknown>(() => parseMessage(props.content))
const textPayload = computed(() => (isText.value ? (parsedContent.value as TextMessage | null) : null)) const textPayload = computed(() => (isText.value ? (parsedContent.value as TextMessage | null) : null))
/** 文本气泡 segment 数组mention 高亮 + URL 自动识别 + 普通文本三段拼接 */
const textSegments = computed(() => {
const content = textPayload.value?.content
if (!content) {
return []
}
return parseTextSegments(content, props.mentions || [])
})
const imagePayload = computed(() => const imagePayload = computed(() =>
isImage.value ? (parsedContent.value as ImageMessage | null) : null isImage.value ? (parsedContent.value as ImageMessage | null) : null
) )

View File

@ -80,6 +80,7 @@
:content="message.content" :content="message.content"
:self-send="message.selfSend" :self-send="message.selfSend"
:upload-progress="message.uploadProgress" :upload-progress="message.uploadProgress"
:mentions="textMentions"
@click-card="handleCardClick" @click-card="handleCardClick"
@open-merge="handleMergeOpen" @open-merge="handleMergeOpen"
/> />
@ -176,6 +177,7 @@ import {
getQuoteFromMessage, getQuoteFromMessage,
parseMessage, parseMessage,
type CardMessage, type CardMessage,
type MentionCandidate,
type TextMessage type TextMessage
} from '@/views/im/utils/message' } from '@/views/im/utils/message'
import { buildRecallTipSegments } from '@/views/im/utils/conversation' import { buildRecallTipSegments } from '@/views/im/utils/conversation'
@ -188,6 +190,7 @@ import { useDraftStore } from '../../../../store/draftStore'
import { useFaceStore } from '../../../../store/faceStore' import { useFaceStore } from '../../../../store/faceStore'
import { import {
getMemberDisplayName, getMemberDisplayName,
getMentionCandidates,
getSenderDisplayName, getSenderDisplayName,
getSenderRealNickname, getSenderRealNickname,
resolveFriendNotificationSegments, resolveFriendNotificationSegments,
@ -305,6 +308,14 @@ function handleMergeOpen(content: string) {
openMergeDetail?.(content) openMergeDetail?.(content)
} }
/** 文本气泡 @ mention 候选名字:仅群消息有效,按 atUserIds 反查群成员真实昵称;非 TEXT 不走 store 读,让 getMentionCandidates 直接返回稳定空数组 */
const textMentions = computed<MentionCandidate[]>(() => {
if (props.message.type !== ImMessageType.TEXT) {
return getMentionCandidates(undefined, null)
}
return getMentionCandidates(props.message.atUserIds, conversationStore.activeConversation)
})
/** 名片点击:用户名片弹 UserInfoCard群名片弹 GroupInfoCard其它 targetType含改包脏数据忽略 */ /** 名片点击:用户名片弹 UserInfoCard群名片弹 GroupInfoCard其它 targetType含改包脏数据忽略 */
function handleCardClick(card: CardMessage, e: MouseEvent) { function handleCardClick(card: CardMessage, e: MouseEvent) {
if (!card?.targetId) { if (!card?.targetId) {

View File

@ -1,27 +1,35 @@
<!-- <!--
会话内灰条 tip 片段渲染依赖 activeConversation 推断点击落点的 addSource / 群名 消息文本片段渲染tip 文案 + TEXT 气泡共用
- mention 段挂点击 openMentionUserInfoCardAtEvent
- link 段渲染 <a target="_blank">浏览器默认行为打开新标签
- text 段原样输出
--> -->
<template> <template>
<template v-for="(segment, _index) in segments" :key="_index"> <template v-for="(segment, _index) in segments" :key="_index">
<span <span
v-if="segment.type === 'mention'" v-if="segment.type === 'mention'"
class="cursor-pointer text-[#576b95] hover:underline" class="text-[#576b95]"
:class="{ 'cursor-pointer hover:underline': isClickableMention(segment) }"
@click.stop="handleMentionClick(segment, $event)" @click.stop="handleMentionClick(segment, $event)"
>{{ segment.text }}</span >{{ 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> <span v-else>{{ segment.text }}</span>
</template> </template>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ImConversationType, ImFriendAddSource } from '@/views/im/utils/constants' import { IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
import type { TipSegment } from '@/views/im/utils/message' import type { TipSegment } from '@/views/im/utils/message'
import { getSenderAvatar } from '@/views/im/utils/user' import { openMentionUserInfoCardAtEvent } from '@/views/im/utils/user'
import type { User } from '../../../../types'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useImUiStore } from '../../../../store/uiStore'
defineOptions({ name: 'ImTipSegments' }) defineOptions({ name: 'ImTipSegments' })
@ -29,34 +37,19 @@ defineProps<{
segments: TipSegment[] segments: TipSegment[]
}>() }>()
/** /** @全体成员是广播 mention仅高亮配色不挂可点击交互 */
* nickname 不传 friend.displayName / member.displayUserName 等备注避免 UserInfo 首屏闪非真实昵称 function isClickableMention(segment: { userId: number }): boolean {
* addSourceExtra 不用 conversation.name 兜底conversation.name = groupRemark || group.name return segment.userId !== IM_AT_ALL_USER_ID
* 个人备注会污染加好友话术 }
*/
/** mention 段点击fallbackName 取 segment 文本,避免 friend / member 都查不到时弹空 */
function handleMentionClick( function handleMentionClick(
segment: { type: 'mention'; userId: number; text: string }, segment: { type: 'mention'; userId: number; text: string },
event: MouseEvent event: MouseEvent
) { ) {
const conversation = useConversationStore().activeConversation if (!isClickableMention(segment)) {
const isGroup = conversation?.type === ImConversationType.GROUP return
const group = isGroup ? useGroupStore().getGroup(conversation!.targetId) : undefined
const member = group?.members?.find((m) => m.userId === segment.userId)
const friend = useFriendStore().getFriend(segment.userId)
const user: User = {
id: segment.userId,
nickname: friend?.nickname || member?.nickname || segment.text,
avatar: getSenderAvatar(
segment.userId,
conversation?.type ?? 0,
conversation?.targetId ?? 0
)
} }
useImUiStore().openUserInfoCardAtEvent( openMentionUserInfoCardAtEvent(segment.userId, event, segment.text)
user,
event,
isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
isGroup ? group?.name || '' : ''
)
} }
</script> </script>

View File

@ -22,13 +22,15 @@ export const generateClientMessageId = (): string => {
return generateUUID() return generateUUID()
} }
// ==================== Tip 片段(灰条文案渲染用) ==================== // ==================== 文本片段tip 文案 + TEXT 气泡共用) ====================
// 把"XX 邀请 YY 加入群聊""XX 撤回了一条消息"等 tip 文案拆成 segment 数组, // 既用于灰条 tip"XX 邀请 YY 加入群聊"),也用于 TEXT 气泡正文(@xxx 高亮 + URL 自动识别)。
// mention 段携带 userId渲染层据此挂点击事件弹 UserInfoCard。 // mention 段携带 userId 用于挂点击弹 UserInfoCardlink 段携带 href 用于 <a> 跳转;
// text 段是纯文本兜底。渲染层TipSegments.vue按 type 分发统一处理。
export type TipSegment = export type TipSegment =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'mention'; userId: number; text: string } | { type: 'mention'; userId: number; text: string }
| { type: 'link'; href: string; text: string }
export const tipText = (text: string): TipSegment => ({ type: 'text', text }) export const tipText = (text: string): TipSegment => ({ type: 'text', text })
@ -38,6 +40,12 @@ export const tipMention = (userId: number, text: string): TipSegment => ({
text text
}) })
export const tipLink = (href: string, text: string): TipSegment => ({
type: 'link',
href,
text
})
export const segmentsToText = (segments: TipSegment[]): string => export const segmentsToText = (segments: TipSegment[]): string =>
segments.map((s) => s.text).join('') segments.map((s) => s.text).join('')
@ -54,6 +62,101 @@ export function joinMentionSegments(
) )
} }
/** mention 候选name 用于匹配文本字面量displayName 用于覆盖渲染(不传则按 name 渲染) */
export interface MentionCandidate {
userId: number
/** 用来在 content 中前缀匹配的字面量(不含 @ */
name: string
/**
* 使 @
*
* content /
* match displayName nickname
* / @
*/
displayName?: string
/**
* name userId true
*
* parser name
* @@
*/
ambiguous?: boolean
}
/** URL 锚定正则;终止于空白、中文、@(避免吞掉下一个 mention、< > " '(防 HTML / 引号串入y 标志走 sticky 匹配,省 text.slice */
const URL_STICKY_REGEX = /(https?:\/\/[^\s-@<>"']+|www\.[^\s-@<>"']+)/iy
/** URL 末尾常见标点剔除,避免吞掉句末中英文标点 */
const URL_TRAILING_PUNCTUATION = /[.,!?;:)\]、,。!?;:)】]+$/
/** 最短 URL`www.ab` / `http:/` 这种孤段不算链接 */
const URL_MIN_LENGTH = 6
/**
* content mention URL text
*
* mentions @xxx 退 text store
*/
export function parseTextSegments(
text: string,
mentions: MentionCandidate[] = []
): TipSegment[] {
if (!text) {
return []
}
// 「@张三丰」不能被「@张三」截胡,候选按 name 长度倒序
const sortedMentions =
mentions.length > 1 ? [...mentions].sort((a, b) => b.name.length - a.name.length) : mentions
const out: TipSegment[] = []
let buffer = ''
const flush = () => {
if (buffer) {
out.push(tipText(buffer))
buffer = ''
}
}
let i = 0
while (i < text.length) {
if (text[i] === '@' && sortedMentions.length > 0) {
const matched = sortedMentions.find((m) => m.name && text.startsWith(m.name, i + 1))
if (matched) {
if (matched.ambiguous) {
// 同字面量对应多 userId无法判定意图整段累入 buffer 让短前缀候选没机会再扫这段
buffer += '@' + matched.name
} else {
flush()
// 渲染统一走 displayName即真实昵称让历史 content 里残留的备注 / 群昵称也收敛到 nickname
out.push(tipMention(matched.userId, '@' + (matched.displayName || matched.name)))
}
i += 1 + matched.name.length
continue
}
}
const head = text[i]
if (head === 'h' || head === 'H' || head === 'w' || head === 'W') {
URL_STICKY_REGEX.lastIndex = i
const linkMatch = URL_STICKY_REGEX.exec(text)
if (linkMatch) {
const urlText = linkMatch[0].replace(URL_TRAILING_PUNCTUATION, '')
if (urlText.length >= URL_MIN_LENGTH) {
flush()
const href = /^https?:\/\//i.test(urlText) ? urlText : `https://${urlText}`
out.push(tipLink(href, urlText))
i += urlText.length
continue
}
}
}
buffer += text[i]
i += 1
}
flush()
return out
}
// ==================== 引用消息 ==================== // ==================== 引用消息 ====================
/** 引用消息 payload(对齐后端 QuoteMessage) */ /** 引用消息 payload(对齐后端 QuoteMessage) */

View File

@ -9,20 +9,35 @@
// 命名约定:显示名相关函数一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐 // 命名约定:显示名相关函数一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐
// ==================================================================== // ====================================================================
import { countBy } from 'lodash-es'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { SystemUserSexEnum } from '@/utils/constants' import { SystemUserSexEnum } from '@/utils/constants'
import { ImConversationType, ImMessageType } from './constants' import {
ImConversationType,
ImFriendAddSource,
ImMessageType,
IM_AT_ALL_NICKNAME,
IM_AT_ALL_USER_ID
} from './constants'
import { getCurrentUserId } from './storage' import { getCurrentUserId } from './storage'
import { import {
joinMentionSegments, joinMentionSegments,
segmentsToText, segmentsToText,
tipMention, tipMention,
tipText, tipText,
type MentionCandidate,
type TipSegment type TipSegment
} from './message' } from './message'
import { useConversationStore } from '../home/store/conversationStore'
import { useFriendStore } from '../home/store/friendStore' import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore' import { useGroupStore } from '../home/store/groupStore'
import type { Friend, Group } from '../home/types' import { useImUiStore } from '../home/store/uiStore'
import type { Conversation, Friend, Group, User } from '../home/types'
// 候选缺失场景的稳定空数组;让 textMentions computed 在非 TEXT / 非群聊 / 无 @ 时返回同一引用,
// MessageBubble 的 textSegments 才不会跟着无谓重算
const EMPTY_MENTIONS: MentionCandidate[] = []
/** /**
* > * >
@ -161,7 +176,6 @@ export function getSenderRealNickname(
return String(senderId) return String(senderId)
} }
// TODO @AI这个方法还需要么之前是哪个调用的可能要看看。
/** /**
* conversation group.members / friend / userStore * conversation group.members / friend / userStore
* *
@ -189,6 +203,104 @@ export function getSenderAvatar(
return useFriendStore().getFriend(senderId)?.avatar || '' return useFriendStore().getFriend(senderId)?.avatar || ''
} }
/**
* @ mention atUserIds name / displayName
*
* userId ambiguous parser
* @@
*/
export function getMentionCandidates(
atUserIds: number[] | undefined,
conversation: Pick<Conversation, 'type' | 'targetId'> | null | undefined
): MentionCandidate[] {
if (!atUserIds || atUserIds.length === 0) {
return EMPTY_MENTIONS
}
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return EMPTY_MENTIONS
}
// 群成员预建 Map避免每个 atUserId 走一次 array find@全体成员场景下成员数 × atUserIds 是 N²
const members = useGroupStore().getGroup(conversation.targetId)?.members || []
const memberById = new Map(members.map((m) => [m.userId, m]))
const friendStore = useFriendStore()
const candidates: MentionCandidate[] = []
const seen = new Set<string>()
for (const userId of atUserIds) {
// @全体成员是虚拟伪成员userId = -1 在 group.members 里查不到,注入字面量「所有人」候选
if (userId === IM_AT_ALL_USER_ID) {
const key = `${IM_AT_ALL_USER_ID}#${IM_AT_ALL_NICKNAME}`
if (!seen.has(key)) {
seen.add(key)
candidates.push({
userId: IM_AT_ALL_USER_ID,
name: IM_AT_ALL_NICKNAME,
displayName: IM_AT_ALL_NICKNAME
})
}
continue
}
const member = memberById.get(userId)
const friend = friendStore.getFriend(userId)
const nickname = (member?.nickname || friend?.nickname || '').trim()
if (!nickname) {
continue
}
for (const literal of [nickname, friend?.displayName, member?.displayUserName]) {
const trimmed = (literal || '').trim()
if (!trimmed) {
continue
}
const key = `${userId}#${trimmed}`
if (seen.has(key)) {
continue
}
seen.add(key)
candidates.push({ userId, name: trimmed, displayName: nickname })
}
}
// seen 已保证 (userId, name) 唯一,所以 countBy 出来的同 name 计数 > 1 一定是跨 userId 歧义
const nameCount = countBy(candidates, 'name')
for (const candidate of candidates) {
if (nameCount[candidate.name] > 1) {
candidate.ambiguous = true
}
}
return candidates
}
/**
* / tip mention User UserInfoCard
*
* getSenderAvatarfriend / member fallbackName tip
* addSource GROUP + + SEARCH
* @ 广 mention PC
*/
export function openMentionUserInfoCardAtEvent(
userId: number,
event: MouseEvent,
fallbackName?: string
): void {
if (userId === IM_AT_ALL_USER_ID) {
return
}
const conversation = useConversationStore().activeConversation
const isGroup = conversation?.type === ImConversationType.GROUP
const group = isGroup ? useGroupStore().getGroup(conversation!.targetId) : undefined
const member = group?.members?.find((m) => m.userId === userId)
const friend = useFriendStore().getFriend(userId)
const user: User = {
id: userId,
nickname: friend?.nickname || member?.nickname || fallbackName || String(userId),
avatar: getSenderAvatar(userId, conversation?.type ?? 0, conversation?.targetId ?? 0)
}
useImUiStore().openUserInfoCardAtEvent(
user,
event,
isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
isGroup ? group?.name || '' : ''
)
}
/** /**
* 广GROUP_* * 广GROUP_*
* *