feat(im): 管理后台新增通话记录只读查询(列表 / 详情 / 参与者);im_rtc_participant 增加 call_id 关联 im_rtc_call.id

 feat(im): 管理后台新增通话记录页面(列表 + 详情抽屉 + 参与者表),消息预览补 RTC_CALL_START / END 文案
im
YunaiV 2026-05-18 12:37:51 +08:00
parent 8329a6a885
commit 5c2ee259a6
7 changed files with 453 additions and 2 deletions

View File

@ -0,0 +1,40 @@
import request from '@/config/axios'
export interface ImManagerRtcCallVO {
id: number
room: string
conversationType: number
mediaType: number
inviterUserId: number
inviterNickname?: string
groupId?: number
groupName?: string
status: number
endReason?: number
startTime: Date
acceptTime?: Date
endTime?: Date
createTime: Date
}
export interface ImManagerRtcParticipantVO {
id: number
callId: number
userId: number
userNickname?: string
role: number
status: number
inviteTime: Date
acceptTime?: Date
leaveTime?: Date
}
// 获得通话记录分页
export const getManagerRtcCallPage = (params: PageParam) => {
return request.get({ url: '/im/manager/rtc/page', params })
}
// 获得通话参与者列表
export const getManagerRtcCallParticipantList = (id: number) => {
return request.get({ url: '/im/manager/rtc/participant-list?id=' + id })
}

View File

@ -339,5 +339,10 @@ export enum DICT_TYPE {
IM_GROUP_MEMBER_ROLE = 'im_group_member_role', // IM 群成员角色 IM_GROUP_MEMBER_ROLE = 'im_group_member_role', // IM 群成员角色
IM_GROUP_ADD_SOURCE = 'im_group_add_source', // IM 加群来源 IM_GROUP_ADD_SOURCE = 'im_group_add_source', // IM 加群来源
IM_GROUP_REQUEST_HANDLE_RESULT = 'im_group_request_handle_result', // IM 加群申请处理结果 IM_GROUP_REQUEST_HANDLE_RESULT = 'im_group_request_handle_result', // IM 加群申请处理结果
IM_RTC_CALL_MEDIA_TYPE = 'im_rtc_call_media_type' // IM 通话媒体类型1=语音 / 2=视频 IM_RTC_CALL_MEDIA_TYPE = 'im_rtc_call_media_type', // IM 通话媒体类型1=语音 / 2=视频
IM_RTC_CALL_CONVERSATION_TYPE = 'im_rtc_call_conversation_type', // IM 通话会话类型1=私聊 / 2=群聊
IM_RTC_CALL_STATUS = 'im_rtc_call_status', // IM 通话状态10=创建 / 20=进行中 / 30=已结束
IM_RTC_CALL_END_REASON = 'im_rtc_call_end_reason', // IM 通话结束原因1=通话结束 / 2=已拒绝 / 3=已取消 / 4=无人接听 / 5=对方正忙 / 9=通话异常
IM_RTC_PARTICIPANT_ROLE = 'im_rtc_participant_role', // IM 通话参与角色1=发起人 / 2=被邀请者 / 3=主动加入者
IM_RTC_PARTICIPANT_STATUS = 'im_rtc_participant_status' // IM 通话参与状态10=邀请中 / 20=已加入 / 30=已拒绝 / 40=未应答 / 50=已离开
} }

View File

@ -119,6 +119,15 @@
{{ friendChatTipText }} {{ friendChatTipText }}
</span> </span>
<!-- 通话事件RTC_CALL_START / RTC_CALL_END中文文案 + 媒体类型 / 结束原因 / 时长 -->
<span
v-else-if="isRtcCallTipType"
class="inline-flex gap-1.5 items-center text-12px text-[var(--el-text-color-secondary)]"
>
<Icon icon="ant-design:phone-outlined" :size="14" class="rotate-[135deg]" />
<span>{{ rtcCallTipText }}</span>
</span>
<!-- 其它系统事件 / 未知类型content 通常是结构化 JSON回退原始预览 --> <!-- 其它系统事件 / 未知类型content 通常是结构化 JSON回退原始预览 -->
<span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span> <span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span>
</template> </template>
@ -128,11 +137,20 @@ import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import { formatFileSize } from '@/utils/file' import { formatFileSize } from '@/utils/file'
import { formatSeconds } from '@/utils/formatTime' import { formatSeconds } from '@/utils/formatTime'
import { ImMessageType, isFriendChatTip, isGroupNotification } from '@/views/im/utils/constants' import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import {
ImMessageType,
ImRtcCallEndReason,
ImRtcCallMediaType,
isFriendChatTip,
isGroupNotification,
isRtcCallTip
} from '@/views/im/utils/constants'
import { MESSAGE_MERGE_PREVIEW_LINES } from '@/views/im/utils/config' import { MESSAGE_MERGE_PREVIEW_LINES } from '@/views/im/utils/config'
import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue' import CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
import { import {
parseMessage, parseMessage,
parseRtcCallPayload,
getFileIconInfo, getFileIconInfo,
resolveFriendNotificationText, resolveFriendNotificationText,
resolveGroupNotificationText, resolveGroupNotificationText,
@ -248,4 +266,34 @@ const groupNotificationText = computed(() =>
props.senderNickname props.senderNickname
) )
) )
/** 是否通话事件气泡RTC_CALL_START / RTC_CALL_END */
const isRtcCallTipType = computed(() => isRtcCallTip(props.type ?? -1))
/** 通话事件文案START 显示「{发起人} 发起了{媒体}通话」END 显示「{媒体}通话已结束 [原因] [时长]」 */
const rtcCallTipText = computed(() => {
const payload = parseRtcCallPayload(props.content)
if (!payload) {
return ''
}
const mediaLabel = payload.mediaType === ImRtcCallMediaType.VIDEO ? '视频' : '语音'
if (props.type === ImMessageType.RTC_CALL_START) {
const inviter = payload.inviterNickname?.trim() || `用户(${payload.inviterUserId ?? ''})`
return `${inviter} 发起了${mediaLabel}通话`
}
// RTC_CALL_END
const segments = [`${mediaLabel}通话已结束`]
// HANGUP label
if (payload.endReason && payload.endReason !== ImRtcCallEndReason.HANGUP) {
const reason = getDictLabel(DICT_TYPE.IM_RTC_CALL_END_REASON, payload.endReason)
if (reason) {
segments.push(reason)
}
}
const duration = payload.durationSeconds ?? 0
if (duration > 0) {
segments.push(`时长 ${formatSeconds(duration)}`)
}
return segments.join('')
})
</script> </script>

