跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
Python

SpringBoot+Vue+Netty+WebSocket+WebRTC 实现视频聊天

实时音视频聊天是当下社交、在线协作类应用的核心功能之一,WebRTC(Web Real-Time Communication)作为浏览器原生支持的实时通信技术,能让前端无需插件即可实现点对点音视频传输;而 Netty 作为高性能的 Java NIO 框架,可提供稳定的 WebSocket 通信通道,配合 SpringBoot 的快速开发能力和 Vue 的前端工程化能力,能快速搭建一套完整的视频聊天…

不知所云发布于 2026/4/6更新于 2026/5/2283K 浏览
SpringBoot+Vue+Netty+WebSocket+WebRTC 实现视频聊天

实时音视频聊天是当下社交、在线协作类应用的核心功能之一,WebRTC(Web Real-Time Communication)作为浏览器原生支持的实时通信技术,能让前端无需插件即可实现点对点音视频传输;而 Netty 作为高性能的 Java NIO 框架,可提供稳定的 WebSocket 通信通道,配合 SpringBoot 的快速开发能力和 Vue 的前端工程化能力,能快速搭建一套完整的视频聊天系统。本文将详细讲解如何基于这些技术栈实现一对一视频聊天功能。

一、SpringBoot+Vue+Netty+WebSocket+WebRTC 实现视频聊天

技术栈核心作用
SpringBoot后端快速开发框架,整合 Netty、配置 WebSocket,提供接口支撑
Vue前端工程化框架,负责音视频界面渲染、WebRTC API 调用
Netty高性能网络通信框架,实现 WebSocket 服务端,处理客户端连接和信令转发
WebSocket全双工通信协议,用于前端和后端之间的信令(如呼叫、应答、ICE 候选)传输
WebRTC实时通信标准,提供音视频采集、编码、点对点传输能力

整个视频聊天系统分为三层,核心是信令转发和点对点音视频传输:

  1. 前端层(Vue):采集音视频流、创建 WebRTC 连接、通过 WebSocket 发送 / 接收信令;
  2. 通信层(Netty+WebSocket):维护客户端连接、转发信令(如呼叫请求、ICE 候选信息);
  3. 后端层(SpringBoot):整合 Netty、管理用户连接状态、提供基础配置支撑。

文章配图

WebRTC 是什么:是浏览器内置的实时通信技术,能让网页直接实现音视频通话、数据传输,无需安装插件。ICE 是什么:ICE(Interactive Connectivity Establishment)是 WebRTC 中用于解决 NAT 穿透(简单说就是让不同网络下的设备能找到彼此)的框架,而 iceServers 就是给 ICE 提供 '辅助服务器' 的配置。STUN 服务器:STUN(Session Traversal Utilities for NAT),直译是 'NAT 会话穿透工具',它是一种轻量级的网络服务器,核心作用是:帮助处于 NAT(网络地址转换)后的设备(比如你的电脑 / 手机),获取自己的公网 IP + 端口,以及 NAT 设备的类型,从而让不同 NAT 后的设备能找到彼此,建立点对点(P2P)连接。

先补个前提:为什么需要 STUN?

我们日常用的网络(比如家里的宽带、公司的内网),设备拿到的都是内网 IP(如 192.168.1.100),不是公网 IP。当两个内网设备要直接通信(比如 WebRTC 音视频通话),它们不知道对方的公网地址,就像两个人在不同的小区里,只知道自己的门牌号,却不知道小区的地址和对外的出入口 ——STUN 服务器就是帮它们查 '小区地址 + 出入口' 的工具。用一个 '打电话' 的例子解释:

  1. 设备 A(内网) 向 STUN 服务器发送一个请求:'请告诉我,你看到的我的地址和端口是什么?'
  2. STUN 服务器 收到请求后,会记录下请求来源的公网 IP + 端口(这是 NAT 设备给设备 A 分配的对外端口),然后把这个信息返回给设备 A。
  3. 设备 A 拿到自己的公网地址后,通过信令服务器(比如 WebSocket)把这个地址告诉设备 B;同理,设备 B 也通过 STUN 服务器拿到自己的公网地址并告诉设备 A。

