Vue2 + 萤石云 EZUIKit视频监控对接

5 阅读4分钟

一、技术选型与准备

1.1 萤石云账号

open.ys7.com 注册企业账号,控制台拿三样东西:

  • AppKey / AppSecret:调 OpenAPI 的凭证。
  • 设备序列号 deviceSerial:每个摄像头唯一编号。
  • 通道号 channelNo:单路设备恒为 1;NVR/多目设备会有多个通道(1~N)。

1.2 播放方案选 EZUIKit-JS

萤石提供过几种 H5 播放方案,目前主推 ezuikit-js,本文基于 8.x 版本。它支持:

  • 直播 / 回放 / 对讲 / 云台
  • ezopen 协议(ezopen://open.ys7.com/{serial}/{channel}.live),SDK 内部自动选 ws-flv/wss-flv
  • 自带 UI 模板(simple、pcRec、theme、voice)

8.x 用 wasm 解码、janus 做对讲,这些静态资源不能打包进 webpack bundle,必须放到 public 目录让浏览器按 URL 加载。后面会讲怎么处理。

1.3 安装

npm i ezuikit-js@^8.2.6 axios qs

ezuikit-js 仓库lib/ 目录的解码器/对讲文件拷到自己项目的 public/ezuikit_static/,目录结构示意:

public/
└── ezuikit_static/
    ├── PlayCtrlWasm/...      # wasm 解码器
    └── talk/                 # janus 对讲

二、一个绕不开的安全话题:AppSecret 不要放前端

萤石获取 accessToken 的接口需要 AppKey + AppSecret,正确做法是放在后端

浏览器 ──► 自家后端 ──► 萤石 /lapp/token/get
         (你自己的鉴权)    (AppKey/Secret 仅后端持有)

前端只调自家后端拿 accessToken。下面的代码示例为了方便演示,把 token 申请也写在前端了——生产环境一定要把 getAccessToken 内部那次请求换成调你自家后端。封装层解耦做好后,这是改一行的事。


三、封装萤石 OpenAPI 客户端

新建 src/services/ys7.js。这是整个对接的核心,所有页面都基于这个文件。

3.1 axios 基础

萤石 OpenAPI 全部要求 application/x-www-form-urlencoded

import axios from 'axios'
import qs from 'qs'

const BASE_URL = process.env.VUE_APP_YS7 || 'https://open.ys7.com/api'
const APP_KEY = process.env.VUE_APP_YS7_APP_KEY
const APP_SECRET = process.env.VUE_APP_YS7_SECRET

const ys7Client = axios.create({
    timeout: 15000,
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})

function postForm (path, payload) {
    return ys7Client.post(`${BASE_URL.replace(/\/$/, '')}${path}`, qs.stringify(payload))
}

3.2 统一拆包

萤石成功响应固定是 { code: '200', data: {...}, msg: '...' },code 不为 200 即业务错误:

function unwrapResponse (response) {
    const data = (response && response.data) || {}
    if (String(data.code) !== '200') {
        const err = new Error(data.msg || '萤石接口请求失败')
        err.code = String(data.code || '')
        err.raw = data
        throw err
    }
    return data.data
}

把 code 挂到 error.code 上,后面识别 token 过期会用到。

3.3 AccessToken 缓存 + 自动续期

要点:

  • 默认有效期 7 天,没必要每次都请求。
  • 用 localStorage 缓存,剩余 1 天才主动刷新
  • 业务请求拿到 code === 10002 说明 token 失效,强刷一次再重试。
const TOKEN_KEY = 'ys7AccessToken'
const TOKEN_EXP_KEY = 'ys7AccessTokenExpireAt'
const REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000

export async function getAccessToken (forceRefresh = false) {
    const cachedToken = localStorage.getItem(TOKEN_KEY)
    const cachedExp = Number(localStorage.getItem(TOKEN_EXP_KEY) || 0)

    if (!forceRefresh && cachedToken && cachedExp - Date.now() > REFRESH_THRESHOLD_MS) {
        return cachedToken
    }

    // ⚠️ 生产环境把这一段换成请求自家后端
    const data = unwrapResponse(await postForm('/lapp/token/get', {
        appKey: APP_KEY,
        appSecret: APP_SECRET
    }))

    const expireAt = Date.now() + (Number(data.expireTime) || 7 * 24 * 3600) * 1000
    localStorage.setItem(TOKEN_KEY, data.accessToken)
    localStorage.setItem(TOKEN_EXP_KEY, String(expireAt))
    return data.accessToken
}

// 高阶函数:所有业务请求包一层,自动带 token + 过期重试
async function withAccessTokenRetry (requestFactory) {
    let token = await getAccessToken(false)
    try {
        return await requestFactory(token)
    } catch (err) {
        if (String(err.code) !== '10002') throw err
    }
    token = await getAccessToken(true)
    return requestFactory(token)
}

withAccessTokenRetry 是整个文件的灵魂——后面所有接口都包它一层,业务代码就完全不用关心 token 生命周期。

3.4 设备列表 + 通道展开(多通道 NVR 必踩的坑)

原来到这里就只调一个 /lapp/device/list 完事,结果接 NVR 时只能看见第一路。正确姿势是两步:

  1. /lapp/device/list 拿一级设备(NVR/网关/单路设备本身)
  2. 对每台设备并发调 /lapp/device/camera/list 拿底下的通道

最后把所有通道拍平成一个数组返回,UI 层就以"通道"为最小展示单位:

function normalizeChannel (camera) {
    const isOnline = String(camera.status) === '1'
    return {
        id: `${camera.deviceSerial}_${camera.channelNo}`,
        deviceSerial: camera.deviceSerial,
        channelNo: Number(camera.channelNo) || 1,
        deviceName: camera.deviceName || '',
        channelName: camera.channelName || camera.deviceName || '',
        netStatus: isOnline ? 'online' : 'offline',
        isOnline,
        coverUrl: camera.picUrl || ''
    }
}

function normalizeDevice (device) {
    const status = String(device.status == null ? device.netStatus : device.status)
    const isOnline = status === '1' || status.toLowerCase() === 'online'
    return {
        id: device.deviceSerial,
        deviceSerial: device.deviceSerial,
        channelNo: 1,
        deviceName: device.deviceName || device.deviceSerial,
        channelName: '',
        netStatus: isOnline ? 'online' : 'offline',
        isOnline,
        coverUrl: device.picUrl || device.devicePic || ''
    }
}

async function fetchCameraList (token, deviceSerial) {
    const data = unwrapResponse(await postForm('/lapp/device/camera/list', {
        accessToken: token, deviceSerial
    }))
    return Array.isArray(data) ? data : []
}

const DEVICE_CACHE_KEY = 'ys7DeviceListCache'
const DEVICE_CACHE_AT_KEY = 'ys7DeviceListCacheAt'
const DEVICE_CACHE_TTL_MS = 5 * 60 * 1000

export async function getAllDevices ({ useCache = true, pageSize = 50 } = {}) {
    const cachedAt = Number(localStorage.getItem(DEVICE_CACHE_AT_KEY) || 0)
    const cachedRaw = localStorage.getItem(DEVICE_CACHE_KEY)

    if (useCache && cachedRaw && Date.now() - cachedAt < DEVICE_CACHE_TTL_MS) {
        try { return JSON.parse(cachedRaw) } catch (e) { /* fallthrough */ }
    }

    const channels = await withAccessTokenRetry(async (token) => {
        // 1. 分页拉一级设备
        const aggregated = []
        let pageStart = 0
        while (true) {
            const data = unwrapResponse(await postForm('/lapp/device/list', {
                accessToken: token, pageStart, pageSize
            }))
            const records = data.devices || data.deviceInfos || data.list || []
            aggregated.push(...records)
            if (records.length < pageSize) break
            pageStart += 1
        }

        // 2. 并发拉通道;没有通道的设备本身当一个单通道节点
        const tasks = aggregated.map(device =>
            fetchCameraList(token, device.deviceSerial)
                .then(cameras => cameras.length
                    ? cameras.map(normalizeChannel)
                    : [normalizeDevice(device)])
                .catch(() => [normalizeDevice(device)])
        )
        return (await Promise.all(tasks)).flat()
    })

    localStorage.setItem(DEVICE_CACHE_KEY, JSON.stringify(channels))
    localStorage.setItem(DEVICE_CACHE_AT_KEY, String(Date.now()))
    return channels
}

返回结构统一为扁平的"通道数组"。任何 UI 层(树、网格、下拉)拿到这个数组就够了。

加 5 分钟缓存的原因:设备列表请求挺慢(要并发拉每台设备的通道),切页面时反复触发体验很差。useCache: false 可以强刷。

3.5 直播地址

/lapp/v2/live/address/get,关键参数是 protocol

protocol含义
1ezopen(推荐,给 EZUIKit 用)
2hls
3rtmp
4flv

protocol=1,拿到 ezopen://... 直接喂 EZUIKit:

function normalizeLiveUrl (data = {}) {
    // 不同协议下字段名不一样,逐个兜底
    const candidates = [
        data.url, data.ezopen, data.liveAddress, data.flv, data.httpFlv,
        data.wsFlv, data.hls, data.rtmp
    ].filter(Boolean)
    return candidates.find(u =>
        /^https?:\/\//i.test(u) || /^wss?:\/\//i.test(u) || /^ezopen:\/\//i.test(u)
    ) || ''
}

export async function getLiveAddress (device) {
    if (!device || !device.deviceSerial) throw new Error('缺少设备序列号')
    return withAccessTokenRetry(async (token) => {
        const data = unwrapResponse(await postForm('/lapp/v2/live/address/get', {
            accessToken: token,
            deviceSerial: device.deviceSerial,
            channelNo: device.channelNo || 1,
            protocol: 1
        }))
        const url = normalizeLiveUrl(data)
        if (!url) throw new Error('未获取到可播放的直播地址')
        return url
    })
}

3.6 回放地址:直接拼,不用调接口

export function buildPlaybackUrl (device, startTime, endTime) {
    // startTime / endTime 格式:yyyyMMddHHmmss(无连字符无冒号)
    return `ezopen://open.ys7.com/${device.deviceSerial}/${device.channelNo || 1}.rec` +
           `?begin=${startTime}&end=${endTime}`
}

EZUIKit 拿到这种 URL 自动从云存储拉取该时段录像。如果设备只有 SD 卡录像,把 .rec 改成 .local 或在 url 里加 &type=device


四、EZUIKit 播放器组件封装

把初始化/销毁/容器尺寸全部包进一个组件,调用方只给 accessToken + videoUrl 两个 prop。

src/components/EzPlayer.vue

<template>
  <div ref="shell" class="ez-player-shell">
    <div :id="containerId" ref="container" class="ez-player-container" :style="containerStyle"></div>
  </div>
</template>

<script>
import { EZUIKitPlayer as EZUIKitPlayerCore } from 'ezuikit-js'

export default {
    name: 'EzPlayer',
    props: {
        accessToken: { type: String, default: '' },
        videoUrl: { type: String, default: '' },
        autoplay: { type: Boolean, default: true },
        audio: { type: Boolean, default: true },
        // 'simple' 直播 / 'pcRec' 回放 / 'voice' 对讲
        template: { type: String, default: 'simple' }
    },
    data () {
        return {
            player: null,
            playerKey: '',          // 防抖用
            resizeTimer: null,
            containerId: `ez-player-${this._uid}`,
            containerStyle: {}
        }
    },
    watch: {
        accessToken () { this.syncPlayer() },
        videoUrl () { this.syncPlayer() }
    },
    mounted () {
        window.addEventListener('resize', this.handleResize)
        this.$nextTick(this.syncPlayer)
    },
    beforeDestroy () {
        window.removeEventListener('resize', this.handleResize)
        this.destroy()
    },
    methods: {
        handleResize () {
            clearTimeout(this.resizeTimer)
            this.resizeTimer = setTimeout(() => this.player && this.syncPlayer(), 200)
        },
        measureShell () {
            const s = this.$refs.shell
            return { width: (s && s.clientWidth) || 960, height: (s && s.clientHeight) || 540 }
        },
        getOptions () {
            const { width, height } = this.measureShell()
            this.containerStyle = { width: width + 'px', height: height + 'px' }
            return {
                id: this.containerId,
                accessToken: this.accessToken,
                url: this.videoUrl,
                width, height,
                autoplay: this.autoplay,
                audio: this.audio,
                language: 'zh',
                scaleMode: 0,
                template: this.template,
                staticPath: process.env.VUE_APP_EZUIKIT_STATIC_PATH || '/ezuikit_static',
                handleError: (e) => this.$emit('error', e),
                handleSuccess: (r) => this.$emit('success', r)
            }
        },
        syncPlayer () {
            const nextKey = `${this.accessToken}::${this.videoUrl}::${this.template}`
            if (!this.accessToken || !this.videoUrl) return this.destroy()
            if (this.player && this.playerKey === nextKey) return  // 同一组合不重建
            this.destroy()
            this.$nextTick(() => {
                try {
                    this.player = new EZUIKitPlayerCore(this.getOptions())
                    this.playerKey = nextKey
                } catch (e) {
                    this.playerKey = ''
                    this.$emit('error', e)
                }
            })
        },
        destroy () {
            clearTimeout(this.resizeTimer)
            const p = this.player
            this.player = null
            this.playerKey = ''
            try { p && p.stop && p.stop() } catch (e) {}
            try { p && p.destroy && p.destroy() } catch (e) {}
            if (this.$refs.container) this.$refs.container.innerHTML = ''
        }
    }
}
</script>

<style scoped>
.ez-player-shell { width: 100%; height: 100%; background: #000; }
</style>

四个关键点说明:

① 容器必须有明确 width/height(数字 px) EZUIKit 内部按这个尺寸创建 canvas。传 0 或者 auto 会黑屏。这里用外壳 ref 实测尺寸再传给 EZUIKit,并给内部容器 inline-style 锁住,防止 canvas 撑炸父布局。

② staticPath 指向 public 下的资源 是 URL 前缀不是磁盘路径。值通常是 /ezuikit_static升级 ezuikit-js 时必须同步更新这些静态资源,否则会出现 wasm 加载失败、画面黑屏。

③ 销毁要彻底 stop() + destroy() + 清空容器 innerHTML 三件套。少做一步都可能留 ws 连接 / 对讲实例 / 残留 DOM,切设备一百次后内存爆掉。

④ 用 playerKey 防抖 window resize 也会触发 watch(因为 measureShell 间接改了 containerStyle)。用 token::url::template 做去重,同一组合不重建。


五、设备树组件

把 3.4 节返回的扁平通道数组按 deviceSerial 分组,渲染成两层树:

萤石设备 (root)
├─ 大门口 NVR
│  ├─ 通道 1 (在线)
│  ├─ 通道 2 (在线)
│  └─ 通道 3 (离线,置灰)
└─ 单路球机
   └─ 通道 1

核心建树逻辑(用 ant-design-vue a-tree,其它 UI 库换组件即可):

buildTree (deviceList) {
    const groupMap = {}
    deviceList.forEach((ch) => {
        const key = ch.deviceSerial
        if (!groupMap[key]) {
            groupMap[key] = {
                id: `__device_${key}__`,
                name: ch.deviceName || key,
                type: 'group',
                selectable: false,    // 父节点不可选
                children: []
            }
        }
        groupMap[key].children.push({
            ...ch,
            name: ch.channelName || ch.deviceName,
            type: 'device',
            selectable: ch.netStatus !== 'offline'  // 离线置灰不可选
        })
    })
    return [{
        id: '__ys7_root__',
        name: '萤石设备',
        type: 'group',
        selectable: false,
        children: Object.values(groupMap)
    }]
}

两个常踩的小坑:

  • 父分组节点一定要 selectable: false,不然点中"NVR"也会触发选择回调。
  • 离线通道置灰 + 不可选,避免用户点了之后才提示"设备离线"。

六、实时监控页装配

<template>
  <div class="real-time">
    <!-- 左侧设备树 -->
    <ys-video-tree :device-list="allDevices" @select-device="onSelectDevice" />

    <!-- 右侧两态:列表 / 播放器 -->
    <div v-if="step === 1" class="device-grid">
      <a-tabs v-model="activeType" @change="applyFilters">
        <a-tab-pane key="all" :tab="`全部(${types.all})`" />
        <a-tab-pane key="online" :tab="`在线(${types.online})`" />
        <a-tab-pane key="offline" :tab="`离线(${types.offline})`" />
      </a-tabs>
      <a-row :gutter="16">
        <a-col v-for="item in pageData" :key="item.id" :span="6">
          <div :class="['cam-card', item.netStatus === 'offline' && 'offline']" @click="play(item)">
            <img :src="item.coverUrl || defaultCover" />
            <div class="cam-card-bottom">
              <span>{{ item.channelName || item.deviceName }}</span>
              <span>{{ item.isOnline ? '在线' : '离线' }}</span>
            </div>
          </div>
        </a-col>
      </a-row>
      <a-pagination v-model="page.current" :page-size="page.size" :total="filtered.length" />
    </div>

    <div v-else class="player-wrap">
      <a-icon type="close" @click="back" />
      <ez-player
        :access-token="playerToken"
        :video-url="playerUrl"
        @error="onErr"
      />
    </div>
  </div>
</template>

<script>
import EzPlayer from '@/components/EzPlayer.vue'
import YsVideoTree from '@/components/YsVideoTree.vue'
import { getAccessToken, getAllDevices, getLiveAddress } from '@/services/ys7'

export default {
    components: { EzPlayer, YsVideoTree },
    data: () => ({
        step: 1,
        allDevices: [],
        filtered: [],
        page: { current: 1, size: 12 },
        activeType: 'all',
        types: { all: 0, online: 0, offline: 0 },
        playerUrl: '',
        playerToken: ''
    }),
    computed: {
        pageData () {
            const s = (this.page.current - 1) * this.page.size
            return this.filtered.slice(s, s + this.page.size)
        }
    },
    created () { this.fetch() },
    methods: {
        async fetch () {
            try {
                this.allDevices = await getAllDevices()
                this.types.all = this.allDevices.length
                this.types.online = this.allDevices.filter(d => d.isOnline).length
                this.types.offline = this.types.all - this.types.online
                this.applyFilters()
            } catch (e) {
                this.$message.error(e.message)
            }
        },
        applyFilters () {
            this.page.current = 1
            this.filtered = this.activeType === 'all'
                ? [...this.allDevices]
                : this.allDevices.filter(d => d.netStatus === this.activeType)
        },
        onSelectDevice (device) { this.play(device) },
        async play (device) {
            if (!device.isOnline) return this.$message.warning('设备已离线')
            try {
                const [url, token] = await Promise.all([
                    getLiveAddress(device),
                    getAccessToken(false)
                ])
                this.playerUrl = this.upgradeProtocol(url)
                this.playerToken = token
                this.step = 2
            } catch (e) {
                this.$message.error(e.message)
            }
        },
        // HTTPS 站点下的 mixed content 升级
        upgradeProtocol (url) {
            if (!url || /^ezopen:\/\//i.test(url)) return url || ''
            if (window.location.protocol !== 'https:') return url
            return url.replace(/^http:/i, 'https:').replace(/^ws:/i, 'wss:')
        },
        back () {
            this.step = 1
            this.playerUrl = ''
            this.playerToken = ''
        },
        onErr (e) {
            this.$message.error(e.msg || e.message || '播放失败')
        }
    }
}
</script>

特别提醒一个HTTPS 站点的隐藏炸弹:萤石返回的 url 可能是 http://ws://,浏览器会拒绝 mixed content 连接。upgradeProtocol 这步不能省。ezopen:// 协议是给 SDK 内部解析的,不需要改。


七、录像回放页

回放 = 实时监控的播放器 + 一组日期/时间选择器,URL 来源换成 buildPlaybackUrl

<template>
  <div class="replay">
    <ys-video-tree :device-list="allDevices" @select-device="onSelect" />
    <div v-if="!selected" class="empty">请选择设备</div>
    <div v-else class="replay-main">
      <div class="toolbar">
        <a-date-picker v-model="date" :allow-clear="false" />
        <a-time-picker v-model="start" format="HH:mm:ss" :allow-clear="false" />
        <span>至</span>
        <a-time-picker v-model="end" format="HH:mm:ss" :allow-clear="false" />
        <a-button type="primary" :loading="loading" @click="playback">播放</a-button>
      </div>
      <ez-player
        v-if="url"
        :access-token="token"
        :video-url="url"
        template="pcRec"
        @error="onErr"
      />
    </div>
  </div>
</template>

<script>
import moment from 'moment'
import { getAccessToken, getAllDevices, buildPlaybackUrl } from '@/services/ys7'

export default {
    data: () => ({
        allDevices: [],
        selected: null,
        date: moment(),
        start: moment('00:00:00', 'HH:mm:ss'),
        end: moment('23:59:59', 'HH:mm:ss'),
        url: '', token: '', loading: false
    }),
    created () {
        getAllDevices().then(d => (this.allDevices = d))
    },
    methods: {
        onSelect (device) {
            this.url = ''
            this.selected = device
        },
        async playback () {
            this.loading = true
            try {
                const ymd = this.date.format('YYYYMMDD')
                const begin = ymd + this.start.format('HHmmss')
                const finish = ymd + this.end.format('HHmmss')
                const url = buildPlaybackUrl(this.selected, begin, finish)
                const token = await getAccessToken(false)
                // 关键:先清空再赋值,强制组件重建
                this.url = ''
                this.$nextTick(() => {
                    this.url = url
                    this.token = token
                })
            } finally {
                this.loading = false
            }
        },
        onErr (e) {
            this.$message.error(e.msg || e.message || '播放失败')
        }
    }
}
</script>

两个细节:

  • 播放器组件复用,但 template 必须是 pcRec,会带回放时间轴 + 倍速控件。
  • 切换时间段时先把 url 置空再 $nextTick 赋新值,触发组件销毁重建。直接改 url 在某些版本的 EZUIKit 上不会重新拉流,这个坑很隐蔽。

八、常见错误码速查

code含义处理
200成功
10002accessToken 过期/异常withAccessTokenRetry 自动重试
10005appKey 异常检查环境变量是否注入
10017appKey 不存在检查环境、有没有用错应用
20002设备不存在deviceSerial 大小写要严格一致
20018用户不拥有该设备设备没加进当前 AppKey 名下
49999数据异常通常是 channelNo 没传 / 通道不存在

完整错误码:open.ys7.com/help/31


九、上线检查清单

  • AppSecret 不在前端代码 / 不在前端环境变量里(除非是内网私有化部署且评估过风险)
  • public/ezuikit_static/ 目录跟 ezuikit-js 版本对应
  • 站点是 https 时,upgradeProtocol 在生效
  • nginx 反代允许 ws/wss 升级(如果用 hls/flv 直播,还要配 CORS)
  • 设备树离线设备置灰、不可点击
  • 切换设备 / 退出页面时播放器 destroy() 被调用
  • 长期运行内存稳定(重点观察切换 100 次后的 chrome task manager)

写在最后

这套封装在我们生产项目跑了大半年,核心要点就三个:

  1. withAccessTokenRetry 高阶函数:业务代码完全不用关心 token 生命周期。
  2. 多通道 NVR 必须 device/list + camera/list 两步拉,否则只能看到第一路。
  3. EZUIKit 销毁三件套(stop + destroy + 清 innerHTML)+ playerKey 防抖,长跑不漏。

把这三件事处理好,剩下的就是 UI 层的活了。