☰
NVR嵌入式Web控制台:CGI接口、视频预览与二次开发
2026/10/26 5:21:33 网站建设 项目流程

简介:这是一份从海康威视 DS-8632N-ST 网络硬盘录像机中导出的嵌入式 Web 程序源码包,面向需要研究 NVR 设备网页管理界面、ASP 服务端页面与前端资源组织方式的开发者、运维人员及嵌入式 Web 学习者。原设备虽内置 Web 管理程序却不开放源码,该包可作为理解登录页、预览页及设备配置交互实现的参考样本。压缩包共 266 个文件,约 762KB,包含 ASP 页面、JavaScript 脚本、CSS 样式、XML 配置以及大量 PNG/GIF 图片等,分别对应页面逻辑、交互脚本、界面样式、设备能力与状态数据、图标素材等模块,目录组织较完整。已有 2329 人学习,适合用于分析海康 NVR Web 界面的结构、接口数据与前后端协作方式,也可作为二次开发、教学演示和排错思路的参考资料。

1. 从设备后台抠出来的网页控制台:NVR嵌入设备WEB程序在管什么

接手一台老NVR,浏览器打开管理页直接白屏,F12 里一排/cgi-bin/xxx.cgi请求全红,这就是很多人第一次接触 NVR 嵌入设备 WEB 程序的样子。它跑在录像机内部,本质是一套静态 HTML + JS 前端,配设备固件里编译好的 CGI 进程,登录、实时预览、录像回放、通道配置、报警查询都靠浏览器和服务端私有 HTTP 接口来回对话。它不是云端平台,没有微服务,前端资源就躺在设备只读分区里,改一个按钮都得先搞懂固件怎么挂载。适合两类人:手里有设备、想给管理页加个定制入口的集成商,以及拿到这套 Web 源码、想照着做嵌入式管理台的开发者。下面按能跑、能看、能改的顺序拆。

2. 技术栈与目录结构:一台 NVR 的 Web 根目录里到底放了什么

设备端资源紧张,选型和普通 Web 项目差别很大。先把它跑在什么环境里讲清楚,再看目录,后面读代码才不会迷路。

2.1 运行环境与选型:为什么是 boa + CGI + 原生 JS

这类设备的硬件底子通常是 ARM 或 MIPS 的 SoC,内存几百 MB 起步,根文件系统是 squashfs 只读镜像,可写区一般挂在/mnt/mtd、/mnt/custom或/tmp这类分区上。Web 服务器不会用 Nginx,常见做法是 boa、lighttpd、goahead、appweb 这几个轻量选手,其中 boa 用得最多,原因是它单进程、内存占用小、原生支持 CGI,跟设备固件一起交叉编译进去就行。

CGI 目录里的可执行文件是 C 编译出来的,少量辅助脚本用 shell。每个请求 fork 一个子进程处理,所以接口里不能写耗时逻辑,否则并发一上来设备就卡死。前端则基本是 jQuery 加原生 JS,不上 React/Vue,一方面是设备内置浏览器内核版本老,另一方面是没有构建链,源码直接丢进固件,改完重新打包,不存在npm run build这一步。

播放器这块是分水岭。早期固件用 ActiveX 或 NPAPI 插件,只能跑在 IE 上,插件里做硬解码;新一点的固件转向无插件方案,设备把码流通过 WebSocket 推给浏览器,前端用 WASM 软解(比如基于 ffmpeg 或专用解码库编译的 wasm)再画到 canvas 上。你在源码里看到player/下有.wasm文件和canvas相关逻辑,基本就是后者。

提示:判断一套 NVR Web 程序属于哪一代,看index.html里有没有<object>或clsid字样,有就是插件时代,没有再看js/player/里有没有 wasm,两条都对不上就是纯 HLS/FLV 转封装方案。

2.2 目录结构逐层拆:静态资源、CGI、配置三块地

从固件里解出来的 Web 根目录,结构大致是这样,不同厂商命名有出入,但分层逻辑一致:

