✨ feat(im): 灰条 tip 文案的 mention 段支持点击弹 UserInfoCard
群广播 / 撤回 / 好友事件 tip 文案从纯字符串拆成 TipSegment[],mention 段携带 userId,渲染层挂点击 → uiStore.openUserInfoCardAtEvent。 - utils/message.ts:加 TipSegment 协议 + 零依赖 helper - utils/user.ts、utils/conversation.ts:加 segments builder,string 版 改写为 segmentsToText 包装,避免 case 表分叉 - TipSegments.vue:按 activeConversation 推断 addSource,群里走 GROUP+群名、私聊走 SEARCH;nickname 不传备注避免 UserInfo 首屏闪 - MessageItem.vue / MessageHistory.vue:tip 块切 <TipSegments> 顺手补:utils/constants.ts 新增 SystemUserSexEnum,替换 IM 模块 sex 硬编码 1 / 2 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>im
parent
5b85a4c469
commit
9eb221e8d2
|
|
@ -64,6 +64,15 @@ export const SystemUserSocialTypeEnum = {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户性别枚举(对齐后端 system_user_sex 字典)
|
||||
*/
|
||||
export const SystemUserSexEnum = {
|
||||
UNKNOWN: 0, // 未知
|
||||
MALE: 1, // 男
|
||||
FEMALE: 2 // 女
|
||||
}
|
||||
|
||||
// ========== INFRA 模块 ==========
|
||||
/**
|
||||
* 代码生成模板类型
|
||||
|
|
|
|||
|
|
@ -139,25 +139,22 @@
|
|||
|
||||
<!-- 消息列表 -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<template
|
||||
v-for="message in currentList"
|
||||
:key="message.id || message.clientMessageId"
|
||||
>
|
||||
<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(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
|
||||
>
|
||||
{{ resolveFriendNotificationText(message) }}
|
||||
<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(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
|
||||
>
|
||||
{{ resolveGroupNotificationText(message) }}
|
||||
<TipSegments :segments="resolveGroupNotificationSegments(message)" />
|
||||
</div>
|
||||
|
||||
<!-- 普通消息行 -->
|
||||
|
|
@ -194,12 +191,12 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-1.5">
|
||||
<!-- 撤回:单独走灰色 tip 文案,不渲染气泡 -->
|
||||
<!-- 撤回:单独走灰色 tip,sender 名段可点击 -->
|
||||
<div
|
||||
v-if="message.type === ImMessageType.RECALL"
|
||||
class="text-sm italic text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
{{ recallTipOf(message) }}
|
||||
<TipSegments :segments="recallTipSegmentsOf(message)" />
|
||||
</div>
|
||||
<!-- 其它类型走 MessageBubble 复用主聊天气泡 -->
|
||||
<MessageBubble
|
||||
|
|
@ -256,10 +253,15 @@ import {
|
|||
getMemberDisplayName,
|
||||
getSenderDisplayName,
|
||||
getSenderRealNickname,
|
||||
resolveFriendNotificationSegments,
|
||||
resolveFriendNotificationText,
|
||||
resolveGroupNotificationText
|
||||
resolveGroupNotificationSegments
|
||||
} from '@/views/im/utils/user'
|
||||
import { buildFacePreviewText, buildRecallTip } from '@/views/im/utils/conversation'
|
||||
import {
|
||||
buildFacePreviewText,
|
||||
buildRecallTip,
|
||||
buildRecallTipSegments
|
||||
} from '@/views/im/utils/conversation'
|
||||
import { useMessagePuller } from '@/views/im/home/composables/useMessagePuller'
|
||||
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
|
||||
import {
|
||||
|
|
@ -281,6 +283,7 @@ import type { Message } from '@/views/im/home/types'
|
|||
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||
import TipSegments from './TipSegments.vue'
|
||||
|
||||
defineOptions({ name: 'ImMessageHistory' })
|
||||
|
||||
|
|
@ -332,7 +335,17 @@ function senderRealNicknameOf(message: Message): string {
|
|||
function recallTipOf(message: Message): string {
|
||||
return buildRecallTip(
|
||||
message.senderId,
|
||||
!!message.selfSend,
|
||||
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
|
||||
)
|
||||
|
|
@ -392,7 +405,11 @@ const filterChipLabel = computed(() => {
|
|||
|
||||
/** 点 tab 落筛选;同 kind 重复点击 → 当 toggle 关掉(避免迷惑) */
|
||||
function setFilter(filter: ActiveFilter) {
|
||||
if (activeFilter.value?.kind === filter.kind && filter.kind !== 'date' && filter.kind !== 'member') {
|
||||
if (
|
||||
activeFilter.value?.kind === filter.kind &&
|
||||
filter.kind !== 'date' &&
|
||||
filter.kind !== 'member'
|
||||
) {
|
||||
activeFilter.value = null
|
||||
return
|
||||
}
|
||||
|
|
@ -552,11 +569,7 @@ async function loadEarlier() {
|
|||
}
|
||||
// 4. 合并到 conversationStore:prependMessages 内部去重 + 升序合并 + 落 IndexedDB;
|
||||
// 主聊天面板的 messages 是同一份引用,老消息也会一起出现在主面板里(符合预期)
|
||||
conversationStore.prependMessages(
|
||||
conversation.value.type,
|
||||
conversation.value.targetId,
|
||||
earlier
|
||||
)
|
||||
conversationStore.prependMessages(conversation.value.type, conversation.value.targetId, earlier)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
|
|
@ -599,9 +612,7 @@ function getAvatar(message: Message): string {
|
|||
}
|
||||
if (isGroup.value) {
|
||||
const group = groupStore.getGroup(conversation.value.targetId)
|
||||
return (
|
||||
group?.members?.find((member) => member.userId === message.senderId)?.avatar || ''
|
||||
)
|
||||
return group?.members?.find((member) => member.userId === message.senderId)?.avatar || ''
|
||||
}
|
||||
return conversation.value.avatar || ''
|
||||
}
|
||||
|
|
@ -650,7 +661,6 @@ function locateMessage(messageId: number) {
|
|||
emit('locate', messageId)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -12,23 +12,23 @@
|
|||
v-if="isFriendChatTipMessage"
|
||||
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
{{ friendChatTipText }}
|
||||
<TipSegments :segments="friendChatTipSegments" />
|
||||
</div>
|
||||
|
||||
<!-- 群广播事件:跟好友事件同灰色样式,文案按 type 拼装 -->
|
||||
<!-- 群广播事件:跟好友事件同灰色样式,mention 段挂点击弹 UserInfoCard -->
|
||||
<div
|
||||
v-else-if="isGroupNotificationMessage"
|
||||
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
{{ groupNotificationText }}
|
||||
<TipSegments :segments="groupNotificationSegments" />
|
||||
</div>
|
||||
|
||||
<!-- 撤回消息:整行展示灰色 tip 文案 -->
|
||||
<!-- 撤回消息:整行灰色 tip,sender 名段可点击 -->
|
||||
<div
|
||||
v-else-if="isRecall"
|
||||
class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
{{ recallTip }}
|
||||
<TipSegments :segments="recallTipSegments" />
|
||||
</div>
|
||||
|
||||
<!-- 普通消息气泡 -->
|
||||
|
|
@ -149,6 +149,7 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
|
@ -173,9 +174,11 @@ import {
|
|||
buildQuoteFromMessage,
|
||||
extractAddableFace,
|
||||
getQuoteFromMessage,
|
||||
type CardMessage
|
||||
parseMessage,
|
||||
type CardMessage,
|
||||
type TextMessage
|
||||
} from '@/views/im/utils/message'
|
||||
import { buildRecallTip } from '@/views/im/utils/conversation'
|
||||
import { buildRecallTipSegments } from '@/views/im/utils/conversation'
|
||||
import { formatTimeTip } from '@/views/im/utils/time'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
|
|
@ -187,8 +190,8 @@ import {
|
|||
getMemberDisplayName,
|
||||
getSenderDisplayName,
|
||||
getSenderRealNickname,
|
||||
resolveFriendNotificationText,
|
||||
resolveGroupNotificationText
|
||||
resolveFriendNotificationSegments,
|
||||
resolveGroupNotificationSegments
|
||||
} from '@/views/im/utils/user'
|
||||
import { useImUiStore } from '../../../../store/uiStore'
|
||||
import { useMessageSender } from '../../../../composables/useMessageSender'
|
||||
|
|
@ -197,6 +200,7 @@ import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
|
|||
import type { Message } from '../../../../types'
|
||||
import MessageReadStatus from './MessageReadStatus.vue'
|
||||
import ReplyPreview from './ReplyPreview.vue'
|
||||
import TipSegments from './TipSegments.vue'
|
||||
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import { IM_FORWARD_DIALOG_KEY, IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
|
||||
|
|
@ -234,6 +238,8 @@ const { uploadAndSendMedia } = useMediaUploader()
|
|||
const muteOverlay = useMuteOverlay()
|
||||
// 仅用 confirm,避免 message 跟 props.message 同名冲突(vue/no-dupe-keys)
|
||||
const { confirm: confirmDialog, success: successMessage } = useMessage()
|
||||
// legacy:true 兼容 HTTP 环境,没有 navigator.clipboard 时降级到 execCommand
|
||||
const { copy: copyToClipboard } = useClipboard({ legacy: true })
|
||||
|
||||
// ==================== 消息类型判断 ====================
|
||||
|
||||
|
|
@ -259,10 +265,10 @@ const isMerge = computed(() => props.message.type === ImMessageType.MERGE)
|
|||
/** 是否已撤回:pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL,渲染只需识别 type */
|
||||
const isRecall = computed(() => props.message.type === ImMessageType.RECALL)
|
||||
|
||||
/** 撤回提示文案:buildRecallTip 实时算 sender 名(按 conversation 上下文走 WeChat 优先级) */
|
||||
const recallTip = computed(() => {
|
||||
/** 撤回提示 segments:依赖 activeConversation 实时算 sender 名 */
|
||||
const recallTipSegments = computed(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
return buildRecallTip(
|
||||
return buildRecallTipSegments(
|
||||
props.message.senderId,
|
||||
props.message.selfSend,
|
||||
conversation?.type ?? 0,
|
||||
|
|
@ -273,14 +279,14 @@ const recallTip = computed(() => {
|
|||
/** 是否会话内好友事件气泡(FRIEND_ADD / FRIEND_DELETE) */
|
||||
const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type))
|
||||
|
||||
/** 好友事件文案:FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示,文案固定 */
|
||||
const friendChatTipText = computed(() => resolveFriendNotificationText(props.message))
|
||||
/** 好友事件 segments */
|
||||
const friendChatTipSegments = computed(() => resolveFriendNotificationSegments(props.message))
|
||||
|
||||
/** 是否群广播事件(GROUP_CREATE..GROUP_BANNED 段位,排除 GROUP_MEMBER_SETTING_UPDATE 个人信号) */
|
||||
const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type))
|
||||
|
||||
/** 群广播事件文案:按 type 拼装(成员加入 / 退群 / 公告变更等) */
|
||||
const groupNotificationText = computed(() => resolveGroupNotificationText(props.message))
|
||||
/** 群广播事件 segments */
|
||||
const groupNotificationSegments = computed(() => resolveGroupNotificationSegments(props.message))
|
||||
|
||||
// ==================== 消息内容解析 / payload ====================
|
||||
|
||||
|
|
@ -446,6 +452,7 @@ const isAtMe = computed(() => {
|
|||
|
||||
/** 右键菜单 key 常量;push 端和分发端从同一处取,typo 编译期就能抓 */
|
||||
const MENU_KEYS = {
|
||||
COPY: 'COPY',
|
||||
REPLY: 'REPLY',
|
||||
FORWARD: 'FORWARD',
|
||||
MULTI_SELECT: 'MULTI_SELECT',
|
||||
|
|
@ -489,6 +496,14 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
danger?: boolean
|
||||
icon?: string
|
||||
}> = []
|
||||
// 「复制」:仅文本消息支持;放在第一项,对齐微信桌面右键习惯
|
||||
if (props.message.type === ImMessageType.TEXT) {
|
||||
items.push({
|
||||
key: MENU_KEYS.COPY,
|
||||
name: '复制',
|
||||
icon: 'ant-design:copy-outlined'
|
||||
})
|
||||
}
|
||||
// 「引用」:已落库(id≠0)+ 未撤回 + 非合并转发;MERGE 内嵌快照在引用预览里无法降级展示
|
||||
if (!!props.message.id && !isRecall.value && !isMerge.value) {
|
||||
items.push({
|
||||
|
|
@ -584,6 +599,7 @@ async function handleContextMenu(e: MouseEvent) {
|
|||
|
||||
// 把菜单渲染交给全局 uiStore(单例,避免每条消息都挂一份菜单 DOM)
|
||||
const menuHandlers: Record<MenuKey, () => void | Promise<void>> = {
|
||||
[MENU_KEYS.COPY]: handleCopy,
|
||||
[MENU_KEYS.REPLY]: handleReply,
|
||||
[MENU_KEYS.FORWARD]: handleForward,
|
||||
[MENU_KEYS.MULTI_SELECT]: handleEnterMultiSelect,
|
||||
|
|
@ -707,6 +723,16 @@ async function handlePin() {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
/** 复制文本消息:解出 content 字段写入剪贴板,提示「内容已复制到剪贴板」 */
|
||||
async function handleCopy() {
|
||||
const text = parseMessage<TextMessage>(props.message.content)?.content
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
await copyToClipboard(text)
|
||||
successMessage('内容已复制到剪贴板')
|
||||
}
|
||||
|
||||
/** 进入引用模式:把当前消息构造成 QuoteMessage 写入 draftStore,MessageInput 顶部引用条响应式出现 */
|
||||
function handleReply() {
|
||||
const conversation = conversationStore.activeConversation
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
<!--
|
||||
会话内灰条 tip 片段渲染:依赖 activeConversation 推断点击落点的 addSource / 群名
|
||||
-->
|
||||
<template>
|
||||
<template v-for="(segment, _index) in segments" :key="_index">
|
||||
<span
|
||||
v-if="segment.type === 'mention'"
|
||||
class="cursor-pointer text-[#576b95] hover:underline"
|
||||
@click.stop="handleMentionClick(segment, $event)"
|
||||
>{{ segment.text }}</span
|
||||
>
|
||||
<span v-else>{{ segment.text }}</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ImConversationType, ImFriendAddSource } 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'
|
||||
|
||||
defineOptions({ name: 'ImTipSegments' })
|
||||
|
||||
defineProps<{
|
||||
segments: TipSegment[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* nickname 不传 friend.displayName / member.displayUserName 等备注,避免 UserInfo 首屏闪非真实昵称;
|
||||
* addSourceExtra 不用 conversation.name 兜底,conversation.name = groupRemark || group.name,
|
||||
* 个人备注会污染加好友话术
|
||||
*/
|
||||
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
|
||||
)
|
||||
}
|
||||
useImUiStore().openUserInfoCardAtEvent(
|
||||
user,
|
||||
event,
|
||||
isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
|
||||
isGroup ? group?.name || '' : ''
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -10,10 +10,14 @@ import { ImMessageType, isFriendChatTip, isGroupNotification } from './constants
|
|||
import {
|
||||
getCardLabelInfo,
|
||||
parseMessage,
|
||||
segmentsToText,
|
||||
tipMention,
|
||||
tipText,
|
||||
type CardMessage,
|
||||
type FaceMessage,
|
||||
type FileMessage,
|
||||
type TextMessage
|
||||
type TextMessage,
|
||||
type TipSegment
|
||||
} from './message'
|
||||
import {
|
||||
getSenderDisplayName,
|
||||
|
|
@ -47,6 +51,36 @@ export function buildFacePreviewText(facePayload: { name?: string } | null | und
|
|||
return facePayload?.name ? `[表情] ${facePayload.name}` : '[表情]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回提示 segments
|
||||
*
|
||||
* senderId 缺失时不挂 mention 段,避免点出错号;算不出真名降级为「对方」纯文本
|
||||
*/
|
||||
export function buildRecallTipSegments(
|
||||
senderId: number,
|
||||
selfSend: boolean,
|
||||
conversationType: number,
|
||||
conversationTargetId: number,
|
||||
fallbackName?: string
|
||||
): TipSegment[] {
|
||||
if (selfSend) {
|
||||
return [tipText('你撤回了一条消息')]
|
||||
}
|
||||
const senderDisplayName = getSenderDisplayName(
|
||||
senderId,
|
||||
conversationType,
|
||||
conversationTargetId,
|
||||
fallbackName
|
||||
)
|
||||
if (!senderId) {
|
||||
return [tipText(`${senderDisplayName || '对方'} 撤回了一条消息`)]
|
||||
}
|
||||
return [
|
||||
tipMention(senderId, senderDisplayName || '对方'),
|
||||
tipText(' 撤回了一条消息')
|
||||
]
|
||||
}
|
||||
|
||||
/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */
|
||||
export function buildRecallTip(
|
||||
senderId: number,
|
||||
|
|
@ -55,16 +89,9 @@ export function buildRecallTip(
|
|||
conversationTargetId: number,
|
||||
fallbackName?: string
|
||||
): string {
|
||||
if (selfSend) {
|
||||
return '你撤回了一条消息'
|
||||
}
|
||||
const senderDisplayName = getSenderDisplayName(
|
||||
senderId,
|
||||
conversationType,
|
||||
conversationTargetId,
|
||||
fallbackName
|
||||
return segmentsToText(
|
||||
buildRecallTipSegments(senderId, selfSend, conversationType, conversationTargetId, fallbackName)
|
||||
)
|
||||
return `${senderDisplayName || '对方'} 撤回了一条消息`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,6 +22,41 @@ export const generateClientMessageId = (): string => {
|
|||
return generateUUID()
|
||||
}
|
||||
|
||||
// ==================== Tip 片段(灰条文案渲染用) ====================
|
||||
// 把"XX 邀请 YY 加入群聊""XX 撤回了一条消息"等 tip 文案拆成 segment 数组,
|
||||
// mention 段携带 userId,渲染层据此挂点击事件弹 UserInfoCard。
|
||||
|
||||
export type TipSegment =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'mention'; userId: number; text: string }
|
||||
|
||||
export const tipText = (text: string): TipSegment => ({ type: 'text', text })
|
||||
|
||||
export const tipMention = (userId: number, text: string): TipSegment => ({
|
||||
type: 'mention',
|
||||
userId,
|
||||
text
|
||||
})
|
||||
|
||||
export const segmentsToText = (segments: TipSegment[]): string =>
|
||||
segments.map((s) => s.text).join('')
|
||||
|
||||
/** 多个 userId 用同一个分隔符插值成 segments,每个 user 单独成 mention 段 */
|
||||
export function joinMentionSegments(
|
||||
userIds: number[],
|
||||
separator: string,
|
||||
resolveName: (userId: number) => string
|
||||
): TipSegment[] {
|
||||
const out: TipSegment[] = []
|
||||
userIds.forEach((id, index) => {
|
||||
if (index > 0) {
|
||||
out.push(tipText(separator))
|
||||
}
|
||||
out.push(tipMention(id, resolveName(id)))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ==================== 引用消息 ====================
|
||||
|
||||
/** 引用消息 payload(对齐后端 QuoteMessage) */
|
||||
|
|
|
|||
|
|
@ -10,8 +10,16 @@
|
|||
// ====================================================================
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { SystemUserSexEnum } from '@/utils/constants'
|
||||
import { ImConversationType, ImMessageType } from './constants'
|
||||
import { getCurrentUserId } from './storage'
|
||||
import {
|
||||
joinMentionSegments,
|
||||
segmentsToText,
|
||||
tipMention,
|
||||
tipText,
|
||||
type TipSegment
|
||||
} from './message'
|
||||
import { useFriendStore } from '../home/store/friendStore'
|
||||
import { useGroupStore } from '../home/store/groupStore'
|
||||
import type { Friend, Group } from '../home/types'
|
||||
|
|
@ -222,100 +230,148 @@ export type GroupNotificationPayload = {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 群广播事件 segments
|
||||
*
|
||||
* resolveName 默认走 getSenderDisplayName,可注入自定义 resolver;
|
||||
* operatorNameOverride 仅覆盖 operator 段文案,mention userId 仍用 payload.operatorUserId
|
||||
*/
|
||||
export function resolveGroupNotificationSegments(
|
||||
message: { type?: number; content?: string; targetId?: number },
|
||||
resolveName?: (userId: number) => string,
|
||||
operatorNameOverride?: string
|
||||
): TipSegment[] {
|
||||
let payload: GroupNotificationPayload = {}
|
||||
try {
|
||||
payload = JSON.parse(message.content || '{}')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const resolve =
|
||||
resolveName ||
|
||||
((id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0))
|
||||
|
||||
// ENTER 主语是 entrant 而非 operator,独立处理;其它 case 都以 operatorUserId 为主语
|
||||
if (message.type === ImMessageType.GROUP_MEMBER_ENTER) {
|
||||
const entrantId = payload.entrantUserId ?? payload.operatorUserId
|
||||
return entrantId ? [tipMention(entrantId, resolve(entrantId)), tipText(' 加入了群聊')] : []
|
||||
}
|
||||
if (!payload.operatorUserId) {
|
||||
return []
|
||||
}
|
||||
const operatorSegment = tipMention(
|
||||
payload.operatorUserId,
|
||||
operatorNameOverride ?? resolve(payload.operatorUserId)
|
||||
)
|
||||
const memberSegments = joinMentionSegments(payload.memberUserIds || [], '、', resolve)
|
||||
|
||||
switch (message.type) {
|
||||
case ImMessageType.GROUP_CREATE:
|
||||
return [operatorSegment, tipText(' 创建了群聊')]
|
||||
case ImMessageType.GROUP_NAME_UPDATE:
|
||||
return [operatorSegment, tipText(` 将群名修改为 "${payload.newName ?? ''}"`)]
|
||||
case ImMessageType.GROUP_NOTICE_UPDATE:
|
||||
return [operatorSegment, tipText(' 更新了群公告')]
|
||||
case ImMessageType.GROUP_INFO_UPDATE:
|
||||
return payload.newAvatar
|
||||
? [operatorSegment, tipText(' 更换了群头像')]
|
||||
: [operatorSegment, tipText(' 更新了群信息')]
|
||||
case ImMessageType.GROUP_DISSOLVE:
|
||||
return [operatorSegment, tipText(' 解散了群聊')]
|
||||
case ImMessageType.GROUP_MEMBER_INVITE:
|
||||
return [operatorSegment, tipText(' 邀请 '), ...memberSegments, tipText(' 加入群聊')]
|
||||
case ImMessageType.GROUP_MEMBER_QUIT:
|
||||
return [operatorSegment, tipText(' 退出了群聊')]
|
||||
case ImMessageType.GROUP_MEMBER_KICK:
|
||||
return [operatorSegment, tipText(' 移出了 '), ...memberSegments]
|
||||
case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE:
|
||||
return [operatorSegment, tipText(` 修改群昵称为 "${payload.displayUserName ?? ''}"`)]
|
||||
case ImMessageType.GROUP_ADMIN_ADD:
|
||||
return [operatorSegment, tipText(' 将 '), ...memberSegments, tipText(' 设为管理员')]
|
||||
case ImMessageType.GROUP_ADMIN_REMOVE:
|
||||
return [
|
||||
operatorSegment,
|
||||
tipText(' 撤销了 '),
|
||||
...memberSegments,
|
||||
tipText(' 的管理员身份')
|
||||
]
|
||||
case ImMessageType.GROUP_OWNER_TRANSFER:
|
||||
return payload.newOwnerUserId
|
||||
? [
|
||||
operatorSegment,
|
||||
tipText(' 已将群主转让给 '),
|
||||
tipMention(payload.newOwnerUserId, resolve(payload.newOwnerUserId))
|
||||
]
|
||||
: []
|
||||
case ImMessageType.GROUP_MESSAGE_PIN:
|
||||
return [operatorSegment, tipText(' 置顶了一条消息')]
|
||||
case ImMessageType.GROUP_MESSAGE_UNPIN:
|
||||
return [operatorSegment, tipText(' 取消了一条置顶消息')]
|
||||
case ImMessageType.GROUP_MEMBER_MUTED:
|
||||
return payload.mutedUserId
|
||||
? [
|
||||
operatorSegment,
|
||||
tipText(' 将 '),
|
||||
tipMention(payload.mutedUserId, resolve(payload.mutedUserId)),
|
||||
tipText(' 禁言')
|
||||
]
|
||||
: []
|
||||
case ImMessageType.GROUP_MEMBER_CANCEL_MUTED:
|
||||
return payload.mutedUserId
|
||||
? [
|
||||
operatorSegment,
|
||||
tipText(' 解除了 '),
|
||||
tipMention(payload.mutedUserId, resolve(payload.mutedUserId)),
|
||||
tipText(' 的禁言')
|
||||
]
|
||||
: []
|
||||
case ImMessageType.GROUP_MUTED:
|
||||
return [operatorSegment, tipText(' 开启了全群禁言')]
|
||||
case ImMessageType.GROUP_CANCEL_MUTED:
|
||||
return [operatorSegment, tipText(' 关闭了全群禁言')]
|
||||
case ImMessageType.GROUP_BANNED:
|
||||
return [operatorSegment, tipText(payload.banned ? ' 封禁了该群' : ' 解封了该群')]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 群广播事件中文文案 */
|
||||
export function resolveGroupNotificationText(
|
||||
message: { type?: number; content?: string; targetId?: number },
|
||||
resolveName?: (userId: number) => string,
|
||||
operatorNameOverride?: string
|
||||
): string {
|
||||
let payload: GroupNotificationPayload = {}
|
||||
try {
|
||||
payload = JSON.parse(message.content || '{}')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
const resolve =
|
||||
resolveName ||
|
||||
((id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0))
|
||||
const operatorName = payload.operatorUserId
|
||||
? (operatorNameOverride ?? resolve(payload.operatorUserId))
|
||||
: ''
|
||||
const memberNames = (payload.memberUserIds || []).map(resolve).join('、')
|
||||
const newOwnerName = payload.newOwnerUserId ? resolve(payload.newOwnerUserId) : ''
|
||||
return segmentsToText(
|
||||
resolveGroupNotificationSegments(message, resolveName, operatorNameOverride)
|
||||
)
|
||||
}
|
||||
|
||||
/** 会话内好友事件 segments */
|
||||
export function resolveFriendNotificationSegments(message: {
|
||||
type?: number
|
||||
}): TipSegment[] {
|
||||
switch (message.type) {
|
||||
case ImMessageType.GROUP_CREATE:
|
||||
return `${operatorName} 创建了群聊`
|
||||
case ImMessageType.GROUP_NAME_UPDATE:
|
||||
return `${operatorName} 将群名修改为 "${payload.newName ?? ''}"`
|
||||
case ImMessageType.GROUP_NOTICE_UPDATE:
|
||||
return `${operatorName} 更新了群公告`
|
||||
case ImMessageType.GROUP_INFO_UPDATE:
|
||||
// 兜底事件:按非 null 字段优先匹配特化文案,全部为空时降级为 "更新了群信息" 通用文案
|
||||
if (payload.newAvatar) {
|
||||
return `${operatorName} 更换了群头像`
|
||||
}
|
||||
return `${operatorName} 更新了群信息`
|
||||
case ImMessageType.GROUP_DISSOLVE:
|
||||
return `${operatorName} 解散了群聊`
|
||||
case ImMessageType.GROUP_MEMBER_INVITE:
|
||||
return `${operatorName} 邀请 ${memberNames} 加入群聊`
|
||||
case ImMessageType.GROUP_MEMBER_ENTER: {
|
||||
// 自由进群 / 主动申请通过:操作人 = 进群者;文案统一展示「XX 加入了群聊」
|
||||
const entrantName = payload.entrantUserId ? resolve(payload.entrantUserId) : operatorName
|
||||
return `${entrantName} 加入了群聊`
|
||||
}
|
||||
case ImMessageType.GROUP_MEMBER_QUIT:
|
||||
return `${operatorName} 退出了群聊`
|
||||
case ImMessageType.GROUP_MEMBER_KICK:
|
||||
return `${operatorName} 移出了 ${memberNames}`
|
||||
case ImMessageType.GROUP_MEMBER_NICKNAME_UPDATE:
|
||||
return `${operatorName} 修改群昵称为 "${payload.displayUserName ?? ''}"`
|
||||
case ImMessageType.GROUP_ADMIN_ADD:
|
||||
return `${operatorName} 将 ${memberNames} 设为管理员`
|
||||
case ImMessageType.GROUP_ADMIN_REMOVE:
|
||||
return `${operatorName} 撤销了 ${memberNames} 的管理员身份`
|
||||
case ImMessageType.GROUP_OWNER_TRANSFER:
|
||||
return `${operatorName} 已将群主转让给 ${newOwnerName}`
|
||||
case ImMessageType.GROUP_MESSAGE_PIN:
|
||||
return `${operatorName} 置顶了一条消息`
|
||||
case ImMessageType.GROUP_MESSAGE_UNPIN:
|
||||
return `${operatorName} 取消了一条置顶消息`
|
||||
case ImMessageType.GROUP_MEMBER_MUTED: {
|
||||
const mutedName = payload.mutedUserId ? resolve(payload.mutedUserId) : ''
|
||||
return `${operatorName} 将 ${mutedName} 禁言`
|
||||
}
|
||||
case ImMessageType.GROUP_MEMBER_CANCEL_MUTED: {
|
||||
const mutedName = payload.mutedUserId ? resolve(payload.mutedUserId) : ''
|
||||
return `${operatorName} 解除了 ${mutedName} 的禁言`
|
||||
}
|
||||
case ImMessageType.GROUP_MUTED:
|
||||
return `${operatorName} 开启了全群禁言`
|
||||
case ImMessageType.GROUP_CANCEL_MUTED:
|
||||
return `${operatorName} 关闭了全群禁言`
|
||||
case ImMessageType.GROUP_BANNED:
|
||||
return payload.banned ? `${operatorName} 封禁了该群` : `${operatorName} 解封了该群`
|
||||
case ImMessageType.FRIEND_ADD:
|
||||
return [tipText('你们已经是好友了,开始聊天吧')]
|
||||
case ImMessageType.FRIEND_DELETE:
|
||||
return [tipText('你已删除好友')]
|
||||
default:
|
||||
return ''
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 会话内好友事件文案:FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示气泡,文案固定不依赖 payload */
|
||||
export function resolveFriendNotificationText(message: { type?: number }): string {
|
||||
switch (message.type) {
|
||||
case ImMessageType.FRIEND_ADD:
|
||||
return '你们已经是好友了,开始聊天吧'
|
||||
case ImMessageType.FRIEND_DELETE:
|
||||
return '你已删除好友'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
return segmentsToText(resolveFriendNotificationSegments(message))
|
||||
}
|
||||
|
||||
/** 性别图标:男 1 / 女 2,0 / null / undefined 一律不展示,对齐微信留白 */
|
||||
/** 性别图标;UNKNOWN / null / undefined 一律不展示,对齐微信留白 */
|
||||
export function getGenderIcon(sex?: number): string {
|
||||
if (sex === 1) {
|
||||
if (sex === SystemUserSexEnum.MALE) {
|
||||
return 'mdi:human-male'
|
||||
}
|
||||
if (sex === 2) {
|
||||
if (sex === SystemUserSexEnum.FEMALE) {
|
||||
return 'mdi:human-female'
|
||||
}
|
||||
return ''
|
||||
|
|
@ -323,10 +379,10 @@ export function getGenderIcon(sex?: number): string {
|
|||
|
||||
/** 性别图标主题色:男蓝、女粉 */
|
||||
export function getGenderColor(sex?: number): string {
|
||||
if (sex === 1) {
|
||||
if (sex === SystemUserSexEnum.MALE) {
|
||||
return '#5b97f5'
|
||||
}
|
||||
if (sex === 2) {
|
||||
if (sex === SystemUserSexEnum.FEMALE) {
|
||||
return '#f56c92'
|
||||
}
|
||||
return ''
|
||||
|
|
|
|||
Loading…
Reference in New Issue