SpringBoot3+WebSocket+Vue3+TypeScript实现简易在线聊天室(附完整源码参考)
目录
一、效果展示与消息格式。
二、代码实现。
(1)引入 websocket stater 起步依赖。
(2)编写 websocket 配置类。
(3)编写存储 HttpSession 配置类。
GetHttpSessionConfig 。
ChatEndPoint实体类。(服务端核心处理消息类)
(4)处理消息的自定义工具类。
(5)响应结果封装类。
三、前端代码。(核心页代码)
四、完整源代码。
- 紧跟上篇博客理论知识。
- 博客链接:消息推送与 WebSocket 学习-CSDN博客
- 本篇博客就是代码实现。
- 博主实现比较潦草且简易。搁置半个月才更新,实习上班心累,属于不想动,,,
- 如果你喜欢,请点个赞hh。如果你更强,请继续完善它吧!
一、效果展示与消息格式。
- 聊天登录。(未实现数据库登录,可以自己实现)
- 首次进入。
- 聊天1。
- 聊天2。
- 聊天3。
- 系统广播。
- 消息格式。
- 客户端 给 服务端:{"toName":"xxx","message":"hello"}。
- 服务端 给 客户端:(两种格式)
(1)系统消息格式:{"system":true,"fromName":null,"message":"xxxx","offline":true/false}。
(2)推送给某一个用户的消息格式:{"system":false,"fromName":"xxx","message":"xxxx","offline":true/false}。
- Message实体类。
package com.hyl.websocket.bean;/*** 封装浏览器(客户端)给服务端发送的消息数据*/ public class Message {private String toName;private String message;public Message(){}public Message(String toName, String message) {this.toName = toName;this.message = message;}public String getToName() {return toName;}public void setToName(String toName) {this.toName = toName;}public String getMessage() {return message;}public void setMessage(String message) {this.message = message;} }
- ResultMessage实体类。
package com.hyl.websocket.bean;/*** 封装服务端给浏览器(客户端)发送的消息数据*/ public class ResultMessage {private boolean isSystem;private String fromName;private Object message; //系统消息可能是数组(用户名称数组)private boolean offline; //是否为下线消息public ResultMessage() {}public ResultMessage(boolean isSystem, String fromName, Object message, boolean offline) {this.isSystem = isSystem;this.fromName = fromName;this.message = message;this.offline = offline;}public boolean isSystem() {return isSystem;}public void setSystem(boolean system) {isSystem = system;}public String getFromName() {return fromName;}public void setFromName(String fromName) {this.fromName = fromName;}public Object getMessage() {return message;}public void setMessage(Object message) {this.message = message;}public boolean isOffline() {return offline;}public void setOffline(boolean offline) {this.offline = offline;}@Overridepublic String toString() {return "ResultMessage{" +"isSystem=" + isSystem +", fromName='" + fromName + '\'' +", message=" + message +", offline=" + offline +'}';} }
二、代码实现。
(1)引入 websocket stater 起步依赖。
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--websocket--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency><!-- https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2 --><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>2.0.53</version></dependency><!--hutool工具类--><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.16</version></dependency>
(2)编写 websocket 配置类。
- 主要作用:扫描并添加含有注解@ServerEndponit的Bean。
- 代码。
package com.hyl.config;import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter;/*** 扫描@ServerEndpoint注解*/ @Configuration public class WebSocketConfig {@Beanpublic ServerEndpointExporter serverEndpointExporter(){return new ServerEndpointExporter();} }
(3)编写存储 HttpSession 配置类。
- 普通 Web 请求中,通过 HttpServletRequest.getSession() 即可简单获取HttpSession 。
- WebSocket 协议是独立于 HTTP 的全双工协议。HttpSession 是 HTTP 层的会话,WebSocket 的 Endpoint 中无法直接获取 HttpSession ,所以要在 WebSocket 中获取 HttpSession ,必须在 HTTP 握手阶段(还未升级为 WebSocket 时) 保存 HttpSession 的引用。
- 自定义 Configurator 继承 ServerEndpointConfig.Configurator。重写modifyHandshake()方法,获取 HttpSession 对象并存储到配置对象中。
- 在注解 @ServerEndpoint 中引入的自定义配置器(Configurator)。
GetHttpSessionConfig 。
package com.hyl.config;import jakarta.servlet.http.HttpSession; import jakarta.websocket.HandshakeResponse; import jakarta.websocket.server.HandshakeRequest; import jakarta.websocket.server.ServerEndpointConfig;/*** HTTP握手阶段(还未升级为 WebSocket时)保存 HttpSession 的引用* ChatEndpoint就通过这个配置类获取HttpSession对象进行操作(@ServerEndpoint中引入)*/ public class GetHttpSessionConfig extends ServerEndpointConfig.Configurator{@Overridepublic void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {//获取HttpSession对象HttpSession httpSession = (HttpSession) request.getHttpSession();//保存HttpSession对象到ServerEndpointConfig对象中//ChatEndpoint类中在onOpen方法中通过EndpointConfig对象获取sec.getUserProperties().put(HttpSession.class.getName(), httpSession);} }
ChatEndPoint实体类。(服务端核心处理消息类)
- @ServerEndPoint注解(端点)引入配置类。
package com.hyl.websocket;import cn.hutool.core.util.ObjectUtil; import com.alibaba.fastjson2.JSON; import com.hyl.config.GetHttpSessionConfig; import com.hyl.utils.MessageUtil; import com.hyl.websocket.bean.Message; import jakarta.servlet.http.HttpSession; import jakarta.websocket.*; import jakarta.websocket.server.ServerEndpoint; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component;import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap;/*** 针对每一个客户端都会创建一个Endpoint(端点)*/ //设置访问路径 //引入HttpSession配置类 @ServerEndpoint(value = "/chat",configurator = GetHttpSessionConfig.class) @Component public class ChatEndpoint {private static final Logger log = LoggerFactory.getLogger(ChatEndpoint.class);//保存当前所有在线用户,key为用户名,value为 Session 对象//static 共享 final 防止重新赋值 ConcurrentHashMap 线程安全private static final Map<String, Session> onlineUsers = new ConcurrentHashMap<>();private HttpSession httpSession; //成员变量,方便当前用户继续使用/*** 建立websocket连接后,被调用* @param wsSession*/@OnOpenpublic void onOpen(Session wsSession, EndpointConfig endpointConfig){ //注意这个是websocket的session//用户登录时,已经保存用户名,从HttpSession中获取用户名this.httpSession = (HttpSession) endpointConfig.getUserProperties().get(HttpSession.class.getName());String username = (String) this.httpSession.getAttribute("currentUser");if(ObjectUtil.isNotEmpty(username) && !onlineUsers.containsKey(username)){ //避免重复存储//存储当前用户以及对应的session进行存储onlineUsers.put(username, wsSession);//再将当前登录的所有用户以广播的方式进行推送String message = MessageUtil.getMessage(true, null, getAllFriendUsernames(), false);broadcastAllUsers(message);}}public Set getAllFriendUsernames(){Set<String> set = onlineUsers.keySet();return set;}/*** 广播方法* 系统消息格式:{"system":true,"fromName":null,"message":"xxx"}*/private void broadcastAllUsers(String message){if(ObjectUtil.isEmpty(message)){return;}try{//遍历当前在线用户的map集合Set<Map.Entry<String, Session>> entries = onlineUsers.entrySet();for (Map.Entry<String, Session> entry : entries) {//获取所有用户的session对象Session session = entry.getValue();if(session.isOpen()){//获取同步消息发送的实例,发送文本消息session.getBasicRemote().sendText(message);}else {onlineUsers.remove(entry.getKey());}}}catch (Exception e){e.printStackTrace();}finally {log.info("广播已发送,消息:{}", message);}}/*** 接收到浏览器(客户端)发送的数据时被调用,如:张三 -> 李四* @param message 发送的数据是JSON字符串 ,格式为:{"toName":"xxx","message":"xxx"}*/@OnMessagepublic void onMessage(String message){//当前登录的用户String username = null;//当前用户发送的消息String messageText = null;//接收人用户名String toUsername = null;try {//将消息推送给指定用户//操作JSON字符串消息需先转换为对应的Message对象Message msg = JSON.parseObject(message, Message.class);//获取接收方用户名称toUsername = msg.getToName();//获取接收方需收到的消息messageText = msg.getMessage();//获取消息接收方用户对象的session对象发消息Session toUserSession = onlineUsers.get(toUsername);//封装发送的消息username = (String) this.httpSession.getAttribute("currentUser");String toUserMsg = MessageUtil.getMessage(false, username, messageText, false);toUserSession.getBasicRemote().sendText(toUserMsg);}catch (Exception e){e.printStackTrace();}finally {log.info("发送人:{},接收人:{},消息内容:{}", username, toUsername, messageText);}}/*** 断开websocket连接被调用* @param wsSession*/@OnClosepublic void onClose(Session wsSession){ //注意这个是websocket的session//从在线用户map中剔除当前退出登录的用户String username = (String) this.httpSession.getAttribute("currentUser");if(ObjectUtil.isNotNull(username)){onlineUsers.remove(username);//通知其他用户,当前用户下线String leaveMessage = MessageUtil.getMessage(true, null, username + ",已下线",true);broadcastAllUsers(leaveMessage);//就是重新发送广播,当前在线用户的map的keyString message = MessageUtil.getMessage(true, null, getAllFriendUsernames(),false);broadcastAllUsers(message);}} }
(4)处理消息的自定义工具类。
- MessageUtil。
package com.hyl.utils;import cn.hutool.core.util.ObjectUtil; import com.alibaba.fastjson2.JSON; import com.hyl.websocket.bean.ResultMessage;/*** 封装json格式的消息工具类*/ public class MessageUtil {/**** @param isSystemMessage 是否是系统消息 广播是系统消息 私聊不是* @param fromName 谁发送的* @param message 具体消息内容* @param offline 是否离线消息* @return*/public static String getMessage(boolean isSystemMessage, String fromName, Object message , boolean offline){try {ResultMessage resultMessage = new ResultMessage();resultMessage.setSystem(isSystemMessage);resultMessage.setMessage(message);//校验fromName是否为空 系统消息默认为nullif(ObjectUtil.isNotNull(fromName)){resultMessage.setFromName(fromName);}if(offline){resultMessage.setOffline(true);}else {resultMessage.setOffline(false);}return JSON.toJSONString(resultMessage);}catch (Exception e){throw new RuntimeException("消息序列化失败", e);}} }
(5)响应结果封装类。
package com.hyl.common;import java.io.Serializable;public class ResponseData<T> implements Serializable {private String code;private String message;private T result;public ResponseData(){}public ResponseData(String code, String message) {this.code = code;this.message = message;}public ResponseData(String code, String message, T result) {this.code = code;this.message = message;this.result = result;}//泛型方法:第一个T的作用域只限于当前方法public static <T> ResponseData<T> success() {ResponseData<T> resData = new ResponseData<>();resData.setCode("200");resData.setMessage("success");return resData;}public static <T> ResponseData<T> success(T result){ResponseData<T> resData = new ResponseData<>();resData.setCode("200");resData.setMessage("success");resData.setResult(result);return resData;}public static <T> ResponseData<T> fail(String message){ResponseData<T> resData = new ResponseData<>();resData.setCode("500"); //后台服务器业务错误resData.setMessage(message);return resData;}public String getCode() {return code;}public void setCode(String code) {this.code = code;}public String getMessage() {return message;}public void setMessage(String message) {this.message = message;}public T getResult() {return result;}public void setResult(T result) {this.result = result;} }
- 实体类User。
package com.hyl.bean;/*** 用户实体类*/ public class User {private Long userId;private String username;private String password;public Long getUserId() {return userId;}public void setUserId(Long userId) {this.userId = userId;}public String getUsername() {return username;}public void setUsername(String username) {this.username = username;}public String getPassword() {return password;}public void setPassword(String password) {this.password = password;}@Overridepublic String toString() {return "User{" +"userId=" + userId +", username='" + username + '\'' +", password='" + password + '\'' +'}';} }
三、前端代码。(核心页代码)
- 这里只放了vue页面代码。前端的其他组件或重要文件可以去源码查看。
- 登录页。
<template><section class="login-container"><div class="login-box"><div class="login-box-left"><div><img style="margin-top: 60px" src="../../public/meta.svg" alt=""></img></div><div>WebSocket <br/> Online Chat Room</div></div><div class="login-div"><div class="login-title">Login In Now</div><div class="username"><n-input ref="uinput" type="text" v-model:value="usernameValue" maxlength="30" show-count round clearable placeholder="请输入用户名" size="large"></n-input></div><div class="password"><n-input ref="pinput" type="password" v-model:value="passwordValue" maxlength="30" show-count round clearable placeholder="请输入密码" show-password-on="mousedown" size="large"></n-input></div><div class="login-button"><n-button :loading="loading" strong secondary round type="success" size="large" @click="handleLogin">Click Login</n-button></div></div></div></section> </template><script setup lang="ts">import {ref} from 'vue'; import {login} from '@/api/login/api' import router from "@/router"; import {useMessage,NInput } from 'naive-ui'const usernameValue = ref<string | null>(); const passwordValue = ref<string | null>(); const uinput = ref<InstanceType<typeof NInput> | null>(); const pinput = ref<InstanceType<typeof NInput> | null>(); const message = useMessage();//新增加载显示 const loading = ref<boolean>(false);async function handleLogin() {loading.value = true;if(!usernameValue.value){message.warning('用户名不能为空!');uinput.value?.focus();loading.value = false;return;}if(!passwordValue.value){message.warning('密码不能为空!');pinput.value?.focus();loading.value = false;return;}try {const res = await login(usernameValue.value, passwordValue.value)if(res?.code === '200'){message.success('登录成功');router.replace('/home');}else {message.error(res?.message);usernameValue.value = '';passwordValue.value = '';uinput.value?.focus();}}catch (err) {console.log('登录请求异常:', err);message.error('网络异常,无法连接服务器');}finally {loading.value = false;} }</script><style scoped>.login-container {height: 100vh;width: 100vw;background-image: url('@/assets/img/login_bg.jpg'); /*背景图片*/background-size: cover; /*填充*/background-repeat: no-repeat; /*不重复*/background-position: center; /*居中*/background-attachment: fixed; /*固定*/display: flex;justify-content: center;align-items: center;text-align: center;}.login-box {width: 800px;height: 600px;font-size: 28px;font-weight: 800;color: #ffffff;display: flex;justify-content: left;border-radius: 20px;box-shadow: 5px 5px 5px #888888, -5px -5px 5px #DDDDDD;overflow: hidden; }.login-box-left {height: 100%;width: 40%;background-color: #9ddd9d; }.login-div {height: 100%;width: 60%;background-color: white;display: flex;flex-direction: column;align-items: center;.login-title {margin-top: 150px;font-size: 30px;font-weight: 700;color: #777676;}.username {width: 350px;margin-top: 40px;}.password {width: 350px;margin-top: 20px;}.login-button {margin-top: 20px;} }</style>
- 聊天主页。
<template><section class="message"><div class="message-box"><!-- 头部 --><div class="box-head"><div class="head-user">当前用户:{{ username }}<span class="status" v-if="isOnline">(在线)</span><span class="status" v-else>(离线)</span></div><div class="head-chat" v-show="chatNameShow">正在和 {{toName}} 聊天</div></div><!-- 主体内容 --><div class="box-main"><!-- 左部分 --><div class="box-left"><!-- 左上部分-未点击好友-空白聊天背景 --><div class="left-nomessage" v-show="!isShowChat"><div class="nomessage-title">当前无聊天。请选择好友列表中的在线好友开始聊天吧!</div></div><!-- 左上部分-消息聊天框 --><div class="left-message" v-show="isShowChat"><div v-for="item in historyMessage"><!--左部分-对面消息--><div class="msg-guest" v-if="item.fromName"><div class="msg-name">{{item.fromName}}</div><div class="msg-content"><img class="msg-img" src="../assets/img/avatar1.jpg" alt=""><div class="message-1">{{item.message}}</div></div></div><!--右边部分-自己消息--><div class="msg-host" v-else><div class="msg-name">{{username}}</div><div class="msg-content"><img class="msg-img" src="../assets/img/avatar2.jpg" alt=""><div class="message-2">{{item.message}}</div></div></div></div></div><!-- 左下部分-消息发送框 --><div class="left-input"><div class="input-wrapper"><n-input v-model:value="sendMessage.message" @keydown:enter="handleEnter" class="l-textarea" type="textarea" placeholder="在此输入文字信息,,," :autosize="{minRows: 3,maxRows: 5}"/><n-button class="send-btn" type="success" size="small" @click="submit">发送</n-button></div><div class="send-tip">提示:Shift + Enter 换行</div></div></div><!-- 右部分 --><div class="box-right"><!-- 右上部分-好友列表 --><div class="right-friend"><div class="right-title"><div>好友列表</div></div><div class="friend-list"><ul><li v-for="item in friendList"><div style="cursor: pointer" @click="showChat(item)">{{ item }}<span class="status"></span></div></li></ul></div></div><!-- 右下部分-广播列表(系统消息) --><div class="right-sys"><div class="right-title"><div>系统广播</div></div><div class="sys-msg"><ul><li v-for="item in systemOfflineMessages">{{item}}</li><li v-for="item in systemMessages">{{item}} 已上线</li></ul></div></div></div></div></div></section> </template><script setup lang="ts">import {ref,onMounted} from "vue"; import {getCurrentUsername} from "@/api/home/api"; import {ChatMessage, ResponseData, SendMessage} from "@/api/type"; import { useMessage } from 'naive-ui'const websocket = ref<WebSocket | null>(); const data = ref<ResponseData<string>>(); //初始化数据 const message = useMessage();const username = ref<string | null>(''); // 当前用户 const isOnline = ref<boolean>(true); const toName = ref<string | null>(''); // 聊天对象 const chatNameShow = ref<boolean>(false); // 聊天对象是否显示const friendList = ref<string[]>([]); //在线好友列表 const systemOfflineMessages = ref<string[]>([]); //离线消息 const systemMessages = ref<string[]>([]); //系统消息 const historyMessage = ref<ChatMessage[]>([]); //聊天历史消息 const isShowChat = ref<boolean>(false); //是否显示聊天框/*message*/ const sendMessage = ref<SendMessage>({toName: '',message: '', })const handleEnter = (e: KeyboardEvent) =>{if(!e.shiftKey){e.preventDefault(); //阻止默认回车换行行为submit();} }function showChat (friend: string) {toName.value = friend; // 聊天对象赋值//处理历史聊天信息const history = sessionStorage.getItem(toName.value);if(!history){historyMessage.value = [];} else {historyMessage.value = JSON.parse(history); //反序列化获取历史聊天记录}isShowChat.value = true; //渲染聊天chatNameShow.value = true; //渲染正在和谁聊天 }//发送信息 const submit = () =>{sendMessage.value.message = sendMessage.value.message.trim();if(!sendMessage.value.message){message.warning('请输入聊天内容');return;}//接收消息人sendMessage.value.toName = toName.value;//添加自己的一条信息historyMessage.value.push(JSON.parse(JSON.stringify(sendMessage.value)));//临时保存与该对象的聊天历史消息sessionStorage.setItem(toName.value, JSON.stringify(historyMessage.value)) //存字符串类型,序列化stringify,反序列化parse//向服务端发送信息websocket.value.send(JSON.stringify(sendMessage.value));sendMessage.value.message = ''; }//onOpen function onOpen() {isOnline.value = true; }const onClose = () => {isOnline.value = false; }//接收服务端发送的信息 const onMessage = (event: MessageEvent) =>{const dataString = event.data; //服务端推送的信息console.log(dataString);const resJsonObj = JSON.parse(dataString);console.log(resJsonObj);//系统信息if(resJsonObj && resJsonObj.system){const names = resJsonObj.message;const offline = resJsonObj.offline;if(offline){systemOfflineMessages.value.push(resJsonObj.message);}else {for(let i=0; i<names.length; i++){if(names[i] !== username.value && !friendList.value.includes(names[i])){friendList.value.push(names[i]);systemMessages.value.push(names[i]);}}}}else { //聊天信息//获取聊天记录const history = sessionStorage.getItem(resJsonObj.fromName);if(!history){historyMessage.value = [];}historyMessage.value.push(resJsonObj);sessionStorage.setItem(resJsonObj.fromName, JSON.stringify(historyMessage.value))} }//初始化 const init = async () => {await getCurrentUsername().then((res) =>{if(res.code === '200'){data.value = res;username.value = data.value.result;}}).catch((err)=>{console.log(err);});/*创建websocket对象*/websocket.value = new WebSocket('ws://localhost:8081/chat');websocket.value.onopen = onOpen;websocket.value.onmessage = onMessage;websocket.value.onclose = onClose;}onMounted(()=>{init(); })</script><style scoped>.message {height: 100vh;display: flex;justify-content: center;align-items: center; }.message-box {width: 50%;height: 700px;border: 1px solid #afdaaf;border-radius: 5px;overflow: hidden; /* 防止内部元素溢出 */box-shadow: 0 2px 8px rgba(157, 221, 157, 0.3); }.box-head {background-color: #afeaaf;height: 60px;padding: 0 10px;display: flex;flex-direction: column;justify-content: flex-start; /*子元素靠顶部对齐,防止拉伸挤压*/.head-user {font-size: 16px;font-weight: 600;margin-top: 5px;.status {font-size: 12px;font-weight: 500;margin-left: 5px;color: #0c8fe1;}}.head-chat {text-align: center;font-size: 18px;font-weight: 600;} }/*主体内容*/ .box-main {height: calc(100% - 60px);display: flex;.box-left {height: 100%;width: 70%;border-right: 1px solid #c2c3c5;display: flex;flex-direction: column;/*左侧无消息*/.left-nomessage {flex: 1;padding: 15px;overflow-y: auto; /*内容过多下滑*/background-color: #fff;.nomessage-title {color: #eae5e5;font-size: 15px;font-weight: 500;line-height: 25px;background-color: #9e9c9c;padding: 5px 10px;}}/*左侧消息*/.left-message {flex: 1;padding: 15px;overflow-y: auto; /*内容过多下滑*/background-color: #fff;.msg-guest {margin-bottom: 12px;padding: 0 5px;text-align: left;.msg-name {font-size: 14px;color: #000000;font-weight: 400;line-height: 25px;}.msg-content {display: flex;align-items: flex-start;.msg-img {width: 36px;height: 36px;border-radius: 5px;}.message-1 {margin-left: 10px;background-color: #f0f0f0;padding: 8px 12px;border-radius: 8px;max-width: 70%; /* 限制气泡最大宽度 */}}}.msg-host {margin-bottom: 12px;padding: 0 5px;text-align: right;.msg-name {font-size: 14px;color: #000000;font-weight: 400;line-height: 25px;}.msg-content {display: flex;align-items: flex-start;flex-direction: row-reverse; /*反转*/.msg-img {width: 36px;height: 36px;border-radius: 5px;margin-left: 10px;}.message-2 {background-color: #beedc6;padding: 8px 12px;border-radius: 8px;max-width: 70%;}}}}/*左侧输入框*/.left-input {display: flex;flex-direction: column;padding: 12px 15px;border-top: 1px solid #c2c3c5;.input-wrapper {position: relative;flex: 1;.l-textarea {width: 100% !important; /*确保输入框占满容器*/padding-bottom: 50px !important; /*给下侧按钮预留空间*/}.send-btn {position: absolute;bottom: 8px;right: 8px;padding: 10px 30px;}}.send-tip {margin-top: 5px;color: #c2c3c5;}}}.box-right {width: 30%;height: 100%;/*处理子容器分布*/display: flex;flex-direction: column;.right-title {font-size: 16px;font-weight: 500;padding: 8px 10px;border-bottom: 1px solid #c2c3c5;}.right-friend {height: 40%;display: flex;flex-direction: column;overflow: hidden;.friend-list {flex: 1;overflow-y: auto;padding: 8px 10px;}}.right-sys {flex: 1;display: flex;flex-direction: column;overflow: hidden;.sys-msg {flex: 1;overflow-y: auto;padding: 8px 10px;}}.friend-list ul,.sys-msg ul {margin: 0;padding: 0;list-style: none;}}}</style>
四、完整源代码。
(1)gitee仓库源码地址:https://gitee.com/suisuipingan-hyl/online-chat-room。