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

im
YunaiV 2026-05-06 20:50:55 +08:00
parent 1ed5dc7e6a
commit 8eebfd4744
14 changed files with 314 additions and 353 deletions

View File

@ -1,70 +1,3 @@
import request from '@/config/axios' // 用户端 IM 表情 API barrel 出口按子目录pack / userItem二次拆分调用方可用 `from '@/api/im/face'` 一次拿全
export * from './pack'
// TODO @AI分拆下文件 export * from './userItem'
/** 用户端表情包项(精简版) */
export interface ImFacePackUserItemVO {
id: number
url: string
name?: string
width: number
height: number
}
/** 用户端表情包 + 嵌套 items */
export interface ImFacePackUserVO {
id: number
name: string
iconUrl?: string
items: ImFacePackUserItemVO[]
}
/** 个人表情 */
export interface ImFaceUserItemVO {
id: number
url: string
name?: string
width: number
height: number
}
/** 添加个人表情请求 */
export interface ImFaceUserItemSaveReqVO {
url: string
name?: string
width: number
height: number
/** 来源消息编号(从消息「添加到表情」时传,自己上传不传) */
sourceMessageId?: number
}
// ==================== 系统表情包 ====================
/** 拉取所有启用的系统表情包(含表情列表) */
export const getFacePackList = () => {
return request.get<ImFacePackUserVO[]>({ url: '/im/face-pack/list' })
}
// ==================== 个人表情 ====================
/** 获取我的个人表情列表 */
export const getMyFaceUserItemList = () => {
return request.get<ImFaceUserItemVO[]>({ url: '/im/face-user-item/list' })
}
/** 添加个人表情;服务端对同 URL 幂等(直接返回旧 id */
export const createFaceUserItem = (data: ImFaceUserItemSaveReqVO) => {
return request.post<number>({ url: '/im/face-user-item/create', data })
}
/** 删除个人表情 */
export const deleteFaceUserItem = (id: number) => {
return request.delete({ url: '/im/face-user-item/delete?id=' + id })
}
/** 批量删除个人表情 */
export const deleteFaceUserItemList = (ids: number[]) => {
return request.delete({
url: '/im/face-user-item/delete-list',
params: { ids: ids.join(',') }
})
}

View File

@ -0,0 +1,23 @@
import request from '@/config/axios'
/** 用户端表情包项(精简版) */
export interface ImFacePackUserItemVO {
id: number
url: string
name?: string
width: number
height: number
}
/** 用户端表情包 + 嵌套 items */
export interface ImFacePackUserVO {
id: number
name: string
iconUrl?: string
items: ImFacePackUserItemVO[]
}
/** 拉取所有启用的系统表情包(含表情列表) */
export const getFacePackList = () => {
return request.get<ImFacePackUserVO[]>({ url: '/im/face-pack/list' })
}

View File

@ -0,0 +1,34 @@
import request from '@/config/axios'
/** 个人表情 */
export interface ImFaceUserItemVO {
id: number
url: string
name?: string
width: number
height: number
}
/** 添加个人表情请求 */
export interface ImFaceUserItemSaveReqVO {
url: string
name?: string
width: number
height: number
sourceMessageId?: number // 来源消息编号(从消息「添加到表情」时传,自己上传不传)
}
/** 获取我的个人表情列表 */
export const getFaceUserItemList = () => {
return request.get<ImFaceUserItemVO[]>({ url: '/im/face-user-item/list' })
}
/** 添加个人表情;同 URL 重复添加服务端抛 FACE_USER_ITEM_DUPLICATED调用方按 catch 提示「已添加过」 */
export const createFaceUserItem = (data: ImFaceUserItemSaveReqVO) => {
return request.post<number>({ url: '/im/face-user-item/create', data })
}
/** 删除个人表情 */
export const deleteFaceUserItem = (id: number) => {
return request.delete({ url: '/im/face-user-item/delete?id=' + id })
}

View File

