✨ feat(im): 初始化表情包 v0.0:第一把 review
parent
8fc5273a88
commit
1ed5dc7e6a
|
|
@ -0,0 +1,70 @@
|
|||
import request from '@/config/axios'
|
||||
|
||||
// TODO @AI:分拆下文件!
|
||||
/** 用户端表情包项(精简版) */
|
||||
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,47 @@
|
|||
import request from '@/config/axios'
|
||||
|
||||
// TODO @AI:face/item/index.ts
|
||||
export interface ImManagerFacePackItemVO {
|
||||
id: number
|
||||
packId: number
|
||||
url: string
|
||||
name?: string
|
||||
width: number
|
||||
height: number
|
||||
sort: number
|
||||
status: number
|
||||
createTime?: Date
|
||||
}
|
||||
|
||||
/** 获得表情分页 */
|
||||
export const getManagerFacePackItemPage = (params: PageParam) => {
|
||||
return request.get({ url: '/im/manager/face-pack-item/page', params })
|
||||
}
|
||||
|
||||
/** 获得表情详情 */
|
||||
export const getManagerFacePackItem = (id: number) => {
|
||||
return request.get({ url: '/im/manager/face-pack-item/get?id=' + id })
|
||||
}
|
||||
|
||||
/** 新增表情 */
|
||||
export const createManagerFacePackItem = (data: ImManagerFacePackItemVO) => {
|
||||
return request.post({ url: '/im/manager/face-pack-item/create', data })
|
||||
}
|
||||
|
||||
/** 修改表情 */
|
||||
export const updateManagerFacePackItem = (data: ImManagerFacePackItemVO) => {
|
||||
return request.put({ url: '/im/manager/face-pack-item/update', data })
|
||||
}
|
||||
|
||||
/** 删除表情 */
|
||||
export const deleteManagerFacePackItem = (id: number) => {
|
||||
return request.delete({ url: '/im/manager/face-pack-item/delete?id=' + id })
|
||||
}
|
||||
|
||||
/** 批量删除表情 */
|
||||
export const deleteManagerFacePackItemList = (ids: number[]) => {
|
||||
return request.delete({
|
||||
url: '/im/manager/face-pack-item/delete-list',
|
||||
params: { ids: ids.join(',') }
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import request from '@/config/axios'
|
||||
|
||||
// TODO @AI:face/pack/index.ts
|
||||
export interface ImManagerFacePackVO {
|
||||
id: number
|
||||
name: string
|
||||
iconUrl?: string
|
||||
sort: number
|
||||
status: number
|
||||
createTime?: Date
|
||||
}
|
||||
|
||||
/** 获得表情包分页 */
|
||||
export const getManagerFacePackPage = (params: PageParam) => {
|
||||
return request.get({ url: '/im/manager/face-pack/page', params })
|
||||
}
|
||||
|
||||
/** 获得表情包详情 */
|
||||
export const getManagerFacePack = (id: number) => {
|
||||
return request.get({ url: '/im/manager/face-pack/get?id=' + id })
|
||||
}
|
||||
|
||||
/** 新增表情包 */
|
||||
export const createManagerFacePack = (data: ImManagerFacePackVO) => {
|
||||
return request.post({ url: '/im/manager/face-pack/create', data })
|
||||
}
|
||||
|
||||
/** 修改表情包 */
|
||||
export const updateManagerFacePack = (data: ImManagerFacePackVO) => {
|
||||
return request.put({ url: '/im/manager/face-pack/update', data })
|
||||
}
|
||||
|
||||
/** 删除表情包 */
|
||||
export const deleteManagerFacePack = (id: number) => {
|
||||
return request.delete({ url: '/im/manager/face-pack/delete?id=' + id })
|
||||
}
|
||||
|
||||
/** 批量删除表情包 */
|
||||
export const deleteManagerFacePackList = (ids: number[]) => {
|
||||
return request.delete({
|
||||
url: '/im/manager/face-pack/delete-list',
|
||||
params: { ids: ids.join(',') }
|
||||
})
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
<template>
|
||||
<!--
|
||||
表情选择器
|
||||
当前实现简化为:
|
||||
- 直接用 Unicode emoji,插入到输入框即所见即所得
|
||||
简版 emoji 选择器:只渲染 Unicode emoji,所选直接拼到调用方文本
|
||||
- 给「留言 / 表单」这类轻量场景用,避免把 FacePicker 整套(个人 + 系统包)塞进单行输入框边
|
||||
- 调用方通过 v-model:visible 控制显隐,通过 @select 接收选中的 emoji 字符
|
||||
- 定位由调用方决定(通常是「浮在表情按钮上方」)
|
||||
- 主聊天输入框场景请用 FacePicker.vue(多 tab)
|
||||
-->
|
||||
<div
|
||||
v-if="visible"
|
||||
|
|
@ -112,4 +111,3 @@ onUnmounted(() => {
|
|||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
<template>
|
||||
<!--
|
||||
表情面板(多 tab):emoji / 个人表情 / N 个系统表情包
|
||||
- 对齐微信 PC:底部 tab 栏切换面板内容;emoji 保持 Unicode(仍由 TEXT 通道发送)
|
||||
- 个人表情 / 系统表情走 FACE 消息类型,通过 select-face 事件由 MessageInput 走 sendRaw 发送
|
||||
- 定位由调用方决定(通常浮在表情按钮上方)
|
||||
-->
|
||||
<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 === 'emoji'" height="300px">
|
||||
<div class="grid grid-cols-10 gap-0.5 p-2">
|
||||
<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="handleSelectEmoji(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 个人表情:5 列方格,无名字标签;末尾「+」上传 -->
|
||||
<el-scrollbar v-show="activeTab === 'mine'" height="300px">
|
||||
<div class="grid grid-cols-5 gap-2 p-3">
|
||||
<!-- 上传入口固定放第一格,对齐微信 -->
|
||||
<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 时不显示标签,保持高度一致) -->
|
||||
<el-scrollbar
|
||||
v-for="pack in faceStore.facePacks"
|
||||
v-show="activeTab === `pack:${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>
|
||||
</div>
|
||||
|
||||
<!-- 底部 tab 栏:[ emoji / 个人 / 系统包 1..N ] -->
|
||||
<div
|
||||
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 === 'emoji' }"
|
||||
type="button"
|
||||
@click="activeTab = '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 === 'mine' }"
|
||||
type="button"
|
||||
@click="activeTab = '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 === `pack:${pack.id}` }"
|
||||
type="button"
|
||||
@click="activeTab = `pack:${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 ref="uploadInputRef" type="file" accept="image/*" hidden @change="onUploadPicked" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, 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 type { ImFacePackUserItemVO, ImFaceUserItemVO } from '@/api/im/face'
|
||||
|
||||
defineOptions({ name: 'ImFacePicker' })
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
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 }]
|
||||
}>()
|
||||
|
||||
// TODO @AI:emoji 和 facepicker 可以做一个融合么?不然代码有点冗余。
|
||||
|
||||
const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
|
||||
const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef')
|
||||
|
||||
const faceStore = useFaceStore()
|
||||
|
||||
/** 当前激活的 tab:emoji / mine / pack:{id} */
|
||||
const activeTab = ref<string>('emoji')
|
||||
|
||||
/** 上传中标记,避免连续点击 + 触发并发上传 */
|
||||
const uploading = ref(false)
|
||||
|
||||
/** 常用 Unicode emoji 列表(所见即所得,不依赖图片资源) */
|
||||
const EMOJI_LIST = [
|
||||
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||
'😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩',
|
||||
'🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
|
||||
'🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤',
|
||||
'😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', '😖',
|
||||
'😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯',
|
||||
'😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '😡', '😠',
|
||||
'🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇', '🤠', '🥳',
|
||||
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
|
||||
'👆', '👇', '✋', '🤚', '🖐', '🖖', '👋', '🤝', '🙏', '💪',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '💕', '💖',
|
||||
'🎉', '🎊', '🎁', '🎂', '🍰', '🌹', '🌷', '🌸', '🎵', '🎶',
|
||||
'⭐', '🌟', '✨', '💫', '🔥', '💯', '✅', '❌', '⚠️', '❓'
|
||||
]
|
||||
|
||||
/** 选 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()
|
||||
}
|
||||
|
||||
/** 加载图片元信息(宽高),失败回退 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 个人表情列表 */
|
||||
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
|
||||
}
|
||||
await faceStore.addFaceUserItem({ url, width: size.width, height: size.height })
|
||||
} catch (err) {
|
||||
console.warn('[IM] 上传个人表情失败', err)
|
||||
ElMessage.error('上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 面板首次展开时拉数据;emoji tab 直接渲染本地常量,不需要预拉 */
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (visible) => {
|
||||
if (visible) {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
// 系统包 + 个人表情都按需拉,避免没打开过面板的用户白白请求
|
||||
await Promise.all([faceStore.ensureFacePacks(), faceStore.ensureFaceUserItems()])
|
||||
} else {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/** 点击面板外部关闭 */
|
||||
function handleDocumentClick(e: MouseEvent) {
|
||||
if (!props.visible || !rootRef.value) {
|
||||
return
|
||||
}
|
||||
if (!rootRef.value.contains(e.target as Node)) {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/* 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>
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
|
||||
<!--
|
||||
底部工具栏:左侧操作图标 + 右侧发送按钮(对齐微信 PC:操作图标统一放底部)
|
||||
- relative 给 EmojiPicker 提供 absolute 锚点,picker 用 bottom-full 向上弹出
|
||||
- relative 给 FacePicker 提供 absolute 锚点,picker 用 bottom-full 向上弹出
|
||||
- 图标统一 30×30 点击区(18px icon + p-1.5),gap-1 让间距贴合微信观感
|
||||
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线(scoped CSS 避绕 UnoCSS preflight 缺失)
|
||||
-->
|
||||
|
|
@ -130,10 +130,11 @@
|
|||
</el-button>
|
||||
|
||||
<!-- 表情面板:bottom-full 让 picker 下沿贴工具栏顶部,向上弹出(对齐工具栏左侧首图标) -->
|
||||
<EmojiPicker
|
||||
<FacePicker
|
||||
v-model:visible="emojiVisible"
|
||||
class="bottom-full left-3 mb-2"
|
||||
@select="insertText"
|
||||
@select-emoji="insertText"
|
||||
@select-face="onSelectFace"
|
||||
/>
|
||||
|
||||
<!-- 语音录制面板:与表情面板同处工具栏,bottom-full 向上弹出,避免离触发的麦克风图标过远 -->
|
||||
|
|
@ -176,11 +177,12 @@ import { getConversationKey } from '@/views/im/utils/conversation'
|
|||
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
|
||||
import {
|
||||
serializeMessage,
|
||||
type FaceMessage,
|
||||
type QuoteMessage,
|
||||
withQuotePayload
|
||||
} from '@/views/im/utils/message'
|
||||
|
||||
import EmojiPicker from './EmojiPicker.vue'
|
||||
import FacePicker from './FacePicker.vue'
|
||||
import MentionPicker from './MentionPicker.vue'
|
||||
import VoiceRecorder from './VoiceRecorder.vue'
|
||||
import ReplyPreview from '../message/ReplyPreview.vue'
|
||||
|
|
@ -193,7 +195,7 @@ const conversationStore = useConversationStore()
|
|||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const draftStore = useDraftStore()
|
||||
const { send } = useMessageSender()
|
||||
const { send, sendRaw } = useMessageSender()
|
||||
const {
|
||||
uploadAndSendMedia,
|
||||
insertMediaPlaceholder,
|
||||
|
|
@ -586,6 +588,23 @@ function toggleEmoji() {
|
|||
}
|
||||
}
|
||||
|
||||
/** 选中表情贴图:拼 FaceMessage payload 直接走 sendRaw 发送(quote 复用当前 reply 快照) */
|
||||
async function onSelectFace(face: { url: string; width: number; height: number; name?: string }) {
|
||||
if (muteOverlay.value) {
|
||||
return
|
||||
}
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
const replyQuote = consumeReply()
|
||||
const payload = withQuotePayload<FaceMessage>(
|
||||
{ url: face.url, width: face.width, height: face.height, name: face.name },
|
||||
replyQuote
|
||||
)
|
||||
await sendRaw(ImMessageType.FACE, serializeMessage(payload), { conversation })
|
||||
}
|
||||
|
||||
// ==================== @ 成员选择(群聊) ====================
|
||||
const isGroup = computed(
|
||||
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { store } from '@/store'
|
||||
|
||||
import {
|
||||
getFacePackList as apiGetFacePackList,
|
||||
getMyFaceUserItemList as apiGetMyFaceUserItemList,
|
||||
createFaceUserItem as apiCreateFaceUserItem,
|
||||
deleteFaceUserItem as apiDeleteFaceUserItem,
|
||||
type ImFacePackUserVO,
|
||||
type ImFaceUserItemVO,
|
||||
type ImFaceUserItemSaveReqVO
|
||||
} from '@/api/im/face'
|
||||
|
||||
/**
|
||||
* IM 表情面板数据 store(系统表情包 + 个人表情)
|
||||
*
|
||||
* 不持久化:数据小、低频;每次进 IM 第一次打开表情面板时按需拉,关 tab 即丢弃。
|
||||
* 系统包面板在面板首次展开时 ensureFacePacks;个人表情在切到「收藏」tab 或要发送 / 添加时 ensureFaceUserItems。
|
||||
*/
|
||||
export const useFaceStore = defineStore('imFace', () => {
|
||||
|
||||
/** 系统表情包列表(含每个包的 items);运营管理后台维护 */
|
||||
const facePacks = ref<ImFacePackUserVO[]>([])
|
||||
/** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */
|
||||
const faceUserItems = ref<ImFaceUserItemVO[]>([])
|
||||
|
||||
/** 已成功拉取过系统包;后续切 tab 不再重复请求 */
|
||||
const facePacksLoaded = ref(false)
|
||||
/** 已成功拉取过个人表情 */
|
||||
const faceUserItemsLoaded = ref(false)
|
||||
|
||||
/** 拉取系统表情包;同 promise 去重,并发调用共用一个请求 */
|
||||
let facePacksFetching: Promise<void> | null = null
|
||||
/** 按需拉取系统表情包(已拉过则直接复用) */
|
||||
async function ensureFacePacks(): Promise<void> {
|
||||
if (facePacksLoaded.value) {
|
||||
return
|
||||
}
|
||||
if (facePacksFetching) {
|
||||
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:是不是也要注释下,风格一致;
|
||||
let faceUserItemsFetching: Promise<void> | null = null
|
||||
/** 按需拉取个人表情 */
|
||||
async function ensureFaceUserItems(): Promise<void> {
|
||||
if (faceUserItemsLoaded.value) {
|
||||
return
|
||||
}
|
||||
if (faceUserItemsFetching) {
|
||||
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 去重
|
||||
*
|
||||
* 来源:1. 用户在表情面板「+」上传图片 2. 长按消息「添加到表情」(带 sourceMessageId)
|
||||
*/
|
||||
async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise<number | undefined> {
|
||||
try {
|
||||
const id = await apiCreateFaceUserItem(reqVO)
|
||||
if (!id) {
|
||||
return undefined
|
||||
}
|
||||
// id 不在缓存里才插入,避免重复添加同张图触发列表里出现两条
|
||||
if (!faceUserItems.value.some((item) => item.id === id)) {
|
||||
faceUserItems.value.unshift({
|
||||
id,
|
||||
url: reqVO.url,
|
||||
name: reqVO.name,
|
||||
width: reqVO.width,
|
||||
height: reqVO.height
|
||||
})
|
||||
}
|
||||
return id
|
||||
} catch (e) {
|
||||
console.warn('[IM] 添加个人表情失败', e)
|
||||
// TODO @AI:应该把参数打印出来,reqVO;
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除个人表情;本地立即移除 */
|
||||
async function removeFaceUserItem(id: number): Promise<boolean> {
|
||||
try {
|
||||
await apiDeleteFaceUserItem(id)
|
||||
faceUserItems.value = faceUserItems.value.filter((item) => item.id !== id)
|
||||
return true
|
||||
} catch (e) {
|
||||
// TODO @AI:应该把参数打印出来,id;
|
||||
console.warn('[IM] 删除个人表情失败', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 切账号 / 退出 IM 时清空缓存,避免下个用户看到上一用户的个人表情 */
|
||||
function reset(): void {
|
||||
facePacks.value = []
|
||||
faceUserItems.value = []
|
||||
facePacksLoaded.value = false
|
||||
faceUserItemsLoaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
facePacks,
|
||||
faceUserItems,
|
||||
ensureFacePacks,
|
||||
ensureFaceUserItems,
|
||||
addFaceUserItem,
|
||||
removeFaceUserItem,
|
||||
reset
|
||||
}
|
||||
})
|
||||
|
||||
/** 在 setup 外(路由守卫等)取 store 实例的工具方法 */
|
||||
export const useFaceStoreWithOut = () => useFaceStore(store)
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useFaceStore, import.meta.hot))
|
||||
}
|
||||
|
|
@ -62,6 +62,18 @@
|
|||
<span>个人名片:{{ cardPayload.nickname }}</span>
|
||||
</span>
|
||||
|
||||
<!-- 表情贴图:缩略图 + 表情名(无名字时仅 [表情]) -->
|
||||
<span v-else-if="isFace && facePayload" class="inline-flex gap-1.5 items-center">
|
||||
<img
|
||||
v-if="facePayload.url"
|
||||
:src="facePayload.url"
|
||||
:alt="facePayload.name || '表情'"
|
||||
class="w-30px h-30px rounded object-contain align-middle"
|
||||
draggable="false"
|
||||
/>
|
||||
<span>{{ facePayload.name ? `[表情] ${facePayload.name}` : '[表情]' }}</span>
|
||||
</span>
|
||||
|
||||
<!-- 控制类消息:撤回 / 已读 / 回执 -->
|
||||
<span
|
||||
v-else-if="props.type === ImMessageType.RECALL"
|
||||
|
|
@ -119,7 +131,8 @@ import {
|
|||
type AudioMessage,
|
||||
type VideoMessage,
|
||||
type TextMessage,
|
||||
type CardMessage
|
||||
type CardMessage,
|
||||
type FaceMessage
|
||||
} from '@/views/im/utils/message'
|
||||
import {
|
||||
resolveFriendNotificationText,
|
||||
|
|
@ -144,6 +157,7 @@ const isFile = computed(() => props.type === ImMessageType.FILE)
|
|||
const isVoice = computed(() => props.type === ImMessageType.VOICE)
|
||||
const isVideo = computed(() => props.type === ImMessageType.VIDEO)
|
||||
const isCard = computed(() => props.type === ImMessageType.CARD)
|
||||
const isFace = computed(() => props.type === ImMessageType.FACE)
|
||||
|
||||
/** 文本内容:从 TextMessage payload 取 .content */
|
||||
const textContent = computed(
|
||||
|
|
@ -165,6 +179,9 @@ const videoPayload = computed(() =>
|
|||
const cardPayload = computed(() =>
|
||||
isCard.value ? parseMessage<CardMessage>(props.content || '') : null
|
||||
)
|
||||
const facePayload = computed(() =>
|
||||
isFace.value ? parseMessage<FaceMessage>(props.content || '') : null
|
||||
)
|
||||
|
||||
/** 点击视频封面:在新标签打开视频 url(不在管理后台内嵌播放,避免列表里多个 video 同时占资源) */
|
||||
function openVideo() {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export function resolveConversationLastContent(
|
|||
return '[视频]'
|
||||
case ImMessageType.CARD:
|
||||
return '[个人名片]'
|
||||
case ImMessageType.FACE: {
|
||||
const facePayload = parseMessage<{ name?: string }>(message.content)
|
||||
return facePayload?.name ? `[表情] ${facePayload.name}` : '[表情]'
|
||||
}
|
||||
case ImMessageType.RECALL:
|
||||
return buildRecallTip(
|
||||
message.senderId,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,18 @@ export interface CardMessage extends Quotable {
|
|||
avatar?: string
|
||||
}
|
||||
|
||||
/** 表情消息 payload(对齐后端 FaceMessage;Unicode emoji 仍走 TEXT,本类型只承载贴图 / 自定义表情包) */
|
||||
export interface FaceMessage extends Quotable {
|
||||
/** 表情图 URL */
|
||||
url: string
|
||||
/** 渲染宽度(像素),避免布局抖动 */
|
||||
width: number
|
||||
/** 渲染高度(像素) */
|
||||
height: number
|
||||
/** 表情名(系统包通常有,个人表情包通常无) */
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** 解析消息 content(JSON 字符串)为指定 payload,非法 JSON 返回 null */
|
||||
export const parseMessage = <T>(content: string): T | null => {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue