fix(im): 批量修复 P1/P2 问题

- 修复管理端消息内容搜索和私聊双向查询
- 加强 RTC 通话并发状态保护,去除重复接口错误提示
- 支持成员永久禁言
- 脱敏群消息 WebSocket 定向收件人字段
- 更新 IM bug 台账,剩余 P1/P2 共 35 个
im
YunaiV 2026-05-25 00:28:59 +08:00
parent 8b06efe5ee
commit f3807e30d5
12 changed files with 170 additions and 104 deletions

View File

@ -63,7 +63,8 @@ const presets = [
{ label: '12 小时', value: 43200 }, { label: '12 小时', value: 43200 },
{ label: '1 天', value: 86400 }, { label: '1 天', value: 86400 },
{ label: '7 天', value: 604800 }, { label: '7 天', value: 604800 },
{ label: '30 天', value: 2592000 } { label: '30 天', value: 2592000 },
{ label: '永久', value: 0 }
] ]
/** 打开弹窗 */ /** 打开弹窗 */

View File

@ -22,6 +22,8 @@
v-else-if="rtcStore.stage === ImRtcCallStage.INCOMING" v-else-if="rtcStore.stage === ImRtcCallStage.INCOMING"
:payload="rtcStore.incomingPayload" :payload="rtcStore.incomingPayload"
:is-group="isGroup" :is-group="isGroup"
:accepting="accepting"
:rejecting="rejecting"
@accept="handleAccept" @accept="handleAccept"
@reject="handleReject" @reject="handleReject"
/> />
@ -42,6 +44,7 @@
:local-stream="localStream" :local-stream="localStream"
:remote-video-stream="remoteVideoStream" :remote-video-stream="remoteVideoStream"
:remote-audio-stream="remoteAudioStream" :remote-audio-stream="remoteAudioStream"
:hanging-up="hangingUp"
@hangup="handleHangup" @hangup="handleHangup"
@toggle-mic="toggleMic" @toggle-mic="toggleMic"
@toggle-camera="toggleCamera" @toggle-camera="toggleCamera"
@ -90,6 +93,11 @@ const message = useMessage()
const lk = useLiveKitRoom() const lk = useLiveKitRoom()
const memberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>() const memberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
const connecting = ref(false)
const accepting = ref(false)
const rejecting = ref(false)
const cancelling = ref(false)
const hangingUp = ref(false)
// ==================== ==================== // ==================== ====================
@ -224,17 +232,22 @@ const participants = computed<CallParticipantVM[]>(() => {
/** 连入 LiveKit 房间并注册离开回调INVITING 主叫预连和被叫 accept 后连入共用 */ /** 连入 LiveKit 房间并注册离开回调INVITING 主叫预连和被叫 accept 后连入共用 */
async function connectLiveKit(livekitUrl: string, token: string) { async function connectLiveKit(livekitUrl: string, token: string) {
// lk.connect room.value stage // lk.connect room.value stage
if (lk.room.value) { if (lk.room.value || connecting.value) {
return return
} }
// connect handler connecting.value = true
lk.onDisconnected(() => handlePeerDisconnected()) try {
lk.onParticipantConnected(maybeEnterRunning) // connect handler
lk.onParticipantDisconnected((userId) => rtcStore.markUserLeft(userId)) lk.onDisconnected(() => handlePeerDisconnected())
await lk.connect(livekitUrl, token, { audio: true, video: initialCamera.value }) lk.onParticipantConnected(maybeEnterRunning)
// connect handler RUNNING lk.onParticipantDisconnected((userId) => rtcStore.markUserLeft(userId))
if (lk.remoteParticipants.value.length > 0) { await lk.connect(livekitUrl, token, { audio: true, video: initialCamera.value })
maybeEnterRunning() // connect handler RUNNING
if (lk.remoteParticipants.value.length > 0) {
maybeEnterRunning()
}
} finally {
connecting.value = false
} }
} }
@ -297,71 +310,85 @@ watch(
/** 主叫取消邀请 */ /** 主叫取消邀请 */
async function handleCancel() { async function handleCancel() {
const room = rtcStore.call?.room if (cancelling.value) {
if (room) { return
try { }
await cancelCall(room) cancelling.value = true
} catch (e) { const room = rtcStore.call?.room
console.warn('[Call] cancel 失败', { room }, e) try {
} if (room) {
await cancelCall(room)
}
await lk.disconnect()
rtcStore.reset()
} finally {
cancelling.value = false
} }
await lk.disconnect()
rtcStore.reset()
} }
/** 被叫拒绝来电 */ /** 被叫拒绝来电 */
async function handleReject() { async function handleReject() {
const payload = rtcStore.incomingPayload if (rejecting.value) {
if (payload?.room) { return
try { }
await rejectCall(payload.room) rejecting.value = true
} catch (e) { const payload = rtcStore.incomingPayload
console.warn('[Call] reject 失败', { room: payload.room }, e) try {
} if (payload?.room) {
// RTC_CALL(REJECTED) store no-op await rejectCall(payload.room)
rtcStore.applyParticipantRejected({ // RTC_CALL(REJECTED) store no-op
room: payload.room, rtcStore.applyParticipantRejected({
conversationType: payload.conversationType, room: payload.room,
groupId: payload.groupId, conversationType: payload.conversationType,
operatorUserId: getCurrentUserId() groupId: payload.groupId,
}) operatorUserId: getCurrentUserId()
})
}
rtcStore.reset()
} finally {
rejecting.value = false
} }
rtcStore.reset()
} }
/** 被叫接听来电 */ /** 被叫接听来电 */
async function handleAccept() { async function handleAccept() {
if (accepting.value) {
return
}
const payload = rtcStore.incomingPayload const payload = rtcStore.incomingPayload
if (!payload) return if (!payload) return
accepting.value = true
try { try {
const data = await acceptCall(payload.room) const data = await acceptCall(payload.room)
rtcStore.enterRunning(data) rtcStore.enterRunning(data)
} catch (e: any) { } finally {
console.error('[Call] accept 失败', { room: payload.room }, e) accepting.value = false
message.error(e?.msg || '接听失败')
rtcStore.reset()
} }
} }
/** 通话中挂断 */ /** 通话中挂断 */
async function handleHangup() { async function handleHangup() {
const call = rtcStore.call if (hangingUp.value) {
if (call?.room) { return
try { }
await leaveCall(call.room) hangingUp.value = true
} catch (e) { const call = rtcStore.call
console.warn('[Call] leave 失败', { room: call.room }, e) try {
} if (call?.room) {
// RTC_PARTICIPANT_DISCONNECTED store no-op END await leaveCall(call.room)
rtcStore.applyParticipantDisconnected({ // RTC_PARTICIPANT_DISCONNECTED store no-op END
room: call.room, rtcStore.applyParticipantDisconnected({
userId: getCurrentUserId(), room: call.room,
conversationType: call.conversationType, userId: getCurrentUserId(),
groupId: call.groupId conversationType: call.conversationType,
}) groupId: call.groupId
})
}
await lk.disconnect()
rtcStore.reset()
} finally {
hangingUp.value = false
} }
await lk.disconnect()
rtcStore.reset()
} }
/** LiveKit Room 异常断开;多见于网络中断 */ /** LiveKit Room 异常断开;多见于网络中断 */
@ -453,14 +480,9 @@ async function handleAddMemberSuccess(userIds: number[]) {
if (!call?.room || userIds.length === 0) { if (!call?.room || userIds.length === 0) {
return return
} }
try { await inviteCall({ room: call.room, inviteeIds: userIds })
await inviteCall({ room: call.room, inviteeIds: userIds }) // inviteeIds pending
// inviteeIds pending rtcStore.appendInvitees(userIds)
rtcStore.appendInvitees(userIds) message.success('已发送邀请')
message.success('已发送邀请')
} catch (e: any) {
console.error('[Call] invite 追加失败', { room: call.room, inviteeIds: userIds }, e)
message.error(e?.msg || '添加成员失败')
}
} }
</script> </script>

View File

@ -53,12 +53,16 @@
<div class="flex flex-shrink-0 gap-2 items-center"> <div class="flex flex-shrink-0 gap-2 items-center">
<button <button
class="flex flex-shrink-0 justify-center items-center w-10 h-10 text-white rounded-full transition-opacity bg-[#f04a4a] hover:opacity-90" class="flex flex-shrink-0 justify-center items-center w-10 h-10 text-white rounded-full transition-opacity bg-[#f04a4a] hover:opacity-90"
:class="{ 'opacity-60 cursor-not-allowed': rejectDisabled }"
:disabled="rejectDisabled"
@click="$emit('reject')" @click="$emit('reject')"
> >
<Icon icon="ant-design:phone-outlined" :size="18" class="rotate-[135deg]" /> <Icon icon="ant-design:phone-outlined" :size="18" class="rotate-[135deg]" />
</button> </button>
<button <button
class="flex flex-shrink-0 justify-center items-center w-10 h-10 text-white rounded-full transition-opacity bg-[#2ec27e] hover:opacity-90" class="flex flex-shrink-0 justify-center items-center w-10 h-10 text-white rounded-full transition-opacity bg-[#2ec27e] hover:opacity-90"
:class="{ 'opacity-60 cursor-not-allowed': acceptDisabled }"
:disabled="acceptDisabled"
@click="$emit('accept')" @click="$emit('accept')"
> >
<Icon icon="ant-design:phone-outlined" :size="18" /> <Icon icon="ant-design:phone-outlined" :size="18" />
@ -78,6 +82,8 @@ import { DICT_TYPE, getDictLabel } from '@/utils/dict'
const props = defineProps<{ const props = defineProps<{
payload: ImRtcCallNotification | null payload: ImRtcCallNotification | null
isGroup?: boolean isGroup?: boolean
accepting?: boolean
rejecting?: boolean
}>() }>()
defineEmits<{ accept: []; reject: [] }>() defineEmits<{ accept: []; reject: [] }>()
@ -88,6 +94,12 @@ const tipText = computed(() => {
return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话` return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话`
}) })
/** 接听按钮禁用态 */
const acceptDisabled = computed(() => !!props.accepting || !!props.rejecting)
/** 拒绝按钮禁用态 */
const rejectDisabled = computed(() => !!props.rejecting || !!props.accepting)
/** 群通话成员;缓存为空时用 INVITE 载荷里的主叫兜底,避免空白 */ /** 群通话成员;缓存为空时用 INVITE 载荷里的主叫兜底,避免空白 */
const callMembers = useGroupCallMembers( const callMembers = useGroupCallMembers(
computed(() => (props.isGroup ? props.payload?.groupId : undefined)), computed(() => (props.isGroup ? props.payload?.groupId : undefined)),

View File

@ -172,6 +172,7 @@
</template> </template>
<div <div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
:class="{ 'opacity-60 pointer-events-none': hangingUp }"
@click="$emit('hangup')" @click="$emit('hangup')"
> >
<span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-[#f04a4a]"> <span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-[#f04a4a]">
@ -212,6 +213,7 @@ const props = defineProps<{
localStream?: MediaStream | null localStream?: MediaStream | null
remoteVideoStream?: MediaStream | null remoteVideoStream?: MediaStream | null
remoteAudioStream?: MediaStream | null remoteAudioStream?: MediaStream | null
hangingUp?: boolean
}>() }>()
defineEmits<{ defineEmits<{

View File

@ -166,12 +166,7 @@ async function handleJoin() {
return return
} }
popoverVisible.value = false popoverVisible.value = false
try { const data = await joinCall(call.room)
const data = await joinCall(call.room) rtcStore.startInviting(data)
rtcStore.startInviting(data)
} catch (e: any) {
console.error('[GroupCallBanner] join 失败', { room: call.room }, e)
message.error(e?.msg || '加入失败')
}
} }
</script> </script>

View File

@ -622,7 +622,6 @@ function onMutedChange(value: boolean | string | number) {
groupStore.setSilent(targetId, next).catch((error) => { groupStore.setSilent(targetId, next).catch((error) => {
console.error('[IM ConversationGroupSide] setSilent 失败', { targetId }, error) console.error('[IM ConversationGroupSide] setSilent 失败', { targetId }, error)
conversationStore.setSilent(type, targetId, !next) conversationStore.setSilent(type, targetId, !next)
message.error('操作失败')
}) })
} }
@ -650,13 +649,9 @@ async function onMuteAllChange(value: boolean | string | number) {
return return
} }
const newValue = !!value const newValue = !!value
try { await muteAll({ groupId: props.group.id, mutedAll: newValue })
await muteAll({ groupId: props.group.id, mutedAll: newValue }) message.success(newValue ? '已开启全群禁言' : '已关闭全群禁言')
message.success(newValue ? '已开启全群禁言' : '已关闭全群禁言') emit('reload')
emit('reload')
} catch {
message.error('操作失败')
}
} }
// ==================== ==================== // ==================== ====================

