✨ feat(im): 初始化表情包 v0.2:第三把 review
parent
2f513f7b8f
commit
a98e32554c
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue