SpringBoot+Vue实现视频广场功能示例教程
2026/7/21 9:26:43 网站建设 项目流程

SpringBoot+Vue实现视频广场功能示例教程

适用场景:需要在 Web 管理后台中,提供"分组 → 通道 → 多路同屏预览"的监控能力。
技术栈:Spring Boot + Vue2(RuoYi 风格)+ flv.js + ZLMediaKit(RTSP→HTTP-FLV 代理)。


摄像头视频预览实现教程:从原理到落地(ZLMediaKit+ flv.js+ Spring Boot):

https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/163018250

一、功能概述

需求要点:

  1. 新增"视频广场"页面
  2. 三栏布局:
    • 左:分组(料棚)列表,先选分组。
    • 中:当前分组下的通道(设备)列表。
    • 右:视频预览区,最多6 路同屏;点第 7 路时关闭最早的一路(LRU 滚动替换)。
  3. 视频窗口不显示进度条/下载等原生控件,点击窗口可全屏。
  4. 设备不在线时不预览,并给出提示。
  5. 每路视频叠加设备名 + 手动关闭按钮。

注:

博客:

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:pwd@ip/... │ └──────────────────┘ └────────────────────────┘

2.2 技术选型说明

选型理由
前端播放flv.js(HTTP-FLV / MSE)浏览器不支持原生 RTSP,需经流媒体转封装;flv.js 兼容性最好
流媒体ZLMediaKit轻量、支持 RTSP 推/拉代理、转 HTTP-FLV,并提供addStreamProxy/delStreamProxyREST 接口
后端仅做"建流 + 返地址",不转码转码吃 CPU,交给 ZLM;后端只调用 ZLM 的 HTTP API
设备离线判断业务状态字段(status无独立"摄像头在线"心跳,以设备状态离线作为不预览依据

2.3 数据流(单路播放)

  1. 前端点通道 →getStreamUrl(deviceId)
  2. 后端查设备拿到 RTSP 地址,调 ZLMaddStreamProxy按需拉起代理,返回http://zlm:port/live/dev_1.flv
  3. 前端把该 FLV 地址交给 flv.js 建Player<video>播放。
  4. 关闭时(手动/被 LRU 淘汰/销毁)调closeStream(deviceId)→ ZLMdelStreamProxy释放拉流资源。

2.4 为什么最多 6 路?——并发路数瓶颈

这是设计 6 路监控墙的硬约束来源,务必理解:

  1. 浏览器同域名并发连接(最硬约束):HTTP-FLV 是 chunked 长连接,每路占一个 HTTP 连接不释放。HTTP/1.1 下 Chrome/Edge 对同一域名+端口最多 6 个并发连接。ZLM 默认单httpPort,因此第 7 路会被排队卡死。6 路正好踩在安全上限
    • 突破办法:ZLM 开 HTTPS + HTTP/2(多路复用)、多端口/多域名分散、或改用WebSocket-FLV / WebRTC(不吃 6 连接限制)。
  2. 客户端解码性能(实际天花板):flv.js 是"JS 解封装 + MSE",每路 1080p 占数百 MB 内存 + 可观 CPU。普通 PC 流畅约4~9 路;切子码流(D1/720p)可显著提升。
  3. 服务端 ZLMediaKit:拉流代理取决于带宽/CPU,几十上百路通常无压力。
  4. 摄像头/NVR 侧:单台 RTSP 并发取流有限(约 6 路)。但多个前端看同一摄像头时,ZLM 只向上游拉 1 路再分发,故此层被 ZLM 挡住,一般不构成瓶颈。

2.5 关键设计点

  • 单路抽成子组件VideoCell:父页面只维护"6 路队列",子组件自己管拉流/全屏/关闭/销毁,职责清晰、可复用。
  • 固定 6 格监控墙:用null占位不足 6 格,更像监控中心;key=c.id让 Vue 复用组件、切换时不黑屏。
  • 强制释放资源VideoCellbeforeDestroycloseStream;菜单设is_cache=0(离开即销毁组件),避免拉流连接堆积。

三、完整实现流程(基于本项目)

3.1 后端:视频流代理服务

ZlmStreamService用 ZLM 的 REST API 建/断流,后端不转码:

@ServicepublicclassZlmStreamService{privatestaticfinalStringSTREAM_PREFIX="dev_";@AutowiredprivateZlmPropertieszlmProperties;@AutowiredprivateRestTemplaterestTemplate;/** 按需拉起 RTSP 代理,返回 HTTP-FLV 地址;幂等(流已存在不报错) */publicStringopenStream(LongdeviceId,StringrtspUrl){StringstreamId=STREAM_PREFIX+deviceId;try{URIuri=UriComponentsBuilder.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){StringstreamId=STREAM_PREFIX+deviceId;StringstreamKey=zlmProperties.getApp()+"/"+streamId;try{URIuri=UriComponentsBuilder.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(){return"http://"+zlmProperties.getHost()+":"+zlmProperties.getHttpPort();}privateStringbuildFlvUrl(StringstreamId){return"http://"+zlmProperties.getPlayHost()+":"+zlmProperties.getHttpPort()+"/"+zlmProperties.getApp()+"/"+streamId+".flv";}}

Controller 侧getStreamUrl(deviceId)成功返回code=200 + flvUrl;无视频地址时返回code=500, 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()关键配置(降低直播延迟、防内存膨胀):

constplayer=flvjs.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_PREVIEW=6constPOLL_INTERVAL=10000data(){return{shedList:[],currentShedId:null,deviceList:[],playingList:[],pollTimer:null}}computed:{// 固定 6 格:不足用 null 占位cells(){constarr=this.playingList.slice()while(arr.length<MAX_PREVIEW)arr.push(null)returnarr}}mounted(){this.getShedList()// 每 10s 轮询刷新设备状态(含预览中设备的实时在线情况)this.pollTimer=setInterval(()=>this.getDeviceList(false),POLL_INTERVAL)}beforeDestroy(){if(this.pollTimer)clearInterval(this.pollTimer)}// 点击设备:加入预览addPreview(device){if(!device||device.id==null)returnif(device.status==='离线'){this.$message.warning(`设备【${device.deviceName}】不在线,无法预览`)return}if(!device.videoUrl){this.$message.warning(`设备【${device.deviceName}】未配置视频地址`)return}// 已在预览:按"最近使用"置顶(移到队尾,画面不中断)constexistIdx=this.playingList.findIndex(d=>d.id===device.id)if(existIdx>=0){constitem=this.playingList.splice(existIdx,1)[0]this.playingList.push(item)this.$message.info(`已将【${device.deviceName}】置为最近预览`)return}// 满 6 路:关掉最早的一路(shift 触发 cell 销毁并释放拉流)if(this.playingList.length>=MAX_PREVIEW){constremoved=this.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=>{constdev=this.deviceList.find(d=>d.id===p.id)if(dev)p.status=dev.status})}

四、通用示例代码

4.1 通用后端(Spring Boot + ZLM)

@RestController@RequestMapping("/api/stream")publicclassStreamController{@AutowiredprivateZlmClientzlmClient;// 封装 ZLM 的 addStreamProxy/delStreamProxy@AutowiredprivateChannelServicechannelService;// 你的通道/摄像头配置服务/** 获取某通道的播放地址(按需建流) */@GetMapping("/url")publicResultgetUrl(@RequestParamLongchannelId){Channelch=channelService.getById(channelId);if(ch==null)returnResult.fail("通道不存在");if(ch.getRtspUrl()==null)returnResult.fail("通道未配置视频地址");Stringflv=zlmClient.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.jsimportaxiosfrom'axios'consthttp=axios.create({baseURL:'/api'})exportfunctiongetStreamUrl(channelId){returnhttp.get('/stream/url',{params:{channelId}})}exportfunctioncloseStream(channelId){returnhttp.get('/stream/close',{params:{channelId}})}

4.3 通用单路视频组件VideoCell.vue

<template> <div class="video-cell" @click="fullscreen"> <video ref="v" autoplay muted playsinline v-show="flvUrl && !error"></video> <div class="title"> <span class="dot" :class="dotClass"></span>{{ name }} </div> <div class="close" @click.stop="$emit('close', channelId)">×</div> <div class="mask" 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; } </style>

4.4 通用视频墙VideoWall.vue(三栏 + 6 格 LRU)

<template> <div class="wall-page"> <!-- 左:分组 --> <aside class="col left"> <div class="tt">分组</div> <ul> <li v-for="g in groups" :key="g.id" :class="{active: g.id===curGroup}" @click="selectGroup(g)">{{ g.name }}</li> </ul> </aside> <!-- 中:通道 --> <aside class="col mid"> <div class="tt">通道({{ channels.length }})</div> <ul> <li v-for="c in channels" :key="c.id" @click="preview(c)"> <span class="dot" :class="c.online ? 'on':'off'"></span> {{ c.name }} <em v-if="inWall(c.id)">预览中</em> </li> </ul> </aside> <!-- 右:监控墙 --> <section class="col right"> <div class="tt">预览({{ playing.length }}/6)</div> <div class="grid"> <div class="cell" v-for="(c,i) in cells" :key="c ? 'c'+c.id : 'e'+i"> <VideoCell v-if="c" :channelId="c.id" :name="c.name" :online="c.online" @close="closeCell" /> <div v-else class="empty">空闲</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>

通用化要点总结:把"分组/通道"做成任意可枚举的树或列表即可;MAXPOLL作为常量暴露,方便后续改 4/9/16 路或调整轮询间隔;"在线"用布尔online而非业务状态字符串,便于和任何心跳机制对接。


五、常见问题(FAQ)

Q1:进入页面能看到菜单,但一点"预览"就 403?
拉流接口受system:device:query权限保护,而菜单权限是system:videosquare:view。给角色同时授权两个标识即可。

Q2:页面看不到"视频广场"菜单?
RuoYi 动态路由在登录时拉取并缓存,单纯刷新页面不会重载。必须重新登录。确认 SQL 已执行、菜单visible='0'(显示)且角色已授权。

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 连接不释放)?

  • 正常beforeDestroycloseStream;但强制刷新/直接关标签页beforeDestroy不保证触发。兜底:确认 ZLMediaKitconfig.ini*_idle_timeout已开启,空闲自动断流。
  • 菜单务必设is_cache=0,离开即销毁组件释放资源。

Q6:多个前端看同一摄像头会重复拉流吗?
不会。ZLM 按stream=dev_{id}去重,多前端只向上游拉 1 路再分发,因此并发瓶颈主要在浏览器侧而非摄像头侧。

Q7:想支持更多路(9/16 路)怎么办?
优先改传输协议为WebRTC / WS-FLV(绕开同域名 6 连接限制);同时让摄像头出子码流(低分辨率),降低前端解码压力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询