web/ ├── index.html # 登录后主框架页,多 iframe 或 tab 切换 ├── login.html # 登录页,独立于主框架 ├── static/ │ ├── css/ # 主题样式,常见 base.css + theme.css │ ├── js/ │ │ ├── api.js # 所有 CGI 请求的统一封装,改接口先改这里 │ │ ├── app.js # 路由、菜单、全局状态 │ │ ├── player/ # 播放器组件,wasm 解码 + canvas 渲染 │ │ └── live/ # 实时预览页业务逻辑 │ ├── libs/ # jquery、moment、第三方 wasm │ └── img/ # 图标、logo、加载动画 ├── cgi-bin/ # 设备端 CGI 可执行文件,前端只认路径 │ ├── auth.cgi # 登录、登出、改密 │ ├── preview.cgi # 实时预览取流地址、码流参数 │ ├── playback.cgi # 录像检索、回放取流 │ ├── config.cgi # 通道、网络、存储配置读写 │ └── alarm.cgi # 报警日志查询 └── conf/ └── menu.json # 菜单树配置,加自定义入口改这里最快

这个结构里,前端开发者真正天天碰的是static/js/api.js和conf/menu.json,前者决定所有接口怎么发,后者决定左侧菜单长什么样。cgi-bin/下的二进制通常不给你源码,只能通过抓包和返回值反推参数,这也是后面调试章节要重点讲的。

几个容易忽略的点:index.html往往不是单页应用,而是用 iframe 把live、playback、config各自嵌进来,好处是模块隔离、改一个页面不影响别的;坏处是 iframe 之间传参靠postMessage或 URL query,调试时容易跟丢。api.js通常会把 baseURL 写成相对路径,所以同一套页面既能从设备上访问,也能在 PC 上起个静态服务访问,前提是接口请求能打到设备,这个后面第 5 章会展开。

conf/menu.json是个很实用的入口,典型内容就是一个树形数组,每项带id、name、url、icon、role。加一个自定义页面,只要往这个数组里塞一项,再在static/下放对应 HTML,前端框架启动时读这个文件渲染菜单即可,不用改编译产物。

3. 核心模块拆解:登录鉴权、实时预览、录像回放三条主链路

这套 Web 程序的功能点很多,真正撑起日常使用的就三条链路:进得去(登录)、看得见(预览)、查得到(回放)。把这三条读透,剩下的配置页都是表单填空。

3.1 登录与会话:token 从哪来、存在哪、怎么带

登录流程一般是前端把用户名和密码做一次 MD5 或带盐哈希,POST 到auth.cgi,设备校验后返回一个 session token 和权限位。token 可能放在响应体,也可能直接 Set-Cookie,取决于固件版本。老版本很多用 Cookie,新版本倾向返回 JSON 让前端自己存sessionStorage,因为设备重启后 Cookie 容易残留导致状态不一致。

api.js里的统一封装大致长这样,改造时优先在这里动手:

// static/js/api.js —— 所有 CGI 请求的统一入口 const API_BASE = '/cgi-bin'; // 设备上和 PC 代理都走这个前缀 // 统一请求方法:自动拼 token、统一错误处理 function request(path, params = {}, method = 'GET') { const token = sessionStorage.getItem('nvr_token') || ''; const query = new URLSearchParams({ ...params, token }).toString(); const url = method === 'GET' ? `${API_BASE}/${path}?${query}` : `${API_BASE}/${path}`; return fetch(url, { method, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: method === 'POST' ? query : undefined, credentials: 'same-origin' // 老固件靠 Cookie 维持会话,这行别删 }) .then(res => res.json()) .then(json => { if (json.code === 401) { // 会话失效,跳回登录页 sessionStorage.removeItem('nvr_token'); location.href = '/login.html'; return Promise.reject(new Error('session expired')); } return json; }); } // 登录:密码先做一次 MD5,再交给设备校验 function login(user, pwd) { const pwdHash = md5(pwd); // 设备端只认哈希,明文不落盘 return request('auth.cgi', { action: 'login', user, pwd: pwdHash }, 'POST') .then(res => { if (res.code === 0) sessionStorage.setItem('nvr_token', res.data.token); return res; }); }

这段代码有三个关键参数要说清楚。token每次请求都带上,是因为 CGI 是无状态的,设备靠它反查会话表;credentials: 'same-origin'是为了兼容仍走 Cookie 的老固件,删掉会出现“登录成功但下一个接口 401”的玄学问题;pwdHash在设备端还会再校验一次盐值,所以前端哈希只是避免明文传输,不是最终凭据。

