✨ feat(im): 管理后台新增通话记录只读查询(列表 / 详情 / 参与者);im_rtc_participant 增加 call_id 关联 im_rtc_call.id
✨ feat(im): 管理后台新增通话记录页面(列表 + 详情抽屉 + 参与者表),消息预览补 RTC_CALL_START / END 文案
im
parent
8329a6a885
commit
5c2ee259a6
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -339,5 +339,10 @@ export enum DICT_TYPE {
|
|||
IM_GROUP_MEMBER_ROLE = 'im_group_member_role', // IM 群成员角色
|
||||
IM_GROUP_ADD_SOURCE = 'im_group_add_source', // 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=已离开
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,15 @@
|
|||
{{ friendChatTipText }}
|
||||
</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,回退原始预览 -->
|
||||
<span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span>
|
||||
</template>
|
||||
|
|
@ -128,11 +137,20 @@ import { computed } from 'vue'
|
|||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import { formatFileSize } from '@/utils/file'
|
||||
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 CardLineLabel from '@/views/im/home/components/card/CardLineLabel.vue'
|
||||
import {
|
||||
parseMessage,
|
||||
parseRtcCallPayload,
|
||||
getFileIconInfo,
|
||||
resolveFriendNotificationText,
|
||||
resolveGroupNotificationText,
|
||||
|
|
@ -248,4 +266,34 @@ const groupNotificationText = computed(() =>
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -123,6 +123,9 @@ export function summarizeMessageContent(
|
|||
return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
|
||||
case ImMessageType.MERGE:
|
||||
return '[聊天记录]'
|
||||
case ImMessageType.RTC_CALL_START:
|
||||
case ImMessageType.RTC_CALL_END:
|
||||
return '[语音通话]'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,3 +98,15 @@ export function formatCallDuration(seconds: number | undefined): string {
|
|||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
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) : '-'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue