366 lines
13 KiB
Vue
366 lines
13 KiB
Vue
<template>
|
||
<!--
|
||
表情面板(多 tab):emoji / 个人表情 / N 个系统表情包
|
||
- 对齐微信 PC:底部 tab 栏切换面板内容;emoji 保持 Unicode(仍由 TEXT 通道发送)
|
||
- 个人表情 / 系统表情走 FACE 消息类型,通过 select-face 事件由调用方走 sendRaw 发送
|
||
- mode='emoji-only' 时只显示 emoji tab + 隐藏底部 tab 栏,给留言 / 评论这类只发文本的场景用
|
||
- 定位由调用方决定(通常浮在表情按钮上方)
|
||
-->
|
||
<div
|
||
v-if="visible"
|
||
ref="rootRef"
|
||
class="im-popover-arrow absolute z-100 flex flex-col w-[380px] rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
|
||
@click.stop
|
||
>
|
||
<!-- 主内容区:高度固定 + 各 tab 用 v-show 切换,避免每次切 tab 重建 scrollbar 造成滚动位置丢失 -->
|
||
<div class="relative h-[300px] overflow-hidden">
|
||
<!-- emoji 网格 -->
|
||
<el-scrollbar v-show="activeTab === FACE_TAB.EMOJI" height="300px">
|
||
<div class="grid grid-cols-10 gap-0.5 p-2">
|
||
<button
|
||
v-for="emoji in IM_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="handleSelectEmoji(emoji)"
|
||
>
|
||
{{ emoji }}
|
||
</button>
|
||
</div>
|
||
</el-scrollbar>
|
||
|
||
<!-- 个人表情:5 列方格,无名字标签;末尾「+」上传 -->
|
||
<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"
|
||
:disabled="uploading"
|
||
@click="onUploadClick"
|
||
>
|
||
<Icon
|
||
:icon="uploading ? 'eos-icons:bubble-loading' : 'ant-design:plus-outlined'"
|
||
:size="20"
|
||
/>
|
||
</button>
|
||
<div
|
||
v-for="item in faceStore.faceUserItems"
|
||
:key="item.id"
|
||
class="im-face-grid-cell group relative aspect-square flex items-center justify-center rounded-md bg-[var(--el-fill-color-lighter)] cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
|
||
:title="item.name || '点击发送 / 右键删除'"
|
||
@click="handleSelectFaceUserItem(item)"
|
||
@contextmenu.prevent="handleDeleteUserItem(item)"
|
||
>
|
||
<img
|
||
:src="item.url"
|
||
:alt="item.name || '表情'"
|
||
class="max-w-full max-h-full object-contain"
|
||
draggable="false"
|
||
/>
|
||
</div>
|
||
<div
|
||
v-if="!faceStore.faceUserItems.length"
|
||
class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--el-text-color-placeholder)]"
|
||
>
|
||
还没有个人表情,点 + 上传 / 在聊天里长按消息添加
|
||
</div>
|
||
</div>
|
||
</el-scrollbar>
|
||
|
||
<!-- 系统表情包:5 列方格 + 下方表情名(无 name 时不显示标签,保持高度一致) -->
|
||
<template v-if="isFullMode">
|
||
<el-scrollbar
|
||
v-for="pack in faceStore.facePacks"
|
||
v-show="activeTab === packTabKey(pack.id)"
|
||
:key="pack.id"
|
||
height="300px"
|
||
>
|
||
<div class="grid grid-cols-5 gap-2 p-3">
|
||
<div
|
||
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
|
||
class="aspect-square w-full flex items-center justify-center bg-[var(--el-fill-color-lighter)] rounded"
|
||
>
|
||
<img
|
||
: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
|
||
v-if="!pack.items.length"
|
||
class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--el-text-color-placeholder)]"
|
||
>
|
||
该表情包暂无表情
|
||
</div>
|
||
</div>
|
||
</el-scrollbar>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 底部 tab 栏:[ emoji / 个人 / 系统包 1..N ];emoji-only 模式下隐藏 -->
|
||
<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)]"
|
||
>
|
||
<el-tooltip content="Emoji 表情" placement="top" :show-after="300">
|
||
<button
|
||
class="im-face-tab"
|
||
:class="{ 'im-face-tab--active': activeTab === FACE_TAB.EMOJI }"
|
||
type="button"
|
||
@click="activeTab = FACE_TAB.EMOJI"
|
||
>
|
||
<Icon icon="ant-design:smile-outlined" :size="18" />
|
||
</button>
|
||
</el-tooltip>
|
||
<el-tooltip content="个人表情" placement="top" :show-after="300">
|
||
<button
|
||
class="im-face-tab"
|
||
:class="{ 'im-face-tab--active': activeTab === FACE_TAB.MINE }"
|
||
type="button"
|
||
@click="activeTab = FACE_TAB.MINE"
|
||
>
|
||
<Icon icon="ant-design:heart-outlined" :size="18" />
|
||
</button>
|
||
</el-tooltip>
|
||
<el-tooltip
|
||
v-for="pack in faceStore.facePacks"
|
||
:key="pack.id"
|
||
:content="pack.name"
|
||
placement="top"
|
||
:show-after="300"
|
||
>
|
||
<button
|
||
class="im-face-tab"
|
||
:class="{ 'im-face-tab--active': activeTab === packTabKey(pack.id) }"
|
||
type="button"
|
||
@click="activeTab = packTabKey(pack.id)"
|
||
>
|
||
<img
|
||
v-if="pack.iconUrl"
|
||
:src="pack.iconUrl"
|
||
:alt="pack.name"
|
||
class="w-[18px] h-[18px] object-contain"
|
||
draggable="false"
|
||
/>
|
||
<Icon v-else icon="ant-design:appstore-outlined" :size="18" />
|
||
</button>
|
||
</el-tooltip>
|
||
</div>
|
||
|
||
<!-- 隐藏的文件选择器(点 + 时触发) -->
|
||
<input
|
||
v-if="isFullMode"
|
||
ref="uploadInputRef"
|
||
type="file"
|
||
accept="image/*"
|
||
hidden
|
||
@change="onUploadPicked"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
import Icon from '@/components/Icon/src/Icon.vue'
|
||
import { updateFile } from '@/api/infra/file'
|
||
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'
|
||
|
||
defineOptions({ name: 'ImFacePicker' })
|
||
|
||
/** 面板模式 */
|
||
// TODO @AI:直接就叫 emoji,不用带 only
|
||
type FacePickerMode = 'full' | 'emoji-only'
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
visible: boolean
|
||
/** full:emoji + 个人表情 + 系统包(聊天主输入用);emoji-only:仅 emoji(留言 / 评论场景) */
|
||
mode?: FacePickerMode
|
||
}>(),
|
||
{ mode: 'full' }
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
'update:visible': [value: boolean]
|
||
/** 选中 Unicode emoji(如 😀),调用方应插入到输入框走 TEXT 通道 */
|
||
'select-emoji': [emoji: string]
|
||
/** 选中表情贴图,调用方应走 FACE 消息发送 */
|
||
'select-face': [face: { url: string; width: number; height: number; name?: string }]
|
||
}>()
|
||
|
||
const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
|
||
const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef')
|
||
|
||
const faceStore = useFaceStore()
|
||
|
||
/** tab 标识常量;pack:N 类用 packTabKey() 拼出,避免散落字符串字面量 */
|
||
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)
|
||
|
||
/** 选 emoji 字符:插到输入框;选完不关面板,方便用户连发多个 */
|
||
function handleSelectEmoji(emoji: string) {
|
||
emit('select-emoji', emoji)
|
||
}
|
||
|
||
/** 选个人表情:直接发;点完关面板,对齐微信 */
|
||
function handleSelectFaceUserItem(item: ImFaceUserItemVO) {
|
||
emit('select-face', { url: item.url, width: item.width, height: item.height, name: item.name })
|
||
emit('update:visible', false)
|
||
}
|
||
|
||
/** 选系统表情包内表情:直接发;点完关面板 */
|
||
function handleSelectPackItem(item: ImFacePackUserItemVO) {
|
||
emit('select-face', { url: item.url, width: item.width, height: item.height, name: item.name })
|
||
emit('update:visible', false)
|
||
}
|
||
|
||
/** 长按 / 右键删除个人表情 */
|
||
async function handleDeleteUserItem(item: ImFaceUserItemVO) {
|
||
if (!confirm('确认删除该表情?')) {
|
||
return
|
||
}
|
||
await faceStore.removeFaceUserItem(item.id)
|
||
}
|
||
|
||
/** 点 + 触发文件选择 */
|
||
function onUploadClick() {
|
||
uploadInputRef.value?.click()
|
||
}
|
||
|
||
/** 文件选完即上传,成功后写入 faceStore 个人表情列表 */
|
||
async function onUploadPicked(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
input.value = ''
|
||
if (!file) {
|
||
return
|
||
}
|
||
uploading.value = true
|
||
try {
|
||
// probe 本地图片宽高 + 上传到 OSS 并行起跑(probe 通常远快于上传,几乎完全被遮蔽)
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
const [size, uploadRes] = await Promise.all([
|
||
probeImageSize(file),
|
||
updateFile(form) as Promise<{ data?: string }>
|
||
])
|
||
const url = uploadRes?.data
|
||
if (!url) {
|
||
ElMessage.error('上传失败')
|
||
return
|
||
}
|
||
const ok = await faceStore.addFaceUserItem({ url, width: size.width, height: size.height })
|
||
if (!ok) {
|
||
ElMessage.error('添加失败,可能已添加过')
|
||
}
|
||
} catch (err) {
|
||
console.warn('[IM] 上传个人表情失败', err)
|
||
ElMessage.error('上传失败')
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
/** 面板展开时拉数据 + 挂全局 click;emoji-only 模式下不拉系统包 / 个人表情 */
|
||
watch(
|
||
() => props.visible,
|
||
(visible) => {
|
||
if (visible) {
|
||
document.addEventListener('click', handleDocumentClick)
|
||
if (isFullMode.value) {
|
||
// 系统包通常已被 home onMounted 预拉过;ensureXxx 内部 promise 缓存避免重复请求
|
||
void faceStore.ensureFacePacks()
|
||
void faceStore.ensureFaceUserItems()
|
||
}
|
||
} else {
|
||
document.removeEventListener('click', handleDocumentClick)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
/** 点击面板外部关闭 */
|
||
function handleDocumentClick(e: MouseEvent) {
|
||
if (!props.visible || !rootRef.value) {
|
||
return
|
||
}
|
||
if (!rootRef.value.contains(e.target as Node)) {
|
||
emit('update:visible', false)
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
/* tab 按钮样式:被选中走主色高亮,鼠标悬停浅底 */
|
||
.im-face-tab {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background-color: transparent;
|
||
color: var(--el-text-color-regular);
|
||
cursor: pointer;
|
||
transition:
|
||
background-color 0.15s,
|
||
color 0.15s;
|
||
}
|
||
.im-face-tab:hover {
|
||
background-color: var(--el-fill-color);
|
||
}
|
||
.im-face-tab--active {
|
||
background-color: var(--el-fill-color);
|
||
color: var(--el-color-primary);
|
||
}
|
||
</style>
|