新增聊天功能

This commit is contained in:
不做码农 2023-09-27 18:26:36 +08:00
parent e6f98d4a2d
commit c1bfaabcb2
8 changed files with 320 additions and 38 deletions

View File

@ -33,6 +33,7 @@
"md-editor-v3": "^1.11.11", "md-editor-v3": "^1.11.11",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "^2.1.6", "pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"qrcodejs2-fixes": "^0.0.2", "qrcodejs2-fixes": "^0.0.2",
"qs": "^6.11.0", "qs": "^6.11.0",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",

4
src/components.d.ts vendored
View File

@ -1,6 +1,6 @@
import SvgIcon from '@/components/SvgIcon/index.vue' import SvgIcon from '@/components/SvgIcon/index.vue'
import UploadImage from '@/components/ImageUpload.vue' import UploadImage from '@/components/ImageUpload.vue'
import FileUpload from '@/components/FileUpload' import UploadFile from '@/components/FileUpload'
import ImagePreview from '@/components/ImagePreview' import ImagePreview from '@/components/ImagePreview'
import DictTag from '@/components/DictTag' import DictTag from '@/components/DictTag'
import Dialog from '@/components/Dialog' import Dialog from '@/components/Dialog'
@ -10,7 +10,7 @@ declare module '@vue/runtime-core' {
UploadImage: typeof UploadImage, UploadImage: typeof UploadImage,
DictTag: typeof DictTag, DictTag: typeof DictTag,
ImagePreview: typeof ImagePreview, ImagePreview: typeof ImagePreview,
FileUpload: typeof FileUpload, UploadFile: typeof UploadFile,
ZrDialog: typeof Dialog ZrDialog: typeof Dialog
} }
} }

View File

