feat(im): 初始化表情包 v0.2:第三把 review

im
YunaiV 2026-05-06 21:08:46 +08:00
parent 2f513f7b8f
commit a98e32554c
5 changed files with 104 additions and 13 deletions

View File

@ -148,7 +148,7 @@
</div> </div>
</div> </div>
<!-- 留言单行右侧表情按钮触发 EmojiPicker所选 emoji 直接拼接到输入末尾 --> <!-- 留言单行右侧表情按钮触发 FacePicker(emoji-only)所选 emoji 直接拼接到输入末尾 -->
<div class="relative"> <div class="relative">
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言"> <el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix> <template #suffix>
@ -161,10 +161,11 @@
</template> </template>
</el-input> </el-input>
<!-- bottom-full picker 下沿贴 input 顶部向上弹出right-0 对齐 input 右侧表情按钮 --> <!-- bottom-full picker 下沿贴 input 顶部向上弹出right-0 对齐 input 右侧表情按钮 -->
<EmojiPicker <FacePicker
v-model:visible="emojiVisible" v-model:visible="emojiVisible"
mode="emoji-only"
class="bottom-full right-0 mb-2" class="bottom-full right-0 mb-2"
@select="handleEmojiSelect" @select-emoji="handleEmojiSelect"
/> />
</div> </div>
@ -192,7 +193,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage' import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from './UserAvatar.vue' import UserAvatar from './UserAvatar.vue'
import EmojiPicker from '../../pages/conversation/components/input/EmojiPicker.vue' import FacePicker from '../../pages/conversation/components/input/FacePicker.vue'
import { useConversationStore } from '../../store/conversationStore' import { useConversationStore } from '../../store/conversationStore'
import { useMessageSender } from '../../composables/useMessageSender' import { useMessageSender } from '../../composables/useMessageSender'
import { ImConversationType, ImMessageType } from '../../../utils/constants' import { ImConversationType, ImMessageType } from '../../../utils/constants'
@ -243,7 +244,7 @@ function resetForm() {
emojiVisible.value = false emojiVisible.value = false
} }
/** 选中 emoji直接拼到留言末尾EmojiPicker 自身 emit('update:visible', false) 关闭面板 */ /** 选中 emoji直接拼到留言末尾FacePicker 自身 emit('update:visible', false) 关闭面板 */
function handleEmojiSelect(emoji: string) { function handleEmojiSelect(emoji: string) {
leaveMessage.value = `${leaveMessage.value}${emoji}` leaveMessage.value = `${leaveMessage.value}${emoji}`
} }

View File

