feat(im): 增加群聊消息的管理

im
YunaiV 2026-05-01 07:08:05 +08:00
parent dfbae06afa
commit d64a695673
3 changed files with 280 additions and 69 deletions

View File

@ -0,0 +1,160 @@
<template>
<!-- 文本 / 系统提示文本直接显示纯文本 -->
<span v-if="isText" class="whitespace-pre-wrap break-all">{{ textContent }}</span>
<!-- 图片缩略图 + 点击放大 -->
<el-image
v-else-if="isImage && imagePayload"
class="w-60px h-60px rounded align-middle"
:src="imagePayload.thumbnailUrl || imagePayload.url"
:preview-src-list="[imagePayload.url]"
:preview-teleported="true"
fit="cover"
/>
<!-- 文件图标 + 名称 + 大小单行内显示 -->
<span v-else-if="isFile && filePayload" class="inline-flex gap-1.5 items-center">
<Icon :icon="fileIconInfo.icon" :color="fileIconInfo.color" :size="18" />
<span class="max-w-200px truncate">{{ filePayload.name }}</span>
<span v-if="filePayload.size" class="text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(filePayload.size) }}
</span>
</span>
<!-- 语音图标 + 时长 -->
<span v-else-if="isVoice && voicePayload" class="inline-flex gap-1.5 items-center">
<Icon icon="ant-design:audio-outlined" :size="16" color="#606266" />
<span>{{ formatSeconds(voicePayload.duration ?? 0) }}</span>
</span>
<!-- 视频图标 + 占位文案 + 大小 -->
<span v-else-if="isVideo" class="inline-flex gap-1.5 items-center">
<Icon icon="ant-design:video-camera-filled" :size="16" color="#9c27b0" />
<span>[视频]</span>
<span v-if="videoPayload?.size" class="text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(videoPayload.size) }}
</span>
</span>
<!-- 控制类消息撤回 / 已读 / 回执 -->
<span
v-else-if="props.type === ImMessageType.RECALL"
class="text-12px text-[var(--el-text-color-secondary)]"
>
[消息已撤回]
</span>
<span
v-else-if="props.type === ImMessageType.READ"
class="text-12px text-[var(--el-text-color-secondary)]"
>
[已读回执]
</span>
<span
v-else-if="props.type === ImMessageType.RECEIPT"
class="text-12px text-[var(--el-text-color-secondary)]"
>
[回执]
</span>
<!-- 系统事件类FRIEND_* / GROUP_*content JSON退 -->
<span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { formatFileSize } from '@/utils/file'
import { formatSeconds } from '@/utils/formatTime'
import { ImMessageType } from '@/views/im/utils/constants'
import {
parseMessage,
resolveTipText,
type ImageMessage,
type FileMessage,
type AudioMessage,
type VideoMessage
} from '@/views/im/utils/message'
defineOptions({ name: 'ImMessageContentPreview' })
const props = defineProps<{
/** 消息类型,对应 ImMessageType */
type?: number
/** 消息 contentJSON 字符串或裸文本) */
content?: string
}>()
/** 各类型判定 */
const isText = computed(
() => props.type === ImMessageType.TEXT || props.type === ImMessageType.TIP_TEXT
)
const isImage = computed(() => props.type === ImMessageType.IMAGE)
const isFile = computed(() => props.type === ImMessageType.FILE)
const isVoice = computed(() => props.type === ImMessageType.VOICE)
const isVideo = computed(() => props.type === ImMessageType.VIDEO)
/** 文本内容:兼容 JSON 包裹和裸字符串两种形态 */
const textContent = computed(() => resolveTipText(props.content || ''))
const imagePayload = computed(() =>
isImage.value ? parseMessage<ImageMessage>(props.content || '') : null
)
const filePayload = computed(() =>
isFile.value ? parseMessage<FileMessage>(props.content || '') : null
)
const voicePayload = computed(() =>
isVoice.value ? parseMessage<AudioMessage>(props.content || '') : null
)
const videoPayload = computed(() =>
isVideo.value ? parseMessage<VideoMessage>(props.content || '') : null
)
/** 文件图标:按扩展名分配 icon + 颜色,对齐 home 端 MessageItem 的观感 */
const fileIconInfo = computed<{ icon: string; color: string }>(() => {
const name = filePayload.value?.name || ''
const ext = name.split('.').pop()?.toLowerCase() || ''
if (ext === 'pdf') {
return { icon: 'ant-design:file-pdf-filled', color: '#ed5757' }
}
if (['doc', 'docx'].includes(ext)) {
return { icon: 'ant-design:file-word-filled', color: '#2b7cd3' }
}
if (['xls', 'xlsx'].includes(ext)) {
return { icon: 'ant-design:file-excel-filled', color: '#1f7244' }
}
if (['ppt', 'pptx'].includes(ext)) {
return { icon: 'ant-design:file-ppt-filled', color: '#d24726' }
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return { icon: 'ant-design:file-zip-filled', color: '#f0ad4e' }
}
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
return { icon: 'ant-design:file-image-filled', color: '#9c27b0' }
}
if (['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv'].includes(ext)) {
return { icon: 'ant-design:video-camera-filled', color: '#9c27b0' }
}
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext)) {
return { icon: 'ant-design:audio-filled', color: '#9c27b0' }
}
if (['txt', 'md', 'log', 'json', 'xml'].includes(ext)) {
return { icon: 'ant-design:file-text-filled', color: '#909399' }
}
return { icon: 'ant-design:file-filled', color: '#909399' }
})
/** 系统事件 / 未知类型 fallback取 JSON 首层 content否则原文 */
const fallbackText = computed(() => {
const raw = props.content || ''
if (!raw) {
return ''
}
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && parsed.content) {
return String(parsed.content)
}
} catch {}
return raw
})
</script>

