零基础入门:基于WebSocket的一对一聊天室实现
从HTTP到WebSocket:构建一对一聊天室
HTTP与WebSocket:TCP之上的两种通信方式
在构建实时应用(如聊天室、在线游戏等)时,我们通常需要服务器(后端)能够主动向客户端(前端)推送数据,而不是只能由客户端(前端)发起请求。传统的HTTP协议是一种无状态的请求-响应
协议,难以满足这样的需求。为此,WebSocket协议应运而生。
在之前用HTTP写CRUD时,我们一直是客户端(前端)向服务端(后端)发送请求,然后服务端(后端)从数据库中获取数据并返回给客户端(前端)数据(一般为JSON格式)。
HTTP 是一种无状态的请求-响应协议:
-
客户端发送请求,服务器返回响应
-
每次通信都需要建立新的TCP连接(HTTP/1.1引入持久连接有所改善)
-
服务器无法主动向客户端推送数据
WebSocket 则是全双工通信协议:
-
建立连接后,双方可以随时主动发送数据
-
一次握手,持久连接
-
低延迟,适合实时应用
为什么需要建立WebSocket连接?
通过上文,我们了解到websocket与http的区别之一:就是websocket要先建立连接
虽然HTTP协议可以模拟实时通信(如轮询、长轮询等),但这些方式效率低下,且增加了服务器和网络的负担。WebSocket的出现解决了以下问题:
-
降低延迟:一旦连接建立,消息可以立即在客户端和服务器之间传递,无需每次都要携带HTTP头。
-
全双工通信:连接建立后,客户端和服务器可以同时发送数据。
-
减少带宽消耗:WebSocket帧头部比HTTP头部小得多。
如何建立WebSocket连接?(具体效果如下图)
WebSocket连接的建立需要借助HTTP协议进行升级。具体步骤如下:
1.客户端发起握手请求:
客户端发送一个HTTP请求,其中包含Upgrade: websocket和Connection: Upgrade头部,以及一个Sec-WebSocket-Key随机字符串,表示希望升级到WebSocket协议。
2.服务器响应握手:
服务器验证请求后,返回一个HTTP 101状态码(表示切换协议成功),并在响应头中包含Upgrade: websocket和Connection: Upgrade,以及一个由客户端发送的Sec-WebSocket-Key计算而来的Sec-WebSocket-Accept值。
值得注意的是:这个Sec-WebSocket-Accept的计算方式为:将客户端发送的Sec-WebSocket-Key与固定的GUID字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接,然后计算SHA-1哈希,最后进行Base64编码。
3.连接升级成功:
一旦握手完成,接下来的通信就使用WebSocket协议的数据帧格式,而不再是HTTP协议。

WebSocket通信
建立连接后,客户端和服务器就可以通过WebSocket协议发送和接收消息。WebSocket消息由一系列帧组成,可以传输文本数据或二进制数据。
-
发送消息:将消息封装成WebSocket帧,通过TCP连接发送。
-
接收消息:从TCP连接中读取WebSocket帧,并解析出消息。
这里比较难理解,我们借用下面两个图来说明websocket工作的整个过程以及websocket帧的数据格式。


一对一聊天室的实现
在前面的章节中,我们深入探讨了WebSocket协议的工作原理、连接建立过程以及通信机制。现在,让我们将这些理论知识付诸实践,构建一个完整的一对一实时聊天室系统。
本文将基于Spring Boot后端和Vue3前端,使用STOMP over WebSocket协议,实现一个功能完善的双人聊天应用。我们将使用咨询师11和用户26这两个固定角色来演示完整的通信流程。
我们之前讨论的WebSocket是底层协议,而STOMP是一个更高级的消息协议,它建立在WebSocket之上,提供了更简单的消息格式和交互模式。
在Spring Boot中,我们通常使用STOMP over WebSocket来构建消息传递系统。(对于STOMP不做太多的赘述)下面是流程图。