登录成功后,app.js会拉一次config.cgi?action=getUserInfo拿到权限位,再据此决定哪些菜单渲染、哪些按钮置灰。权限位常见是位掩码,比如0x01预览、0x02回放、0x04配置、0x08用户管理,判断时用按位与,别写成相等比较,这是个踩坑高发点。

3.2 实时预览:一条 WebSocket 把码流送到 canvas

实时预览是整套 Web 里最重的模块。无插件方案的典型链路是:前端先调preview.cgi拿一个带 token 的 WebSocket 地址,然后建连接收 H.264 裸流,把 NAL 单元喂给 WASM 解码器,解出 YUV 后由渲染层画到 canvas。插件方案则是调接口拿 RTSP 地址,交给 ActiveX 控件内部处理,源码里那部分通常只是一段new ActiveXObject(...)。

无插件方案的初始化代码大概是这样:

// static/js/live/preview.js —— 单通道实时预览初始化 async function startPreview(channel, container) { // 1. 向设备申请取流地址,stream=1 主码流,stream=2 子码流 const res = await request('preview.cgi', { action: 'getStreamUrl', channel: channel, // 通道号,从 1 开始 stream: 2 // 预览默认走子码流,省带宽也省解码 }); if (res.code !== 0) throw new Error(res.msg); // 2. 建立 WebSocket,地址里带一次性 token const ws = new WebSocket(res.data.wsUrl); ws.binaryType = 'arraybuffer'; // 3. 初始化 WASM 解码器,绑定 canvas 渲染 const decoder = await Player.create({ canvas: container }); ws.onmessage = evt => { decoder.push(new Uint8Array(evt.data)); // 喂裸流,内部按帧解 }; // 4. 断线重连:设备重启或网络抖动时别让画面永久黑屏 ws.onclose = () => setTimeout(() => startPreview(channel, container), 3000); return ws; }

参数上,stream选主还是子是性能与清晰度的取舍:主码流分辨率高、码率大,四宫格预览同时拉四路主码流,老设备解码器直接跪;子码流分辨率低但流畅,所以预览默认子码流,双击放大才切主码流,这个策略几乎所有 NVR Web 都这么干。channel是设备内部通道编号,不等于界面上的窗口序号,多硬盘录像机里通道和 IP 摄像机的映射关系存在配置里,改造时要先查config.cgi?action=getChannelList。

WASM 解码器这块要注意内存,解码实例要复用不要每路新建,四宫格就是四个实例共享一个渲染循环。另外 WebSocket 消息可能是分片的,不能假设一条消息就是一帧,decoder.push内部要做环形缓冲,按 NAL 起始码切帧。

3.3 录像回放:先查时间段,再按时间戳 seek

回放比预览多一步检索。前端先传通道和起止时间给playback.cgi,设备返回这个时间段内实际有录像的片段列表(因为硬盘可能只存了部分时段),前端把片段画到时间轴上,用户点某一段再申请该段的取流地址。查询和播放是两次独立请求,别想着一次拿全。

// static/js/playback/playback.js —— 按天检索录像并渲染时间轴 async function loadRecordings(channel, day) { const start = day + ' 00:00:00'; const end = day + ' 23:59:59'; // 第一次请求只拿“哪些时间段有录像”,不拿码流 const res = await request('playback.cgi', { action: 'query', channel: channel, start: start, end: end }); if (res.code !== 0) return; // res.data.segments 形如 [{start:'...',end:'...',type:'normal'}] renderTimeline(res.data.segments); // 用户点击某段后才真正申请取流 onSegmentClick = seg => { request('playback.cgi', { action: 'getStreamUrl', channel: channel, start: seg.start, end: seg.end }).then(r => player.play(r.data.wsUrl)); // 复用预览那套播放器 }; }

segments里通常还带type字段区分普通录像、移动侦测、报警录像,时间轴会用不同颜色标出来,改界面时这个字段别丢。回放的 seek 不是本地跳帧,而是重新向设备申请目标时间点的流,所以拖动进度条会有一次请求往返,做交互时加个 loading 遮罩,否则用户以为卡死了。

