websocket + stomp + sockjs学习

文章目录

    • 学习链接
    • 后台代码
      • 引入依赖
      • application.yml
      • WebSocketConfig
      • PrivateController
        • WebSocketService
      • WebSocketEventListener
      • CorsFilter
    • 前端代码
      • Room.vue

学习链接

WebSocket入门教程示例代码,代码地址已fork至本地gitee,原github代码地址,源老外的代码地址

  • [WebSocket入门]手把手搭建WebSocket多人在线聊天室(SpringBoot+WebSocket)
  • [WebSocket]第二章:WebSocket集群分布式改造——实现多人在线聊天室
  • [WebSocket]使用WebSocket实现实时多人答题对战游戏

其它可参考

  • 手把手搭建WebSocket多人在线聊天室(SpringBoot+WebSocket),这个比较详细,排版看上去比较舒服

  • springboot集成websocket小案例 bilibili视频

  • SpringBoot+STOMP 实现聊天室(单聊+多聊)及群发消息详解

  • springboot+websocket构建在线聊天室(群聊+单聊)

  • 基于STOMP协议的WebSocket

  • spring websocket + stomp 实现广播通信和一对一通信 ,这个用法很详细

深入使用

  • SpringBoot——整合WebSocket(STOMP协议) 原创

  • SpringBoot——整合WebSocket(基于STOMP协议)

  • 点对点通信

后续使用rabbimtmq作为消息代理实现时,参考的文章

  • Docker容器添加映射端口的两种实现方法,因为需要rabbitmq需要开启rabbitmq_web_stomp插件、rabbitmq_web_stomp_examples插件,开启方式参考下面这个链接,然后需要在docker和主机之间开启端口映射
  • Rabbitmq报错:Connection refused: no further information: /ip:61613,使用rabbitmq作为消息代理,需要让我们的服务连接到rabbitmq,并且mq要开启rabbitmq_web_stomp插件、rabbitmq_web_stomp_examples插件

在这里插入图片描述

后台代码

引入依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.timeless</groupId>
    <artifactId>timeless-chat-websocket</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.4</version>
    </parent>

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <!--<dependency>-->
        <!--    <groupId>com.itheima</groupId>-->
        <!--    <artifactId>pd-tools-swagger2</artifactId>-->
        <!--    <version>1.0-SNAPSHOT</version>-->
        <!--</dependency>-->

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

        <!-- RabbitMQ Starter Dependency -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <!-- Following additional dependency is required for Full Featured STOMP Broker Relay -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-reactor-netty</artifactId>
        </dependency>



    </dependencies>

</project>

application.yml

server:
  port: 8888
spring:
  application:
    name: timeless-chat-websocket
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/timeless_chat_websocket?serverTimeZone=UTC
    username: root
    password: root
  mvc:
    pathmatch:
      # Springboot2.6以后将SpringMVC 默认路径匹配策略从AntPathMatcher 更改为PathPatternParser
      #
      matching-strategy: ANT_PATH_MATCHER
  rabbitmq:
    host: ${rabbitmq.host}
    port: ${rabbitmq.port}
    username: ${rabbitmq.username}
    password: ${rabbitmq.password}
    virtual-host: ${rabbitmq.virtual-host}
#pinda:
#  swagger:
#    enabled: true
#    title: timeless文档
#    base-package: com.timeless.controller
mybatis-plus:
  configuration:
    log-impl: com.timeless.utils.NoLog

WebSocketConfig

