零基础入门:基于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: websocketConnection: Upgrade头部,以及一个Sec-WebSocket-Key随机字符串,表示希望升级到WebSocket协议。

2.服务器响应握手

服务器验证请求后,返回一个HTTP 101状态码(表示切换协议成功),并在响应头中包含Upgrade: websocketConnection: 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协议。

 

image

 

WebSocket通信

建立连接后,客户端和服务器就可以通过WebSocket协议发送和接收消息。WebSocket消息由一系列帧组成,可以传输文本数据或二进制数据。

  • 发送消息:将消息封装成WebSocket帧,通过TCP连接发送。

  • 接收消息:从TCP连接中读取WebSocket帧,并解析出消息。

这里比较难理解,我们借用下面两个图来说明websocket工作的整个过程以及websocket帧的数据格式。

 

image

 

image

 

 

一对一聊天室的实现

在前面的章节中,我们深入探讨了WebSocket协议的工作原理、连接建立过程以及通信机制。现在,让我们将这些理论知识付诸实践,构建一个完整的一对一实时聊天室系统。

本文将基于Spring Boot后端Vue3前端,使用STOMP over WebSocket协议,实现一个功能完善的双人聊天应用。我们将使用咨询师11和用户26这两个固定角色来演示完整的通信流程。

我们之前讨论的WebSocket是底层协议,而STOMP是一个更高级的消息协议,它建立在WebSocket之上,提供了更简单的消息格式和交互模式。

在Spring Boot中,我们通常使用STOMP over WebSocket来构建消息传递系统。(对于STOMP不做太多的赘述)下面是流程图。

image

 

后端实现详解

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>

效果展示:

image

 

posted @ 2025-10-19 09:46  雨花阁  阅读(23)  评论(0)    收藏  举报