@ -1,6 +1,5 @@
import request from '@/config/axios' import request from '@/config/axios'
// TODO @AIface/item/index.ts
export interface ImManagerFacePackItemVO { export interface ImManagerFacePackItemVO {
id: number id: number
packId: number packId: number

View File

@ -1,6 +1,5 @@
import request from '@/config/axios' import request from '@/config/axios'
// TODO @AIface/pack/index.ts
export interface ImManagerFacePackVO { export interface ImManagerFacePackVO {
id: number id: number
name: string name: string

View File

@ -33,6 +33,7 @@ import { useFriendStore } from './store/friendStore'
import { useGroupStore } from './store/groupStore' import { useGroupStore } from './store/groupStore'
import { useGroupRequestStore } from './store/groupRequestStore' import { useGroupRequestStore } from './store/groupRequestStore'
import { useDraftStore } from './store/draftStore' import { useDraftStore } from './store/draftStore'
import { useFaceStore } from './store/faceStore'
import { useMessagePuller } from './composables/useMessagePuller' import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender' import { useMessageSender } from './composables/useMessageSender'
import { ImConversationType } from '../utils/constants' import { ImConversationType } from '../utils/constants'
@ -50,6 +51,7 @@ const friendStore = useFriendStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore() const groupRequestStore = useGroupRequestStore()
const draftStore = useDraftStore() const draftStore = useDraftStore()
const faceStore = useFaceStore()
const { pullOnce } = useMessagePuller() const { pullOnce } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender() const { readActive, syncPrivateReadStatus } = useMessageSender()
@ -92,6 +94,9 @@ onMounted(async () => {
void groupRequestStore.fetchUnhandledList().catch((e) => void groupRequestStore.fetchUnhandledList().catch((e) =>
console.warn('[IM] 拉取未处理加群申请失败', e) console.warn('[IM] 拉取未处理加群申请失败', e)
) )
// 3.2 tab
// TODO @AI
void faceStore.ensureFacePacks().catch((e) => console.warn('[IM] 后台预拉表情包失败', e))
// 4. // 4.
const sorted = conversationStore.getSortedConversations const sorted = conversationStore.getSortedConversations
@ -129,10 +134,11 @@ function onBeforeUnload() {
} }
window.addEventListener('beforeunload', onBeforeUnload) window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:主动断 WebSocketdisconnect 内部已清掉 onclose 防自动重连)+ flush 草稿 + 解绑 unload */ /** 离开 IM 主壳:主动断 WebSocketdisconnect 内部已清掉 onclose 防自动重连)+ flush 草稿 + 表情缓存 reset + 解绑 unload */
onUnmounted(() => { onUnmounted(() => {
webSocketStore.disconnect() webSocketStore.disconnect()
draftStore.flushPersist() draftStore.flushPersist()
faceStore.reset()
window.removeEventListener('beforeunload', onBeforeUnload) window.removeEventListener('beforeunload', onBeforeUnload)
}) })

View File

@ -1,113 +0,0 @@
<template>
<!--
简版 emoji 选择器只渲染 Unicode emoji所选直接拼到调用方文本
- 留言 / 表单这类轻量场景用避免把 FacePicker 整套个人 + 系统包塞进单行输入框边
- 调用方通过 v-model:visible 控制显隐通过 @select 接收选中的 emoji 字符
- 主聊天输入框场景请用 FacePicker.vue tab
-->
<div
v-if="visible"
ref="rootRef"
class="im-popover-arrow absolute z-100 w-80 p-2 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
@click.stop
>
<el-scrollbar max-height="240px">
<div class="grid grid-cols-10 gap-0.5">
<button
v-for="emoji in EMOJI_LIST"
:key="emoji"
class="p-1 text-xl leading-none bg-transparent border-none rounded cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
type="button"
@click="handleSelect(emoji)"
>
{{ emoji }}
</button>
</div>
</el-scrollbar>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
defineOptions({ name: 'ImEmojiPicker' })
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
select: [emoji: string]
}>()
const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
/** 常用 emoji 列表Unicode 表情,所见即所得;不依赖图片资源) */
const EMOJI_LIST = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩',
'🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
'🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤',
'😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', '😖',
'😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯',
'😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '😡', '😠',
'🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇', '🤠', '🥳',
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
'👆', '👇', '✋', '🤚', '🖐', '🖖', '👋', '🤝', '🙏', '💪',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '💕', '💖',
'🎉', '🎊', '🎁', '🎂', '🍰', '🌹', '🌷', '🌸', '🎵', '🎶',
'⭐', '🌟', '✨', '💫', '🔥', '💯', '✅', '❌', '⚠️', '❓'
]
function handleSelect(emoji: string) {
emit('select', emoji)
emit('update:visible', false)
}
/** 点击面板外部关闭:组件根节点已经 @click.stop所以面板内点击不会触发 */
function handleDocumentClick(e: MouseEvent) {
if (!props.visible || !rootRef.value) {
return
}
if (!rootRef.value.contains(e.target as Node)) {
emit('update:visible', false)
}
}
/** 仅在面板可见时监听,避免长期占用全局事件 */
watch(
() => props.visible,
(visible) => {
if (visible) {
document.addEventListener('click', handleDocumentClick)
} else {
document.removeEventListener('click', handleDocumentClick)
}
}
)
onMounted(() => {
if (props.visible) {
document.addEventListener('click', handleDocumentClick)
}
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
<style scoped>
/* 底部小三角:指向触发图标,仿微信 PC 气泡指针left 偏移对应表情按钮(工具栏 1st icon */
.im-popover-arrow::after {
content: '';
position: absolute;
top: calc(100% - 1px);
left: 10px;
border-style: solid;
border-width: 6px 6px 0 6px;
border-color: var(--el-bg-color) transparent transparent transparent;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
}
</style>

View File

@ -2,7 +2,8 @@
<!-- <!--
表情面板 tabemoji / 个人表情 / N 个系统表情包 表情面板 tabemoji / 个人表情 / N 个系统表情包
- 对齐微信 PC底部 tab 栏切换面板内容emoji 保持 Unicode仍由 TEXT 通道发送 - 对齐微信 PC底部 tab 栏切换面板内容emoji 保持 Unicode仍由 TEXT 通道发送
- 个人表情 / 系统表情走 FACE 消息类型通过 select-face 事件由 MessageInput sendRaw 发送 - 个人表情 / 系统表情走 FACE 消息类型通过 select-face 事件由调用方走 sendRaw 发送
- mode='emoji-only' 时只显示 emoji tab + 隐藏底部 tab 给留言 / 评论这类只发文本的场景用
- 定位由调用方决定通常浮在表情按钮上方 - 定位由调用方决定通常浮在表情按钮上方
--> -->
<div <div
@ -14,10 +15,10 @@
<!-- 主内容区高度固定 + tab v-show 切换避免每次切 tab 重建 scrollbar 造成滚动位置丢失 --> <!-- 主内容区高度固定 + tab v-show 切换避免每次切 tab 重建 scrollbar 造成滚动位置丢失 -->
<div class="relative h-[300px] overflow-hidden"> <div class="relative h-[300px] overflow-hidden">
<!-- emoji 网格 --> <!-- emoji 网格 -->
<el-scrollbar v-show="activeTab === 'emoji'" height="300px"> <el-scrollbar v-show="activeTab === FACE_TAB.EMOJI" height="300px">
<div class="grid grid-cols-10 gap-0.5 p-2"> <div class="grid grid-cols-10 gap-0.5 p-2">
<button <button
v-for="emoji in EMOJI_LIST" v-for="emoji in IM_EMOJI_LIST"
:key="emoji" :key="emoji"
class="p-1 text-xl leading-none bg-transparent border-none rounded cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]" class="p-1 text-xl leading-none bg-transparent border-none rounded cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
type="button" type="button"
@ -29,7 +30,7 @@
</el-scrollbar> </el-scrollbar>
<!-- 个人表情5 列方格无名字标签末尾+上传 --> <!-- 个人表情5 列方格无名字标签末尾+上传 -->
<el-scrollbar v-show="activeTab === '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">
<!-- 上传入口固定放第一格对齐微信 --> <!-- 上传入口固定放第一格对齐微信 -->
<button <button
@ -68,56 +69,59 @@
</el-scrollbar> </el-scrollbar>
<!-- 系统表情包5 列方格 + 下方表情名 name 时不显示标签保持高度一致 --> <!-- 系统表情包5 列方格 + 下方表情名 name 时不显示标签保持高度一致 -->
<el-scrollbar <template v-if="isFullMode">
v-for="pack in faceStore.facePacks" <el-scrollbar
v-show="activeTab === `pack:${pack.id}`" v-for="pack in faceStore.facePacks"
:key="pack.id" v-show="activeTab === packTabKey(pack.id)"
height="300px" :key="pack.id"
> height="300px"
<div class="grid grid-cols-5 gap-2 p-3"> >
<div <div class="grid grid-cols-5 gap-2 p-3">
v-for="item in pack.items"
:key="item.id"
class="im-face-grid-cell flex flex-col items-center gap-1 cursor-pointer rounded-md p-1 transition-colors hover:bg-[var(--el-fill-color)]"
:title="item.name || ''"
@click="handleSelectPackItem(item)"
>
<div <div
class="aspect-square w-full flex items-center justify-center bg-[var(--el-fill-color-lighter)] rounded" v-for="item in pack.items"
:key="item.id"
class="im-face-grid-cell flex flex-col items-center gap-1 cursor-pointer rounded-md p-1 transition-colors hover:bg-[var(--el-fill-color)]"
:title="item.name || ''"
@click="handleSelectPackItem(item)"
> >
<img <div
:src="item.url" class="aspect-square w-full flex items-center justify-center bg-[var(--el-fill-color-lighter)] rounded"
:alt="item.name || '表情'" >
class="max-w-full max-h-full object-contain" <img
draggable="false" :src="item.url"
/> :alt="item.name || '表情'"
class="max-w-full max-h-full object-contain"
draggable="false"
/>
</div>
<div
class="h-4 text-xs text-[var(--el-text-color-secondary)] truncate w-full text-center leading-4"
>
{{ item.name || '' }}
</div>
</div> </div>
<div <div
class="h-4 text-xs text-[var(--el-text-color-secondary)] truncate w-full text-center leading-4" v-if="!pack.items.length"
class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--el-text-color-placeholder)]"
> >
{{ item.name || '' }} 该表情包暂无表情
</div> </div>
</div> </div>
<div </el-scrollbar>
v-if="!pack.items.length" </template>
class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--el-text-color-placeholder)]"
>
该表情包暂无表情
</div>
</div>
</el-scrollbar>
</div> </div>
<!-- 底部 tab [ emoji / 个人 / 系统包 1..N ] --> <!-- 底部 tab [ emoji / 个人 / 系统包 1..N ]emoji-only 模式下隐藏 -->
<div <div
v-if="isFullMode"
class="flex flex-shrink-0 items-center gap-1 px-2 py-1.5 border-t border-[var(--el-border-color-lighter)]" class="flex flex-shrink-0 items-center gap-1 px-2 py-1.5 border-t border-[var(--el-border-color-lighter)]"
> >
<el-tooltip content="Emoji 表情" placement="top" :show-after="300"> <el-tooltip content="Emoji 表情" placement="top" :show-after="300">
<button <button
class="im-face-tab" class="im-face-tab"
:class="{ 'im-face-tab--active': activeTab === 'emoji' }" :class="{ 'im-face-tab--active': activeTab === FACE_TAB.EMOJI }"
type="button" type="button"
@click="activeTab = 'emoji'" @click="activeTab = FACE_TAB.EMOJI"
> >
<Icon icon="ant-design:smile-outlined" :size="18" /> <Icon icon="ant-design:smile-outlined" :size="18" />
</button> </button>
@ -125,9 +129,9 @@
<el-tooltip content="个人表情" placement="top" :show-after="300"> <el-tooltip content="个人表情" placement="top" :show-after="300">
<button <button
class="im-face-tab" class="im-face-tab"
:class="{ 'im-face-tab--active': activeTab === 'mine' }" :class="{ 'im-face-tab--active': activeTab === FACE_TAB.MINE }"
type="button" type="button"
@click="activeTab = 'mine'" @click="activeTab = FACE_TAB.MINE"
> >
<Icon icon="ant-design:heart-outlined" :size="18" /> <Icon icon="ant-design:heart-outlined" :size="18" />
</button> </button>
@ -141,9 +145,9 @@
> >
<button <button
class="im-face-tab" class="im-face-tab"
:class="{ 'im-face-tab--active': activeTab === `pack:${pack.id}` }" :class="{ 'im-face-tab--active': activeTab === packTabKey(pack.id) }"
type="button" type="button"
@click="activeTab = `pack:${pack.id}`" @click="activeTab = packTabKey(pack.id)"
> >
<img <img
v-if="pack.iconUrl" v-if="pack.iconUrl"
@ -158,24 +162,41 @@
</div> </div>
<!-- 隐藏的文件选择器 + 时触发 --> <!-- 隐藏的文件选择器 + 时触发 -->
<input ref="uploadInputRef" type="file" accept="image/*" hidden @change="onUploadPicked" /> <input
v-if="isFullMode"
ref="uploadInputRef"
type="file"
accept="image/*"
hidden
@change="onUploadPicked"
/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue' import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import { updateFile } from '@/api/infra/file' import { updateFile } from '@/api/infra/file'
import { useFaceStore } from '@/views/im/home/store/faceStore' import { useFaceStore } from '@/views/im/home/store/faceStore'
import { IM_EMOJI_LIST } from '@/views/im/utils/emoji'
import { probeImageSize } from '@/views/im/utils/image'
import type { ImFacePackUserItemVO, ImFaceUserItemVO } from '@/api/im/face' import type { ImFacePackUserItemVO, ImFaceUserItemVO } from '@/api/im/face'
defineOptions({ name: 'ImFacePicker' }) defineOptions({ name: 'ImFacePicker' })
const props = defineProps<{ /** 面板模式 */
visible: boolean type FacePickerMode = 'full' | 'emoji-only'
}>()
const props = withDefaults(
defineProps<{
visible: boolean
/** fullemoji + 个人表情 + 系统包聊天主输入用emoji-only仅 emoji留言 / 评论场景) */
mode?: FacePickerMode
}>(),
{ mode: 'full' }
)
const emit = defineEmits<{ const emit = defineEmits<{
'update:visible': [value: boolean] 'update:visible': [value: boolean]
@ -185,36 +206,27 @@ const emit = defineEmits<{
'select-face': [face: { url: string; width: number; height: number; name?: string }] 'select-face': [face: { url: string; width: number; height: number; name?: string }]
}>() }>()
// TODO @AIemoji facepicker
const rootRef = useTemplateRef<HTMLDivElement>('rootRef') const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef') const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef')
const faceStore = useFaceStore() const faceStore = useFaceStore()
/** 当前激活的 tabemoji / mine / pack:{id} */ /** tab 标识常量pack:N 类用 packTabKey() 拼出,避免散落字符串字面量 */
const activeTab = ref<string>('emoji') const FACE_TAB = {
EMOJI: 'emoji',
MINE: 'mine'
} as const
const packTabKey = (packId: number) => `pack:${packId}`
/** 上传中标记,避免连续点击 + 触发并发上传 */ /** 当前激活的 tab */
const activeTab = ref<string>(FACE_TAB.EMOJI)
/** 是否完整模式(含个人 / 系统包 tab */
const isFullMode = computed(() => props.mode === 'full')
/** 上传中标记,避免连续点击触发并发上传 */
const uploading = ref(false) const uploading = ref(false)
/** 常用 Unicode emoji 列表(所见即所得,不依赖图片资源) */
const EMOJI_LIST = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩',
'🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
'🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤',
'😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', '😖',
'😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯',
'😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '😡', '😠',
'🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇', '🤠', '🥳',
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
'👆', '👇', '✋', '🤚', '🖐', '🖖', '👋', '🤝', '🙏', '💪',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '💕', '💖',
'🎉', '🎊', '🎁', '🎂', '🍰', '🌹', '🌷', '🌸', '🎵', '🎶',
'⭐', '🌟', '✨', '💫', '🔥', '💯', '✅', '❌', '⚠️', '❓'
]
/** 选 emoji 字符:插到输入框;选完不关面板,方便用户连发多个 */ /** 选 emoji 字符:插到输入框;选完不关面板,方便用户连发多个 */
function handleSelectEmoji(emoji: string) { function handleSelectEmoji(emoji: string) {
emit('select-emoji', emoji) emit('select-emoji', emoji)
@ -234,7 +246,7 @@ function handleSelectPackItem(item: ImFacePackUserItemVO) {
/** 长按 / 右键删除个人表情 */ /** 长按 / 右键删除个人表情 */
async function handleDeleteUserItem(item: ImFaceUserItemVO) { async function handleDeleteUserItem(item: ImFaceUserItemVO) {
if (!confirm(`确认删除该表情?`)) { if (!confirm('确认删除该表情?')) {
return return
} }
await faceStore.removeFaceUserItem(item.id) await faceStore.removeFaceUserItem(item.id)
@ -245,23 +257,6 @@ function onUploadClick() {
uploadInputRef.value?.click() uploadInputRef.value?.click()
} }
/** 加载图片元信息(宽高),失败回退 200×200 */
function probeImageSize(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(objectUrl)
resolve({ width: img.naturalWidth || 200, height: img.naturalHeight || 200 })
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
resolve({ width: 200, height: 200 })
}
img.src = objectUrl
})
}
/** 文件选完即上传,成功后写入 faceStore 个人表情列表 */ /** 文件选完即上传,成功后写入 faceStore 个人表情列表 */
async function onUploadPicked(e: Event) { async function onUploadPicked(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
@ -284,7 +279,10 @@ async function onUploadPicked(e: Event) {
ElMessage.error('上传失败') ElMessage.error('上传失败')
return return
} }
await faceStore.addFaceUserItem({ url, width: size.width, height: size.height }) const ok = await faceStore.addFaceUserItem({ url, width: size.width, height: size.height })
if (!ok) {
ElMessage.error('添加失败,可能已添加过')
}
} catch (err) { } catch (err) {
console.warn('[IM] 上传个人表情失败', err) console.warn('[IM] 上传个人表情失败', err)
ElMessage.error('上传失败') ElMessage.error('上传失败')
@ -293,18 +291,22 @@ async function onUploadPicked(e: Event) {
} }
} }
/** 面板首次展开时拉数据emoji tab 直接渲染本地常量,不需要预拉 */ /** 面板展开时拉数据 + 挂全局 clickemoji-only 模式下不拉系统包 / 个人表情 */
watch( watch(
() => props.visible, () => props.visible,
async (visible) => { (visible) => {
if (visible) { if (visible) {
document.addEventListener('click', handleDocumentClick) document.addEventListener('click', handleDocumentClick)
// + if (isFullMode.value) {
await Promise.all([faceStore.ensureFacePacks(), faceStore.ensureFaceUserItems()]) // home onMounted ensureXxx promise
void faceStore.ensureFacePacks()
void faceStore.ensureFaceUserItems()
}
} else { } else {
document.removeEventListener('click', handleDocumentClick) document.removeEventListener('click', handleDocumentClick)
} }
} },
{ immediate: true }
) )
/** 点击面板外部关闭 */ /** 点击面板外部关闭 */
@ -317,12 +319,6 @@ function handleDocumentClick(e: MouseEvent) {
} }
} }
onMounted(() => {
if (props.visible) {
document.addEventListener('click', handleDocumentClick)
}
})
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick) document.removeEventListener('click', handleDocumentClick)
}) })