@Configuration
@Slf4j
@EnableWebSocketMessageBroker
@EnableConfigurationProperties(RabbitMQProperties.class)
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private RabbitMQProperties rabbitMQProperties;

    public WebSocketConfig(RabbitMQProperties rabbitMQProperties) {
        this.rabbitMQProperties = rabbitMQProperties;
        log.info("连接rabbitmq, host: {}", rabbitMQProperties.getHost());
    }


    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry
                // 这个和客户端创建连接时的url有关,后面在客户端的代码中可以看到
                .addEndpoint("/ws")
                .addInterceptors(new HandshakeInterceptor() {
                    @Override
                    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
                        log.info("客户端握手即将开始===================【开始】");
                        if (request instanceof ServletServerHttpRequest) {
                            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
                            log.info("请求路径: {}", ((ServletServerHttpRequest) request).getServletRequest().getRequestURL());
                            log.info("校验请求头,以验证用户身份:  {}", JsonUtil.obj2Json(servletRequest.getHeaders()));
                            HttpSession session = servletRequest.getServletRequest().getSession();
                            attributes.put("sessionId", session.getId());
                            return true;
                        }
                        log.info("客户端握手结束=================== 【失败】");
                        return false;
                    }

                    @Override
                    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
                        log.info("客户端握手结束=================== 【成功】");
                    }
                })
                // .setAllowedOrigins("http://localhost:8080")
                // 当传入*时, 使用该方法, 而不要使用setAllowedOrigins("*")
                .setAllowedOriginPatterns("*")
                .withSockJS()
        ;
    }


    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {

        // 1. 当客户端发送消息或订阅消息时,url路径开头如果是/app/xxx 时,会先解析stomp协议,然后路由到@controller的@MessageMapping("/xxx")的方法上执行。
        //    如果不设置,客户端所有发送消息或订阅消息时、都将去匹配@messageMapping。所以最好还是配置上。
        // 2. 这句表示客户端向服务端发送时的主题上面需要加"/app"作为前缀
        registry.setApplicationDestinationPrefixes("/app");

        // 1. 基于内存的消息代理
        // 2. 声明消息中间件Broker的主题名称,当向这个主题下发送消息时(js: stompclient.send("/topic/target1",{},"hello")),订阅当前主题的客户端都可以收到消息。
        //    注意:js 客户端如果发送时、直接是/topic/xxx,spring收到消息会直接发送给broker中。
        //    点对点发送时:enableSimpleBroker 中要配置 /user才可以用: template.convertAndSendToUser("zhangsan","/aaa/hello","111"),否则收不到消息
        // 3. 这句表示在topic和user这两个域上可以向客户端发消息
        registry.enableSimpleBroker("/topic", "/user");

        // 1. 点对点发送前缀
        // 2. 这句表示给指定用户发送(一对一)的主题前缀是 /user
        registry.setUserDestinationPrefix("/user");

        // Use this for enabling a Full featured broker like RabbitMQ
        /*
        // 基于mq的消息代理
        registry.enableStompBrokerRelay("/topic")
                .setVirtualHost(rabbitMQProperties.getVirtualHost())
                .setRelayHost(rabbitMQProperties.getHost())
                .setRelayPort(61613)
                .setClientLogin(rabbitMQProperties.getUsername())
                .setClientPasscode(rabbitMQProperties.getPassword())
                .setSystemLogin(rabbitMQProperties.getUsername())
                .setSystemPasscode(rabbitMQProperties.getPassword())
                .setSystemHeartbeatSendInterval(5000)
                .setSystemHeartbeatReceiveInterval(5000);
        */

    }
}

PrivateController

@Slf4j
@RestController
public class PrivateController {

    @Autowired
    private WebSocketService ws;

    // 1. 这个注解其实就是用来定义接受客户端发送消息的url(不能是topic开头,如果是topic直接发送给broker了,要用/app/privateChat)
    //    如果有返回值,则会将返回的内容转换成stomp协议格式发送给broker(主题名:/topic/privateChat)。如果要换主题名可使用@sendTo
    //    @SubscribeMapping注解和@messageMapping差不多,但不会再把内容发给broker,而是直接将内容响应给客户端,
    @MessageMapping("/privateChat")
    public void privateChat(PrivateMessage message) {
        ws.sendChatMessage(message);
    }

    // 客户端向 /app/broadcastMsg 发送消息, 将会使用该方法处理,
    // 并且因为此方法有返回值, 所以将结果又发送到/topic/broadcastMsg, 因此订阅了/topic/broadcastMsg的客户端将会收到此消息
    @MessageMapping("/broadcastMsg")
    @SendTo("/topic/broadcastMsg")
    public BroadcastMessage broadcastMsg(@Payload BroadcastMessage message,
                                         SimpMessageHeaderAccessor headerAccessor) {
        // 理解为会话添加属性标识
        headerAccessor.getSessionAttributes().put("extraInfo", message.getFromUsername());
        message.setContent("广播消息>>> " + message.getContent());
        return message;
    }

    // 客户端向 /app/userMsg 发送消息, 将会使用该方法处理,(谁请求, 则发送给谁, 不会发送给其它的用户)
    // 并且因为此方法有返回值, 所以将结果又发送到 /user/{username}/singleUserMsg, 其中username
    @MessageMapping("/userMsg")
    // broadcast设置为false表示: 将消息只回给发送此消息的会话用户(1个用户可能有多个会话)
    @SendToUser(value = "/singleUserMsg", broadcast = false)  // ?????????????????????????????????????
    public BroadcastMessage singleUserMsg(@Payload BroadcastMessage message,
                                          SimpMessageHeaderAccessor headerAccessor) {
        headerAccessor.getSessionAttributes().put("username", message.getFromUsername());
        message.setContent("用户消息>>> " + message.getContent());
        return message;
    }

    @Autowired
    private SimpMessagingTemplate template;

    // 广播推送消息
    // (向此接口发送请求, 将会向所有的订阅了 /topic/broadcastMsg的客户端发送消息)
    @RequestMapping("/sendTopicMessage")
    public void sendTopicMessage(String content) {
        template.convertAndSend("/topic/broadcastMsg", content);
    }

    // 点对点消息
    @RequestMapping("/sendPointMessage")
    // (向此接口发送请求, 将会向所有的订阅了 /user/{targetUsername}/singleUserMsg 的客户端发送消息。
    //  这种方式调用的前提是需要registry.enableSimpleBroker("/topic", "/user");
    //                      registry.setUserDestinationPrefix("/user");)
    //  这2个同时配置了才能使用的
    public void sendQueueMessage(String targetUsername, String content) {
        this.template.convertAndSendToUser(targetUsername, "/singleUserMsg", content);
    }

}

WebSocketService

@Service
public class WebSocketService {


    @Autowired
    private SimpMessagingTemplate template;

    @Autowired
    private PrivateMessageService privateMessageService;

    /**
     * 简单点对点聊天室
     */
    public void sendChatMessage(PrivateMessage message) {
        message.setMessage(message.getFromUsername() + " 发送:" + message.getMessage());
        // 消息存储到数据库
        boolean save = privateMessageService.save(message);
        //可以看出template最大的灵活就是我们可以获取前端传来的参数来指定订阅地址, 前面参数是订阅地址,后面参数是消息信息
        template.convertAndSend("/topic/ServerToClient.private." + message.getToUsername(), message);
        if(!save){
            throw new SystemException(AppHttpCodeEnum.SYSTEM_ERROR);
        }
    }

}

WebSocketEventListener

@Component
public class WebSocketEventListener {

    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    public static AtomicInteger userNumber = new AtomicInteger(0);

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
        userNumber.incrementAndGet();
        messagingTemplate.convertAndSend("/topic/ServerToClient.showUserNumber", userNumber);
        System.out.println("我来了哦~");
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        userNumber.decrementAndGet();
        messagingTemplate.convertAndSend("/topic/ServerToClient.showUserNumber", userNumber);
        System.out.println("我走了哦~");
    }
}

CorsFilter

@WebFilter
public class CorsFilter implements Filter {
 
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) res;
        response.setHeader("Access-Control-Allow-Origin", "*");  
        response.setHeader("Access-Control-Allow-Methods", "*");  
        response.setHeader("Access-Control-Max-Age", "3600");  
        response.setHeader("Access-Control-Allow-Headers", "*");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        chain.doFilter(req, res);  
    }  
}

前端代码

Room.vue

<template>
  <div>
    <h3 style="text-align: center">当前用户:{{ this.username }}</h3>
    <h3 style="text-align: center">在线人数:{{ this.userNumber }}</h3>
    <!--    <h3 style="text-align: center">在线用户:-->
    <!--      <div v-for="user in usernameOnlineList" :key="user">{{ user }}</div>-->
    <!--    </h3>-->
    <div class="container">
      <div class="left">
        <h2 style="text-align: center">用户列表</h2>
        <ul>
          <li v-for="user in userList" :key="user.id" :class="{ selected: user.selected }" title="点击选择用户聊天">
            <div class="user-info">
              <span @click="selectUser(user)">
                {{ user.toUsername }}
              </span>
              <!-- <div class="button-container">
                <el-button
                  v-if="user.isFriend === 0"
                  type="primary"
                  size="mini"
                  @click="sendFriendRequest(user)"
                >
                  申请加好友
                </el-button>
                <el-button
                  v-if="user.isFriend === 1"
                  type="success"
                  @click="sendMessage(user)"
                >
                  好友
                </el-button>
                <el-button v-if="user.isFriend === 2" type="danger" disabled>
                  申请中
                </el-button>
              </div> -->
            </div>
          </li>
        </ul>
      </div>
      <div class="right">
        <div v-if="selectedUser">
          <h2 style="text-align: center">
            正在与{{ selectedUser.toUsername }}聊天
          </h2>
        </div>
        <div v-if="selectedUser">
          <ul>
            <li v-for="message in messageList[username + selectedUser.toUsername]" :key="message.id">
              {{ message }}
            </li>
          </ul>
        </div>
        <div v-if="selectedUser">
          <div class="message-input">
            <el-input v-model="selectedUserMessage.message" placeholder="请输入内容" @keyup.enter.native="sendMsg"></el-input>
            <div class="button-container">
              <el-button type="primary" @click="sendMsg">发送消息</el-button>
              <el-button type="danger" @click="deleteAllMsgs">删除所有消息</el-button>
            </div>
          </div>
          <div class="message-input">
            <el-input v-model="broadcastMsgContent" placeholder="请输入广播消息内容" @keyup.enter.native="sendMsg"></el-input>
            <div class="button-container">
              <el-button type="primary" @click="sendBroadcastMsg">发送广播消息</el-button>
            </div>
          </div>
          <div class="message-input">
            <el-input v-model="userMsgContent" placeholder="请输入userMsg" @keyup.enter.native="sendMsg"></el-input>
            <div class="button-container">
              <el-button type="primary" @click="sendUserMsg">发送userMsg</el-button>
            </div>
          </div>
          <div class="message-input">
            <el-input v-model="toTopicMsgContent" placeholder="请输入ToTopicMsg" @keyup.enter.native="sendMsg"></el-input>
            <div class="button-container">
              <el-button type="primary" @click="sendToTopicMsg">发送ToTopicMsg</el-button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div>
      <h1 class="bottom" style="text-align: center">好友申请</h1>

      <h2 style="text-align: center; color: rgb(57, 29, 216)">
        功能开发中......
      </h2>
    </div>
  </div>
</template>

<script>
import { getAllUsers, listPrivateMessages, deleteAllMsg } from "@/api";
import SockJS from "sockjs-client";
import Stomp from "stompjs";
import { Message } from "element-ui";

export default {
  name: "Room",
  data() {
    return {
      userList: [],
      groupList: [],
      selectedUser: null,
      message: "",
      stompClient: null,
      messageList: {}, // 使用对象来存储每个用户的聊天记录
      username: "",
      usernameOnlineList: [],
      userNumber: 1,
      selectedUserMessage: {
        user: null,
        message: "",
      },
      broadcastMsgContent: '',
      userMsgContent: '',
      toTopicMsgContent: '',
    };
  },
  methods: {

    listAllUsers() {
      getAllUsers(this.username).then((response) => {
        this.userNumber = ++response.data.userNumber;
        this.userList = response.data.friends.filter(
          (user) => user.toUsername !== this.username
        );
      });
    },

    selectUser(user) {

      if (!this.messageList[this.username + user.toUsername]) {
        console.log(2222222)
        this.$set(this.messageList, this.username + user.toUsername, []);
      }

      // TODO 展示数据库中存在的信息,也就是聊天记录
      listPrivateMessages(this.username, user.toUsername).then((response) => {
        this.$set(this.messageList, this.username + user.toUsername, response.data);
      });

      this.selectedUser = user;
      this.selectedUserMessage.user = user;
      this.selectedUserMessage.message = ""; // 清空输入框内容
      this.userList.forEach((u) => {
        u.selected = false;
      });
      user.selected = true;
    },

    sendMsg() {
      if (this.stompClient !== null && this.selectedUserMessage.message !== "") {

        // 发送私聊消息给服务端
        this.stompClient.send(
          "/app/privateChat",
          {},
          JSON.stringify({
            fromUsername: this.username,
            message: this.selectedUserMessage.message,
            toUsername: this.selectedUserMessage.user.toUsername,
          })
        );

        this.messageList[this.username + this.selectedUserMessage.user.toUsername].push(
          this.username + " 发送:" + this.selectedUserMessage.message
        );

        this.selectedUserMessage.message = ""; // 清空输入框内容
      } else {
        Message.info("请输入消息");
      }
    },

    sendBroadcastMsg() {
      if (this.stompClient !== null) {
        // 发送私聊消息给服务端
        this.stompClient.send(
          "/app/broadcastMsg",
          {},
          JSON.stringify({
            fromUsername: this.username,
            content: this.broadcastMsgContent
          })
        );
      }
    },
    sendUserMsg() {
      if (this.stompClient !== null) {
        // 发送私聊消息给服务端
        this.stompClient.send(
          "/app/userMsg",
          {},
          JSON.stringify({
            fromUsername: this.username,
            content: this.userMsgContent
          })
        );
      }
    },
    // 客户端也可以发送 /topic/xx, 这样订阅了 /topic/xx的客户端也会收到消息
    sendToTopicMsg() {
      if (this.stompClient !== null) {
        // 发送私聊消息给服务端
        this.stompClient.send(
          "/topic/broadcastMsg",
          {},
          JSON.stringify({
            fromUsername: this.username,
            content: this.toTopicMsgContent
          })
        );
      }
    },
    deleteAllMsgs() {
      if (this.messageList[this.username + this.selectedUserMessage.user.toUsername] == "") {
        Message.error("当前没有聊天记录");
        return;
      }
      deleteAllMsg(this.username, this.selectedUser.toUsername).then(
        (response) => {
          this.messageList[this.username + this.selectedUserMessage.user.toUsername] = [];
          Message.success("删除成功");
        }
      );
    },
    connect() {

      //建立连接对象(还未发起连接)
      const socket = new SockJS("/api/ws");

      // 获取 STOMP 子协议的客户端对象 
      this.stompClient = Stomp.over(socket);
      window.stompClient = this.stompClient

      // 向服务器发起websocket连接并发送CONNECT帧 
      this.stompClient.connect(
        {},
        (frame) => { // 连接成功时(服务器响应 CONNECTED 帧)的回调方法

          console.log("建立连接: " + frame);

          // 订阅当前个人用户消息
          this.stompClient.subscribe(`/user/${this.username}/singleUserMsg`, (response) => {
            console.log('收到当前点对点用户消息: ', response.body);
          })

          // 订阅当前个人用户消息2
          this.stompClient.subscribe(`/user/singleUserMsg`, (response) => {
            console.log('收到当前点对点用户消息2: ', response.body);
          })

          // 订阅广播消息
          this.stompClient.subscribe(`/topic/broadcastMsg`, (response) => {
            console.log('收到广播消息: ', response.body);
          })

          // 订阅 服务端发送给客户端 的私聊消息
          //(疑问: 订阅的范围如何限制?当前用户应该不能订阅别的用户吧?
          //  尝试: 每进来一个用户动态生成这个用户对应的一个标识, 然后, 这个用户订阅当前这个标识, 
          //        其它没用如果想发消息给这个用户, 后台先查询这个用户标识, 然后发消息给这个用户,
          //        这样这个订阅路径就是动态的, 其它不是好友的用户就无法获取到这个动态生成的用户标识。)
          this.stompClient.subscribe(
            "/topic/ServerToClient.private." + this.username,
            (result) => {
              this.showContent(
                JSON.parse(result.body).message,
                JSON.parse(result.body).fromUsername,
                JSON.parse(result.body).toUsername,
              )
            });

          // 订阅 服务端发送给客户端删除所有聊天内容 的消息
          this.stompClient.subscribe("/topic/ServerToClient.deleteMsg", (result) => {
            const res = JSON.parse(result.body);
            this.messageList[res.toUsername + res.fromUsername] = [];
          });

          // 订阅 服务端发送给客户端在线用户数量 的消息
          this.stompClient.subscribe("/topic/ServerToClient.showUserNumber", (result) => {
            this.userNumber = result.body;
          });

        });
    },

    disconnect() {
      if (this.stompClient !== null) {
        // 断开连接
        this.stompClient.disconnect();
      }
      console.log("断开连接...");
    },

    showContent(body, from, to) {
      // 处理接收到的消息
      // 示例代码,根据实际需求进行修改
      if (!this.messageList[to + from]) {
        this.$set(this.messageList, to + from, []); // 初始化选定用户的聊天记录数组
      }
      this.messageList[to + from].push(body); // 将接收到的消息添加到选定用户的聊天记录数组
    },
  },
  created() {
  },
  mounted() {
    // 从sessionStorage中获取用户名
    this.username = sessionStorage.getItem("username");
    console.log('username', this.username);
    if (!this.username) {
      this.$router.push('/login')
      return
    }
    this.connect();
    this.listAllUsers();
    // console.log(this.username);
  },
  beforeDestroy() {
    this.disconnect();
  },
};
</script>

<style scoped>
.container {
  display: flex;
  justify-content: space-between;
  margin: 10px;
}

