前两篇笔记主要学习了ruoyi脚手架的项目结构,并在原项目的基础上尝试修改开发了通知公告且搭建了公告发布的审批流程功能,本次继续在上述基础上完善,增加消息弹窗功能。
四、消息弹窗功能
4.1 功能策划
本次新增一个“即时消息”页面,并实现以下功能:
- 在新增的“即时消息”页面中,可以手动给任何在线的用户发送弹窗消息
- 流程节点的相关人员,在流程到达后,会自动收到系统的弹窗消息,提醒有流程需要操作
最终效果如下所示:
功能2只在功能1基础上增加监听器即可实现,故以下首先实现功能1:
4.2 WebSocket 连接建立
本次利用WebSocket实现即时消息发送,WebSocket:
WebSocket 是 HTML5 一种新的协议。它实现了浏览器与服务器全双工通信,能更好的节省服务器资源和带宽并达到实时通讯,它建立在 TCP 之上,同 HTTP 一样通过 TCP 来传输数据,但是它和 HTTP 最大不同是:
- WebSocket 是一种双向通信协议,在建立连接后,WebSocket 服务器和 Browser/Client Agent 都能主动的向对方发送或接收数据,就像 Socket 一样
- WebSocket 需要类似 TCP 的客户端和服务器端通过握手连接,连接成功后才能相互通信
4.2.1 前端修改
为了能够使后端在建立WebSocket连接后,能够区分不同的Session对应的用户,则需要在建立连接握手时把不同的用户身份标记上,前端向后端传递用户身份可以在请求地址后面使用“?”加身份信息的方式,也可以在前端请求建立WebSocket连接时携带token作为参数的方式。本次按后种方式改造前端。
同时为了在用户登录后立即建立连接,并使无论用户登录后处在任意一个页面都可以接收到弹窗,故在导航栏对应的views即Navbar.vue中发起连接请求:
mounted() {
....
const wsuri = "ws://127.0.0.1:8080/websocket/message";
this.ws = new WebSocket(wsuri,store.getters.token);
}
4.2.2 后端修改
在ruoyi-framework下新建websocket包,新建一个java类MyHandshakeInterceptor.java,实现HandshakeInterceptor接口,用于处理握手前后的信息,把后端传递过来的token鉴权后转换为对应的用户Id作为session的key值,故在该类中实现以下方法:
握手前,通过LoginHelper找到对应的用户ID:
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Map<String, Object> map) {
if (request instanceof ServletServerHttpRequest) {
HttpServletRequest req = ((ServletServerHttpRequest) request).getServletRequest();
String authorization = req.getHeader("Sec-WebSocket-Protocol");
LoginUser loginUser = LoginHelper.getLoginUser(authorization);
String userId = String.valueOf(loginUser.getUserId());
map.put("loginUser", userId);
if (Objects.isNull(loginUser)){
serverHttpResponse.setStatusCode(HttpStatus.FORBIDDEN);
return false;
}
}
return true;
}
握手后,需要再把前端自定义协议头Sec-WebSocket-Protocol原封不动返回回去,否则会报错:
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) {
HttpServletRequest httpRequest = ((ServletServerHttpRequest) request).getServletRequest();
HttpServletResponse httpResponse = ((ServletServerHttpResponse) serverHttpResponse).getServletResponse();
if (StringUtils.isNotEmpty(httpRequest.getHeader("Sec-WebSocket-Protocol"))) {
httpResponse.addHeader("Sec-WebSocket-Protocol", httpRequest.getHeader("Sec-WebSocket-Protocol"));
}
}
再新建一个 WsSessionManager类,用以管理各个在线用户建立的session:
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
public class WsSessionManager {
/**
* 保存连接 session 的地方
*/
public static ConcurrentHashMap<String, WebSocketSession> SESSION_POOL = new ConcurrentHashMap<>();
/**
* 添加 session
*
* @param key
*/
public static void add(String key, WebSocketSession session) {
// 添加 session
SESSION_POOL.put(key, session);
}
/**
* 删除 session,会返回删除的 session
*
* @param key
* @return
*/
public static WebSocketSession remove(String key) {
// 删除 session
return SESSION_POOL.remove(key);
}
/**
* 删除并同步关闭连接
*
* @param session
*/
public static void removeAndClose(WebSocketSession session) {
String key = getKey(session);
if(key != null) {
WebSocketSession removedSession = remove(key);
if (removedSession != null) {
try {
// 关闭连接
session.close();
System.out.println("WS关闭成功");
} catch (IOException e) {
// todo: 关闭出现异常处理
e.printStackTrace();
}
}
} else {
System.out.println("该session不存在");
}
}
/**
* 获得 session
*
* @param key
* @return
*/
public static WebSocketSession get(String key) {
// 获得 session
return SESSION_POOL.get(key);
}
/**
* 获得session对应的 key
*
* @param session
* @return
*/
public static String getKey(WebSocketSession session) {
if(SESSION_POOL.containsValue(session)) {
Set<Map.Entry<String, WebSocketSession>> oneEntry = SESSION_POOL.entrySet();
for (Map.Entry<String, WebSocketSession> entry : oneEntry) {
if (entry.getValue().equals(session)) {
return entry.getKey();
}
}
}
return null;
}
}
新建MyWsHandler类实现AbstractWebSocketHandler抽象类,并重写afterConnectionEstablished方法,用以把握手完成后的session存储到上述的WsSessionManager管理类中进行管理:
@Component
@Slf4j
public class MyWsHandler extends AbstractWebSocketHandler {
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("建立ws连接");
String userId = session.getAttributes().get("loginUser").toString();
WsSessionManager.add(userId,session);
log.info("当前连接池人数:{}",WsSessionManager.SESSION_POOL.size());
log.info("当前连接池内容:{}",WsSessionManager.SESSION_POOL.entrySet());
}
....
}
最后,新建一个WebSocketConfig的配置类,把上述MyWsHandler和MyHandshakeInterceptor握手前和建立连接后的实例注册到WebSocket,并将后端服务的地址设置为前端请求的地址(本次为/websocket/message)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* websocket 配置
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer{
@Autowired
private PortalHandshakeInterceptor portalHandshakeInterceptor;
@Autowired
private MyWsHandler webSocketServer;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketServer,"/websocket/message").addInterceptors(portalHandshakeInterceptor).setAllowedOrigins("*");
}
}
注意,registry最后.setAllowedOrigins("*")避免跨域问题,否则前端报错无法连接ws
至此,WebSocket连接和管理已完成。
4.3 获取在线用户列表
WebSocket建立完成后,前端还需要获取到当前在线的用户列表,以保证前端的当前用户在发送弹窗消息时,能够指明是要发给谁的,效果如下:
4.3.1 后端Controller相关修改
本次前后端通讯session是使用用户id来标识和区分的,而原框架中在线用户相关的Controller返回结果数据中,并没有id信息,故需要先改造SysUserOnline和UserOnlineDTO两个数据对象类,在其中增加userId即可(为了前端显示头像同时增加avatar字段):
/**
* 用户ID
*/
private String userId;
/**
* 头像
*/
private String avatar;
之后针对SysUserOnlineController改造,仿照list方法新增一个sessionlist方法,用以只获取在线用户且session仍连接的列表信息:
@SaCheckPermission("monitor:online:list")
@GetMapping("/sessionlist")
public TableDataInfo<SysUserOnline> sessionlist(String Dept, String userNick) {
// 获取所有未过期的 token
List<String> keys = StpUtil.searchTokenValue("", 0, -1, false);
List<UserOnlineDTO> userOnlineDTOList = new ArrayList<>();
for (String key : keys) {
String token = StringUtils.substringAfterLast(key, ":");
// 如果已经过期则跳过
if (StpUtil.stpLogic.getTokenActivityTimeoutByToken(token) < -1) {
continue;
}
UserOnlineDTO userOnlineDTO = RedisUtils.getCacheObject(CacheConstants.ONLINE_TOKEN_KEY + token);
SysUser one = sysUserService.selectUserByUserName(userOnlineDTO.getUserName());
userOnlineDTO.setUserId(String.valueOf(one.getUserId()));
userOnlineDTO.setAvatar(one.getAvatar());
userOnlineDTO.setUserNick(one.getNickName());
userOnlineDTOList.add(userOnlineDTO);
}
if (StringUtils.isNotEmpty(Dept) && StringUtils.isNotEmpty(userNick)) {
userOnlineDTOList = StreamUtils.filter(userOnlineDTOList, userOnline ->
StringUtils.contains(userOnline.getDeptName(),Dept) &&
StringUtils.contains(userOnline.getUserNick(),userNick)
);
} else if (StringUtils.isNotEmpty(Dept)) {
userOnlineDTOList = StreamUtils.filter(userOnlineDTOList, userOnline ->
StringUtils.contains(userOnline.getDeptName(),Dept)
);
} else if (StringUtils.isNotEmpty(userNick)) {
userOnlineDTOList = StreamUtils.filter(userOnlineDTOList, userOnline ->
StringUtils.contains(userOnline.getUserNick(),userNick)
);
}
if(WsSessionManager.SESSION_POOL.size()> 0){
List<String> sessionKeys = WsSessionManager.SESSION_POOL.entrySet().stream().map(entry -> entry.getKey()).collect(Collectors.toList());
userOnlineDTOList = StreamUtils.filter(userOnlineDTOList, userOnline ->
sessionKeys.contains(userOnline.getUserId())
);
}
Collections.reverse(userOnlineDTOList);
userOnlineDTOList.removeAll(Collections.singleton(null));
List<SysUserOnline> userOnlineList = BeanUtil.copyToList(userOnlineDTOList, SysUserOnline.class);
return TableDataInfo.build(userOnlineList);
}
为了防止前端同一用户多终端登录造成的N条相同id,导致其他浏览器无法收到消息的问题,可以重新设置application.yml中的相关配置:
将上述/sessionlist添加到前端api的online.js中
// 查询在线且session在线的用户列表
export function sessionlist(query) {
return request({
url: '/monitor/online/sessionlist',
method: 'get',
params: query
})
}
4.3.2 前端页面开发
在前端新增一个“即时消息”的全新的页面,访问上述api接口拿到在线用户列表数据
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="68px">
<el-row>
<el-col :span="18" v-show="showSearch" >
<el-form-item label="用户昵称" prop="userNick">
<el-input
v-model="queryParams.userNick"
placeholder="请输入用户昵称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="部门名称" prop="Dept">
<el-input
v-model="queryParams.Dept"
placeholder="请输入部门名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" style="height: 50px;"></right-toolbar>
</el-row>
</el-form>
<el-table v-loading="loading" :data="listSlice" @selection-change="handleSelectionChange" style="width: 100%;" >
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" type="index" align="center">
<template slot-scope="scope">
<span>{{(pageNum - 1) * pageSize + scope.$index + 1}}</span>
</template>
</el-table-column>
<el-table-column label="头像" align="center" >
<template slot-scope="scope" >
<el-Image style="width: 50px; margin: 6px 0 0 0;" :src="scope.row.avatar" @click.stop/>
</template>
</el-table-column>
<el-table-column label="用户昵称" align="center" prop="userNick" :show-overflow-tooltip="true" />
<el-table-column label="部门名称" align="center" prop="deptName" />
<el-table-column label="主机" align="center" prop="ipaddr" :show-overflow-tooltip="true" />
<el-table-column label="登录地点" align="center" prop="loginLocation" :show-overflow-tooltip="true" />
<el-table-column label="浏览器" align="center" prop="browser" />
<el-table-column label="操作系统" align="center" prop="os" :show-overflow-tooltip="true"/>
<el-table-column label="登录时间" align="center" prop="loginTime" width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.loginTime) }}</span>
</template>
</el-table-column>
</el-table>
<pagination v-show="total>0" :total="total" :page.sync="pageNum" :limit.sync="pageSize" />
</div>
</template>
<script>
import { sessionlist } from "@/api/monitor/online";
import store from '@/store';
export default {
name: "FDept",
data() {
return {
showSearch: true,
// 遮罩层
loading: true,
// 总条数
total: 0,
// 表格数据
list: [],
pageNum: 1,
pageSize: 10,
// 查询参数
queryParams: {
Dept: undefined,
userNick: undefined
},
toUsers:[],
};
},
created() {
this.getList();
},
computed: {
listSlice () {
this.list.forEach(one => {
one.avatar = (one.avatar == "" || one.avatar == null) ? require("@/assets/images/profile.png") : one.avatar;
} );
return this.list.slice((this.pageNum-1)*this.pageSize,this.pageNum*this.pageSize);
}
},
methods: {
/** 搜索按钮操作 */
handleQuery() {
this.pageNum = 1;
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
},
/** 查询登录日志列表 */
getList() {
this.loading = true;
let self = this;
sessionlist(self.queryParams).then(response => {
self.list = response.rows;
self.total = response.total;
self.loading = false;
});
},
/* 多选框选择处理 */
handleSelectionChange(selection) {
this.toUsers = selection.map(item => item.userId)
},
},
};
</script>
其中:
- listSlice方法主要是改造的原框架中在线用户前端页面的列表分页方式,原框架中是在template部分中直接计算的分页,这导致和后期的input文本框双向绑定数据时,用户输入内容时会与列表的checkbox多选按钮冲突(不管先选了哪个checkbox,在input输入时都会重置),为了解决这个问题在computed中计算此属性即可
- 当用户头像为默认值时,后端传过来的用户头像avatar值为空,故需要针对list在前端用foreach重新写入avatar,但这个操作不能在mounted中进行,所以放到了computed
- toUsers是为了记录用户使用checkbox勾选的在线用户,并把这个选择在发送消息时,用json数据形式传递到后端
4.4 手动发送消息
在“即时消息”页面添加form表单,收集用户要发的弹窗消息内容,勾选要发送的用户后,点击发送按钮把消息发送到后端:
4.4.1 前端增加form内容
在上述4.3的前端“即时消息”页面的基础上,增加以下内容
<el-form :model="queryParams" ref="queryForm" size="small" class="myform">
<el-row type="flex" justify="center" align="middle">
<el-col :span="21">
<el-form-item class="myitem">
<el-input type="textarea" v-model="message" :rows="9" style="width: 100%; height:100% ;" placeholder="在这里输入发送的消息内容后,选择下方要发送的人员后点击发送,可立即发送弹窗消息" class="myinput"/>
</el-form-item>
</el-col>
<el-col :span="3">
<el-form-item>
<el-button size="big" style="width: 100%; height: 188px; margin: 0px 5px;" type="primary" @click="send" plain>点击发送弹窗消息</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
data() {
return {
...
//要发送的即时消息内容
message: "",
//要发送的整体信息
usersMessage: {
fromNick:"",
users:[],
msg:""
},
};
},
methods: {
...
//发送方法
send() {
if (store.getters.ws) {
this.usersMessage.fromNick = store.getters.nick;
this.usersMessage.users = this.toUsers;
this.usersMessage.msg = this.message;
store.getters.ws.send(JSON.stringify(this.usersMessage));
} else {
alert("未连接到服务器");
}
},
}
其中:
- 为了后期显示数据时,能显示是谁发来的消息,则需要把当前用户的昵称也装入整体消息,并按json数据形式发送给后端,这里通过store中增加nick字段的方式实现
- 关键的send方法中,需要注意的是,此处不应该再新建WebSocket连接,即不要通过ws=new websocket(url)的方式再新建一个,这样会使后端挤掉原WsSessionManager中已经建立完成的连接,导致其他用户再给本用户发消息时无法接收到。故应拿到原session才行,本次是通过VueX,在Navbar.vue页面建立Websocket时就把原session存为全局变量,然后“即时消息”页面直接从store中取出此session发送消息即可:
this.usersMessage.fromNick = store.getters.nick;
store.getters.ws.send(JSON.stringify(this.usersMessage));
存储session和nick的代码,在store中的user模块和getters中:
const user = {
state: {
...
nick:'',
ws:null
},
mutations: {
...
SET_WS:(state,session)=> {
state.ws = session
},
SET_NICK:(state,nick)=> {
state.nick = nick
}
},
actions: {
...
// 获取用户信息
GetInfo({ commit, state }) {
return new Promise((resolve, reject) => {
...
commit('SET_NICK', user.nickName)
resolve(res)
}).catch(error => {
reject(error)
})
})
},
}
export default user
const getters = {
...
ws: state => state.user.ws,
nick:state => state.user.nick,
}
export default getters
在Navbar.vue中的mounted添加
this.$store.commit('SET_WS',this.ws);
4.4.2 后端收到消息后的处理
后端收到消息后解析出收消息者列表(toUsers)、发消息者(fromUser)、消息内容(msg)三类信息,之后从WsSessionManager中查找对应的session完成消息发送:
在MyWsHandler中实现handleTextMessage方法:
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.info("发送文本消息");
// 获得客户端传来的消息
JSONObject json = JSONUtil.parseObj(message.getPayload());
if(json != null) {
List<String> users = (List<String>) json.get("users");
String msg = (String) json.get("msg");
String fromNick = (String) json.get("fromNick");
if(fromNick == null){
fromNick ="后台系统";
}
for(String user : users) {
WebSocketSession userSession = WsSessionManager.get(user);
System.out.println(userSession);
if (userSession != null) {
//向消息者发送消息
JSONObject jsonObject = new JSONObject();
jsonObject.set("fromUser", fromNick);
jsonObject.set("msg", msg);
userSession.sendMessage(new TextMessage(jsonObject.toString()));
//向发送者反馈发送信息
JSONObject sendResult = new JSONObject();
sendResult.set("fromUser", "系统发送反馈");
sendResult.set("msg", "消息发送成功");
if(session != null) {
session.sendMessage(new TextMessage(sendResult.toString()));
}
} else {
//向发送者反馈发送信息
JSONObject sendResult = new JSONObject();
sendResult.set("fromUser", "系统发送反馈");
sendResult.set("msg", "消息发送失败,可能是对方已经下线,请刷新用户列表");
if(session != null) {
session.sendMessage(new TextMessage(sendResult.toString()));
}
}
}
}
}
4.5 前端弹窗消息
前端根据后端发送回来的消息解析,通过fromUser分辨出是系统对发送者的信息反馈还是收到的弹窗消息,再做相关的不同的信息显示:
反馈消息:
弹窗消息:
在前端的Navbar.vue的mounted中改造如下:
mounted() {
//console.log("hello");
// vue 3.0以上跳转多次报错的解决方法
const originalPush = Router.prototype.push;
Router.prototype.push = function push(location) {
return originalPush.call(this, location).catch((err) => err);
};
const wsuri = "ws://127.0.0.1:8080/websocket/message";
this.ws = new WebSocket(wsuri,store.getters.token);
const self = this;
this.$store.commit('SET_WS',this.ws);
console.log(store.getters.ws);
console.log("已经建立连接!" );
this.ws.onopen = function (event) {
console.log("已经打开连接!" );
//self.text_content = self.text_content + "已经打开连接!" + "\n";
};
this.ws.onmessage = function (event) {
console.log("收到消息!!!");
//self.text_content = event.data + "\n";
// 判断是推动预警消息的时候
var fromUser = JSON.parse(event.data).fromUser;
var msg = JSON.parse(event.data).msg;
// 预警消息包含预警id的时候
if(fromUser == "系统发送反馈"){
if(msg.includes("成功")){
self.$modal.msgSuccess(msg);
} else {
self.$modal.msgError(msg);
}
} else if(fromUser == "流程中心") {
Notification.info({
title: "来自" + fromUser + "的消息 " + moment(new Date().getTime()).format(
"HH:mm:ss"
),
dangerouslyUseHTMLString: true,
message: msg,
duration: 3000,
position: "bottom-right",
onClick: function () {
self.flowDetail(); //自定义回调,message为传的参数
},
});
} else {
Notification.info({
title: "来自" + fromUser + "的消息 " + moment(new Date().getTime()).format(
"HH:mm:ss"
),
dangerouslyUseHTMLString: true,
message: msg,
duration: 3000,
position: "bottom-right",
onClick: function () {//预留跳转
//self.warnDetailByWarnid(messageBody.warnId); //自定义回调,message为传的参数
// 点击跳转的页面
},
});
}
};
this.ws.onclose = function (event) {
self.text_content = self.text_content + "已经关闭连接!" + "\n";
};
},
其中,当fromUser为“流程中心”时,点击弹窗直接到代办任务页面,为功能2流程流转时的弹窗提醒做预留。methods中设置flowDetail:
flowDetail() {
// 跳转流程中心代办任务详情页面
this.$router.push({
path: "/work/todo",
query: {},
});
},
4.6 流程流转时的消息弹窗
后端流程模型中增加监听器,监听到相关任务执行时直接触发流程节点人员的session,发送消息即可实现消息提醒。
流程模型中增加监听器:
注意监听器需要填写全类名,之后在后端实现该监听器:
import cn.hutool.json.JSONObject;
import com.ruoyi.framework.websocket.WsSessionManager;
import org.flowable.engine.delegate.TaskListener;
import org.flowable.task.service.delegate.DelegateTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
/**
* 用户任务监听器
*
* @author KonBAI
* @since 2023/5/13
*/
@Component(value = "userTaskListener")
public class UserTaskListener implements TaskListener {
private Logger logger = LoggerFactory.getLogger(UserTaskListener.class);
/**
* 注入字段(名称与流程设计时字段名称一致)
*/
// private FixedValue field;
@Override
public void notify(DelegateTask delegateTask) {
//TODO 实现你的任务监听器逻辑
System.out.println("执行任务监听器...");
String assigneeUser = delegateTask.getAssignee();
WebSocketSession userSession = WsSessionManager.get(assigneeUser);
if (userSession != null) {
//向消息者发送消息
JSONObject jsonObject = new JSONObject();
jsonObject.set("fromUser", "流程中心");
jsonObject.set("msg", "您有一个待办任务,请及时处理");
try {
userSession.sendMessage(new TextMessage(jsonObject.toString()));
} catch (IOException e) {
throw new RuntimeException(e);
}
logger.info(assigneeUser + "的流程消息发送成功");
} else {
logger.info(assigneeUser + "用户未连接,流程消息发送失败");
}
}
}
整体监听器是从传递过来的delegateTask中,通过getAssignee拿到用户ID的。如果流程模型中是会签等多用户的节点,系统会启动多个监听器,每个监听器对应一个用户id。
注意在监听器中,是没办法直接用到其他service类等类似的Bean的,可以使用ruoyi框架中Utils工具类的springUtils来获取。
4.7 最终效果