Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 59bd83403f fix: restore recording playback and default to 30-day retention 2026-10-06 22:20:00 +08:00
17 changed files with 160 additions and 20 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点
**录像机原有历史录像回放尚未实现。** 需要取得具体型号及合法设备账号,接入其录像检索和回放接口;实时 RTSP 地址不能代替录像检索接口。驱动选项目前仅表示路径模板与接入类型,不能视为某个品牌全系列已经兼容。实际通道号需要由设备确认,不能根据三台双镜头直接猜成通道 1–6。 **录像机原有历史录像回放尚未实现。** 需要取得具体型号及合法设备账号,接入其录像检索和回放接口;实时 RTSP 地址不能代替录像检索接口。驱动选项目前仅表示路径模板与接入类型,不能视为某个品牌全系列已经兼容。实际通道号需要由设备确认,不能根据三台双镜头直接猜成通道 1–6。
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。 本机录像默认关闭,用户逐通道开启。默认最长保留 30 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。保留天数是时间上限,不保证在容量限制内存满该天数。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
HLS 会有数秒延迟。网页清晰度仅提供“主码流”和“子码流”两个选项,默认主码流。主码流预览使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。子码流预览使用实际子码流,由 FFmpeg 按需生成 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。网页不提供原始编码直出的预览选项。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。 HLS 会有数秒延迟。网页清晰度仅提供“主码流”和“子码流”两个选项,默认主码流。主码流预览使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。子码流预览使用实际子码流,由 FFmpeg 按需生成 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。网页不提供原始编码直出的预览选项。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
+13
View File
@@ -0,0 +1,13 @@
# v0.1.38 — 录像留存与回放修复
- 新安装默认最长保留 30 天,保留现有 40 GB 容量上限、8 GB 磁盘预留;既有安装的自定义策略不会因升级被覆盖。
- 容量不足时暂停写入,空间恢复后继续;过期录像由 MediaMTX 自动清理。保留天数是上限,实际时长取决于码率、路数与可用容量。
- 回放页显示实际写入、未开启、设备离线、存储保护等状态;管理员可直接进入当前镜头的录像设置。后台状态刷新不重建播放器。
- 修正首次录像前目录不存在被误报为服务故障;仅匹配当前镜头的缺失目录,权限、损坏文件与真实服务错误仍报错。
- 修正微秒时间转换为网页毫秒时精度丢失,避免查询起点落在片段之前导致 404。查询边界向录像范围内收敛,不填补无录像空档。
- 回放响应关闭反向代理缓冲,并减少 FFmpeg 输入探测等待;保留双路转换并发限制与退出清理。
- “持续录像”开关明确说明存储于服务器并受保留策略管理。
验证:173 项 Python 与 140 项 JavaScript 回归测试;Vue / Bootstrap 生产构建。包含时间边界、空录像目录、权限错误、容量暂停、状态显示及代理缓冲回归。
升级不重建业务数据库,不批量启用摄像头录制。已关闭录制期间的历史画面无法补录。
+1 -1
View File
@@ -1 +1 @@
0.1.37 0.1.38
+34 -8
View File
@@ -73,6 +73,7 @@ SCAN = {'running': False, 'items': [], 'error': '', 'network': '', 'at': None}
ATTEMPTS = {} ATTEMPTS = {}
API_PORT, HLS_PORT, PLAYBACK_PORT = 19997, 18888, 19996 API_PORT, HLS_PORT, PLAYBACK_PORT = 19997, 18888, 19996
MEDIA_AUTH = None MEDIA_AUTH = None
DEFAULT_STORAGE = {'retentionDays': 30, 'maxGB': 40, 'reserveGB': 8}
class Problem(Exception): class Problem(Exception):
@@ -329,7 +330,7 @@ def source(c, quality):
def write_media_config(): def write_media_config():
with LOCK: with LOCK:
paths = {} paths = {}
policy = setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8}) policy = setting('storage', DEFAULT_STORAGE)
for c in objects('cameras'): for c in objects('cameras'):
for quality in ('main', 'sub'): for quality in ('main', 'sub'):
paths[stream_name(c, quality)] = { paths[stream_name(c, quality)] = {
@@ -381,16 +382,24 @@ def media_json(path, playback=False):
return json.load(r) return json.load(r)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
with exc: with exc:
if playback and path.startswith('/list?') and exc.code == 404: if playback and path.startswith('/list?') and exc.code in (400, 404):
try: try:
detail = json.loads(exc.read(4096)) detail = json.loads(exc.read(4096))
if detail.get('error') == 'no recording segments found': message = detail.get('error')
if exc.code == 404 and message == 'no recording segments found':
return []
# MediaMTX 1.21 returns 400 before this camera's first recording.
# Match only the requested camera directory, never permissions,
# another path, a corrupt file, or an unavailable service.
name = url.parse_qs(url.urlsplit(path).query).get('path', [''])[0]
if (exc.code == 400 and re.fullmatch(r'cam_[a-f0-9]{16}_main', name)
and message == 'lstat ' + str(RECORDINGS / name) + ': no such file or directory'):
return [] return []
except (ValueError, AttributeError): except (ValueError, AttributeError):
pass pass
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) raise Problem('录像服务暂不可用,请稍后重试或联系管理员检查服务', 503)
except Exception: except Exception:
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) raise Problem('录像服务暂不可用,请稍后重试或联系管理员检查服务', 503)
def media_config_watch(): def media_config_watch():
@@ -417,6 +426,22 @@ def media_config_watch():
time.sleep(5) time.sleep(5)
def recording_state(camera, ready, media_online):
if not camera.get('record'):
return 'off'
if not camera.get('enabled'):
return 'disabled'
if STORAGE['paused']:
return 'paused'
if not media_online:
return 'unavailable'
if not ready:
return 'offline'
if time.time() - RECENT_RECORDS.get(stream_name(camera), 0) < 45:
return 'recording'
return 'waiting'
def status(): def status():
try: try:
data = media_json('/v3/paths/list?itemsPerPage=200') data = media_json('/v3/paths/list?itemsPerPage=200')
@@ -432,10 +457,10 @@ def status():
if endpoint: if endpoint:
c = dict(c, host=endpoint['host'], port=endpoint['port']) c = dict(c, host=endpoint['host'], port=endpoint['port'])
main = ready.get(stream_name(c), {}) main = ready.get(stream_name(c), {})
recording = recording_state(c, main.get('ready', False), media)
cameras.append({**public_camera(c), 'ready': main.get('ready', False), 'tracks': main.get('tracks', []), cameras.append({**public_camera(c), 'ready': main.get('ready', False), 'tracks': main.get('tracks', []),
'recordingRequested': c['enabled'] and c['record'] and not STORAGE['paused'], 'recordingRequested': c['enabled'] and c['record'] and not STORAGE['paused'],
'recordingActive': bool(c['enabled'] and c['record'] and not STORAGE['paused'] and main.get('ready', False) 'recordingState': recording, 'recordingActive': recording == 'recording'})
and time.time() - RECENT_RECORDS.get(stream_name(c), 0) < 45)})
with LOCK: with LOCK:
storage = dict(STORAGE) storage = dict(STORAGE)
scan = dict(SCAN) scan = dict(SCAN)
@@ -867,6 +892,7 @@ class Handler(BaseHTTPRequestHandler):
self.connection.settimeout(15) self.connection.settimeout(15)
self.send_response(200) self.send_response(200)
self.send_header('Content-Type', 'video/mp4') self.send_header('Content-Type', 'video/mp4')
self.send_header('X-Accel-Buffering', 'no')
self.send_header('Cache-Control', 'no-store') self.send_header('Cache-Control', 'no-store')
self.send_header('Accept-Ranges', 'none') self.send_header('Accept-Ranges', 'none')
self.send_header('X-Content-Type-Options', 'nosniff') self.send_header('X-Content-Type-Options', 'nosniff')
@@ -1107,7 +1133,7 @@ def initialize():
HERITAGE.initialize() HERITAGE.initialize()
JOURNAL.initialize() JOURNAL.initialize()
if not setting('storage'): if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8}) set_setting('storage', DEFAULT_STORAGE)
if not setting('mediaSecret'): if not setting('mediaSecret'):
set_setting('mediaSecret', secrets.token_hex(32)) set_setting('mediaSecret', secrets.token_hex(32))
MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode() MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode()
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "zhao-steward-ui", "name": "zhao-steward-ui",
"private": true, "private": true,
"version": "0.1.37", "version": "0.1.38",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
+1 -1
View File
@@ -15,6 +15,6 @@ export function enhance(root){
export async function loadControllers(){ export async function loadControllers(){
enhance(document.getElementById('app')) enhance(document.getElementById('app'))
new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true}) new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true})
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.37';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)}) for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.38';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
await window.ZhaoVue.boot() await window.ZhaoVue.boot()
} }
+1 -1
View File
@@ -4,7 +4,7 @@
<dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"></option><option value="手机"></option><option value="冰箱"></option><option value="空调"></option><option value="传感器"></option><option value="电脑"></option><option value="路由器"></option><option value="交换机"></option><option value="无线接入点"></option><option value="其他"></option></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog> <dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"></option><option value="手机"></option><option value="冰箱"></option><option value="空调"></option><option value="传感器"></option><option value="电脑"></option><option value="路由器"></option><option value="交换机"></option><option value="无线接入点"></option><option value="其他"></option></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="heritage-dialog"><form id="heritage-form"><div class="dialog-heading"><h2 id="heritage-form-title">传承资料</h2><button type="button" class="quiet" data-close="heritage-dialog">关闭</button></div><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="kind"><div class="form-grid"><label class="full">档案名称 *<input name="name" required maxlength="120"></label><label>相关日期<input name="date" type="date"></label><label>讲述人 / 整理人<input name="contributor" maxlength="80"></label><label class="full">相关内容与来历<textarea name="content" rows="6" maxlength="8000"></textarea></label><label class="full">资料 / 报道链接<input name="sourceUrl" type="url" maxlength="1500" placeholder="https://"></label><label class="full">关联已有设备<select name="deviceReference"><option value="">不关联设备</option></select></label><label data-heritage-asset>所有人 / 共有说明<input name="ownerName" maxlength="80"></label><label data-heritage-asset>保管人<input name="custodianName" maxlength="80"></label><label data-heritage-asset>意向接收人<input name="successorName" maxlength="80"></label><label class="full" data-heritage-asset>交接意愿与说明<textarea name="handoverNote" rows="3" maxlength="2000"></textarea></label><label class="check full"><input type="checkbox" name="archived">归档(保留资料,可恢复)</label></div><p class="description">这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存档案</button></div></form></dialog> <dialog id="heritage-dialog"><form id="heritage-form"><div class="dialog-heading"><h2 id="heritage-form-title">传承资料</h2><button type="button" class="quiet" data-close="heritage-dialog">关闭</button></div><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="kind"><div class="form-grid"><label class="full">档案名称 *<input name="name" required maxlength="120"></label><label>相关日期<input name="date" type="date"></label><label>讲述人 / 整理人<input name="contributor" maxlength="80"></label><label class="full">相关内容与来历<textarea name="content" rows="6" maxlength="8000"></textarea></label><label class="full">资料 / 报道链接<input name="sourceUrl" type="url" maxlength="1500" placeholder="https://"></label><label class="full">关联已有设备<select name="deviceReference"><option value="">不关联设备</option></select></label><label data-heritage-asset>所有人 / 共有说明<input name="ownerName" maxlength="80"></label><label data-heritage-asset>保管人<input name="custodianName" maxlength="80"></label><label data-heritage-asset>意向接收人<input name="successorName" maxlength="80"></label><label class="full" data-heritage-asset>交接意愿与说明<textarea name="handoverNote" rows="3" maxlength="2000"></textarea></label><label class="check full"><input type="checkbox" name="archived">归档(保留资料,可恢复)</label></div><p class="description">这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存档案</button></div></form></dialog>
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><fieldset class="full geo-picker" data-geography="" data-geo-title="空间所在地"></fieldset><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog> <dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><fieldset class="full geo-picker" data-geography="" data-geo-title="空间所在地"></fieldset><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>ONVIF 服务端口<input name="onvifPort" type="number" value="80" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog> <dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>ONVIF 服务端口<input name="onvifPort" type="number" value="80" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">持续录像(保存到服务器,按保留策略清理)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="1" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog> <dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="1" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog> <dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">选择本家庭 / 场所的成员,分别指定主要和备用负责人。主要负责人即一家之主;选择普通成员时,需明确确认授予其本家庭管理权限。备用负责人可稍后安排。</p><p id="leadership-scope" class="fw-semibold"></p><div id="leadership-empty" class="alert alert-info" role="status" hidden>本家庭 / 场所还没有可选成员。请先添加本家庭管理员,保存账号后会自动返回这里。其他家庭的账号不会混入此列表。</div><div class="leadership-member-actions d-flex flex-wrap gap-2 mb-3"><button type="button" id="leadership-new" class="secondary">+ 添加本家庭管理员</button><button type="button" id="leadership-members" class="quiet">管理本家庭成员</button></div><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label id="leadership-grant" class="check full alert alert-warning" hidden><input type="checkbox" name="grantManagement"><span id="leadership-grant-note"></span></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog> <dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">选择本家庭 / 场所的成员,分别指定主要和备用负责人。主要负责人即一家之主;选择普通成员时,需明确确认授予其本家庭管理权限。备用负责人可稍后安排。</p><p id="leadership-scope" class="fw-semibold"></p><div id="leadership-empty" class="alert alert-info" role="status" hidden>本家庭 / 场所还没有可选成员。请先添加本家庭管理员,保存账号后会自动返回这里。其他家庭的账号不会混入此列表。</div><div class="leadership-member-actions d-flex flex-wrap gap-2 mb-3"><button type="button" id="leadership-new" class="secondary">+ 添加本家庭管理员</button><button type="button" id="leadership-members" class="quiet">管理本家庭成员</button></div><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label id="leadership-grant" class="check full alert alert-warning" hidden><input type="checkbox" name="grantManagement"><span id="leadership-grant-note"></span></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
+2 -1
View File
@@ -34,6 +34,7 @@ import DeviceNetworkPanel from './DeviceNetworkPanel.vue'
<section id="page-playback" class="page" hidden> <section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div> <div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
<div class="panel playback-controls"><label>镜头 / 通道<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像(待接入)</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div> <div class="panel playback-controls"><label>镜头 / 通道<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像(待接入)</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
<aside id="recording-health" class="panel" aria-label="本机录像状态" hidden><div class="section-head"><strong id="recording-health-text" role="status"></strong><button id="recording-configure" class="quiet" hidden>录像设置</button></div><p id="recording-policy-note" class="description"></p><p id="recording-start-note" class="description"></p></aside>
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div> <div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载原始片段</a></div></div> <div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载原始片段</a></div></div>
<div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div> <div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div>
@@ -42,7 +43,7 @@ import DeviceNetworkPanel from './DeviceNetworkPanel.vue'
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button class="quiet" data-page="sites" data-admin>管理区域</button><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="例如:卧室 灯;也可查 IP、品牌、型号、人员"></label><button id="device-clear" type="button" class="quiet" hidden>清除设备筛选</button></div><div class="device-actions view-presets" role="group" aria-label="设备分组方式"><button type="button" class="quiet" data-group-preset="space,type">按区域查看</button><button type="button" class="quiet" data-group-preset="type,space">按类型查看</button><button type="button" class="quiet" data-group-preset="manager,type,space">按负责人查看</button></div><details class="group-custom"><summary>自定义分组顺序</summary><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div></details><p id="device-count" class="description"></p></div><div class="device-actions" role="group" aria-label="设备展示方式"><button id="devices-card-view" class="quiet" aria-pressed="true">设备卡片</button><button id="devices-topology-view" class="quiet" aria-pressed="false">网络拓扑</button></div><DeviceCollection /><NetworkMap /></section> <section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button class="quiet" data-page="sites" data-admin>管理区域</button><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="例如:卧室 灯;也可查 IP、品牌、型号、人员"></label><button id="device-clear" type="button" class="quiet" hidden>清除设备筛选</button></div><div class="device-actions view-presets" role="group" aria-label="设备分组方式"><button type="button" class="quiet" data-group-preset="space,type">按区域查看</button><button type="button" class="quiet" data-group-preset="type,space">按类型查看</button><button type="button" class="quiet" data-group-preset="manager,type,space">按负责人查看</button></div><details class="group-custom"><summary>自定义分组顺序</summary><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div></details><p id="device-count" class="description"></p></div><div class="device-actions" role="group" aria-label="设备展示方式"><button id="devices-card-view" class="quiet" aria-pressed="true">设备卡片</button><button id="devices-topology-view" class="quiet" aria-pressed="false">网络拓扑</button></div><DeviceCollection /><NetworkMap /></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section> <section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section> <section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section> <section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">默认最长保留 30 天,超过设定天数的录像会自动删除。容量上限由全部摄像头共用;达到上限或预留空间不足时暂停录像,空间恢复后继续。实际可留时长取决于码率和路数,未必存满设定天数。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-heritage-records" class="page" hidden><div class="section-head"><h2 id="heritage-records-title">文化与家风</h2><div><button class="quiet" data-page="heritage">返回家庭传承</button><button id="heritage-new">+ 新建档案</button></div></div><div class="panel heritage-controls"><label>查找档案<input id="heritage-search" type="search" placeholder="名称、内容、相关人员"></label><label class="check"><input id="heritage-archived" type="checkbox">包含归档资料</label><button id="heritage-reload" class="quiet">重新载入</button></div><p id="heritage-error" class="form-error" role="status"></p><div id="heritage-list" class="site-grid"></div><aside id="heritage-detail" class="panel" hidden></aside></section> <section id="page-heritage-records" class="page" hidden><div class="section-head"><h2 id="heritage-records-title">文化与家风</h2><div><button class="quiet" data-page="heritage">返回家庭传承</button><button id="heritage-new">+ 新建档案</button></div></div><div class="panel heritage-controls"><label>查找档案<input id="heritage-search" type="search" placeholder="名称、内容、相关人员"></label><label class="check"><input id="heritage-archived" type="checkbox">包含归档资料</label><button id="heritage-reload" class="quiet">重新载入</button></div><p id="heritage-error" class="form-error" role="status"></p><div id="heritage-list" class="site-grid"></div><aside id="heritage-detail" class="panel" hidden></aside></section>
<section id="page-family" class="page" hidden> <section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div> <div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
+1 -1
View File
@@ -192,7 +192,7 @@ class Accounts:
state['sites'] = [s for s in state['sites'] if s['id'] in permitted] state['sites'] = [s for s in state['sites'] if s['id'] in permitted]
state['assets'] = [s for s in state['assets'] if s['siteId'] in permitted] state['assets'] = [s for s in state['assets'] if s['siteId'] in permitted]
state['devices'] = [s for s in state['devices'] if s['siteId'] in permitted] state['devices'] = [s for s in state['devices'] if s['siteId'] in permitted]
fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'archiveSource') fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'recordingState', 'archiveSource')
state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted] state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted]
state['recorders'] = [] state['recorders'] = []
state['scan'] = {} state['scan'] = {}
+1
View File
@@ -32,6 +32,7 @@ def command(source, authorization, duration):
return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin', return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
'-threads', '1', '-filter_threads', '1'] + acceleration + [ '-threads', '1', '-filter_threads', '1'] + acceleration + [
'-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n', '-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
'-analyzeduration', '1000000', '-probesize', '1000000',
'-readrate', '1', '-i', source, '-t', str(duration), '-readrate', '1', '-i', source, '-t', str(duration),
'-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [ '-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [
'-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k', '-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k',
+59
View File
@@ -257,6 +257,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(command[command.index('-rc_mode') + 1], 'CQP') self.assertEqual(command[command.index('-rc_mode') + 1], 'CQP')
self.assertIn('h264_vaapi', command) self.assertIn('h264_vaapi', command)
self.assertIn('pipe:1', command) self.assertIn('pipe:1', command)
self.assertEqual(command[command.index('-analyzeduration') + 1], '1000000')
def test_ptz_requires_both_space_and_control_permission(self): def test_ptz_requires_both_space_and_control_permission(self):
app.save_object('sites', dict(id='permitted', name='Permitted')) app.save_object('sites', dict(id='permitted', name='Permitted'))
@@ -378,6 +379,64 @@ class WebTests(unittest.TestCase):
app.media_json('/list?path=example', playback=True) app.media_json('/list?path=example', playback=True)
self.assertTrue(error.fp.closed) self.assertTrue(error.fp.closed)
def test_first_recording_missing_directory_is_empty_only_for_requested_camera(self):
name = 'cam_' + 'a' * 16 + '_main'
path = '/list?path=' + name
expected = 'lstat ' + str(app.RECORDINGS / name) + ': no such file or directory'
for message, empty in [(expected, True), (expected.replace(name, 'other'), False),
(expected.replace('no such file or directory', 'permission denied'), False),
('invalid start timestamp', False)]:
error = app.urllib.error.HTTPError('http://localhost/list', 400, 'Bad Request', {},
io.BytesIO(json.dumps({'error': message}).encode()))
with self.subTest(message=message), patch('app.urllib.request.urlopen', side_effect=error):
if empty:
self.assertEqual(app.media_json(path, playback=True), [])
else:
with self.assertRaises(app.Problem):
app.media_json(path, playback=True)
self.assertTrue(error.fp.closed)
def test_playback_disables_reverse_proxy_buffering(self):
with patch.object(playback, 'stream', return_value=io.BytesIO(b'test-mp4')):
code, headers, body = self.request('/media/playback?camera='+'a'*16+'&start=2026-01-01T00:00:00Z&duration=10')
self.assertEqual(code, 200)
self.assertEqual(headers['X-Accel-Buffering'], 'no')
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertEqual(body, b'test-mp4')
def test_default_retention_is_30_days_and_keeps_space_guards(self):
self.assertEqual(app.DEFAULT_STORAGE, dict(retentionDays=30, maxGB=40, reserveGB=8))
camera = dict(id='e'*16, enabled=True, record=True)
def setting(key, default=None):
return 'test-secret' if key == 'mediaSecret' else default
with tempfile.TemporaryDirectory() as folder, patch.object(app, 'CONFIG', app.Path(folder)/'media.json'), \
patch.object(app, 'objects', return_value=[camera]), patch.object(app, 'setting', side_effect=setting), \
patch.object(app, 'source', return_value='rtsp://test/stream'), patch.dict(app.STORAGE, paused=False):
app.write_media_config()
config=json.loads(app.CONFIG.read_text())
main=config['paths'][app.stream_name(camera)]
self.assertEqual(main['recordDeleteAfter'], '720h')
self.assertTrue(main['record'])
self.assertFalse(config['paths'][app.stream_name(camera, 'sub')]['record'])
app.STORAGE['paused']=True
app.write_media_config()
self.assertFalse(json.loads(app.CONFIG.read_text())['paths'][app.stream_name(camera)]['record'])
def test_recording_status_requires_recent_writes_and_distinguishes_protection(self):
camera=dict(id='e'*16, enabled=True, record=True)
with patch.dict(app.STORAGE, paused=False), patch.dict(app.RECENT_RECORDS, {}, clear=True):
self.assertEqual(app.recording_state(camera, True, True), 'waiting')
app.RECENT_RECORDS[app.stream_name(camera)]=time.time()
self.assertEqual(app.recording_state(camera, True, True), 'recording')
self.assertEqual(app.recording_state(dict(camera,record=False), True, True), 'off')
self.assertEqual(app.recording_state(dict(camera,enabled=False), True, True), 'disabled')
self.assertEqual(app.recording_state(camera, False, True), 'offline')
self.assertEqual(app.recording_state(camera, True, False), 'unavailable')
app.RECENT_RECORDS[app.stream_name(camera)]=time.time()-60
self.assertEqual(app.recording_state(camera, True, True), 'waiting')
app.STORAGE['paused']=True
self.assertEqual(app.recording_state(camera, True, True), 'paused')
def family_save(self, kind, data): def family_save(self, kind, data):
return app.FAMILY.save(kind, dict(data, revision=app.setting('familyRevision', 0)), self.admin) return app.FAMILY.save(kind, dict(data, revision=app.setting('familyRevision', 0)), self.admin)
+22
View File
@@ -4,6 +4,15 @@ test('ranges clip to selected day and merge overlaps without filling gaps',()=>{
const items=[{start:date(-10),duration:40},{start:date(20),duration:30},{start:date(60),duration:100},{start:'invalid',duration:20},{start:date(3),duration:-4},{start:date(3),duration:Infinity}]; const items=[{start:date(-10),duration:40},{start:date(20),duration:30},{start:date(60),duration:100},{start:'invalid',duration:20},{start:date(3),duration:-4},{start:date(3),duration:Infinity}];
assert.deepEqual(Ranges.normalize(items,date(0),date(100)),[{start:+date(0),end:+date(50)},{start:+date(60),end:+date(100)}]); assert.deepEqual(Ranges.normalize(items,date(0),date(100)),[{start:+date(0),end:+date(50)},{start:+date(60),end:+date(100)}]);
}); });
test('microsecond recording starts round inward and never seek into the preceding gap',()=>{
const raw='2026-10-04T00:00:00.340240Z';
const ranges=Ranges.normalize([{start:raw,duration:41.1355}],date(0),date(100));
assert.equal(new Date(ranges[0].start).toISOString(),'2026-10-04T00:00:00.341Z');
assert.equal(new Date(ranges[0].end).toISOString(),'2026-10-04T00:00:41.475Z');
const exact=Ranges.normalize([{start:'2026-10-04T00:00:00.340000Z',duration:10}],date(0),date(100));
assert.equal(exact[0].start,Date.parse(raw));
assert.deepEqual(Ranges.normalize([{start:raw,duration:0.0001}],date(0),date(1)),[]);
});
test('long recording continues after each one-hour session',()=>{ test('long recording continues after each one-hour session',()=>{
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(3600)}); const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(3600)});
assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null); assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null);
@@ -20,6 +29,19 @@ test('clip requires valid, positive, fully recorded interval up to one hour',()=
function section(file,a,b){const s=fs.readFileSync('web/'+file,'utf8'),i=s.indexOf(a),j=s.indexOf(b,i+a.length);assert.ok(i>=0&&j>i);return s.slice(i,j);} function section(file,a,b){const s=fs.readFileSync('web/'+file,'utf8'),i=s.indexOf(a),j=s.indexOf(b,i+a.length);assert.ok(i>=0&&j>i);return s.slice(i,j);}
function deferred(){let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};} function deferred(){let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};}
function ui(extra={}){const elements=new Map();const $=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){},reset(){},value:'',hidden:false});return elements.get(s);};return vm.createContext({$,Number,Date,URLSearchParams,loggedIn:true,activePage:'playback',RecordingRanges:Ranges,...extra});} function ui(extra={}){const elements=new Map();const $=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){},reset(){},value:'',hidden:false});return elements.get(s);};return vm.createContext({$,Number,Date,URLSearchParams,loggedIn:true,activePage:'playback',RecordingRanges:Ranges,...extra});}
test('recording health distinguishes configured from writing and never changes the player',()=>{
const c=ui({state:{cameras:[{id:'camera',recordingState:'off'}],storage:{retentionDays:30,maxGB:40,reserveGB:8}},isAdmin:()=>true});
c.$('#play-camera').value='camera';c.$('#archive-source').value='local';c.$('#recording-configure').dataset={};
c.$('#play-video').src='keep-playing';
vm.runInContext(section('recording-ui.js','function renderRecordingStatus(','function localDateTime('),c);
for(const [status,text] of [['off','未开启'],['waiting','等待文件写入'],['recording','正在录像'],['paused','磁盘保护'],['offline','等待摄像头'],['unavailable','服务未连接'],['disabled','设备连接已停用']]){
c.state.cameras[0].recordingState=status;c.renderRecordingStatus();assert.ok(c.$('#recording-health-text').textContent.includes(text));
}
assert.match(c.$('#recording-policy-note').textContent,/30 天/);assert.equal(c.$('#recording-configure').dataset.editCamera,'camera');
assert.equal(c.$('#play-video').src,'keep-playing');
c.isAdmin=()=>false;c.state.storage={};c.renderRecordingStatus();assert.equal(c.$('#recording-configure').hidden,true);assert.doesNotMatch(c.$('#recording-policy-note').textContent,/undefined|40 GB/);
c.$('#archive-source').value='recorder';c.renderRecordingStatus();assert.equal(c.$('#recording-health').hidden,true);
});
test('auto-continue preserves user choice, stops short clips and ends at last segment',()=>{ test('auto-continue preserves user choice, stops short clips and ends at last segment',()=>{
const c=ui({playbackRange:{start:0,end:10000},spans:[{start:0,end:10000},{start:20000,end:30000}],playAt:d=>c.played=+d}); const c=ui({playbackRange:{start:0,end:10000},spans:[{start:0,end:10000},{start:20000,end:30000}],playAt:d=>c.played=+d});
vm.runInContext(section('recording-ui.js','function continuePlayback(','function makeClip('),c); vm.runInContext(section('recording-ui.js','function continuePlayback(','function makeClip('),c);
+1
View File
@@ -107,6 +107,7 @@ function render() {
if (activePage === 'recorders') renderRecorders(); if (activePage === 'recorders') renderRecorders();
if (activePage === 'playback') { if (activePage === 'playback') {
setOptions($('#play-camera'), filtered().map(c=>`<option value="${c.id}">${esc(siteName(c.siteId)+' / '+c.name)}</option>`).join('')); setOptions($('#play-camera'), filtered().map(c=>`<option value="${c.id}">${esc(siteName(c.siteId)+' / '+c.name)}</option>`).join(''));
renderRecordingStatus();
drawTimeline(); drawTimeline();
} }
if (activePage === 'storage') renderStorage(); if (activePage === 'storage') renderStorage();
+7 -1
View File
@@ -1,9 +1,15 @@
'use strict'; 'use strict';
// Pure range logic shared by the UI and regression tests. All ranges are [start,end). // Pure range logic shared by the UI and regression tests. All ranges are [start,end).
const RecordingRanges=(()=>{ const RecordingRanges=(()=>{
// Date truncates fractional milliseconds. Round the query start inward so
// MediaMTX never receives a time before this segment's microsecond timestamp.
function firstMillisecond(value){
const time=+new Date(value),fraction=typeof value==='string'&&value.match(/\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/);
return time+(fraction&&/[1-9]/.test(fraction[1].slice(3))?1:0);
}
function normalize(items,start,end){ function normalize(items,start,end){
const lo=+start,hi=+end;if(!Number.isFinite(lo)||!Number.isFinite(hi)||hi<=lo)return []; const lo=+start,hi=+end;if(!Number.isFinite(lo)||!Number.isFinite(hi)||hi<=lo)return [];
const ranges=(Array.isArray(items)?items:[]).filter(s=>s&&Number.isFinite(+new Date(s.start))&&Number.isFinite(Number(s.duration))&&Number(s.duration)>0).map(s=>({start:Math.max(lo,+new Date(s.start)),end:Math.min(hi,+new Date(s.start)+Number(s.duration)*1000)})) const ranges=(Array.isArray(items)?items:[]).filter(s=>s&&Number.isFinite(+new Date(s.start))&&Number.isFinite(Number(s.duration))&&Number(s.duration)>0).map(s=>({start:Math.max(lo,firstMillisecond(s.start)),end:Math.min(hi,Math.floor(+new Date(s.start)+Number(s.duration)*1000))}))
.filter(s=>Number.isFinite(s.start)&&Number.isFinite(s.end)&&s.end>s.start).sort((a,b)=>a.start-b.start); .filter(s=>Number.isFinite(s.start)&&Number.isFinite(s.end)&&s.end>s.start).sort((a,b)=>a.start-b.start);
const result=[];for(const r of ranges){const last=result.at(-1);if(last&&r.start<=last.end)last.end=Math.max(last.end,r.end);else result.push({...r});}return result; const result=[];for(const r of ranges){const last=result.at(-1);if(last&&r.start<=last.end)last.end=Math.max(last.end,r.end);else result.push({...r});}return result;
} }
+13 -2
View File
@@ -1,6 +1,17 @@
'use strict'; 'use strict';
let playbackRange=null; let playbackRange=null;
function playbackSourceHint(){if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';} function playbackSourceHint(){renderRecordingStatus();if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
function renderRecordingStatus(){
const camera=state.cameras.find(c=>c.id===$('#play-camera').value),box=$('#recording-health');
box.hidden=!camera||$('#archive-source').value==='recorder';if(box.hidden)return;
const messages={recording:'正在录像 · 主码流持续保存',off:'未开启本机录像 · 实时画面接通不代表已保存录像',disabled:'录像已暂停 · 设备连接已停用',paused:'录像已暂停 · 已触发容量或磁盘保护',unavailable:'录像服务未连接 · 请联系管理员检查服务',offline:'等待摄像头接通 · 当前没有新录像写入',waiting:'已开启录像 · 等待文件写入,请稍候;若持续等待请检查存储'};
$('#recording-health-text').textContent=messages[camera.recordingState]||'正在读取录像状态';
const policy=state.storage||{};
$('#recording-policy-note').textContent=policy.retentionDays?`最长保留 ${policy.retentionDays} 天,过期自动清理;全平台录像上限 ${policy.maxGB} GB,磁盘预留 ${policy.reserveGB} GB。容量不足会提前暂停,实际可留时长取决于码率和路数。`:'录像由服务器按保留策略自动管理。';
$('#recording-start-note').textContent='本机只保存开启录像期间的画面。新录像按 5 分钟分段保存,回放查询可能稍有延迟。';
const button=$('#recording-configure');button.hidden=!isAdmin();button.dataset.editCamera=camera.id;
}
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;} function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
function resetPlayback(){ function resetPlayback(){
@@ -51,7 +62,7 @@ function makeClip(){
}catch(e){$('#clip-status').textContent=e.message;} }catch(e){$('#clip-status').textContent=e.message;}
} }
$('#query-recordings').onclick=queryRecordings; $('#query-recordings').onclick=queryRecordings;
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=()=>{resetPlayback();playbackSourceHint();};$('#play-date').onchange=resetPlayback; $('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';playbackSourceHint();};$('#archive-source').onchange=()=>{resetPlayback();playbackSourceHint();};$('#play-date').onchange=resetPlayback;
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));}; $('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
$('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));}; $('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';}; $('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-DuGUtT0D.js"></script> <!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-DKr_acag.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-XTjkr4dE.css"> <link rel="stylesheet" crossorigin href="/ui/assets/index-XTjkr4dE.css">
</head><body><div id="app"></div></body></html> </head><body><div id="app"></div></body></html>