一、技术选型与准备
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 时只能看见第一路。正确姿势是两步:
/lapp/device/list拿一级设备(NVR/网关/单路设备本身)- 对每台设备并发调
/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 | 含义 |
|---|---|
| 1 | ezopen(推荐,给 EZUIKit 用) |
| 2 | hls |
| 3 | rtmp |
| 4 | flv |
走 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 | 成功 | — |
| 10002 | accessToken 过期/异常 | withAccessTokenRetry 自动重试 |
| 10005 | appKey 异常 | 检查环境变量是否注入 |
| 10017 | appKey 不存在 | 检查环境、有没有用错应用 |
| 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)
写在最后
这套封装在我们生产项目跑了大半年,核心要点就三个:
withAccessTokenRetry高阶函数:业务代码完全不用关心 token 生命周期。- 多通道 NVR 必须
device/list+camera/list两步拉,否则只能看到第一路。 - EZUIKit 销毁三件套(stop + destroy + 清 innerHTML)+ playerKey 防抖,长跑不漏。
把这三件事处理好,剩下的就是 UI 层的活了。