Vue3在线聊天室
聊天,即前端与后端通信,基于 websocket 通道,前端可以实时从 websocket 通道来获取后台推送的消息,就不需要刷新网页了。
后端
pom加入websocket依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
配置文件
1 WebSocketConfig
// 1.WebSocketConfig
package com.partner.boot.common;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration // 配置文件必备
@EnableWebSocket // 开启服务
public class WebSocketConfig {
/**
* 注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解申明的websocket endpoint
* ServerEndpointExporter 是依赖所提供的的类
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
2 WebSocketServer.java
package com.partner.boot.service.impl;
import cn.hutool.core.lang.Dict;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.partner.boot.entity.Im;
import com.partner.boot.entity.User;
import com.partner.boot.service.IImService;
import com.partner.boot.service.IUserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author websocket服务
*/
@ServerEndpoint(value = "/imserver/{uid}") // 定义路由 websocket 也需要路由
@Component // 注册为springbootservice 服务才能生效
public class WebSocketServer {
private static final Logger log = LoggerFactory.getLogger(WebSocketServer.class);
public static final Map<String, Session> sessionMap = new ConcurrentHashMap<>(); // 记录当前在线连接数
@Resource
IUserService userService;
@Resource
IImService imService;
private static IUserService staticUserService;
private static IImService staticImService;
/**
* 0.页面初始化
* 程序初始化的时候触发这个方法 赋值
*/
@PostConstruct
public void setStaticUser() {
// 在程序初始化时把 加载到内存,变成静态的成员变量
// 也可以通过 SpringBeanUtil ,但需要自己封装spring获取bean的类
staticUserService = userService;
staticImService = imService;
}
/**
* 1.页面开启
* 连接建立成功调用的方法 可以获取前端页面的 session (一个客户端和一个服务端建立的连接) 通过session往客户端发消息
* 当新的用户连接成功后,会广播给每一个用户当前有多少个人
* 并把新用户的 session 存到 sessionMap
*/
@OnOpen
public void onOpen(Session session, @PathParam("uid") String uid) {
// uid 作为key:一个用户只有一个客户端,只有一个页面。
sessionMap.put(uid, session);
log.info("有新用户加入,uid={}, 当前在线人数为:{}", uid, sessionMap.size());
// 将当前所有用户个数传给客户端。Dict,Hutool 提供的 字典。
Dict dict = Dict.create().set("nums", sessionMap.size());
// 后台发送消息给所有的客户端
sendAllMessage(JSONUtil.toJsonStr(dict));
}
/**
* 2.页面关闭
* 连接关闭调用的方法
* 刷新(session会变),离开都算关闭
*/
@OnClose
public void onClose(Session session, @PathParam("uid") String uid) {
// 从后台缓存去掉
sessionMap.remove(uid);
log.info("有一连接关闭,uid={}的用户session, 当前在线人数为:{}", uid, sessionMap.size());
Dict dict = Dict.create().set("nums", sessionMap.size());
// 后台发送消息给所有的客户端
sendAllMessage(JSONUtil.toJsonStr(dict));
}
/**
* 3. 发送消息。核心功能。
* 收到客户端消息后调用的方法
* 后台收到客户端发送过来的消息
* onMessage 是一个消息的中转站
* 接受 浏览器端 socket.send 发送过来的 json数据
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, Session fromSession, @PathParam("uid") String uid) throws JsonProcessingException {
log.info("服务端收到用户uid={}的消息:{}:", uid, message);
if (staticUserService == null) {
return;
}
// 查询用户
User user = staticUserService.getOne(new QueryWrapper<User>().eq("uid", uid));
if (user == null) {
log.error("获取用户信息失败,uid={}", uid);
return;
}
// 4
// 直接存到数据库里面
Im im = Im.builder().uid(uid).username(user.getNamex()).avatar(user.getAvatar()).sign(user.getSign())
.createTime(LocalDateTime.now()).text(message).build();
// 2
// // 前端代码改了后 message 就不再是 json 了,而是消息字符串,需要自己构建DTO
// ImMessageDTO messageDTO = ImMessageDTO.builder().uid(uid).username(user.getNamex()).avatar(user.getAvatar()).sign(user.getSign())
// .createTime(new Date()).text(message).build();
// 1
// // Json 字符串转化为数据类
// ImMessageDTO imMessageDTO = JSONUtil.toBean(message, ImMessageDTO.class);
// imMessageDTO.setCreateTime(new Date());
// 3
// // 存数据到数据库
// Im im = new Im();
// BeanUtil.copyProperties(messageDTO,im);
staticImService.save(im);
// // message 处理好后再转回json;处理后的消息体;
// 刚发消息时显示不出来,原因:实体类加了注解@Alias("xx"),json的key会变成中文
// String jsonStr = JSONUtil.toJsonStr(im);
// 处理后的消息体
String jsonStr = new ObjectMapper().writeValueAsString(im);
// 广播
// 消息体是后端构建的 前端需要使用数据时,前端自己不构建,依赖于后端发送的数据
// 所以自己也要接受到后端返回的消息 所以这里应该用 sendAllMessage 而不是 sendMessage
this.sendAllMessage(jsonStr);
log.info("发送消息:{}:", jsonStr);
}
/**
* 3.1
* 广播 服务端发送消息给除了自己的其他客户端
*/
private void sendMessage(Session fromSession, String message) {
sessionMap.values().forEach(session -> {
// 把发送消息的自己排除
if (fromSession != session) {
log.info("服务端给客户端[{}]发送消息{}:", session.getId(), message);
try {
session.getBasicRemote().sendText(message); // 发消息
} catch (IOException e) {
log.error("服务端发送消息给客户端异常", e);
}
}
});
}
/**
* 3.2
* 服务端发送消息给所有客户端
*/
private void sendAllMessage(String message) {
try {
for (Session session : sessionMap.values()) {
log.info("服务端给客户端[{}]发送消息{}:", session.getId(), message);
session.getBasicRemote().sendText(message);
}
} catch (Exception e) {
log.error("服务端发送消息给客户端失败", e);
}
}
/**
* 4.
* 发生错误
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("发生错误");
error.printStackTrace();
}
}
前端
安装表情包依赖 npm i [email protected]
表情包依赖文档:https://github.com/ADKcodeXD/Vue3-Emoji
im.vue
<script setup>
import {nextTick, onMounted, ref} from "vue";
import V3Emoji from 'vue3-emoji'
import 'vue3-emoji/dist/style.css'
import {useUserStore} from "@/stores/user";
import request from "@/utils/request";
const messages = ref([])
const userStore = useUserStore()
const user = userStore.getUser
const text = ref('') // 聊天输入的内容
const divRef = ref() // 聊天框的引用。发送消息后,需要把滚动条滚动到最新位置,需要用到这个引用
// 页面滚动到最新位置的函数
const scrollBottom = () => {
// 数据渲染通过v-if, scrollBottom函数触发时,页面的DOM不一定渲染好了
// 等到页面元素出来之后再去滚动
nextTick(() => {
divRef.value.scrollTop = divRef.value.scrollHeight
})
}
// 页面加载完成触发此函数
onMounted(() => {
// 数据加载完再滚动
request.get("/im/init/10").then(res => {
messages.value = res.data
// // 1.刷新滚动
// scrollBottom()
})
})
// 后台 WebSocketServer.java 提供的路由
const client = new WebSocket(`ws://localhost:9090/imserver/${user.uid}`)
// 2.发送消息触发滚动条滚动
const send = () => {
if (client) {
// 之前在前端写死数据 目的是 明白实体类应该包括哪些字段
// message.value.push({uid: user.id, username: user.name, avatar: user.avatar,text: text.value})
// 现在是改造的更简单
client.send(text.value)
}
text.value = '' // 清空文本框
// send 之后不能立马滚动,要接收到消息后再滚动
// 消息要转到后台存到数据库再发送给其它用户
// scrollBottom()
}
// 获取当前文件状态
client.onopen = () => {
console.log('open')
}
// 页面刷新的时候 和 后台websocket服务关闭的时候
client.onclose = () => {
console.log('close')
}
// 获取后台消息
client.onmessage = (msg) => {
if (msg.data) {
let json = JSON.parse(msg.data)
// 有聊天消息
if (json.uid && json.text) {
messages.value.push(json)
// 消息接收到后,滚动页面到最底部
scrollBottom()
}
}
}
const optionsName = {
'Smileys & Emotion': '笑脸&表情',
'Food & Drink': '食物&饮料',
'Animals & Nature': '动物&自然',
'Travel & Places': '旅行&地点',
'People & Body': '人物&身体',
Objects: '物品',
Symbols: '符号',
Flags: '旗帜',
Activities: '活动'
}
</script>
<template>
<div style="width: 80%; margin: 10px auto">
<!-- 聊天框-->
<div ref="divRef" style="background-color: white; padding: 20px; border: 1px solid #ccc; border-radius: 10px; height: 400px; overflow-y: scroll;">
<!-- 循环获取消息 -->
<!-- item.uid + item.createTime + Math.random() 消息的唯一id -->
<!-- 创建了im表后,表里的id是唯一的 -->
<div v-for="item in messages" :key="item.id">
<!-- 1 别人给我发的消息 -->
<div style="display: flex; margin: 20px 0;" v-if="user.uid !== item.uid">
<!-- 点击头像所显示的框框 -->
<el-popover
placement="top-start"
:width="100"
trigger="hover"
>
<!-- 头像 -->
<template #reference>
<img :src="item.avatar" alt="" style="width: 30px; height: 30px; border-radius: 50%; margin-right: 10px">
</template>
<!-- 弹出框类容 -->
<div style="line-height: 20px">
<div style="font-size: 16px">{{ item.username }}</div>
<div style="font-size: 12px;">{{ item.sign }}</div>
</div>
</el-popover>
<!-- 聊天类容 width:fit-content 不加,消息会占满整行 -->
<!-- <div style="width: 50px; line-height: 30px; margin-left: 5px; color: #888; overflow: hidden; font-size: 14px">{{ item.username }}</div> -->
<div style="line-height: 30px; background-color: aliceblue; padding: 0 10px; width:fit-content; border-radius: 10px">{{ item.text }}</div>
</div>
<!-- 2 我给别人发的消息 justify-content: flex-end:靠右显示-->
<div style="display: flex; justify-content: flex-end; margin: 20px 0;" v-else>
<div style="line-height: 30px; background-color: lightyellow; padding: 0 10px; width:fit-content; border-radius: 10px;">{{ item.text }}</div>
<el-popover
placement="top-start"
:width="100"
trigger="hover"
>
<template #reference>
<img :src="item.avatar" alt="" style="width: 30px; height: 30px; border-radius: 50%; margin-left: 10px">
</template>
<div style="line-height: 20px">
<div style="font-size: 16px">{{ item.username }}</div>
<div style="font-size: 12px;">{{ item.sign }}</div>
</div>
</el-popover>
</div>
</div>
</div>
<!-- 聊天输入框 第三方插件 -->
<div style="margin: 10px 0; width: 100%">
<!-- :keep="true" 标签框关闭不会销毁组件。页面性能会好点,不用重复渲染。-->
<!-- :textArea="true" 显示聊天框; v-model="text" 聊天框内容 -->
<V3Emoji default-select="recent" :recent="true" :options-name="optionsName" :keep="true" :textArea="true" size="mid" v-model="text" />
<div style="text-align: right"><el-button @click="send" type="primary">发送</el-button></div>
</div>
</div>
</template>
ImMessageDTO.java 后台接收前台的实体,作为数据传输用,本身没有太大价值,与数据库没关系。
发送的消息 --> 需要把数据发送给后台 --> 后端接收消息,存到数据库表 im 里去 im
表 --> ImMessageDTO就没用了。
管理端
创建im权限(页面)管理 、 为admin分配权限。
存储数据、取数据。
// ImController
@GetMapping("/init/{limit}")
// @PathVariable 接受花括号形式的参数
public Result findAllInit(@PathVariable Integer limit) {
// 默认取limit条数据
return Result.success(imService.list(new QueryWrapper<Im>().last("limit " + limit)));
}
问题
问题:在 WebSocketServer.java 的 onMessage() 方法里 获取 user 失败
解决:
为什么消息会出现null?因为消息是多线程,前端每构建的消息通道,都是一个单独的线程,在多线程里面不能通过注入 @Resource IUserService userService;
从容器里拿到bean,可以通过 static 初始化进来,也可以通过 SpringBeanUtil ,但需要自己封装spring获取bean的类/方法,然后再从容器里拿bean。
// 把 userService 初始化进来
private static IUserService staticUserService;
/**
* 0.页面初始化
* 程序初始化的时候触发这个方法 赋值
*/
@PostConstruct
public void setStaticUser() {
staticUserService = userService;
}
首页动态展示
换代码生成器了
增加 个人主页,修改密码,两个页面和后端接口。
前端:person.vue password.vue
后端:
// 1 WebController
// 修改密码
@PostMapping("/password/change")
public Result passwordChange(@RequestBody UserRequest userRequest) {
userService.passwordChange(userRequest);
return Result.success();
}
// 更新个人信息
@PutMapping("/updateUser")
public Result updateUser(@RequestBody User user) {
Object loginId = StpUtil.getLoginId();
if (!loginId.equals(user.getUid())) {
Result.error("无权限");
}
userService.updateById(user);
return Result.success(user);
}
// 2 IUserService
void passwordChange(UserRequest userRequest);
// 3 UserRequest
private String uid;
private String newPassword;
// 4 UserServiceImpl
public void passwordChange(UserRequest userRequest) {
User dbUser = getOne(new UpdateWrapper<User>().eq("uid", userRequest.getUid()));
if (dbUser == null) {
throw new ServiceException("未找到用户");
}
boolean checkpw = BCrypt.checkpw(userRequest.getPassword(), dbUser.getPassword());
if (!checkpw) {
throw new ServiceException("原密码错误");
}
String newPass = userRequest.getNewPassword();
dbUser.setPassword(BCrypt.hashpw(newPass));
updateById(dbUser); // 设置到数据库
}
动态展示首页
在后台获取用户信息 User user = (User) StpUtil.getSession().get(Constants.LOGIN_USER_KEY);
// Dynamic
@TableField(exist = false)
private User user;
@GetMapping("/hot")
@SaCheckPermission("dynamic.list.hot")
public Result hot( @RequestParam Integer pageNum,
@RequestParam Integer pageSize) {
// 用户存在动态表里的是uid 这里获取信息时需要的是完整的用户id
// 实际业务时需要在不同系统交互数据 这里的话单个系统比较简单 直接根据用户id查数据即可
// 但用户比较多,需要分页了,否则前端可能会卡了
QueryWrapper<Dynamic> queryWrapper = new QueryWrapper<Dynamic>().orderByDesc("id");
Page<Dynamic> page = dynamicService.page(new Page<>(pageNum, pageSize), queryWrapper);
// 1 通过sql 2 直接从一个很大的用户接口里筛选需要的数据
// 这里使用 2
List<User> users = userService.list();
for (Dynamic record : page.getRecords()) {
// 从users里面找到uid跟当前动态里面的uid一样的数据
// ifPresent 表示数据筛选有结果的时候
users.stream().filter(user -> user.getUid().equals(record.getUid())).findFirst().ifPresent(record::setUser);
}
return Result.success(page);
}
前端
<script setup>
import {ChatLineRound, Compass, Pointer, View} from '@element-plus/icons-vue'
import request from "@/utils/request";
import {reactive} from "vue";
function filterTime(time) {
const date = new Date(time)
const Y = date.getFullYear()
const M = date.getMonth() + 1 < 10 ? '0'+(date.getMonth()+1) : date.getMonth()+1
const D = date.getDate()
return `${Y}-${M}-${D}`
}
const state = reactive({
hotDynamics: []
})
const load = () => {
request.get('/dynamic/hot', {
params: {
pageNum: 1,
pageSize: 5
}
}).then(res => {
state.hotDynamics = res.data.records
console.log(res.data.records)
})
}
load()
</script>
<template>
<div style="background-color: white; border-radius: 10px; margin-bottom: 10px" class="container-height;">
<div style="display: flex; padding: 10px">
<!-- 话题动态-->
<div class="dynamic-box" style="width: 60%; ">
<!-- :src="item.user.avatar" {{ item.user.namex }} -->
<div style="padding: 20px; border: 1px solid #ccc; border-radius: 10px; margin-bottom: 10px" v-for="item in state.hotDynamics" :key="item.id">
<div style="display:flex;">
<img style="width: 50px; height: 50px; margin-right: 20px; border-radius: 50%"
:src="item.user.avatar"
alt="">
<div style="flex: 1; line-height: 25px">
<div style="font-weight: bold"> {{ item.user.namex }} </div>
<div style="font-size: 12px; color: #999">{{ filterTime(item.createTime) }} · 来自 {{ item.user.address }}</div>
</div>
<el-button>关注</el-button>
</div>
<div style="" class="content">{{ item.description }}</div>
<div style="margin: 10px 0">
<el-row :gutter="10">
<el-col :span="12" style="margin-bottom: 10px">
<img style="width: 100%;"
:src="item.img"
alt="">
</el-col>
<el-col :span="12" style="margin-bottom: 10px">
<img style="width: 100%;"
:src="item.img"
alt="">
</el-col>
</el-row>
</div>
<div style="margin: 10px 0; display: flex; line-height: 25px">
<div style="width: 50%">
<el-tag># 冬至到了</el-tag>
<el-tag type="danger" style="margin-left: 10px">
<el-icon style="top: 1px">
<Compass/>
</el-icon>
米粉杂谈
</el-tag>
</div>
<div style="width: 50%; text-align: right; color: #999; font-size: 14px;">
<el-icon size="20" style="top: 5px">
<View/>
</el-icon>
20
<el-icon size="20" style="margin-left: 10px; top: 5px">
<Pointer/>
</el-icon>
10
<el-icon size="20" style="margin-left: 10px; top: 5px">
<ChatLineRound/>
</el-icon>
30
</div>
</div>
</div>
</div>
<!-- 咨询-->
<div style="width: 40%; ">
<div style=" padding: 10px; margin-left: 10px; border: 1px solid #ccc; border-radius: 10px; margin-bottom: 10px">
<div style="font-size: 18px; padding: 10px; color: salmon"><b>交友资讯</b></div>
<div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
<div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
<div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
<div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
<div style="font-size: 14px; margin: 10px"><span style="color: goldenrod">1. </span> <span>经常不在家,如何让亲人听到你的声音?</span></div>
</div>
<div style=" padding: 10px; margin-left: 10px; border: 1px solid #ccc; border-radius: 10px">
<div style="font-size: 18px; padding: 10px; color: #8ec5fc"><b>推荐圈子</b></div>
<el-row :gutter="10" style="margin: 10px 0">
<el-col :span="12">
<el-card style="margin-bottom: 10px; cursor: pointer">
<div style="padding: 5px; text-align: center">米粉圈子</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card style="margin-bottom: 10px; cursor: pointer">
<div style="padding: 5px; text-align: center">米粉圈子</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card style="margin-bottom: 10px; cursor: pointer">
<div style="padding: 5px; text-align: center">米粉圈子</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card style="margin-bottom: 10px; cursor: pointer">
<div style="padding: 5px; text-align: center">米粉圈子</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card style="margin-bottom: 10px; cursor: pointer">
<div style="padding: 5px; text-align: center">米粉圈子</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card style="margin-bottom: 10px; cursor: pointer">
<div style="padding: 5px; text-align: center">米粉圈子</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card style="margin-bottom: 10px; cursor: pointer">
<div style="padding: 5px; text-align: center">米粉圈子</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 只显示2行文本,多余的用省略号代替 */
.content {
margin: 10px 0;
line-height: 25px;
text-align: justify;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
问题集
问题一:不用登陆也可以看首页
解决:
1
2
3
// 直接进入首页,缓存里没用任何用户信息,会报错
const avatar = ref('')
const user = store.getUser // store.getUser 默认是 {},if({})是true
// 有值再赋值
if (user.avatar) {
avatar.value = user.avatar
}
问题二:
解决:
// role.vue
const handleEdit = (raw) => {
dialogFormVisible.value = true
// 未来元素渲染
nextTick(() => {
ruleFormRef.value.resetFields()
state.form = JSON.parse(JSON.stringify(raw))
// 初始化,默认不选择任何节点
permissionTreeRef.value.setCheckedKeys([])
raw.permissionIds.forEach(v => {
// 给权限树设置选中的节点
permissionTreeRef.value.setChecked(v, true, false)
})
})
}
问题三:没登录 系统不知道是用户还是管理员 没法鉴权 。 需要把权限开放给用户
解决:
问题四:保存有数据,数据库有数据,但是前台未显示数据
解决:表单deleted字段未设置默认值