新增:接收 websocket 消息

feature/im
安浩浩 2024-05-27 23:41:45 +08:00
parent 569b1b64bd
commit 063a61644a
11 changed files with 473 additions and 202 deletions

View File

@ -26,17 +26,22 @@ export interface ImMessageRespVO {
sequence: number // 序号
}
export interface pullParams {
sequence: number
size: number
}
// 发送消息
export const sendMessage = async (data: ImMessageSendReqVO) => {
export const sendMessage = async (data: ImMessageSendReqVO): Promise<ImMessageSendRespVO> => {
return await request.post({ url: `/im/message/send`, data })
}
// 消息列表-拉取大于 sequence 的消息列表
export const pullMessageList = async (params: { sequence: number; size: number }) => {
export const pullMessageList = async (params: pullParams): Promise<ImMessageRespVO[]> => {
return await request.get({ url: `/im/message/pull`, params })
}
// 消息列表-根据接收人和发送时间进行分页查询
export const getMessageList = async (params: any) => {
export const getMessageList = async (params: any): Promise<ImMessageRespVO[]> => {
return await request.get({ url: `/im/message/list`, params })
}

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { Dialog } from '@/components/Dialog'
import { shallowRef, defineAsyncComponent, DefineComponent } from 'vue'
import { webSocketStore } from '@/store/modules/webSocketStore'
//
const IMComponent = defineAsyncComponent(() => import('@/views/im/index.vue'))
@ -13,6 +14,17 @@ function openDialog() {
dialogVisible.value = true
currentComponent.value = IMComponent // IM
}
// WebSocket
const { status, data, initWebSocket } = webSocketStore()
/** 监听接收到的数据 */
const messageList = ref([] as { time: number; text: string }[]) //
// ========== =========
onMounted(() => {
// websocket
initWebSocket()
})
</script>
<template>

View File

@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
export interface ImMessageWebSocket {
id: number // 编号
conversationType: number // 会话类型
senderId: number // 发送人编号
senderNickname: string // 发送人昵称
senderAvatar: string // 发送人头像
receiverId: number // 接收人编号
contentType: number // 内容类型
content: string // 内容
sendTime: string // 发送时间
sequence: number // 序号
}
export const imMessageStore = defineStore({
id: 'imMessage',
state: () => ({
messages: [] as ImMessageWebSocket[]
}),
actions: {
addMessage(message: ImMessageWebSocket) {
this.messages.push(message)
}
}
})

View File

@ -0,0 +1,81 @@
import { defineStore } from 'pinia'
import { useWebSocket, WebSocketStatus } from '@vueuse/core'
import { getAccessToken } from '@/utils/auth'
import { imMessageStore, ImMessageWebSocket } from './imMessageStore'
// 从 imMessageStore 中导入 addMessage 方法
const { addMessage } = imMessageStore()
export const webSocketStore = defineStore({
id: 'websocket',
state: () => ({
data: ref(null) as any,
status: ref<WebSocketStatus> as any,
close: null as ((code?: number | undefined, reason?: string | undefined) => void) | null,
open: null as (() => void) | null,
send: null as
| ((data: string | ArrayBuffer | Blob, useBuffer?: boolean | undefined) => boolean)
| null,
pingInterval: null as NodeJS.Timeout | null
}),
actions: {
initWebSocket() {
const server =
(import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') +
'?token=' +
getAccessToken(),
{ data, status, close, open, send } = useWebSocket(server, {
autoReconnect: true,
heartbeat: true,
onMessage(ws, event) {
// ws 状态不是 open 时不处理
if (ws.readyState !== WebSocket.OPEN) return
try {
// 1. 收到心跳
if (data.value === 'pong') {
console.log('websocket 收到心跳包')
return
}
console.log('websocket 收到消息', event.data)
// 2.1 解析 type 消息类型
const jsonMessage = JSON.parse(data.value)
const type = jsonMessage.type
const content = JSON.parse(jsonMessage.content)
if (!type) {
// message.error('未知的消息类型:' + data.value)
return
}
// 2.2 消息类型demo-message-receive
if (type === 'im-message-receive') {
const message: ImMessageWebSocket = {
id: content.id,
conversationType: content.conversationType,
senderId: content.senderId,
senderNickname: content.senderNickname,
senderAvatar: content.senderAvatar,
receiverId: content.receiverId,
contentType: content.contentType,
content: content.content,
sendTime: content.sendTime,
sequence: content.sequence
}
// 保存到 pina imMessageStore
addMessage(message)
return
}
} catch (error) {
console.error(error)
}
}
})
this.status = status
this.data = data
this.send = send
this.close = close
this.open = open
console.log('websocket 初始化成功')
}
}
})

View File

@ -0,0 +1,150 @@
<script setup>
import { useStore } from 'vuex'
import router from '@/router'
import { useRoute } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { messageType } from '@/constant'
/* 单人头像 */
import defaultSingleAvatar from '@/assets/imgs/im/avatar/theme2x.png'
/* store */
const store = useStore()
/* route */
const route = useRoute()
const { CHAT_TYPE } = messageType
/* 进入会话 */
const toChatMessage = () => {
console.log('>>>>>>>...route.query')
router.push({
path: '/chat/conversation/message',
query: {
id: route.query.id,
chatType: route.query.chatType
}
})
}
</script>
<template>
<div class="app_container">
<el-header class="contactInfo_header">
<el-page-header style="margin-top: 12px" :icon="ArrowLeft" @click="$router.back(-1)" />
<el-divider />
</el-header>
<el-main class="contactInfo_main">
<div class="contactInfo_main_card">
<div class="contactInfo_box">
<div class="avatar">
<el-avatar
class="avatar_img"
:src="nowContactInfo.avatarurl ? nowContactInfo.avatarurl : defaultSingleAvatar"
/>
</div>
<div class="name">
<p>
{{
nowContactInfo.nickname
? `${nowContactInfo.nickname}(${nowContactInfo.hxId})`
: nowContactInfo.hxId
}}
</p>
</div>
</div>
<div class="contaactInfo_btn">
<el-button type="primary" size="large" @click="toChatMessage"> </el-button>
</div>
</div>
</el-main>
</div>
</template>
<style lang="scss" scoped>
.app_container {
background: #f1f2f4;
height: 100%;
border-radius: 0 5px 5px 0;
overflow: hidden;
.contactInfo_header {
display: flex;
flex-direction: column;
height: 60px;
line-height: 60px;
}
.contactInfo_main {
height: 100%;
.contactInfo_main_card {
width: 100%;
height: 90%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 0 auto;
border-radius: 5px;
transition: all 0.5s;
&:hover {
background: #fff;
box-shadow: 12px 12px 2px 1px rgba(125, 125, 126, 0.068);
}
.contactInfo_box {
width: 80%;
min-height: 500px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
.avatar > .avatar_img {
width: 80px;
height: 80px;
}
.name {
margin-top: 15px;
font-size: 22px;
}
.func_box {
width: 100%;
.single_func {
height: 100px;
// background: #000;
margin-top: 25px;
cursor: pointer;
.add_black_list {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
font-size: 16px;
}
.del_friend {
width: 100%;
color: red;
transition: all 0.3s;
}
}
}
}
.contaactInfo_btn {
width: 80%;
text-align: center;
}
}
}
}
//线
::v-deep .el-page-header__left::after {
width: 0px !important;
}
</style>

View File

@ -0,0 +1,116 @@
<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'
// import router from '@/router'
import { messageType } from '@/constant'
/* 默认头像 */
import defaultAvatar from '@/assets/images/avatar/theme2x.png'
/* store */
const store = useStore()
//friendList
const classifyFriendList = computed(() => store.getters.sortedFriendList)
//
const { CHAT_TYPE } = messageType
</script>
<template>
<div class="friendItem_container">
<div
v-for="(friendName, friendItemKey) in classifyFriendList"
:key="friendItemKey"
>
<div class="friend_main">
<div class="friend_title">
<p>
{{
friendItemKey === ' '
? '#'
: friendItemKey.toUpperCase()
}}
</p>
<el-divider style="margin: 0" />
</div>
<el-row>
<el-col
class="friendItem_box"
:span="24"
v-for="item in friendName"
:key="item.hxId"
@click="
$emit('toContacts', {
id: item.hxId,
chatType: CHAT_TYPE.SINGLE
})
"
>
<el-avatar
style="margin-right: 11px"
:size="33.03"
:src="
item.avatarurl ? item.avatarurl : defaultAvatar
"
>
</el-avatar>
<span class="friend_name">
{{ item.nickname ? item.nickname : item.hxId }}
</span>
</el-col>
</el-row>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.friendItem_box {
height: 66px;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
// background: #000;
padding: 0 22px;
background: #efefef;
font-weight: 500;
font-size: 14px;
line-height: 20px;
/* identical to box height */
text-align: center;
color: #333333;
cursor: pointer;
.friend_name {
display: inline-block;
text-align: left;
width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
background: #dcdcdc;
}
}
.friend_main {
width: 100%;
.friend_title {
width: 100%;
height: 32px;
mix-blend-mode: normal;
opacity: 0.21;
font-size: 14px;
padding-left: 23px;
font-weight: 400;
font-size: 12px;
line-height: 32px;
letter-spacing: 0.342857px;
}
}
</style>

View File

@ -1,5 +1,30 @@
<script setup lang="ts"></script>
<template>
<el-container style="height: 100%">
<el-aside class="contacts_box">
<SearchInput :searchType="'contacts'" />
<el-scrollbar class="contacts_collapse" tag="div" :always="false">
<!-- 联系人群组列表 -->
<el-collapse />
</el-scrollbar>
</el-aside>
<el-main class="contacts_infors_main_box" />
</el-container>
</template>
<script lang="ts" setup>
/* 相关组件 */
import { SearchInput } from '@/components/Im/SearchInput'
import * as UserApi from '@/api/system/user'
<template></template>
<style scoped lang="scss"></style>
//
const friendList = computed(() => {})
const getUserList = async () => {
const data = await UserApi.getSimpleUserList()
console.log('data', data)
friendList.value = data
}
/** 初始化 */
onMounted(() => {
getUserList()
})
</script>
<style lang="scss" scoped></style>

View File

@ -116,9 +116,9 @@ const sendTextMessage = _.debounce(async () => {
messageQuoteRef.value?.clearQuoteContent()
try {
console.log('imMessageSendReqVO', imMessageSendReqVO)
await MessageApi.sendMessage(imMessageSendReqVO)
const imMessageSendRespVO = await MessageApi.sendMessage(imMessageSendReqVO)
console.log('>>>>>发送成功', imMessageSendRespVO)
} catch (error) {
//handleSDKErrorNotifi(error.type, error.message)
console.log('>>>>>>>发送失败+++++++', error)
} finally {
isAtAll.value = false

View File

@ -25,21 +25,20 @@ const props = defineProps({
})
const { nowPickInfo } = toRefs(props)
const { messageData } = toRefs(props)
console.log('>>>>>messageData', messageData)
/* 处理时间显示间隔 */
const handleMsgTimeShow = (time, index) => {
console.log('>>>>>时间显示', time, index)
const handleMsgTimeShow = (sendTime, index) => {
const msgList = Array.from(messageData.value)
if (index !== 0) {
const lastTime = msgList[index - 1].time
console.log('>>>>>时间间隔', time - lastTime, time, lastTime)
return time - lastTime > 50000 ? formatDate(time, 'MM/DD/HH:mm') : ''
const lastTime = msgList[index - 1].sendTime
return sendTime - lastTime > 50000 ? formatDate(sendTime, 'MM/DD/HH:mm') : ''
} else {
return formatDate(time, 'MM/DD/HH:mm')
return formatDate(sendTime, 'MM/DD/HH:mm')
}
}
/* computed-- 消息来源是否为自己 */
const isMyself = (msgBody) => {
return msgBody.from === '1'
return msgBody.senderId === userStore.user.id || msgBody.receiverId === userStore.user.id
}
/* 文本中是否包含link */
const isLink = computed(() => {
@ -51,15 +50,11 @@ const isLink = computed(() => {
const loginUserInfo = {
avatarurl: avatar.value
}
/* 获取他人的用户信息 */
const otherUserInfo = (from) => {
return {
avatarurl: 'https://avatars.githubusercontent.com/u/2?v=4'
}
}
//
const handleNickName = (from) => {
return from === '1' ? '我' : '对方'
const handleNickName = (msgBody) => {
return msgBody.senderId === userStore.user.id || msgBody.receiverId === userStore.user.id
? '我'
: '对方'
}
//
let clickQuoteMsgId = ref('')
@ -102,121 +97,40 @@ const startplayAudio = (msgBody) => {
>
<!-- 普通消息气泡 -->
<div
v-if="!msgBody.isRecall && msgBody.type !== ALL_MESSAGE_TYPE.OA_NOTIFICATION"
v-if="msgBody.conversationType !== ALL_MESSAGE_TYPE.OA_NOTIFICATION"
class="message_box_item"
:style="{
flexDirection: isMyself(msgBody) ? 'row-reverse' : 'row'
}"
>
<div class="message_item_time">
{{ handleMsgTimeShow(msgBody.time, index) }}
{{ handleMsgTimeShow(msgBody.sendTime, index) }}
</div>
<el-avatar
class="message_item_avator"
:src="
isMyself(msgBody)
? loginUserInfo.avatarurl
: otherUserInfo(msgBody.from).avatarurl || defaultAvatar
"
/>
<el-avatar class="message_item_avator" :src="loginUserInfo.avatarurl" />
<!-- 普通消息内容 -->
<div class="message_box_card">
<span v-show="!isMyself(msgBody)" class="message_box_nickname">{{
handleNickName(msgBody.from)
handleNickName(msgBody)
}}</span>
<el-dropdown
class="message_box_content"
:class="[
isMyself(msgBody) ? 'message_box_content_mine' : 'message_box_content_other',
clickQuoteMsgId === msgBody.id && 'quote_msg_avtive'
]"
:class="[isMyself(msgBody) ? 'message_box_content_mine' : 'message_box_content_other']"
trigger="contextmenu"
placement="bottom-end"
>
<!-- 文本类型消息 -->
<p
style="padding: 10px; line-height: 20px"
v-if="msgBody.type === ALL_MESSAGE_TYPE.TEXT"
v-if="msgBody.contentType === ALL_MESSAGE_TYPE.TEXT"
>
<template v-if="!isLink(msgBody.msg)">
{{ msgBody.msg }}
<template v-if="!isLink(msgBody.content)">
{{ msgBody.content }}
</template>
<template v-else> <span v-html="paseLink(msgBody.msg).msg"> </span></template>
<template v-else> <span v-html="paseLink(msgBody.content).msg"> </span></template>
</p>
<!-- 图片类型消息 -->
<!-- <div> -->
<el-image
v-if="msgBody.type === ALL_MESSAGE_TYPE.IMAGE"
style="border-radius: 5px"
:src="msgBody.thumb"
:preview-src-list="[msgBody.url]"
:initial-index="1"
fit="cover"
/>
<!-- </div> -->
<!-- 语音类型消息 -->
<div
:class="[
'message_box_content_audio',
isMyself(msgBody)
? 'message_box_content_audio_mine'
: 'message_box_content_audio_other'
]"
v-if="msgBody.type === ALL_MESSAGE_TYPE.AUDIO"
@click="startplayAudio(msgBody)"
:style="`width:${msgBody.length * 10}px`"
>
<span class="audio_length_text"> {{ msgBody.length }} </span>
<div
:class="[
isMyself(msgBody) ? 'play_audio_icon_mine' : 'play_audio_icon_other',
audioPlayStatus.playMsgId === msgBody.id && 'start_play_audio'
]"
style="background-size: 100% 100%"
></div>
</div>
<div v-if="msgBody.type === ALL_MESSAGE_TYPE.LOCAL">
<p style="padding: 10px">[暂不支持位置消息展示]</p>
</div>
<!-- 文件类型消息 -->
<div v-if="msgBody.type === ALL_MESSAGE_TYPE.FILE" class="message_box_content_file">
<div class="file_text_box">
<div class="file_name">
{{ msgBody.filename }}
</div>
<div class="file_size">
{{ fileSizeFormat(msgBody.file_length) }}
</div>
<a class="file_download" :href="msgBody.url" download>点击下载</a>
</div>
<span class="iconfont icon-wenjian"></span>
</div>
<!-- 自定义类型消息 -->
<div v-if="msgBody.type === ALL_MESSAGE_TYPE.CUSTOM" class="message_box_content_custom">
<template v-if="msgBody.customEvent && CUSTOM_TYPE[msgBody.customEvent]">
<div class="user_card">
<div class="user_card_main">
<!-- 头像 -->
<el-avatar
shape="circle"
:size="50"
:src="
(msgBody.customExts && msgBody.customExts.avatarurl) ||
msgBody.customExts.avatar ||
defaultAvatar
"
fit="cover"
/>
<!-- 昵称 -->
<span class="nickname">{{
(msgBody.customExts && msgBody.customExts.nickname) || msgBody.customExts.uid
}}</span>
</div>
<el-divider style="margin: 5px 0; border-top: 1px solid black" />
<p style="font-size: 8px">个人名片</p>
</div>
</template>
</div>
</el-dropdown>
</div>
</div>

View File

@ -5,6 +5,7 @@ import { messageType } from '@/constant/im'
import MessageList from './components/messageList/index.vue'
import InputBox from './components/inputBox/index.vue'
import * as MessageApi from '@/api/im/message'
import { ImMessageRespVO } from '@/api/im/message'
const { query } = useRoute() //
@ -25,10 +26,10 @@ const delTheFriend = () => {
}
//
const nowPickInfo = ref({
id: 1,
id: 100,
chatType: CHAT_TYPE.SINGLE,
userInfo: {
nickname: '芋道源码',
nickname: '芋道',
userStatus: '1'
},
groupDetail: {
@ -42,78 +43,16 @@ const nowPickInfo = ref({
const groupDetail = computed(() => {
return nowPickInfo.value.groupDetail
})
//id
const messageData = computed(() => [
{
id: 1,
type: ALL_MESSAGE_TYPE.TEXT,
isRecall: false,
time: '1711944110000',
from: '1',
msg: 'Hello, world!111',
modifiedInfo: {
operationCount: 1
},
customExts: {
nickname: '芋道源码',
avatar: 'https://avatars.githubusercontent.com/u/2?v=4'
},
customEvent: {
type: '1',
data: {
type: '1',
data: 'https://avatars.githubusercontent.com/u/2?v=4'
}
},
file_length: 0
},
{
id: 2,
type: ALL_MESSAGE_TYPE.TEXT,
isRecall: false,
time: '1711944221000',
from: '2',
msg: 'Hi, there!222',
modifiedInfo: {
operationCount: 0
},
customExts: {
nickname: '芋道源码',
avatar: 'https://avatars.githubusercontent.com/u/2?v=4'
},
customEvent: {
type: '1',
data: {
type: '1',
data: 'https://avatars.githubusercontent.com/u/2?v=4'
}
},
file_length: 0
},
{
id: 3,
type: ALL_MESSAGE_TYPE.TEXT,
isRecall: false,
time: '1711944332000',
from: '1',
msg: 'Hello, world!333',
modifiedInfo: {
operationCount: 0
},
customExts: {
nickname: '芋道源码',
avatar: 'https://avatars.githubusercontent.com/u/2?v=4'
},
customEvent: {
type: '1',
data: {
type: '1',
data: 'https://avatars.githubusercontent.com/u/2?v=4'
}
},
file_length: 0
}
])
//
const pullParams = reactive({
sequence: 0,
size: 100
})
const messageData = ref([])
const getMessageData = async () => {
messageData.value = await MessageApi.pullMessageList(pullParams)
}
console.log(messageData)
/* 消息相关 */
const loadingHistoryMsg = ref(false) //
@ -124,6 +63,7 @@ const fechHistoryMessage = (loadType) => {
console.log(loadType)
console.log('加载更多')
loadingHistoryMsg.value = true
getMessageData()
setTimeout(() => {
loadingHistoryMsg.value = false
}, 1000)
@ -137,6 +77,10 @@ const inputBox = ref(null)
const reEditMessage = (msg) => (inputBox.value.textContent = msg)
//
const messageQuote = (msg) => inputBox.value.handleQuoteMessage(msg)
/** 初始化 **/
onMounted(() => {
getMessageData()
})
</script>
<template>

View File

@ -1,17 +1,3 @@
<script lang="ts" setup>
import { shallowRef, defineAsyncComponent, DefineComponent } from 'vue'
import NavBar from './NavBar/index.vue'
//
const ConversationComponent = defineAsyncComponent(
() => import('@/views/im/Conversation/index.vue')
)
const currentComponent = shallowRef<DefineComponent | null>(ConversationComponent) //
defineOptions({ name: 'IM' })
</script>
<template>
<div class="app-container">
<el-container class="chat_container">
@ -24,7 +10,19 @@ defineOptions({ name: 'IM' })
</el-container>
</div>
</template>
<script lang="ts" setup>
import { shallowRef, defineAsyncComponent, DefineComponent } from 'vue'
import NavBar from './NavBar/index.vue'
defineOptions({ name: 'IM' })
//
const conversationComponent = defineAsyncComponent(
() => import('@/views/im/Conversation/index.vue')
)
const currentComponent = shallowRef<DefineComponent | null>(conversationComponent) //
</script>
<style lang="scss" scoped>
.app-container {
position: fixed;