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