✨ feat(im): 初始化表情包 v0.1:第二把 review
parent
1ed5dc7e6a
commit
8eebfd4744
|
|
@ -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(',') }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import request from '@/config/axios'
|
import request from '@/config/axios'
|
||||||
|
|
||||||
// TODO @AI:face/item/index.ts
|
|
||||||
export interface ImManagerFacePackItemVO {
|
export interface ImManagerFacePackItemVO {
|
||||||
id: number
|
id: number
|
||||||
packId: number
|
packId: number
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import request from '@/config/axios'
|
import request from '@/config/axios'
|
||||||
|
|
||||||
// TODO @AI:face/pack/index.ts
|
|
||||||
export interface ImManagerFacePackVO {
|
export interface ImManagerFacePackVO {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -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 主壳:主动断 WebSocket(disconnect 内部已清掉 onclose 防自动重连)+ flush 草稿 + 解绑 unload */
|
/** 离开 IM 主壳:主动断 WebSocket(disconnect 内部已清掉 onclose 防自动重连)+ flush 草稿 + 表情缓存 reset + 解绑 unload */
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
webSocketStore.disconnect()
|
webSocketStore.disconnect()
|
||||||
draftStore.flushPersist()
|
draftStore.flushPersist()
|
||||||
|
faceStore.reset()
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
<!--
|
<!--
|
||||||
表情面板(多 tab):emoji / 个人表情 / N 个系统表情包
|
表情面板(多 tab):emoji / 个人表情 / 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
|
||||||
|
/** full:emoji + 个人表情 + 系统包(聊天主输入用);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 @AI:emoji 和 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()
|
||||||
|
|
||||||
/** 当前激活的 tab:emoji / 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 直接渲染本地常量,不需要预拉 */
|
/** 面板展开时拉数据 + 挂全局 click;emoji-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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 即丢弃
|
||||||
* 系统包面板在面板首次展开时 ensureFacePacks;个人表情在切到「收藏」tab 或要发送 / 添加时 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)
|
* 系统表情包拉取 promise;ensureFacePacks 内 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 {
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* IM 常用 Unicode emoji 列表
|
||||||
|
*
|
||||||
|
* 所见即所得(不依赖图片资源),由表情面板(FacePicker 的 emoji tab)使用;
|
||||||
|
* Unicode emoji 选中后由调用方插入到输入框走 TEXT 通道,不走 FACE 消息类型
|
||||||
|
*/
|
||||||
|
export const IM_EMOJI_LIST: string[] = [
|
||||||
|
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||||
|
'😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩',
|
||||||
|
'🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
|
||||||
|
'🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤',
|
||||||
|
'😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', '😖',
|
||||||
|
'😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯',
|
||||||
|
'😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '😡', '😠',
|
||||||
|
'🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇', '🤠', '🥳',
|
||||||
|
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
|
||||||
|
'👆', '👇', '✋', '🤚', '🖐', '🖖', '👋', '🤝', '🙏', '💪',
|
||||||
|
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '💕', '💖',
|
||||||
|
'🎉', '🎊', '🎁', '🎂', '🍰', '🌹', '🌷', '🌸', '🎵', '🎶',
|
||||||
|
'⭐', '🌟', '✨', '💫', '🔥', '💯', '✅', '❌', '⚠️', '❓'
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
/** 解析消息 content(JSON 字符串)为指定 payload,非法 JSON 返回 null */
|
/** 解析消息 content(JSON 字符串)为指定 payload,非法 JSON 返回 null */
|
||||||
export const parseMessage = <T>(content: string): T | null => {
|
export const parseMessage = <T>(content: string): T | null => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue