SpringBootVue实现视频广场功能示例教程适用场景需要在 Web 管理后台中提供分组 → 通道 → 多路同屏预览的监控能力。技术栈Spring Boot Vue2RuoYi 风格 flv.js ZLMediaKitRTSP→HTTP-FLV 代理。摄像头视频预览实现教程从原理到落地(ZLMediaKit flv.js Spring Boot)https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/163018250一、功能概述需求要点新增视频广场页面三栏布局左分组料棚列表先选分组。中当前分组下的通道设备列表。右视频预览区最多6 路同屏点第 7 路时关闭最早的一路LRU 滚动替换。视频窗口不显示进度条/下载等原生控件点击窗口可全屏。设备不在线时不预览并给出提示。每路视频叠加设备名 手动关闭按钮。注博客https://blog.csdn.net/badao_liumang_qizhi二、技术架构设计2.1 总体架构┌────────────────────────────────────────────────────────────┐ │ 浏览器Vue2 flv.js │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ...(最多6路) │ │ │VideoCell│ │VideoCell│ │VideoCell│ HTTP-FLV 长连接 │ │ └────┬────┘ └────┬────┘ └────┬────┘ │ └───────┼────────────┼────────────┼───────────────────────────┘ │ getStreamUrl(deviceId) / closeStream(deviceId) ▼ ┌────────────────────────────────────────────────────────────┐ │ 后端Spring Boot │ │ BusDeviceController → ZlmStreamService │ │ openStream / closeStream调用 ZLMediaKit 的 HTTP API │ └───────┬──────────────────────────────────────────────────────┘ │ addStreamProxy / delStreamProxy (REST) ▼ ┌──────────────────┐ ┌────────────────────────┐ │ ZLMediaKit 流媒体 │◄─RTSP──┤ 摄像头 / NVR海康等 │ │ RTSP→HTTP-FLV │ │ rtsp://user:pwdip/... │ └──────────────────┘ └────────────────────────┘2.2 技术选型说明层选型理由前端播放flv.jsHTTP-FLV / MSE浏览器不支持原生 RTSP需经流媒体转封装flv.js 兼容性最好流媒体ZLMediaKit轻量、支持 RTSP 推/拉代理、转 HTTP-FLV并提供addStreamProxy/delStreamProxyREST 接口后端仅做建流 返地址不转码转码吃 CPU交给 ZLM后端只调用 ZLM 的 HTTP API设备离线判断业务状态字段status无独立摄像头在线心跳以设备状态离线作为不预览依据2.3 数据流单路播放前端点通道 →getStreamUrl(deviceId)。后端查设备拿到 RTSP 地址调 ZLMaddStreamProxy按需拉起代理返回http://zlm:port/live/dev_1.flv。前端把该 FLV 地址交给 flv.js 建Playervideo播放。关闭时手动/被 LRU 淘汰/销毁调closeStream(deviceId)→ ZLMdelStreamProxy释放拉流资源。2.4 为什么最多 6 路——并发路数瓶颈这是设计 6 路监控墙的硬约束来源务必理解浏览器同域名并发连接最硬约束HTTP-FLV 是 chunked 长连接每路占一个 HTTP 连接不释放。HTTP/1.1 下 Chrome/Edge 对同一域名端口最多 6 个并发连接。ZLM 默认单httpPort因此第 7 路会被排队卡死。6 路正好踩在安全上限。突破办法ZLM 开 HTTPS HTTP/2多路复用、多端口/多域名分散、或改用WebSocket-FLV / WebRTC不吃 6 连接限制。客户端解码性能实际天花板flv.js 是JS 解封装 MSE每路 1080p 占数百 MB 内存 可观 CPU。普通 PC 流畅约4~9 路切子码流D1/720p可显著提升。服务端 ZLMediaKit拉流代理取决于带宽/CPU几十上百路通常无压力。摄像头/NVR 侧单台 RTSP 并发取流有限约 6 路。但多个前端看同一摄像头时ZLM 只向上游拉 1 路再分发故此层被 ZLM 挡住一般不构成瓶颈。2.5 关键设计点单路抽成子组件VideoCell父页面只维护6 路队列子组件自己管拉流/全屏/关闭/销毁职责清晰、可复用。固定 6 格监控墙用null占位不足 6 格更像监控中心keyc.id让 Vue 复用组件、切换时不黑屏。强制释放资源VideoCell在beforeDestroy调closeStream菜单设is_cache0离开即销毁组件避免拉流连接堆积。三、完整实现流程基于本项目3.1 后端视频流代理服务ZlmStreamService用 ZLM 的 REST API 建/断流后端不转码ServicepublicclassZlmStreamService{privatestaticfinalStringSTREAM_PREFIXdev_;AutowiredprivateZlmPropertieszlmProperties;AutowiredprivateRestTemplaterestTemplate;/** 按需拉起 RTSP 代理返回 HTTP-FLV 地址幂等流已存在不报错 */publicStringopenStream(LongdeviceId,StringrtspUrl){StringstreamIdSTREAM_PREFIXdeviceId;try{URIuriUriComponentsBuilder.fromHttpUrl(buildApiBase()/index/api/addStreamProxy).queryParam(secret,zlmProperties.getSecret()).queryParam(vhost,__defaultVhost__).queryParam(app,zlmProperties.getApp()).queryParam(stream,streamId).queryParam(url,rtspUrl).build().encode().toUri();restTemplate.postForEntity(uri,null,String.class);}catch(Exceptione){log.warn(ZLMediaKit addStreamProxy 异常{},e.getMessage());}returnbuildFlvUrl(streamId);}/** 释放拉流资源 */publicvoidcloseStream(LongdeviceId){StringstreamIdSTREAM_PREFIXdeviceId;StringstreamKeyzlmProperties.getApp()/streamId;try{URIuriUriComponentsBuilder.fromHttpUrl(buildApiBase()/index/api/delStreamProxy).queryParam(secret,zlmProperties.getSecret()).queryParam(streamKey,streamKey).build().toUri();restTemplate.getForEntity(uri,String.class);}catch(Exceptione){log.warn(ZLMediaKit delStreamProxy 异常{},e.getMessage());}}privateStringbuildApiBase(){returnhttp://zlmProperties.getHost():zlmProperties.getHttpPort();}privateStringbuildFlvUrl(StringstreamId){returnhttp://zlmProperties.getPlayHost():zlmProperties.getHttpPort()/zlmProperties.getApp()/streamId.flv;}}Controller 侧getStreamUrl(deviceId)成功返回code200 flvUrl无视频地址时返回code500, msg设备未配置视频地址被前端拦截器 reject进入.catch。接口带PreAuthorize(ss.hasPermi(system:device:query))授权时别漏。3.2 前端单路视频组件VideoCell路径ruoyi-ui/src/components/VideoCell/index.vue核心逻辑video不挂controls→ 无进度条/下载按钮点击调requestFullscreen。挂载即loadStream()beforeDestroy销毁 player 并closeStream释放资源。监听flvjs.Events.ERROR做断流自动恢复首帧 8s 宽限判失败 有限重试2 次。监听visibilitychange切后台 tab 时pause()省 CPU回前台play()。接收status渲染在线/离线角标status离线显示灰罩。play()关键配置降低直播延迟、防内存膨胀constplayerflvjs.createPlayer({type:flv,isLive:true,url:flvUrl},{enableStashBuffer:false,stashInitialSize:128,autoCleanupSourceBuffer:true,liveBufferLatencyChasing:true})3.3 前端视频广场主页面三栏 6 格墙 LRU路径ruoyi-ui/src/views/system/videosquare/index.vue。核心逻辑constMAX_PREVIEW6constPOLL_INTERVAL10000data(){return{shedList:[],currentShedId:null,deviceList:[],playingList:[],pollTimer:null}}computed:{// 固定 6 格不足用 null 占位cells(){constarrthis.playingList.slice()while(arr.lengthMAX_PREVIEW)arr.push(null)returnarr}}mounted(){this.getShedList()// 每 10s 轮询刷新设备状态含预览中设备的实时在线情况this.pollTimersetInterval(()this.getDeviceList(false),POLL_INTERVAL)}beforeDestroy(){if(this.pollTimer)clearInterval(this.pollTimer)}// 点击设备加入预览addPreview(device){if(!device||device.idnull)returnif(device.status离线){this.$message.warning(设备【${device.deviceName}】不在线无法预览)return}if(!device.videoUrl){this.$message.warning(设备【${device.deviceName}】未配置视频地址)return}// 已在预览按最近使用置顶移到队尾画面不中断constexistIdxthis.playingList.findIndex(dd.iddevice.id)if(existIdx0){constitemthis.playingList.splice(existIdx,1)[0]this.playingList.push(item)this.$message.info(已将【${device.deviceName}】置为最近预览)return}// 满 6 路关掉最早的一路shift 触发 cell 销毁并释放拉流if(this.playingList.lengthMAX_PREVIEW){constremovedthis.playingList.shift()this.$message.info(已达${MAX_PREVIEW}路上限已关闭最早的【${removed.deviceName}】)}this.playingList.push({id:device.id,deviceName:device.deviceName,status:device.status})}轮询刷新后还需把最新status同步进预览队列驱动角标/灰罩syncPlayingStatus(){this.playingList.forEach(p{constdevthis.deviceList.find(dd.idp.id)if(dev)p.statusdev.status})}四、通用示例代码4.1 通用后端Spring Boot ZLMRestControllerRequestMapping(/api/stream)publicclassStreamController{AutowiredprivateZlmClientzlmClient;// 封装 ZLM 的 addStreamProxy/delStreamProxyAutowiredprivateChannelServicechannelService;// 你的通道/摄像头配置服务/** 获取某通道的播放地址按需建流 */GetMapping(/url)publicResultgetUrl(RequestParamLongchannelId){ChannelchchannelService.getById(channelId);if(chnull)returnResult.fail(通道不存在);if(ch.getRtspUrl()null)returnResult.fail(通道未配置视频地址);StringflvzlmClient.openStream(channelId,ch.getRtspUrl());returnResult.ok(Map.of(flvUrl,flv));}/** 关闭流释放资源 */GetMapping(/close)publicResultclose(RequestParamLongchannelId){zlmClient.closeStream(channelId);returnResult.ok();}}通用要点后端只返回播放地址不关心前端用几路、怎么布局通道的在线建议在Channel上维护一个online布尔字段由设备心跳/保活更新比纯状态字符串更通用。4.2 通用前端 API 封装// api/stream.jsimportaxiosfromaxiosconsthttpaxios.create({baseURL:/api})exportfunctiongetStreamUrl(channelId){returnhttp.get(/stream/url,{params:{channelId}})}exportfunctioncloseStream(channelId){returnhttp.get(/stream/close,{params:{channelId}})}4.3 通用单路视频组件VideoCell.vuetemplate div classvideo-cell clickfullscreen video refv autoplay muted playsinline v-showflvUrl !error/video div classtitle span classdot :classdotClass/span{{ name }} /div div classclose click.stop$emit(close, channelId)×/div div classmask v-if!flvUrl || error{{ error || 加载中… }}/div /div /template script import flvjs from flv.js import { getStreamUrl, closeStream } from /api/stream export default { props: { channelId: { type: [Number, String], required: true }, name: { type: String, default: }, online: { type: Boolean, default: true } }, data: () ({ flvUrl: , error: , player: null, retry: 0, maxRetry: 2 }), computed: { dotClass() { return this.online ? on : off } }, mounted() { this.load() document.addEventListener(visibilitychange, this.onVis) }, beforeDestroy() { document.removeEventListener(visibilitychange, this.onVis) this.destroy() }, methods: { async load() { if (!this.online) { this.error 设备已离线; return } this.error try { const { data } await getStreamUrl(this.channelId) this.flvUrl data.flvUrl this.$nextTick(() this.play(this.flvUrl)) } catch (e) { this.error e.response ? 流媒体服务异常 : 网络异常 } }, play(url) { if (!flvjs.isSupported()) { this.error 浏览器不支持 flv.js; return } this.destroy() const v this.$refs.v const p flvjs.createPlayer({ type: flv, isLive: true, url }, { enableStashBuffer: false, autoCleanupSourceBuffer: true, liveBufferLatencyChasing: true }) this.player p p.attachMediaElement(v); p.load(); p.play() p.on(flvjs.Events.ERROR, () this.recover()) // 首帧宽限 8s setTimeout(() { if (this.player v.readyState 2) this.recover() }, 8000) }, recover() { if (document.hidden || this.retry this.maxRetry) { if (this.retry this.maxRetry) this.error 视频加载失败 return } this.retry setTimeout(() this.load(), 1500) // 有限重试 }, destroy() { if (this.player) { try { this.player.pause(); this.player.unload(); this.player.detachMediaElement(); this.player.destroy() } catch (e) {} this.player null } closeStream(this.channelId).catch(() {}) }, onVis() { const v this.$refs.v if (!v) return document.hidden ? v.pause() : v.play().catch(() {}) }, fullscreen() { const el this.$refs.v el.requestFullscreen ? el.requestFullscreen() : el.webkitRequestFullscreen el.webkitRequestFullscreen() } } } /script style scoped .video-cell { position: relative; width: 100%; height: 100%; background: #000; cursor: pointer; } video { width: 100%; height: 100%; object-fit: contain; } .title { position: absolute; top: 6px; left: 8px; color: #fff; font-size: 12px; background: rgba(0,0,0,.55); padding: 2px 8px; border-radius: 3px; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } .dot.on { background: #67c23a; } .dot.off { background: #909399; } .close { position: absolute; top: 4px; right: 6px; color: #fff; background: rgba(0,0,0,.55); width: 22px; height: 22px; line-height: 22px; text-align: center; border-radius: 50%; } .mask { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #c0c4cc; background: #000; font-size: 13px; } /style4.4 通用视频墙VideoWall.vue三栏 6 格 LRUtemplate div classwall-page !-- 左分组 -- aside classcol left div classtt分组/div ul li v-forg in groups :keyg.id :class{active: g.idcurGroup} clickselectGroup(g){{ g.name }}/li /ul /aside !-- 中通道 -- aside classcol mid div classtt通道{{ channels.length }}/div ul li v-forc in channels :keyc.id clickpreview(c) span classdot :classc.online ? on:off/span {{ c.name }} em v-ifinWall(c.id)预览中/em /li /ul /aside !-- 右监控墙 -- section classcol right div classtt预览{{ playing.length }}/6/div div classgrid div classcell v-for(c,i) in cells :keyc ? cc.id : ei VideoCell v-ifc :channelIdc.id :namec.name :onlinec.online closecloseCell / div v-else classempty空闲/div /div /div /section /div /template script import VideoCell from /components/VideoCell.vue const MAX 6, POLL 10000 export default { components: { VideoCell }, data: () ({ groups: [], curGroup: null, channels: [], playing: [], timer: null }), computed: { cells() { const a this.playing.slice() while (a.length MAX) a.push(null) return a } }, mounted() { this.loadGroups() this.timer setInterval(() this.loadChannels(false), POLL) // 状态轮询 }, beforeDestroy() { clearInterval(this.timer) }, methods: { async loadGroups() { this.groups await api.getGroups() if (this.groups[0]) this.selectGroup(this.groups[0]) }, async selectGroup(g) { this.curGroup g.id this.loadChannels(true) }, async loadChannels(clear) { if (!this.curGroup) return if (clear) this.channels [] this.channels await api.getChannels(this.curGroup) // 同步预览队列在线状态 this.playing.forEach(p { const ch this.channels.find(c c.id p.id) if (ch) p.online ch.online }) }, preview(ch) { if (!ch.online) { this.$message.warning(${ch.name} 不在线); return } const i this.playing.findIndex(p p.id ch.id) if (i 0) { // 已在预览置顶 this.playing.push(this.playing.splice(i, 1)[0]) return } if (this.playing.length MAX) { const r this.playing.shift() this.$message.info(已达 ${MAX} 路已关闭最早的【${r.name}】) } this.playing.push({ id: ch.id, name: ch.name, online: ch.online }) }, inWall(id) { return this.playing.some(p p.id id) }, closeCell(id) { const i this.playing.findIndex(p p.id id) if (i 0) this.playing.splice(i, 1) } } } /script style scoped .wall-page { display: flex; height: 100vh; gap: 10px; padding: 10px; box-sizing: border-box; } .col { background: #fff; border-radius: 4px; display: flex; flex-direction: column; overflow: hidden; } .left { flex: 0 0 200px; } .mid { flex: 0 0 300px; } .right { flex: 1; min-width: 0; } .tt { padding: 10px 12px; font-weight: 600; border-bottom: 1px solid #ebeef5; } .grid { flex: 1; display: grid; grid-template-columns: repeat(3,1fr); grid-template-rows: repeat(2,1fr); gap: 8px; padding: 8px; } .cell { position: relative; background: #000; border-radius: 4px; overflow: hidden; } .empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #909399; border: 1px dashed #dcdfe6; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } .dot.on { background: #67c23a; } .dot.off { background: #909399; } /style通用化要点总结把分组/通道做成任意可枚举的树或列表即可MAX与POLL作为常量暴露方便后续改 4/9/16 路或调整轮询间隔在线用布尔online而非业务状态字符串便于和任何心跳机制对接。五、常见问题FAQQ1进入页面能看到菜单但一点预览就 403拉流接口受system:device:query权限保护而菜单权限是system:videosquare:view。给角色同时授权两个标识即可。Q2页面看不到视频广场菜单RuoYi 动态路由在登录时拉取并缓存单纯刷新页面不会重载。必须重新登录。确认 SQL 已执行、菜单visible0显示且角色已授权。Q3为什么最多只能 6 路第 7 路拉不出来见 2.4 节HTTP/1.1 同域名并发连接上限为 6。ZLM 单端口下第 7 路会被排队。要么控制在 6 路内本设计要么 ZLM 开 HTTP/2/HTTPS、用多端口或改 WebSocket-FLV / WebRTC。Q4视频黑屏/一直在加载中摄像头rtspUrl配错或摄像头离线 → 后端addStreamProxy拉不到流前端 8s 宽限后触发重试最终提示失败。ZLM 服务没启动或secret/host配错 → 网络异常提示。浏览器不支持 flv.js旧 Safari→ 提示换 Chrome/Edge。Q5关闭页面后摄像头还在被拉流ZLM 连接不释放正常beforeDestroy会closeStream但强制刷新/直接关标签页时beforeDestroy不保证触发。兜底确认 ZLMediaKitconfig.ini的*_idle_timeout已开启空闲自动断流。菜单务必设is_cache0离开即销毁组件释放资源。Q6多个前端看同一摄像头会重复拉流吗不会。ZLM 按streamdev_{id}去重多前端只向上游拉 1 路再分发因此并发瓶颈主要在浏览器侧而非摄像头侧。Q7想支持更多路9/16 路怎么办优先改传输协议为WebRTC / WS-FLV绕开同域名 6 连接限制同时让摄像头出子码流低分辨率降低前端解码压力。