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

View File

@ -53,12 +53,16 @@
<div class="flex flex-shrink-0 gap-2 items-center">
<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="{ 'opacity-60 cursor-not-allowed': rejectDisabled }"
:disabled="rejectDisabled"
@click="$emit('reject')"
>
<Icon icon="ant-design:phone-outlined" :size="18" class="rotate-[135deg]" />
</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="{ 'opacity-60 cursor-not-allowed': acceptDisabled }"
:disabled="acceptDisabled"
@click="$emit('accept')"
>
<Icon icon="ant-design:phone-outlined" :size="18" />
@ -78,6 +82,8 @@ import { DICT_TYPE, getDictLabel } from '@/utils/dict'
const props = defineProps<{
payload: ImRtcCallNotification | null
isGroup?: boolean
accepting?: boolean
rejecting?: boolean
}>()
defineEmits<{ accept: []; reject: [] }>()
@ -88,6 +94,12 @@ const tipText = computed(() => {
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 载荷里的主叫兜底,避免空白 */
const callMembers = useGroupCallMembers(
computed(() => (props.isGroup ? props.payload?.groupId : undefined)),

View File

@ -172,6 +172,7 @@
</template>
<div
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')"
>
<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
remoteVideoStream?: MediaStream | null
remoteAudioStream?: MediaStream | null
hangingUp?: boolean
}>()
defineEmits<{

View File

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

View File

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

View File

@ -58,20 +58,28 @@
</div>
<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 }}
</span>
<!-- @红字提示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 + 当前会话上下文实时算名字 -->
<span
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;
</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 }}
</span>
@ -239,7 +247,6 @@ function handleMuted() {
: groupStore.setSilent(targetId, next)
sync.catch((e) => {
console.error('[IM] 切换免打扰失败', e)
message.error('切换免打扰失败')
conversationStore.setSilent(type, targetId, !next)
})
}
@ -285,4 +292,12 @@ function handleContextMenu(e: MouseEvent) {
.conversation-item__silent :deep(svg) {
fill: currentColor !important;
}
.conversation-item__prefix {
max-width: 45%;
}
.conversation-item__sender {
max-width: 50%;
}
</style>

View File

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

View File

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

View File

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

View File

@ -33,7 +33,15 @@
/>
</el-select>
</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-date-picker
v-model="queryParams.sendTime"
@ -176,6 +184,7 @@ const queryParams = reactive({
groupId: undefined as number | undefined,
senderId: undefined as number | undefined,
type: undefined as number | undefined,
content: undefined as string | undefined,
sendTime: [] as string[]
})
const queryFormRef = ref() //

View File

@ -37,7 +37,15 @@
/>
</el-select>
</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-date-picker
v-model="queryParams.sendTime"
@ -151,6 +159,7 @@ const queryParams = reactive({
senderId: undefined as number | undefined,
receiverId: undefined as number | undefined,
type: undefined as number | undefined,
content: undefined as string | undefined,
sendTime: [] as string[]
})
const queryFormRef = ref() //