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
// + 穿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.showName}`
span.textContent = `@${member.nickname || member.showName}`
mentionRange.insertNode(span)
// token editor contenteditable=false token
// DOM walk

View File

@ -1,11 +1,11 @@
<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')"
>
{{ textPayload.content }}
<TipSegments :segments="textSegments" />
</div>
<!-- 图片el-image 内置预览上传中半透明遮罩 -->
@ -167,18 +167,21 @@ import { formatSeconds } from '@/utils/formatTime'
import { ImMessageType, MERGE_FORWARD_PREVIEW_LINES } from '@/views/im/utils/constants'
import {
parseMessage,
parseTextSegments,
getFileIconInfo,
type AudioMessage,
type CardMessage,
type FaceMessage,
type FileMessage,
type ImageMessage,
type MentionCandidate,
type MergeMessage,
type TextMessage,
type VideoMessage
} from '@/views/im/utils/message'
import { summarizeMessageContent } from '@/views/im/utils/conversation'
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
import TipSegments from './TipSegments.vue'
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
defineOptions({ name: 'ImMessageBubble' })
@ -192,6 +195,8 @@ const props = defineProps<{
selfSend?: boolean
/** 媒体上传进度0-100非 null 即视为上传中,渲染遮罩 / 进度条 */
uploadProgress?: number | null
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
mentions?: MentionCandidate[]
}>()
const emit = defineEmits<{
@ -224,6 +229,15 @@ const uploadProgressText = computed(() => `${uploadProgress.value}%`)
const parsedContent = computed<unknown>(() => parseMessage(props.content))
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(() =>
isImage.value ? (parsedContent.value as ImageMessage | null) : null
)

View File

@ -80,6 +80,7 @@
:content="message.content"
:self-send="message.selfSend"
:upload-progress="message.uploadProgress"
:mentions="textMentions"
@click-card="handleCardClick"
@open-merge="handleMergeOpen"
/>
@ -176,6 +177,7 @@ import {
getQuoteFromMessage,
parseMessage,
type CardMessage,
type MentionCandidate,
type TextMessage
} from '@/views/im/utils/message'
import { buildRecallTipSegments } from '@/views/im/utils/conversation'
@ -188,6 +190,7 @@ import { useDraftStore } from '../../../../store/draftStore'
import { useFaceStore } from '../../../../store/faceStore'
import {
getMemberDisplayName,
getMentionCandidates,
getSenderDisplayName,
getSenderRealNickname,
resolveFriendNotificationSegments,
@ -305,6 +308,14 @@ function handleMergeOpen(content: string) {
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含改包脏数据忽略 */
function handleCardClick(card: CardMessage, e: MouseEvent) {
if (!card?.targetId) {

View File

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

View File

@ -22,13 +22,15 @@ export const generateClientMessageId = (): string => {
return generateUUID()
}
// ==================== Tip 片段(灰条文案渲染用) ====================
// 把"XX 邀请 YY 加入群聊""XX 撤回了一条消息"等 tip 文案拆成 segment 数组,
// mention 段携带 userId渲染层据此挂点击事件弹 UserInfoCard。
// ==================== 文本片段tip 文案 + TEXT 气泡共用) ====================
// 既用于灰条 tip"XX 邀请 YY 加入群聊"),也用于 TEXT 气泡正文(@xxx 高亮 + URL 自动识别)。
// mention 段携带 userId 用于挂点击弹 UserInfoCardlink 段携带 href 用于 <a> 跳转;
// text 段是纯文本兜底。渲染层TipSegments.vue按 type 分发统一处理。
export type TipSegment =
| { type: 'text'; text: string }
| { type: 'mention'; userId: number; text: string }
| { type: 'link'; href: string; text: string }
export const tipText = (text: string): TipSegment => ({ type: 'text', text })
@ -38,6 +40,12 @@ export const tipMention = (userId: number, text: string): TipSegment => ({
text
})
export const tipLink = (href: string, text: string): TipSegment => ({
type: 'link',
href,
text
})
export const segmentsToText = (segments: TipSegment[]): string =>
segments.map((s) => s.text).join('')
@ -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) */

View File

@ -9,20 +9,35 @@
// 命名约定:显示名相关函数一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐
// ====================================================================
import { countBy } from 'lodash-es'
import { useUserStore } from '@/store/modules/user'
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 {
joinMentionSegments,
segmentsToText,
tipMention,
tipText,
type MentionCandidate,
type TipSegment
} from './message'
import { useConversationStore } from '../home/store/conversationStore'
import { useFriendStore } from '../home/store/friendStore'
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)
}
// TODO @AI这个方法还需要么之前是哪个调用的可能要看看。
/**
* conversation group.members / friend / userStore
*
@ -189,6 +203,104 @@ export function getSenderAvatar(
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_*
*