@ -33,6 +33,7 @@
<el-scrollbar v-if="isFullMode" v-show="activeTab === FACE_TAB.MINE" height="300px"> <el-scrollbar v-if="isFullMode" v-show="activeTab === FACE_TAB.MINE" height="300px">
<div class="grid grid-cols-5 gap-2 p-3"> <div class="grid grid-cols-5 gap-2 p-3">
<!-- 上传入口固定放第一格对齐微信 --> <!-- 上传入口固定放第一格对齐微信 -->
<!-- TODO @AI这里的界面有点丑你看看/Users/yunai/Downloads/iShot_2026-05-06_21.07.24.png -->
<button <button
class="aspect-square flex items-center justify-center rounded-md border border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)] text-2xl text-[var(--el-text-color-placeholder)] cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]" class="aspect-square flex items-center justify-center rounded-md border border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)] text-2xl text-[var(--el-text-color-placeholder)] cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
type="button" type="button"
@ -187,6 +188,7 @@ import type { ImFacePackUserItemVO, ImFaceUserItemVO } from '@/api/im/face'
defineOptions({ name: 'ImFacePicker' }) defineOptions({ name: 'ImFacePicker' })
/** 面板模式 */ /** 面板模式 */
// TODO @AI emoji only
type FacePickerMode = 'full' | 'emoji-only' type FacePickerMode = 'full' | 'emoji-only'
const props = withDefaults( const props = withDefaults(
@ -349,7 +351,9 @@ onUnmounted(() => {
background-color: transparent; background-color: transparent;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
cursor: pointer; cursor: pointer;
transition: background-color 0.15s, color 0.15s; transition:
background-color 0.15s,
color 0.15s;
} }
.im-face-tab:hover { .im-face-tab:hover {
background-color: var(--el-fill-color); background-color: var(--el-fill-color);

View File

@ -261,6 +261,15 @@
<span>个人名片{{ cardOf(message)?.nickname || '' }}</span> <span>个人名片{{ cardOf(message)?.nickname || '' }}</span>
</div> </div>
<!-- 表情贴图直接渲染图片对照微信观感 -->
<img
v-else-if="message.type === ImMessageType.FACE && faceOf(message)?.url"
:src="faceOf(message)?.url"
:alt="faceOf(message)?.name || '表情'"
class="block max-w-[120px] max-h-[120px] object-contain"
draggable="false"
/>
<!-- 撤回 --> <!-- 撤回 -->
<div <div
v-else-if="message.type === ImMessageType.RECALL" v-else-if="message.type === ImMessageType.RECALL"
@ -324,7 +333,7 @@ import {
resolveFriendNotificationText, resolveFriendNotificationText,
resolveGroupNotificationText resolveGroupNotificationText
} from '@/views/im/utils/user' } from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation' import { buildFacePreviewText, buildRecallTip } from '@/views/im/utils/conversation'
import { useMessagePuller } from '@/views/im/home/composables/useMessagePuller' import { useMessagePuller } from '@/views/im/home/composables/useMessagePuller'
import { import {
ImConversationType, ImConversationType,
@ -339,7 +348,8 @@ import {
type ImageMessage, type ImageMessage,
type FileMessage, type FileMessage,
type AudioMessage, type AudioMessage,
type CardMessage type CardMessage,
type FaceMessage
} from '@/views/im/utils/message' } from '@/views/im/utils/message'
import type { Message } from '@/views/im/home/types' import type { Message } from '@/views/im/home/types'
import UserAvatar from '../../../../components/user/UserAvatar.vue' import UserAvatar from '../../../../components/user/UserAvatar.vue'
@ -679,6 +689,9 @@ function audioOf(message: Message): AudioMessage | null {
function cardOf(message: Message): CardMessage | null { function cardOf(message: Message): CardMessage | null {
return parseMessage<CardMessage>(message.content) return parseMessage<CardMessage>(message.content)
} }
function faceOf(message: Message): FaceMessage | null {
return parseMessage<FaceMessage>(message.content)
}
/** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */ /** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string { function textSnippetOf(message: Message): string {
@ -698,6 +711,8 @@ function textSnippetOf(message: Message): string {
return '[视频]' return '[视频]'
case ImMessageType.CARD: case ImMessageType.CARD:
return `[个人名片] ${parseMessage<CardMessage>(message.content)?.nickname ?? ''}` return `[个人名片] ${parseMessage<CardMessage>(message.content)?.nickname ?? ''}`
case ImMessageType.FACE:
return buildFacePreviewText(faceOf(message))
case ImMessageType.RECALL: case ImMessageType.RECALL:
return recallTipOf(message) return recallTipOf(message)
default: default:

View File

@ -157,6 +157,21 @@
> >
[视频消息] [视频消息]
</div> </div>
<!-- 表情贴图 <img>不套气泡对齐微信观感贴图本体就是装饰再叠气泡显累赘 -->
<div
v-else-if="isFace && facePayload"
class="inline-block"
>
<img
:src="facePayload.url"
:alt="facePayload.name || '表情'"
:title="facePayload.name || ''"
:width="facePayload.width"
:height="facePayload.height"
class="block max-w-[160px] max-h-[160px] object-contain"
draggable="false"
/>
</div>
<!-- 名片消息头像 + 昵称 + 个人名片标签点击气泡弹被推荐用户的名片浮层 <!-- 名片消息头像 + 昵称 + 个人名片标签点击气泡弹被推荐用户的名片浮层
参照微信观感自己 / 对方都是浅灰白卡片不染绿头像圆角块底部分隔条灰字个人名片 --> 参照微信观感自己 / 对方都是浅灰白卡片不染绿头像圆角块底部分隔条灰字个人名片 -->
<div <div
@ -275,6 +290,7 @@ import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '@/api/i
import { removeGroupMember } from '@/api/im/group/member' import { removeGroupMember } from '@/api/im/group/member'
import { import {
buildQuoteFromMessage, buildQuoteFromMessage,
extractAddableFace,
getQuoteFromMessage, getQuoteFromMessage,
parseMessage, parseMessage,
getFileIconInfo, getFileIconInfo,
@ -283,9 +299,10 @@ import {
type FileMessage, type FileMessage,
type AudioMessage, type AudioMessage,
type VideoMessage, type VideoMessage,
type CardMessage type CardMessage,
type FaceMessage
} from '@/views/im/utils/message' } from '@/views/im/utils/message'
import { buildRecallTip } from '../../../../../utils/conversation' import { buildRecallTip } from '@/views/im/utils/conversation'
import { formatSeconds } from '@/utils/formatTime' import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file' import { formatFileSize } from '@/utils/file'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
@ -293,6 +310,7 @@ import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'
import { useFriendStore } from '../../../../store/friendStore' import { useFriendStore } from '../../../../store/friendStore'
import { useDraftStore } from '../../../../store/draftStore' import { useDraftStore } from '../../../../store/draftStore'
import { useFaceStore } from '../../../../store/faceStore'
import { import {
getMemberDisplayName, getMemberDisplayName,
getSenderDisplayName, getSenderDisplayName,
@ -334,6 +352,7 @@ const conversationStore = useConversationStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
const draftStore = useDraftStore() const draftStore = useDraftStore()
const faceStore = useFaceStore()
const uiStore = useImUiStore() const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender() const { recall, sendRaw } = useMessageSender()
const { uploadAndSendMedia } = useMediaUploader() const { uploadAndSendMedia } = useMediaUploader()
@ -361,6 +380,7 @@ const isFile = computed(() => props.message.type === ImMessageType.FILE)
const isVoice = computed(() => props.message.type === ImMessageType.VOICE) const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO) const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
const isCard = computed(() => props.message.type === ImMessageType.CARD) const isCard = computed(() => props.message.type === ImMessageType.CARD)
const isFace = computed(() => props.message.type === ImMessageType.FACE)
// ==================== helper ==================== // ==================== helper ====================
// self / other ::before message-bubble--self/other helper 4 selfSend ternary // self / other ::before message-bubble--self/other helper 4 selfSend ternary
@ -477,6 +497,9 @@ const videoPayload = computed(() =>
const cardPayload = computed(() => const cardPayload = computed(() =>
isCard.value ? parseMessage<CardMessage>(props.message.content) : null isCard.value ? parseMessage<CardMessage>(props.message.content) : null
) )
const facePayload = computed(() =>
isFace.value ? parseMessage<FaceMessage>(props.message.content) : null
)
/** 名片点击:弹被推荐用户的 UserInfoCard陌生人名片走「名片」加好友来源 */ /** 名片点击:弹被推荐用户的 UserInfoCard陌生人名片走「名片」加好友来源 */
function handleCardClick(e: MouseEvent) { function handleCardClick(e: MouseEvent) {
@ -666,6 +689,7 @@ const isAtMe = computed(() => {
const MENU_KEYS = { const MENU_KEYS = {
REPLY: 'REPLY', REPLY: 'REPLY',
PIN: 'PIN', PIN: 'PIN',
ADD_TO_FACE: 'ADD_TO_FACE',
MUTE: 'MUTE', MUTE: 'MUTE',
UNMUTE: 'UNMUTE', UNMUTE: 'UNMUTE',
KICK: 'KICK', KICK: 'KICK',
@ -714,6 +738,14 @@ async function handleContextMenu(e: MouseEvent) {
icon: 'ant-design:pushpin-outlined' icon: 'ant-design:pushpin-outlined'
}) })
} }
// FACE / IMAGE + +
if (canAddToFace.value) {
items.push({
key: MENU_KEYS.ADD_TO_FACE,
name: '添加到表情',
icon: 'ant-design:smile-outlined'
})
}
// / / + + // / / + +
if (currentGroup.value && !props.message.selfSend && canManageSender.value) { if (currentGroup.value && !props.message.selfSend && canManageSender.value) {
const senderMember = currentGroup.value.members?.find( const senderMember = currentGroup.value.members?.find(
@ -775,6 +807,8 @@ async function handleContextMenu(e: MouseEvent) {
handleReply() handleReply()
} else if (item.key === MENU_KEYS.PIN) { } else if (item.key === MENU_KEYS.PIN) {
await handlePin() await handlePin()
} else if (item.key === MENU_KEYS.ADD_TO_FACE) {
await handleAddToFace()
} else if (item.key === MENU_KEYS.MUTE) { } else if (item.key === MENU_KEYS.MUTE) {
handleMute() handleMute()
} else if (item.key === MENU_KEYS.UNMUTE) { } else if (item.key === MENU_KEYS.UNMUTE) {
@ -789,6 +823,30 @@ async function handleContextMenu(e: MouseEvent) {
}) })
} }
/** 是否可「添加到表情」FACE / IMAGE 消息 + 已落库 + 未撤回GIF / 静图都允许) */
const canAddToFace = computed(() => {
if (isRecall.value || !props.message.id) {
return false
}
return extractAddableFace(props.message) !== null
})
/** 添加到个人表情:从 message 抽 url + 尺寸 + name 写入个人表情库;幂等失败时返回 false 走 toast 兜底 */
async function handleAddToFace() {
const payload = extractAddableFace(props.message)
if (!payload) {
return
}
// TODO @AI data
const ok = await faceStore.addFaceUserItem({
...payload,
sourceMessageId: props.message.id
})
if (ok) {
successMessage('已添加到表情')
}
}
/** 当前激活会话对应的群(私聊场景为 undefined */ /** 当前激活会话对应的群(私聊场景为 undefined */
const currentGroup = computed(() => { const currentGroup = computed(() => {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation

View File

@ -65,7 +65,15 @@
</span> </span>
</template> </template>
<!-- 图片 / 视频缩略图 --> <!-- 表情贴图缩略图 + name name 仅显示 [表情] -->
<template v-else-if="isFace">
<span class="flex-shrink-0">[表情]</span>
<span v-if="parsedPayload?.name" class="im-reply-preview__text min-w-0">
{{ parsedPayload.name }}
</span>
</template>
<!-- 图片 / 视频 / 表情贴图缩略图 -->
<img <img
v-if="thumbnailUrl" v-if="thumbnailUrl"
:src="thumbnailUrl" :src="thumbnailUrl"
@ -99,6 +107,7 @@ import {
getFileIconInfo, getFileIconInfo,
type AudioMessage, type AudioMessage,
type CardMessage, type CardMessage,
type FaceMessage,
type FileMessage, type FileMessage,
type ImageMessage, type ImageMessage,
type TextMessage, type TextMessage,
@ -158,7 +167,7 @@ const senderName = computed(() => {
/** quote.content 解析一次缓存,让多个 computed 复用,长会话每条引用气泡少一次 JSON.parse */ /** quote.content 解析一次缓存,让多个 computed 复用,长会话每条引用气泡少一次 JSON.parse */
type AnyQuotePayload = Partial< type AnyQuotePayload = Partial<
TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage & CardMessage TextMessage & ImageMessage & FileMessage & AudioMessage & VideoMessage & CardMessage & FaceMessage
> >
const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content)) const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content))
@ -166,6 +175,7 @@ const isText = computed(() => props.quote.type === ImMessageType.TEXT)
const isFile = computed(() => props.quote.type === ImMessageType.FILE) const isFile = computed(() => props.quote.type === ImMessageType.FILE)
const isVoice = computed(() => props.quote.type === ImMessageType.VOICE) const isVoice = computed(() => props.quote.type === ImMessageType.VOICE)
const isCard = computed(() => props.quote.type === ImMessageType.CARD) const isCard = computed(() => props.quote.type === ImMessageType.CARD)
const isFace = computed(() => props.quote.type === ImMessageType.FACE)
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */ /** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
const textPreview = computed(() => { const textPreview = computed(() => {
@ -178,7 +188,7 @@ const textPreview = computed(() => {
/** 文件 icon按扩展名挑色跟主气泡渲染同源 */ /** 文件 icon按扩展名挑色跟主气泡渲染同源 */
const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name)) const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name))
/** 缩略图 URL图片 / 视频从 quote.content 直接取,不依赖本地缓存 */ /** 缩略图 URL图片 / 视频 / 表情贴图从 quote.content 直接取,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => { const thumbnailUrl = computed<string | undefined>(() => {
if (isRecalled.value) { if (isRecalled.value) {
return undefined return undefined
@ -190,6 +200,9 @@ const thumbnailUrl = computed<string | undefined>(() => {
if (type === ImMessageType.VIDEO) { if (type === ImMessageType.VIDEO) {
return parsedPayload.value?.coverUrl return parsedPayload.value?.coverUrl
} }
if (type === ImMessageType.FACE) {
return parsedPayload.value?.url
}
return undefined return undefined
}) })