回放和预览共用同一个播放器组件,区别只在取流接口不同。这也意味着播放器要支持两种数据源标识,改造时最好在组件里留一个sourceType参数,别把判断散在各处。

4. 接口约定与联调:CGI 请求怎么发、JSON 怎么回、错误码怎么看

设备端 CGI 没有 OpenAPI 文档,接口约定全靠抓包和源码里api.js反推。把约定摸清楚,联调效率能翻几倍。

4.1 统一请求格式:GET 查、POST 改、返回永远是三段式

几乎所有 CGI 都遵守同一套格式:GET 用来查询,参数拼在 query 上;POST 用来修改,参数放表单体里,Content-Type是application/x-www-form-urlencoded,不是 JSON。返回体统一是{code, msg, data}三段式,code为 0 表示成功,非 0 是错误码,data里才是业务数据。

这个约定看起来简单,但有三个细节容易翻车。第一,POST 参数必须拼成key=value&key2=value2,部分老固件不认 JSON 体,你发application/json它会直接返回空。第二,参数值要做encodeURIComponent,密码哈希里带+或/时不编码会被当成空格,登录就失败了。第三,data的类型不固定,查询列表时是数组,查询单项时是对象,前端取值前先判断类型,别直接.map。

封装层面,建议在api.js里再加一层按业务分组的函数,比如api.config.getChannelList(),把参数名和错误处理都收进去。这样设备固件升级导致接口变更时,只改一处,页面代码不动。

4.2 错误码与设备状态:别把所有非 0 都当网络错误

错误码是联调时最费时间的部分,因为设备不会告诉你每个码什么意思,只能靠试。下表是这类 NVR Web 接口里高频出现的码,含义按经验整理,具体以你手上固件的返回为准:

code含义常见触发场景前端处理建议
0成功正常返回直接取 data
401会话失效token 过期、设备重启清 token 跳登录页
403权限不足当前用户无该通道权限提示并置灰入口
404资源不存在通道号越界、录像段已覆盖提示并刷新列表
500设备内部错误CGI 子进程异常、磁盘满提示稍后重试,记录日志
503设备忙并发预览路数超限降低码流或减少窗口

处理时别用一个大catch吞掉所有错误,401 和 503 的应对完全不同:前者要重新登录,后者要降级。比较稳的写法是在request里只处理 401,其余码抛给业务层,由业务层决定提示文案。另外设备忙的时候返回可能很慢,fetch 要设超时,不然页面会一直转圈,用户以为死机。

5. 常见问题排查:登录掉线、预览黑屏、回放卡顿、接口 401

这套系统的坑大多不在代码逻辑,而在设备资源限制和固件版本差异。下面几条是排查时最常遇到的,按现象、原因、解决三段记。

5.1 会话与权限类问题

现象:登录成功,点任何菜单都跳回登录页。原因多半是 token 没带上,或者 Cookie 被浏览器拦截。老固件返回的 token 放在 Set-Cookie 里,如果前端同时用sessionStorage存了一份,两者不一致时设备以 Cookie 为准,而fetch默认不带跨域 Cookie,页面和 CGI 不同源就丢了。解决办法是把页面和接口部署在同一域名端口下,或者显式设置credentials: 'include',并确认设备响应头里有Access-Control-Allow-Credentials: true。

现象:管理员账号能看到全部通道,普通账号只看到一部分,但权限位判断总是错。原因是权限用的是位掩码,代码里写成了if (role === 0x04)。正确写法是if (role & 0x04)。这类 bug 很隐蔽,因为管理员账号权限位往往是全 1,怎么判断都对,只有普通账号才暴露。

5.2 视频流与播放类问题

现象:实时预览黑屏,控制台没有报错,WebSocket 状态是已连接。原因通常是解码器没收到完整的 NAL 单元,或者收到的是子码流但解码器按主码流参数初始化。排查时先在ws.onmessage里打印首字节,正常 H.264 流起始码是00 00 00 01,如果不是,说明设备推的不是裸流而是带私有头的封装,需要先剥头。另一个可能是 wasm 文件没加载成功,Player.create失败但被 Promise 吞了,加个.catch就能看到。