View File

@ -58,20 +58,28 @@
</div> </div>
<div class="flex items-center mt-1 leading-5"> <div class="flex items-center mt-1 leading-5">
<!-- 进群申请红字前缀群主 / 管理员看到自己管理的群下还有未处理申请时显示 --> <!-- 进群申请红字前缀群主 / 管理员看到自己管理的群下还有未处理申请时显示 -->
<span v-if="requestText" class="flex-shrink-0 text-12px text-[#c70b0b]"> <span
v-if="requestText"
class="conversation-item__prefix flex-shrink overflow-hidden text-12px text-[#c70b0b] truncate whitespace-nowrap"
>
{{ requestText }} {{ requestText }}
</span> </span>
<!-- @红字提示atMe 优先于 atAll --> <!-- @红字提示atMe 优先于 atAll -->
<span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span> <span
v-if="atText"
class="conversation-item__prefix flex-shrink overflow-hidden text-12px text-[#c70b0b] truncate whitespace-nowrap"
>
{{ atText }}
</span>
<!-- 群聊最后一条发送者前缀 lastSenderId + 当前会话上下文实时算名字 --> <!-- 群聊最后一条发送者前缀 lastSenderId + 当前会话上下文实时算名字 -->
<span <span
v-if="showSendName" v-if="showSendName"
class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)] whitespace-nowrap" class="conversation-item__sender flex-shrink overflow-hidden text-12px text-[var(--el-text-color-secondary)] truncate whitespace-nowrap"
> >
{{ lastSenderDisplayName }}:&nbsp; {{ lastSenderDisplayName }}:&nbsp;
</span> </span>
<span <span
class="flex-1 overflow-hidden text-12px truncate text-[var(--el-text-color-secondary)]" class="flex-1 min-w-0 overflow-hidden text-12px truncate text-[var(--el-text-color-secondary)]"
> >
{{ lastContentDisplay }} {{ lastContentDisplay }}
</span> </span>
@ -239,7 +247,6 @@ function handleMuted() {
: groupStore.setSilent(targetId, next) : groupStore.setSilent(targetId, next)
sync.catch((e) => { sync.catch((e) => {
console.error('[IM] 切换免打扰失败', e) console.error('[IM] 切换免打扰失败', e)
message.error('切换免打扰失败')
conversationStore.setSilent(type, targetId, !next) conversationStore.setSilent(type, targetId, !next)
}) })
} }
@ -285,4 +292,12 @@ function handleContextMenu(e: MouseEvent) {
.conversation-item__silent :deep(svg) { .conversation-item__silent :deep(svg) {
fill: currentColor !important; fill: currentColor !important;
} }
.conversation-item__prefix {
max-width: 45%;
}
.conversation-item__sender {
max-width: 50%;
}
</style> </style>

View File

@ -202,7 +202,7 @@ watch(visible, (open) => {
} }
}) })
/** 备注 popover 点击保存:先走 store API 同步后端,成功后再关 popover + 提示(接口错误由全局拦截器统一 toast不重复 catch */ /** 备注 popover 点击保存 */
async function handleSaveDisplayName() { async function handleSaveDisplayName() {
if (!props.friend) { if (!props.friend) {
return return
@ -227,7 +227,6 @@ function handleMutedChange(value: boolean | string | number) {
} }
friendStore.setSilent(targetId, next).catch((error) => { friendStore.setSilent(targetId, next).catch((error) => {
console.error('[IM ConversationPrivateSide] 切换免打扰失败', { targetId }, error) console.error('[IM ConversationPrivateSide] 切换免打扰失败', { targetId }, error)
message.error('切换免打扰失败')
conversationStore.setSilent(type, targetId, !next) conversationStore.setSilent(type, targetId, !next)
}) })
} }

View File

@ -280,28 +280,26 @@ async function onUploadPicked(e: Event) {
return return
} }
uploading.value = true uploading.value = true
let payload: { url: string; width: number; height: number } let size: { width: number; height: number }
try {
size = await probeImageSize(file)
} catch (err) {
console.warn('[IM] 解析个人表情失败', err)
ElMessage.error('图片解析失败')
uploading.value = false
return
}
try { try {
// probe + OSS probe
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
const [size, uploadRes] = await Promise.all([ const uploadRes = (await updateFile(form)) as { data?: string }
probeImageSize(file),
updateFile(form) as Promise<{ data?: string }>
])
const url = uploadRes?.data const url = uploadRes?.data
if (!url) { if (!url) {
ElMessage.error('上传失败') ElMessage.error('上传失败')
return return
} }
payload = { url, width: size.width, height: size.height } const payload = { url, width: size.width, height: size.height }
} catch (err) {
console.warn('[IM] 上传个人表情失败', err)
ElMessage.error('上传失败')
uploading.value = false
return
}
try {
await faceStore.addFaceUserItem(payload) await faceStore.addFaceUserItem(payload)
} finally { } finally {
uploading.value = false uploading.value = false

View File

@ -464,6 +464,7 @@ function toggleSide() {
/** 私聊通话入口popover 触发;点 语音 / 视频 直接发起 */ /** 私聊通话入口popover 触发;点 语音 / 视频 直接发起 */
const callPopoverVisible = ref(false) const callPopoverVisible = ref(false)
const callInviting = ref(false) //
async function startPrivateCall(mediaType: number) { async function startPrivateCall(mediaType: number) {
callPopoverVisible.value = false callPopoverVisible.value = false
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
@ -503,13 +504,21 @@ async function onCallMemberPicked(selectedIds: number[]) {
}) })
} }
/** 实际调 create 接口;统一处理成功 / ENDED如忙线立即结束/ 异常三种返回 */ /** 实际调 create 接口;统一处理成功 / ENDED如忙线立即结束 */
async function doInvite(reqVO: { async function doInvite(reqVO: {
conversationType: number conversationType: number
mediaType: number mediaType: number
groupId?: number groupId?: number
inviteeIds: number[] inviteeIds: number[]
}) { }) {
if (callInviting.value) {
return
}
if (rtcStore.isActive) {
message.warning('当前已有通话')
return
}
callInviting.value = true
try { try {
const data = await createCall(reqVO) const data = await createCall(reqVO)
// INSERT + end线toast INVITING chat tip RTC_CALL_END // INSERT + end线toast INVITING chat tip RTC_CALL_END
@ -519,8 +528,8 @@ async function doInvite(reqVO: {
} }
// INVITING store / RTC // INVITING store / RTC
rtcStore.startInviting(data) rtcStore.startInviting(data)
} catch (e: any) { } finally {
message.error(e?.msg || '发起通话失败') callInviting.value = false
} }
} }