后端实现详解
1.引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2. WebSocket配置类
查看代码
package com.example.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket配置类 - 简化版(只支持实时对话)
*
* 核心功能:
* 1. 配置消息代理,支持广播和点对点消息
* 2. 注册WebSocket端点,支持浏览器兼容
* 3. 配置消息通道和拦截器
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 配置消息代理
* 启用简单内存消息代理,处理以"/topic"和"/queue"为前缀的消息
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 启用简单消息代理,用于广播和点对点消息
config.enableSimpleBroker("/topic", "/queue");
// 设置应用程序目的地前缀,客户端发送消息需要以"/app"开头
config.setApplicationDestinationPrefixes("/app");
}
/**
* 注册STOMP端点
* 提供两种连接方式:SockJS和原生WebSocket
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 添加SockJS端点,支持浏览器兼容性
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
// 添加原生WebSocket端点,方便测试
registry.addEndpoint("/ws-native")
.setAllowedOriginPatterns("*");
}
/**
* 配置客户端入站通道
* 增加线程池大小,提高并发处理能力
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 配置STOMP解码器以更宽松地处理消息格式
// 增加消息缓冲区大小
registration.taskExecutor().corePoolSize(4).maxPoolSize(8);
// 自定义消息处理,处理可能的格式问题
registration.interceptors(new StompMessageInterceptor());
}
/**
* 自定义STOMP消息拦截器,用于增强消息处理和错误恢复
*/
public class StompMessageInterceptor implements org.springframework.messaging.support.ChannelInterceptor {
@Override
public org.springframework.messaging.Message<?> preSend(org.springframework.messaging.Message<?> message, org.springframework.messaging.MessageChannel channel) {
// 这里可以添加消息日志或格式检查
return message;
}
@Override
public void postSend(org.springframework.messaging.Message<?> message, org.springframework.messaging.MessageChannel channel, boolean sent) {
// 消息发送后的处理
}
@Override
public boolean preReceive(org.springframework.messaging.MessageChannel channel) {
return true;
}
@Override
public org.springframework.messaging.Message<?> postReceive(org.springframework.messaging.Message<?> message, org.springframework.messaging.MessageChannel channel) {
return message;
}
}
}
3. WebSocket消息控制器
查看代码
package com.example.controller;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import java.time.LocalDateTime;
/**
* WebSocket消息控制器 - 简化版
* 核心功能:
* 1. 处理私聊消息路由
* 2. 消息格式转换和验证
* 3. 错误处理和状态管理
*/
@Controller
public class WebSocketController {
private static final Logger log = LoggerFactory.getLogger(WebSocketController.class);
@Resource
private SimpMessagingTemplate messagingTemplate;
/**
* 发送私聊消息(简化版,只保留实时对话功能)
* 消息路由逻辑:
* 1. 用户发送消息到 /app/chat.private
* 2. 服务器根据接收者类型和ID路由消息
* 3. 同时发送确认消息给发送者
*/
@MessageMapping("/chat.private")
public void handlePrivateMessage(@Payload MessageDTO messageDTO) {
try {
log.info("收到私聊消息: 发送者={}({}), 接收者={}, 内容={}",
messageDTO.getSenderId(), messageDTO.getSenderType(),
messageDTO.getReceiverId(), messageDTO.getContent());
// 创建简单的消息对象用于传输
ChatMessage chatMessage = new ChatMessage();
chatMessage.setSenderId(messageDTO.getSenderId());
chatMessage.setReceiverId(messageDTO.getReceiverId());
chatMessage.setSenderType(messageDTO.getSenderType());
chatMessage.setContent(messageDTO.getContent());
chatMessage.setTimestamp(LocalDateTime.now());
// 发送消息给接收者
// 根据发送者类型确定接收者类型(用户↔咨询师)
String receiverType = "USER".equals(messageDTO.getSenderType()) ? "COUNSELOR" : "USER";
String destination = "/queue/messages/" + receiverType.toLowerCase() + "/" + messageDTO.getReceiverId();
messagingTemplate.convertAndSend(destination, chatMessage);
log.info("消息路由到: {}", destination);
// 发送确认消息给发送者(可选,用于前端确认)
String senderDestination = "/queue/messages/" + messageDTO.getSenderType().toLowerCase() + "/" + messageDTO.getSenderId();
messagingTemplate.convertAndSend(senderDestination, chatMessage);
log.info("消息已发送并确认: 发送者ID={}, 接收者ID={}", messageDTO.getSenderId(), messageDTO.getReceiverId());
} catch (Exception e) {
log.error("处理私聊消息异常: {}", e.getMessage(), e);
// 发送错误消息给发送者
ErrorDTO errorDTO = new ErrorDTO("系统错误,请稍后重试");
messagingTemplate.convertAndSend("/queue/errors/" + messageDTO.getSenderType().toLowerCase() + "/" + messageDTO.getSenderId(), errorDTO);
}
}
/**
* 消息DTO类,用于接收客户端发送的消息
*/
public static class MessageDTO {
private Long senderId;
private Long receiverId;
private String senderType; // USER, COUNSELOR
private String content;
// Getters and Setters
public Long getSenderId() {
return senderId;
}
public void setSenderId(Long senderId) {
this.senderId = senderId;
}
public Long getReceiverId() {
return receiverId;
}
public void setReceiverId(Long receiverId) {
this.receiverId = receiverId;
}
public String getSenderType() {
return senderType;
}
public void setSenderType(String senderType) {
this.senderType = senderType;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
/**
* 聊天消息类,用于传输消息
*/
public static class ChatMessage {
private Long senderId;
private Long receiverId;
private String senderType;
private String content;
private LocalDateTime timestamp;
// Getters and Setters
public Long getSenderId() {
return senderId;
}
public void setSenderId(Long senderId) {
this.senderId = senderId;
}
public Long getReceiverId() {
return receiverId;
}
public void setReceiverId(Long receiverId) {
this.receiverId = receiverId;
}
public String getSenderType() {
return senderType;
}
public void setSenderType(String senderType) {
this.senderType = senderType;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
}
/**
* 错误DTO类,用于发送错误消息
*/
public static class ErrorDTO {
private String message;
public ErrorDTO(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
}
前端实现详解
安装依赖
# 安装 STOMP 客户端和 WebSocket 库
npm install @stomp/stompjs sockjs-client
app.vue
<template>
<div class="dual-chat-app">
<!-- 聊天界面标题 -->
<div class="chat-title">
<h1>双人聊天演示</h1>
<p>左侧: 咨询师11 → 用户26 | 右侧: 用户26 → 咨询师11</p>
</div>
<!-- 两个聊天界面并排显示 -->
<div class="chat-container">
<!-- 聊天界面1: 咨询师11对用户26 -->
<div :id="chatInstances[0].id" class="chat-window">
<div class="chat-app">
<!-- 顶部导航 -->
<div class="chat-header">
<div class="user-info">
<span class="current-user">当前用户: {{ chatInstances[0].currentUser.name }}</span>
<span class="online-status" :class="{ 'online': chatInstances[0].connected, 'offline': !chatInstances[0].connected }">
{{ chatInstances[0].connected ? '在线' : '离线' }}
</span>
</div>
<div class="chat-with">
正在和 {{ chatInstances[0].selectedFriend.name }} 聊天
</div>
</div>
<div class="chat-content">
<!-- 左侧好友列表 -->
<div class="friends-list">
<h3>好友列表</h3>
<div class="friend-item"
v-for="friend in chatInstances[0].friends"
:key="friend.id"
:class="{ 'active': friend.id === chatInstances[0].selectedFriend.id }">
<span class="friend-avatar">{{ friend.avatar }}</span>
<span class="friend-name">{{ friend.name }}</span>
<span class="friend-status" :class="{ 'online': friend.online, 'offline': !friend.online }"></span>
</div>
<!-- 系统广播 -->
<div class="system-section">
<h3>系统广播</h3>
<div class="system-message" v-for="(msg, index) in chatInstances[0].systemMessages" :key="index">
{{ msg.content }}
</div>
</div>
</div>
<!-- 中间聊天区域 -->
<div class="chat-main">
<div class="chat-messages">
<div v-if="getCurrentMessages(chatInstances[0]).length === 0" class="empty-chat">
开始和 {{ chatInstances[0].selectedFriend.name }} 聊天吧!
</div>
<div v-else>
<div v-for="msg in getCurrentMessages(chatInstances[0])" :key="msg.id"
class="message-bubble" :class="{ 'sent': msg.isSent, 'received': !msg.isSent }">
<div class="message-info">
<span class="sender-name">{{ msg.isSent ? '我' : msg.senderName }}</span>
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
</div>
<div class="message-text">{{ msg.content }}</div>
</div>
</div>
</div>
<!-- 消息输入区域 -->
<div class="message-input-area">
<textarea v-model="chatInstances[0].messageContent"
placeholder="在此输入文字信息..."
@keydown.enter.ctrl="sendMessage(chatInstances[0])"></textarea>
<button class="send-btn" @click="sendMessage(chatInstances[0])" :disabled="!chatInstances[0].connected">发送</button>
</div>
</div>
<!-- 右侧工具栏 -->
<div class="chat-sidebar">
<div class="sidebar-item">
<span class="sidebar-icon">😊</span>
</div>
<div class="sidebar-divider"></div>
<div class="sidebar-item">
<span class="sidebar-icon">⚙️</span>
</div>
</div>
</div>
</div>
</div>
<!-- 聊天界面2: 用户26对咨询师11 -->
<div :id="chatInstances[1].id" class="chat-window">
<div class="chat-app">
<!-- 顶部导航 -->
<div class="chat-header">
<div class="user-info">
<span class="current-user">当前用户: {{ chatInstances[1].currentUser.name }}</span>
<span class="online-status" :class="{ 'online': chatInstances[1].connected, 'offline': !chatInstances[1].connected }">
{{ chatInstances[1].connected ? '在线' : '离线' }}
</span>
</div>
<div class="chat-with">
正在和 {{ chatInstances[1].selectedFriend.name }} 聊天
</div>
</div>
<div class="chat-content">
<!-- 左侧好友列表 -->
<div class="friends-list">
<h3>好友列表</h3>
<div class="friend-item"
v-for="friend in chatInstances[1].friends"
:key="friend.id"
:class="{ 'active': friend.id === chatInstances[1].selectedFriend.id }">
<span class="friend-avatar">{{ friend.avatar }}</span>
<span class="friend-name">{{ friend.name }}</span>
<span class="friend-status" :class="{ 'online': friend.online, 'offline': !friend.online }"></span>
</div>
<!-- 系统广播 -->
<div class="system-section">
<h3>系统广播</h3>
<div class="system-message" v-for="(msg, index) in chatInstances[1].systemMessages" :key="index">
{{ msg.content }}
</div>
</div>
</div>
<!-- 中间聊天区域 -->
<div class="chat-main">
<div class="chat-messages">
<div v-if="getCurrentMessages(chatInstances[1]).length === 0" class="empty-chat">
开始和 {{ chatInstances[1].selectedFriend.name }} 聊天吧!
</div>
<div v-else>
<div v-for="msg in getCurrentMessages(chatInstances[1])" :key="msg.id"
class="message-bubble" :class="{ 'sent': msg.isSent, 'received': !msg.isSent }">
<div class="message-info">
<span class="sender-name">{{ msg.isSent ? '我' : msg.senderName }}</span>
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
</div>
<div v-if="msg.content.startsWith('data:image/')" class="message-image-container">
<img
:src="msg.content"
alt="聊天图片"
class="message-image"
@click="previewImage(msg.content)"
/>
</div>
<div v-else class="message-text">{{ msg.content }}</div>
</div>
</div>
</div>
<!-- 消息输入区域 -->
<div class="message-input-area">
<textarea v-model="chatInstances[1].messageContent"
placeholder="在此输入文字信息..."
@keydown.enter.ctrl="sendMessage(chatInstances[1])"></textarea>
<button class="send-btn" @click="sendMessage(chatInstances[1])" :disabled="!chatInstances[1].connected">发送</button>
</div>
</div>
<!-- 右侧工具栏 -->
<div class="chat-sidebar">
<div class="sidebar-item">
<span class="sidebar-icon">😊</span>
</div>
<div class="sidebar-divider"></div>
<div class="sidebar-item">
<span class="sidebar-icon">⚙️</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
// 创建两个独立的聊天实例 - 固定26为用户,11为咨询师
const chatInstances = ref([
{
id: 'chat11',
currentUser: {
id: 11,
name: '咨询师11',
type: 'COUNSELOR' // 修改为咨询师类型
},
selectedFriend: {
id: 26,
name: '用户26',
type: 'USER',
online: true
},
friends: [
{
id: 26,
name: '用户26',
type: 'USER',
online: true,
avatar: '👤'
}
],
messageContent: '',
messagesByUser: {
26: [
{
id: 1,
senderId: 11,
senderName: '咨询师11',
receiverId: 26,
content: '你好,用户26',
timestamp: new Date().toISOString(),
isSent: true
}
]
},
connected: false,
stompClient: null,
systemMessages: [
{
content: '您已上线',
timestamp: new Date().toISOString()
}
]
},
{
id: 'chat26',
currentUser: {
id: 26,
name: '用户26',
type: 'USER' // 固定为用户类型
},
selectedFriend: {
id: 11,
name: '咨询师11',
type: 'COUNSELOR',
online: true
},
friends: [
{
id: 11,
name: '咨询师11',
type: 'COUNSELOR',
online: true,
avatar: '👤'
}
],
messageContent: '',
messagesByUser: {
11: []
},
connected: false,
stompClient: null,
systemMessages: [
{
content: '您已上线',
timestamp: new Date().toISOString()
}
]
}
]);
// 连接WebSocket - 修改订阅逻辑,根据用户类型订阅不同队列
const connectWebSocket = (instance) => {
try {
// 创建SockJS实例,连接到后端WebSocket端点
const socket = new SockJS('http://localhost:8080/ws');
// 创建Stomp客户端
instance.stompClient = new Client({
webSocketFactory: () => socket,
onConnect: () => {
console.log(`WebSocket连接成功 (${instance.currentUser.name})`);
instance.connected = true;
// 固定订阅队列 - 根据用户类型使用正确的队列名称
// 用户26使用user队列,咨询师11使用counselor队列
const typePath = instance.currentUser.type.toLowerCase(); // 转换为小写
const queueName = `/queue/messages/${typePath}/${instance.currentUser.id}`;
console.log(`订阅队列: ${queueName}`);
instance.stompClient.subscribe(queueName, (message) => {
try {
const chatMessage = JSON.parse(message.body);
// 添加接收到的消息
if (!instance.messagesByUser[chatMessage.senderId]) {
instance.messagesByUser[chatMessage.senderId] = [];
}
// 查找发送者的名字
const sender = instance.friends.find(f => f.id === chatMessage.senderId);
const senderName = sender ? sender.name : `${chatMessage.senderId}`;
instance.messagesByUser[chatMessage.senderId].push({
id: Date.now(),
senderId: chatMessage.senderId,
senderName: senderName,
receiverId: chatMessage.receiverId,
content: chatMessage.content,
timestamp: chatMessage.timestamp ? new Date(chatMessage.timestamp).toISOString() : new Date().toISOString(),
isSent: false
});
// 滚动到底部
scrollToBottom(instance.id);
console.log(`收到消息 (${instance.currentUser.name}):`, chatMessage);
} catch (e) {
console.error(`解析消息失败 (${instance.currentUser.name}):`, e);
}
});
// 订阅错误消息队列 - 使用相同的类型路径
const errorQueueName = `/queue/errors/${typePath}/${instance.currentUser.id}`;
instance.stompClient.subscribe(errorQueueName, (message) => {
try {
const errorData = JSON.parse(message.body);
console.error(`收到错误 (${instance.currentUser.name}):`, errorData);
} catch (e) {
console.error(`解析错误消息失败 (${instance.currentUser.name}):`, e);
}
});
// 模拟上线通知
instance.systemMessages.push({
content: `您已上线`,
timestamp: new Date().toISOString()
});
},
onStompError: (frame) => {
console.error(`WebSocket连接错误 (${instance.currentUser.name}):`, frame);
instance.connected = false;
},
onDisconnect: () => {
console.log(`WebSocket连接断开 (${instance.currentUser.name})`);
instance.connected = false;
// 模拟下线状态更新
instance.friends.forEach(friend => {
friend.online = false;
});
}
});
// 连接
instance.stompClient.activate();
} catch (error) {
console.error(`连接异常 (${instance.currentUser.name}):`, error);
instance.connected = false;
}
};
// 断开WebSocket连接
const disconnectWebSocket = (instance) => {
if (instance.stompClient) {
instance.stompClient.deactivate();
instance.stompClient = null;
instance.connected = false;
console.log(`已断开连接 (${instance.currentUser.name})`);
}
};
// 发送消息
const sendMessage = (instance) => {
if (!instance.connected) {
alert('请先连接WebSocket');
return;
}
if (!instance.messageContent.trim()) {
alert('消息内容不能为空');
return;
}
try {
// 构造消息对象 - 确保发送正确的类型
const messageDTO = {
senderId: instance.currentUser.id,
receiverId: instance.selectedFriend.id,
senderType: instance.currentUser.type,
content: instance.messageContent
};
// 立即添加到消息列表
if (!instance.messagesByUser[instance.selectedFriend.id]) {
instance.messagesByUser[instance.selectedFriend.id] = [];
}
instance.messagesByUser[instance.selectedFriend.id].push({
id: Date.now(),
senderId: instance.currentUser.id,
senderName: instance.currentUser.name,
receiverId: instance.selectedFriend.id,
content: instance.messageContent,
timestamp: new Date().toISOString(),
isSent: true
});
// 通过WebSocket发送消息
instance.stompClient.publish({
destination: '/app/chat.private',
body: JSON.stringify(messageDTO)
});
console.log(`消息已发送 (${instance.currentUser.name}):`, messageDTO);
instance.messageContent = '';
// 滚动到底部
scrollToBottom(instance.id);
} catch (error) {
console.error(`发送消息失败 (${instance.currentUser.name}):`, error);
alert('发送消息失败: ' + error.message);
}
};
// 滚动到底部
const scrollToBottom = (chatId) => {
setTimeout(() => {
const chatContainer = document.querySelector(`#${chatId} .chat-messages`);
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}, 100);
};
// 格式化时间
const formatTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
};
// 获取当前聊天的消息列表
const getCurrentMessages = (instance) => {
if (!instance.messagesByUser[instance.selectedFriend.id]) {
instance.messagesByUser[instance.selectedFriend.id] = [];
}
return instance.messagesByUser[instance.selectedFriend.id];
};
// 组件挂载时连接WebSocket
onMounted(() => {
chatInstances.value.forEach(instance => {
connectWebSocket(instance);
});
});
// 组件卸载时断开连接
onUnmounted(() => {
chatInstances.value.forEach(instance => {
disconnectWebSocket(instance);
});
});
</script>
<style scoped>
.dual-chat-app {
display: flex;
flex-direction: column;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f5f5f5;
}
.chat-title {
text-align: center;
padding: 20px;
background-color: #2c3e50;
color: white;
}
.chat-title h1 {
margin: 0 0 10px 0;
font-size: 24px;
}
.chat-title p {
margin: 0;
font-size: 14px;
opacity: 0.8;
}
.chat-container {
display: flex;
flex: 1;
overflow: hidden;
gap: 10px;
padding: 10px;
}
.chat-window {
flex: 1;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
background-color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.chat-app {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: #34495e;
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.current-user {
font-weight: 500;
}
.online-status {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.online-status.online {
background-color: #27ae60;
}
.online-status.offline {
background-color: #7f8c8d;
}
.chat-with {
font-weight: 500;
}
.chat-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* 好友列表 */
.friends-list {
width: 200px;
background-color: white;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
}
.friends-list h3 {
padding: 15px;
margin: 0;
font-size: 16px;
color: #333;
border-bottom: 1px solid #e0e0e0;
}
.friend-item {
display: flex;
align-items: center;
padding: 15px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.friend-item:hover {
background-color: #f5f5f5;
}
.friend-item.active {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
}
.friend-avatar {
font-size: 24px;
margin-right: 12px;
}
.friend-name {
flex: 1;
font-size: 14px;
color: #333;
}
.friend-status {
width: 8px;
height: 8px;
border-radius: 50%;
}
.friend-status.online {
background-color: #27ae60;
}
.friend-status.offline {
background-color: #bdc3c7;
}
/* 系统消息区域 */
.system-section {
margin-top: auto;
border-top: 1px solid #e0e0e0;
}
.system-section h3 {
font-size: 14px;
color: #666;
}
.system-message {
padding: 10px 15px;
font-size: 12px;
color: #7f8c8d;
border-bottom: 1px solid #f0f0f0;
}
/* 聊天主区域 */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background-color: #f9f9f9;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 15px;
}
.empty-chat {
text-align: center;
color: #95a5a6;
margin-top: 50px;
font-size: 16px;
}
.message-bubble {
max-width: 70%;
padding: 10px 15px;
border-radius: 18px;
word-wrap: break-word;
}
.message-bubble.sent {
background-color: #2196f3;
color: white;
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.message-bubble.received {
background-color: white;
color: #333;
align-self: flex-start;
border-bottom-left-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.message-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
font-size: 12px;
}
.message-bubble.sent .message-info {
color: rgba(255, 255, 255, 0.8);
}
.message-bubble.received .message-info {
color: #7f8c8d;
}
.sender-name {
font-weight: 500;
}
.message-time {
font-size: 11px;
}
/* 消息文本 */
.message-text {
font-size: 14px;
line-height: 1.4;
}
/* 样式已简化 - 移除图片相关样式 */
/* 消息输入区域 */
.message-input-area {
padding: 15px;
background-color: white;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 10px;
align-items: flex-end;
}
.message-input-area textarea {
flex: 1;
min-height: 50px;
max-height: 100px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 20px;
resize: none;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.message-input-area textarea:focus {
border-color: #2196f3;
}
.send-btn {
background-color: #2196f3;
color: white;
border: none;
padding: 10px 20px;
border-radius: 18px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
white-space: nowrap;
}
.send-btn:hover:not(:disabled) {
background-color: #1976d2;
}
.send-btn:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
/* 侧边栏 */
.chat-sidebar {
width: 50px;
background-color: white;
border-left: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
gap: 20px;
}
.sidebar-item {
width: 35px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
}
.sidebar-item:hover {
background-color: #f5f5f5;
}
.sidebar-icon {
font-size: 18px;
}
.sidebar-divider {
width: 25px;
height: 1px;
background-color: #e0e0e0;
margin: 10px 0;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.chat-container {
flex-direction: column;
}
.chat-window {
height: 50%;
}
.friends-list {
width: 180px;
}
}
</style>
效果展示:

浙公网安备 33010602011771号