Compare commits

..

2 Commits

Author SHA1 Message Date
Codex a3f4544ddf feat: introduce Chinese courtyard visual theme v0.1.22 2026-10-04 08:59:15 +08:00
Codex 68be32e269 Add camera presets snapshots and continuous recording playback 2026-10-04 08:33:00 +08:00
17 changed files with 838 additions and 58 deletions
+17 -1
View File
@@ -2,6 +2,10 @@
运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。 运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。
## 界面风格
v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点缀与淡金本人标记。统一实时监控、家谱树、家族记事、设备档案、表单和登录界面;全屏监控保留深色背景。样式集中在 `web/chinese-theme.css`,本地系统宋体用于标题,不请求外部字体。桌面和手机使用同一套功能与自适应布局。
## 对象与空间 ## 对象与空间
- **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。 - **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。
@@ -49,7 +53,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml mediamtx ./data/mediamtx.yml
``` ```
回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。 访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。
@@ -167,3 +171,15 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
### 实机验收完善(v0.1.20) ### 实机验收完善(v0.1.20)
摄像机信息及参数改为紧凑布局;全屏监控隐藏管理参数入口。读取或保存时暂时锁定参数输入,避免正在编辑的值被返回结果覆盖。视频服务错误响应也会正确关闭,修复新 Python 版本提示的资源未释放问题。 摄像机信息及参数改为紧凑布局;全屏监控隐藏管理参数入口。读取或保存时暂时锁定参数输入,避免正在编辑的值被返回结果覆盖。视频服务错误响应也会正确关闭,修复新 Python 版本提示的资源未释放问题。
## 云台常用位置、截图与录像续播(v0.1.21)
- “云台控制”中读取设备预置位。管理员可以保存当前位置、删除位置;有对应空间与云台权限的个人账号可以查看和转到已有位置。保存和删除前检查版本,保存后回读设备列表。位置保存在摄像机中,双镜头可能共用同一云台。名称最多 40 字。本版不提供覆盖已有位置:实测 H9c 当前固件拒绝标准更新请求,调整后可另存新位置,再由管理员决定是否删除旧位置。
- 云台操作按物理设备 / 节点互斥。预置位转动后查询设备状态,观察窗口为 10 秒,单次设备请求另有 5 秒超时;结束、超时或异常都尝试发送 Stop。停止按钮不排队,可取消正在等待的转动。设备网络失联时无法保证 Stop 送达,界面会明确提示核对画面。关闭面板或切换后台时,对正在执行的操作尝试停止。预置位位置和设备能力仍以具体型号、固件实际返回为准。
- 实时画面与录像回放增加“截图”。截取当前播放器已解码的一帧,可预览并下载 PNG。图片仅在当前浏览器内存中生成,不上传到服务器,关闭后释放。截图分辨率取决于当前预览码流;截取时间不是摄像机源时间,仍有播放缓冲延迟。
- 本机录像查询按所选日期裁剪、排序与合并重叠范围。每次转换最多一小时,播放结束可以继续下一段;真实空档会跳过并提示。可取消自动续播。未正常播完的片段不会自动略过,主动暂停不会触发续播。
- 录像页显示实际录像时间,支持以当前播放位置标记片段起止、手动修改起止时间、生成原始编码下载链接。片段最长一小时,不能跨越无录像空档;边界受原始关键帧影响,非逐帧无损剪辑。自动续播保留正在填写的自定义片段。
- 本版不创建新的家庭数据或修改已有账户和录像策略;新增界面及设备操作继续使用既有空间、管理与云台权限。录像机原有历史录像仍未适配,以上续播与片段工具用于小电脑本机录像。
预置位协议依据:[ONVIF PTZ Service 5.4](https://www.onvif.org/specs/srv/ptz/ONVIF-PTZ-Service-Spec.pdf)。自动回归包含权限、并发、版本冲突、设备拒绝或未确认写入、停止、回放空档、日期边界和退出账号后的异步响应清理;模拟设备测试不代表所有品牌均已实机验证。
+1 -1
View File
@@ -1 +1 @@
0.1.20 0.1.22
+25 -3
View File
@@ -597,7 +597,7 @@ class Handler(BaseHTTPRequestHandler):
return return
self.require_auth() self.require_auth()
if path not in ('/api/logout', '/api/account/password', '/api/family/restore', if path not in ('/api/logout', '/api/account/password', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz'): '/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz', '/api/ptz/presets'):
self.require_admin() self.require_admin()
if path == '/api/logout': if path == '/api/logout':
with LOCK: with LOCK:
@@ -619,6 +619,18 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('设备保存请求未确认,可能已经生效;请重新读取参数后核对,勿反复提交', 502) raise Problem('设备保存请求未确认,可能已经生效;请重新读取参数后核对,勿反复提交', 502)
audit('修改摄像机参数 · ' + data['section'], c['name'] + ' / ' + self.user['username']) audit('修改摄像机参数 · ' + data['section'], c['name'] + ' / ' + self.user['username'])
self.answer(result) self.answer(result)
elif path == '/api/ptz/presets':
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
if data.get('action') != 'goto':
self.require_admin()
try:
result = ptz.CONTROLLER.preset_command(c, data)
except ValueError as e:
raise Problem(str(e), 400)
except Exception:
raise Problem('设备操作未确认,请核对画面并重新读取预置位,勿重复提交;转动异常可点击停止', 502)
audit('云台预置位 ' + data['action'], c['name'] + ' / ' + self.user['username'])
self.answer(result)
elif path == '/api/ptz': elif path == '/api/ptz':
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera'))) c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
try: try:
@@ -778,6 +790,15 @@ class Handler(BaseHTTPRequestHandler):
elif path == '/api/users': elif path == '/api/users':
self.require_admin() self.require_admin()
self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]}) self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]})
elif path == '/api/ptz/presets':
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
try:
result, _ = ptz.CONTROLLER.read_presets(c)
except ValueError as e:
raise Problem(str(e), 400)
except Exception:
raise Problem('未能读取预置位,设备可能未开放该接口;方向微调仍可单独使用', 502)
self.answer(dict(result, canManage=self.user['role'] == 'admin'))
elif path == '/api/ptz': elif path == '/api/ptz':
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera'))) c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
try: try:
@@ -854,10 +875,11 @@ class Handler(BaseHTTPRequestHandler):
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css', files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/family.js': 'family.js', '/kinship.js': 'kinship.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js', '/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css', '/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js', '/family-events.js': 'family-events.js',
'/family-history.js': 'family-history.js', '/family-history.js': 'family-history.js',
'/camera-settings.js': 'camera-settings.js', '/camera-settings.js': 'camera-settings.js',
'/recording-ranges.js': 'recording-ranges.js', '/recording-ui.js': 'recording-ui.js', '/snapshot.js': 'snapshot.js',
'/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js', '/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js',
'/zhao-icon.png': 'zhao-icon.png', '/zhao-icon.png': 'zhao-icon.png',
'/vendor/hls.min.js': 'vendor/hls.min.js'} '/vendor/hls.min.js': 'vendor/hls.min.js'}
@@ -873,7 +895,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_header('X-Content-Type-Options', 'nosniff') self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY') self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'same-origin') self.send_header('Referrer-Policy', 'same-origin')
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
self.send_header('Cache-Control', 'no-store' if path == '/' else 'no-cache') self.send_header('Cache-Control', 'no-store' if path == '/' else 'no-cache')
self.end_headers() self.end_headers()
self.wfile.write(raw) self.wfile.write(raw)
+7 -2
View File
@@ -4,6 +4,7 @@ import datetime as dt
import hashlib import hashlib
import secrets import secrets
import urllib.request import urllib.request
import urllib.error
import urllib.parse import urllib.parse
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
@@ -39,8 +40,12 @@ def request(host, port, endpoint, namespace, operation, content, username, passw
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPDigestAuthHandler(passwords)) opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPDigestAuthHandler(passwords))
req = urllib.request.Request(target, data=envelope.encode(), headers={ req = urllib.request.Request(target, data=envelope.encode(), headers={
'Content-Type': 'application/soap+xml; charset=utf-8; action="' + namespace + '/' + operation + '"'}) 'Content-Type': 'application/soap+xml; charset=utf-8; action="' + namespace + '/' + operation + '"'})
with opener.open(req, timeout=5) as response: try:
root = ET.fromstring(response.read(1024 * 1024)) with opener.open(req, timeout=5) as response:
root = ET.fromstring(response.read(1024 * 1024))
except urllib.error.HTTPError as exc:
exc.close()
raise
if root.find('.//{http://www.w3.org/2003/05/soap-envelope}Fault') is not None: if root.find('.//{http://www.w3.org/2003/05/soap-envelope}Fault') is not None:
raise ValueError('ONVIF returned a fault; check device credentials and permissions') raise ValueError('ONVIF returned a fault; check device credentials and permissions')
return root return root
+100
View File
@@ -1,8 +1,10 @@
"""Capability-driven ONVIF pan/tilt: bounded nudges, never latched movement.""" """Capability-driven ONVIF pan/tilt: bounded nudges, never latched movement."""
import hashlib import hashlib
import json
import threading import threading
import time import time
import urllib.parse as url import urllib.parse as url
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
import onvif import onvif
@@ -17,6 +19,100 @@ class Controller:
self.guard = threading.RLock() self.guard = threading.RLock()
self.cache = {} self.cache = {}
self.locks = {} self.locks = {}
self.cancellations = {}
self.preset_versions = {}
def node_key(self, c, info):
return c['host'], info['endpoint'][0], info['node']
def read_presets(self, c):
info = self.discover(c)
node = self.call(c, info['endpoint'], PTZ, 'GetNode',
'<m:NodeToken>' + escape(info['node']) + '</m:NodeToken>')
maximum = int(node.findtext('.//' + T + 'MaximumNumberOfPresets', '0'))
if not 0 < maximum <= 1024:
raise ValueError('设备未开放预置位功能')
root = self.call(c, info['endpoint'], PTZ, 'GetPresets',
'<m:ProfileToken>' + escape(info['profile']) + '</m:ProfileToken>')
items = []
for item in root.findall('.//{' + PTZ + '}Preset'):
token = item.get('token', '')
if not token or len(token) > 256 or any(p['token'] == token for p in items):
raise ValueError('设备返回了无效预置位列表')
items.append(dict(token=token, name=item.findtext(T + 'Name', '') or '未命名位置'))
positions = [ET.tostring(p, encoding='unicode') for p in root.findall('.//{' + PTZ + '}Preset')]
with self.guard:
version = self.preset_versions.get(self.node_key(c, info), 0)
fingerprint = [info['profile'], info['node'], maximum, sorted(items, key=lambda p: p['token']), sorted(positions), version]
revision = hashlib.sha256(json.dumps(fingerprint, sort_keys=True).encode()).hexdigest()
return dict(items=items, maximum=maximum, revision=revision,
note='预置位保存在摄像机中,两个镜头可能共用云台和位置。'), info
def preset_command(self, c, data):
action = data.get('action')
if action not in ('create', 'remove', 'goto'):
raise ValueError('请选择保存当前位置、删除或转到预置位')
info = self.discover(c)
key = self.node_key(c, info)
with self.guard:
lock = self.locks.setdefault(key, threading.Lock())
if not lock.acquire(blocking=False):
raise ValueError('云台正在执行操作,请稍后再点')
try:
snapshot, info = self.read_presets(c)
if data.get('revision') != snapshot['revision']:
raise ValueError('预置位已变化,请重新读取后操作')
token = data.get('token')
if action != 'create' and not any(p['token'] == token for p in snapshot['items']):
raise ValueError('预置位不存在,请重新读取')
body = '<m:ProfileToken>' + escape(info['profile']) + '</m:ProfileToken>'
if action == 'goto':
cancel = threading.Event()
with self.guard:
self.cancellations[key] = cancel
try:
# A finite preset move has no ONVIF Timeout parameter. Bound it on
# the server and send Stop even after an ambiguous network failure.
self.call(c, info['endpoint'], PTZ, 'GotoPreset', body + '<m:PresetToken>' + escape(token) + '</m:PresetToken>')
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if cancel.wait(0.5):
return dict(ok=True, completed=False, message='本次预置位转动已停止')
status = self.call(c, info['endpoint'], PTZ, 'GetStatus', body)
if status.findtext('.//' + T + 'MoveStatus/' + T + 'PanTilt') == 'IDLE':
return dict(ok=True, completed=True, message='设备报告转动结束,请核对画面位置')
return dict(ok=True, completed=False, message='设备未确认转动结束,已发送停止;请核对画面后重试')
finally:
try:
self.call(c, info['endpoint'], PTZ, 'Stop', body + '<m:PanTilt>true</m:PanTilt><m:Zoom>true</m:Zoom>')
finally:
with self.guard:
self.cancellations.pop(key, None)
if action == 'create':
name = data.get('name')
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 40 or any(ord(x) < 32 for x in name):
raise ValueError('位置名称需要 1–40 个字且不包含控制字符')
name = name.strip()
if any(p['name'].casefold() == name.casefold() for p in snapshot['items']):
raise ValueError('位置名称已存在,请换一个名称')
if len(snapshot['items']) >= snapshot['maximum']:
raise ValueError('设备预置位已满')
payload = body + '<m:PresetName>' + escape(name) + '</m:PresetName>'
with self.guard:
self.preset_versions[key] = self.preset_versions.get(key, 0) + 1
response = self.call(c, info['endpoint'], PTZ, 'SetPreset', payload)
saved_token = response.findtext('.//{' + PTZ + '}PresetToken')
else:
with self.guard:
self.preset_versions[key] = self.preset_versions.get(key, 0) + 1
self.call(c, info['endpoint'], PTZ, 'RemovePreset', body + '<m:PresetToken>' + escape(token) + '</m:PresetToken>')
latest, _ = self.read_presets(c)
verified = (not any(p['token'] == token for p in latest['items']) if action == 'remove'
else any(p['token'] == saved_token and p['name'] == name for p in latest['items']))
return dict(ok=True, verified=verified, presets=latest,
message='设备预置位列表已回读确认' if verified else '设备响应与预置位回读不一致,请核对后再操作')
finally:
lock.release()
def call(self, c, endpoint, namespace, operation, body=''): def call(self, c, endpoint, namespace, operation, body=''):
port, path = endpoint port, path = endpoint
@@ -98,6 +194,10 @@ class Controller:
def stop(): def stop():
self.call(c, info['endpoint'], PTZ, 'Stop', body + '<m:PanTilt>true</m:PanTilt>') self.call(c, info['endpoint'], PTZ, 'Stop', body + '<m:PanTilt>true</m:PanTilt>')
if action == 'stop': if action == 'stop':
with self.guard:
cancel = self.cancellations.get(self.node_key(c, info))
if cancel:
cancel.set()
stop() stop()
return {'ok': True, 'stopped': True} return {'ok': True, 'stopped': True}
# Lock by physical host/node, including commands sent through another lens/account. # Lock by physical host/node, including commands sent through another lens/account.
+28 -1
View File
@@ -59,6 +59,33 @@ class WebTests(unittest.TestCase):
with self.subTest(path=path): with self.subTest(path=path):
self.assertEqual(self.request(path, False)[0], 401) self.assertEqual(self.request(path, False)[0], 401)
def test_presets_api_enforces_control_and_management_roles(self):
camera = dict(id='f'*16, name='Preset test', siteId='preset-site', enabled=True)
app.save_object('sites', dict(id='preset-site', name='Preset site'))
app.save_object('cameras', camera)
viewer = app.ACCOUNTS.save(dict(username='preset-viewer', password='test-password', siteIds=['preset-site'], ptzControl=True), self.admin)
token = '2'*64
with app.LOCK:
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, viewer['id']))
app.DB.commit()
path='/api/ptz/presets'
payload=dict(camera=camera['id'],action='goto',token='1',revision='v1')
with patch.object(app.ptz.CONTROLLER,'read_presets',return_value=({'items':[]},{})),patch.object(app.ptz.CONTROLLER,'preset_command',return_value={'ok':True}) as command:
self.assertEqual(self.request(path+'?camera='+camera['id'],False)[0],401)
code,_,body=self.request(path+'?camera='+camera['id'],token=token)
self.assertEqual(code,200);self.assertFalse(json.loads(body)['canManage'])
self.assertEqual(self.request(path,data=payload,token=token)[0],200)
for action in ['create','replace','remove']:
self.assertEqual(self.request(path,data=dict(payload,action=action),token=token)[0],403)
self.assertEqual(command.call_count,1)
self.assertEqual(self.request(path,data=dict(payload,action='create'))[0],200)
self.assertEqual(self.request(path,data=payload,origin='https://other.example')[0],403)
app.ACCOUNTS.save(dict(viewer,ptzControl=False),self.admin)
with app.LOCK:
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, viewer['id']))
app.DB.commit()
self.assertEqual(self.request(path+'?camera='+camera['id'],token=token)[0],403)
def test_static_security_headers(self): def test_static_security_headers(self):
code, headers, _ = self.request('/', False) code, headers, _ = self.request('/', False)
self.assertEqual(code, 200) self.assertEqual(code, 200)
@@ -78,7 +105,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['Cache-Control'], 'no-store') self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body) self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode()) assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
self.assertEqual(len(assets), 15) self.assertEqual(len(assets), 19)
version = (app.ROOT / 'VERSION').read_text().strip() version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets: for asset in assets:
self.assertTrue(asset.endswith('?v=' + version)) self.assertTrue(asset.endswith('?v=' + version))
+104
View File
@@ -0,0 +1,104 @@
import threading
import unittest
from unittest.mock import patch, Mock
from xml.etree.ElementTree import fromstring
import ptz
def xml(body):
return fromstring('<root xmlns:t="' + ptz.onvif.SCHEMA + '" xmlns:p="' + ptz.PTZ + '">' + body + '</root>')
class PresetTests(unittest.TestCase):
def setUp(self):
self.ctrl = ptz.Controller()
self.camera = dict(host='192.168.0.2', username='admin', password='example', mainPath='/stream1')
self.info = dict(endpoint=(80, '/onvif/PTZ'), profile='p1', node='shared')
self.snapshot = dict(revision='v1', maximum=12, items=[dict(token='1', name='Door')])
def test_read_presets_validates_device_and_list(self):
node = xml('<t:MaximumNumberOfPresets>12</t:MaximumNumberOfPresets>')
items = xml('<p:Preset token="1"><t:Name>Door</t:Name></p:Preset>')
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', side_effect=[node, items]):
result, _ = self.ctrl.read_presets(self.camera)
self.assertEqual(result['items'], self.snapshot['items'])
self.assertEqual(len(result['revision']), 64)
self.assertNotIn('endpoint', result)
for response in [xml('<t:MaximumNumberOfPresets>0</t:MaximumNumberOfPresets>'), xml('<t:MaximumNumberOfPresets>999999</t:MaximumNumberOfPresets>')]:
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', return_value=response):
with self.assertRaises(ValueError): self.ctrl.read_presets(self.camera)
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', side_effect=[node, xml('<p:Preset token="1"/><p:Preset token="1"/>')]):
with self.assertRaises(ValueError): self.ctrl.read_presets(self.camera)
def command(self, data, latest=None, response=None):
versions = [(self.snapshot, self.info)]
if latest is not None: versions.append((latest, self.info))
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'read_presets', side_effect=versions), patch.object(self.ctrl, 'call', return_value=response) as call:
result = self.ctrl.preset_command(self.camera, dict(revision='v1', **data))
return result, call
def test_creation_escapes_name_and_confirms_returned_token(self):
latest = dict(self.snapshot, items=[*self.snapshot['items'], dict(token='2', name='Yard & Gate')])
result, call = self.command(dict(action='create', name='Yard & Gate'), latest, xml('<p:PresetToken>2</p:PresetToken>'))
self.assertTrue(result['verified'])
self.assertIn('Yard &amp; Gate', call.call_args.args[4])
self.assertNotIn('<m:PresetToken>', call.call_args.args[4])
def test_stale_snapshot_and_unknown_token_cannot_write(self):
for data in [dict(action='create', name='Yard', revision='old'), dict(action='goto', token='unknown', revision='v1')]:
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'read_presets', return_value=(self.snapshot,self.info)), patch.object(self.ctrl, 'call') as call:
with self.assertRaises(ValueError): self.ctrl.preset_command(self.camera, data)
call.assert_not_called()
self.assertFalse(self.ctrl.locks[self.ctrl.node_key(self.camera,self.info)].locked())
def test_invalid_names_duplicate_names_and_full_device_rejected(self):
for name in ['', 'x'*41, 'bad\nname', None, ' door ']:
with self.assertRaises(ValueError): self.command(dict(action='create', name=name))
self.snapshot['maximum']=1
with self.assertRaises(ValueError): self.command(dict(action='create',name='Yard'))
def test_remove_readback_and_unsupported_replace_rejected(self):
result,call=self.command(dict(action='remove',token='1'),dict(self.snapshot,items=[]))
self.assertTrue(result['verified']);self.assertEqual(call.call_args.args[3],'RemovePreset')
with patch.object(self.ctrl,'call') as call:
with self.assertRaises(ValueError):self.ctrl.preset_command(self.camera,dict(action='replace',token='1',name='Door'))
call.assert_not_called()
def test_ignored_write_is_not_reported_as_verified(self):
result,_=self.command(dict(action='create',name='Yard'),self.snapshot,xml('<p:PresetToken>2</p:PresetToken>'))
self.assertFalse(result['verified'])
def test_readback_failure_releases_lock_and_is_not_retried(self):
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',side_effect=[(self.snapshot,self.info),OSError()]),patch.object(self.ctrl,'call') as call:
with self.assertRaises(OSError):self.ctrl.preset_command(self.camera,dict(action='remove',token='1',revision='v1'))
self.assertEqual(call.call_count,1)
self.assertFalse(self.ctrl.locks[self.ctrl.node_key(self.camera,self.info)].locked())
def test_goto_polls_device_and_always_stops(self):
status=xml('<t:MoveStatus><t:PanTilt>IDLE</t:PanTilt></t:MoveStatus>')
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',side_effect=[None,status,None]) as call,patch('ptz.threading.Event',return_value=Mock(wait=Mock(return_value=False))):
result=self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
self.assertTrue(result['completed'])
self.assertEqual([c.args[3] for c in call.call_args_list],['GotoPreset','GetStatus','Stop'])
self.assertFalse(self.ctrl.cancellations)
def test_goto_network_failure_still_attempts_stop(self):
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',side_effect=[OSError(),None]) as call:
with self.assertRaises(OSError):self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
self.assertEqual(call.call_args.args[3],'Stop');self.assertFalse(self.ctrl.cancellations)
def test_timeout_reports_incomplete_and_stops(self):
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',return_value=xml('<t:MoveStatus><t:PanTilt>MOVING</t:PanTilt></t:MoveStatus>')) as call,patch('ptz.time.monotonic',side_effect=[0,0,11]),patch('ptz.threading.Event',return_value=Mock(wait=Mock(return_value=False))):
result=self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
self.assertFalse(result['completed']);self.assertEqual(call.call_args.args[3],'Stop')
def test_shared_node_blocks_preset_writes_but_stop_cancels(self):
key=self.ctrl.node_key(self.camera,self.info);lock=threading.Lock();lock.acquire();self.ctrl.locks[key]=lock
event=threading.Event();self.ctrl.cancellations[key]=event
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'call'):
with self.assertRaises(ValueError):self.ctrl.preset_command(self.camera,dict(action='create',name='Yard'))
self.ctrl.command(self.camera,'stop');self.assertTrue(event.is_set())
lock.release()
if __name__=='__main__':unittest.main()
+60
View File
@@ -0,0 +1,60 @@
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),Ranges=require('./web/recording-ranges.js');
const start=Date.parse('2026-10-04T00:00:00Z'),date=n=>new Date(start+n*1000);
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}];
assert.deepEqual(Ranges.normalize(items,date(0),date(100)),[{start:+date(0),end:+date(50)},{start:+date(60),end:+date(100)}]);
});
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)});
assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null);
});
test('next segment skips the actual gap and does not replay the previous end',()=>{
const ranges=[{start:+date(0),end:+date(10)},{start:+date(30),end:+date(40)}];assert.equal(Ranges.at(ranges,date(10)),null);
assert.deepEqual(Ranges.next(ranges,date(10)),{start:+date(30),end:+date(40)});
});
test('clip requires valid, positive, fully recorded interval up to one hour',()=>{
const r=[{start:+date(0),end:+date(4000)},{start:+date(5000),end:+date(8000)}];
for(const [a,b] of [[date(1),date(1)],[date(4),date(3)],[date(0),date(3601)],[date(3900),date(5100)],[date(-1),date(5)],[new Date(NaN),date(5)]])assert.throws(()=>Ranges.clip(r,a,b));
assert.deepEqual(Ranges.clip(r,date(0),date(3600)),{start:+date(0),end:+date(3600)});
});
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 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('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});
vm.runInContext(section('recording-ui.js','function continuePlayback(','function makeClip('),c);
c.$('#play-video').ended=true;c.$('#play-video').currentTime=10;c.$('#play-autonext').checked=false;c.continuePlayback();assert.equal(c.played,undefined);
c.$('#play-autonext').checked=true;c.$('#play-video').currentTime=1;c.continuePlayback();assert.match(c.$('#play-info').textContent,/提前结束/);assert.equal(c.played,undefined);
c.$('#play-video').currentTime=10;c.continuePlayback();assert.equal(c.played,20000);assert.match(c.$('#play-info').textContent,/跳过/);
c.playbackRange={start:20000,end:30000};c.continuePlayback();assert.match(c.$('#play-info').textContent,/播放完毕/);
});
test('late recording list cannot replace another camera or a logged-out view',async()=>{
const p=deferred(),c=ui({playbackGeneration:2,queryCamera:'',resetPlayback(){},api:()=>p.promise,drawTimeline(){throw Error('late draw');}});
c.$('#play-camera').value='camera';c.$('#play-date').value='2026-10-04';c.$('#archive-source').value='local';
vm.runInContext(section('recording-ui.js','async function queryRecordings(','function drawTimeline('),c);
const work=c.queryRecordings();c.playbackGeneration++;p.resolve({items:[]});await work;assert.equal(c.spans,undefined);
});
test('download generation refuses gaps and invalidates old links first',()=>{
const c=ui({queryCamera:'camera',spans:[{start:+date(0),end:+date(60)}],playbackParams:()=>new URLSearchParams({start:'test'}),Error});
vm.runInContext(section('recording-ui.js','function makeClip(',"$('#query-recordings')"),c);
c.$('#clip-start').value=date(0).toISOString();c.$('#clip-end').value=date(61).toISOString();c.makeClip();assert.equal(c.$('#clip-download').hidden,true);assert.match(c.$('#clip-status').textContent,/超出/);
});
test('preset load from a closed panel is ignored',async()=>{
const p=deferred(),c=ui({ptzCamera:'first',ptzBusy:false,ptzPresetGeneration:0,clearPresetConfirmation(){},api:()=>p.promise,renderPresets(){throw Error('late render');}});
vm.runInContext(section('monitor.js','async function loadPresets(','async function openPtz('),c);
const work=c.loadPresets();c.ptzPresetGeneration++;c.ptzCamera='';p.resolve({items:[]});await work;
});
test('late goto result cannot overwrite a newer stop response',async()=>{
const p=deferred(),c=ui({ptzBusy:false,ptzCamera:'camera',ptzPresets:{revision:'1'},ptzGeneration:1,ptzCommandGeneration:1,clearPresetConfirmation(){},setPtzBusy(){},api:()=>p.promise});
vm.runInContext(section('monitor.js','async function presetCommand(','function closePtz('),c);
const work=c.presetCommand('goto','1');c.ptzCommandGeneration++;c.$('#preset-status').textContent='stopped';p.resolve({message:'old goto'});await work;assert.equal(c.$('#preset-status').textContent,'stopped');
});
test('snapshot callback cannot reveal a prior login frame after logout',async()=>{
let callback,created=0;const c=ui({sessionGeneration:1,snapshotGeneration:0,clearSnapshot(){c.snapshotGeneration++;},toast(){},URL:{createObjectURL(){created++;}},document:{createElement(){return{getContext:()=>({drawImage(){}}),toBlob:f=>callback=f};}}});
vm.runInContext(section('snapshot.js','async function captureFrame(','document.addEventListener'),c);
const work=c.captureFrame({readyState:4,videoWidth:100,videoHeight:50},'camera','Test');c.sessionGeneration++;c.loggedIn=false;callback({});await work;assert.equal(created,0);
});
test('snapshot rejects empty or errored video without producing a blob',async()=>{
let messages=0;const c=ui({toast:()=>messages++});vm.runInContext(section('snapshot.js','async function captureFrame(','document.addEventListener'),c);
await c.captureFrame({readyState:0,videoWidth:0});await c.captureFrame({readyState:4,videoWidth:100,error:{code:3}});assert.equal(messages,2);
});
+61
View File
@@ -0,0 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, 'web/chinese-theme.css'), 'utf8');
const html = fs.readFileSync(path.join(__dirname, 'web/index.html'), 'utf8');
const root = css.match(/:root\s*\{([^}]+)\}/)[1];
const token = name => root.match(new RegExp('--' + name + ':\\s*(#[0-9a-f]{6})'))[1];
function luminance(hex) {
const values = hex.slice(1).match(/../g).map(v => parseInt(v, 16) / 255)
.map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4);
return values[0] * .2126 + values[1] * .7152 + values[2] * .0722;
}
function contrast(a, b) {
const x = luminance(a), y = luminance(b);
return (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
}
test('paper theme is loaded last, versioned and uses only local fonts', () => {
const styles = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map(x => x[1]);
assert.equal(styles.at(-1), '/chinese-theme.css?v=__ASSET_VERSION__');
assert.doesNotMatch(css, /@import|https?:|@font-face/i);
assert.match(html, /name="theme-color" content="#213d34"/);
assert.match(html, /class="courtyard-landscape"[^>]+aria-hidden="true"/);
});
test('primary text, muted text and actions have at least 4.5:1 contrast', () => {
for (const [fg, bg] of [
[token('ink'), token('paper')], [token('muted'), token('paper')],
[token('muted'), token('panel')], ['#fffdf6', token('accent')],
['#c9d1be', '#213d34'], ['#244638', '#efebdc'],
['#355b3d', '#e4eddf'], ['#835428', '#f2e8d9'],
['#994437', '#fbf8ef'], ['#d9dfca', '#24372b'],
]) assert.ok(contrast(fg, bg) >= 4.5, `${fg} on ${bg}: ${contrast(fg, bg).toFixed(2)}`);
});
test('monitor fullscreen, reduced motion and mobile navigation are explicitly styled', () => {
assert.match(css, /#page-live:fullscreen\s*\{[^}]+color-scheme: dark/);
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(css, /overflow-x: auto/);
assert.match(css, /\.family-node\.self\s*\{[^}]+background: #f1ddb0/);
assert.match(css, /--timeline-paper:/);
});
test('recording timeline paints with theme tokens and keeps range geometry', () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, 'web/recording-ui.js'), 'utf8');
const draw = source.slice(source.indexOf('function drawTimeline()'), source.indexOf('function playbackParams('));
const rectangles = [];
const ctx = { scale() {}, beginPath() {}, moveTo() {}, lineTo() {}, stroke() {},
fillRect(...coordinates) { rectangles.push({ color: this.fillStyle, coordinates }); } };
const canvas = { clientWidth: 480, getContext: () => ctx };
const values = { '--timeline-paper': '#ede8dd', '--timeline-grid': '#d3ccbc', '--timeline-range': '#4c7761' };
const scope = vm.createContext({ $: () => canvas, window: { devicePixelRatio: 2 }, document: { documentElement: {} },
getComputedStyle: () => ({ getPropertyValue: name => values[name] }),
queryStart: new Date(0), queryEnd: new Date(60000), spans: [{ start: 10000, end: 20000 }] });
vm.runInContext(draw + ';drawTimeline()', scope);
assert.equal(canvas.width, 960);
assert.equal(canvas.height, 152);
assert.deepEqual(rectangles, [
{ color: '#ede8dd', coordinates: [0, 0, 480, 76] },
{ color: '#4c7761', coordinates: [80, 25, 80, 25] },
]);
assert.equal(ctx.strokeStyle, '#d3ccbc');
});
+4 -19
View File
@@ -15,6 +15,7 @@ function applyPermissions(){
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : ''; $('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
} }
function clearPrivateView(){ function clearPrivateView(){
if(typeof clearSnapshot==='function')clearSnapshot();
if(typeof clearCameraSettings==='function')clearCameraSettings(); if(typeof clearCameraSettings==='function')clearCameraSettings();
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false; sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{}); if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
@@ -87,7 +88,7 @@ function renderLive() {
const key = ZhaoLivePlayer.key(cameras, quality); const key = ZhaoLivePlayer.key(cameras, quality);
if (key === liveKey) { updateLiveStatus(cameras); return; } if (key === liveKey) { updateLiveStatus(cameras); return; }
stopPlayers(); liveKey = key; stopPlayers(); liveKey = key;
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-camera="${c.id}">截图</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
for (const c of cameras.filter(c=>c.enabled)) { for (const c of cameras.filter(c=>c.enabled)) {
const video = $(`video[data-camera="${c.id}"]`); const video = $(`video[data-camera="${c.id}"]`);
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`)); players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
@@ -137,25 +138,9 @@ $('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;clearP
$('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();render();}; $('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();render();};
$('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}}; $('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}};
$('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}}; $('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}};
function resetPlayback() {playbackGeneration++;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';drawTimeline();$('#recording-list').innerHTML='';} window.addEventListener('beforeunload',stopPlayers);
async function queryRecordings() {
resetPlayback();const generation=playbackGeneration;const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;}
const channel=state.cameras.find(c=>c.id===id);if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。请先提供录像机型号,在录像机或原厂 App 中可继续查看已有录像。';return;}
queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id;
$('#play-info').textContent='正在查找录像…';
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));if(generation!==playbackGeneration)return;spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${esc(s.start)}">${s.date.toLocaleTimeString()} — ${new Date(+s.date+s.duration*1000).toLocaleTimeString()}<small>${(s.duration/60).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){if(generation===playbackGeneration)$('#play-info').textContent=e.message;}
}
function drawTimeline() {const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);ctx.fillStyle='#101f25';ctx.fillRect(0,0,w,76);ctx.strokeStyle='#2b454e';for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}if(!queryStart||!queryEnd)return;ctx.fillStyle='#72cdb5';const total=queryEnd-queryStart;for(const s of spans){const a=Math.max(0,(s.date-queryStart)/total),b=Math.min(1,(+s.date+s.duration*1000-queryStart)/total);if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}}
function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 兼容回放,最多一小时;使用下方时间轴定位';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;}
$('#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;$('#play-date').onchange=resetPlayback;
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
$('#timeline').onclick=e=>{if(!queryStart)return;const rect=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-rect.left)/rect.width));};
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};
const today=new Date();$('#play-date').value=[today.getFullYear(),String(today.getMonth()+1).padStart(2,'0'),String(today.getDate()).padStart(2,'0')].join('-');
window.addEventListener('resize',drawTimeline);window.addEventListener('beforeunload',stopPlayers);
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000); setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);auth(); setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',auth,{once:true});
function renderAssets(){ function renderAssets(){
const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value); const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
+245
View File
@@ -0,0 +1,245 @@
/* 赵府智家 · 庭院书卷。Keep visual tokens separate from layout and playback. */
:root {
color-scheme: light;
--paper: #f4f0e7;
--ink: #2d3932;
--muted: #6e6b5f;
--line: #dcd5c6;
--panel: #fffdf8;
--accent: #315b4d;
--cinnabar: #9e4b3d;
--gold: #86682f;
--serif: "Songti SC", "STSong", "SimSun", serif;
--timeline-paper: #ede8dd;
--timeline-grid: #d3ccbc;
--timeline-range: #4c7761;
color: var(--ink);
background: var(--paper);
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
}
body {
background-image: radial-gradient(ellipse at 85% 0, #e4e9df80, transparent 50%);
min-width: 0;
}
::selection { background: #e8d7ad; color: #2d3932; }
button, .button, input, select, textarea, fieldset { border-radius: 5px; }
button, .button { background: var(--accent); color: #fffdf6; font-weight: 500; transition: background .15s, border-color .15s, box-shadow .15s; }
button:hover, .button:hover { filter: brightness(.95); }
.secondary { background: #e8ede5; color: #315b4d; border-color: #c7d3c4; }
.quiet { background: transparent; color: #586653; border-color: #cfcfbf; }
input, select, textarea { color: var(--ink); background: #fffefa; border-color: #cbc5b6; }
input::placeholder, textarea::placeholder { color: #847f72; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #315b4d12; }
input[type=range], input[type=checkbox] { accent-color: var(--accent); }
label, .family-date legend { color: #625f53; }
h1, h2, h3 { font-family: var(--serif); font-weight: 600; }
h1 { font-size: 34px; letter-spacing: 4px; margin-top: 17px; }
h2 { letter-spacing: 1px; }
.sidebar {
width: 222px;
background: #213d34;
background-image: linear-gradient(145deg, #2d4b3d, transparent 60%);
border-right: 1px solid #425344;
padding: 30px 18px 20px;
color: #ebe7d8;
scrollbar-width: thin;
scrollbar-color: #6f8271 #213d34;
}
.sidebar .brand { gap: 11px; padding: 4px 5px 30px; color: #f3eddf; font-family: var(--serif); font-size: 22px; letter-spacing: 2px; }
.brand-icon { width: 44px; height: 44px; border-radius: 5px; outline: 1px solid #c6b98c66; outline-offset: 3px; }
.brand small { font-family: "Microsoft YaHei", sans-serif; color: #c0c7b5; font-size: 9px; letter-spacing: 2px; margin-top: 10px; }
.nav-label { color: #b2bba6; font-size: 10px; padding: 12px 13px 17px; letter-spacing: 3px; border-top: 1px solid #b9c2aa24; }
.sidebar nav { gap: 5px; }
.sidebar nav button { color: #c9d1be; border-radius: 4px; padding: 12px 13px; gap: 13px; position: relative; border: 1px solid transparent; background: transparent; }
.sidebar nav button span { font-size: 13px; letter-spacing: .6px; }
.sidebar nav button:hover { background: #ffffff09; filter: none; }
.sidebar nav button.active { background: #efebdc; border-color: #e0d6b8; color: #244638; }
.sidebar nav button.active::after { content: ""; width: 5px; height: 5px; background: var(--cinnabar); position: absolute; right: 13px; transform: rotate(45deg); }
.sidebar .side-note { padding: 22px 5px 8px; color: #c4ccb9; font-size: 11px; }
.sidebar .dot { background: #afc39c; width: 5px; height: 5px; }
.sidebar #current-user { color: #d3d7c8; padding: 10px 5px 0; border-top: 1px solid #b9c2aa24; }
.sidebar .quiet { color: #d1d6c6; border-color: #8b9a813f; padding: 9px; font-size: 11px; }
.sidebar #account-password { margin-bottom: 8px; }
.sidebar :focus-visible { outline-color: #e7c887; }
main { margin-left: 222px; padding: 30px 38px 22px; max-width: 1900px; }
main > header { min-height: 142px; padding: 13px 0 23px; margin-bottom: 25px; border-bottom: 1px solid #b4b5a2; position: relative; gap: 24px; isolation: isolate; }
main > header::after { content: ""; position: absolute; bottom: -2px; left: 0; height: 3px; width: 48px; background: var(--cinnabar); }
main > header > div { position: relative; z-index: 1; }
.eyebrow { color: var(--gold); font-size: 10px; letter-spacing: 3px; }
header p { color: #686f60; font-size: 12px; letter-spacing: .5px; }
.header-status { color: #666e5e; gap: 12px; font-size: 11px; flex-shrink: 0; }
.courtyard-landscape { position: absolute; width: 330px; height: 125px; right: 5px; bottom: 0; z-index: 0; pointer-events: none; opacity: .55; }
.badge { background: #e4eddf; color: #355b3d; border: 1px solid #c8d6be; border-radius: 3px; font-size: 10px; }
.badge.off { background: #f2e8d9; color: #835428; border-color: #dfc6a3; }
.metrics { background: #fcfaf4; border-radius: 6px; box-shadow: 0 3px 12px #3a402905; }
.metrics > div { padding: 20px 22px; align-items: baseline; }
.metrics span { color: #6f7260; }
.metrics strong { font-family: Georgia, var(--serif); color: #365846; font-size: 29px; }
.filter-row { color: #6e6b5f; margin: 24px 0; }
.section-head { margin-top: 25px; }
.section-head small { color: #8a7859; font-size: 9px; letter-spacing: 2px; }
.wall-toolbar { padding: 12px 15px; background: #eae8dc; border: 1px solid var(--line); border-radius: 5px; gap: 14px; }
.wall-toolbar select { background: #f9f6ed; }
.live-grid { gap: 22px; }
.camera-card, .site-card, .panel, .empty, .family-canvas-panel { border-radius: 6px; box-shadow: 0 4px 18px #39422a05; }
.camera-card { border-color: #d2cebe; }
.camera-meta { padding: 17px 18px 13px; }
.camera-meta strong { font-family: var(--serif); font-size: 16px; letter-spacing: .5px; }
.camera-meta p { color: #737464; margin-top: 7px; }
.camera-actions { flex-wrap: wrap; gap: 7px; padding-bottom: 16px; }
.camera-actions .quiet { border-color: #dbd9cb; color: #52604d; background: #faf9f2; }
.video-panel { border-radius: 5px; border-color: #c1c6b8; }
.video-empty { color: #b0bdae; background: radial-gradient(ellipse at center, #243d34 0, transparent 70%); }
.video-empty .lens { color: #a5bca4; }
.video-error { color: #f6d7c1; background: #472c26ed; }
.empty { color: #6c7462; border-color: #c3c6b6; }
.empty h3 { color: #384e3b; font-size: 21px; }
.panel { padding: 24px; }
.time-labels { color: #6e705e; }
.recording-item { color: #35533f; background: #f9f7ee; border-color: #d5d1c0; }
.recording-item small { color: #737261; }
.scan-chip { background: #fffdf7; border-radius: 5px; }
.device-table { border-radius: 5px; }
.device-table th { color: #6a6d5c; background: #ecebde; }
.device-table td small { color: #737261; }
.device-table tbody tr:hover { background: #f2f1e7; }
.site-card h3 { font-size: 20px; }
.site-card dt { color: #737261; }
.site-card dd { color: #384e3b; }
.audit-item { color: #56654f; }
.audit-item small { color: #767461; }
.banner { color: #784c26; background: #f6e9cf; border-color: #dfc797; }
dialog { background: #fbf8ef; color: var(--ink); border: 1px solid #d5c8ac; border-radius: 8px; box-shadow: 0 28px 100px #172c3455; padding: 30px; }
dialog::backdrop { background: #20382cb8; backdrop-filter: blur(5px); }
.dialog-heading { border-bottom: 1px solid var(--line); padding-bottom: 20px; gap: 16px; }
.dialog-heading h2 { font-size: 23px; }
.dialog-footer { gap: 10px; flex-wrap: wrap; }
#auth-dialog { border-top: 4px solid var(--cinnabar); padding: 38px; background-image: radial-gradient(ellipse at 100% 0, #e4e9dd, transparent 65%); }
#auth-dialog .brand-icon { width: 52px; height: 52px; }
#auth-form h2 { font-size: 32px; letter-spacing: 3px; margin-top: 26px; }
#auth-form p { color: #6b725d; }
.form-error { color: #994437; }
footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
#toast { background: #2a4c3c; color: #f3efdc; border-color: #819672; border-radius: 5px; box-shadow: 0 8px 25px #30433726; max-width: calc(100vw - 30px); }
.ptz-panel { background: #f8f5eb; border-color: #b9c5ad; border-radius: 7px; box-shadow: 0 12px 50px #26392d44; color: var(--ink); }
.ptz-pad [data-ptz-action=stop] { color: #8b4236; background: #efdbd0; border-color: #d5b2a4; }
#preset-confirmation { background: #f4e2d5; }
#snapshot-image { border-radius: 4px; }
/* Family register: paper, ink lines and a restrained gold highlight for oneself. */
.family-canvas-panel { background: #fcfaf3; border-color: #c7cbb7; }
.family-map-heading { padding-top: 24px; }
.family-map-heading h2 { letter-spacing: 3px; font-size: 25px; }
.family-view-tabs { background: #f1eee2; border-color: #d7d0bf; border-radius: 4px; }
.family-view-tabs button { color: #606650; border-radius: 2px; }
.family-view-tabs [aria-pressed=true] { background: #365945; color: #fffbed; }
.family-map-viewport { background-color: #efece0; background-image: radial-gradient(#c9cbbb 1px, transparent 1px); scrollbar-color: #8b9a7b #e8e6d8; }
.family-map-lines marker path { fill: #678b71; }
.family-edge { stroke: #94a58a; }
.family-edge.focused { stroke: #446b50; }
.family-edge.spouse { stroke: #a9854f; }
.family-edge.nonbiological { stroke: #93829e; }
.family-node { background: #fffdf6; border-color: #c9cdb9; border-radius: 5px; color: #304632; box-shadow: 0 3px 12px #3943250d; }
.family-node > strong { font-family: var(--serif); font-size: 17px; }
.family-node-avatar { background: #e8ecdf; color: #536e45; border-radius: 4px; }
.family-node .family-node-relation { color: #53664a; }
.family-node > small, .family-node-flags > small { color: #6f775f; }
.family-node.self { background: #f1ddb0; border-color: #af873f; box-shadow: 0 5px 18px #b4872425; }
.family-node.self .family-node-avatar { background: #f8eac9; }
.family-node.selected { outline-color: #426c50; }
.family-node.search-hit::after { background: #596b78; }
.self-badge { background: #94682f; color: #fff8e9; border-radius: 2px; }
.family-map-footer { border-color: #d0d3c0; }
.family-map-legend { color: #69705b; }
.family-map-footer #family-map-note:not(:empty) { color: #87652f; }
.family-inspector.is-self { background: linear-gradient(135deg, #f3e3bd, #fcfaf1 65%); border-color: #c3a66b; }
.family-self-heading { color: #88652d; letter-spacing: 1px; }
.family-register { background: #eeecdf; }
.person-card { color: #344732; border-color: #c9cbb9; }
.person-card.selected { background: #e6ecdc; outline-color: #66845b; }
.person-card.self { background: #f1ddb0; border-color: #b89553; color: #5f461f; }
.person-card.self span:not(.self-badge), .person-card.self small { color: #765b2e; }
.family-tree ul { border-color: #a2b197; }
.family-workspace:is(:fullscreen, .family-expanded) { background: var(--paper); }
.family-event-filters { background: #f0eee2; }
.timeline-year { color: #456344; font-family: var(--serif); }
.timeline-event::before { border-color: #b7c2a5; }
.timeline-event::after { background: #768d5f; box-shadow: 0 0 0 4px #e7ebdc; }
.timeline-event > time { color: #6c745d; }
.timeline-event-body { background: #fffdf6; border-color: #d3d6c2; border-radius: 5px; }
.timeline-event:hover .timeline-event-body { background: #f7f7ec; border-color: #8caa78; }
.timeline-event-body > strong { color: #344d32; font-family: var(--serif); font-size: 18px; }
.timeline-event-status, .timeline-summary, .timeline-event-body > small { color: #6b725b; }
.timeline-read { color: #41623b; }
.timeline-event.draft .timeline-event-body { background: #efede3; }
.timeline-event.draft::after { background: #a28a62; }
.event-detail-meta, .person-event-link time { color: #71745e; }
.event-lead { background: #eaecdd; border-color: #8e9f77; color: #46553b; }
.event-article { color: #424c3a; }
.person-event-link { color: #455a3c; }
.history-columns dl > div { border-color: #d9d6c4; }
.history-changed { background: #e8ecda; border-radius: 4px; }
#history-confirm { border-color: #b3be98; border-radius: 5px; background: #efefdf; }
/* The monitoring wall stays dark to keep attention on live video. */
#page-live:fullscreen { --muted: #c0c9b4; --line: #52614d; --panel: #25352c; color: #efecdd; background: #111c16; color-scheme: dark; }
#page-live:fullscreen .wall-toolbar { background: #24372b; border-color: #40543e; }
#page-live:fullscreen label { color: #d9deca; }
#page-live:fullscreen select { background: #1a2a20; color: #ebeede; border-color: #65765a; }
#page-live:fullscreen .quiet { color: #d9dfca; border-color: #66765a; background: transparent; }
#page-live:fullscreen .camera-card { border-color: #394f38; }
#page-live:fullscreen .camera-actions { background: #17251ee8; }
#page-live:fullscreen .ptz-panel { --muted: #6e6b5f; --line: #dcd5c6; color-scheme: light; }
#page-live:fullscreen .ptz-panel label, #page-live:fullscreen .ptz-panel .quiet { color: #586653; border-color: #cfcfbf; }
@media (min-width: 1600px) { main { padding-left: 46px; padding-right: 46px; } }
@media (max-width: 1100px) {
.sidebar { width: 200px; padding: 27px 14px 18px; }
.sidebar .brand { font-size: 20px; gap: 9px; }
.brand-icon { width: 38px; height: 38px; }
main { margin-left: 200px; padding: 24px; }
.metrics > div { padding: 18px 14px; }
.metrics strong { font-size: 25px; }
.courtyard-landscape { width: 260px; opacity: .4; }
}
@media (max-width: 760px) {
.sidebar { position: static; width: auto; padding: 22px 17px 14px; display: grid; grid-template-columns: 1fr auto; gap: 0 10px; overflow: visible; }
.sidebar .brand { padding: 2px 4px 20px; font-size: 22px; grid-column: 1/-1; }
.brand-icon { width: 38px; height: 38px; }
.brand small { font-size: 9px; }
.sidebar nav { grid-column: 1/-1; display: flex; overflow-x: auto; gap: 5px; padding-bottom: 12px; scrollbar-width: thin; scrollbar-color: #819573 #213d34; }
.sidebar nav button { padding: 10px 12px; gap: 7px; flex-shrink: 0; }
.sidebar nav button span { font-size: 12px; }
.sidebar nav button.active::after { display: none; }
.sidebar #current-user { position: static; grid-column: 1/-1; margin: 4px 0 10px; padding-top: 12px; font-size: 11px; }
.sidebar #account-password, .sidebar #logout { margin: 0; font-size: 11px; padding: 8px 12px; align-self: center; width: fit-content; }
.sidebar #logout { justify-self: end; }
main { margin: 0; padding: 20px 17px; }
main > header { min-height: 132px; padding-top: 9px; gap: 10px; margin-bottom: 20px; }
h1 { font-size: 28px; letter-spacing: 3px; }
.eyebrow { font-size: 9px; letter-spacing: 2px; }
.header-status { font-size: 9px; gap: 9px; max-width: 123px; padding-top: 2px; }
.header-status .badge { font-size: 9px; }
header p { max-width: 205px; font-size: 11px; line-height: 1.8; }
.courtyard-landscape { width: 170px; height: 90px; right: 0; opacity: .38; }
.metrics > div { padding: 16px; }
.metrics strong { font-size: 25px; }
.metrics > div:nth-last-child(-n+2) { border-bottom: 0; }
.filter-row { margin: 20px 0; }
.filter-row select { max-width: 155px; }
.filter-row > button { padding: 9px; }
.section-head { flex-wrap: wrap; }
.section-head > div { gap: 10px; }
.wall-toolbar { padding: 11px; gap: 10px; }
.wall-toolbar label { font-size: 12px; }
#wall-fullscreen { padding: 10px 12px; }
.camera-meta { padding: 14px; }
.camera-meta strong { font-size: 15px; }
.camera-actions { padding: 0 14px 14px; gap: 8px; }
.camera-actions button { padding: 9px 11px; }
.panel, .site-card { padding: 18px; }
.family-map-heading h2 { font-size: 23px; }
dialog { padding: 22px; }
#auth-dialog { padding: 30px 26px; }
.dialog-heading h2 { font-size: 21px; }
.dialog-footer { gap: 8px; }
footer { gap: 16px; flex-wrap: wrap; font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
+19 -17
View File
@@ -2,15 +2,16 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#10191d"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"> <title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script> <script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script> <script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script> <script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script>
</head> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>HOME / 本地家庭空间</small></span></a> <a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>家和 · 岁岁安宁</small></span></a>
<div class="nav-label">工作空间</div> <div class="nav-label">家宅 · 起居</div>
<nav aria-label="主导航"> <nav aria-label="主导航">
<button data-page="live" class="active">◫ <span>实时画面</span></button> <button data-page="live" class="active">◫ <span>实时画面</span></button>
<button data-page="playback">◷ <span>录像回放</span></button> <button data-page="playback">◷ <span>录像回放</span></button>
@@ -24,31 +25,31 @@
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button> <p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
</aside> </aside>
<main> <main>
<header><div><div class="eyebrow">ZHAOFU HOME / FAMILY</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header> <header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<div id="banner" class="banner" hidden></div> <div id="banner" class="banner" hidden></div>
<section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section> <section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div> <div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><button id="ptz-close" class="quiet">收起云台</button></section></section> <section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>守望</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-playback" class="page" hidden> <section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</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>
<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 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>
</section> </section>
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>CHANNELS</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section> <section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>CAMERA ASSETS</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section> <section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>器物</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>RECORDERS</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>PLACES</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>STORAGE</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">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 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-family" class="page" hidden> <section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>FAMILY REGISTER</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>
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p> <p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><button id="family-find" class="quiet" hidden>定位搜索结果</button><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div> <div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><button id="family-find" class="quiet" hidden>定位搜索结果</button><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
<div id="family-error" class="form-error" role="status"></div> <div id="family-error" class="form-error" role="status"></div>
<div id="family-workspace" class="family-workspace"> <div id="family-workspace" class="family-workspace">
<section id="family-canvas-panel" class="family-canvas-panel" aria-label="全家关系图"> <section id="family-canvas-panel" class="family-canvas-panel" aria-label="全家关系图">
<div class="family-map-heading"><div><span class="eyebrow">FAMILY CONNECTIONS</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div> <div class="family-map-heading"><div><span class="eyebrow">枝叶相连 · 世代相承</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
<div id="family-map-controls" class="family-map-controls"><div class="family-zoom-controls"><button id="family-zoom-out" class="quiet" aria-label="缩小家谱">−</button><output id="family-zoom-value" aria-live="polite">100%</output><button id="family-zoom-in" class="quiet" aria-label="放大家谱">+</button><button id="family-fit" class="quiet">查看全谱</button></div><button id="family-locate-self" class="quiet">◎ 定位本人</button><button id="family-fullscreen" class="quiet">全屏家谱</button></div> <div id="family-map-controls" class="family-map-controls"><div class="family-zoom-controls"><button id="family-zoom-out" class="quiet" aria-label="缩小家谱">−</button><output id="family-zoom-value" aria-live="polite">100%</output><button id="family-zoom-in" class="quiet" aria-label="放大家谱">+</button><button id="family-fit" class="quiet">查看全谱</button></div><button id="family-locate-self" class="quiet">◎ 定位本人</button><button id="family-fullscreen" class="quiet">全屏家谱</button></div>
<div id="family-tree" class="family-map-viewport" tabindex="0" role="region" aria-label="家谱关系图,可拖动空白处或使用滚动条浏览"></div> <div id="family-tree" class="family-map-viewport" tabindex="0" role="region" aria-label="家谱关系图,可拖动空白处或使用滚动条浏览"></div>
<div id="family-people" class="family-people family-register" hidden></div> <div id="family-people" class="family-people family-register" hidden></div>
@@ -65,7 +66,7 @@
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">亲属称呼用于描述人物关系。摄像头空间权限、家谱权限和管理员身份分别设置。无需为每一位家谱人物创建账号。</p><div id="user-list"></div></section> <section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">亲属称呼用于描述人物关系。摄像头空间权限、家谱权限和管理员身份分别设置。无需为每一位家谱人物创建账号。</p><div id="user-list"></div></section>
<footer><span>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer> <footer><span>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer>
</main> </main>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog> <dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></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="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><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="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><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>设备用户名<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">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</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>设备用户名<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">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</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="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></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="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
@@ -75,10 +76,11 @@
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog> <dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog> <dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog> <dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">FAMILY CHRONICLE</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog> <dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">家事 · 家声</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
<dialog id="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog> <dialog id="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog>
<dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog> <dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog>
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog> <dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调与预置位:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
<div id="toast" role="status" hidden></div> <div id="toast" role="status" hidden></div>
<dialog id="snapshot-dialog" aria-labelledby="snapshot-title"><div class="dialog-heading"><h2 id="snapshot-title">画面截图</h2><button data-close="snapshot-dialog" class="quiet">关闭</button></div><img id="snapshot-image" alt="当前视频画面截图"><p id="snapshot-info" class="description"></p><a id="snapshot-save" class="button" download>下载 PNG 图片</a></dialog>
</body> </body>
</html> </html>
+64 -14
View File
@@ -70,23 +70,73 @@ document.addEventListener('click',e=>{
if(b.dataset.wallBefore||b.dataset.wallAfter){const ids=wallOrder.filter(id=>filtered().some(c=>c.id===id)),id=b.dataset.wallBefore||b.dataset.wallAfter;wallMove(id,ids[ids.indexOf(id)+(b.dataset.wallBefore?-1:1)]);} if(b.dataset.wallBefore||b.dataset.wallAfter){const ids=wallOrder.filter(id=>filtered().some(c=>c.id===id)),id=b.dataset.wallBefore||b.dataset.wallAfter;wallMove(id,ids[ids.indexOf(id)+(b.dataset.wallBefore?-1:1)]);}
if(b.dataset.ptzCamera)openPtz(b.dataset.ptzCamera); if(b.dataset.ptzCamera)openPtz(b.dataset.ptzCamera);
}); });
let ptzPresets=null,ptzPresetGeneration=0,ptzBusy=false,ptzCommandGeneration=0,ptzReady=false,ptzConfirmation=null;
function setPtzBusy(busy){
ptzBusy=busy;
$$('[data-ptz-action]').forEach(b=>b.disabled=!ptzReady||(busy&&b.dataset.ptzAction!=='stop'));
$$('#ptz-presets button,#ptz-presets input').forEach(b=>b.disabled=busy);
if(!busy&&ptzPresets)$('#preset-form button').disabled=ptzPresets.items.length>=ptzPresets.maximum;
}
function clearPresetConfirmation(){ptzConfirmation=null;$('#preset-confirmation').hidden=true;}
function renderPresets(data){
ptzPresets=data;clearPresetConfirmation();
$('#preset-list').innerHTML=data.items.map(p=>`<div class="preset-row"><strong>${esc(p.name)}</strong><button class="quiet" data-preset-goto="${esc(p.token)}">转到</button>${isAdmin()?`<button class="quiet" data-preset-remove="${esc(p.token)}">删除</button>`:''}</div>`).join('')||'<p class="description">还没有预置位。把云台调到需要的位置后保存。</p>';
$('#preset-count').textContent=`${data.items.length} / ${data.maximum} 个位置`;
$('#preset-form').hidden=!isAdmin();$('#preset-form button').disabled=data.items.length>=data.maximum;
}
async function loadPresets(){
if(!ptzCamera||ptzBusy)return;const id=ptzCamera,generation=++ptzPresetGeneration;
ptzPresets=null;clearPresetConfirmation();$('#preset-list').replaceChildren();$('#preset-form').hidden=true;
$('#preset-status').textContent='正在读取设备预置位…';$('#preset-reload').disabled=true;
try{const data=await api('/api/ptz/presets?camera='+encodeURIComponent(id));if(generation!==ptzPresetGeneration||id!==ptzCamera||!loggedIn)return;renderPresets(data);$('#preset-status').textContent=data.note;}
catch(e){if(!e.stale&&generation===ptzPresetGeneration)$('#preset-status').textContent=e.message;}
finally{if(generation===ptzPresetGeneration)$('#preset-reload').disabled=false;}
}
async function openPtz(id){ async function openPtz(id){
const generation=++ptzGeneration;ptzCamera=id;$('#ptz-panel').hidden=false; if(ptzCamera&&ptzCamera!==id)closePtz();
$('#ptz-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像头')+' · 云台'; const generation=++ptzGeneration;ptzCamera=id;ptzReady=false;$('#ptz-panel').hidden=false;
$('#ptz-status').textContent='正在读取云台能力…';$$('[data-ptz-action]').forEach(b=>b.disabled=true); $('#ptz-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像头')+' · 云台与预置位';
try{const caps=await api('/api/ptz?camera='+id);if(generation!==ptzGeneration)return;$('#ptz-status').textContent=caps.note;$$('[data-ptz-action]').forEach(b=>b.disabled=false);} $('#ptz-status').textContent='正在读取云台能力…';setPtzBusy(false);loadPresets();
catch(e){if(generation===ptzGeneration)$('#ptz-status').textContent=e.message;} try{const caps=await api('/api/ptz?camera='+id);if(generation!==ptzGeneration||!loggedIn)return;ptzReady=true;$('#ptz-status').textContent=caps.note;setPtzBusy(ptzBusy);}
catch(e){if(!e.stale&&generation===ptzGeneration)$('#ptz-status').textContent=e.message;}
} }
async function ptzCommand(action){ async function ptzCommand(action){
if(!ptzCamera)return;const id=ptzCamera,generation=ptzGeneration; if(!ptzCamera||(!ptzReady&&action!=='stop')||(ptzBusy&&action!=='stop'))return;
$$('[data-ptz-action]:not([data-ptz-action="stop"])').forEach(b=>b.disabled=true); const id=ptzCamera,generation=ptzGeneration,command=++ptzCommandGeneration;
$('#ptz-status').textContent=action==='stop'?'正在停止…':'正在微调…'; setPtzBusy(true);$('#ptz-status').textContent=action==='stop'?'正在停止…':'正在微调…';
try{await api('/api/ptz',{camera:id,action});if(generation===ptzGeneration)$('#ptz-status').textContent='停止指令已确认,可继续微调';} try{await api('/api/ptz',{camera:id,action});if(generation===ptzGeneration&&command===ptzCommandGeneration)$('#ptz-status').textContent='停止指令已确认,可继续微调';}
catch(e){if(generation===ptzGeneration)$('#ptz-status').textContent=e.message;} catch(e){if(!e.stale&&generation===ptzGeneration&&command===ptzCommandGeneration)$('#ptz-status').textContent=e.message;}
finally{if(generation===ptzGeneration)$$('[data-ptz-action]').forEach(b=>b.disabled=false);} finally{if(generation===ptzGeneration&&command===ptzCommandGeneration)setPtzBusy(false);}
}
function confirmPreset(action,token){
if(!isAdmin()||ptzBusy||!ptzPresets)return;
const preset=ptzPresets.items.find(p=>p.token===token);if(!preset)return;
ptzConfirmation={action,token,name:preset.name};
$('#preset-confirm-text').textContent=`删除“${preset.name}”?设备中的这个预置位会被移除。`;
$('#preset-confirmation').hidden=false;
}
async function presetCommand(action,token,name){
if(ptzBusy||!ptzCamera||!ptzPresets)return;
const id=ptzCamera,generation=ptzGeneration,command=++ptzCommandGeneration,revision=ptzPresets.revision;
clearPresetConfirmation();setPtzBusy(true);$('#preset-status').textContent=action==='goto'?'正在转到预置位,可随时点击停止…':'正在更新设备预置位…';
try{const result=await api('/api/ptz/presets',{camera:id,action,token,name,revision});
if(generation!==ptzGeneration||command!==ptzCommandGeneration||!loggedIn)return;
if(result.presets){renderPresets(result.presets);$('#preset-form').reset();}
$('#preset-status').textContent=result.message;
}catch(e){if(!e.stale&&generation===ptzGeneration&&command===ptzCommandGeneration){ptzPresets=null;$('#preset-list').replaceChildren();$('#preset-form').hidden=true;$('#preset-status').textContent=e.message+'。请重新读取后核对。';}}
finally{if(generation===ptzGeneration&&command===ptzCommandGeneration)setPtzBusy(false);}
}
function closePtz(){
const id=ptzCamera;if(id&&ptzBusy&&loggedIn)api('/api/ptz',{camera:id,action:'stop'}).catch(()=>{});
ptzGeneration++;ptzPresetGeneration++;ptzCommandGeneration++;ptzCamera='';ptzPresets=null;ptzBusy=false;ptzReady=false;
$('#ptz-panel').hidden=true;$('#preset-list').replaceChildren();$('#preset-form').reset();clearPresetConfirmation();
} }
function closePtz(){ptzGeneration++;ptzCamera='';$('#ptz-panel').hidden=true;}
$('#ptz-close').onclick=closePtz; $('#ptz-close').onclick=closePtz;
$$('[data-ptz-action]').forEach(b=>b.onclick=()=>ptzCommand(b.dataset.ptzAction)); $$('[data-ptz-action]').forEach(b=>b.onclick=()=>ptzCommand(b.dataset.ptzAction));
document.addEventListener('visibilitychange',()=>{if(document.hidden&&ptzCamera)ptzCommand('stop');}); $('#preset-reload').onclick=loadPresets;
window.addEventListener('pagehide',()=>{if(ptzCamera)fetch('/api/ptz',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:ptzCamera,action:'stop'})}).catch(()=>{});}); $('#preset-form').onsubmit=e=>{e.preventDefault();presetCommand('create',undefined,$('#preset-name').value);};
$('#preset-confirm-cancel').onclick=clearPresetConfirmation;
$('#preset-confirm-yes').onclick=()=>{if(ptzConfirmation){const p=ptzConfirmation;presetCommand(p.action,p.token,p.name);}};
$('#preset-list').onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.presetGoto)presetCommand('goto',b.dataset.presetGoto);if(b.dataset.presetRemove)confirmPreset('remove',b.dataset.presetRemove);};
document.addEventListener('visibilitychange',()=>{if(document.hidden&&ptzCamera&&ptzBusy)ptzCommand('stop');});
window.addEventListener('pagehide',()=>{if(ptzCamera&&ptzBusy)fetch('/api/ptz',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:ptzCamera,action:'stop'})}).catch(()=>{});});
+21
View File
@@ -0,0 +1,21 @@
'use strict';
// Pure range logic shared by the UI and regression tests. All ranges are [start,end).
const RecordingRanges=(()=>{
function normalize(items,start,end){
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)}))
.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;
}
function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+3600000)}:null;}
function next(ranges,end){const s=ranges.find(s=>s.end>+end);return s?at(ranges,Math.max(s.start,+end)):null;}
function clip(ranges,start,end){
const a=+start,b=+end;
if(!Number.isFinite(a)||!Number.isFinite(b)||b<=a)throw Error('结束时间必须晚于开始时间');
if(b-a>3600000)throw Error('一次下载最多一小时,请缩短片段');
if(!ranges.some(s=>a>=s.start&&b<=s.end))throw Error('所选片段跨越无录像时段或超出查询日期,请选择同一连续录像时段');
return {start:a,end:b};
}
return {normalize,at,next,clip};
})();
if(typeof module!=='undefined')module.exports=RecordingRanges;
+61
View File
@@ -0,0 +1,61 @@
'use strict';
let playbackRange=null;
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(){
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
$('#play-empty').hidden=false;$('#download').hidden=true;$('#clip-download').hidden=true;$('#clip-status').textContent='';$('#clip-form').reset();
spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';$('#play-position').textContent='';drawTimeline();$('#recording-list').innerHTML='';
}
async function queryRecordings(){
resetPlayback();const generation=playbackGeneration,id=$('#play-camera').value,date=$('#play-date').value;
if(!id||!date){toast('请选择摄像头和日期');return;}
if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。现有录像仍可在录像机或原厂 App 中查看。';return;}
queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id;
$('#play-info').textContent='正在查找录像…';
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));
if(generation!==playbackGeneration||!loggedIn)return;
spans=RecordingRanges.normalize(d.items,queryStart,queryEnd);
$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();
$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${new Date(s.start).toISOString()}">${new Date(s.start).toLocaleTimeString()} — ${new Date(s.end).toLocaleTimeString()}<small>${((s.end-s.start)/60000).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');
if(spans.length)playAt(new Date(spans[0].start));
}catch(e){if(!e.stale&&generation===playbackGeneration)$('#play-info').textContent=e.message;}
}
function drawTimeline(){
const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);const theme=getComputedStyle(document.documentElement);ctx.fillStyle=theme.getPropertyValue('--timeline-paper').trim()||'#ede8dd';ctx.fillRect(0,0,w,76);ctx.strokeStyle=theme.getPropertyValue('--timeline-grid').trim()||'#d3ccbc';
for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}
if(!queryStart||!queryEnd)return;ctx.fillStyle=theme.getPropertyValue('--timeline-range').trim()||'#4c7761';const total=queryEnd-queryStart;
for(const s of spans){const a=(s.start-queryStart)/total,b=(s.end-queryStart)/total;if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}
}
function playbackParams(range){return new URLSearchParams({camera:queryCamera,start:new Date(range.start).toISOString(),duration:String((range.end-range.start)/1000)});}
function playAt(date,preserveClip=false){
const range=RecordingRanges.at(spans,date);if(!range){toast('这个时间没有录像,请选择绿色时段');return;}
playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{});
$('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每次最多一小时,结束后按续播选项处理';$('#play-empty').hidden=true;
$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;
if(!preserveClip){$('#clip-start').value=localDateTime(range.start);$('#clip-end').value=localDateTime(range.end);$('#clip-download').hidden=true;$('#clip-status').textContent='可修改起止时间后生成下载链接';}
}
function continuePlayback(){
const v=$('#play-video');if(!loggedIn||activePage!=='playback'||!playbackRange||!v.ended)return;
if(v.currentTime+2<(playbackRange.end-playbackRange.start)/1000){$('#play-info').textContent='录像提前结束,已停止续播;请核对该片段或重新查询';return;}
if(!$('#play-autonext').checked){$('#play-info').textContent='本段播放结束,自动续播已关闭';return;}
const end=playbackRange.end,next=RecordingRanges.next(spans,end);
if(!next){$('#play-info').textContent='所选日期的已查询录像播放完毕';return;}
playAt(new Date(next.start),true);if(next.start>end)$('#play-info').textContent=`已跳过无录像时段,续播 ${new Date(next.start).toLocaleString()}`;
}
function makeClip(){
$('#clip-download').hidden=true;
try{if(!queryCamera)throw Error('请先查询录像');const range=RecordingRanges.clip(spans,new Date($('#clip-start').value),new Date($('#clip-end').value));
const a=$('#clip-download');a.href='/media/playback?'+playbackParams(range)+'&download=1';a.setAttribute('download','recording.mp4');a.hidden=false;$('#clip-status').textContent=`已选 ${((range.end-range.start)/60e3).toFixed(2)} 分钟,下载保留原始编码。`;
}catch(e){$('#clip-status').textContent=e.message;}
}
$('#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;$('#play-date').onchange=resetPlayback;
$('#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));};
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};$('#play-video').onended=continuePlayback;
$('#play-video').ontimeupdate=()=>{if(playbackRange)$('#play-position').textContent='录像时间 '+new Date(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000)).toLocaleString();};
$('#clip-form').onsubmit=e=>{e.preventDefault();makeClip();};
for(const id of ['clip-start','clip-end'])$('#'+id).oninput=()=>{$('#clip-download').hidden=true;$('#clip-status').textContent='起止时间已修改,请重新生成链接';};
$$('[data-clip-mark]').forEach(b=>b.onclick=()=>{if(!playbackRange||!$('#play-video').videoWidth){toast('请先播放录像');return;}$('#clip-'+b.dataset.clipMark).value=localDateTime(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000));$('#clip-download').hidden=true;$('#clip-status').textContent='已使用当前播放位置,请重新生成链接';});
$('#play-date').value=localDateTime(Date.now()).slice(0,10);window.addEventListener('resize',drawTimeline);
+17
View File
@@ -0,0 +1,17 @@
'use strict';
let snapshotUrl='',snapshotGeneration=0;
function clearSnapshot(){snapshotGeneration++;if(snapshotUrl)URL.revokeObjectURL(snapshotUrl);snapshotUrl='';$('#snapshot-image').removeAttribute('src');$('#snapshot-save').removeAttribute('href');$('#snapshot-info').textContent='';}
async function captureFrame(video,cameraId,label){
if(!loggedIn||!video||video.readyState<2||!video.videoWidth||video.error){toast('画面尚未就绪,请播放后再截图');return;}
clearSnapshot();const generation=snapshotGeneration,session=sessionGeneration,canvas=document.createElement('canvas');
canvas.width=video.videoWidth;canvas.height=video.videoHeight;const captured=new Date();
try{canvas.getContext('2d').drawImage(video,0,0);const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
if(!loggedIn||session!==sessionGeneration||generation!==snapshotGeneration)return;
if(!blob)throw Error('图片生成失败');snapshotUrl=URL.createObjectURL(blob);$('#snapshot-image').src=snapshotUrl;
$('#snapshot-title').textContent=label+' · 画面截图';$('#snapshot-info').textContent=`${canvas.width} × ${canvas.height} · 本次截取时间 ${captured.toLocaleString()}。图片取自当前播放器画面,包含播放缓冲延迟;不上传到服务器。`;
$('#snapshot-save').href=snapshotUrl;$('#snapshot-save').download='snapshot-'+cameraId+'-'+captured.toISOString().replace(/[:.]/g,'-')+'.png';$('#snapshot-dialog').showModal();
}catch(e){if(generation===snapshotGeneration)toast('截图失败,请重新播放后再试');}
}
document.addEventListener('click',e=>{const b=e.target.closest('[data-snapshot-camera]');if(b){const id=b.dataset.snapshotCamera;captureFrame(b.closest('.camera-card').querySelector('video'),id,state.cameras.find(c=>c.id===id)?.name||'摄像头');}});
$('#play-snapshot').onclick=()=>captureFrame($('#play-video'),queryCamera,'录像回放');
$('#snapshot-dialog').addEventListener('close',clearSnapshot);
+4
View File
@@ -22,3 +22,7 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
#page-live:fullscreen .camera-actions [data-camera-settings]{display:none} #page-live:fullscreen .camera-actions [data-camera-settings]{display:none}
@media(max-width:700px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr 1fr}} @media(max-width:700px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr 1fr}}
@media(max-width:420px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr}} @media(max-width:420px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr}}
/* v0.1.21: presets and recording tools */
.ptz-panel{width:620px;display:grid;grid-template-columns:1fr auto;max-height:80dvh;overflow:auto;align-items:start}.ptz-panel>div:first-child{grid-column:1}.ptz-panel .ptz-pad{grid-column:2;grid-row:1/3}#ptz-presets{grid-column:1/-1;min-width:0;border-top:1px solid var(--line);padding-top:12px}#ptz-presets .section-head{margin:0;gap:10px}#ptz-presets p{max-width:none;line-height:1.6}#preset-count{color:var(--muted);font-size:12px}.preset-row{display:flex;align-items:center;gap:5px;padding:9px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}.preset-row strong{font-size:13px;flex:1;overflow-wrap:anywhere}.preset-row button{padding:5px 8px;font-size:12px}#preset-form{display:flex;align-items:end;gap:10px;margin-top:12px}#preset-form label{flex:1;margin:0}#preset-name{width:100%}#preset-confirmation{padding:12px;background:#553b31;border-radius:8px;margin-top:10px}#ptz-close{justify-self:start}.playback-tools{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:12px 0}.playback-tools .check{margin:0}.clip-editor{border-top:1px solid var(--line);padding-top:12px}.clip-editor summary{cursor:pointer;margin-bottom:14px}#snapshot-dialog{width:min(1000px,92vw)}#snapshot-image{width:100%;max-height:65vh;object-fit:contain;background:#000;border-radius:8px}#play-position{font-size:13px;color:var(--muted)}
@media(max-width:600px){.ptz-panel{right:12px;bottom:12px;padding:12px;gap:12px}.ptz-panel .ptz-pad{grid-column:2}#preset-form{flex-wrap:wrap}#preset-form label{flex-basis:100%}}