目录

  1. 一、SpringBoot+Vue+Netty+WebSocket+WebRTC 实现视频聊天
  2. 先补个前提:为什么需要 STUN?
  3. 二、服务端开发
  4. 2.1 项目搭建与依赖配置
  5. 2.2 核心实体类与消息处理器
  6. 三、Netty 服务启动类与 SpringBoot 启动类
  7. 四、Vue前端开发
  8. 4.1 编辑模版template
  9. 4.2 编写核心脚本(script setup),实现交互逻辑
  10. 4.3 补充 “挂断” 功能
  • 💰 8折买阿里云服务器限时8折了解详情
  • 最后,设备 A 和设备 B 就可以用彼此的公网地址,直接建立 P2P 连接。
  • 二、服务端开发

    2.1 项目搭建与依赖配置

    <!-- SpringBoot 核心 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <!-- Netty WebSocket 依赖 --> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.94.Final</version> </dependency> <!-- JSON 解析 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.25</version> </dependency>
    

    依赖作用:Netty 是核心,负责网络通信;FastJSON 用于处理前后端传输的 JSON 格式信令

    2.2 核心实体类与消息处理器

    1.创建消息实体类 Message.java

    需求:信令消息需要包含'消息类型''发送方ID''接收方ID''消息内容',所以定义对应的属性

    public class Message { // 消息类型:register(注册)、call(呼叫)、answer(应答)、ice(ICE候选) private String type; // 发送方ID private String from; // 接收方ID private String to; // 消息内容(SDP/ICE 数据) private String data; }
    

    2.创建 Netty WebSocket 处理器

    核心作用:处理 WebSocket 连接的建立、消息接收与转发、连接断开等事件,是服务端的核心逻辑

    import com.alibaba.fastjson.JSON; import com.qcby.springboot.entity.Message; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; import org.springframework.context.annotation.Configuration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 描述: */ @Configuration @ChannelHandler.Sharable public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> { // 存储用户ID与Channel的映射(线程安全) public static final ConcurrentHashMap<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>(); @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { System.out.println("与客户端建立连接,通道开启!"); } /** * 处理接收到的文本消息 */ @Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception { // 解析JSON消息 String text = msg.text(); Message message = JSON.parseObject(text, Message.class); System.out.println("收到消息:" + text); switch (message.getType()) { case "register": // 注册用户ID与Channel的映射 USER_CHANNEL_MAP.put(message.getFrom(), ctx.channel()); System.out.println("用户 " + message.getFrom() + " 注册成功"); break; case "call": case "answer": case "ice": // 转发消息到接收方 Channel targetChannel = USER_CHANNEL_MAP.get(message.getTo()); if (targetChannel != null && targetChannel.isActive()) { targetChannel.writeAndFlush(new TextWebSocketFrame(text)); System.out.println("转发消息到用户 " + message.getTo()); } else { System.out.println("用户 " + message.getTo() + " 不在线"); } break; default: System.out.println("未知消息类型:" + message.getType()); } } /** * 处理连接断开事件 */ @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { System.out.println("与客户端断开连接,通道关闭!"); } /** * 处理异常 */ @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { System.out.println("连接异常:" + cause.getMessage()); USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue() == ctx.channel()); ctx.close(); } }
    

    三、Netty 服务启动类与 SpringBoot 启动类

    需求:需要在 SpringBoot 项目启动时,自动启动 Netty 的 WebSocket 服务,监听指定端口(8081)

    import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.http.HttpObjectAggregator; import io.netty.handler.codec.http.HttpServerCodec; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; import io.netty.handler.stream.ChunkedWriteHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; /** Netty WebSocket 服务端*/ @Configurationpublic class NettyWebSocketServer { @Autowired private WebSocketHandler coordinationSocketHandler; public void start() throws Exception { EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup group = new NioEventLoopGroup(); try { ServerBootstrap sb = new ServerBootstrap(); sb.option(ChannelOption.SO_BACKLOG, 1024); sb.group(group, bossGroup) // 绑定线程池 .channel(NioServerSocketChannel.class) // 指定使用的channel .localAddress(8004)// 绑定监听端口 .childHandler(new ChannelInitializer<SocketChannel>() { // 绑定客户端连接时候触发操作 @Override protected void initChannel(SocketChannel ch) throws Exception { //websocket协议本身是基于http协议的,所以这边也要使用http解编码器 ch.pipeline().addLast(new HttpServerCodec()); //以块的方式来写的处理器 ch.pipeline().addLast(new ChunkedWriteHandler()); ch.pipeline().addLast(new HttpObjectAggregator(8192)); ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws", "WebSocket", true, 65536 * 10)); ch.pipeline().addLast(coordinationSocketHandler);//自定义消息处理类 } }); ChannelFuture cf = sb.bind().sync(); // 服务器异步创建绑定 cf.channel().closeFuture().sync(); // 关闭服务器通道 } finally { group.shutdownGracefully().sync(); // 释放线程池资源 bossGroup.shutdownGracefully().sync(); } } }
    
    • @PostConstruct 和 @PreDestroy:Spring 的注解,分别在项目启动后和销毁前执行,实现 Netty 服务的自动启停
    • EventLoopGroup 线程组:bossGroup 负责接收连接,workerGroup 负责处理读写,是 Netty 高性能的核心
    • 通道处理器链:按顺序添加处理器,WebSocket 基于 HTTP 握手,所以先添加 HTTP 相关处理器,最后添加自定义处理器
    import com.qcby.springboot.commun.NettyWebSocketServer; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("com.qcby.springboot.dao") public class Application implements CommandLineRunner { public static void main(String[] args) { SpringApplication.run(Application.class, args); } @Autowired private NettyWebSocketServer nettyServer; @Override public void run(String... args) throws Exception { nettyServer.start(); } }
    

    四、Vue前端开发

    4.1 编辑模版template

    <template> <div> <h2>WebRTC 视频聊天</h2> <!-- 1. 用户ID输入与连接服务器区域 --> <div> <input v-model="userId" placeholder="输入你的用户ID(如user1)" type="text" /> <button @click="connect">连接服务器</button> </div> <!-- 2. 呼叫功能区域(仅连接成功后显示) --> <div v-if="socketConnected"> <input v-model="targetUserId" placeholder="输入对方用户ID(如user2)" type="text" /> <button @click="call">发起视频呼叫</button> </div> <!-- 3. 视频展示区域 --> <div> <div> <p>本地视频</p> <!-- muted:本地视频静音,避免回声;autoplay:自动播放 --> <video ref="localVideo" autoplay muted></video> </div> <div> <p>远程视频</p> <video ref="remoteVideo" autoplay></video> </div> </div> </div> </template> <style scoped> /* 全局容器:居中+固定宽度,避免页面太宽/太窄 */ .container { width: 900px; margin: 20px auto; text-align: center; font-family: "Microsoft YaHei", sans-serif; } /* 输入框+按钮组样式:统一间距和样式 */ .input-group { margin: 25px 0; } input { padding: 10px 15px; width: 220px; margin-right: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } button { padding: 10px 20px; background: #42b983; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } button:hover { background: #359469; /* 鼠标悬浮变色,提升交互体验 */ } /* 视频容器:并排展示两个视频 */ .video-container { display: flex; justify-content: center; gap: 30px; : ; }  { : center; }   { : ; : ; : ; }   { : ; : ; :  solid ; : ; : ;  } </style>
    

    4.2 编写核心脚本(script setup),实现交互逻辑

    步骤 1:导入依赖 + 定义基础变量

    <script setup> // 1. 导入 Vue 内置的响应式变量和生命周期钩子 import { ref, onUnmounted } from 'vue'; // 2. 定义响应式变量(页面上用到的动态数据) const userId = ref(''); // 本地用户ID const targetUserId = ref(''); // 对方用户ID const socketConnected = ref(false); // WebSocket连接状态(控制呼叫区域显示) // 3. 定义视频DOM引用(用于绑定音视频流) const localVideo = ref(null); // 本地视频DOM const remoteVideo = ref(null); // 远程视频DOM // 4. 定义非响应式全局变量(仅脚本内使用,无需页面响应) let socket = null; // WebSocket实例 let peerConnection = null; // WebRTC核心实例 let localStream = null; // 本地音视频流(用于后续停止流) </script>
    
    • ref:创建响应式变量,变量值变化时,页面会自动更新(比如 socketConnected 变为 true,呼叫区域会显示);
    • onUnmounted:页面销毁时执行的钩子,用于清理资源(避免内存泄漏);
    • localStream:额外定义本地流变量,方便后续 '挂断' 时停止摄像头 / 麦克风。

    步骤 2:配置 STUN 服务器 + 编写 WebSocket 连接函数

    <script setup> // (接上一步代码) // 5. 配置 STUN 服务器(WebRTC 必需,用于获取公网ICE候选) const iceServers = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, // 谷歌免费STUN(需外网) { urls: 'stun:stun.qq.com:3478' }, // 腾讯免费STUN(国内更稳定) { urls: 'stun:stun.aliyun.com:3478' } // 阿里云免费STUN(备用) ] }; // 6. 连接 WebSocket 服务器函数(点击'连接服务器'按钮触发) const connect = () => { // 校验:用户ID不能为空 if (!userId.value.trim()) { alert('请输入你的用户ID!(不能为空/仅空格)'); return; } // 创建 WebSocket 连接(Netty服务端地址:ws://localhost:8081/ws) // 注意:如果服务端部署在其他机器,需替换为对应IP(如 ws://192.168.1.100:8081/ws) socket = new WebSocket(`ws://localhost:8081/ws`); // 6.1 连接成功回调 socket.onopen = () => { console.log('✅ WebSocket连接成功'); socketConnected.value = true; // 更新连接状态,显示呼叫区域 // 发送注册消息:告诉服务端'我上线了' sendMessage({ type: 'register', from: userId.value, to: '', data: '' }); }; // 6.2 接收服务端消息回调(核心:处理转发的信令) socket.onmessage = (e) => { try { const message = JSON.parse(e.data); // 解析JSON消息 console.log('📥 收到服务端消息:', message); handleMessage(message); // 专门处理消息的函数(后续定义) } catch (err) { console.error('❌ 消息解析失败:', err); } }; // 6.3 连接关闭回调 socket.onclose = () => { console.log('❌ WebSocket连接关闭'); socketConnected.value = false; // 更新连接状态,隐藏呼叫区域 }; // 6.4 连接错误回调 socket.onerror = (err) => { console.error('❌ WebSocket连接错误:', err); socketConnected.value = false; alert('连接服务器失败!请检查服务端是否启动,端口是否正确。'); }; }; </script>
    
    • STUN 服务器:必须配置,否则内网设备无法找到彼此的网络地址,导致视频无法连接;
    • trim():去除用户 ID 前后空格,避免用户输入空字符 / 仅空格;
    • try-catch:防止服务端返回非 JSON 格式消息,导致脚本崩溃;
    • WebSocket 地址:ws:// 对应 HTTP,wss:// 对应 HTTPS,本地测试用 ws:// 即可。

    步骤3:编写通用消息发送函数

    <script setup> // (接上一步代码) // 7. 通用发送WebSocket消息函数(复用,避免重复代码) const sendMessage = (message) => { // 校验:WebSocket必须处于打开状态 if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message)); // 转为JSON字符串发送 console.log('📤 发送消息:', message); } else { console.error('❌ WebSocket未连接,无法发送消息'); alert('未连接服务器,请先点击'连接服务器'!'); } }; </script>
    
    • WebSocket.OPEN:状态码为 1,表示连接已打开;其他状态(0 = 连接中,2 = 关闭中,3 = 已关闭)都无法发送消息;
    • 封装成通用函数:后续发送 call/answer/ice 消息时,直接调用即可,减少重复代码。

    步骤 4:编写消息处理函数

    <script setup> // (接上一步代码) // 8. 处理收到的信令消息 const handleMessage = async (message) => { switch (message.type) { case 'call': // 收到呼叫请求:自动应答(实际项目可加'是否接听'弹窗) await answerCall(message); break; case 'answer': // 收到应答消息:设置远程SDP await setRemoteSDP(message.data); break; case 'ice': // 收到ICE候选:添加到PeerConnection await addIceCandidate(message.data); break; default: console.log('📌 未知消息类型:', message.type); } }; </script>
    

    要点:

    • 按消息类型分支处理:对应服务端的 register/call/answer/ice 四种类型;
    • async/await:后续操作(如设置 SDP)是异步的,需等待完成,避免报错。

    步骤 5:编写 WebRTC 核心函数

    <script setup> // (接上一步代码) // 9. 初始化 PeerConnection(WebRTC核心,复用函数) const initPeerConnection = async () => { // 如果已有PeerConnection,先关闭(避免重复创建) if (peerConnection) { peerConnection.close(); } // 创建PeerConnection实例(传入STUN服务器配置) peerConnection = new RTCPeerConnection(iceServers); // 9.1 监听ICE候选生成事件(本地网络地址) peerConnection.onicecandidate = (e) => { if (e.candidate) { // 发送ICE候选给对方 sendMessage({ type: 'ice', from: userId.value, to: targetUserId.value, data: JSON.stringify(e.candidate) }); } }; // 9.2 监听远程音视频流到达事件(关键:显示对方视频) peerConnection.ontrack = (e) => { // 将远程流绑定到远程视频DOM remoteVideo.value.srcObject = e.streams[0]; console.log('🎥 收到远程音视频流'); }; }; // 10. 发起视频呼叫函数(点击'发起视频呼叫'按钮触发) const call = async () => { // 校验:对方ID不能为空 if (!targetUserId.value.trim()) { alert('请输入对方用户ID!'); return; } try { // 初始化PeerConnection await initPeerConnection(); // 获取本地音视频流(请求摄像头/麦克风权限) localStream = await navigator.mediaDevices.getUserMedia({ video: true, // 开启视频 audio: true // 开启音频 }); // 将本地流绑定到本地视频DOM localVideo.value.srcObject = localStream; // 将音视频轨道添加到PeerConnection(传给对方) localStream.getTracks().forEach(track => { peerConnection.addTrack(track, localStream); }); // 创建SDP提议(offer):包含本地音视频配置 const offer = await peerConnection.createOffer(); // 设置本地SDP await peerConnection.setLocalDescription(offer); // 发送呼叫信令(含SDP offer)给对方 sendMessage({ type: 'call', from: userId.value, to: targetUserId.value, data: JSON.stringify(offer) }); console.log('📞 发起视频呼叫:', targetUserId.value); } catch (err) { console.error('❌ 发起呼叫失败:', err); alert('发起呼叫失败!请检查摄像头/麦克风权限,或是否已连接服务器。'); } }; // 11. 应答呼叫请求函数 const answerCall = async (message) => { // 记录呼叫方ID(后续发送应答/ICE消息需要) targetUserId.value = message.from; try { // 初始化PeerConnection await initPeerConnection(); // 获取本地音视频流 localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.value.srcObject = localStream; localStream.getTracks().forEach(track => { peerConnection.addTrack(track, localStream); }); // 设置远程SDP(呼叫方的offer) await peerConnection.setRemoteDescription(JSON.parse(message.data)); // 创建SDP应答(answer) const answer = await peerConnection.createAnswer(); // 设置本地SDP await peerConnection.setLocalDescription(answer); // 发送应答信令给呼叫方 sendMessage({ type: 'answer', from: userId.value, to: targetUserId.value, data: JSON.stringify(answer) }); console.log('📞 应答视频呼叫:', targetUserId.value); } catch (err) { console.error('❌ 应答呼叫失败:', err); alert('应答呼叫失败!'); } }; // 12. 设置远程SDP函数 const setRemoteSDP = async (sdpStr) => { try { const sdp = JSON.parse(sdpStr); await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); console.log('✅ 设置远程SDP成功'); } catch (err) { console.error('❌ 设置远程SDP失败:', err); } }; // 13. 添加ICE候选函数 const addIceCandidate = async (iceStr) => { try { const ice = JSON.parse(iceStr); await peerConnection.addIceCandidate(new RTCIceCandidate(ice)); console.log('✅ 添加ICE候选成功'); } catch (err) { console.error('❌ 添加ICE候选失败:', err); } }; </script>
    
    • getUserMedia:浏览器原生 API,请求音视频权限,返回本地流;如果用户拒绝权限,会抛出错误,所以用 try-catch 包裹;
    • PeerConnection:WebRTC 的核心对象,负责协商连接、传输音视频流;
    • ontrack 事件:对方的音视频流到达时触发,将流绑定到 remoteVideo 即可显示对方画面;
    • onicecandidate 事件:本地生成网络地址(ICE 候选)时触发,发送给对方,双方才能建立 P2P 连接。

    步骤6:编写资源清理函数

    <script setup> // (接上一步代码) // 14. 页面销毁时清理资源(避免内存泄漏/设备占用) onUnmounted(() => { // 关闭WebSocket连接 if (socket) { socket.close(); console.log('🔌 关闭WebSocket连接'); } // 关闭PeerConnection if (peerConnection) { peerConnection.close(); console.log('🔌 关闭PeerConnection'); } // 停止本地音视频流(释放摄像头/麦克风) if (localStream) { localStream.getTracks().forEach(track => { track.stop(); console.log('🔇 停止本地音视频流'); }); } }); </script>
    
    • 必须停止本地流:否则页面关闭后,摄像头 / 麦克风仍会被占用(浏览器标签栏会显示摄像头图标);
    • onUnmounted:Vue3 的生命周期钩子,页面销毁时自动执行,确保资源全部清理。

    4.3 补充 '挂断' 功能

    在 video-container 上方添加:

    <!-- 挂断按钮(仅连接成功且有远程流时显示) --> <div v-if="socketConnected && remoteVideo.value?.srcObject"> <button @click="hangUp">挂断通话</button> </div>
    

    脚本添加挂断函数

    <script setup> // (添加在 initPeerConnection 之后) // 15. 挂断通话函数 const hangUp = () => { // 停止本地流 if (localStream) { localStream.getTracks().forEach(track => track.stop()); localVideo.value.srcObject = null; // 清空本地视频 } // 清空远程视频 if (remoteVideo.value) { remoteVideo.value.srcObject = null; } // 关闭PeerConnection if (peerConnection) { peerConnection.close(); peerConnection = null; } // 重置目标用户ID targetUserId.value = ''; console.log('📞 挂断通话'); alert('已挂断通话!'); }; </script>
    
    margin-top
    30px
    .video-item
    text-align
    .video-item
    p
    margin-bottom
    8px
    font-size
    16px
    color
    #333
    /* 视频标签样式:固定尺寸,加边框,提升美观度 */
    video
    width
    400px
    height
    300px
    border
    1px
    #ccc
    border-radius
    8px
    background-color
    #f5f5f5
    /* 未加载流时显示浅灰色背景 */
    • Magick API 一键接入全球大模型注册送1000万token查看
    • 🤖 一键搭建Deepseek满血版了解详情
    • 一键打造专属AI 智能体了解详情
    极客日志微信公众号二维码

    微信扫一扫,关注极客日志

    微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

    更多推荐文章

    查看全部
    • OpenClaw 多飞书机器人与多 Agent 团队实战复盘
    • Qwen3-VL 基于 Llama-Factory QLoRA 微调及 Ollama/LMDeploy 部署流程
    • AI 产品架构设计:从 0 到 1 搭建信息架构与核心业务流程
    • 异步编程实战:构建高性能Python网络应用
    • Open-WebUI 本地部署指南:打造私有化 AI 对话界面
    • Java 结合 PostgreSQL 实现节气与季节检索实战
    • 使用 Luckyexcel 实现 Excel 到 Web 表格的转换
    • Xilinx FPGA 实现 RISC-V 五级流水线 CPU 设计实战
    • OpenClaw 网关与子节点配对指南:构建分布式 AI 助手网络
    • 2026 年各大高校 AIGC 检测政策汇总
    • Coze(扣子)全解析:100 个落地用途与发布使用指南
    • 多模态基础大模型技术白皮书解读与核心挑战分析
    • 大模型技术原理详解:从 Transformer 到 RLHF
    • HarmonyOS 网络请求实战:Axios 集成与用户列表交互
    • HarmonyOS 6 视频封面智能生成与 AI 集成
    • ZeroClaw 本地 AI 管理面板搭建:Gateway + LM Studio + Reflex
    • C++ 运算符重载:让自定义类型支持自然运算
    • Python asyncio 异步编程教程
    • 使用 CopilotKit 快速为前端集成 AI 助手实战指南
    • 基于 Vivado 的 RISC-V 五级流水线 CPU FPGA 实现详解

    相关免费在线工具

    • curl 转代码

      解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online

    • Base64 字符串编码/解码

      将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

    • Base64 文件转换器

      将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

    • Markdown转HTML

      将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

    • HTML转Markdown

      将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

    • JSON 压缩

      通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online