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

View File

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

View File

@ -261,6 +261,15 @@
<span>个人名片{{ cardOf(message)?.nickname || '' }}</span>
</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
v-else-if="message.type === ImMessageType.RECALL"
@ -324,7 +333,7 @@ import {
resolveFriendNotificationText,
resolveGroupNotificationText
} 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 {
ImConversationType,
@ -339,7 +348,8 @@ import {
type ImageMessage,
type FileMessage,
type AudioMessage,
type CardMessage
type CardMessage,
type FaceMessage
} from '@/views/im/utils/message'
import type { Message } from '@/views/im/home/types'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
@ -679,6 +689,9 @@ function audioOf(message: Message): AudioMessage | null {
function cardOf(message: Message): CardMessage | null {
return parseMessage<CardMessage>(message.content)
}
function faceOf(message: Message): FaceMessage | null {
return parseMessage<FaceMessage>(message.content)
}
/** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string {
@ -698,6 +711,8 @@ function textSnippetOf(message: Message): string {
return '[视频]'
case ImMessageType.CARD:
return `[个人名片] ${parseMessage<CardMessage>(message.content)?.nickname ?? ''}`
case ImMessageType.FACE:
return buildFacePreviewText(faceOf(message))
case ImMessageType.RECALL:
return recallTipOf(message)
default:

View File

@ -157,6 +157,21 @@
>
[视频消息]
</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
@ -275,6 +290,7 @@ import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '@/api/i
import { removeGroupMember } from '@/api/im/group/member'
import {
buildQuoteFromMessage,
extractAddableFace,
getQuoteFromMessage,
parseMessage,
getFileIconInfo,
@ -283,9 +299,10 @@ import {
type FileMessage,
type AudioMessage,
type VideoMessage,
type CardMessage
type CardMessage,
type FaceMessage
} from '@/views/im/utils/message'
import { buildRecallTip } from '../../../../../utils/conversation'
import { buildRecallTip } from '@/views/im/utils/conversation'
import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file'
import { useUserStore } from '@/store/modules/user'
@ -293,6 +310,7 @@ import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useDraftStore } from '../../../../store/draftStore'
import { useFaceStore } from '../../../../store/faceStore'
import {
getMemberDisplayName,
getSenderDisplayName,
@ -334,6 +352,7 @@ const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const faceStore = useFaceStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
const { uploadAndSendMedia } = useMediaUploader()
@ -361,6 +380,7 @@ const isFile = computed(() => props.message.type === ImMessageType.FILE)
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
const isCard = computed(() => props.message.type === ImMessageType.CARD)
const isFace = computed(() => props.message.type === ImMessageType.FACE)
// ==================== helper ====================
// self / other ::before message-bubble--self/other helper 4 selfSend ternary
@ -477,6 +497,9 @@ const videoPayload = computed(() =>
const cardPayload = computed(() =>
isCard.value ? parseMessage<CardMessage>(props.message.content) : null
)
const facePayload = computed(() =>
isFace.value ? parseMessage<FaceMessage>(props.message.content) : null
)
/** 名片点击:弹被推荐用户的 UserInfoCard陌生人名片走「名片」加好友来源 */
function handleCardClick(e: MouseEvent) {
@ -666,6 +689,7 @@ const isAtMe = computed(() => {
const MENU_KEYS = {
REPLY: 'REPLY',
PIN: 'PIN',
ADD_TO_FACE: 'ADD_TO_FACE',
MUTE: 'MUTE',
UNMUTE: 'UNMUTE',
KICK: 'KICK',
@ -714,6 +738,14 @@ async function handleContextMenu(e: MouseEvent) {
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) {
const senderMember = currentGroup.value.members?.find(
@ -775,6 +807,8 @@ async function handleContextMenu(e: MouseEvent) {
handleReply()
} else if (item.key === MENU_KEYS.PIN) {
await handlePin()
} else if (item.key === MENU_KEYS.ADD_TO_FACE) {
await handleAddToFace()
} else if (item.key === MENU_KEYS.MUTE) {
handleMute()
} 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 */
const currentGroup = computed(() => {
const conversation = conversationStore.activeConversation

View File

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