View File

@ -0,0 +1,78 @@
<template>
<el-dialog v-model="dialogVisible" title="群聊消息详情" width="700">
<el-descriptions :column="2" border>
<el-descriptions-item label="编号">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="客户端编号">{{ detail.clientMessageId || '-' }}</el-descriptions-item>
<el-descriptions-item label="群">
{{ detail.groupName }} ({{ detail.groupId }})
</el-descriptions-item>
<el-descriptions-item label="发送人">
{{ detail.senderNickname }} ({{ detail.senderId }})
</el-descriptions-item>
<el-descriptions-item label="类型">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" />
</el-descriptions-item>
<el-descriptions-item label="状态">
<dict-tag :type="DICT_TYPE.IM_GROUP_MESSAGE_STATUS" :value="detail.status" />
</el-descriptions-item>
<el-descriptions-item label="@用户" :span="2">
<template v-if="detail.atUserIds?.length">
<span v-for="(userId, idx) in detail.atUserIds" :key="userId">
<span v-if="idx > 0"></span>
<template v-if="userId === IM_AT_ALL_USER_ID">@{{ IM_AT_ALL_NICKNAME }}</template>
<template v-else>
@{{ detail.atUserNicknames?.[idx] || userId }}
<span class="text-gray-400">({{ userId }})</span>
</template>
</span>
</template>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="发送时间" :span="2">
{{ formatDate(detail.sendTime) }}
</el-descriptions-item>
<el-descriptions-item label="消息内容" :span="2">
<MessageContentPreview :type="detail.type" :content="detail.content" />
</el-descriptions-item>
<el-descriptions-item label="原始 JSON" :span="2">
<pre class="content-pre">{{ formatJson(detail.content) }}</pre>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</template>
<script lang="ts" setup>
import { formatDate } from '@/utils/formatTime'
import { DICT_TYPE } from '@/utils/dict'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
import { formatJson } from '@/views/im/utils/message'
import * as ManagerGroupMessageApi from '@/api/im/manager/message/group'
import MessageContentPreview from '../MessageContentPreview.vue'
defineOptions({ name: 'ImGroupMessageDetailDialog' })
const dialogVisible = ref(false) //
const detail = ref<ManagerGroupMessageApi.ImManagerGroupMessageVO>(
{} as ManagerGroupMessageApi.ImManagerGroupMessageVO
) //
/** 打开详情弹窗 */
const open = (row: ManagerGroupMessageApi.ImManagerGroupMessageVO) => {
detail.value = row
dialogVisible.value = true
}
defineExpose({ open }) // open
</script>
<style scoped>
.content-pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
}
</style>

View File