View File

@ -0,0 +1,112 @@
<template>
<el-drawer v-model="drawerVisible" title="通话记录详情" size="900px" :destroy-on-close="true">
<!-- 基础信息 -->
<el-descriptions :column="2" border>
<el-descriptions-item label="编号">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="业务通话编号">{{ detail.room }}</el-descriptions-item>
<el-descriptions-item label="发起人">
{{ detail.inviterNickname || '-' }} ({{ detail.inviterUserId }})
</el-descriptions-item>
<el-descriptions-item label="会话类型">
<dict-tag :type="DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE" :value="detail.conversationType" />
</el-descriptions-item>
<el-descriptions-item label="群">
<span v-if="detail.groupId">
{{ detail.groupName || '-' }} ({{ detail.groupId }})
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="媒体类型">
<dict-tag :type="DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE" :value="detail.mediaType" />
</el-descriptions-item>
<el-descriptions-item label="通话状态">
<dict-tag :type="DICT_TYPE.IM_RTC_CALL_STATUS" :value="detail.status" />
</el-descriptions-item>
<el-descriptions-item label="结束原因">
<dict-tag
v-if="detail.endReason"
:type="DICT_TYPE.IM_RTC_CALL_END_REASON"
:value="detail.endReason"
/>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="发起时间">{{ formatDate(detail.startTime) }}</el-descriptions-item>
<el-descriptions-item label="接通时间">
{{ detail.acceptTime ? formatDate(detail.acceptTime) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="结束时间">
{{ detail.endTime ? formatDate(detail.endTime) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="通话时长">{{ duration }}</el-descriptions-item>
</el-descriptions>
<!-- 参与者列表 -->
<div class="mt-20px mb-15px font-bold">参与者列表</div>
<el-table v-loading="loading" :data="participants" border>
<el-table-column label="用户编号" prop="userId" width="120" align="center" />
<el-table-column label="昵称" prop="userNickname" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.userNickname || '-' }}</template>
</el-table-column>
<el-table-column label="参与角色" prop="role" width="120" align="center">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_RTC_PARTICIPANT_ROLE" :value="row.role" />
</template>
</el-table-column>
<el-table-column label="参与状态" prop="status" width="120" align="center">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_RTC_PARTICIPANT_STATUS" :value="row.status" />
</template>
</el-table-column>
<el-table-column
label="被邀请时间"
prop="inviteTime"
width="170"
align="center"
:formatter="dateFormatter"
/>
<el-table-column label="接听时间" prop="acceptTime" width="170" align="center">
<template #default="{ row }">
{{ row.acceptTime ? formatDate(row.acceptTime) : '-' }}
</template>
</el-table-column>
<el-table-column label="离开时间" prop="leaveTime" width="170" align="center">
<template #default="{ row }">
{{ row.leaveTime ? formatDate(row.leaveTime) : '-' }}
</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
<script lang="ts" setup>
import { dateFormatter, formatDate } from '@/utils/formatTime'
import { DICT_TYPE } from '@/utils/dict'
import * as ManagerRtcCallApi from '@/api/im/manager/rtc'
import { resolveCallDuration } from '@/views/im/utils/time'
defineOptions({ name: 'ImRtcCallDetail' })
const drawerVisible = ref(false)
const detail = ref<ManagerRtcCallApi.ImManagerRtcCallVO>(
{} as ManagerRtcCallApi.ImManagerRtcCallVO
)
const loading = ref(false)
const participants = ref<ManagerRtcCallApi.ImManagerRtcParticipantVO[]>([])
/** 通话时长(接通到结束);未接通显示 - */
const duration = computed(() => resolveCallDuration(detail.value.acceptTime, detail.value.endTime))
/** 打开详情,加载参与者 */
const open = async (row: ManagerRtcCallApi.ImManagerRtcCallVO) => {
detail.value = row
drawerVisible.value = true
loading.value = true
try {
participants.value = await ManagerRtcCallApi.getManagerRtcCallParticipantList(row.id)
} finally {
loading.value = false
}
}
defineExpose({ open })
</script>

View File

@ -0,0 +1,231 @@
<template>
<ContentWrap>
<!-- 搜索栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="88px"
>
<el-form-item label="发起人" prop="inviterUserId">
<UserSelectV2
v-model="queryParams.inviterUserId"
placeholder="请选择发起人"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="会话类型" prop="conversationType">
<el-select
v-model="queryParams.conversationType"
placeholder="请选择会话类型"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="媒体类型" prop="mediaType">
<el-select
v-model="queryParams.mediaType"
placeholder="请选择媒体类型"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</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.IM_RTC_CALL_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="结束原因" prop="endReason">
<el-select
v-model="queryParams.endReason"
placeholder="请选择结束原因"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IM_RTC_CALL_END_REASON)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="发起时间" prop="startTime">
<el-date-picker
v-model="queryParams.startTime"
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" min-width="160">
<template #default="{ row }">
<span>{{ row.inviterNickname || '-' }}</span>
<span class="text-gray-400 ml-5px">({{ row.inviterUserId }})</span>
</template>
</el-table-column>
<el-table-column label="会话类型" align="center" prop="conversationType" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE" :value="row.conversationType" />
</template>
</el-table-column>
<el-table-column label="群" align="center" min-width="160">
<template #default="{ row }">
<span v-if="row.groupId">
{{ row.groupName || '-' }}
<span class="text-gray-400 ml-5px">({{ row.groupId }})</span>
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="媒体类型" align="center" prop="mediaType" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE" :value="row.mediaType" />
</template>
</el-table-column>
<el-table-column label="通话状态" align="center" prop="status" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_RTC_CALL_STATUS" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="结束原因" align="center" prop="endReason" width="120">
<template #default="{ row }">
<dict-tag v-if="row.endReason" :type="DICT_TYPE.IM_RTC_CALL_END_REASON" :value="row.endReason" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="通话时长" align="center" width="120">
<template #default="{ row }">
{{ resolveCallDuration(row.acceptTime, row.endTime) }}
</template>
</el-table-column>
<el-table-column
label="发起时间"
align="center"
prop="startTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center" width="100" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
@click="openDetail(row)"
v-hasPermi="['im:manager:rtc:query']"
>
详情
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 详情 -->
<RtcCallDetail ref="detailRef" />
</template>
<script lang="ts" setup>
import { dateFormatter } from '@/utils/formatTime'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as ManagerRtcCallApi from '@/api/im/manager/rtc'
import { resolveCallDuration } from '@/views/im/utils/time'
import UserSelectV2 from '@/views/system/user/components/UserSelectV2.vue'
import RtcCallDetail from './RtcCallDetail.vue'
defineOptions({ name: 'ImRtcCall' })
const loading = ref(true)
const total = ref(0)
const list = ref<ManagerRtcCallApi.ImManagerRtcCallVO[]>([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
inviterUserId: undefined as number | undefined,
conversationType: undefined as number | undefined,
mediaType: undefined as number | undefined,
status: undefined as number | undefined,
endReason: undefined as number | undefined,
startTime: [] as string[]
})
const queryFormRef = ref()
/** 查询通话记录分页 */
const getList = async () => {
loading.value = true
try {
const data = await ManagerRtcCallApi.getManagerRtcCallPage(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 detailRef = ref<InstanceType<typeof RtcCallDetail>>()
const openDetail = (row: ManagerRtcCallApi.ImManagerRtcCallVO) => {
detailRef.value?.open(row)
}
onMounted(() => {
getList()
})
</script>

View File

@ -123,6 +123,9 @@ export function summarizeMessageContent(
return buildFacePreviewText(parseMessage<FaceMessage>(message.content)) return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
case ImMessageType.MERGE: case ImMessageType.MERGE:
return '[聊天记录]' return '[聊天记录]'
case ImMessageType.RTC_CALL_START:
case ImMessageType.RTC_CALL_END:
return '[语音通话]'
default: default:
return '' return ''
} }

View File

@ -98,3 +98,15 @@ export function formatCallDuration(seconds: number | undefined): string {
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}` return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
} }
/** 接通到结束的通话时长;任一时间缺失返回 '-' */
export function resolveCallDuration(
acceptTime: Date | string | undefined,
endTime: Date | string | undefined
): string {
if (!acceptTime || !endTime) {
return '-'
}
const seconds = Math.floor((new Date(endTime).getTime() - new Date(acceptTime).getTime()) / 1000)
return seconds > 0 ? formatCallDuration(seconds) : '-'
}