接入会话置顶、会话免打扰功能到聊天设置页面;接入退出群聊、解散群聊功能,并解决历史遗留问题:群主不能退群(普通群);接入新版socket消息监听用于处理消息已读回执,读消息视角依然沿用旧版轮询方案防止丢失既有数据;查视角采用新版监听socket消息方案代替轮询接口实现

This commit is contained in:
wangyifeng 2025-05-27 18:49:48 +08:00
parent 8ce7d143ce
commit bdfd604fd9
5 changed files with 477 additions and 47 deletions

View File

@ -11,13 +11,14 @@ import {
ServeSecedeGroup, ServeSecedeGroup,
ServeUpdateGroupCard, ServeUpdateGroupCard,
ServeGetGroupNotices, ServeGetGroupNotices,
ServeEditGroup ServeEditGroup,
ServeDismissGroup
} from '@/api/group' } from '@/api/group'
import { useInject } from '@/hooks' import { useInject } from '@/hooks'
import customModal from '@/components/common/customModal.vue' import customModal from '@/components/common/customModal.vue'
import avatarModule from '@/components/avatar-module/index.vue' import avatarModule from '@/components/avatar-module/index.vue'
import UserCardModal from '@/components/user/UserCardModal.vue' import UserCardModal from '@/components/user/UserCardModal.vue'
import { ServeEmptyMessage } from '@/api/chat' import { ServeEmptyMessage, ServeTopTalkList, ServeSetNotDisturb } from '@/api/chat'
import { parseTime } from '@/utils/datetime' import { parseTime } from '@/utils/datetime'
const userStore = useUserStore() const userStore = useUserStore()
@ -48,6 +49,12 @@ const props = defineProps({
} }
}) })
const talkParams = reactive({
isTop: computed(() => talkStore.findItem(props.talkType + '_' + props.gid)?.is_top),
isDisturb: computed(() => talkStore.findItem(props.talkType + '_' + props.gid)?.is_disturb),
sessionId: computed(() => talkStore.findItem(props.talkType + '_' + props.gid)?.id)
})
watch(props, () => { watch(props, () => {
if (props.talkType === 2) { if (props.talkType === 2) {
loadDetail() loadDetail()
@ -98,7 +105,8 @@ const state = reactive({
}, // }, //
editGroupName: false, // editGroupName: false, //
editGroupNameValue: '', // editGroupNameValue: '', //
chatSettingOperateType: '' // chatSettingOperateType: '', //
isLastAdmin: false //
}) })
const members = ref<any[]>([]) const members = ref<any[]>([])
@ -177,17 +185,36 @@ const onClose = () => {
emit('close') emit('close')
} }
const onSignOut = () => { const onSignOut = (closeLoading) => {
ServeSecedeGroup({ ServeSecedeGroup({
group_id: props.gid group_id: props.gid
}).then((res) => { })
.then((res) => {
closeLoading()
if (res.code == 200) { if (res.code == 200) {
window['$message'].success('已退出群聊') window['$message'].success('已退出群聊')
state.isShowModal = false
onClose() onClose()
} else { } else {
window['$message'].error(res.message) window['$message'].error(res.message || res.msg)
} }
}) })
.catch((err) => {
closeLoading()
window['$message'].error(err.message)
})
}
const onDismiss = async (closeLoading) => {
const { code, message } = await ServeDismissGroup({ group_id: props.gid })
closeLoading()
if (code === 200) {
onClose()
state.isShowModal = false
window['$message'].success('群聊已解散')
} else {
window['$message'].info(message)
}
} }
const onChangeRemark = () => { const onChangeRemark = () => {
@ -236,8 +263,18 @@ const handleModalConfirm = (closeLoading) => {
closeLoading() closeLoading()
window['$message'].error(err.message) window['$message'].error(err.message)
}) })
} else if (state.chatSettingOperateType == 'quit'){ } else if (state.chatSettingOperateType == 'quit') {
//退 //退
if (state.isLastAdmin) {
//退
onDismiss(closeLoading)
} else {
//退
onSignOut(closeLoading)
}
} else if (state.chatSettingOperateType == 'dismiss') {
//
onDismiss(closeLoading)
} }
} }
@ -256,12 +293,15 @@ const showChatSettingOperateModal = (type: string) => {
break break
case 'quit': case 'quit':
state.chatSettingOperateHint = '确定退出群聊' state.chatSettingOperateHint = '确定退出群聊'
const findAdmin = groupMemberList.value.find((item) => item.leader === 2 || item.leader === 1) const findOtherAdmin = groupMemberList.value.find(
const isLastAdmin = findAdmin && findAdmin.user_id === userStore.uid (item) => (item.leader === 2 || item.leader === 1) && item.user_id !== userStore.uid
if (isLastAdmin) { )
state.chatSettingOperateSubHint = '退出后,本群将被解散' if (findOtherAdmin) {
} else { state.isLastAdmin = false
state.chatSettingOperateSubHint = '退出后,聊天记录将被清空' state.chatSettingOperateSubHint = '退出后,聊天记录将被清空'
} else {
state.isLastAdmin = true
state.chatSettingOperateSubHint = '退出后,本群将被解散'
} }
break break
} }
@ -380,6 +420,42 @@ const handleEditGroupNameConfirm = () => {
} }
}) })
} }
//
const onTopChange = (value: boolean) => {
ServeTopTalkList({
list_id: talkParams.sessionId,
type: value ? 1 : 2
}).then(({ code, message }) => {
if (code == 200) {
talkStore.updateItem({
index_name: props.talkType + '_' + props.gid,
is_top: talkParams.isTop == 0 ? 1 : 0
})
} else {
window['$message'].error(message)
}
})
}
//
const onDisturbChange = (value: boolean) => {
ServeSetNotDisturb({
talk_type: props.talkType,
receiver_id: props.gid,
is_disturb: value ? 1 : 0
}).then(({ code, message }) => {
if (code == 200) {
window['$message'].success('设置成功!')
talkStore.updateItem({
index_name: props.talkType + '_' + props.gid,
is_disturb: value ? 1 : 0
})
} else {
window['$message'].error(message)
}
})
}
</script> </script>
<template> <template>
<section class="el-container is-vertical section"> <section class="el-container is-vertical section">
@ -602,14 +678,14 @@ const handleEditGroupNameConfirm = () => {
<div class="b-box" style="margin: 16px 0 32px;"> <div class="b-box" style="margin: 16px 0 32px;">
<div class="block"> <div class="block">
<div class="title">置顶会话</div> <div class="title">置顶会话</div>
<n-switch /> <n-switch :value="talkParams.isTop === 1" @update:value="onTopChange" />
</div> </div>
</div> </div>
<div class="b-box" style="margin: 32px 0 20px;"> <div class="b-box" style="margin: 32px 0 20px;">
<div class="block"> <div class="block">
<div class="title">消息免打扰</div> <div class="title">消息免打扰</div>
<n-switch /> <n-switch :value="talkParams.isDisturb === 1" @update:value="onDisturbChange" />
</div> </div>
</div> </div>
</div> </div>
@ -733,9 +809,11 @@ const handleEditGroupNameConfirm = () => {
<template #content> <template #content>
<div class="custom-modal-content"> <div class="custom-modal-content">
<text>{{ state.chatSettingOperateHint }}</text> <text>{{ state.chatSettingOperateHint }}</text>
<text style="font-size: 16px; color: #999999; margin: 0; line-height: 22px;">{{ <text
state.chatSettingOperateSubHint style="font-size: 16px; color: #999999; margin: 0; line-height: 22px;"
}}</text> :style="{ color: state.isLastAdmin ? '#CF3050' : '' }"
>{{ state.chatSettingOperateSubHint }}</text
>
</div> </div>
</template> </template>
</customModal> </customModal>

View File

@ -24,7 +24,7 @@ const { showUserInfoModal } = useInject()
<em v-show="index < extra.members.length - 1"></em> <em v-show="index < extra.members.length - 1"></em>
</template> </template>
<span>出群聊</span> <span>出群聊</span>
</div> </div>
</div> </div>
</template> </template>

View File

@ -85,6 +85,7 @@ class Connect {
this.onImContactStatus() this.onImContactStatus()
this.onImMessageRevoke() this.onImMessageRevoke()
this.onImMessageKeyboard() this.onImMessageKeyboard()
this.onImMessageListenReadIncr()
} }
onPing() { onPing() {
@ -131,6 +132,13 @@ class Connect {
this.conn.on('im.message.revoke', (data: any) => new EventRevoke(data)) this.conn.on('im.message.revoke', (data: any) => new EventRevoke(data))
} }
onImMessageListenReadIncr() {
// 消息已读回执监听事件
this.conn.on('im.message.listen.read.incr', (data: any) => {
console.error('====接收到了新版已读回执增量=====', data)
})
}
onImContactApply() { onImContactApply() {
// 好友申请事件 // 好友申请事件
this.conn.on('im.contact.apply', (data: any) => { this.conn.on('im.contact.apply', (data: any) => {

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { watch, onMounted, ref, nextTick } from 'vue' import { watch, onMounted, ref, nextTick, onUnmounted } from 'vue'
import { NDropdown, NCheckbox } from 'naive-ui' import { NDropdown, NCheckbox } from 'naive-ui'
import { Loading, MoreThree, ToTop } from '@icon-park/vue-next' import { Loading, MoreThree, ToTop } from '@icon-park/vue-next'
import { bus } from '@/utils/event-bus' import { bus } from '@/utils/event-bus'
@ -14,10 +14,34 @@ import { ITalkRecord } from '@/types/chat'
import { EditorConst } from '@/constant/event-bus' import { EditorConst } from '@/constant/event-bus'
import { useInject, useTalkRecord, useUtil } from '@/hooks' import { useInject, useTalkRecord, useUtil } from '@/hooks'
import { ExclamationCircleFilled } from '@ant-design/icons-vue' import { ExclamationCircleFilled } from '@ant-design/icons-vue'
import { useUserStore ,useUploadsStore} from '@/store' import { useUserStore, useUploadsStore } from '@/store'
import RevokeMessage from '@/components/talk/message/RevokeMessage.vue' import RevokeMessage from '@/components/talk/message/RevokeMessage.vue'
import { voiceToText } from '@/api/chat.js' import { voiceToText } from '@/api/chat.js'
import {confirmBox} from '@/components/confirm-box/service.js' import { confirmBox } from '@/components/confirm-box/service.js'
import ws from '@/connect'
//
interface ReadStatus {
msg_ids: string[]
talk_type: number
receiver_id: number,
user_id: number
}
//
interface State {
visibleElements: Set<HTMLElement>
visibleOutElements: Set<HTMLElement>
tempWaitDoRead: ReadStatus[]
tempWaitDoCheck: ReadStatus[]
setMessageReadInterval: number | null
setOutMessageReadInterval: number | null
lastUpdateTime: number
isScrolling: boolean
scrollTimer: number | null
lastTriggerTime: number
}
const props = defineProps({ const props = defineProps({
uid: { uid: {
type: Number, type: Number,
@ -38,6 +62,10 @@ const props = defineProps({
specifiedMsg: { specifiedMsg: {
type: String, type: String,
default: '' default: ''
},
num: {
type: Number,
default: 0
} }
}) })
@ -96,6 +124,19 @@ const onPanelScroll = (e: any) => {
dialogueStore.setUnreadBubble(0) dialogueStore.setUnreadBubble(0)
} }
//
state.value.isScrolling = true
if (state.value.scrollTimer) {
clearTimeout(state.value.scrollTimer)
}
state.value.scrollTimer = window.setTimeout(() => {
state.value.isScrolling = false
//
checkVisibleOutElements()
//
lastVisibleOutTriggerTime = Date.now()
}, 150) // 150ms
// //
if (skipBottom.value == false) { if (skipBottom.value == false) {
let len = dialogueStore.records.length let len = dialogueStore.records.length
@ -232,7 +273,7 @@ const onClickNickname = (data: ITalkRecord) => {
// //
const onContextMenu = (e: any, item: ITalkRecord) => { const onContextMenu = (e: any, item: ITalkRecord) => {
console.log('item',item) console.log('item', item)
if (!dialogueStore.isShowEditor || dialogueStore.isOpenMultiSelect) { if (!dialogueStore.isShowEditor || dialogueStore.isOpenMultiSelect) {
return e.preventDefault() return e.preventDefault()
} }
@ -295,7 +336,10 @@ watch(
try { try {
const parsed = JSON.parse(decodeURIComponent(newProps.specifiedMsg)) const parsed = JSON.parse(decodeURIComponent(newProps.specifiedMsg))
// id // id
if (parsed.talk_type === newProps.talk_type && parsed.receiver_id === newProps.receiver_id) { if (
parsed.talk_type === newProps.talk_type &&
parsed.receiver_id === newProps.receiver_id
) {
specialParams = parsed specialParams = parsed
} }
} catch (e) {} } catch (e) {}
@ -313,26 +357,319 @@ watch(
) )
// onMounted(() => { // onMounted(() => {
// onLoad({ ...props, limit: 30 }) // onLoad({ ...props, limit: 30 })
// }) // })
const retry=(item:any)=>{ const retry = (item: any) => {
confirmBox({ confirmBox({
content:'确定重发吗' content: '确定重发吗'
}).then(()=>{ }).then(() => {
uploadsStore.retryCommonUpload(item.extra.upload_id) uploadsStore.retryCommonUpload(item.extra.upload_id)
}) })
} }
const onContextMenuAvatar=(e:any,item:any)=>{ const onContextMenuAvatar = (e: any, item: any) => {
e.preventDefault() e.preventDefault()
if(item.float!=='right'){ if (item.float !== 'right') {
bus.emit(EditorConst.Mention, { bus.emit(EditorConst.Mention, {
id: item.user_id, id: item.user_id,
value: item.nickname value: item.nickname
}) })
} }
} }
const state = ref<State>({
visibleElements: new Set(),
visibleOutElements: new Set(),
tempWaitDoRead: [],
tempWaitDoCheck: [],
setMessageReadInterval: null,
setOutMessageReadInterval: null,
lastUpdateTime: 0,
isScrolling: false,
scrollTimer: null,
lastTriggerTime: 0
})
// Map
const recordReadsMap = ref<Map<string, number>>(new Map())
//
let observer: IntersectionObserver | null = null
// Map UI
watch(
recordReadsMap,
(newMap) => {
requestAnimationFrame(() => {
newMap.forEach((readNum, msgId) => {
const element = document.getElementById(msgId)
if (element) {
element.dataset.readNum = String(readNum)
const readNumElement = element.querySelector('.have_read_num')
if (readNumElement) {
if (props.talk_type === 1) {
readNumElement.textContent = readNum > 0 ? '已读' : '未读'
} else {
readNumElement.textContent =
'已读 (' +
readNum +
'/' +
(Number(props.num) - 1 > 0 ? Number(props.num) - 1 : 0) +
')'
}
}
}
})
})
},
{
deep: true,
immediate: true
}
)
//
const checkVisibleElements = () => {
if (state.value.visibleElements.size > 0) {
let waitDoRead: ReadStatus[] = []
state.value.visibleElements.forEach((el: HTMLElement) => {
const msgId = el.dataset.msgid
const talkType = Number(el.dataset.talktype)
const receiverId = Number(el.dataset.receiverid)
if (!msgId) return
if (waitDoRead.length === 0) {
waitDoRead.push({
msg_ids: [msgId],
talk_type: talkType,
receiver_id: receiverId
})
} else {
const existingItem = waitDoRead.find(
(item) => item.talk_type === talkType && item.receiver_id === receiverId
)
if (existingItem) {
existingItem.msg_ids.push(msgId)
} else {
waitDoRead.push({
msg_ids: [msgId],
talk_type: talkType,
receiver_id: receiverId
})
}
}
})
if (waitDoRead.length > 0) {
waitDoRead.forEach((doReadItem) => {
const prevItem = state.value.tempWaitDoRead.find(
(prev) =>
prev.talk_type === doReadItem.talk_type && prev.receiver_id === doReadItem.receiver_id
)
if (!prevItem || !doReadItem.msg_ids.every((id) => prevItem.msg_ids.includes(id))) {
console.error('====发送了新版已读回执=====', doReadItem)
ws.emit('im.message.new.read', doReadItem)
}
})
}
state.value.tempWaitDoRead = JSON.parse(JSON.stringify(waitDoRead))
}
}
//
const checkVisibleOutElements = () => {
if (state.value.visibleOutElements.size > 0) {
let waitDoCheck: ReadStatus[] = []
state.value.visibleOutElements.forEach((el: HTMLElement) => {
const msgId = el.dataset.msgid
const talkType = Number(el.dataset.talktype)
const receiverId = Number(el.dataset.receiverid)
if (!msgId) return
if (waitDoCheck.length === 0) {
waitDoCheck.push({
msg_ids: [msgId],
talk_type: talkType,
receiver_id: receiverId,
user_id: props.uid
})
} else {
const existingItem = waitDoCheck.find(
(item) => item.talk_type === talkType && item.receiver_id === receiverId
)
if (existingItem) {
existingItem.msg_ids.push(msgId)
} else {
waitDoCheck.push({
msg_ids: [msgId],
talk_type: talkType,
receiver_id: receiverId,
user_id: props.uid
})
}
}
})
if (waitDoCheck.length > 0) {
waitDoCheck.forEach((doCheckItem) => {
// console.error('=========', doCheckItem)
console.error('====组装了新版已读回执参数需要发送socket=====', doCheckItem)
ws.emit('im.message.listen.read', doCheckItem)
// let params = Object.assign({}, doCheckItem, {
// talkType: doCheckItem.talk_type,
// receiverId: doCheckItem.talk_type === 1 ? props.uid : props.receiver_id,
// msgIds: doCheckItem.msg_ids,
// type: 'list'
// })
// const resp = ServeReadConditionList(params)
// resp
// .then(({ code, data }) => {
// if (code == 200) {
// if (Array.isArray(data.data)) {
// console.error('', data.data)
// data.data.forEach((item) => {
// if (item.msgId && item.readNum !== undefined) {
// recordReadsMap.value.set(item.msgId, item.readNum)
// }
// })
// } else if (data.data && data.data.readNum !== undefined) {
// console.error('', data.data)
// doCheckItem.msg_ids.forEach((msgId) => {
// recordReadsMap.value.set(msgId, data.data.readNum)
// })
// }
// }
// })
// .catch(() => {})
})
}
state.value.tempWaitDoCheck = JSON.parse(JSON.stringify(waitDoCheck))
}
}
//
let lastVisibleOutTriggerTime = 0
//socket
watch(() => state.value.visibleOutElements, (newVal) => {
const now = Date.now()
if (now - lastVisibleOutTriggerTime < 1000) {
return
}
lastVisibleOutTriggerTime = now
checkVisibleOutElements()
}, {
deep: true,
immediate: true
})
//
const handleIntersection = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
let elData = entry.target.dataset
const msgType = elData.msgtype
const userId = elData.userid
if (Number(msgType) < 1000 && Number(userId) !== Number(props.uid)) {
//
state.value.visibleElements.add(entry.target)
}
if (Number(msgType) < 1000 && Number(userId) === Number(props.uid)) {
//
state.value.visibleOutElements.add(entry.target)
}
} else {
//
state.value.visibleElements.delete(entry.target)
state.value.visibleOutElements.delete(entry.target)
}
})
}
//
watch(
() => records.value,
() => {
nextTick(() => {
//
if (observer) {
observer.disconnect()
}
//
const options = {
root: null,
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
rootMargin: '50px 0px'
}
observer = new IntersectionObserver(handleIntersection, options)
//
const messageElements = document.querySelectorAll('.message-item')
messageElements.forEach((el) => {
if (observer) {
observer.observe(el)
}
})
})
},
{ deep: true }
)
onMounted(() => {
//
if (state.value.setMessageReadInterval) {
clearInterval(state.value.setMessageReadInterval)
state.value.setMessageReadInterval = null
}
state.value.setMessageReadInterval = setInterval(() => {
checkVisibleElements()
}, 2000)
if (state.value.setOutMessageReadInterval) {
clearInterval(state.value.setOutMessageReadInterval)
state.value.setOutMessageReadInterval = null
}
// socket
// state.value.setOutMessageReadInterval = setInterval(() => {
// checkVisibleOutElements()
// }, 2000)
//
const options = {
root: null,
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
rootMargin: '50px 0px'
}
observer = new IntersectionObserver(handleIntersection, options)
//
nextTick(() => {
const messageElements = document.querySelectorAll('.message-item')
messageElements.forEach((el) => {
if (observer) {
observer.observe(el)
}
})
})
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
if (state.value.setMessageReadInterval) {
clearInterval(state.value.setMessageReadInterval)
state.value.setMessageReadInterval = null
checkVisibleElements()
}
if (state.value.setOutMessageReadInterval) {
clearInterval(state.value.setOutMessageReadInterval)
state.value.setOutMessageReadInterval = null
checkVisibleOutElements()
}
})
</script> </script>
<template> <template>
@ -354,6 +691,11 @@ const onContextMenuAvatar=(e:any,item:any)=>{
v-for="(item, index) in records" v-for="(item, index) in records"
:key="item.msg_id" :key="item.msg_id"
:id="item.msg_id" :id="item.msg_id"
:data-msgid="item.msg_id"
:data-msgtype="item.msg_type"
:data-userid="item.user_id"
:data-talktype="props?.talk_type"
:data-receiverid="props?.receiver_id"
> >
<!-- 系统消息 --> <!-- 系统消息 -->
<div v-if="item.msg_type >= 1000" class="message-box"> <div v-if="item.msg_type >= 1000" class="message-box">
@ -387,7 +729,11 @@ const onContextMenuAvatar=(e:any,item:any)=>{
> >
<!-- 多选按钮 --> <!-- 多选按钮 -->
<aside v-if="dialogueStore.isOpenMultiSelect" class="checkbox-column shrink-0"> <aside v-if="dialogueStore.isOpenMultiSelect" class="checkbox-column shrink-0">
<n-checkbox size="small" :checked="item.isCheck" @update:checked="item.isCheck = !item.isCheck" /> <n-checkbox
size="small"
:checked="item.isCheck"
@update:checked="item.isCheck = !item.isCheck"
/>
</aside> </aside>
<!-- 头像信息 --> <!-- 头像信息 -->
@ -415,9 +761,7 @@ const onContextMenuAvatar=(e:any,item:any)=>{
<span>{{ parseTime(item.created_at, '{y}/{m}/{d} {h}:{i}') }}</span> <span>{{ parseTime(item.created_at, '{y}/{m}/{d} {h}:{i}') }}</span>
</div> --> </div> -->
<div class="talk-title"> <div class="talk-title">
<span class="mr-7px" <span class="mr-7px" v-show="talk_type == 2 && item.float == 'left'"
v-show="talk_type == 2 && item.float == 'left'"
>{{ item.nickname }} >{{ item.nickname }}
</span> </span>
<span>{{ parseTime(item.created_at, '{y}/{m}/{d} {h}:{i}') }}</span> <span>{{ parseTime(item.created_at, '{y}/{m}/{d} {h}:{i}') }}</span>

View File

@ -60,11 +60,11 @@ const onSendMessage = (data = {}, callBack: any) => {
} }
ServePublishMessage(message) ServePublishMessage(message)
.then(({ code, message }) => { .then(({ code, message, msg }) => {
if (code == 200) { if (code == 200) {
callBack(true) callBack(true)
} else { } else {
window['$message'].warning(message) window['$message'].warning(message || msg)
} }
}) })
.catch(() => { .catch(() => {