LeanCloud 是领先的 Serverless 云服务,为产品开发提供强有力的后端支持,旨在帮助开发者降低研发、运营维护等阶段投入的精力和成本。 LeanCloud 整合了各项服务,让开发者能够聚焦在核心业务上,为客户创造更多价值。
*即时通讯
LeanCloud地址
效果图
安装
npm install leancloud-realtime leancloud-realtime-plugin-typed-messages leancloud-storage --save
简单封装ChatComponent.vue
<template>
<div class="chat">
<div class="userList">
<ul>
<li v-for="item in userList" :class="{ active: item === adverse }" :key="item" @click="selectUser(item)">{{ item }}</li>
</ul>
</div>
<div class="instant-messaging">
<h3>{{ adverse }}</h3>
<ul v-show="newMessage.length !== 0" ref="scrollContainer" id="scrollContainer" class="scroll-container">
<li v-for="item in newMessage" :key="item" class="message-item"
:style="`justify-content: ${item.from !== creator ? 'flex-start' : 'flex-end'};`">
<div v-if="item.from !== creator" class="message-content">
<div class="user">
<img :src="'https://img0.baidu.com/it/u=2226630510,461838410&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=521'"
alt="" />
</div>
<div class="message image" :style="'padding: 6px;'" v-if="item.content._lctype === -2">
<img :src="item.content._lcfile.url" alt="" />
</div>
<div class="message" v-if="item.content._lctype === -1">{{ item.content._lctext }}</div>
</div>
<div v-else class="message-content">
<div class="message image" :style="'padding: 6px;'" v-if="item.content._lctype === -2">
<img :src="item.content._lcfile.url" alt="" />
</div>
<div class="message" v-if="item.content._lctype === -1">{{ item.content._lctext }}</div>
<div class="user">
<img :src="'https://img1.baidu.com/it/u=3622150954,2575811681&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500'"
alt="" />
</div>
</div>
</li>
</ul>
<ul v-show="newMessage.length === 0">
<li>暂无消息</li>
</ul>
<div class="message-operation">
<!-- <el-button type="primary" @click="moreMessage">更多</el-button> -->
<el-upload v-model:file-list="fileList" ref="uploadRef" class="upload-demo" multiple :limit="3"
:auto-upload="false" :show-file-list="false" :accept="'image/*'">
<el-button :icon="Picture" />
</el-upload>
<el-input class="message-input" v-model="messageValue" @keyup.enter="sendMessage" style="width: 240px"
placeholder="" />
<el-button type="primary" @click="sendMessage">发送</el-button>
</div>
</div>
</div>
</template>
<script setup>
import * as IM from 'leancloud-realtime'
import AV from 'leancloud-storage'
import initPlugin from 'leancloud-realtime-plugin-typed-messages'
import { onMounted, ref, watch, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import axios from 'axios'
const props = defineProps({
// 自己的 id
ownId: {
type: String,
default: ''
},
// 对方的 id
adverseId: {
type: String,
default: ''
},
// 聊天室名称
chatsName: {
type: String,
default: ''
},
})
const { Realtime, TextMessage, Event } = IM
const { TypedMessagesPlugin, ImageMessage } = initPlugin(AV, IM)
let realtime;
const newMessage = ref([])
const creator = ref('')
const adverse = ref('')
const messageValue = ref('')
const fileList = ref([])
const uploadRef = ref(null)
const scrollContainer = ref(null)
// 监听文件上传
watch(
() => fileList.value.length,
() => {
console.log(fileList.value)
fileList.value.length !== 0 && sendMessage()
}
)
// 发送消息
const sendMessage = () => {
console.log(messageValue.value === '' && fileList.value.length === 0)
if (messageValue.value === '' && fileList.value.length === 0) {
ElMessage({
message: '不能发送空消息!',
type: 'warning'
})
return
}
/**
* 创建一个 IMClient 实例
*/
// 1、Tom 用自己的名字作为 clientId 来登录即时通讯服务
realtime
.createIMClient(props.ownId)
.then(function (own) {
// 成功登录
console.log('登录成功', own)
// 创建与 Jerry 之间的对话
own.createConversation({
// tom 是一个 IMClient 实例
// 指定对话的成员除了当前用户 Tom(SDK 会默认把当前用户当做对话成员)之外,还有 Jerry
members: [props.adverseId],
// 对话名称
name: props.chatsName,
unique: true
}).then(function (conversation) {
// 创建成功
console.log('创建成功', conversation)
// 发送一条文本消息
if (messageValue.value !== '') {
conversation
.send(new TextMessage(messageValue.value))
.then(function (message) {
console.log('发送成功!')
createUser_Jerry()
messageValue.value = ''
})
.catch(console.error)
}
if (fileList.value.length !== 0) {
var file = new AV.File('avatar.jpg', fileList.value[0].raw)
file
.save()
.then(function () {
var message = new ImageMessage(file)
message.setText('本地图片')
message.setAttributes({ location: '武汉' })
return conversation.send(message)
})
.then(function () {
console.log('发送成功')
createUser_Jerry()
fileList.value = []
})
.catch(console.error.bind(console))
}
})
})
.catch(console.error)
}
const createUser_Jerry = () => {
// Jerry 登录
realtime
.createIMClient(props.ownId)
.then(function (own) {
// 成功登录
console.log('登录成功', own)
creator.value = own.id
// 确保 Jerry 已加入对话
own.createConversation({
members: [props.adverseId],
name: props.chatsName,
unique: true
}).then(function (conversation) {
// 创建成功
console.log('创建成功', conversation)
adverse.value = conversation.members.find((item) => item !== conversation.creator)
// 当前用户被添加至某个对话
own.on(Event.INVITED, function invitedEventHandler(payload, conversation) {
console.log(payload.invitedBy, conversation.id)
})
// 当前用户收到了某一条消息,可以通过响应 Event.MESSAGE 这一事件来处理。
own.on(Event.MESSAGE, function (message, conversation) {
// newMessage.value.push(message.text)
console.log('收到新消息:' + message.text, conversation)
getChatRecord(conversation)
})
getChatRecord(conversation)
})
})
.catch(console.error)
}
let messageIterator
// 获取聊天记录
const getChatRecord = (conversation) => {
// conversation
// .queryMessages({
// limit: 10, // limit 取值范围 1~100,默认 20
// })
// .then(function (messages) {
// // 最新的十条消息,按时间增序排列
// newMessage.value = messages
// })
// .catch(console.error.bind(console));
// JS SDK 通过迭代器隐藏了翻页的实现细节,开发者通过不断的调用 next 方法即可获得后续数据。
// 创建一个迭代器,每次获取 10 条历史消息
messageIterator = conversation.createMessagesIterator({ limit: 50 })
// 第一次调用 next 方法,获得前 10 条消息,还有更多消息,done 为 false
messageIterator
.next()
.then(function (result) {
// result: {
// value: [message1, ..., message10],
// done: false,
// }
console.log(result)
newMessage.value = result.value
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
})
.catch(console.error.bind(console))
}
// 更多消息
const moreMessage = () => {
// 第二次调用 next 方法,获得第 11~20 条消息,还有更多消息,done 为 false
// 迭代器内部会记录起始消息的数据,无需开发者显示指定
messageIterator
.next()
.then(function (result) {
// result: {
// value: [message21, ..., message30],
// done: true,
// }
if (result.value.length === 0) {
ElMessage.warning('没有更多消息了')
return
}
// newMessage.value = [...result.value, ...newMessage.value]
newMessage.value = result.value.concat(newMessage.value)
})
.catch(console.error.bind(console))
}
let isScrollLocked = true
// 滚动事件
const handleScroll = () => {
if (!isScrollLocked && isNearTop(scrollContainer.value)) {
isScrollLocked = true // 锁定滚动条位置
const ulContainer = document.getElementById('scrollContainer')
console.log(ulContainer.scrollHeight)
setTimeout(() => {
moreMessage()
isScrollLocked = false
}, 1000)
} else {
setTimeout(() => {
isScrollLocked = false
}, 1000);
}
}
/* 节流 */
const throttle = (func, delay) => {
let time = null
return function () {
let args = Array.from(arguments)
if (time === null) {
time = setTimeout(() => {
func(...args)
clearTimeout(time)
time = null
}, delay)
}
}
}
// 是否到达顶部
const isNearTop = (scrollContainer) => {
const threshold = 50 // 可调整的阈值,距离底部多少像素时触发加载
return scrollContainer.scrollTop <= threshold
}
// 延迟一段时间后再获取滚动高度
const checkAndSetScrollTop = () => {
const ulContainer = document.getElementById('scrollContainer')
// 确保滚动高度不为零
if (ulContainer.scrollHeight !== 0) {
// 将滚动条设置到最底部
ulContainer.scrollTop = ulContainer.scrollHeight
} else {
// 如果滚动高度仍为0,再延迟几秒重新检查
setTimeout(checkAndSetScrollTop, 500) // 这里的延迟时间可以根据实际情况调整
}
}
const userList = ref([])
// 获取会话
const getConversation = () => {
axios({
method: 'get',
url: `/1.2/rtm/conversations?limit=10&where={"c": "${props.ownId}"}`,
headers: {
'X-LC-Id': 'NLblIbY4gEKenESSb7Q3vY9Y-gzGzoHsz',
'X-LC-Key': 'l1JvznaAgLvkGumBdiXDYQ6q,master',
'Content-Type': 'application/json'
}
})
.then(function (response) {
console.log(response.data);
userList.value = response.data.results.map(item => {
return item.m.find(v => v !== item.c)
})
console.log(userList.value);
});
}
const emit = defineEmits(['selectUser'])
// 选择用户
const selectUser = (user) => {
emit('selectUser', user)
}
// 监听props.adverseId
watch(() => props.adverseId,
() => {
createUser_Jerry()
getConversation()
getUnreader()
}
)
// 获取未读消息
const getUnreader = () => {
axios({
method: 'get',
url: `/1.2/rtm/clients/${props.ownId}/unread-count`,
headers: {
'X-LC-Id': 'NLblIbY4gEKenESSb7Q3vY9Y-gzGzoHsz',
'X-LC-Key': 'l1JvznaAgLvkGumBdiXDYQ6q,master',
'Content-Type': 'application/json'
}
})
.then(function (response) {
console.log(response.data);
if (response.data.unread > 0) {
ElMessage.warning('有新消息')
}
});
}
onMounted(() => {
realtime = new Realtime({
appId: 'NLblIbY4gEKenESSb7Q3vY9Y-gzGzoHsz',
appKey: 'pVpG3Q8DWAnWDWUquOb5cBu0',
server: 'https://nlbliby4.lc-cn-n1-shared.com',
// 初始化即时通讯服务时需要指定富媒体消息插件
plugins: [TypedMessagesPlugin]
})
localStorage.setItem('debug', 'LC*')
AV.init({
appId: 'NLblIbY4gEKenESSb7Q3vY9Y-gzGzoHsz',
appKey: 'pVpG3Q8DWAnWDWUquOb5cBu0',
serverURL: 'https://nlbliby4.lc-cn-n1-shared.com'
})
createUser_Jerry()
getConversation()
getUnreader()
checkAndSetScrollTop() // 延迟一段时间后再获取滚动高度
scrollContainer.value.addEventListener('scroll', throttle(handleScroll, 1000))
})
onBeforeUnmount(() => {
scrollContainer.value.removeEventListener('scroll', handleScroll)
})
</script>
<style lang="scss" scoped>
* {
ul,
li {
list-style: none;
padding: 0;
margin: 0;
}
}
.chat {
display: flex;
.userList {
border: 1px solid #e3e3e3;
width: 16%;
ul {
li {
line-height: 36px;
background: #e3e3e3;
padding: 0 10px;
margin-bottom: 1px;
cursor: pointer;
}
li:hover{
background: #cacaca;
}
.active{
background: #c1c1c1;
}
}
}
.instant-messaging {
flex: 1;
background: #f1f1f1;
padding: 20px;
h3 {
text-align: center;
}
.scroll-container {
height: 580px;
overflow-y: auto;
&::-webkit-scrollbar {
/*滚动条整体样式*/
width: 10px;
/*高宽分别对应横竖滚动条的尺寸*/
height: 10px;
}
&::-webkit-scrollbar-thumb {
/*滚动条里面小方块*/
border-radius: 10px;
background: #ccc;
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
}
.message-item {
display: flex;
margin-bottom: 10px;
.message-content {
display: flex;
align-items: center;
.user {
width: 36px;
height: 36px;
margin-right: 10px;
border-radius: 50px;
img {
width: 100%;
height: 100%;
}
}
.message {
line-height: 36px;
background: #fff;
padding: 0 6px;
margin-right: 10px;
display: flex;
justify-content: center;
}
.image {
max-width: 100px;
height: 52px;
img {
width: 100%;
height: 100%;
}
}
}
}
}
.message-operation {
display: flex;
justify-content: flex-end;
.message-input {
margin: 0 20px;
}
}
}
}
</style>
优化后的封装
<template>
<div class="chat">
<div class="userList">
<ul>
<li v-for="item in userList" :class="{ active: item === adverse }" :key="item"
@click="selectUser(item)">{{ item }}</li>
</ul>
</div>
<div class="instant-messaging">
<h3>{{ adverse }}</h3>
<el-scrollbar v-show="newMessage.length !== 0" class="scroll-container" ref="scrollbarRef"
@scroll="handleScrollVal">
<div v-for="item in newMessage" :key="item" class="message-item"
:style="`justify-content: ${item.from !== creator ? 'flex-start' : 'flex-end'};`">
<div v-if="item.from !== creator" class="message-content">
<div class="user">
<img :src="'https://img0.baidu.com/it/u=2226630510,461838410&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=521'"
alt="" />
</div>
<div class="message image" :style="'padding: 6px;'" v-if="item.content._lctype === -2">
<img :src="item.content._lcfile.url" alt="" />
</div>
<div class="message" v-if="item.content._lctype === -1">{{ item.content._lctext }}</div>
</div>
<div v-else class="message-content">
<div class="message image" :style="'padding: 6px;'" v-if="item.content._lctype === -2">
<img :src="item.content._lcfile.url" alt="" />
</div>
<div class="message" v-if="item.content._lctype === -1">{{ item.content._lctext }}</div>
<div class="user">
<img :src="'https://img1.baidu.com/it/u=3622150954,2575811681&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500'"
alt="" />
</div>
</div>
</div>
</el-scrollbar>
<ul v-show="newMessage.length === 0">
<li>暂无消息</li>
</ul>
<div class="message-operation">
<!-- <el-button type="primary" @click="moreMessage">更多</el-button> -->
<el-upload v-model:file-list="fileList" ref="uploadRef" class="upload-demo" multiple :limit="3"
:auto-upload="false" :show-file-list="false" :accept="'image/*'">
<el-button :icon="Picture" />
</el-upload>
<el-input class="message-input" v-model="messageValue" @keyup.enter="sendMessage" style="width: 240px"
placeholder="" />
<el-button type="primary" @click="sendMessage">发送</el-button>
</div>
</div>
</div>
</template>
<script setup>
import * as IM from 'leancloud-realtime'
import AV from 'leancloud-storage'
import initPlugin from 'leancloud-realtime-plugin-typed-messages'
import { onMounted, ref, watch, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import axios from 'axios'
import _ from 'lodash'
const props = defineProps({
// 自己的 id
ownId: {
type: String,
default: ''
},
// 对方的 id
adverseId: {
type: String,
default: ''
},
// 聊天室名称
chatsName: {
type: String,
default: ''
},
})
const { Realtime, TextMessage, Event } = IM
const { TypedMessagesPlugin, ImageMessage } = initPlugin(AV, IM)
let realtime;
const newMessage = ref([])
const creator = ref('')
const adverse = ref('')
const messageValue = ref('')
const fileList = ref([])
const uploadRef = ref(null)
const scrollContainer = ref(null)
// 监听文件上传
watch(
() => fileList.value.length,
() => {
console.log(fileList.value)
fileList.value.length !== 0 && sendMessage()
}
)
// 发送消息
const sendMessage = () => {
if (messageValue.value === '' && fileList.value.length === 0) {
ElMessage({
message: '不能发送空消息!',
type: 'warning'
})
return
}
/**
* 创建一个 IMClient 实例
*/
// 1、Tom 用自己的名字作为 clientId 来登录即时通讯服务
realtime
.createIMClient(props.ownId)
.then(function (own) {
// 成功登录
console.log('登录成功', own)
// 创建与 Jerry 之间的对话
own.createConversation({
// tom 是一个 IMClient 实例
// 指定对话的成员除了当前用户 Tom(SDK 会默认把当前用户当做对话成员)之外,还有 Jerry
members: [props.adverseId],
// 对话名称
name: props.chatsName,
unique: true
}).then(function (conversation) {
// 创建成功
console.log('创建成功', conversation)
// 发送一条文本消息
if (messageValue.value !== '') {
conversation
.send(new TextMessage(messageValue.value))
.then(function (message) {
console.log('发送成功!')
createUser_Jerry()
messageValue.value = ''
})
.catch(console.error)
}
if (fileList.value.length !== 0) {
var file = new AV.File('avatar.jpg', fileList.value[0].raw)
file
.save()
.then(function () {
var message = new ImageMessage(file)
message.setText('本地图片')
message.setAttributes({ location: '武汉' })
return conversation.send(message)
})
.then(function () {
console.log('发送成功')
createUser_Jerry()
fileList.value = []
})
.catch(console.error.bind(console))
}
})
})
.catch(console.error)
}
const createUser_Jerry = () => {
// Jerry 登录
realtime
.createIMClient(props.ownId)
.then(function (own) {
// 成功登录
console.log('登录成功', own)
creator.value = own.id
// 确保 Jerry 已加入对话
own.createConversation({
members: [props.adverseId],
name: props.chatsName,
unique: true
}).then(function (conversation) {
// 创建成功
console.log('创建成功', conversation)
adverse.value = conversation.members.find((item) => item !== conversation.creator)
// 当前用户被添加至某个对话
own.on(Event.INVITED, function invitedEventHandler(payload, conversation) {
console.log(payload.invitedBy, conversation.id)
})
// 当前用户收到了某一条消息,可以通过响应 Event.MESSAGE 这一事件来处理。
own.on(Event.MESSAGE, function (message, conversation) {
// newMessage.value.push(message.text)
console.log('收到新消息:' + message.text, conversation)
getChatRecord(conversation)
})
getChatRecord(conversation)
})
})
.catch(console.error)
}
let messageIterator
// 获取聊天记录
const getChatRecord = (conversation) => {
// JS SDK 通过迭代器隐藏了翻页的实现细节,开发者通过不断的调用 next 方法即可获得后续数据。
// 创建一个迭代器,每次获取 10 条历史消息
messageIterator = conversation.createMessagesIterator({ limit: 20 })
// 第一次调用 next 方法,获得前 10 条消息,还有更多消息,done 为 false
messageIterator
.next()
.then(function (result) {
newMessage.value = result.value
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
})
.catch(console.error.bind(console))
}
// 更多消息
const moreMessage = () => {
messageIterator
.next()
.then(function (result) {
if (result.value.length === 0) {
ElMessage.warning('没有更多消息了')
return
}
newMessage.value = result.value.concat(newMessage.value)
})
.catch(console.error.bind(console))
}
const userList = ref([]) // 用户列表
// 获取会话
const getConversation = () => {
axios({
method: 'get',
url: `/1.2/rtm/conversations?limit=10&where={"c": "${props.ownId}"}`,
headers: {
'X-LC-Id': 'NLblIbY4gEKenESSb7Q3vY9Y-gzGzoHsz',
'X-LC-Key': 'l1JvznaAgLvkGumBdiXDYQ6q,master',
'Content-Type': 'application/json'
}
})
.then(function (response) {
console.log(response.data);
userList.value = response.data.results.map(item => {
return item.m.find(v => v !== item.c)
})
console.log(userList.value);
});
}
const emit = defineEmits(['selectUser'])
// 选择用户
const selectUser = (user) => {
emit('selectUser', user)
}
// 监听props.adverseId
watch(() => props.adverseId,
() => {
createUser_Jerry()
getConversation()
getUnreader()
}
)
// 获取未读消息
const getUnreader = () => {
axios({
method: 'get',
url: `/1.2/rtm/clients/${props.ownId}/unread-count`,
headers: {
'X-LC-Id': 'NLblIbY4gEKenESSb7Q3vY9Y-gzGzoHsz',
'X-LC-Key': 'l1JvznaAgLvkGumBdiXDYQ6q,master',
'Content-Type': 'application/json'
}
})
.then(function (response) {
console.log(response.data);
if (response.data.unread > 0) {
ElMessage.warning('有新消息')
}
});
}
const scrollbarRef = ref(null) // 滚动组件实例
// 在滚动事件绑定时创建节流函数
const throttledMoreMessage = _.throttle(moreMessage, 1000, { 'trailing': false });
// 滚动条滚动事件
const handleScrollVal = (val) => {
if (val.scrollTop < 52) {
throttledMoreMessage()
}
}
onMounted(() => {
realtime = new Realtime({
appId: 'NLblIbY4gEKenESSb7Q3vY9Y-gzGzoHsz',
appKey: 'pVpG3Q8DWAnWDWUquOb5cBu0',
server: 'https://nlbliby4.lc-cn-n1-shared.com',
// 初始化即时通讯服务时需要指定富媒体消息插件
plugins: [TypedMessagesPlugin]
})
localStorage.setItem('debug', 'LC*')
AV.init({
appId: 'NLblIbY4gEKenESSb7Q3vY9Y-gzGzoHsz',
appKey: 'pVpG3Q8DWAnWDWUquOb5cBu0',
serverURL: 'https://nlbliby4.lc-cn-n1-shared.com'
})
createUser_Jerry()
getConversation()
getUnreader()
setTimeout(() => {
scrollbarRef.value.setScrollTop(1000)
}, 500)
})
</script>
<style lang="scss" scoped>
* {
ul,
li {
list-style: none;
padding: 0;
margin: 0;
}
}
.chat {
display: flex;
.userList {
border: 1px solid #e3e3e3;
width: 16%;
ul {
li {
line-height: 36px;
background: #e3e3e3;
padding: 0 10px;
margin-bottom: 1px;
cursor: pointer;
}
li:hover {
background: #cacaca;
}
.active {
background: #c1c1c1;
}
}
}
.instant-messaging {
flex: 1;
background: #f1f1f1;
padding: 20px;
h3 {
text-align: center;
}
.scroll-container {
height: 580px;
overflow-y: auto;
.message-item {
display: flex;
margin-bottom: 10px;
.message-content {
display: flex;
align-items: center;
.user {
width: 36px;
height: 36px;
margin-right: 10px;
border-radius: 50px;
img {
width: 100%;
height: 100%;
}
}
.message {
line-height: 36px;
background: #fff;
padding: 0 6px;
margin-right: 10px;
display: flex;
justify-content: center;
}
.image {
max-width: 100px;
height: 52px;
img {
width: 100%;
height: 100%;
}
}
}
}
}
.message-operation {
display: flex;
justify-content: flex-end;
.message-input {
margin: 0 20px;
}
}
}
}
</style>
使用组件
<template>
<div>
<ChatComponent :ownId="ownId" :adverseId="adverseId" :chatsName="chatsName" @selectUser="selectUser" />
</div>
</template>
<script setup>
import ChatComponent from '@/components/ChatComponent.vue'
import { ref } from 'vue'
// const ownId = ref('goto_w')
// const adverseId = ref('tom')
// const chatsName = ref('goto_w & tom')
// const ownId = ref('红红')
// const adverseId = ref('熊熊')
// const chatsName = ref('红红 & 熊熊')
const ownId = ref('goto_w')
const adverseId = ref('M')
const chatsName = ref('goto_w & M')
const selectUser = (user) => {
adverseId.value = user
}
</script>