fix(im): 批量修复 P0 安全边界和通话流程问题
- 拒绝匿名 WebSocket 握手,收紧 RTC 接听和入会忙线校验 - 支持封禁群解散,管理端解散改为独立权限码 - 增加个人表情数量配置、唯一约束和并发重复兜底 - 修复 RTC 异常断开上报、视频远端音频和好友选择大列表渲染 - 让个人表情添加失败透出后端业务错误 - 流转 P0 bug 文档,并按产品取舍记录 apiSecret 默认值不强制拦截im
parent
00f273ca77
commit
2ede2b371f
|
|
@ -51,6 +51,11 @@ export const unbanManagerGroup = (id: number) => {
|
|||
return request.put({ url: '/im/manager/group/unban?id=' + id })
|
||||
}
|
||||
|
||||
// 解散群
|
||||
export const dissolveManagerGroup = (id: number) => {
|
||||
return request.delete({ url: '/im/manager/group/dissolve?id=' + id })
|
||||
}
|
||||
|
||||
// 获得群成员列表(含已退群成员,由前端按需过滤)
|
||||
export const getManagerGroupMemberList = (groupId: number) => {
|
||||
return request.get({ url: '/im/manager/group/member/list?groupId=' + groupId })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<!--
|
||||
好友选择面板:用于「新建群聊 / 邀请好友 / 推荐时创建聊天」等好友选择场景
|
||||
- 左:搜索 + 字母分桶好友列表(圆形勾选)
|
||||
- 左:搜索 + 好友列表(圆形勾选)
|
||||
- 右:已选数标题 + 已选好友列表(按点击顺序)
|
||||
- Panel 不带 el-dialog 壳;dialog 由业务壳持有
|
||||
- 三态语义:hide > locked > disabled(详见 contract)
|
||||
|
|
@ -20,59 +20,49 @@
|
|||
</el-input>
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="flex-1">
|
||||
<template v-for="bucket in buckets" :key="bucket.letter">
|
||||
<!-- 字母分桶 header:浅底 + 小字号 -->
|
||||
<div
|
||||
class="pt-1 pb-0.5 px-3.5 text-12px text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-lighter)]"
|
||||
>
|
||||
{{ bucket.letter }}
|
||||
</div>
|
||||
<div
|
||||
v-for="friend in bucket.list"
|
||||
:key="friend.id"
|
||||
class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--el-fill-color)]"
|
||||
:class="{
|
||||
'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(friend)
|
||||
}"
|
||||
@click="handleToggle(friend)"
|
||||
>
|
||||
<!-- 圆形勾选指示器:未选灰色空心圆,选中实心微信绿 + 白对勾;锁定 / 禁用走灰底 -->
|
||||
<span
|
||||
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
|
||||
:class="getCheckClass(friend)"
|
||||
<div class="flex-1 min-h-0">
|
||||
<PagedScroller v-if="filtered.length > 0" :items="filtered" :page-size="30" item-key="id">
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
:key="(item as FriendLite).id"
|
||||
class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--el-fill-color)]"
|
||||
:class="{
|
||||
'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(item as FriendLite)
|
||||
}"
|
||||
@click="handleToggle(item as FriendLite)"
|
||||
>
|
||||
<Icon
|
||||
v-if="isSelected(friend) || isLocked(friend)"
|
||||
icon="ant-design:check-outlined"
|
||||
:size="12"
|
||||
color="#fff"
|
||||
<!-- 圆形勾选指示器:未选灰色空心圆,选中实心微信绿 + 白对勾;锁定 / 禁用走灰底 -->
|
||||
<span
|
||||
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
|
||||
:class="getCheckClass(item as FriendLite)"
|
||||
>
|
||||
<Icon
|
||||
v-if="isSelected(item as FriendLite) || isLocked(item as FriendLite)"
|
||||
icon="ant-design:check-outlined"
|
||||
:size="12"
|
||||
color="#fff"
|
||||
/>
|
||||
</span>
|
||||
<UserAvatar
|
||||
:id="(item as FriendLite).id"
|
||||
:url="(item as FriendLite).avatar"
|
||||
:name="(item as FriendLite).nickname"
|
||||
:size="36"
|
||||
:clickable="false"
|
||||
/>
|
||||
</span>
|
||||
<UserAvatar
|
||||
:id="friend.id"
|
||||
:url="friend.avatar"
|
||||
:name="friend.nickname"
|
||||
:size="36"
|
||||
:clickable="false"
|
||||
/>
|
||||
<!-- 行内名字:备注优先,列表里不重复展示昵称 -->
|
||||
<span
|
||||
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
|
||||
>
|
||||
{{ friend.displayName || friend.nickname }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div
|
||||
v-if="filtered.length === 0"
|
||||
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
|
||||
>
|
||||
<!-- 行内名字:备注优先,列表里不重复展示昵称 -->
|
||||
<span
|
||||
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
|
||||
>
|
||||
{{ (item as FriendLite).displayName || (item as FriendLite).nickname }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</PagedScroller>
|
||||
<div v-else class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]">
|
||||
{{ keyword ? '没有匹配的好友' : '暂无好友' }}
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏 -->
|
||||
|
|
@ -137,6 +127,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
|
|||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
|
||||
import UserAvatar from '../user/UserAvatar.vue'
|
||||
import PagedScroller from '../PagedScroller.vue'
|
||||
import { useFriendBuckets } from '../../composables/useFriendBuckets'
|
||||
import { useSelectedItems } from '../../composables/useSelectedItems'
|
||||
import type { FriendLite } from '../../types'
|
||||
|
|
@ -194,8 +185,8 @@ const candidates = computed(() =>
|
|||
props.friends.filter((friend) => !hideSet.value.has(friend.id))
|
||||
)
|
||||
|
||||
/** 委托 useFriendBuckets:搜索 + 字母分桶共用一套规则 */
|
||||
const { filtered, buckets } = useFriendBuckets(candidates, keyword)
|
||||
/** 委托 useFriendBuckets:搜索规则复用,左侧列表按滚动分页渲染 */
|
||||
const { filtered } = useFriendBuckets(candidates, keyword)
|
||||
|
||||
/** 已选数 + 已选好友列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
|
||||
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
|
||||
|
|
@ -251,4 +242,3 @@ function handleToggle(friend: FriendLite) {
|
|||
emit('update:selectedIds', next)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -369,12 +369,18 @@ function handlePeerDisconnected() {
|
|||
if (!rtcStore.isActive) {
|
||||
return
|
||||
}
|
||||
const room = rtcStore.call?.room
|
||||
// 给 RTC_CALL_END WebSocket 推送一个小窗口;私聊超时 / 主动挂断等场景下,后端 endSession 会先推 RTC_CALL_END,
|
||||
// 让前端按业务语义("对方未接听" / "已取消" 等)reset,避免错把业务断开 toast 成「通话已断开」
|
||||
setTimeout(() => {
|
||||
if (!rtcStore.isActive) {
|
||||
return
|
||||
}
|
||||
// 上报离开房间
|
||||
if (room) {
|
||||
leaveCall(room).catch(() => undefined)
|
||||
}
|
||||
// 清理本地通话状态
|
||||
message.warning('通话已断开')
|
||||
rtcStore.reset()
|
||||
}, 100)
|
||||
|
|
|
|||
|
|
@ -75,8 +75,13 @@
|
|||
<div class="text-[17px] font-medium">{{ peerNickname }}</div>
|
||||
<div class="text-13px text-white/60">{{ formattedDuration }}</div>
|
||||
</div>
|
||||
<audio v-if="remoteAudioStream" ref="remoteAudioRef" autoplay :muted="!speakerEnabled"></audio>
|
||||
</template>
|
||||
<audio
|
||||
v-if="!isGroup && remoteAudioStream"
|
||||
ref="remoteAudioRef"
|
||||
autoplay
|
||||
:muted="!speakerEnabled"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区:麦克风 / 扬声器 / 摄像头 / (群聊:共享屏幕 / 添加成员) / 挂断 -->
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@ async function onUploadPicked(e: Event) {
|
|||
return
|
||||
}
|
||||
uploading.value = true
|
||||
let payload: { url: string; width: number; height: number }
|
||||
try {
|
||||
// probe 本地图片宽高 + 上传到 OSS 并行起跑(probe 通常远快于上传,几乎完全被遮蔽)
|
||||
const form = new FormData()
|
||||
|
|
@ -293,13 +294,15 @@ async function onUploadPicked(e: Event) {
|
|||
ElMessage.error('上传失败')
|
||||
return
|
||||
}
|
||||
const ok = await faceStore.addFaceUserItem({ url, width: size.width, height: size.height })
|
||||
if (!ok) {
|
||||
ElMessage.error('添加失败,可能已添加过')
|
||||
}
|
||||
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)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -786,7 +786,7 @@ const canAddToFace = computed(() => {
|
|||
return extractAddableFace(props.message) !== null
|
||||
})
|
||||
|
||||
/** 添加到个人表情:从 message 抽 url + 尺寸 + name 写入个人表情库;幂等失败时返回 false 走 toast 兜底 */
|
||||
/** 添加到个人表情:从 message 抽 url + 尺寸 + name 写入个人表情库 */
|
||||
async function handleAddToFace() {
|
||||
const payload = extractAddableFace(props.message)
|
||||
if (!payload) {
|
||||
|
|
|
|||
|
|
@ -88,34 +88,28 @@ export const useFaceStore = defineStore('imFace', () => {
|
|||
* 添加个人表情;服务端对同 URL 抛 FACE_USER_ITEM_DUPLICATED 错误
|
||||
*
|
||||
* 来源:1. 用户在表情面板「+」上传图片 2. 长按消息「添加到表情」
|
||||
* 返回 true / false 与 removeFaceUserItem 风格对齐;调用方按 boolean 决定是否提示
|
||||
*/
|
||||
async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise<boolean> {
|
||||
const requestEpoch = storeEpoch
|
||||
try {
|
||||
const id = await apiCreateFaceUserItem(reqVO)
|
||||
if (!id) {
|
||||
return false
|
||||
}
|
||||
// reset 已切账号:旧请求拿到的 id 不能再 unshift 进新账号内存
|
||||
if (requestEpoch !== storeEpoch) {
|
||||
return false
|
||||
}
|
||||
// id 不在缓存里才插入;服务端唯一约束兜底了 race,本地理论上不会拿到重复 id
|
||||
if (!faceUserItems.value.some((item) => item.id === id)) {
|
||||
faceUserItems.value.unshift({
|
||||
id,
|
||||
url: reqVO.url,
|
||||
name: reqVO.name,
|
||||
width: reqVO.width,
|
||||
height: reqVO.height
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn('[IM] 添加个人表情失败', { reqVO }, e)
|
||||
const id = await apiCreateFaceUserItem(reqVO)
|
||||
if (!id) {
|
||||
return false
|
||||
}
|
||||
// reset 已切账号:旧请求拿到的 id 不能再 unshift 进新账号内存
|
||||
if (requestEpoch !== storeEpoch) {
|
||||
return false
|
||||
}
|
||||
// id 不在缓存里才插入;服务端唯一约束兜底了 race,本地理论上不会拿到重复 id
|
||||
if (!faceUserItems.value.some((item) => item.id === id)) {
|
||||
faceUserItems.value.unshift({
|
||||
id,
|
||||
url: reqVO.url,
|
||||
name: reqVO.name,
|
||||
width: reqVO.width,
|
||||
height: reqVO.height
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 删除个人表情;本地立即移除 */
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@
|
|||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" width="280" fixed="right">
|
||||
<el-table-column label="操作" align="center" width="340" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
|
|
@ -159,6 +159,15 @@
|
|||
>
|
||||
解封
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === CommonStatusEnum.ENABLE"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDissolve(row)"
|
||||
v-hasPermi="['im:manager:group:dissolve']"
|
||||
>
|
||||
解散
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
|
@ -179,6 +188,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import * as ManagerGroupApi from '@/api/im/manager/group'
|
||||
import UserSelectV2 from '@/views/system/user/components/UserSelectV2.vue'
|
||||
import GroupDetail from './GroupDetail.vue'
|
||||
|
|
@ -252,6 +262,19 @@ const handleUnban = async (row: ManagerGroupApi.ImManagerGroupVO) => {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
/** 解散按钮操作 */
|
||||
const handleDissolve = async (row: ManagerGroupApi.ImManagerGroupVO) => {
|
||||
try {
|
||||
// 解散的二次确认
|
||||
await message.confirm(`确认解散群「${row.name}」吗?`)
|
||||
// 发起解散
|
||||
await ManagerGroupApi.dissolveManagerGroup(row.id)
|
||||
message.success('解散成功')
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 跳转到群聊消息页面,查看该群的对话 */
|
||||
const goConversation = (row: ManagerGroupApi.ImManagerGroupVO) => {
|
||||
push({
|
||||
|
|
|
|||
Loading…
Reference in New Issue