@ -13,7 +13,7 @@
<el-badge :is-dot="newsDot" class="item"> 通知 </el-badge> <el-badge :is-dot="newsDot" class="item"> 通知 </el-badge>
</template> </template>
<div class="content-box"> <div class="content-box">
<div class="content-box-item" v-for="item in noticeList" @click="handleDetails(item)"> <div class="content-box-item" v-for="item in noticeList" @click="handleDetails(item, 0)">
<el-icon :size="30" color="#409EFF"><bell /></el-icon> <el-icon :size="30" color="#409EFF"><bell /></el-icon>
<div class="content"> <div class="content">
<div class="title">{{ item.noticeTitle }}</div> <div class="title">{{ item.noticeTitle }}</div>
@ -23,22 +23,50 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="私信" name="1"> <div class="content-box"></div></el-tab-pane> <el-tab-pane name="1">
<template #label>
<el-badge :value="chatList.length"> 私信 </el-badge>
</template>
<div class="content-box">
<div class="content-box-item" v-for="item in chatList" @click="handleDetails(item, 1)">
<el-avatar :src="item.fromUser.avatar"></el-avatar>
<div class="content">
<div class="title">
<span class="name">{{ item.fromUser.nickName }}</span>
{{ item.message }}
</div>
<div class="content-box-time">{{ formatTime(item.chatTime) }}</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs> </el-tabs>
<div class="foot-box" @click="onGoToGiteeClick" v-if="noticeList.length > 0">前往通知中心</div> <div class="foot-box">
<div @click="onGoToGiteeClick" v-if="noticeList.length > 0">前往通知中心</div>
<div>全部已读</div>
</div>
</div> </div>
</el-popover> </el-popover>
<el-dialog :title="info.noticeTitle" v-model="show" append-to-body> <el-dialog draggable v-model="show" append-to-body>
<div v-html="info.noticeContent"></div> <template #header> {{ info.title }} </template>
<template v-if="info">
<div v-if="info.type == 0">
<div v-html="info.item.noticeContent"></div>
</div>
<msgList v-if="info.type == 1" v-model="info.userId"> </msgList>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup name="noticeIndex"> <script setup name="noticeIndex">
import msgList from '@/views/components/msgList.vue'
import useSocketStore from '@/store/modules/socket' import useSocketStore from '@/store/modules/socket'
import useUserStore from '@/store/modules/user'
import { dayjs } from 'element-plus' import { dayjs } from 'element-plus'
import { formatTime } from '@/utils/index'
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const noticeType = ref('0') const noticeType = ref('0')
// //
@ -50,10 +78,17 @@ const noticeList = computed(() => {
const noticeDot = computed(() => { const noticeDot = computed(() => {
return useSocketStore().noticeDot return useSocketStore().noticeDot
}) })
const chatList = computed(() => {
return useSocketStore().getSessionList(useUserStore().userId)
})
const info = ref({}) const info = ref({})
function handleDetails(item) { function handleDetails(item, type) {
show.value = true show.value = true
info.value = item if (type == 0) {
info.value = { type, item, title: item.noticeTitle }
} else if (type == 1) {
info.value = { type, title: item.fromUser.nickName, userId: item.userId }
}
} }
// //
function onAllReadClick() { function onAllReadClick() {
@ -67,24 +102,24 @@ function onGoToGiteeClick() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.head-box { // .head-box {
display: flex; // display: flex;
border-bottom: 1px solid #ebeef5; // border-bottom: 1px solid #ebeef5;
box-sizing: border-box; // box-sizing: border-box;
color: #333333; // color: #333333;
justify-content: space-between; // justify-content: space-between;
height: 35px; // height: 35px;
align-items: center; // align-items: center;
.head-box-btn { // .head-box-btn {
color: #1890ff; // color: #1890ff;
font-size: 13px; // font-size: 13px;
cursor: pointer; // cursor: pointer;
opacity: 0.8; // opacity: 0.8;
&:hover { // &:hover {
opacity: 1; // opacity: 1;
} // }
} // }
} // }
.content-box { .content-box {
font-size: 13px; font-size: 13px;
min-height: 60px; min-height: 60px;
@ -102,6 +137,13 @@ function onGoToGiteeClick() {
&:last-of-type { &:last-of-type {
padding-bottom: 12px; padding-bottom: 12px;
} }
.content {
margin-left: 8px;
.name {
color: var(--el-color-primary);
}
}
.icon { .icon {
width: 30px; width: 30px;
height: 30px; height: 30px;
@ -132,7 +174,7 @@ function onGoToGiteeClick() {
opacity: 0.8; opacity: 0.8;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-around;
border-top: 1px solid #ebeef5; border-top: 1px solid #ebeef5;
&:hover { &:hover {
opacity: 1; opacity: 1;

View File

@ -51,18 +51,19 @@ export default {
}) })
// 接收聊天数据 // 接收聊天数据
connection.on('receiveChat', (data) => { connection.on('receiveChat', (data) => {
const title = `来自${data.userName}的消息通知` const { fromUser, message } = data
useSocketStore().setChat(data) useSocketStore().setChat(data)
if (data.userid != useUserStore().userId) { if (data.userid != useUserStore().userId) {
ElNotification({ ElNotification({
title: title, title: fromUser.nickName,
message: data.message, message: message,
type: 'success', type: 'success',
duration: 3000 duration: 3000
}) })
} }
webNotify({ title: title, body: data.message }) webNotify({ title: fromUser.nickName, body: message })
}) })
connection.on('onlineInfo', (data) => { connection.on('onlineInfo', (data) => {

View File

@ -1,3 +1,5 @@
const store = createPinia() import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default store const store = createPinia()
store.use(piniaPluginPersistedstate)
export default store

View File

@ -1,4 +1,9 @@
import useUserStore from './user'
import signalR from '@/signalr/signalr'
const useSocketStore = defineStore('socket', { const useSocketStore = defineStore('socket', {
persist: {
paths: ['chatMessage', 'chatList', 'sessionList'] //存储指定key
},
state: () => ({ state: () => ({
onlineNum: 0, onlineNum: 0,
onlineUsers: [], onlineUsers: [],
@ -7,9 +12,24 @@ const useSocketStore = defineStore('socket', {
//在线用户信息 //在线用户信息
onlineInfo: {}, onlineInfo: {},
// 聊天数据 // 聊天数据
chatList: [], chatList: {},
leaveUser: {} leaveUser: {},
sessionList: {},
newChat: 0
}), }),
getters: {
/**
* 返回当前会话的消息
* @param {*} state
* @returns
*/
getMessageList(state) {
return (conversationId) => state.chatList[conversationId]
},
getSessionList(state) {
return (userid) => state.sessionList[userid] || []
}
},
actions: { actions: {
//更新在线人数 //更新在线人数
setOnlineUserNum(num) { setOnlineUserNum(num) {
@ -32,7 +52,44 @@ const useSocketStore = defineStore('socket', {
this.onlineInfo = data this.onlineInfo = data
}, },
setChat(data) { setChat(data) {
this.chatList.push(data) const userStore = useUserStore()
var selfUserId = userStore.userId
var sessionId = data.toUserId
if (data.userId != selfUserId) {
sessionId = data.userId
}
var obj = this.chatList[sessionId]
if (obj && obj.length >= 50) {
this.chatList[sessionId].shift()
}
if (obj == null || obj == undefined) {
this.chatList[sessionId] = []
}
// 判断消息是否是自己发的
data.self = data.userId == selfUserId
this.chatList[sessionId].push(data)
if (selfUserId == data.userId) return
if (this.sessionList[selfUserId] == undefined) {
this.sessionList[selfUserId] = []
}
var index = this.getSessionList(selfUserId).findIndex((x) => x.userId == data.userId)
this.getSessionList(selfUserId).splice(index, 1)
this.getSessionList(selfUserId).push(data)
},
sendChat(data) {
// console.log(JSON.stringify(data))
return new Promise((resolve, reject) => {
signalR.SR.invoke('sendMessage', data.toUserId, data.message)
.then(() => {
resolve(true)
})
.catch((err) => {
reject(false)
console.error(err.toString())
})
})
} }
} }
}) })

View File

@ -0,0 +1,179 @@
<template>
<div style="height: 400px">
<div class="message_content">
<el-scrollbar height="100%" ref="scrollContainer">
<template v-for="item in conversionMsgList">
<div class="talk_item talk_primary" v-if="item.self">
<div class="head">
<el-avatar shape="square"> </el-avatar>
</div>
<div class="content">
<div class="bubble">{{ item.message }}</div>
</div>
</div>
<div class="talk_item talk_other" v-else>
<div class="head">
<el-avatar :src="item.fromUser.avatar" shape="square" />
</div>
<div class="content">
<div class="bubble">{{ item.message }}</div>
</div>
</div>
</template>
</el-scrollbar>
</div>
<div class="talk_bottom">
<div class="talk_area">
<textarea class="textarea" placeholder="请输入聊天内容" @keyup.enter="handleSend" v-model="content"></textarea>
<div class="talk_btn">
<el-button type="success" size="default" @click="handleSend">发送</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup name="msglist">
import useSocketStore from '@/store/modules/socket'
const socketStore = useSocketStore()
const { proxy } = getCurrentInstance()
const props = defineProps({
modelValue: {}
})
const conversionMsgList = computed(() => {
return socketStore.getMessageList(props.modelValue)
})
const content = ref('')
function handleSend() {
if (content.value.trim().length <= 0) {
proxy.$modal.msgError('请输入聊天内容')
return
}
var obj = {
toUserId: props.modelValue,
message: content.value
}
socketStore
.sendChat(obj)
.then(() => {
content.value = ''
scrollBottom()
})
.catch(() => {
proxy.$modal.msgError('发送失败')
})
}
/**
* 滚动聊天记录
* @param {*} type
*/
function scrollBottom(type) {
setTimeout(() => {
const scrollWrapper = computed(() => proxy.$refs.scrollContainer.$refs.wrapRef)
const height = scrollWrapper.value.scrollHeight
if (type == 1) {
scrollWrapper.value.scrollTop = height
} else {
scrollWrapper.value.scrollTo({ top: height + 120, behavior: 'smooth' })
}
}, 100)
}
watch(
() => props.modelValue,
() => {
scrollBottom(1)
},
{
immediate: true
}
)
</script>
<style lang="scss" scoped>
.message_content {
width: 100%;
height: 300px;
border-top: 1px solid #ddd;
padding-top: 10px;
}
.talk_item {
position: relative;
display: flex;
height: auto;
margin-bottom: 1rem;
.head {
display: block;
width: 3rem;
height: 3rem;
float: left;
cursor: pointer;
img {
border-radius: 0.3rem;
width: 90%;
height: 90%;
}
}
.content {
display: block;
overflow: hidden;
margin-right: 0.4rem;
max-width: 70%;
.bubble {
display: inline-block;
position: relative;
text-align: left;
font-size: 1rem;
margin-right: 0.2rem;
border-radius: 0.4rem;
background: var(--el-color-success);
color: #fff;
padding: 4px 10px;
}
}
}
.talk_primary {
flex-direction: row-reverse;
padding-right: 5px;
}
.talk_item:after {
clear: both;
}
.talk_bottom {
border-top: 1px solid #ddd;
.talk_btn {
position: absolute;
right: 10px;
padding: 0 10px;
bottom: 20px;
}
.talk_area {
position: relative;
height: 100px;
.textarea {
padding: 10px 0.2rem;
width: 100%;
flex: 1;
resize: none;
background: none;
border: none;
outline: none;
height: 100%;
}
.textarea:focus {
outline: none;
}
}
}
</style>

View File

@ -111,7 +111,7 @@ function onChat(item) {
inputErrorMessage: '消息内容不能为空' inputErrorMessage: '消息内容不能为空'
}) })
.then(({ value }) => { .then(({ value }) => {
proxy.signalr.SR.invoke('sendMessage', item.connnectionId, item.userid, value).catch(function (err) { proxy.signalr.SR.invoke('sendMessage', item.userid, value).catch(function (err) {
console.error(err.toString()) console.error(err.toString())
}) })
}) })