feat(im): 优化名片消息类型 v0.3:增加表情选择

im
YunaiV 2026-05-06 08:47:18 +08:00
parent b17f7a57e5
commit 59aab8ecdc
6 changed files with 97 additions and 47 deletions

View File

@ -148,8 +148,25 @@
</div>
</div>
<!-- 留言单行 -->
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言" />
<!-- 留言单行右侧表情按钮触发 EmojiPicker所选 emoji 直接拼接到输入末尾 -->
<div class="relative">
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
:size="18"
class="cursor-pointer text-[var(--el-text-color-secondary)] hover:text-[var(--el-color-primary)]"
@click.stop="emojiVisible = !emojiVisible"
/>
</template>
</el-input>
<!-- bottom-full picker 下沿贴 input 顶部向上弹出right-0 对齐 input 右侧表情按钮 -->
<EmojiPicker
v-model:visible="emojiVisible"
class="bottom-full right-0 mb-2"
@select="handleEmojiSelect"
/>
</div>
<!-- 操作按钮 0/1 显示发送多个显示分别发送(n) -->
<div class="flex gap-2 justify-end">
@ -175,10 +192,11 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from './UserAvatar.vue'
import EmojiPicker from '../../pages/conversation/components/input/EmojiPicker.vue'
import { useConversationStore } from '../../store/conversationStore'
import { useMessageSender } from '../../composables/useMessageSender'
import { ImConversationType, ImMessageType } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { serializeMessage, type CardMessage } from '../../../utils/message'
import type { Conversation, User } from '../../types'
@ -207,6 +225,8 @@ const visible = computed({
const keyword = ref('')
const leaveMessage = ref('')
const sending = ref(false)
/** 表情面板显隐:右侧 smile icon 切换 */
const emojiVisible = ref(false)
/** 已勾选的会话 key 列表type:targetId 组合主键selectedSet 派生用于 row 快查 */
const selectedKeys = ref<string[]>([])
/** 已选 key 集合handlerToggle 写数组row isSelected 走 set 快查避免 O(N) 扫描 */
@ -220,6 +240,12 @@ function resetForm() {
keyword.value = ''
leaveMessage.value = ''
selectedKeys.value = []
emojiVisible.value = false
}
/** 选中 emoji直接拼到留言末尾EmojiPicker 自身 emit('update:visible', false) 关闭面板 */
function handleEmojiSelect(emoji: string) {
leaveMessage.value = `${leaveMessage.value}${emoji}`
}
/** 候选会话:私聊「推荐给本人」过滤掉避免无意义自推 */
@ -231,15 +257,9 @@ const candidateConversations = computed<Conversation[]>(() => {
})
/** 按搜索关键字过滤展示列表(仅按 name 模糊匹配) */
const shownConversations = computed(() => {
const keywordLower = keyword.value.trim().toLowerCase()
if (!keywordLower) {
return candidateConversations.value
}
return candidateConversations.value.filter((c) =>
(c.name || '').toLowerCase().includes(keywordLower)
)
})
const shownConversations = computed(() =>
filterConversationsByKeyword(candidateConversations.value, keyword.value)
)
/** 已选会话:右栏预览渲染用,按 selectedKeys 顺序展示 */
const selectedConversations = computed<Conversation[]>(() => {

View File

@ -130,12 +130,7 @@ function handleClick(e: MouseEvent) {
}
// user
if (props.user) {
uiStore.openUserInfoCard(
props.user,
{ x: e.clientX + 20, y: e.clientY },
props.addSource,
props.addSourceExtra
)
uiStore.openUserInfoCardAtEvent(props.user, e, props.addSource, props.addSourceExtra)
return
}
// user id + +
@ -143,13 +138,13 @@ function handleClick(e: MouseEvent) {
if (!numId || numId <= 0) {
return
}
uiStore.openUserInfoCard(
uiStore.openUserInfoCardAtEvent(
{
id: numId,
nickname: props.name,
avatar: props.url
},
{ x: e.clientX + 20, y: e.clientY },
e,
props.addSource,
props.addSourceExtra
)

View File

@ -60,12 +60,7 @@
<div
v-if="isText"
class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend
? 'text-black bg-[#95ec69]'
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
]"
:class="bubbleClass('text')"
>
{{ textContent }}
</div>
@ -89,13 +84,7 @@
<div
v-else-if="isFile && filePayload"
class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border rounded transition-colors"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]',
isUploading ? 'cursor-default' : 'cursor-pointer'
]"
:class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']"
@click="handleFileClick"
>
<div class="flex-1 min-w-0">
@ -204,9 +193,10 @@
<!-- 状态区自己消息展示发送状态 + 已读/群回执对方消息 + @自己时展示 @徽标 -->
<div class="flex gap-1.5 items-center text-base">
<template v-if="message.selfSend">
<!-- 媒体消息 SENDING 时气泡自身已显示进度遮罩/进度条外层 loading 多余其它消息含语音保留 loading 表达 -->
<!-- SENDING 显示外层 loading图片/视频/文件气泡自身有进度反馈则抑制
语音气泡只有麦克风 + 时长无内嵌进度条必须保留外层 loading 让用户感知正在发送 -->
<Icon
v-if="message.status === ImMessageStatus.SENDING && !isUploading"
v-if="message.status === ImMessageStatus.SENDING && (!isUploading || isVoice)"
icon="ant-design:loading-outlined"
class="im-loading-spin"
/>
@ -374,6 +364,34 @@ const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
const isCard = computed(() => props.message.type === ImMessageType.CARD)
// ==================== helper ====================
// self / other ::before message-bubble--self/other helper 4 selfSend ternary
// variant switch caseclass 便 UnoCSS
/** 文本 / 文件 / 语音气泡的整体 class含 selfSend 配色 + ::before 三角的 side class */
function bubbleClass(variant: 'text' | 'file' | 'voice'): string[] {
const isSelf = props.message.selfSend
const side = isSelf ? 'message-bubble--self' : 'message-bubble--other'
switch (variant) {
case 'text':
return [
side,
isSelf
? 'text-black bg-[#95ec69]'
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
]
case 'file':
return [
side,
isSelf
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]'
]
case 'voice':
return [side, isSelf ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]']
}
}
// TODO @AI message.ts
/**
* 时间分隔线文案
@ -468,13 +486,13 @@ function handleCardClick(e: MouseEvent) {
if (!card?.userId) {
return
}
uiStore.openUserInfoCard(
uiStore.openUserInfoCardAtEvent(
{
id: card.userId,
nickname: card.nickname,
avatar: card.avatar
},
{ x: e.clientX + 20, y: e.clientY },
e,
ImFriendAddSource.CARD
)
}

View File

@ -107,7 +107,7 @@ import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { StorageKeys } from '../../../utils/storage'
import { ImConversationType } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { CommonStatusEnum } from '@/utils/constants'
import type { Conversation, Friend, FriendLite } from '../../types'
import ResizableAside from '../../components/ResizableAside.vue'
@ -129,15 +129,9 @@ const createGroupVisible = ref(false)
const sortedConversations = computed(() => conversationStore.getSortedConversations)
/** 顶部搜索框过滤会话:只按 name 模糊匹配,避免命中 lastContent 等次要字段干扰 */
const filteredConversations = computed(() => {
const keywordLower = keyword.value.trim().toLowerCase()
if (!keywordLower) {
return sortedConversations.value
}
return sortedConversations.value.filter((c) =>
(c.name || '').toLowerCase().includes(keywordLower)
)
})
const filteredConversations = computed(() =>
filterConversationsByKeyword(sortedConversations.value, keyword.value)
)
// ==================== ====================

View File

@ -41,6 +41,16 @@ export const useImUiStore = defineStore('imUiStore', () => {
userInfoCard.show = true
}
/** 鼠标点击位置 + 20px 横向偏移打开名片:避免名片直接覆盖触发元素,对齐头像 / 名片消息等点击交互的统一观感 */
function openUserInfoCardAtEvent(
user: User,
e: MouseEvent,
addSource: number = ImFriendAddSource.SEARCH,
addSourceExtra: string = ''
) {
openUserInfoCard(user, { x: e.clientX + 20, y: e.clientY }, addSource, addSourceExtra)
}
/** 关闭用户名片 */
function closeUserInfoCard() {
userInfoCard.show = false
@ -86,6 +96,7 @@ export const useImUiStore = defineStore('imUiStore', () => {
return {
userInfoCard,
openUserInfoCard,
openUserInfoCardAtEvent,
closeUserInfoCard,
contextMenu,

View File

@ -20,6 +20,18 @@ export function getConversationKey(conversation: { type: number; targetId: numbe
return `${conversation.type}-${conversation.targetId}`
}
/** 按昵称模糊过滤会话列表:空 keyword 原样返回,命中走 toLowerCase 不区分大小写 */
export function filterConversationsByKeyword<T extends { name?: string }>(
list: T[],
keyword: string
): T[] {
const trimmed = keyword.trim().toLowerCase()
if (!trimmed) {
return list
}
return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed))
}
/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */
export function buildRecallTip(
senderId: number,