现象:回放拖动进度条后画面卡住,过几秒又跳回原位。原因是 seek 请求还没返回,播放器就被旧的时间戳回调覆盖了。回放组件里要维护一个请求序号,每次 seek 自增,回调回来时比对序号,不匹配就丢弃。设备端录像段是文件切片,跨段 seek 还要先判断目标时间落在哪个 segment 里,直接传任意时间戳设备会返回 404。

5.3 部署与缓存类问题

现象:改了index.html或app.js,设备上刷新还是旧页面。这是最经典的一条。设备 Web 服务器会给静态资源加长缓存头,浏览器拿着旧文件不放;更麻烦的是根文件系统是 squashfs 只读,你通过 FTP 传上去的文件可能落在可写分区,而 Web 服务器读的是只读分区里的旧版本,两个路径不是一个地方。解决分两步:先在请求 URL 后加版本号app.js?v=20240601强制刷新;再确认文件到底传到了哪个挂载点,用mount看 squashfs 和可写分区的挂载关系,必要时走固件重打包,别在只读分区上反复覆盖。

提示:改设备页面之前先df -h看一眼可写分区剩余空间,/tmp 类 tmpfs 重启即清空,放定制页面会丢,长期方案还是改固件镜像里的 web 目录。

6. 二次开发改造思路:加一个自定义菜单和一路设备巡检接口

真到落地改造,最常见的需求就两个:在管理页加一个自己的入口,以及提供一个能批量查通道状态的接口。前者改前端,后者加 CGI,两边都不复杂,关键是别碰设备原有逻辑。

加菜单最快的路径是改conf/menu.json,往里追加一项,然后在static/下放一个独立 HTML。菜单项要注意role字段,不填或填 0 可能被权限过滤掉,照着已有项抄一个即可。页面里要用接口就复用api.js,别自己另写一套 fetch,否则 token 和错误处理都得重来。下面是一个最小菜单项和对应页面骨架:

// conf/menu.json 追加项(示例,字段名以现有文件为准) { "id": "custom_check", "name": "设备巡检", "url": "/static/custom/check.html", "icon": "icon-check", "role": 1 // 1 表示登录用户可见,具体值抄相邻项 }

巡检接口我一般不在前端拼多次请求,而是加一个check.cgi在设备端汇总,一次返回所有通道的在线状态、码流状态、硬盘剩余。用 shell 写 CGI 就够,生产环境再换 C:

#!/bin/sh # cgi-bin/check.cgi —— 汇总通道与存储状态,返回 JSON echo "Content-Type: application/json" echo "" # 从设备配置里读通道数,这里假设存在 /mnt/mtd/conf/channel.conf CH_NUM=$(cat /mnt/mtd/conf/channel.conf 2>/dev/null | wc -l) ONLINE=0 for i in $(seq 1 "$CH_NUM"); do # 用一个已存在的设备工具查通道状态,返回 1 表示在线 if [ "$(channel_status "$i")" = "1" ]; then ONLINE=$((ONLINE + 1)) fi done # 硬盘剩余百分比,df 取根分区 DISK=$(df /mnt/mtd 2>/dev/null | awk 'NR==2 {print $5}' | tr -d '%') printf '{"code":0,"msg":"ok","data":{"total":%d,"online":%d,"diskUsed":%s}}\n' \ "$CH_NUM" "$ONLINE" "${DISK:-0}"

脚本里CH_NUM是通道总数,ONLINE是在线数,DISK是已用百分比,前端拿到后直接渲染成一块状态卡。验证方法很简单:先在设备上用curl直接打http://127.0.0.1/cgi-bin/check.cgi看返回是不是合法 JSON,再从前端页面调,两步都通才算接好。注意 CGI 权限要设成可执行,很多固件对/cgi-bin下的文件有属主检查,传上去后chmod 755别忘了。

改造时还有一条血泪经验:别直接改设备自带的api.js和app.js,新建一个custom.js在页面末尾引入,用事件委托挂到已有 DOM 上。设备固件一升级,自带文件全被覆盖,你的改动全没;独立文件至少还在,重新引一次就行。从那以后我每次动设备端页面,都先把原始 web 目录完整备份一份,改完再 diff 一遍确认只动了预期文件,这个习惯救过好几次现场。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询