feat(im): 初始化表情包 v0.3:第四把 review(增加表情管理的界面)

im
YunaiV 2026-05-06 23:00:08 +08:00
parent a98e32554c
commit 0eca952c6a
14 changed files with 1016 additions and 40 deletions

View File

@ -13,7 +13,7 @@ export interface ImFacePackUserItemVO {
export interface ImFacePackUserVO {
id: number
name: string
iconUrl?: string
icon?: string
items: ImFacePackUserItemVO[]
}

View File

@ -15,7 +15,6 @@ export interface ImFaceUserItemSaveReqVO {
name?: string
width: number
height: number
sourceMessageId?: number // 来源消息编号(从消息「添加到表情」时传,自己上传不传)
}
/** 获取我的个人表情列表 */

View File

@ -3,7 +3,7 @@ import request from '@/config/axios'
export interface ImManagerFacePackVO {
id: number
name: string
iconUrl?: string
icon?: string
sort: number
status: number
createTime?: Date

View File

@ -0,0 +1,22 @@
import request from '@/config/axios'
export interface ImManagerFaceUserItemVO {
id: number
userId: number
userNickname?: string
url: string
name?: string
width?: number
height?: number
createTime?: Date
}
/** 获得用户表情分页 */
export const getManagerFaceUserItemPage = (params: PageParam) => {
return request.get({ url: '/im/manager/face-user-item/page', params })
}
/** 删除用户表情 */
export const deleteManagerFaceUserItem = (id: number) => {
return request.delete({ url: '/im/manager/face-user-item/delete?id=' + id })
}

View File

@ -148,7 +148,7 @@
</div>
</div>
<!-- 留言单行右侧表情按钮触发 FacePicker(emoji-only)所选 emoji 直接拼接到输入末尾 -->
<!-- 留言单行右侧表情按钮触发 FacePicker(mode=emoji)所选 emoji 直接拼接到输入末尾 -->
<div class="relative">
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
@ -163,7 +163,7 @@
<!-- bottom-full picker 下沿贴 input 顶部向上弹出right-0 对齐 input 右侧表情按钮 -->
<FacePicker
v-model:visible="emojiVisible"
mode="emoji-only"
mode="emoji"
class="bottom-full right-0 mb-2"
@select-emoji="handleEmojiSelect"
/>

View File

@ -3,7 +3,7 @@
表情面板 tabemoji / 个人表情 / N 个系统表情包
- 对齐微信 PC底部 tab 栏切换面板内容emoji 保持 Unicode仍由 TEXT 通道发送
- 个人表情 / 系统表情走 FACE 消息类型通过 select-face 事件由调用方走 sendRaw 发送
- mode='emoji-only' 时只显示 emoji tab + 隐藏底部 tab 给留言 / 评论这类只发文本的场景用
- mode='emoji' 时只显示 emoji tab + 隐藏底部 tab 给留言 / 评论这类只发文本的场景用
- 定位由调用方决定通常浮在表情按钮上方
-->
<div
@ -32,17 +32,17 @@
<!-- 个人表情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 -->
<!-- 上传入口固定放第一格dashed border 与表情格子区分视觉语义对齐 el-upload 观感 -->
<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)]"
class="im-face-upload-btn aspect-square flex items-center justify-center rounded-md cursor-pointer transition-colors"
type="button"
:disabled="uploading"
:title="uploading ? '上传中…' : '上传图片到个人表情'"
@click="onUploadClick"
>
<Icon
:icon="uploading ? 'eos-icons:bubble-loading' : 'ant-design:plus-outlined'"
:size="20"
:size="22"
/>
</button>
<div
@ -112,7 +112,7 @@
</template>
</div>
<!-- 底部 tab [ emoji / 个人 / 系统包 1..N ]emoji-only 模式下隐藏 -->
<!-- 底部 tab [ emoji / 个人 / 系统包 1..N ]mode='emoji' 隐藏 -->
<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)]"
@ -151,8 +151,8 @@
@click="activeTab = packTabKey(pack.id)"
>
<img
v-if="pack.iconUrl"
:src="pack.iconUrl"
v-if="pack.icon"
:src="pack.icon"
:alt="pack.name"
class="w-[18px] h-[18px] object-contain"
draggable="false"
@ -188,13 +188,12 @@ import type { ImFacePackUserItemVO, ImFaceUserItemVO } from '@/api/im/face'
defineOptions({ name: 'ImFacePicker' })
/** 面板模式 */
// TODO @AI emoji only
type FacePickerMode = 'full' | 'emoji-only'
type FacePickerMode = 'full' | 'emoji'
const props = withDefaults(
defineProps<{
visible: boolean
/** fullemoji + 个人表情 + 系统包聊天主输入用emoji-only:仅 emoji留言 / 评论场景) */
/** fullemoji + 个人表情 + 系统包聊天主输入用emoji:仅 emoji留言 / 评论场景) */
mode?: FacePickerMode
}>(),
{ mode: 'full' }
@ -293,7 +292,7 @@ async function onUploadPicked(e: Event) {
}
}
/** 面板展开时拉数据 + 挂全局 clickemoji-only 模式下不拉系统包 / 个人表情 */
/** 面板展开时拉数据 + 挂全局 clickmode='emoji' 时不拉系统包 / 个人表情 */
watch(
() => props.visible,
(visible) => {
@ -362,4 +361,19 @@ onUnmounted(() => {
background-color: var(--el-fill-color);
color: var(--el-color-primary);
}
/* 个人表情上传按钮dashed border 区分视觉语义,对齐 el-upload */
.im-face-upload-btn {
border: 1px dashed var(--el-border-color);
background-color: transparent;
color: var(--el-text-color-placeholder);
}
.im-face-upload-btn:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.im-face-upload-btn:disabled {
cursor: not-allowed;
color: var(--el-text-color-disabled);
}
</style>

View File

@ -158,10 +158,7 @@
[视频消息]
</div>
<!-- 表情贴图 <img>不套气泡对齐微信观感贴图本体就是装饰再叠气泡显累赘 -->
<div
v-else-if="isFace && facePayload"
class="inline-block"
>
<div v-else-if="isFace && facePayload" class="inline-block">
<img
:src="facePayload.url"
:alt="facePayload.name || '表情'"
@ -187,7 +184,9 @@
:size="40"
:clickable="false"
/>
<div class="flex-1 min-w-0 text-sm font-medium truncate text-[var(--el-text-color-primary)]">
<div
class="flex-1 min-w-0 text-sm font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ cardPayload.nickname }}
</div>
</div>
@ -837,12 +836,8 @@ async function handleAddToFace() {
if (!payload) {
return
}
// TODO @AI data
const ok = await faceStore.addFaceUserItem({
...payload,
sourceMessageId: props.message.id
})
if (ok) {
const data = await faceStore.addFaceUserItem(payload)
if (data) {
successMessage('已添加到表情')
}
}

View File

@ -70,7 +70,7 @@ export const useFaceStore = defineStore('imFace', () => {
/**
* URL FACE_USER_ITEM_DUPLICATED
*
* 1. + 2. sourceMessageId
* 1. + 2.
* true / false removeFaceUserItem boolean
*/
async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise<boolean> {

View File

@ -0,0 +1,130 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="500">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="80px"
>
<el-form-item label="封面" prop="icon">
<UploadImg v-model="formData.icon" :limit="1" />
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入表情包名称"
maxlength="64"
show-word-limit
class="w-1/1"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="formData.sort"
:min="0"
:max="9999"
controls-position="right"
class="!w-1/1"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status" class="w-1/1">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import * as ManagerFacePackApi from '@/api/im/manager/face/pack'
defineOptions({ name: 'ImManagerFacePackForm' })
const message = useMessage() //
const { t } = useI18n() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined as number | undefined,
name: '',
icon: '',
sort: 0,
status: CommonStatusEnum.ENABLE
})
const formRules = reactive({
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = type === 'create' ? '新增表情包' : '修改表情包'
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await ManagerFacePackApi.getManagerFacePack(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as ManagerFacePackApi.ImManagerFacePackVO
if (formType.value === 'create') {
await ManagerFacePackApi.createManagerFacePack(data)
message.success(t('common.createSuccess'))
} else {
await ManagerFacePackApi.updateManagerFacePack(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: '',
icon: '',
sort: 0,
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
</script>

View File

@ -0,0 +1,232 @@
<template>
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="65%" destroy-on-close>
<!-- 搜索栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="表情名" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入表情名"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-160px">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['im:manager:face-pack-item:create']"
>
<Icon icon="ep:plus" class="mr-5px" />新增表情
</el-button>
<el-button
type="danger"
plain
:disabled="checkedIds.length === 0"
@click="handleDeleteBatch"
v-hasPermi="['im:manager:face-pack-item:delete']"
>
<Icon icon="ep:delete" class="mr-5px" />批量删除
</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table v-loading="loading" :data="list" @selection-change="handleRowCheckboxChange">
<el-table-column type="selection" width="55" />
<el-table-column label="编号" align="center" prop="id" width="80" />
<el-table-column label="表情图" align="center" prop="url" width="80">
<template #default="scope">
<el-image
v-if="scope.row.url"
:src="scope.row.url"
:preview-src-list="[scope.row.url]"
preview-teleported
class="w-40px h-40px rounded"
fit="contain"
/>
</template>
</el-table-column>
<el-table-column
label="表情名"
align="center"
prop="name"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="尺寸" align="center" width="100">
<template #default="scope">
<span v-if="scope.row.width || scope.row.height">
{{ scope.row.width || '?' }} × {{ scope.row.height || '?' }}
</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="排序" align="center" prop="sort" width="80" />
<el-table-column label="状态" align="center" prop="status" width="80">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="160" fixed="right">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['im:manager:face-pack-item:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['im:manager:face-pack-item:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<FacePackItemForm ref="formRef" :pack-id="currentPackId" @success="getList" />
</el-drawer>
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as ManagerFacePackItemApi from '@/api/im/manager/face/item'
import type { ImManagerFacePackVO } from '@/api/im/manager/face/pack'
import FacePackItemForm from './FacePackItemForm.vue'
defineOptions({ name: 'ImManagerFacePackItemDrawer' })
const message = useMessage() //
const { t } = useI18n() //
const drawerVisible = ref(false) //
const drawerTitle = ref('') //
const currentPackId = ref<number>(0) //
const loading = ref(true) //
const total = ref(0) //
const list = ref<ManagerFacePackItemApi.ImManagerFacePackItemVO[]>([]) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
packId: 0,
name: undefined as string | undefined,
status: undefined as number | undefined
})
const queryFormRef = ref() //
/** 打开抽屉 */
const open = (pack: ImManagerFacePackVO) => {
drawerVisible.value = true
drawerTitle.value = `${pack.name}」表情管理`
currentPackId.value = pack.id
// +
queryParams.packId = pack.id
queryParams.pageNo = 1
queryParams.name = undefined
queryParams.status = undefined
void getList()
}
defineExpose({ open }) // open
/** 查询表情图分页 */
const getList = async () => {
loading.value = true
try {
const data = await ManagerFacePackItemApi.getManagerFacePackItemPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
// packId
queryParams.packId = currentPackId.value
handleQuery()
}
/** 添加 / 修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
} catch {
return
}
//
await ManagerFacePackItemApi.deleteManagerFacePackItem(id)
message.success(t('common.delSuccess'))
//
await getList()
}
const checkedIds = ref<number[]>([]) //
/** 表格选中变化 */
const handleRowCheckboxChange = (rows: ManagerFacePackItemApi.ImManagerFacePackItemVO[]) => {
checkedIds.value = rows.map((row) => row.id)
}
/** 批量删除按钮操作 */
const handleDeleteBatch = async () => {
try {
//
await message.delConfirm()
} catch {
return
}
//
await ManagerFacePackItemApi.deleteManagerFacePackItemList(checkedIds.value)
checkedIds.value = []
message.success(t('common.delSuccess'))
//
await getList()
}
</script>

View File

@ -0,0 +1,175 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="500">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="80px"
>
<el-form-item label="表情图" prop="url">
<UploadImg v-model="formData.url" :limit="1" @update:model-value="onUrlChange" />
</el-form-item>
<el-form-item label="表情名" prop="name">
<el-input
v-model="formData.name"
placeholder="可选;如「狗头」「捂脸」"
maxlength="64"
show-word-limit
class="w-1/1"
/>
</el-form-item>
<el-form-item label="尺寸">
<el-input-number
v-model="formData.width"
:min="0"
:max="9999"
controls-position="right"
class="!w-1/3"
/>
<span class="mx-2 text-[var(--el-text-color-secondary)]">×</span>
<el-input-number
v-model="formData.height"
:min="0"
:max="9999"
controls-position="right"
class="!w-1/3"
/>
<span class="ml-2 text-12px text-[var(--el-text-color-placeholder)]">
上传后自动探测可手动调整
</span>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="formData.sort"
:min="0"
:max="9999"
controls-position="right"
class="!w-1/1"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status" class="w-1/1">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import * as ManagerFacePackItemApi from '@/api/im/manager/face/item'
import { probeImageSize } from '@/views/im/utils/image'
defineOptions({ name: 'ImManagerFacePackItemForm' })
const props = defineProps<{
/** 所属表情包 idcreate 时必填 */
packId: number
}>()
const message = useMessage() //
const { t } = useI18n() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined as number | undefined,
packId: 0,
url: '',
name: '',
width: 0,
height: 0,
sort: 0,
status: CommonStatusEnum.ENABLE
})
const formRules = reactive({
url: [{ required: true, message: '表情图不能为空', trigger: 'change' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 上传完成后从 URL 回探宽高,自动填表单(用户仍可手改) */
async function onUrlChange(url: string) {
if (!url) {
formData.value.width = 0
formData.value.height = 0
return
}
const size = await probeImageSize(url)
formData.value.width = size.width
formData.value.height = size.height
}
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = type === 'create' ? '新增表情' : '修改表情'
formType.value = type
resetForm()
//
if (id) {
formLoading.value = true
try {
formData.value = await ManagerFacePackItemApi.getManagerFacePackItem(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // open
/** 提交表单 */
const emit = defineEmits(['success']) // success
const submitForm = async () => {
//
await formRef.value.validate()
//
formLoading.value = true
try {
const data = formData.value as unknown as ManagerFacePackItemApi.ImManagerFacePackItemVO
if (formType.value === 'create') {
data.packId = props.packId
await ManagerFacePackItemApi.createManagerFacePackItem(data)
message.success(t('common.createSuccess'))
} else {
await ManagerFacePackItemApi.updateManagerFacePackItem(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
packId: props.packId,
url: '',
name: '',
width: 0,
height: 0,
sort: 0,
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
</script>

View File

@ -0,0 +1,237 @@
<template>
<ContentWrap>
<!-- 搜索栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="表情包" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入表情包名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['im:manager:face-pack:create']"
>
<Icon icon="ep:plus" class="mr-5px" />新增
</el-button>
<el-button
type="danger"
plain
:disabled="checkedIds.length === 0"
@click="handleDeleteBatch"
v-hasPermi="['im:manager:face-pack:delete']"
>
<Icon icon="ep:delete" class="mr-5px" />批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list" @selection-change="handleRowCheckboxChange">
<el-table-column type="selection" width="55" />
<el-table-column label="编号" align="center" prop="id" width="100" />
<el-table-column label="封面" align="center" prop="icon" width="80">
<template #default="scope">
<el-image
v-if="scope.row.icon"
:src="scope.row.icon"
:preview-src-list="[scope.row.icon]"
preview-teleported
class="w-32px h-32px rounded"
fit="contain"
/>
</template>
</el-table-column>
<el-table-column
label="名称"
align="center"
prop="name"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="排序" align="center" prop="sort" width="80" />
<el-table-column label="状态" align="center" prop="status" width="80">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center" width="220" fixed="right">
<template #default="scope">
<el-button
link
type="primary"
@click="openItemDrawer(scope.row)"
v-hasPermi="['im:manager:face-pack-item:query']"
>
管理表情
</el-button>
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['im:manager:face-pack:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['im:manager:face-pack:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<FacePackForm ref="formRef" @success="getList" />
<FacePackItemDrawer ref="itemDrawerRef" />
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import * as ManagerFacePackApi from '@/api/im/manager/face/pack'
import FacePackForm from './FacePackForm.vue'
import FacePackItemDrawer from './FacePackItemDrawer.vue'
defineOptions({ name: 'ImManagerFacePack' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const total = ref(0)
const list = ref<ManagerFacePackApi.ImManagerFacePackVO[]>([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined as string | undefined,
status: undefined as number | undefined,
createTime: [] as string[]
})
const queryFormRef = ref()
/** 查询表情包分页 */
const getList = async () => {
loading.value = true
try {
const data = await ManagerFacePackApi.getManagerFacePackPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 打开表情包新增 / 修改弹窗 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 打开表情包详情抽屉(管理包内表情图) */
const itemDrawerRef = ref()
const openItemDrawer = (pack: ManagerFacePackApi.ImManagerFacePackVO) => {
itemDrawerRef.value.open(pack)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
} catch {
return
}
await ManagerFacePackApi.deleteManagerFacePack(id)
message.success(t('common.delSuccess'))
await getList()
}
/** 表格选中变化 */
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (rows: ManagerFacePackApi.ImManagerFacePackVO[]) => {
checkedIds.value = rows.map((row) => row.id)
}
/** 批量删除按钮操作 */
const handleDeleteBatch = async () => {
try {
await message.delConfirm()
} catch {
return
}
await ManagerFacePackApi.deleteManagerFacePackList(checkedIds.value)
checkedIds.value = []
message.success(t('common.delSuccess'))
await getList()
}
onMounted(() => {
getList()
})
</script>

View File

@ -0,0 +1,167 @@
<template>
<ContentWrap>
<!-- 搜索栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="80px"
>
<el-form-item label="用户编号" prop="userId">
<el-input
v-model="queryParams.userId"
placeholder="请输入用户编号"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="表情名" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入表情名"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="添加时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="id" width="100" />
<el-table-column label="表情图" align="center" prop="url" width="80">
<template #default="scope">
<el-image
v-if="scope.row.url"
:src="scope.row.url"
:preview-src-list="[scope.row.url]"
preview-teleported
class="w-40px h-40px rounded"
fit="contain"
/>
</template>
</el-table-column>
<el-table-column label="表情名" align="center" prop="name" min-width="120" show-overflow-tooltip />
<el-table-column label="所属用户" align="center" min-width="160">
<template #default="scope">
<span>{{ scope.row.userNickname || '—' }}</span>
<span class="ml-1 text-12px text-[var(--el-text-color-placeholder)]">
({{ scope.row.userId }})
</span>
</template>
</el-table-column>
<el-table-column label="尺寸" align="center" width="100">
<template #default="scope">
<span v-if="scope.row.width || scope.row.height">
{{ scope.row.width || '?' }} × {{ scope.row.height || '?' }}
</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column
label="添加时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center" width="100" fixed="right">
<template #default="scope">
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['im:manager:face-user-item:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>
<script lang="ts" setup>
import { dateFormatter } from '@/utils/formatTime'
import * as ManagerFaceUserItemApi from '@/api/im/manager/face/userItem'
defineOptions({ name: 'ImManagerFaceUserItem' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const total = ref(0)
const list = ref<ManagerFaceUserItemApi.ImManagerFaceUserItemVO[]>([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
userId: undefined as number | undefined,
name: undefined as string | undefined,
createTime: [] as string[]
})
const queryFormRef = ref()
/** 查询用户表情分页 */
const getList = async () => {
loading.value = true
try {
const data = await ManagerFaceUserItemApi.getManagerFaceUserItemPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 删除用户表情(运营审计场景) */
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
} catch {
return
}
await ManagerFaceUserItemApi.deleteManagerFaceUserItem(id)
message.success(t('common.delSuccess'))
await getList()
}
onMounted(() => {
getList()
})
</script>

View File

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