.left,
.middle,
.right {
  flex: 0.5;
  margin: 5px;
  padding: 10px;
  background-color: lightgray;
}

.right {
  flex: 2;
}

.bottom {
  margin-top: 20px;
  text-align: center;
}

li {
  cursor: pointer;
  transition: color 0.3s ease;
}

li:hover {
  color: blue;
}

li.selected {
  color: blue;
  font-weight: bold;
}

.send-button {
  display: flex;
  justify-content: flex-end;
}

.message-input {
  display: flex;
  align-items: center;
}

.button-container {
  margin-left: 10px;
  /* 调整间距大小 */
}

.message-container {
  display: flex;
  justify-content: flex-end;
}

.button-container {
  display: flex;
  justify-content: flex-end;
}

.user-info {
  display: flex;
  align-items: center;
}

.button-container {
  margin-left: auto;
}
</style>

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/82855.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

马哈鱼数据血缘工具背后的项目: gsp_demo_java 项目简单介绍与使用

0.背景 马哈鱼数据血缘工具(https://www.sqlflow.cn/)是SQLflow工具的中文译名,实际就是sqlflow. 对于SQL flow来说,底层调用的是General SQL Parser(GSP https://sqlparser.com) 的库. 这个gsp有开源的java demo项目:https://github.com/sqlparser/gsp_demo_java 1.快速使用…

【C# 基础精讲】LINQ 基础

LINQ&#xff08;Language Integrated Query&#xff09;是一项强大的C#语言特性&#xff0c;它使数据查询和操作变得更加简洁、灵活和可读性强。通过使用LINQ&#xff0c;您可以使用类似SQL的语法来查询各种数据源&#xff0c;如集合、数组、数据库等。本文将介绍LINQ的基础概…

(排序) 剑指 Offer 45. 把数组排成最小的数 ——【Leetcode每日一题】

❓ 剑指 Offer 45. 把数组排成最小的数 难度&#xff1a;中等 输入一个非负整数数组&#xff0c;把数组里所有数字拼接起来排成一个数&#xff0c;打印能拼接出的所有数字中最小的一个。 示例 1: 输入: [10,2] 输出: “102” 示例 2: 输入: [3,30,34,5,9] 输出: “3033459”…

PV3D: A 3D GENERATIVE MODEL FOR PORTRAITVIDEO GENERATION 【2023 ICLR】

ICLR&#xff1a;International Conference on Learning Representations CCF-A 国际表征学习大会&#xff1a;深度学习的顶级会议 生成对抗网络(GANs)的最新进展已经证明了生成令人惊叹的逼真肖像图像的能力。虽然之前的一些工作已经将这种图像gan应用于无条件的2D人像视频生…

[K8s]问题描述:k8s拉起来的容器少了cuda的so文件

问题解决&#xff1a;需要设置Runtimes&#xff1a;nvidia的同时设置Default Runtimenvidia

Java请求Http接口-OkHttp(超详细-附带工具类)

简介&#xff1a;OkHttp是一个默认有效的HTTP客户端&#xff0c;有效地执行HTTP可以加快您的负载并节省带宽&#xff0c;如果您的服务有多个IP地址&#xff0c;如果第一次连接失败&#xff0c;OkHttp将尝试备用地址。这对于IPv4 IPv6和冗余数据中心中托管的服务是必需的。OkHt…

Win11游戏高性能模式怎么开

1、点击桌面任务栏上的“开始”图标&#xff0c;在打开的应用中&#xff0c;点击“设置”&#xff1b; 2、“设置”窗口&#xff0c;左侧找到“游戏”选项&#xff0c;在右侧的选项中&#xff0c;找到并点击打开“游戏模式”&#xff1b; 3、打开的“游戏模式”中&#xff0c;找…

搭载KaihongOS的工业平板、机器人、无人机等产品通过3.2版本兼容性测评,持续繁荣OpenHarmony生态

近日&#xff0c;搭载深圳开鸿数字产业发展有限公司&#xff08;简称“深开鸿”&#xff09;KaihongOS软件发行版的工业平板、机器人、无人机等商用产品均通过OpenAtom OpenHarmony&#xff08;以下简称“OpenHarmony”&#xff09;3.2 Release版本兼容性测评&#xff0c;获颁O…

【Vue】yarn 安装包时权限不足或者文件夹被占用导致安装失败

在一个 Vue3 项目中&#xff0c;用 yarn 安装 Vue 插件或者 Vue-Router 时&#xff0c;出现同样的 error &#xff0c;如下&#xff1a; An unexpected error occurred: “EPERM: operation not permitted, unlink ‘C:\Codefield\项目\yupao-frontend\node_modules\esbuild\w…

c#设计模式-结构型模式 之 代理模式

前言 由于某些原因需要给某对象提供一个代理以控制对该对象的访问。这时&#xff0c;访问对象不适合或者不能直接 引用目标对象&#xff0c;代理对象作为访问对象和目标对象之间的中介。在学习代理模式的时候&#xff0c;可以去了解一下Aop切面编程AOP切面编程_aop编程…

python高级基础

文章目录 python高级基础闭包修饰器单例模式跟工厂模式工厂模式单例模式 多线程多进程创建websocket服务端手写客户端 python高级基础 闭包 简单解释一下闭包就是可以在内部访问外部函数的变量&#xff0c;因为如果声明全局变量&#xff0c;那在后面就有可能会修改 在闭包中的…

下载安装并使用小乌龟TortoiseGit

1、下载TortoiseGit安装包 官网&#xff1a;Download – TortoiseGit – Windows Shell Interface to Githttps://tortoisegit.org/download/ 2、小乌龟汉化包 在官网的下面就有官方提供的下载包 3、安装

android 的Thread类

Thread类 位于java.lang包下的Thread类是非常重要的线程类&#xff0c;它实现了Runnable接口&#xff0c;学习Thread类包括这些相关知识&#xff1a;线程的几种状态、上下文切换&#xff0c;Thread类中的方法的具体使用。 线程&#xff1a;比进程更小的执行单元&#xff0c;每…

消息中间件-kafka实战-第六章-kafka加线程池多线程消费

目录 参考架构图延时队列 参考 头条面试&#xff1a;当线上Kafka集群有大量消息积压时&#xff0c;如何利用多线程消费解决消费积压问题 架构图 延时队列

vulnhub靶机DarkHole_2

靶机下载地址&#xff1a;DarkHole: 2 ~ VulnHub 靶机发现 arp-scan -l 扫描端口 nmap --min-rate 10000 -p- 192.168.21.145 扫描服务 nmap -sV -sT -O -p22,80 192.168.21.145 漏洞扫描 nmap --scriptvuln -p22,80 192.168.21.145 这里有git源码泄露 git clone mirrors…

网络编程基础(1)

目录 网络编程解决是跨主机的进程间通讯 1、网络 2、互联网 3、ip地址 &#xff08;1&#xff09;ipv4: &#xff08;2&#xff09;ipV6:1 &#xff08;3&#xff09;IP地址的组成&#xff1a; (4)Linux查看IP地址&#xff1a;ifconfig 4、mac地址 5、ping Ip地址 6…

Vue2-TodoList案例(初级 后面会进行完善)

&#x1f954;&#xff1a;觉得累是因为在走上坡路 本案例是初级案例&#xff0c;在下面几节会进行完善——Vue.js TodoList案例 组件化编码流程&#xff08;通用&#xff09;整体思路1、分析结构2、拆html和css3、初始化列表4、实现添加列表功能5、实现勾选功能6、实现删除功能…

第三讲:ApplicationContext的实现

这里写目录标题 一、前文回顾二、基础代码准备三、基于XML的ClassPathXmlApplicationContext1. 创建spring-config.xml配置文件2. 指定配置文件的路径 四、基于注解的AnnotationConfigApplicationContext1. 新增一个配置类2.指定配置类信息 五、基于注解和ServletWebServer应用…

Endnote在线链接pubmed的时候报错12057:不能连接到吊销服务器,或者未能获得最终响应?

​嘎嘎嘎问题如下&#xff1a; 解决办法&#xff1a; 打开控制面板: ok,完了之后再去EndNote就不会出现此问题了。&#xff08;有的可能需要重启电脑&#xff0c;重启EndNote才会生效&#xff09;

Docker 网络之 ipvlan 和 macvlan

Docker ipvlan 和 macvlan 引言 本文讲解了Docker 网络模式中的 ipvlan 和 macvlan 的区别,目前自己在生产环境中使用的 ipvlan 模式非常问题.也解决了实际业务问题. IPvlan L2 mode example ipvlan 无需网卡混杂模式 , 运行如下命令后可以生成一个 vlan 子接口 , 会和主网卡…