View File

@ -33,7 +33,15 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<!-- 消息内容搜索入口暂时移除后端 page 接口没有 content 字段留着会误导管理员后端补上后再恢复 --> <el-form-item label="消息内容" prop="content">
<el-input
v-model="queryParams.content"
placeholder="请输入消息内容"
clearable
class="!w-240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="发送时间" prop="sendTime"> <el-form-item label="发送时间" prop="sendTime">
<el-date-picker <el-date-picker
v-model="queryParams.sendTime" v-model="queryParams.sendTime"
@ -176,6 +184,7 @@ const queryParams = reactive({
groupId: undefined as number | undefined, groupId: undefined as number | undefined,
senderId: undefined as number | undefined, senderId: undefined as number | undefined,
type: undefined as number | undefined, type: undefined as number | undefined,
content: undefined as string | undefined,
sendTime: [] as string[] sendTime: [] as string[]
}) })
const queryFormRef = ref() // const queryFormRef = ref() //

View File

@ -37,7 +37,15 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<!-- 消息内容搜索入口暂时移除后端 page 接口没有 content 字段留着会误导管理员后端补上后再恢复 --> <el-form-item label="消息内容" prop="content">
<el-input
v-model="queryParams.content"
placeholder="请输入消息内容"
clearable
class="!w-240px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="发送时间" prop="sendTime"> <el-form-item label="发送时间" prop="sendTime">
<el-date-picker <el-date-picker
v-model="queryParams.sendTime" v-model="queryParams.sendTime"
@ -151,6 +159,7 @@ const queryParams = reactive({
senderId: undefined as number | undefined, senderId: undefined as number | undefined,
receiverId: undefined as number | undefined, receiverId: undefined as number | undefined,
type: undefined as number | undefined, type: undefined as number | undefined,
content: undefined as string | undefined,
sendTime: [] as string[] sendTime: [] as string[]
}) })
const queryFormRef = ref() // const queryFormRef = ref() //