@ -6,7 +6,7 @@
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="80px"
label-width="88px"
>
<el-form-item label="群编号" prop="groupId">
<el-input
@ -14,7 +14,7 @@
placeholder="请输入群编号"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="发送人编号" prop="senderId">
@ -23,7 +23,7 @@
placeholder="请输入发送人用户编号"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="消息类型" prop="type">
@ -31,7 +31,7 @@
v-model="queryParams.type"
placeholder="请选择消息类型"
clearable
class="!w-160px"
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IM_MESSAGE_TYPE)"
@ -46,10 +46,10 @@
v-model="queryParams.status"
placeholder="请选择消息状态"
clearable
class="!w-160px"
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IM_MESSAGE_STATUS)"
v-for="dict in getIntDictOptions(DICT_TYPE.IM_GROUP_MESSAGE_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
@ -95,24 +95,9 @@
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="row.type" />
</template>
</el-table-column>
<el-table-column label="内容预览" align="left" min-width="240" show-overflow-tooltip>
<el-table-column label="内容预览" align="left" min-width="240">
<template #default="{ row }">
{{ getContentPreview(row.content) }}
</template>
</el-table-column>
<el-table-column label="@" align="center" width="80">
<template #default="{ row }">
{{ row.atUserIds?.length ? row.atUserIds.length : '-' }}
</template>
</el-table-column>
<el-table-column label="回执" align="center" prop="receiptStatus" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_GROUP_MESSAGE_RECEIPT_STATUS" :value="row.receiptStatus" />
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="row.status" />
<MessageContentPreview :type="row.type" :content="row.content" />
</template>
</el-table-column>
<el-table-column
@ -122,6 +107,31 @@
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="@用户" align="left" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row.atUserIds?.length">
<span v-for="(userId, idx) in row.atUserIds" :key="userId">
<span v-if="idx > 0"></span>
<template v-if="userId === IM_AT_ALL_USER_ID">@{{ IM_AT_ALL_NICKNAME }}</template>
<template v-else>
@{{ row.atUserNicknames?.[idx] || userId }}
<span class="text-gray-400">({{ userId }})</span>
</template>
</span>
</template>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="回执" align="center" prop="receiptStatus" width="110">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_GROUP_MESSAGE_RECEIPT_STATUS" :value="row.receiptStatus" />
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_GROUP_MESSAGE_STATUS" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="100" fixed="right">
<template #default="{ row }">
<el-button
@ -135,6 +145,7 @@
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
@ -143,36 +154,17 @@
/>
</ContentWrap>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="群聊消息详情" width="700">
<el-descriptions :column="2" border>
<el-descriptions-item label="编号">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="客户端编号">{{ detail.clientMessageId || '-' }}</el-descriptions-item>
<el-descriptions-item label="群">{{ detail.groupName }} ({{ detail.groupId }})</el-descriptions-item>
<el-descriptions-item label="发送人">{{ detail.senderNickname }} ({{ detail.senderId }})</el-descriptions-item>
<el-descriptions-item label="类型">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_TYPE" :value="detail.type" />
</el-descriptions-item>
<el-descriptions-item label="状态">
<dict-tag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
</el-descriptions-item>
<el-descriptions-item label="@ 用户" :span="2">
{{ detail.atUserIds?.length ? detail.atUserIds.join(', ') : '-' }}
</el-descriptions-item>
<el-descriptions-item label="发送时间">{{ formatDate(detail.sendTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(detail.createTime) }}</el-descriptions-item>
<el-descriptions-item label="消息内容(原始 JSON" :span="2">
<pre class="content-pre">{{ formatJson(detail.content) }}</pre>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 详情 -->
<GroupMessageDetail ref="detailRef" />
</template>
<script lang="ts" setup>
import { dateFormatter, formatDate } from '@/utils/formatTime'
import { dateFormatter } from '@/utils/formatTime'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
import * as ManagerGroupMessageApi from '@/api/im/manager/message/group'
import { getContentPreview, formatJson } from '@/views/im/utils/message'
import MessageContentPreview from '../MessageContentPreview.vue'
import GroupMessageDetail from './GroupMessageDetail.vue'
defineOptions({ name: 'ImGroupMessage' })
@ -214,16 +206,10 @@ const resetQuery = () => {
handleQuery()
}
/** 详情弹窗 */
const detailVisible = ref(false) //
const detail = ref<ManagerGroupMessageApi.ImManagerGroupMessageVO>(
{} as ManagerGroupMessageApi.ImManagerGroupMessageVO
) //
/** 打开详情弹窗 */
const detailRef = ref<InstanceType<typeof GroupMessageDetail>>() // Ref
const openDetail = (row: ManagerGroupMessageApi.ImManagerGroupMessageVO) => {
detail.value = row
detailVisible.value = true
detailRef.value?.open(row)
}
/** 初始化 */
@ -231,16 +217,3 @@ onMounted(() => {
getList()
})
</script>
<style scoped>
.content-pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
}
</style>