View File

@ -4,7 +4,7 @@ import { store } from '@/store'
import { import {
getFacePackList as apiGetFacePackList, getFacePackList as apiGetFacePackList,
getMyFaceUserItemList as apiGetMyFaceUserItemList, getFaceUserItemList as apiGetFaceUserItemList,
createFaceUserItem as apiCreateFaceUserItem, createFaceUserItem as apiCreateFaceUserItem,
deleteFaceUserItem as apiDeleteFaceUserItem, deleteFaceUserItem as apiDeleteFaceUserItem,
type ImFacePackUserVO, type ImFacePackUserVO,
@ -15,8 +15,9 @@ import {
/** /**
* IM store + * IM store +
* *
* IM tab * IM tab
* ensureFacePackstab / ensureFaceUserItems * - IM onMounted
* - tab /
*/ */
export const useFaceStore = defineStore('imFace', () => { export const useFaceStore = defineStore('imFace', () => {
@ -25,69 +26,60 @@ export const useFaceStore = defineStore('imFace', () => {
/** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */ /** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */
const faceUserItems = ref<ImFaceUserItemVO[]>([]) const faceUserItems = ref<ImFaceUserItemVO[]>([])
/** 已成功拉取过系统包;后续切 tab 不再重复请求 */ /**
const facePacksLoaded = ref(false) * promiseensureFacePacks cache
/** 已成功拉取过个人表情 */ * - null =
const faceUserItemsLoaded = ref(false) * - resolve = await
* - reject null
/** 拉取系统表情包;同 promise 去重,并发调用共用一个请求 */ */
let facePacksFetching: Promise<void> | null = null let facePacksPromise: Promise<void> | null = null
/** 按需拉取系统表情包(已拉过则直接复用 */ /** 按需拉取系统表情包(已拉过则直接复用 cached promise */
async function ensureFacePacks(): Promise<void> { async function ensureFacePacks(): Promise<void> {
if (facePacksLoaded.value) { if (!facePacksPromise) {
return facePacksPromise = apiGetFacePackList()
.then((data) => {
facePacks.value = data || []
})
.catch((e) => {
console.warn('[IM] 拉取表情包失败', e)
facePacksPromise = null
throw e
})
} }
if (facePacksFetching) { return facePacksPromise
return facePacksFetching
}
facePacksFetching = (async () => {
try {
facePacks.value = (await apiGetFacePackList()) || []
facePacksLoaded.value = true
} catch (e) {
console.warn('[IM] 拉取表情包失败', e)
} finally {
facePacksFetching = null
}
})()
return facePacksFetching
} }
// TODO @AI是不是也要注释下风格一致 /** 个人表情拉取 promise语义同上 */
let faceUserItemsFetching: Promise<void> | null = null let faceUserItemsPromise: Promise<void> | null = null
/** 按需拉取个人表情 */ /** 按需拉取个人表情(已拉过则直接复用 cached promise */
async function ensureFaceUserItems(): Promise<void> { async function ensureFaceUserItems(): Promise<void> {
if (faceUserItemsLoaded.value) { if (!faceUserItemsPromise) {
return faceUserItemsPromise = apiGetFaceUserItemList()
.then((data) => {
faceUserItems.value = data || []
})
.catch((e) => {
console.warn('[IM] 拉取个人表情失败', e)
faceUserItemsPromise = null
throw e
})
} }
if (faceUserItemsFetching) { return faceUserItemsPromise
return faceUserItemsFetching
}
faceUserItemsFetching = (async () => {
try {
faceUserItems.value = (await apiGetMyFaceUserItemList()) || []
faceUserItemsLoaded.value = true
} catch (e) {
console.warn('[IM] 拉取个人表情失败', e)
} finally {
faceUserItemsFetching = null
}
})()
return faceUserItemsFetching
} }
/** /**
* URL id id * URL FACE_USER_ITEM_DUPLICATED
* *
* 1. + 2. sourceMessageId * 1. + 2. sourceMessageId
* true / false removeFaceUserItem boolean
*/ */
async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise<number | undefined> { async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise<boolean> {
try { try {
const id = await apiCreateFaceUserItem(reqVO) const id = await apiCreateFaceUserItem(reqVO)
if (!id) { if (!id) {
return undefined return false
} }
// id 不在缓存里才插入,避免重复添加同张图触发列表里出现两条 // id 不在缓存里才插入;服务端唯一约束兜底了 race本地理论上不会拿到重复 id
if (!faceUserItems.value.some((item) => item.id === id)) { if (!faceUserItems.value.some((item) => item.id === id)) {
faceUserItems.value.unshift({ faceUserItems.value.unshift({
id, id,
@ -97,11 +89,10 @@ export const useFaceStore = defineStore('imFace', () => {
height: reqVO.height height: reqVO.height
}) })
} }
return id return true
} catch (e) { } catch (e) {
console.warn('[IM] 添加个人表情失败', e) console.warn('[IM] 添加个人表情失败', { reqVO }, e)
// TODO @AI应该把参数打印出来reqVO return false
return undefined
} }
} }
@ -112,8 +103,7 @@ export const useFaceStore = defineStore('imFace', () => {
faceUserItems.value = faceUserItems.value.filter((item) => item.id !== id) faceUserItems.value = faceUserItems.value.filter((item) => item.id !== id)
return true return true
} catch (e) { } catch (e) {
// TODO @AI应该把参数打印出来id console.warn('[IM] 删除个人表情失败', { id }, e)
console.warn('[IM] 删除个人表情失败', e)
return false return false
} }
} }
@ -122,8 +112,8 @@ export const useFaceStore = defineStore('imFace', () => {
function reset(): void { function reset(): void {
facePacks.value = [] facePacks.value = []
faceUserItems.value = [] faceUserItems.value = []
facePacksLoaded.value = false facePacksPromise = null
faceUserItemsLoaded.value = false faceUserItemsPromise = null
} }
return { return {

View File

@ -71,7 +71,7 @@
class="w-30px h-30px rounded object-contain align-middle" class="w-30px h-30px rounded object-contain align-middle"
draggable="false" draggable="false"
/> />
<span>{{ facePayload.name ? `[表情] ${facePayload.name}` : '[表情]' }}</span> <span>{{ buildFacePreviewText(facePayload) }}</span>
</span> </span>
<!-- 控制类消息撤回 / 已读 / 回执 --> <!-- 控制类消息撤回 / 已读 / 回执 -->
@ -138,6 +138,7 @@ import {
resolveFriendNotificationText, resolveFriendNotificationText,
resolveGroupNotificationText resolveGroupNotificationText
} from '@/views/im/utils/user' } from '@/views/im/utils/user'
import { buildFacePreviewText } from '@/views/im/utils/conversation'
defineOptions({ name: 'ImMessageContentPreview' }) defineOptions({ name: 'ImMessageContentPreview' })

View File

@ -7,7 +7,7 @@
// ==================================================================== // ====================================================================
import { ImMessageType, isFriendChatTip, isGroupNotification } from './constants' import { ImMessageType, isFriendChatTip, isGroupNotification } from './constants'
import { parseMessage, type TextMessage } from './message' import { parseMessage, type FaceMessage, type TextMessage } from './message'
import { import {
getSenderDisplayName, getSenderDisplayName,
resolveFriendNotificationText, resolveFriendNotificationText,
@ -32,6 +32,14 @@ export function filterConversationsByKeyword<T extends { name?: string }>(
return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed)) return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed))
} }
/**
* / / /
* name `[表情] name` name `[表情]` name
*/
export function buildFacePreviewText(facePayload: { name?: string } | null | undefined): string {
return facePayload?.name ? `[表情] ${facePayload.name}` : '[表情]'
}
/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */ /** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */
export function buildRecallTip( export function buildRecallTip(
senderId: number, senderId: number,
@ -70,10 +78,8 @@ export function resolveConversationLastContent(
return '[视频]' return '[视频]'
case ImMessageType.CARD: case ImMessageType.CARD:
return '[个人名片]' return '[个人名片]'
case ImMessageType.FACE: { case ImMessageType.FACE:
const facePayload = parseMessage<{ name?: string }>(message.content) return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
return facePayload?.name ? `[表情] ${facePayload.name}` : '[表情]'
}
case ImMessageType.RECALL: case ImMessageType.RECALL:
return buildRecallTip( return buildRecallTip(
message.senderId, message.senderId,

View File

@ -0,0 +1,21 @@
/**
* IM Unicode emoji
*
* FacePicker emoji tab使
* Unicode emoji TEXT FACE
*/
export const IM_EMOJI_LIST: string[] = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩',
'🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
'🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤',
'😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', '😖',
'😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯',
'😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '😡', '😠',
'🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇', '🤠', '🥳',
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
'👆', '👇', '✋', '🤚', '🖐', '🖖', '👋', '🤝', '🙏', '💪',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '💕', '💖',
'🎉', '🎊', '🎁', '🎂', '🍰', '🌹', '🌷', '🌸', '🎵', '🎶',
'⭐', '🌟', '✨', '💫', '🔥', '💯', '✅', '❌', '⚠️', '❓'
]

View File

@ -0,0 +1,34 @@
// ====================================================================
// IM 图片本地探针 utility
// ====================================================================
// 仅做「读 File 的宽高」一类纯前端 probe不涉及上传 / 网络
// ====================================================================
/** 默认占位尺寸probe 失败 / 解码异常时兜底,避免 width/height 为 0 让消息渲染塌掉 */
const DEFAULT_FALLBACK_SIZE = { width: 200, height: 200 } as const
/**
* File naturalWidth / naturalHeight
*
* -
* - / 200×200 nullable
* - revokeObjectURL blob URL
*/
export function probeImageSize(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(objectUrl)
resolve({
width: img.naturalWidth || DEFAULT_FALLBACK_SIZE.width,
height: img.naturalHeight || DEFAULT_FALLBACK_SIZE.height
})
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
resolve({ ...DEFAULT_FALLBACK_SIZE })
}
img.src = objectUrl
})
}

View File

@ -1,4 +1,5 @@
import { generateUUID } from '@/utils' import { generateUUID } from '@/utils'
import { ImMessageType } from './constants'
import type { Message } from '../home/types' import type { Message } from '../home/types'
// ==================================================================== // ====================================================================
@ -97,14 +98,45 @@ export interface CardMessage extends Quotable {
export interface FaceMessage extends Quotable { export interface FaceMessage extends Quotable {
/** 表情图 URL */ /** 表情图 URL */
url: string url: string
/** 渲染宽度(像素),避免布局抖动 */ /** 渲染宽度(像素),避免布局抖动;可选,缺失时调用方走 CSS max-w 兜底 */
width: number width?: number
/** 渲染高度(像素) */ /** 渲染高度(像素),可选 */
height: number height?: number
/** 表情名(系统包通常有,个人表情包通常无) */ /** 表情名(系统包通常有,个人表情包通常无) */
name?: string name?: string
} }
/** 「添加到表情」的可发起源FACE / IMAGE 都允许GIF 图片也常被收藏) */
export interface AddableFacePayload {
url: string
width: number
height: number
name?: string
}
/**
* payload null
*
* MessageItem nullable
*/
export function extractAddableFace(message: Message): AddableFacePayload | null {
if (message.type === ImMessageType.FACE) {
const face = parseMessage<FaceMessage>(message.content)
if (!face?.url) {
return null
}
return { url: face.url, width: face.width || 200, height: face.height || 200, name: face.name }
}
if (message.type === ImMessageType.IMAGE) {
const image = parseMessage<ImageMessage>(message.content)
if (!image?.url) {
return null
}
return { url: image.url, width: image.width || 200, height: image.height || 200 }
}
return null
}
/** 解析消息 contentJSON 字符串)为指定 payload非法 JSON 返回 null */ /** 解析消息 contentJSON 字符串)为指定 payload非法 JSON 返回 null */
export const parseMessage = <T>(content: string): T | null => { export const parseMessage = <T>(content: string): T | null => {
try { try {