当前位置: 代码网 > it编程>编程语言>Java > 基于SpringBoot+Vue实现视频广场功能的操作教程

基于SpringBoot+Vue实现视频广场功能的操作教程

2026年07月21日 Java 我要评论
适用场景:需要在 web 管理后台中,提供"分组 → 通道 → 多路同屏预览"的监控能力。技术栈:spring boot + vue2(ruoyi 风格)+ f

适用场景:需要在 web 管理后台中,提供"分组 → 通道 → 多路同屏预览"的监控能力。
技术栈:spring boot + vue2(ruoyi 风格)+ flv.js + zlmediakit(rtsp→http-flv 代理)。

一、功能概述

需求要点:

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

二、技术架构设计

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/delstreamproxy rest 接口
后端仅做"建流 + 返地址",不转码转码吃 cpu,交给 zlm;后端只调用 zlm 的 http api
设备离线判断业务状态字段(status无独立"摄像头在线"心跳,以设备状态 离线 作为不预览依据

2.3 数据流(单路播放)

  1. 前端点通道 → getstreamurl(deviceid)
  2. 后端查设备拿到 rtsp 地址,调 zlm addstreamproxy 按需拉起代理,返回 http://zlm:port/live/dev_1.flv
  3. 前端把该 flv 地址交给 flv.js 建 player<video> 播放。
  4. 关闭时(手动/被 lru 淘汰/销毁)调 closestream(deviceid) → zlm delstreamproxy 释放拉流资源。

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 建/断流,后端不转码:

@service
public class zlmstreamservice {
    private static final string stream_prefix = "dev_";
    @autowired private zlmproperties zlmproperties;
    @autowired private resttemplate resttemplate;

    /** 按需拉起 rtsp 代理,返回 http-flv 地址;幂等(流已存在不报错) */
    public string openstream(long deviceid, string rtspurl) {
        string streamid = stream_prefix + deviceid;
        try {
            uri uri = 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 (exception e) {
            log.warn("zlmediakit addstreamproxy 异常:{}", e.getmessage());
        }
        return buildflvurl(streamid);
    }

    /** 释放拉流资源 */
    public void closestream(long deviceid) {
        string streamid = stream_prefix + deviceid;
        string streamkey = zlmproperties.getapp() + "/" + streamid;
        try {
            uri uri = uricomponentsbuilder
                .fromhttpurl(buildapibase() + "/index/api/delstreamproxy")
                .queryparam("secret", zlmproperties.getsecret())
                .queryparam("streamkey", streamkey)
                .build().touri();
            resttemplate.getforentity(uri, string.class);
        } catch (exception e) {
            log.warn("zlmediakit delstreamproxy 异常:{}", e.getmessage());
        }
    }

    private string buildapibase() {
        return "http://" + zlmproperties.gethost() + ":" + zlmproperties.gethttpport();
    }
    private string buildflvurl(string streamid) {
        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() 关键配置(降低直播延迟、防内存膨胀):

const player = 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。核心逻辑:

const max_preview = 6
const poll_interval = 10000
data() {
  return {
    shedlist: [], currentshedid: null,
    devicelist: [], playinglist: [], polltimer: null
  }
}
computed: {
  // 固定 6 格:不足用 null 占位
  cells() {
    const arr = this.playinglist.slice()
    while (arr.length < max_preview) arr.push(null)
    return arr
  }
}
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) return
  if (device.status === '离线') {
    this.$message.warning(`设备【${device.devicename}】不在线,无法预览`)
    return
  }
  if (!device.videourl) {
    this.$message.warning(`设备【${device.devicename}】未配置视频地址`)
    return
  }
  // 已在预览:按"最近使用"置顶(移到队尾,画面不中断)
  const existidx = this.playinglist.findindex(d => d.id === device.id)
  if (existidx >= 0) {
    const item = 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) {
    const removed = 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 => {
    const dev = this.devicelist.find(d => d.id === p.id)
    if (dev) p.status = dev.status
  })
}

四、通用示例代码

4.1 通用后端(spring boot + zlm)

@restcontroller
@requestmapping("/api/stream")
public class streamcontroller {

    @autowired private zlmclient zlmclient;        // 封装 zlm 的 addstreamproxy/delstreamproxy
    @autowired private channelservice channelservice; // 你的通道/摄像头配置服务

    /** 获取某通道的播放地址(按需建流) */
    @getmapping("/url")
    public result geturl(@requestparam long channelid) {
        channel ch = channelservice.getbyid(channelid);
        if (ch == null) return result.fail("通道不存在");
        if (ch.getrtspurl() == null) return result.fail("通道未配置视频地址");
        string flv = zlmclient.openstream(channelid, ch.getrtspurl());
        return result.ok(map.of("flvurl", flv));
    }

    /** 关闭流(释放资源) */
    @getmapping("/close")
    public result close(@requestparam long channelid) {
        zlmclient.closestream(channelid);
        return result.ok();
    }
}

通用要点:后端只返回播放地址,不关心前端用几路、怎么布局;通道的"在线"建议在 channel 上维护一个 online 布尔字段(由设备心跳/保活更新),比纯状态字符串更通用。

4.2 通用前端 api 封装

// api/stream.js
import axios from 'axios'
const http = axios.create({ baseurl: '/api' })
export function getstreamurl(channelid) {
  return http.get('/stream/url', { params: { channelid } })
}
export function closestream(channelid) {
  return http.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 不保证触发。兜底:确认 zlmediakit config.ini*_idle_timeout 已开启,空闲自动断流。
  • 菜单务必设 is_cache=0,离开即销毁组件释放资源。

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

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

以上就是基于springboot+vue实现视频广场功能的操作教程的详细内容,更多关于springboot vue视频广场功能的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com