实现消息转发

This commit is contained in:
bob
2025-05-07 12:21:42 +08:00
parent 5763438082
commit 98b66780c3
7 changed files with 620 additions and 12 deletions

View File

@@ -17,6 +17,8 @@ const avatarSize = computed(() => {
return 50
case 'small':
return 30
case 'tiny':
return 24
case 'default':
default:
return 40
@@ -31,6 +33,8 @@ const svgSize = computed(() => {
return 30
case 'small':
return 18
case 'tiny':
return 16
case 'default':
default:
return 24

View File

@@ -0,0 +1,298 @@
<script setup>
import { ref, computed } from 'vue'
import { Search, Close } from '@element-plus/icons-vue'
import SessionTitleItem from '@/components/item/SessionTitleItem.vue'
import HashNoData from '@/components/common/HasNoData.vue'
import { userQueryService, userQueryByNickService } from '@/api/user'
import { combineId, smartMatch } from '@/js/utils/common'
import { useUserStore, useMessageStore } from '@/stores'
import { MsgType } from '@/proto/msg'
const props = defineProps(['isShow', 'sessionListSortedKey'])
const emit = defineEmits(['update:isShow', 'showUserCard', 'showGroupCard', 'confirm'])
const userData = useUserStore()
const messageData = useMessageStore()
const selected = ref([])
const myAccount = computed(() => {
return userData.user.account
})
const searchKey = ref('')
const optionsFromServer = ref({})
const optionsAll = computed(() => {
return {
...messageData.sessionList,
...optionsFromServer.value
}
})
const optionKeys = computed(() => {
const allKeys = [...props.sessionListSortedKey, ...Object.keys(optionsFromServer.value)]
if (!searchKey.value) {
return allKeys
} else {
const data = []
allKeys.forEach((key) => {
const item = optionsAll.value[key]
if (
item.sessionType === MsgType.CHAT &&
(item.objectInfo.account === searchKey.value ||
smartMatch(item.objectInfo.nickName, searchKey.value) ||
smartMatch(item.mark, searchKey.value))
) {
data.push(key)
} else if (
item.sessionType === MsgType.GROUP_CHAT &&
(item.objectInfo.groupId === searchKey.value ||
smartMatch(item.objectInfo.groupName, searchKey.value) ||
smartMatch(item.mark, searchKey.value))
) {
data.push(key)
}
})
return data
}
})
let timer
const onQuery = () => {
if (!searchKey.value) return
clearTimeout(timer)
const key = searchKey.value //在异步执行中,变量禁止使用响应式,因为在将来执行的时候响应式数据随时会发生改变
timer = setTimeout(async () => {
userQueryByNickService({ keyWords: key }).then((res) => {
res.data.data?.forEach((item) => {
const sessionId = combineId(myAccount.value, item.account)
if (!messageData.sessionList[sessionId]) {
// 这里先不create Session点击确认转发才create Session
optionsFromServer.value[sessionId] = {}
optionsFromServer.value[sessionId].sessionId = sessionId
optionsFromServer.value[sessionId].sessionType = MsgType.CHAT
optionsFromServer.value[sessionId].mark = ''
optionsFromServer.value[sessionId].objectInfo = item
}
})
})
const sessionId = combineId(myAccount.value, key)
if (!messageData.sessionList[sessionId]) {
userQueryService({ account: key }).then((res) => {
if (res.data.data) {
// 这里先不create Session点击确认转发才create Session
optionsFromServer.value[sessionId] = {}
optionsFromServer.value[sessionId].sessionId = sessionId
optionsFromServer.value[sessionId].sessionType = MsgType.CHAT
optionsFromServer.value[sessionId].mark = ''
optionsFromServer.value[sessionId].objectInfo = res.data.data
}
})
}
}, 300)
}
const onShowUserCard = (account) => {
emit('showUserCard', { account })
}
const onShowGroupCard = (groupId) => {
emit('showGroupCard', { groupId })
}
const onConfirm = () => {
const data = []
selected.value.forEach((account) => {
data.push(optionsAll.value[account])
})
emit('confirm', data)
}
const onOpen = () => {
searchKey.value = ''
}
const onClose = () => {
emit('update:isShow', false)
selected.value = []
optionsFromServer.value = {}
}
const onCancle = () => {
emit('update:isShow', false)
}
const onClearSelected = () => {
selected.value = []
}
const onRemoveSelectedItem = (index) => {
selected.value.splice(index, 1)
}
</script>
<template>
<el-dialog
class="select-dialog"
:model-value="props.isShow"
:modal="false"
:top="'30vh'"
:width="'610px'"
:z-index="1"
style="border-radius: 10px"
@open="onOpen"
@close="onClose"
>
<template #header>
<slot name="title"></slot>
</template>
<div class="main bdr-t bdr-b bdr-l bdr-r">
<div class="left bdr-r">
<el-input
v-model.trim="searchKey"
placeholder="搜索:昵称/账号/备注/群名称"
:prefix-icon="Search"
:clearable="true"
@input="onQuery"
/>
<div v-if="optionKeys.length > 0" class="my-scrollbar" style="flex: 1; overflow-y: scroll">
<el-checkbox-group v-model="selected">
<el-checkbox v-for="item in optionKeys" :key="item" :value="item">
<SessionTitleItem
:session="optionsAll[item]"
:keyWords="searchKey"
@showUserCard="onShowUserCard"
@showGroupCard="onShowGroupCard"
></SessionTitleItem>
</el-checkbox>
</el-checkbox-group>
</div>
<HashNoData v-else></HashNoData>
</div>
<div class="right">
<div class="head bdr-b">
<div style="font-size: 13px; color: gray">
{{ `已选择:${selected.length} 个会话` }}
</div>
<el-button type="info" size="small" @click="onClearSelected" plain>清空</el-button>
</div>
<div v-if="selected.length > 0" class="my-scrollbar" style="flex: 1; overflow-y: scroll">
<div class="selected-item" v-for="(item, index) in selected" :key="index">
<SessionTitleItem
:session="optionsAll[item]"
@showUserCard="onShowUserCard"
@showGroupCard="onShowGroupCard"
></SessionTitleItem>
<el-button :icon="Close" size="small" circle @click="onRemoveSelectedItem(index)" />
</div>
</div>
<HashNoData v-else></HashNoData>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="info" @click="onCancle" plain>取消</el-button>
<el-button type="primary" @click="onConfirm" plain>确认</el-button>
</div>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.main {
height: 360px;
margin: 10px 0 10px 0;
display: flex;
flex-direction: row;
.left {
width: 49%;
padding: 10px;
display: flex;
flex-direction: column;
overflow: hidden;
.head {
display: flex;
align-items: center;
}
.el-checkbox-group {
display: flex;
flex-direction: column;
.el-checkbox {
height: 45px;
margin: 0 2px 2px 0;
padding: 0 10px 0 10px;
border-radius: 8px;
color: black;
&:hover {
background-color: #dedfe0;
}
}
.is-checked {
background-color: #dedfe0;
}
}
}
.right {
padding: 10px;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.head {
height: 30px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.selected-item {
height: 45px;
margin: 0 0 2px 0;
padding: 0 10px 0 10px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
color: black;
--close-button-color: transparent;
&:hover {
background: #dedfe0;
--close-button-color: auto;
}
.el-button {
border: none;
color: var(--close-button-color);
background-color: var(--close-button-background-color);
&:hover {
--close-button-background-color: #f0f0f0;
}
}
}
}
}
.el-input {
width: 100%;
height: 30px;
margin-bottom: 10px;
:deep(.el-input__wrapper) {
border-radius: 25px;
}
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup>
import { computed } from 'vue'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import GroupAvatarIcon from '@/components/common/GroupAvatarIcon.vue'
import { MsgType } from '@/proto/msg'
import { useGroupStore } from '@/stores'
import { highLightedText } from '@/js/utils/common'
/**
* objectInfo对象详情
* keyWords搜索关键字用于高亮显示检索的关键字
*/
const props = defineProps(['session', 'keyWords'])
const emit = defineEmits(['showUserCard', 'showGroupCard'])
const groupData = useGroupStore()
const showName = computed(() => {
let name = ''
if (props.session.sessionType === MsgType.CHAT) {
name = props.session.objectInfo.nickName
} else if (props.session.sessionType === MsgType.GROUP_CHAT) {
name = props.session.objectInfo.groupName
} else {
return ''
}
return props.session.mark ? `${props.session.mark}(${name})` : name
})
const showId = computed(() => {
if (props.session.sessionType === MsgType.CHAT) {
return props.session.objectInfo.account
} else if (props.session.sessionType === MsgType.GROUP_CHAT) {
return props.session.objectInfo.groupId
} else {
return ''
}
})
const onShowUserCard = (e) => {
e.preventDefault()
emit('showUserCard', showId.value)
}
const onShowGroupCard = (e) => {
e.preventDefault()
emit('showGroupCard', showId.value)
}
</script>
<template>
<div class="session-wrapper">
<div v-if="props.session.sessionType === MsgType.CHAT" class="user-session">
<UserAvatarIcon
class="user-session-avatar"
:showName="showName"
:showId="showId"
:showAvatarThumb="props.session.objectInfo.avatarThumb"
:size="'small'"
@click="onShowUserCard"
></UserAvatarIcon>
<div class="user-session-info">
<span
class="name text-ellipsis"
:title="showName"
v-html="highLightedText(showName, props.keyWords, '#409eff')"
>
</span>
<span
class="id"
:title="showId"
v-html="highLightedText(showId, props.keyWords, '#409eff', 'full')"
>
</span>
</div>
</div>
<div v-else-if="props.session.sessionType === MsgType.GROUP_CHAT" class="group-session">
<GroupAvatarIcon
class="group-session-avatar"
:avatarThumb="groupData.groupInfoList[props.session.objectInfo.groupId].avatarThumb"
:size="'small'"
@click="onShowGroupCard"
></GroupAvatarIcon>
<div class="group-session-info">
<span
class="name text-ellipsis"
:title="showName"
v-html="highLightedText(showName, props.keyWords, '#409eff')"
>
</span>
<span
class="id"
:title="showId"
v-html="highLightedText(showId, props.keyWords, '#409eff', 'full')"
>
</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.session-wrapper {
padding: 2px 0 2px 5px;
.user-session {
display: flex;
gap: 5px;
.user-session-info {
max-width: 165px;
display: flex;
align-items: center;
gap: 5px;
user-select: text;
.id {
font-size: 12px;
}
}
}
.group-session {
display: flex;
gap: 5px;
.group-session-info {
max-width: 165px;
display: flex;
flex-direction: column;
gap: 4px;
user-select: text;
.id {
font-size: 12px;
}
}
}
}
</style>

View File

@@ -19,6 +19,7 @@ import InputEditor from '@/views/message/components/InputEditor.vue'
import MessageItem from '@/views/message/components/MessageItem.vue'
import SessionTag from '@/views/message/components/SessionTag.vue'
import SelectUserDialog from '@/components/common/SelectUserDialog.vue'
import SelectSessionDialog from '@/components/common/SelectSessionDialog.vue'
import {
useUserStore,
useSettingStore,
@@ -522,7 +523,7 @@ const handleSelectedSession = async (sessionId) => {
// 如果是群组要加载成员列表显示消息需要accountnickNameavatar信息
if (selectedSession.value.sessionType === MsgType.GROUP_CHAT) {
// 没有members数据才需要加载成员列表加载过了就不重复加载了
if (!groupMembers.value) {
if (!groupMembers.value && !isNotInGroup.value) {
const res = await groupInfoService({ groupId: selectedSession.value.remoteId })
groupData.setGroupInfo({
groupId: selectedSession.value.remoteId,
@@ -570,6 +571,92 @@ const sendRead = () => {
}
}
/**
* 处理发送转发的消息
*/
const handleSendForwardMsg = async ({ session, content }) => {
if (session.sessionType === MsgType.GROUP_CHAT && session.leave) {
ElMessage.warning('您已离开该群或群已被解散')
return
}
if (session.sessionType === MsgType.GROUP_CHAT) {
if (!groupData.groupMembersList[session.remoteId]) {
const res = await groupInfoService({ groupId: session.remoteId })
groupData.setGroupInfo({
groupId: session.remoteId,
groupInfo: res.data.data.groupInfo || {}
})
groupData.setGroupMembers({
groupId: session.remoteId,
members: res.data.data.members || {}
})
}
const meInGroup = groupData.groupMembersList[session.remoteId][myAccount.value]
if (
meInGroup.mutedMode === 1 ||
(groupData.groupInfoList[session.remoteId].allMuted && meInGroup.mutedMode !== 2)
) {
ElMessage.warning('您已被禁言,请联系管理员')
return
}
}
const seq = uuidv4()
const msg = {
msgId: seq,
seq: seq,
sessionId: session.sessionId,
fromId: myAccount.value,
remoteId: session.remoteId,
msgType: session.sessionType,
content: content,
status: msgSendStatus.PENDING,
msgTime: new Date(),
sendTime: new Date()
}
messageData.addMsgRecords(msg.sessionId, [msg])
messageData.updateMsgKeySort(msg.sessionId)
if (selectedSessionId.value === msg.sessionId) {
capacity.value++
msgListReachBottom()
}
const resendInterval = 2000 //2秒
const before = (data) => {
setTimeout(() => {
if (msg.status === msgSendStatus.PENDING) {
wsConnect.sendAgent(data)
setTimeout(() => {
if (msg.status === msgSendStatus.PENDING) {
wsConnect.sendAgent(data)
setTimeout(() => {
if (msg.status === msgSendStatus.PENDING) {
wsConnect.sendAgent(data)
setTimeout(() => {
if (msg.status === msgSendStatus.PENDING) {
messageData.updateMsg(msg.sessionId, msg.msgId, {
status: msgSendStatus.FAILED
})
ElMessage.error('消息发送失败')
}
}, resendInterval)
}
}, resendInterval)
}
}, resendInterval)
}
}, resendInterval)
}
const after = (msgId) => {
messageData.updateMsg(msg.sessionId, msg.msgId, { msgId, status: msgSendStatus.OK })
}
wsConnect.sendMsg(msg.sessionId, msg.remoteId, msg.msgType, msg.content, msg.seq, before, after)
}
/**
* 发送时先添加本地消息,可以立即渲染
*/
@@ -834,7 +921,7 @@ const onShowUserCard = ({ sessionId, account }) => {
})
}
if (messageData.sessionList[sessionId].sessionType === MsgType.GROUP_CHAT) {
if (sessionId && messageData.sessionList[sessionId].sessionType === MsgType.GROUP_CHAT) {
const groupId = selectedSession.value.remoteId
groupData.setOneOfGroupMembers({
groupId: groupId,
@@ -1066,6 +1153,65 @@ const onSelectOprMenu = (label) => {
}
}
const isShowForwardMsgDialog = ref(false)
let forwardMsg = {} // 待转发的消息
const sessionListSortedKey = computed(() => {
return sessionListSorted.value
.filter((item) => {
return !(item.sessionType === MsgType.GROUP_CHAT && item.leave)
})
.map((item) => item.sessionId)
})
const showForwardMsgDialog = (msgId) => {
forwardMsg = messageData.getMsg(selectedSessionId.value, msgId)
isShowForwardMsgDialog.value = true
}
const handleConfirmForwardMsg = async (sessions) => {
const loadingInstance = ElLoading.service(el_loading_options)
try {
for (const item of sessions) {
const sessionId = item.sessionId
const remoteId = item.remoteId
// 如果没有session先创建session
if (!messageData.sessionList[sessionId]) {
const res = await msgChatCreateSessionService({
sessionId: sessionId,
remoteId: remoteId,
sessionType: item.sessionType
})
messageData.addSession(res.data.data.session)
}
await handleSendForwardMsg({
session: item,
content: forwardMsg.content
.split(/(<.*?>)/)
.map((item) => {
const sliceStr = item.slice(1, -1)
const index = sliceStr.indexOf('-')
if (index !== -1) {
const nickName = sliceStr.slice(index + 1)
if (nickName) {
return `@${nickName}`
} else {
return item
}
}
return item
})
.join('')
})
}
} catch (error) {
console.error('forward msg error: ', error)
} finally {
loadingInstance.close()
isShowForwardMsgDialog.value = false
}
}
const showMenuAddOpr = (e) => {
addOprMenuRef.value.handleShowMenu(e)
}
@@ -1307,6 +1453,7 @@ const onShowRecorder = () => {
@resendMsg="handleResendMessage"
@loadFinished="updateScroll"
@showHighlight="handleShowHighlight"
@forwardMsg="showForwardMsgDialog"
></MessageItem>
</MenuMsgMain>
</div>
@@ -1432,6 +1579,17 @@ const onShowRecorder = () => {
<div style="font-size: 16px; font-weight: bold; white-space: nowrap">创建群组</div>
</template>
</SelectUserDialog>
<SelectSessionDialog
v-model:isShow="isShowForwardMsgDialog"
:sessionListSortedKey="sessionListSortedKey"
@showUserCard="onShowUserCard"
@showGroupCard="onShowGroupCard"
@confirm="handleConfirmForwardMsg"
>
<template #title>
<div style="font-size: 16px; font-weight: bold; white-space: nowrap">转发消息</div>
</template>
</SelectSessionDialog>
</template>
<style lang="scss" scoped>

View File

@@ -63,12 +63,6 @@ const menu = computed(() => {
}
if (msgStatus.value === msgSendStatus.OK) {
o.push({
label: 'forward',
desc: '转发',
icon: markRaw(ForwardIcon),
index: 1
})
o.push({
label: 'multiSelect',
desc: '多选',
@@ -83,6 +77,15 @@ const menu = computed(() => {
})
}
if (msgStatus.value === msgSendStatus.OK && contentType.value !== msgContentType.RECORDING) {
o.push({
label: 'forward',
desc: '转发',
icon: markRaw(ForwardIcon),
index: 1
})
}
if (
myAccount.value === props.msg.fromId &&
msgStatus.value === msgSendStatus.OK &&

View File

@@ -44,7 +44,8 @@ const emit = defineEmits([
'showGroupCard',
'resendMsg',
'loadFinished',
'showHighlight'
'showHighlight',
'forwardMsg'
])
const userData = useUserStore()
@@ -224,7 +225,7 @@ const renderAt = (content) => {
color: '#337ECC',
fontWeight: account === myAccount.value || account === '0' ? 'bold' : 'normal'
}
return h('span', { style }, `@${nickName}`)
return h('span', { style }, `@${nickName} `)
} else {
return h('span', `<${content}>`)
}
@@ -850,6 +851,9 @@ const onSelectMenuMsgItem = async (label) => {
msgTime: msg.value.msgTime
})
break
case 'forward':
emit('forwardMsg', props.msgKey)
break
default:
break
}

View File

@@ -236,7 +236,7 @@ const showDetailContent = computed(() => {
}
return item
})
.join(' ')
.join('')
}
return formatDraft
} else {
@@ -324,7 +324,7 @@ const showDetailContent = computed(() => {
}
return item
})
.join(' ')
.join('')
return getGroupChatMsgTips(content)
default:
return ''