Compare commits

...

4 Commits

27 changed files with 1709 additions and 116 deletions
+59 -1
View File
@@ -49,7 +49,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml mediamtx ./data/mediamtx.yml
``` ```
回归测试:`python3 -m unittest -v test_app test_ptz` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.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`。测试使用临时数据库和模拟播放器,不连接真实设备。
访问 `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 使用管理员与个人账号分权。
@@ -121,3 +121,61 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
### 代理缓存兼容(v0.1.17) ### 代理缓存兼容(v0.1.17)
入口 HTML 禁止缓存;页面引用的脚本和样式自动带上发布版本号,避免 NPM 等反向代理为旧资源设置长缓存后,新页面混用旧脚本。升级时普通重新载入即可获取与页面一致的资源。 入口 HTML 禁止缓存;页面引用的脚本和样式自动带上发布版本号,避免 NPM 等反向代理为旧资源设置长缓存后,新页面混用旧脚本。升级时普通重新载入即可获取与页面一致的资源。
## 逻辑一致性修复(v0.1.18)
- 记事正文实际支持 12000 字中文、摘要支持换行;该接口的 JSON 请求上限为 128 KiB,其他接口保持 16 KiB,并拒绝无效 Content-Length。
- 编辑已发布记事默认“保存修改”保持发布状态,Enter 保存也不会撤回。撤回为草稿使用独立按钮;首次发布时间在撤回、重发和归档恢复之间保留。
- 遇到家谱并发修订冲突,当前输入保留;复制需要保留的内容后,可明确点击“读取最新版本(覆盖本次输入)”继续编辑。服务器保存成功而页面重新读取失败时,提示已保存,避免误以为需要再次提交。
- 从记事跳到归档人物时自动显示归档人物。当前账号的本人标记与关系参照人物区分;变更本人关联会重新布局。
- 新建、恢复亲生关系及修改人物出生日期时,已知父母出生日期必须早于子女;日期未知以及收养、继亲关系不据此推断或拒绝。
- 退出或切换账号后,旧请求不得重新写入私有视图;过时的状态刷新不会覆盖较新的结果。共享浏览器标签页切换账号时,在下一次状态刷新或会话检查中清理前一账号的视图。
- 日期表单按人物或记事分别校验,编辑记事不会清除另一张人物表单的去世日期。
## 摄像机参数、版本留存与账号自助(v0.1.19)
### 摄像机设置
管理员在实时画面或镜头通道中点击“摄像机设置”。服务通过已保存的设备凭据读取能力,精确匹配当前镜头的主 / 子码流路径(包括查询参数)。不接受客户端提供的任意设备服务地址。
- 显示设备型号、固件、镜头视频源及编码。图像设置作用于该物理镜头,主、子码流共用。
- 按设备实际支持范围提供亮度、对比度、饱和度、锐度、日夜模式、背光补偿、宽动态及白平衡。
- 主、子码流分别提供分辨率、帧率、码率上限、画质和关键帧间隔;未开放的参数不会出现。
- 保留现有视频编码及设备扩展字段。有些设备使用 H264 兼容结构报告 H265,不能仅凭标准 Encoding 字段就改写编码。
- 保存前重新读取、验证范围并检查参数版本,同一物理设备串行保存。保存后回读实际值,未确认或不一致会提示重新核对,不自动反复写入。
- 修改码流可能短暂中断实时画面,并影响录像机收到的码流。当前设备参数写入仅管理员可用。
- 双向对讲、自动跟踪、告警灯 / 警笛、画面翻转、隐私遮蔽、云录像、消息推送尚未接入;界面明确列出这些边界。设备宣告支持某个 ONVIF 服务不等于本系统已经实现该功能。
协议依据:[ONVIF Imaging Service](https://www.onvif.org/specs/srv/img/ONVIF-Imaging-Service-Spec-v1606.pdf)、[ONVIF Media Service](https://www.onvif.org/specs/srv/media/ONVIF-Media-Service-Spec-v240.pdf)。本系统按设备返回的选项校验与设置,不依赖萤石云账号。
### 家谱修改记录
人物档案、亲属关系及记事详情都有历史入口。每次保存将修改前 / 后内容、操作者与时间、家谱版本号写入 `family_changes`,与实际记录一起提交。可以分页查阅、对照旧版并选择恢复;恢复会重新验证现有家谱约束,并生成新版本,不删除后续历史。不提供创建前的空版本恢复。
历史包含曾撤回的内容,只对家谱编辑者和管理员开放。读取版本后若家谱发生变化,恢复会拒绝并要求重新读取。历史从本版本开始记录;升级前的内容会在下一次修改时作为“修改前”保存。历史属于私有数据库,不进入公共源码或普通家谱 JSON 导出。
### 我的密码
所有登录账号都可通过侧栏“修改我的密码”填写当前密码、新密码及确认。服务器验证当前密码,并限制连续错误次数。成功后撤销该账号全部登录会话,其他账号不受影响;账号角色和人物关联不变。系统不会把密码记录到操作日志。
### 并发与数据一致性
设备对象的校验与保存串行执行;镜头数量限制不能被同时提交绕过。摄像头对象移动空间时,所有镜头归属在同一数据库事务更新。录像机地址或账号更新后,关联通道读取最新端点。无效对象编号、错误 Origin 和非法地址类型会返回明确错误。
### 实机验收完善(v0.1.20)
摄像机信息及参数改为紧凑布局;全屏监控隐藏管理参数入口。读取或保存时暂时锁定参数输入,避免正在编辑的值被返回结果覆盖。视频服务错误响应也会正确关闭,修复新 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.17 0.1.21
+125 -32
View File
@@ -3,6 +3,8 @@ import base64
import concurrent.futures import concurrent.futures
import datetime as dt import datetime as dt
import hashlib import hashlib
import functools
import camera_settings
import hmac import hmac
import http.client import http.client
import http.cookies import http.cookies
@@ -126,6 +128,8 @@ def integer(value, lo, hi):
def host_address(value): def host_address(value):
try: try:
if not isinstance(value, str):
raise ValueError()
ip = ipaddress.ip_address(value) ip = ipaddress.ip_address(value)
if ip.version != 4 or ip.is_global or ip.is_loopback or ip.is_multicast or ip.is_unspecified or ip.is_reserved: if ip.version != 4 or ip.is_global or ip.is_loopback or ip.is_multicast or ip.is_unspecified or ip.is_reserved:
raise ValueError() raise ValueError()
@@ -138,10 +142,28 @@ SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'o
'building', 'unit', 'floor', 'door', 'address', 'note') 'building', 'unit', 'floor', 'door', 'address', 'note')
def serialized_save(function):
@functools.wraps(function)
def save(body):
with LOCK:
return function(body)
return save
def object_key(table, body):
key = body.get('id', '')
if key in ('', None):
return secrets.token_hex(8)
if not isinstance(key, str) or not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('对象编号不正确')
if not get_object(table, key):
raise Problem('对象不存在,请重新载入', 404)
return key
@serialized_save
def save_site(body): def save_site(body):
key = body.get('id') or secrets.token_hex(8) key = object_key('sites', body)
if not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('空间编号不正确')
item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100, item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100,
required=k == 'name') for k in SITE_FIELDS} required=k == 'name') for k in SITE_FIELDS}
item.update(id=key, kind=body.get('kind', 'home')) item.update(id=key, kind=body.get('kind', 'home'))
@@ -152,10 +174,9 @@ def save_site(body):
return item return item
@serialized_save
def save_camera(body): def save_camera(body):
key = body.get('id') or secrets.token_hex(8) key = object_key('cameras', body)
if not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('设备编号不正确')
old = get_object('cameras', key) or {} old = get_object('cameras', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'point', 'username', 'model')} for k in ('name', 'point', 'username', 'model')}
@@ -202,10 +223,9 @@ def save_camera(body):
return public_camera(item) return public_camera(item)
@serialized_save
def save_asset(body): def save_asset(body):
key = body.get('id') or secrets.token_hex(8) key = object_key('assets', body)
if not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('对象编号不正确')
site = body.get('siteId') site = body.get('siteId')
if not get_object('sites', site): if not get_object('sites', site):
raise Problem('请选择所属空间') raise Problem('请选择所属空间')
@@ -215,19 +235,18 @@ def save_asset(body):
attached = [c for c in objects('cameras') if c.get('assetId') == key] attached = [c for c in objects('cameras') if c.get('assetId') == key]
if len(attached) > item['lensCount']: if len(attached) > item['lensCount']:
raise Problem('镜头数不能小于已经建立的通道数') raise Problem('镜头数不能小于已经建立的通道数')
with LOCK: with DB:
save_object('assets', item) DB.execute('INSERT OR REPLACE INTO assets VALUES (?,?)', (key, json.dumps(item)))
for c in attached: for c in attached:
c['siteId'] = site c['siteId'] = site
save_object('cameras', c) DB.execute('UPDATE cameras SET body=? WHERE id=?', (json.dumps(c), c['id']))
audit('保存摄像头对象', item['name']) audit('保存摄像头对象', item['name'])
return item return item
@serialized_save
def save_recorder(body): def save_recorder(body):
key = body.get('id') or secrets.token_hex(8) key = object_key('recorders', body)
if not re.fullmatch('[a-f0-9]{16}', key):
raise Problem('录像机编号不正确')
old = get_object('recorders', key) or {} old = get_object('recorders', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name') item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'brand', 'model', 'username')} for k in ('name', 'brand', 'model', 'username')}
@@ -249,7 +268,13 @@ def save_recorder(body):
def public_camera(c): def public_camera(c):
return {**{k: v for k, v in c.items() if k != 'password'}, 'passwordConfigured': bool(c['password'])} result = {**{k: v for k, v in c.items() if k != 'password'}, 'passwordConfigured': bool(c.get('password'))}
if c.get('sourceKind') == 'recorder':
recorder = get_object('recorders', c.get('recorderId'))
if recorder:
result.update({k: recorder[k] for k in ('host', 'port', 'username')})
result['passwordConfigured'] = bool(recorder.get('password'))
return result
def stream_name(c, quality='main'): def stream_name(c, quality='main'):
@@ -318,13 +343,14 @@ def media_json(path, playback=False):
with urllib.request.urlopen(req, timeout=5) as r: with urllib.request.urlopen(req, timeout=5) as r:
return json.load(r) return json.load(r)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
if playback and path.startswith('/list?') and exc.code == 404: with exc:
try: if playback and path.startswith('/list?') and exc.code == 404:
detail = json.loads(exc.read(4096)) try:
if detail.get('error') == 'no recording segments found': detail = json.loads(exc.read(4096))
return [] if detail.get('error') == 'no recording segments found':
except (ValueError, AttributeError): return []
pass except (ValueError, AttributeError):
pass
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
except Exception: except Exception:
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
@@ -493,11 +519,14 @@ class Handler(BaseHTTPRequestHandler):
if self.user['role'] != 'admin': if self.user['role'] != 'admin':
raise Problem('此操作需要管理员权限', 403) raise Problem('此操作需要管理员权限', 403)
def body(self): def body(self, maximum=16384):
if self.headers.get_content_type() != 'application/json': if self.headers.get_content_type() != 'application/json':
raise Problem('请求需要 JSON 格式') raise Problem('请求需要 JSON 格式')
n = int(self.headers.get('Content-Length', '0')) try:
if not 0 < n <= 16384: n = int(self.headers.get('Content-Length', '0'))
except ValueError:
raise Problem('请求长度不正确')
if not 0 < n <= maximum:
raise Problem('请求长度不正确') raise Problem('请求长度不正确')
try: try:
data = json.loads(self.rfile.read(n)) data = json.loads(self.rfile.read(n))
@@ -509,8 +538,14 @@ class Handler(BaseHTTPRequestHandler):
def validate_origin(self): def validate_origin(self):
origin = self.headers.get('Origin') origin = self.headers.get('Origin')
if origin and url.urlsplit(origin).netloc != self.headers.get('Host'): if origin:
raise Problem('请从系统页面提交操作', 403) try:
parsed = url.urlsplit(origin)
valid = parsed.scheme in ('http', 'https') and parsed.netloc == self.headers.get('Host')
except ValueError:
valid = False
if not valid:
raise Problem('请从系统页面提交操作', 403)
def login(self, data, setup=False): def login(self, data, setup=False):
ip = self.client_address[0] ip = self.client_address[0]
@@ -553,20 +588,49 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
try: try:
self.validate_origin()
path = url.urlsplit(self.path).path path = url.urlsplit(self.path).path
data = self.body() # 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB.
data = self.body(131072 if path == '/api/family/event' else 16384)
self.validate_origin()
if path in ('/api/login', '/api/setup'): if path in ('/api/login', '/api/setup'):
self.login(data, path == '/api/setup') self.login(data, path == '/api/setup')
return return
self.require_auth() self.require_auth()
if path not in ('/api/logout', '/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz'): if path not in ('/api/logout', '/api/account/password', '/api/family/restore',
'/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:
DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),)) DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),))
DB.commit() DB.commit()
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict') self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
elif path == '/api/account/password':
result = ACCOUNTS.change_password(data, self.user)
self.answer(result, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
elif path == '/api/family/restore':
self.answer(FAMILY.restore(data, self.user))
elif path == '/api/camera/settings':
c = ACCOUNTS.camera(self.user, get_object('cameras', data.get('camera')))
try:
result = camera_settings.CONTROLLER.save(c, data)
except ValueError as e:
raise Problem(str(e))
except Exception:
raise Problem('设备保存请求未确认,可能已经生效;请重新读取参数后核对,勿反复提交', 502)
audit('修改摄像机参数 · ' + data['section'], c['name'] + ' / ' + self.user['username'])
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:
@@ -726,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:
@@ -737,6 +810,23 @@ class Handler(BaseHTTPRequestHandler):
self.answer(result) self.answer(result)
elif path == '/api/family': elif path == '/api/family':
self.answer(FAMILY.snapshot(self.user)) self.answer(FAMILY.snapshot(self.user))
elif path == '/api/camera/settings':
self.require_admin()
c = ACCOUNTS.camera(self.user, get_object('cameras', query.get('camera')))
try:
result, _ = camera_settings.CONTROLLER.read(c, query.get('quality', 'main'))
except ValueError as e:
raise Problem(str(e))
except Exception:
raise Problem('读取摄像机参数失败,请检查连接、设备账号或 ONVIF 服务', 502)
self.answer(result)
elif path in ('/api/family/history', '/api/family/change'):
try:
number = int(query['before' if path.endswith('history') else 'revision']) if ('before' if path.endswith('history') else 'revision') in query else None
except ValueError:
raise Problem('修改记录编号不正确')
self.answer(FAMILY.history(self.user, query.get('kind'), query.get('id'), number)
if path.endswith('history') else FAMILY.change(self.user, number))
elif path == '/api/audit': elif path == '/api/audit':
self.require_admin() self.require_admin()
with LOCK: with LOCK:
@@ -787,6 +877,9 @@ class Handler(BaseHTTPRequestHandler):
'/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',
'/family-events.js': 'family-events.js', '/family-events.js': 'family-events.js',
'/family-history.js': 'family-history.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'}
@@ -802,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)
+225
View File
@@ -0,0 +1,225 @@
"""Capability-driven, per-lens ONVIF settings. Never accepts an endpoint from a client."""
import copy
import hashlib
import json
import math
import threading
import urllib.parse as url
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape
import onvif
IMAGING = 'http://www.onvif.org/ver20/imaging/wsdl'
T = '{' + onvif.SCHEMA + '}'
M = '{' + onvif.MEDIA + '}'
I = '{' + IMAGING + '}'
IMAGE_FIELDS = {
'brightness': ('Brightness', 'Brightness', '亮度'),
'contrast': ('Contrast', 'Contrast', '对比度'),
'saturation': ('ColorSaturation', 'ColorSaturation', '饱和度'),
'sharpness': ('Sharpness', 'Sharpness', '锐度'),
'dayNight': ('IrCutFilter', 'IrCutFilterModes', '日夜模式'),
'backlight': ('BacklightCompensation/Mode', 'BacklightCompensation/Mode', '背光补偿'),
'wdr': ('WideDynamicRange/Mode', 'WideDynamicRange/Mode', '宽动态'),
'wdrLevel': ('WideDynamicRange/Level', 'WideDynamicRange/Level', '宽动态强度'),
'whiteBalance': ('WhiteBalance/Mode', 'WhiteBalance/Mode', '白平衡'),
}
ENUMS = {'dayNight': ('ON', 'OFF', 'AUTO'), 'backlight': ('ON', 'OFF'),
'wdr': ('ON', 'OFF'), 'whiteBalance': ('AUTO', 'MANUAL')}
VIDEO_FIELDS = {'fps': 'RateControl/FrameRateLimit', 'bitrate': 'RateControl/BitrateLimit',
'quality': 'Quality', 'gop': 'H264/GovLength'}
def path(value):
return '/'.join(T + part for part in value.split('/'))
def number(value):
try:
result = float(value)
return result if math.isfinite(result) else None
except (TypeError, ValueError):
return None
def limits(root, name):
if root is None:
return None
minimum, maximum = number(root.findtext(path(name + '/Min'))), number(root.findtext(path(name + '/Max')))
return dict(min=minimum, max=maximum) if minimum is not None and maximum is not None and minimum <= maximum else None
def uri_path(value):
parsed = url.urlsplit(value)
return parsed.path, sorted(url.parse_qsl(parsed.query, keep_blank_values=True))
class Settings:
def __init__(self):
self.guard = threading.Lock()
self.locks = {}
def call(self, camera, endpoint, namespace, operation, body=''):
return onvif.request(camera['host'], endpoint[0], endpoint[1], namespace, operation,
body, camera['username'], camera['password'])
@staticmethod
def endpoint(camera, address):
parsed = url.urlsplit(address or '')
if parsed.scheme != 'http' or parsed.hostname != camera['host'] or parsed.username or parsed.password:
raise ValueError('设备返回了不匹配的服务地址')
return parsed.port or 80, parsed.path + ('?' + parsed.query if parsed.query else '')
def discover(self, camera, quality):
if camera.get('sourceKind', 'direct') != 'direct':
raise ValueError('摄像机参数需要直连摄像头;录像机通道请在录像机中配置')
if not camera.get('enabled'):
raise ValueError('请先启用这个镜头通道')
if quality not in ('main', 'sub'):
raise ValueError('请选择主码流或子码流')
device = (camera.get('onvifPort', 80), '/onvif/device_service')
caps = self.call(camera, device, onvif.DEVICE, 'GetCapabilities', '<m:Category>All</m:Category>')
services = {}
for name in ('Media', 'Imaging', 'PTZ', 'Events', 'Recording', 'Replay'):
address = caps.findtext('.//' + T + name + '/' + T + 'XAddr')
if address:
services[name] = self.endpoint(camera, address)
if 'Media' not in services:
raise ValueError('设备没有提供媒体配置服务')
profiles = self.call(camera, services['Media'], onvif.MEDIA, 'GetProfiles')
wanted = camera['mainPath' if quality == 'main' else 'subPath']
selected = None
# Exact path AND query matching prevents selecting the other lens on shared paths.
for profile in profiles.findall('.//' + M + 'Profiles')[:16]:
token = profile.get('token', '')
content = ('<m:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol>'
'</tt:Transport></m:StreamSetup><m:ProfileToken>' + escape(token) + '</m:ProfileToken>')
response = self.call(camera, services['Media'], onvif.MEDIA, 'GetStreamUri', content)
uri = response.findtext('.//' + T + 'Uri')
if uri and url.urlsplit(uri).hostname == camera['host'] and uri_path(uri) == uri_path(wanted):
selected = profile
break
if selected is None:
raise ValueError('未找到与当前镜头及码流路径精确匹配的配置,请先核对通道')
source = selected.findtext(path('VideoSourceConfiguration/SourceToken'))
encoder = selected.find(T + 'VideoEncoderConfiguration')
if not source or encoder is None:
raise ValueError('设备没有返回镜头或编码器配置')
return dict(device=device, services=services, source=source, profile=selected.get('token'), encoder=encoder)
def read(self, camera, quality='main'):
info = self.discover(camera, quality)
return self.details(camera, info), info
def details(self, camera, info):
result = dict(profile=info['profile'], source=info['source'], device={}, imaging={}, video={}, notes=[],
advertisedServices=list(info['services']))
try:
device = self.call(camera, info['device'], onvif.DEVICE, 'GetDeviceInformation')
result['device'] = {key: device.findtext('.//{' + onvif.DEVICE + '}' + key, '')
for key in ('Manufacturer', 'Model', 'FirmwareVersion', 'HardwareId')}
except Exception:
result['notes'].append('设备型号读取失败,可重试')
encoder = info['encoder']
values = {key: number(encoder.findtext(path(field))) for key, field in VIDEO_FIELDS.items()}
values['resolution'] = encoder.findtext(path('Resolution/Width'), '') + 'x' + encoder.findtext(path('Resolution/Height'), '')
codec = encoder.get('encoding') or encoder.findtext(T + 'Encoding', '')
result['video'] = dict(values=values, options={}, codec=codec)
try:
body = '<m:ConfigurationToken>' + escape(encoder.get('token', '')) + '</m:ConfigurationToken>'
opts = self.call(camera, info['services']['Media'], onvif.MEDIA, 'GetVideoEncoderConfigurationOptions', body).find('.//' + M + 'Options')
# Some EZVIZ devices expose H265 through the H264 compatibility schema.
# Keep the entire original XML (including vendor encoding attribute) when writing.
schema_codec = encoder.findtext(T + 'Encoding', '')
if schema_codec not in ('H264', 'MPEG4', 'JPEG'):
raise ValueError('unsupported encoding schema')
codec_opts = opts.find(T + schema_codec)
for key, field in [('fps', 'FrameRateRange'), ('gop', 'GovLengthRange')]:
limit = limits(codec_opts, field)
if limit and values[key] is not None:result['video']['options'][key] = limit
bitrate = limits(opts, 'Extension/' + schema_codec + '/BitrateRange')
quality = limits(opts, 'QualityRange')
if bitrate and values['bitrate'] is not None:result['video']['options']['bitrate'] = bitrate
if quality and values['quality'] is not None:result['video']['options']['quality'] = quality
resolutions = [r.findtext(T + 'Width', '') + 'x' + r.findtext(T + 'Height', '')
for r in codec_opts.findall(T + 'ResolutionsAvailable')]
if resolutions:result['video']['options']['resolution'] = {'choices': list(dict.fromkeys(resolutions))}
except Exception:
result['notes'].append('码流可调范围读取失败,暂不可修改码流参数')
result['video']['options'] = {}
if 'Imaging' in info['services']:
try:
body = '<m:VideoSourceToken>' + escape(info['source']) + '</m:VideoSourceToken>'
settings = self.call(camera, info['services']['Imaging'], IMAGING, 'GetImagingSettings', body).find('.//' + I + 'ImagingSettings')
options = self.call(camera, info['services']['Imaging'], IMAGING, 'GetOptions', body).find('.//' + I + 'ImagingOptions')
if settings is None or options is None:raise ValueError('missing imaging response')
info['imagingXML'] = settings
for key, (field, option, label) in IMAGE_FIELDS.items():
value = settings.findtext(path(field))
if value is None:continue
if key in ENUMS:
choices = [v.text for v in options.findall(path(option)) if v.text in ENUMS[key]]
rule = {'choices': list(dict.fromkeys(choices))} if choices else None
else:
value, rule = number(value), limits(options, option)
if rule and value is not None:result['imaging'][key] = dict(value=value, label=label, **rule)
except Exception:
result['notes'].append('图像参数读取失败或设备未开放,暂不可修改图像参数')
else:
result['notes'].append('设备未开放图像调节服务')
fingerprint = dict(source=info['source'], profile=info['profile'], encoder=encoder.get('token'),
imaging=result['imaging'], video=result['video'])
result['revision'] = hashlib.sha256(json.dumps(fingerprint, sort_keys=True).encode()).hexdigest()
return result
@staticmethod
def validate(values, rules, integer_fields=()):
if not isinstance(values, dict) or not values or any(k not in rules for k in values):
raise ValueError('提交的参数未由设备开放,或没有需要保存的参数')
for key, value in values.items():
rule = rules[key]
if 'choices' in rule:
if not isinstance(value, str) or value not in rule['choices']:raise ValueError('参数选项不受设备支持')
elif (type(value) not in (int, float) or not math.isfinite(value) or not rule['min'] <= value <= rule['max']
or (key in integer_fields and int(value) != value)):
raise ValueError('参数超出设备允许范围')
def save(self, camera, data):
key = (camera['host'], camera.get('onvifPort', 80))
with self.guard:lock = self.locks.setdefault(key, threading.Lock())
if not lock.acquire(blocking=False):raise ValueError('这台摄像头正在保存参数,请稍后重试')
try:
current, info = self.read(camera, data.get('quality', 'main'))
if data.get('revision') != current['revision']:
raise ValueError('设备参数已变化,请重新读取后再修改')
section, values = data.get('section'), data.get('values')
if section == 'imaging':
self.validate(values, current['imaging'])
settings = copy.deepcopy(info['imagingXML'])
for key, value in values.items():settings.find(path(IMAGE_FIELDS[key][0])).text = str(value)
body = '<m:VideoSourceToken>' + escape(info['source']) + '</m:VideoSourceToken>' + ET.tostring(settings, encoding='unicode') + '<m:ForcePersistence>true</m:ForcePersistence>'
endpoint, namespace, operation = info['services']['Imaging'], IMAGING, 'SetImagingSettings'
elif section == 'video':
self.validate(values, current['video']['options'], ('fps', 'bitrate', 'gop'))
settings = copy.deepcopy(info['encoder']);settings.tag = M + 'Configuration'
for key, value in values.items():
if key == 'resolution':
for field, size in zip(('Width', 'Height'), value.split('x')):settings.find(path('Resolution/' + field)).text = size
else:settings.find(path(VIDEO_FIELDS[key])).text = str(int(value) if key in ('fps', 'bitrate', 'gop') else value)
body = ET.tostring(settings, encoding='unicode') + '<m:ForcePersistence>true</m:ForcePersistence>'
endpoint, namespace, operation = info['services']['Media'], onvif.MEDIA, 'SetVideoEncoderConfiguration'
else:raise ValueError('请选择图像或码流设置')
self.call(camera, endpoint, namespace, operation, body)
try:
fresh, _ = self.read(camera, data.get('quality', 'main'))
actual = {k: v['value'] for k, v in fresh['imaging'].items()} if section == 'imaging' else fresh['video']['values']
confirmed = all(actual.get(k) == v for k, v in values.items())
return dict(ok=True, verified=confirmed, settings=fresh,
message='设备已保存并回读确认' if confirmed else '设备已响应,但回读值与提交值不同,请核对设备实际参数')
except Exception:
return dict(ok=True, verified=False, settings=None, message='设备已响应,暂未读回参数;请重新读取确认,不要反复提交')
finally:
lock.release()
CONTROLLER = Settings()
+84 -12
View File
@@ -15,6 +15,11 @@ class Family:
CREATE TABLE IF NOT EXISTS people (id TEXT PRIMARY KEY, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS people (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS family_links (id TEXT PRIMARY KEY, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS family_links (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS family_events (id TEXT PRIMARY KEY, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS family_events (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS family_changes (
revision INTEGER PRIMARY KEY, kind TEXT NOT NULL, target_id TEXT NOT NULL,
before_json TEXT, after_json TEXT NOT NULL, actor TEXT NOT NULL,
at TEXT NOT NULL, restored_from INTEGER);
CREATE INDEX IF NOT EXISTS family_changes_target ON family_changes(kind,target_id,revision);
''') ''')
self.a.DB.commit() self.a.DB.commit()
@@ -33,7 +38,7 @@ class Family:
'canEdit': can_edit, 'canEdit': can_edit,
'selfId': user.get('personId', '')} 'selfId': user.get('personId', '')}
def save(self, kind, data, user): def save(self, kind, data, user, restored_from=None):
self.authorize(user, True) self.authorize(user, True)
a = self.a a = self.a
with a.LOCK: with a.LOCK:
@@ -51,20 +56,71 @@ class Family:
if kind == 'event': if kind == 'event':
item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'], item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'],
createdBy=old.get('createdBy', user['username']) if old else user['username']) createdBy=old.get('createdBy', user['username']) if old else user['username'])
if item['status'] == 'published': # Withdrawal and restoration must retain the first publication time.
item['publishedAt'] = old.get('publishedAt', item['updatedAt']) if old else item['updatedAt'] if old and old.get('publishedAt'):
item['publishedAt'] = old['publishedAt']
elif item['status'] == 'published':
item['publishedAt'] = item['updatedAt']
# Revision and record are committed together, so concurrent forms cannot overwrite silently. # Revision and record are committed together, so concurrent forms cannot overwrite silently.
a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item))) with a.DB:
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1))) a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item)))
a.DB.commit() a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1)))
a.DB.execute('INSERT INTO family_changes VALUES (?,?,?,?,?,?,?,?)',
(revision + 1, kind, item['id'], json.dumps(old) if old else None,
json.dumps(item), user['username'], item['updatedAt'], restored_from))
a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username']) a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username'])
return {'item': item, 'revision': revision + 1} return {'item': item, 'revision': revision + 1}
def history(self, user, kind, target_id, before=None):
# Previous drafts and withdrawn text are visible only to family editors.
self.authorize(user, True)
a = self.a
table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}.get(kind)
with a.LOCK:
if not table or not a.get_object(table, target_id):
raise a.Problem('家谱记录不存在', 404)
if before is not None:
a.integer(before, 1, 2**53 - 1)
rows = a.DB.execute('SELECT * FROM family_changes WHERE kind=? AND target_id=? AND revision<? '
'ORDER BY revision DESC LIMIT 21', (kind, target_id, before or 2**53 - 1)).fetchall()
items = []
for row in rows[:20]:
old, new = json.loads(row['before_json'] or '{}'), json.loads(row['after_json'])
fields = [k for k in new if k not in ('id', 'updatedAt', 'updatedBy', 'createdAt', 'createdBy', 'publishedAt')
and old.get(k) != new[k]]
items.append(dict(revision=row['revision'], at=row['at'], actor=row['actor'], fields=fields,
created=row['before_json'] is None, restoredFrom=row['restored_from']))
return dict(items=items, nextBefore=items[-1]['revision'] if len(rows) > 20 else None,
revision=a.setting('familyRevision', 0))
def change(self, user, change_revision):
self.authorize(user, True)
self.a.integer(change_revision, 1, 2**53 - 1)
with self.a.LOCK:
row = self.a.DB.execute('SELECT * FROM family_changes WHERE revision=?', (change_revision,)).fetchone()
if not row:
raise self.a.Problem('修改记录不存在', 404)
return dict(revision=row['revision'], kind=row['kind'], targetId=row['target_id'],
before=json.loads(row['before_json']) if row['before_json'] else None,
after=json.loads(row['after_json']), at=row['at'], actor=row['actor'])
def restore(self, data, user):
self.authorize(user, True)
with self.a.LOCK:
change = self.change(user, data.get('changeRevision'))
side = data.get('side')
if side not in ('before', 'after') or change[side] is None:
raise self.a.Problem('请选择有效的历史版本;创建前没有可恢复的内容')
# Reuse all current relationship/date/publication validation. Restore is a new edit,
# not an overwrite of history or a rollback of other people's records.
return self.save(change['kind'], dict(change[side], revision=data.get('revision')),
user, restored_from=change['revision'])
def event(self, data, old): def event(self, data, old):
a = self.a a = self.a
item = {'id': old['id'] if old else secrets.token_hex(8)} item = {'id': old['id'] if old else secrets.token_hex(8)}
for key, maximum, required in [('title', 160, True), ('eventDate', 10, True), for key, maximum, required in [('title', 160, True), ('eventDate', 10, True),
('summary', 400, False), ('sourceName', 160, False), ('sourceName', 160, False),
('sourceUrl', 2000, False)]: ('sourceUrl', 2000, False)]:
item[key] = a.clean_text(data.get(key, ''), maximum, required) item[key] = a.clean_text(data.get(key, ''), maximum, required)
try: try:
@@ -72,16 +128,17 @@ class Family:
raise ValueError() raise ValueError()
except ValueError: except ValueError:
raise a.Problem('记事日期请填写有效日期,格式为 YYYY-MM-DD') raise a.Problem('记事日期请填写有效日期,格式为 YYYY-MM-DD')
body = data.get('body', '') for key, maximum, label in [('summary', 400, '摘要'), ('body', 12000, '正文')]:
if not isinstance(body, str) or len(body) > 12000 or any(ord(c) < 32 and c not in '\r\n\t' for c in body): value = data.get(key, '')
raise a.Problem('记事正文格式不正确,最多 12000 字') if not isinstance(value, str) or len(value) > maximum or any(ord(c) < 32 and c not in '\r\n\t' for c in value):
item['body'] = body.strip() raise a.Problem(f'记事{label}格式不正确,最多 {maximum} 字')
item[key] = value.strip()
item['personIds'] = data.get('personIds', []) item['personIds'] = data.get('personIds', [])
if (not isinstance(item['personIds'], list) or len(item['personIds']) > 200 if (not isinstance(item['personIds'], list) or len(item['personIds']) > 200
or any(not isinstance(p, str) or not a.get_object('people', p) for p in item['personIds'])): or any(not isinstance(p, str) or not a.get_object('people', p) for p in item['personIds'])):
raise a.Problem('请选择有效的关联人物,最多 200 位') raise a.Problem('请选择有效的关联人物,最多 200 位')
item['personIds'] = list(dict.fromkeys(item['personIds'])) item['personIds'] = list(dict.fromkeys(item['personIds']))
item['status'] = data.get('status', 'draft') item['status'] = data.get('status', old.get('status', 'draft') if old else 'draft')
item['archived'] = data.get('archived', old.get('archived', False) if old else False) item['archived'] = data.get('archived', old.get('archived', False) if old else False)
if item['status'] not in ('draft', 'published') or not isinstance(item['archived'], bool): if item['status'] not in ('draft', 'published') or not isinstance(item['archived'], bool):
raise a.Problem('记事状态不正确') raise a.Problem('记事状态不正确')
@@ -127,8 +184,22 @@ class Family:
item['archived'] = data.get('archived', False) item['archived'] = data.get('archived', False)
if not isinstance(item['archived'], bool): if not isinstance(item['archived'], bool):
raise a.Problem('归档状态不正确') raise a.Problem('归档状态不正确')
if not old or item['birthDate'] != old.get('birthDate', ''):
self.check_birth_order([e for e in a.objects('family_links')
if item['id'] in (e['fromId'], e['toId'])], item)
return item return item
def check_birth_order(self, links, proposed_person=None):
people = {p['id']: p for p in self.a.objects('people')}
if proposed_person:
people[proposed_person['id']] = proposed_person
for edge in links:
if not edge.get('active') or edge['kind'] != 'parent' or edge.get('lineage') != 'biological':
continue
parent, child = people.get(edge['fromId']), people.get(edge['toId'])
if parent and child and parent.get('birthDate') and child.get('birthDate') and parent['birthDate'] >= child['birthDate']:
raise self.a.Problem('亲生父母的出生日期必须早于子女,请核对日期或亲子属性')
def link(self, data, old): def link(self, data, old):
a = self.a a = self.a
left, right = data.get('fromId'), data.get('toId') left, right = data.get('fromId'), data.get('toId')
@@ -148,6 +219,7 @@ class Family:
item = dict(id=old['id'] if old else secrets.token_hex(8), fromId=left, toId=right, kind=kind, item = dict(id=old['id'] if old else secrets.token_hex(8), fromId=left, toId=right, kind=kind,
lineage=lineage if kind == 'parent' else 'unspecified', active=active, lineage=lineage if kind == 'parent' else 'unspecified', active=active,
note=a.clean_text(data.get('note', ''), 300)) note=a.clean_text(data.get('note', ''), 300))
self.check_birth_order([item])
links = [e for e in a.objects('family_links') if e.get('active') and e['id'] != item['id']] links = [e for e in a.objects('family_links') if e.get('active') and e['id'] != item['id']]
if active: if active:
for e in links: for e in links:
+26
View File
@@ -4,11 +4,13 @@ import hmac
import json import json
import re import re
import secrets import secrets
import time
class Accounts: class Accounts:
def __init__(self, app): def __init__(self, app):
self.a = app self.a = app
self.password_attempts = {}
def initialize(self): def initialize(self):
a = self.a a = self.a
@@ -99,6 +101,30 @@ class Accounts:
a.audit('更新账号' if old else '建立账号', username) a.audit('更新账号' if old else '建立账号', username)
return self.public(user) return self.public(user)
def change_password(self, data, actor):
a = self.a
with a.LOCK:
current = a.get_object('users', actor['id'])
if not current or current['disabled']:
raise a.Problem('请重新登录', 401)
now = time.time()
self.password_attempts = {k: v for k, v in self.password_attempts.items() if v[1] > now}
count, expiry = self.password_attempts.get(current['id'], (0, now + 900))
if count >= 8:
raise a.Problem('当前密码尝试次数过多,请 15 分钟后重试', 429)
if not self.verify(current['username'], data.get('currentPassword')):
self.password_attempts[current['id']] = (count + 1, expiry)
raise a.Problem('当前密码不正确', 403)
if data.get('newPassword') == data.get('currentPassword'):
raise a.Problem('新密码不能与当前密码相同')
credentials = self.password(data.get('newPassword'))
with a.DB:
a.DB.execute('UPDATE users SET body=? WHERE id=?', (json.dumps(dict(current, **credentials)), current['id']))
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (current['id'],))
self.password_attempts.pop(current['id'], None)
a.audit('本人修改密码', current['username'])
return {'ok': True}
def camera(self, user, camera): def camera(self, user, camera):
if not camera: if not camera:
raise self.a.Problem('摄像头不存在', 404) raise self.a.Problem('摄像头不存在', 404)
+14 -3
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
@@ -13,6 +14,12 @@ MEDIA = 'http://www.onvif.org/ver10/media/wsdl'
SCHEMA = 'http://www.onvif.org/ver10/schema' SCHEMA = 'http://www.onvif.org/ver10/schema'
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
# Camera services must stay on the validated device endpoint.
return None
def request(host, port, endpoint, namespace, operation, content, username, password): def request(host, port, endpoint, namespace, operation, content, username, password):
created = dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') created = dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
nonce = secrets.token_bytes(16) nonce = secrets.token_bytes(16)
@@ -30,11 +37,15 @@ def request(host, port, endpoint, namespace, operation, content, username, passw
target = 'http://' + host + ':' + str(port) + endpoint target = 'http://' + host + ':' + str(port) + endpoint
passwords = urllib.request.HTTPPasswordMgrWithDefaultRealm() passwords = urllib.request.HTTPPasswordMgrWithDefaultRealm()
passwords.add_password(None, target, username, password) passwords.add_password(None, target, username, password)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), 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
+103 -1
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
@@ -53,7 +149,9 @@ class Controller:
body = '<m:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></m:StreamSetup><m:ProfileToken>' + escape(p.get('token', '')) + '</m:ProfileToken>' body = '<m:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></m:StreamSetup><m:ProfileToken>' + escape(p.get('token', '')) + '</m:ProfileToken>'
root = self.call(c, media, onvif.MEDIA, 'GetStreamUri', body) root = self.call(c, media, onvif.MEDIA, 'GetStreamUri', body)
uri = root.find('.//' + T + 'Uri') uri = root.find('.//' + T + 'Uri')
if uri is not None and url.urlsplit(uri.text).path == url.urlsplit(c['mainPath']).path: actual, wanted_uri = url.urlsplit(uri.text if uri is not None else ''), url.urlsplit(c['mainPath'])
if (actual.hostname == c['host'] and actual.path == wanted_uri.path
and sorted(url.parse_qsl(actual.query, keep_blank_values=True)) == sorted(url.parse_qsl(wanted_uri.query, keep_blank_values=True))):
selected = p selected = p
break break
if selected is None: if selected is None:
@@ -96,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.
+286 -4
View File
@@ -39,9 +39,9 @@ class WebTests(unittest.TestCase):
app.DB.close() app.DB.close()
_data.cleanup() _data.cleanup()
def request(self, path, authenticated=True, data=None, origin=None): def request(self, path, authenticated=True, data=None, origin=None, token=None):
c = http.client.HTTPConnection(*self.server.server_address, timeout=3) c = http.client.HTTPConnection(*self.server.server_address, timeout=3)
headers = {'Cookie': 'vision=' + 'f' * 64} if authenticated else {} headers = {'Cookie': 'vision=' + (token or 'f' * 64)} if authenticated else {}
if origin: if origin:
headers['Origin'] = origin headers['Origin'] = origin
if data is not None: if data is not None:
@@ -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), 13) self.assertEqual(len(assets), 18)
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))
@@ -146,7 +173,7 @@ class WebTests(unittest.TestCase):
for path in ['/api/users','/api/audit','/media/live/cam_'+'a'*16+'_hd/index.m3u8', for path in ['/api/users','/api/audit','/media/live/cam_'+'a'*16+'_hd/index.m3u8',
'/api/recordings?camera='+'a'*16]: '/api/recordings?camera='+'a'*16]:
self.assertEqual(request(path),403) self.assertEqual(request(path),403)
for path in ['/api/storage','/api/users','/api/family/person']: for path in ['/api/storage','/api/users','/api/family/person','/api/family/event']:
self.assertEqual(request(path,{}),403) self.assertEqual(request(path,{}),403)
self.assertEqual(request('/api/family'),200) self.assertEqual(request('/api/family'),200)
app.ACCOUNTS.save(dict(member,disabled=True),self.admin) app.ACCOUNTS.save(dict(member,disabled=True),self.admin)
@@ -275,6 +302,57 @@ class WebTests(unittest.TestCase):
event=app.FAMILY.event(dict(good,sourceUrl='https://example.org/report'),None) event=app.FAMILY.event(dict(good,sourceUrl='https://example.org/report'),None)
self.assertEqual(event['sourceUrl'],'https://example.org/report') self.assertEqual(event['sourceUrl'],'https://example.org/report')
def test_event_accepts_full_chinese_body_and_multiline_summary(self):
payload={'title':'中文长文','eventDate':'2026-10-03','body':'家'*12000,'summary':'第一段\n第二段',
'status':'published','revision':app.setting('familyRevision',0)}
self.assertGreater(len(json.dumps(payload).encode()),16384)
code,_,body=self.request('/api/family/event',data=payload)
self.assertEqual(code,200)
saved=json.loads(body)['item']
self.assertEqual(saved['body'],payload['body'])
self.assertEqual(saved['summary'],payload['summary'])
for changes in [{'body':'家'*12001},{'summary':'家'*401},{'summary':'bad\x00text'}]:
invalid=dict(payload,**changes,revision=app.setting('familyRevision',0))
self.assertEqual(self.request('/api/family/event',data=invalid)[0],400)
def test_event_keeps_publication_history_and_omitted_status(self):
def save(data):return app.FAMILY.save('event',dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
event=save({'title':'记事','eventDate':'2026-10-03','body':'正文','status':'published'})
published=event['publishedAt'];created=event['createdAt']
edited=dict(event,title='更新标题');edited.pop('status');edited.pop('publishedAt')
event=save(edited)
self.assertEqual(event['status'],'published');self.assertEqual(event['publishedAt'],published)
event=save(dict(event,status='draft'))
self.assertEqual(event['publishedAt'],published)
reader={'role':'member','familyAccess':'read'}
self.assertNotIn(event['id'],[e['id'] for e in app.FAMILY.snapshot(reader)['events']])
event=save(dict(event,status='published'))
self.assertEqual(event['publishedAt'],published);self.assertEqual(event['createdAt'],created)
def test_biological_birth_order_checked_on_link_and_person_changes(self):
def save(kind,data):return app.FAMILY.save(kind,dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
parent=save('person',{'name':'Parent','birthDate':'1980-01-01'})
child=save('person',{'name':'Child','birthDate':'2000-01-01'})
relation=save('link',{'fromId':parent['id'],'toId':child['id'],'kind':'parent','lineage':'biological'})
for person,date in [(parent,'2000-01-01'),(parent,'2001-01-01'),(child,'1979-01-01')]:
revision=app.setting('familyRevision',0)
with self.assertRaises(app.Problem):save('person',dict(person,birthDate=date))
self.assertEqual(app.setting('familyRevision',0),revision)
self.assertEqual(app.get_object('people',person['id'])['birthDate'],person['birthDate'])
save('link',dict(relation,active=False))
save('person',dict(parent,birthDate='2001-01-01'))
with self.assertRaises(app.Problem):save('link',dict(relation,active=True))
save('link',dict(relation,active=True,lineage='adoptive'))
def test_request_body_limit_is_bounded_and_invalid_length_is_400(self):
from email.message import Message
from types import SimpleNamespace
for length in ['invalid','-1','0','131073']:
headers=Message();headers['Content-Type']='application/json';headers['Content-Length']=length
fake=SimpleNamespace(headers=headers,rfile=io.BytesIO(b'{}'))
with self.assertRaises(app.Problem) as caught:app.Handler.body(fake,131072)
self.assertEqual(caught.exception.status,400)
def test_event_endpoint_and_static_modules(self): def test_event_endpoint_and_static_modules(self):
self.assertEqual(self.request('/api/family/event',False,{'title':'Denied'})[0],401) self.assertEqual(self.request('/api/family/event',False,{'title':'Denied'})[0],401)
payload={'title':'API event','eventDate':'2026-01-01','body':'Test only','status':'published','revision':app.setting('familyRevision',0)} payload={'title':'API event','eventDate':'2026-01-01','body':'Test only','status':'published','revision':app.setting('familyRevision',0)}
@@ -287,12 +365,216 @@ class WebTests(unittest.TestCase):
io.BytesIO(b'{"error":"no recording segments found"}')) io.BytesIO(b'{"error":"no recording segments found"}'))
with patch('app.urllib.request.urlopen', side_effect=error): with patch('app.urllib.request.urlopen', side_effect=error):
self.assertEqual(app.media_json('/list?path=example', playback=True), []) self.assertEqual(app.media_json('/list?path=example', playback=True), [])
self.assertTrue(error.fp.closed)
def test_real_media_service_error_is_preserved(self): def test_real_media_service_error_is_preserved(self):
error = app.urllib.error.HTTPError('http://localhost/list', 500, 'Error', {}, io.BytesIO(b'{}')) error = app.urllib.error.HTTPError('http://localhost/list', 500, 'Error', {}, io.BytesIO(b'{}'))
with patch('app.urllib.request.urlopen', side_effect=error): with patch('app.urllib.request.urlopen', side_effect=error):
with self.assertRaises(app.Problem): with self.assertRaises(app.Problem):
app.media_json('/list?path=example', playback=True) app.media_json('/list?path=example', playback=True)
self.assertTrue(error.fp.closed)
def family_save(self, kind, data):
return app.FAMILY.save(kind, dict(data, revision=app.setting('familyRevision', 0)), self.admin)
def test_history_records_before_after_and_restore_as_new_version(self):
first = self.family_save('person', {'name': 'History original', 'biography': 'Original story'})
second = self.family_save('person', dict(first['item'], name='History changed'))
change = app.FAMILY.change(self.admin, second['revision'])
self.assertEqual(change['before']['name'], 'History original')
self.assertEqual(change['after']['name'], 'History changed')
restored = app.FAMILY.restore({'changeRevision': second['revision'], 'side': 'before',
'revision': app.setting('familyRevision')}, self.admin)
self.assertEqual(restored['item']['name'], 'History original')
self.assertGreater(restored['revision'], second['revision'])
entries = app.FAMILY.history(self.admin, 'person', first['item']['id'])['items']
self.assertEqual(len(entries), 3)
self.assertEqual(entries[0]['restoredFrom'], second['revision'])
self.assertEqual(app.FAMILY.change(self.admin, second['revision']), change)
def test_history_rejects_stale_restore_without_writing(self):
first = self.family_save('person', {'name': 'Stale restore'})
second = self.family_save('person', dict(first['item'], name='Latest'))
with self.assertRaises(app.Problem) as caught:
app.FAMILY.restore(dict(changeRevision=first['revision'], side='after', revision=first['revision']), self.admin)
self.assertEqual(caught.exception.status, 409)
self.assertEqual(app.get_object('people', first['item']['id'])['name'], 'Latest')
self.assertEqual(app.setting('familyRevision'), second['revision'])
def test_restore_revalidates_current_birth_order(self):
old = self.family_save('person', {'name': 'Restore parent', 'birthDate': '1980-01-01'})
parent = self.family_save('person', dict(old['item'], birthDate='1960-01-01'))['item']
child = self.family_save('person', {'name': 'Restore child', 'birthDate': '1970-01-01'})['item']
self.family_save('link', dict(fromId=parent['id'], toId=child['id'], kind='parent', lineage='biological'))
with self.assertRaises(app.Problem):
app.FAMILY.restore(dict(changeRevision=old['revision'], side='after', revision=app.setting('familyRevision')), self.admin)
self.assertEqual(app.get_object('people', parent['id'])['birthDate'], '1960-01-01')
def test_restore_link_cannot_create_new_cycle(self):
a = self.family_save('person', {'name': 'Cycle A'})['item']
b = self.family_save('person', {'name': 'Cycle B'})['item']
old = self.family_save('link', dict(fromId=a['id'], toId=b['id'], kind='parent'))
self.family_save('link', dict(old['item'], active=False))
self.family_save('link', dict(fromId=b['id'], toId=a['id'], kind='parent'))
with self.assertRaises(app.Problem):
app.FAMILY.restore(dict(changeRevision=old['revision'], side='after', revision=app.setting('familyRevision')), self.admin)
self.assertFalse(app.get_object('family_links', old['item']['id'])['active'])
def test_restore_event_preserves_first_publication_and_validates_scope(self):
draft = self.family_save('event', dict(title='Restored story', eventDate='2020-01-01', body='Draft', status='draft'))
published = self.family_save('event', dict(draft['item'], body='Published', status='published'))
restored = app.FAMILY.restore(dict(changeRevision=draft['revision'], side='after', revision=app.setting('familyRevision')), self.admin)
self.assertEqual(restored['item']['status'], 'draft')
self.assertEqual(restored['item']['publishedAt'], published['item']['publishedAt'])
self.assertNotIn(draft['item']['id'], [e['id'] for e in app.FAMILY.snapshot(dict(role='member', familyAccess='read'))['events']])
for side in ['before', 'invalid']:
with self.assertRaises(app.Problem):
app.FAMILY.restore(dict(changeRevision=draft['revision'], side=side, revision=app.setting('familyRevision')), self.admin)
def test_history_pagination_is_complete_and_omits_story_bodies(self):
item = self.family_save('person', {'name': 'Pagination', 'biography': 'Private full story'})['item']
for i in range(23):item = self.family_save('person', dict(item, note=str(i)))['item']
first = app.FAMILY.history(self.admin, 'person', item['id'])
second = app.FAMILY.history(self.admin, 'person', item['id'], first['nextBefore'])
revisions = [v['revision'] for v in first['items'] + second['items']]
self.assertEqual(len(revisions), 24)
self.assertEqual(len(set(revisions)), 24)
self.assertIsNone(second['nextBefore'])
self.assertNotIn('Private full story', json.dumps(first))
def test_history_and_restore_endpoints_enforce_edit_permission(self):
item = self.family_save('person', {'name': 'Permission history'})
reader = app.ACCOUNTS.save(dict(username='history-reader', password='test-reader', familyAccess='read'), self.admin)
token = 'd' * 64
with app.LOCK:
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, reader['id']))
app.DB.commit()
path = '/api/family/history?kind=person&id=' + item['item']['id']
for target in [path, '/api/family/change?revision=' + str(item['revision'])]:
self.assertEqual(self.request(target, token=token)[0], 403)
self.assertEqual(self.request(target, authenticated=False)[0], 401)
self.assertEqual(self.request(target)[0], 200)
self.assertEqual(self.request('/api/family/restore', token=token, data={})[0], 403)
for number in ['bad', '-1', '0', '99999999999999999999999']:
self.assertEqual(self.request('/api/family/change?revision=' + number)[0], 400)
def test_family_history_transaction_rolls_back_on_storage_failure(self):
before = app.setting('familyRevision', 0)
with app.LOCK:
app.DB.execute("CREATE TRIGGER fail_history BEFORE INSERT ON family_changes BEGIN SELECT RAISE(ABORT, 'test failure'); END")
app.DB.commit()
try:
import sqlite3
with self.assertRaises(sqlite3.IntegrityError):self.family_save('person', {'name': 'Must not persist'})
self.assertEqual(app.setting('familyRevision', 0), before)
self.assertFalse(any(p['name']=='Must not persist' for p in app.objects('people')))
finally:
with app.LOCK:app.DB.execute('DROP TRIGGER fail_history');app.DB.commit()
def test_concurrent_family_saves_accept_only_one_revision(self):
from concurrent.futures import ThreadPoolExecutor
revision = app.setting('familyRevision', 0); barrier = threading.Barrier(2)
def save(index):
barrier.wait()
try:app.FAMILY.save('person', dict(name='Concurrent '+str(index), revision=revision), self.admin);return 200
except app.Problem as e:return e.status
with ThreadPoolExecutor(max_workers=2) as pool:self.assertEqual(sorted(pool.map(save, range(2))), [200, 409])
self.assertEqual(app.setting('familyRevision'), revision+1)
def test_password_change_revokes_all_own_sessions_and_keeps_other_accounts(self):
member = app.ACCOUNTS.save(dict(username='password-member', password='old-password', familyAccess='read'), self.admin)
tokens = ['1'*64, '2'*64]
with app.LOCK:
for token in tokens:app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, member['id']))
app.DB.commit()
code, headers, _ = self.request('/api/account/password', token=tokens[0], data=dict(currentPassword='old-password', newPassword='new-password', id=self.admin['id'], role='admin'))
self.assertEqual(code, 200);self.assertIn('Max-Age=0', headers['Set-Cookie'])
self.assertIsNone(app.ACCOUNTS.verify(member['username'], 'old-password'))
self.assertEqual(app.ACCOUNTS.verify(member['username'], 'new-password')['role'], 'member')
for token in tokens:self.assertEqual(self.request('/api/family', token=token)[0], 401)
self.assertEqual(self.request('/api/users')[0], 200)
def test_password_validation_and_rate_limit(self):
actor = app.ACCOUNTS.save(dict(username='password-validation', password='old-password'), self.admin)
for data in [dict(currentPassword='old-password', newPassword='tiny'),dict(currentPassword='old-password', newPassword='old-password')]:
with self.assertRaises(app.Problem):app.ACCOUNTS.change_password(data, actor)
for _ in range(8):
with self.assertRaises(app.Problem) as caught:app.ACCOUNTS.change_password(dict(currentPassword='wrong', newPassword='new-password'), actor)
self.assertEqual(caught.exception.status, 403)
with self.assertRaises(app.Problem) as caught:app.ACCOUNTS.change_password(dict(currentPassword='old-password', newPassword='new-password'), actor)
self.assertEqual(caught.exception.status, 429)
self.assertIsNotNone(app.ACCOUNTS.verify(actor['username'], 'old-password'))
self.assertEqual(self.request('/api/account/password', authenticated=False, data={})[0], 401)
self.assertEqual(self.request('/api/account/password', data={}, origin='http://[bad')[0], 403)
def test_object_ids_and_hosts_reject_malformed_input_without_500(self):
for endpoint in ['sites', 'assets', 'cameras', 'recorders']:
for value in [[], {}, 17, True]:
self.assertEqual(self.request('/api/'+endpoint, data={'id':value})[0], 400)
self.assertEqual(self.request('/api/'+endpoint, data={'id':'9'*16})[0], 404)
for value in [None, 123, [], {}, True]:
with self.assertRaises(app.Problem):app.host_address(value)
def test_concurrent_camera_capacity_and_asset_space_move(self):
from concurrent.futures import ThreadPoolExecutor
site = app.save_site({'name':'Capacity space'})
other = app.save_site({'name':'Moved space'})
asset = app.save_asset(dict(name='Single lens', siteId=site['id'], lensCount=1))
barrier = threading.Barrier(2)
def save(index):
barrier.wait()
try:app.save_camera(dict(name='Concurrent lens '+str(index), assetId=asset['id'], host='192.168.0.2'));return 200
except app.Problem as e:return e.status
with patch.object(app, 'write_media_config'):
with ThreadPoolExecutor(max_workers=2) as pool:self.assertEqual(sorted(pool.map(save, range(2))), [200, 400])
attached = [c for c in app.objects('cameras') if c.get('assetId')==asset['id']]
self.assertEqual(len(attached), 1)
app.save_asset(dict(asset, siteId=other['id']))
self.assertEqual(app.get_object('cameras', attached[0]['id'])['siteId'], other['id'])
def test_recorder_updates_are_reflected_in_channel_endpoint_without_password(self):
site = app.save_site({'name':'Recorder test'})
asset = app.save_asset(dict(name='Recorder lens', siteId=site['id'], lensCount=1))
with patch.object(app, 'write_media_config'):
r = app.save_recorder(dict(name='NVR test', siteId=site['id'], host='192.168.0.8', username='test', password='private'))
camera = app.save_camera(dict(name='Channel', assetId=asset['id'], sourceKind='recorder', recorderId=r['id']))
app.save_recorder(dict(r, host='192.168.0.9', username='updated'))
current = app.public_camera(app.get_object('cameras', camera['id']))
self.assertEqual(current['host'], '192.168.0.9')
self.assertEqual(current['username'], 'updated')
self.assertTrue(current['passwordConfigured']);self.assertNotIn('password', current)
def test_camera_settings_admin_only_and_errors_are_explicit(self):
user = app.ACCOUNTS.save(dict(username='settings-member', password='settings-pass', siteIds=['test-site'] if app.get_object('sites','test-site') else []), self.admin)
token = '3'*64
with app.LOCK:
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(),time.time()+3600,user['id']));app.DB.commit()
path = '/api/camera/settings?camera='+'a'*16
self.assertEqual(self.request(path,token=token)[0],403)
self.assertEqual(self.request('/api/camera/settings',data={},token=token)[0],403)
self.assertEqual(self.request(path,authenticated=False)[0],401)
with patch.object(app.camera_settings.CONTROLLER,'read',return_value=({'device':{'Model':'Example'},'revision':'one'},{})):
self.assertEqual(self.request(path)[0],200)
with patch.object(app.camera_settings.CONTROLLER,'save',side_effect=ValueError('unsupported')):
self.assertEqual(self.request('/api/camera/settings',data={'camera':'a'*16})[0],400)
with patch.object(app.camera_settings.CONTROLLER,'save',side_effect=OSError()):
self.assertEqual(self.request('/api/camera/settings',data={'camera':'a'*16})[0],502)
def test_asset_move_rolls_back_all_channels_on_failure(self):
import sqlite3
site = app.save_site({'name':'Original site atomic'})
other = app.save_site({'name':'New site atomic'})
asset = app.save_asset(dict(name='Atomic move',siteId=site['id'],lensCount=1))
with patch.object(app,'write_media_config'):
camera = app.save_camera(dict(name='Atomic lens',assetId=asset['id'],host='192.168.0.2'))
with app.LOCK:
app.DB.execute("CREATE TRIGGER fail_camera_move BEFORE UPDATE ON cameras BEGIN SELECT RAISE(ABORT,'test'); END");app.DB.commit()
try:
with self.assertRaises(sqlite3.IntegrityError):app.save_asset(dict(asset,siteId=other['id']))
self.assertEqual(app.get_object('assets',asset['id'])['siteId'],site['id'])
self.assertEqual(app.get_object('cameras',camera['id'])['siteId'],site['id'])
finally:
with app.LOCK:app.DB.execute('DROP TRIGGER fail_camera_move');app.DB.commit()
if __name__ == '__main__': if __name__ == '__main__':
+122
View File
@@ -0,0 +1,122 @@
"""ONVIF contract tests against synthetic devices; no real camera is modified."""
import copy
import unittest
from unittest.mock import patch
from xml.etree import ElementTree as ET
import camera_settings as cs
import onvif
def xml(inner):
return ET.fromstring('<root xmlns:t="'+onvif.SCHEMA+'" xmlns:m="'+onvif.MEDIA+'" xmlns:i="'+cs.IMAGING+'">'+inner+'</root>')
class SettingsTests(unittest.TestCase):
def setUp(self):
self.controller=cs.Settings()
self.camera=dict(host='192.168.0.2',username='test',password='private',sourceKind='direct',enabled=True,
mainPath='/stream?profile=lens2',subPath='/sub?profile=lens2')
self.encoder=xml('<t:VideoEncoderConfiguration token="encoder2" encoding="H265"><t:Encoding>H264</t:Encoding>'
'<t:Resolution><t:Width>1920</t:Width><t:Height>1080</t:Height></t:Resolution><t:Quality>3</t:Quality>'
'<t:RateControl><t:FrameRateLimit>15</t:FrameRateLimit><t:BitrateLimit>2048</t:BitrateLimit></t:RateControl>'
'<t:H264><t:GovLength>60</t:GovLength><t:H264Profile>Main</t:H264Profile></t:H264>'
'<t:Multicast><t:TTL>1</t:TTL></t:Multicast><t:SessionTimeout>PT5S</t:SessionTimeout></t:VideoEncoderConfiguration>')[0]
self.info=dict(device=(80,'/onvif/device_service'),services={'Media':(80,'/media'),'Imaging':(80,'/imaging')},
source='source2',profile='lens2',encoder=self.encoder)
self.imaging=xml('<i:ImagingSettings><t:Brightness>50</t:Brightness><t:IrCutFilter>AUTO</t:IrCutFilter>'
'<t:Focus><t:AutoFocusMode>MANUAL</t:AutoFocusMode></t:Focus></i:ImagingSettings>')
self.image_options=xml('<i:ImagingOptions><t:Brightness><t:Min>0</t:Min><t:Max>100</t:Max></t:Brightness>'
'<t:IrCutFilterModes>AUTO</t:IrCutFilterModes><t:IrCutFilterModes>ON</t:IrCutFilterModes></i:ImagingOptions>')
self.video_options=xml('<m:Options><t:QualityRange><t:Min>0</t:Min><t:Max>5</t:Max></t:QualityRange>'
'<t:H264><t:FrameRateRange><t:Min>1</t:Min><t:Max>15</t:Max></t:FrameRateRange>'
'<t:ResolutionsAvailable><t:Width>1920</t:Width><t:Height>1080</t:Height></t:ResolutionsAvailable></t:H264>'
'<t:Extension><t:H264><t:BitrateRange><t:Min>256</t:Min><t:Max>2048</t:Max></t:BitrateRange></t:H264></t:Extension></m:Options>')
self.calls=[]
def call(self,camera,endpoint,ns,operation,body=''):
self.calls.append((operation,body))
if operation=='GetDeviceInformation':return xml('')
if operation=='GetImagingSettings':return copy.deepcopy(self.imaging)
if operation=='GetOptions':return self.image_options
if operation=='GetVideoEncoderConfigurationOptions':return self.video_options
if operation=='SetImagingSettings':
root=xml(body.replace('xmlns:', 'xmlns:'))
self.imaging=xml(ET.tostring(root.find(cs.I+'ImagingSettings'),encoding='unicode'))
return xml('')
return xml('')
def test_device_ranges_and_vendor_codec_are_exposed_without_credentials(self):
with patch.object(self.controller,'call',self.call):data=self.controller.details(self.camera,self.info)
self.assertEqual(data['imaging']['brightness']['max'],100)
self.assertEqual(data['imaging']['dayNight']['choices'],['AUTO','ON'])
self.assertEqual(data['video']['codec'],'H265')
self.assertEqual(data['video']['options']['bitrate']['max'],2048)
self.assertNotIn('private',str(data))
self.assertNotIn('sharpness',data['imaging'])
def test_rejects_bad_values_and_unsupported_controls(self):
rule={'brightness':dict(min=0,max=100)}
for value in [True,101,-1,float('nan'),float('inf'),'50']:
with self.assertRaises(ValueError):self.controller.validate({'brightness':value},rule)
for values in [{},{'unknown':1},None,[]]:
with self.assertRaises(ValueError):self.controller.validate(values,rule)
with self.assertRaises(ValueError):self.controller.validate({'fps':1.5},{'fps':dict(min=1,max=15)},('fps',))
with self.assertRaises(ValueError):self.controller.validate({'mode':'OFF'},{'mode':dict(choices=['AUTO'])})
def test_imaging_write_preserves_other_fields_and_verifies_readback(self):
with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call):
data,_=self.controller.read(self.camera)
result=self.controller.save(self.camera,dict(revision=data['revision'],section='imaging',values={'brightness':60},quality='main'))
self.assertTrue(result['verified'])
sent=next(body for op,body in self.calls if op=='SetImagingSettings')
self.assertIn('MANUAL',sent);self.assertIn('source2',sent);self.assertIn('ForcePersistence',sent)
def test_stale_revision_never_issues_device_write(self):
with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call):
with self.assertRaises(ValueError):self.controller.save(self.camera,dict(revision='old',section='imaging',values={'brightness':60}))
self.assertFalse(any(op.startswith('Set') for op,_ in self.calls))
def test_video_write_retains_codec_extension_multicast_and_other_settings(self):
with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call):
data,_=self.controller.read(self.camera)
result=self.controller.save(self.camera,dict(revision=data['revision'],section='video',values={'fps':10}))
sent=next(body for op,body in self.calls if op=='SetVideoEncoderConfiguration')
self.assertIn('encoding="H265"',sent);self.assertIn('Multicast',sent);self.assertIn('SessionTimeout',sent)
self.assertIn('>10<',sent);self.assertFalse(result['verified']) # Synthetic device deliberately ignores this write.
def test_device_timeout_after_save_does_not_falsely_claim_confirmation(self):
with patch.object(self.controller,'call',self.call):data=self.controller.details(self.camera,self.info)
with patch.object(self.controller,'read',side_effect=[(data,self.info),OSError()]),patch.object(self.controller,'call',return_value=xml('')):
result=self.controller.save(self.camera,dict(revision=data['revision'],section='imaging',values={'brightness':60}))
self.assertFalse(result['verified']);self.assertIsNone(result['settings'])
def test_onvif_requests_do_not_follow_redirects(self):
self.assertIsNone(onvif.NoRedirect().redirect_request(None, None, 302, 'Found', {}, 'http://other.example/'))
def test_network_endpoints_and_recorder_channels_are_rejected(self):
for address in ['http://evil.example/api','https://192.168.0.2/api','http://u:p@192.168.0.2/api','file:///etc/passwd']:
with self.assertRaises(ValueError):self.controller.endpoint(self.camera,address)
for overrides in [dict(sourceKind='recorder'),dict(enabled=False)]:
with self.assertRaises(ValueError):self.controller.discover(dict(self.camera,**overrides),'main')
with self.assertRaises(ValueError):self.controller.discover(self.camera,'bad')
def test_exact_profile_path_and_query_selects_second_lens(self):
selected=[]
def call(c,e,ns,op,body=''):
if op=='GetCapabilities':return xml('<t:Media><t:XAddr>http://192.168.0.2/media</t:XAddr></t:Media>')
if op=='GetProfiles':return xml(''.join('<m:Profiles token="lens'+str(i)+'"><t:VideoSourceConfiguration><t:SourceToken>source'+str(i)+'</t:SourceToken></t:VideoSourceConfiguration>'+ET.tostring(self.encoder,encoding='unicode')+'</m:Profiles>' for i in [1,2]))
if op=='GetStreamUri':
token='lens2' if 'lens2' in body else 'lens1';selected.append(token)
return xml('<t:Uri>rtsp://192.168.0.2/stream?profile='+token+'</t:Uri>')
with patch.object(self.controller,'call',call):info=self.controller.discover(self.camera,'main')
self.assertEqual(info['source'],'source2');self.assertEqual(selected,['lens1','lens2'])
def test_failed_options_disable_writes_but_keep_other_section_readable(self):
def call(*args):
if args[3]=='GetVideoEncoderConfigurationOptions':raise OSError()
return self.call(*args)
with patch.object(self.controller,'call',call):data=self.controller.details(self.camera,self.info)
self.assertEqual(data['video']['options'],{});self.assertIn('brightness',data['imaging']);self.assertTrue(data['notes'])
if __name__=='__main__':unittest.main()
+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);
});
+124
View File
@@ -0,0 +1,124 @@
// Run actual UI functions in isolated contexts; no live accounts or browser state.
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
const source=name=>fs.readFileSync('web/'+name,'utf8');
function section(file,start,end){const s=source(file),a=s.indexOf(start),b=s.indexOf(end,a+start.length);assert.ok(a>=0&&b>a);return s.slice(a,b);}
const apiSource=section('app.js','async function api(','function toast(');
const refreshSource=section('app.js','async function refresh(','async function auth(');
function context(extra={}){return vm.createContext({sessionGeneration:0,refreshGeneration:0,authGeneration:0,loggedIn:true,currentUser:{id:'a'},state:{},Error,...extra});}
function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};}
test('late successful response cannot restore private data after account switch',async()=>{
const pending=deferred(),c=context({fetch:()=>pending.promise});vm.runInContext(apiSource,c);
const request=c.api('/api/family');c.sessionGeneration++;c.currentUser={id:'b'};
pending.resolve({ok:true,json:async()=>({privatePerson:'previous account'})});
await assert.rejects(request,e=>e.stale===true);
});
test('old unauthorized response cannot log out a new session',async()=>{
const pending=deferred();let clears=0;const c=context({fetch:()=>pending.promise,clearPrivateView:()=>clears++,auth:()=>{}});vm.runInContext(apiSource,c);
const request=c.api('/api/state');c.sessionGeneration++;
pending.resolve({ok:false,status:401,json:async()=>({error:'old session'})});
await assert.rejects(request,e=>e.stale===true);assert.equal(clears,0);
});
test('API errors retain conflict status for explicit reload workflow',async()=>{
const c=context({fetch:async()=>({ok:false,status:409,json:async()=>({error:'家谱已被更新'})})});vm.runInContext(apiSource,c);
await assert.rejects(c.api('/api/family/person',{}),e=>e.status===409);
});
test('late state refresh cannot overwrite newer state',async()=>{
const first=deferred(),second=deferred(),requests=[first,second];let renders=0;
const c=context({api:()=>requests.shift().promise,render:()=>renders++});vm.runInContext(refreshSource,c);
const a=c.refresh(),b=c.refresh();second.resolve({version:'new',user:{id:'a'}});await b;
first.resolve({version:'old',user:{id:'a'}});await a;
assert.equal(c.state.version,'new');assert.equal(renders,1);
});
test('late refresh failure cannot cover a newer successful screen',async()=>{
const first=deferred(),second=deferred(),requests=[first,second];let banners=0;
const c=context({api:()=>requests.shift().promise,render:()=>{},$:()=>{banners++;return {};}});vm.runInContext(refreshSource,c);
const a=c.refresh(),b=c.refresh();second.resolve({user:{id:'a'}});await b;first.reject(Error('old failure'));await a;assert.equal(banners,0);
});
test('another tab changing the shared login clears the previous private view',async()=>{
let clears=0;const c=context({api:async()=>({user:{id:'b'},cameras:[]}),render:()=>{},
clearPrivateView:()=>{clears++;c.loggedIn=false;c.currentUser=null;}});
vm.runInContext(refreshSource,c);await c.refresh();assert.equal(clears,1);assert.equal(c.currentUser.id,'b');assert.equal(c.loggedIn,true);
});
test('expired session clears private view when session endpoint itself returns 200',async()=>{
const fields=new Map();const $=s=>{if(!fields.has(s))fields.set(s,{elements:{username:{}},showModal(){this.open=true;}});return fields.get(s);};
let clears=0;const c=context({api:async()=>({authenticated:false,user:null,setupRequired:false}),$,
clearPrivateView:()=>{clears++;c.currentUser=null;c.loggedIn=false;},applyPermissions:()=>{},refresh:()=>{},toast:()=>{}});
vm.runInContext(section('app.js','async function auth(','function openSite('),c);await c.auth();
assert.equal(clears,1);assert.equal(c.loggedIn,false);assert.equal($('#auth-dialog').open,true);
});
test('archived person reached from an event is revealed before graph location',()=>{
const toggle={checked:false};let rendered=false,located='';
const c=context({family:{people:[{id:'ancestor',archived:true}]},$:()=>toggle,window:{innerWidth:1200},document:{},
renderFamily:()=>{assert.equal(toggle.checked,true);rendered=true;},familyMap:{locate:id=>located=id}});
vm.runInContext(section('family.js','function selectFamilyPerson(','function openPerson('),c);c.selectFamilyPerson('ancestor',true);
assert.ok(rendered);assert.equal(c.familyView,'tree');assert.equal(located,'ancestor');
});
test('event default save preserves published state and exposes explicit withdrawal',()=>{
const controls=new Map(),form={dataset:{},querySelector:s=>{if(!controls.has(s))controls.set(s,{});return controls.get(s);}};
const elements=new Map([['#event-form',form]]);const $=s=>{if(!elements.has(s))elements.set(s,{showModal(){},close(){}});return elements.get(s);};
const c=context({family:{canEdit:true,revision:3,people:[],events:[{id:'published',status:'published',personIds:[]}]},$,fillForm:()=>{},initDateFields:()=>{},esc:String});
vm.runInContext(section('family-events.js','function editFamilyEvent(',"$('#event-form').addEventListener"),c);
c.editFamilyEvent('published');assert.equal(form.querySelector('[data-save-event]').value,'published');assert.equal(form.querySelector('[data-save-event]').textContent,'保存修改');assert.equal(form.querySelector('[data-publish-event]').hidden,true);assert.equal(form.querySelector('[data-withdraw-event]').hidden,false);
c.editFamilyEvent();assert.equal(form.querySelector('[data-save-event]').value,'draft');assert.equal(form.querySelector('[data-withdraw-event]').hidden,true);
});
test('event date validation never clears a separately edited person death date',()=>{
const c=context({$:()=>{throw Error('unexpected person form access');},syncLifeStatus:()=>{throw Error('unexpected life state change');},dateHint:()=>{}});
vm.runInContext(section('date-fields.js','function syncDateFields(','for(const field of $$'),c);
c.syncDateFields(true,{id:'event-form',querySelectorAll:()=>[]});
});
test('older family request errors cannot repopulate logged-out view',async()=>{
const pending=deferred();let messages=0;const c=context({familyGeneration:0,api:()=>pending.promise,$:()=>{messages++;return {};}});
vm.runInContext(section('family.js','async function loadFamily(','function relationText('),c);
const p=c.loadFamily();c.loggedIn=false;c.familyGeneration++;pending.reject(Error('late error'));assert.equal(await p,false);assert.equal(messages,0);
});
const historySource=section('family-history.js','async function showFamilyChange(','function prepareFamilyRestore(');
test('history prose is never translated as an enum value',()=>{
const c=context({personName:id=>'Person '+id});vm.runInContext(section('family-history.js','function historyValue(','async function openFamilyHistory('),c);
assert.equal(c.historyValue('body','published'),'published');assert.equal(c.historyValue('status','published'),'已发布');
assert.equal(c.historyValue('personIds',['a','b']),'Person a、Person b');assert.equal(c.historyValue('active',false),'否');
});
test('history detail from a previous selection cannot replace newer detail',async()=>{
const a=deferred(),b=deferred(),calls=[a,b],elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{});return elements.get(s);};
const c=context({historyContext:{},historyDetailGeneration:0,historySelection:null,api:()=>calls.shift().promise,$,historyFields:{name:'Name'},esc:String,historyValue:(_,v)=>v});
vm.runInContext(historySource,c);const old=c.showFamilyChange(1),latest=c.showFamilyChange(2);
b.resolve({revision:2,before:null,after:{name:'New'}});await latest;const html=$('#history-detail').innerHTML;
a.resolve({revision:1,before:null,after:{name:'Old'}});await old;assert.equal($('#history-detail').innerHTML,html);assert.equal(c.historySelection.revision,2);
});
test('closing history prevents late private detail from rendering',async()=>{
const pending=deferred(),detail={};const c=context({historyContext:{},historyDetailGeneration:0,historySelection:null,api:()=>pending.promise,$:()=>detail});
vm.runInContext(historySource,c);const request=c.showFamilyChange(1);c.historyContext=null;c.historyDetailGeneration++;pending.resolve({after:{name:'Private'}});await request;
assert.equal(detail.innerHTML,undefined);assert.equal(c.historySelection,null);
});
test('password form requires matching, distinct and bounded new password',()=>{
const c=context();vm.runInContext(section('app.js','function validateNewPassword(','$(\'#account-password\').onclick='),c);
for(const data of [{newPassword:'different',confirmPassword:'mismatch'},{newPassword:'samepass',confirmPassword:'samepass',currentPassword:'samepass'},{newPassword:'short',confirmPassword:'short'},{newPassword:'x'.repeat(129),confirmPassword:'x'.repeat(129)}])assert.throws(()=>c.validateNewPassword(data));
assert.doesNotThrow(()=>c.validateNewPassword({newPassword:'new-password',confirmPassword:'new-password',currentPassword:'old-password'}));
});
test('history restore sends the reviewed revision and never the full text',async()=>{
let sent;const elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){}});return elements.get(s);};
const c=context({historyContext:{revision:10},historySelection:{revision:3,before:{name:'Original'}},historyDetailGeneration:1,$,$$:()=>[],
api:async(path,data)=>{sent={path,data};},loadFamilyHistory:async()=>{},loadFamily:async()=>true,toast:()=>{}});
vm.runInContext(section('family-history.js','async function restoreFamilyChange(','$(\'#history-more\').onclick='),c);await c.restoreFamilyChange('before');
assert.equal(sent.path,'/api/family/restore');assert.equal(JSON.stringify(sent.data),JSON.stringify({changeRevision:3,side:'before',revision:10}));assert.equal(c.historySelection,null);
});
test('camera form sends only changed and advertised parameters',()=>{
const c=context();vm.runInContext(section('camera-settings.js','function changedCameraValues(','async function saveCameraSettings('),c);
const form={elements:{brightness:{value:'60'},dayNight:{value:'AUTO'},malicious:{value:'anything'}}};
const settings={imaging:{brightness:{value:50,min:0,max:100},dayNight:{value:'AUTO',choices:['AUTO','ON']}}};
assert.equal(JSON.stringify(c.changedCameraValues(form,'imaging',settings)),JSON.stringify({brightness:60}));
});
test('late camera settings cannot populate a closed or different camera dialog',async()=>{
const pending=deferred(),elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{value:'main'});return elements.get(s);};let renders=0;
const c=context({cameraSettingsContext:{id:'old'},cameraSettingsGeneration:0,$,$$:()=>[],URLSearchParams,api:()=>pending.promise,renderCameraSettings:()=>renders++});
vm.runInContext(section('camera-settings.js','async function loadCameraSettings(','async function openCameraSettings('),c);const request=c.loadCameraSettings();c.cameraSettingsContext={id:'new'};c.cameraSettingsGeneration++;pending.resolve({device:{}});await request;assert.equal(renders,0);
});
test('ambiguous device save requires re-read before another write',async()=>{
const elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{});return elements.get(s);};const buttons=[{disabled:false}];
const c=context({cameraSettingsContext:{id:'camera',quality:'main',settings:{revision:'old'}},cameraSettingsGeneration:0,$,$$:()=>buttons,
changedCameraValues:()=>({brightness:60}),api:async()=>{throw Error('connection lost');},toast:()=>{}});
vm.runInContext(section('camera-settings.js','async function saveCameraSettings(','for(const [id,section]'),c);await c.saveCameraSettings({},'imaging');
assert.equal(c.cameraSettingsContext.settings,null);assert.equal(buttons[0].disabled,true);assert.equal($('#camera-settings-reload').disabled,false);
});
+28 -26
View File
@@ -5,15 +5,19 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他'); const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false; let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0; let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
let currentUser = null; let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
const isAdmin = () => currentUser?.role === 'admin'; const isAdmin = () => currentUser?.role === 'admin';
function applyPermissions(){ function applyPermissions(){
const admin=isAdmin(); const admin=isAdmin();
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera]').forEach(el=>el.hidden=!admin); $('#account-password').hidden=!loggedIn;
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin);
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none'); $('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
$('#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();
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(()=>{});
currentUser=null;stopPlayers();resetPlayback(); currentUser=null;stopPlayers();resetPlayback();
state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}}; state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
@@ -25,9 +29,11 @@ function clearPrivateView(){
} }
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']}; const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
async function api(path, data) { async function api(path, data) {
const generation=sessionGeneration;
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)}); const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)});
let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); } let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); }
if (!r.ok) { if (r.status === 401 && loggedIn) { loggedIn = false; clearPrivateView(); auth(); } throw Error(result.error || '操作未完成'); } if(generation!==sessionGeneration)throw Object.assign(Error('登录会话已变化,请重新操作'),{stale:true});
if (!r.ok) { if (r.status === 401 && loggedIn) { clearPrivateView(); auth(); } throw Object.assign(Error(result.error || '操作未完成'),{status:r.status}); }
return result; return result;
} }
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); } function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
@@ -82,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-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`));
@@ -104,7 +110,7 @@ function renderCameras() {
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测'; $('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : ''); $('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
const cameras=filtered(); const cameras=filtered();
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。'); $('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
} }
function renderSites() { function renderSites() {
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value); const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
@@ -115,9 +121,9 @@ function renderStorage() {
if (!$('#storage-form').contains(document.activeElement)) for(const k of ['retentionDays','maxGB','reserveGB']) $('#storage-form').elements[k].value=s[k]??''; if (!$('#storage-form').contains(document.activeElement)) for(const k of ['retentionDays','maxGB','reserveGB']) $('#storage-form').elements[k].value=s[k]??'';
$('#storage-status').innerHTML=[['录像占用',(s.usedGB??'—')+' GB'],['磁盘可用',(s.freeGB??'—')+' GB'],['视频服务',state.mediaOnline?'运行中':'未连接'],['空间保护',s.paused?s.reason:'未触发'],['录像方式','主码流持续录像 / 分段保存'],['系统运行',Math.floor(state.uptime/3600)+' 小时 '+Math.floor(state.uptime%3600/60)+' 分钟']].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join(''); $('#storage-status').innerHTML=[['录像占用',(s.usedGB??'—')+' GB'],['磁盘可用',(s.freeGB??'—')+' GB'],['视频服务',state.mediaOnline?'运行中':'未连接'],['空间保护',s.paused?s.reason:'未触发'],['录像方式','主码流持续录像 / 分段保存'],['系统运行',Math.floor(state.uptime/3600)+' 小时 '+Math.floor(state.uptime%3600/60)+' 分钟']].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');
} }
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){toast(e.message);} } async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId)return;state=next;currentUser=state.user;render();}catch(e){$('#banner').textContent=e.message;$('#banner').hidden=false;}} async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
async function auth() {try{const s=await api('/api/session');loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){toast('无法连接管理服务:'+e.message);}} async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();} function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();} function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
function formValues(f) {return Object.fromEntries(new FormData(f).entries());} function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
@@ -132,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);
@@ -167,3 +157,15 @@ $('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').el
handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)})); handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)}));
$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}}; $('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}};
function validateNewPassword(values){
if(values.newPassword!==values.confirmPassword)throw Error('两次输入的新密码不一致');
if(values.newPassword===values.currentPassword)throw Error('新密码不能与当前密码相同');
if(values.newPassword.length<8||values.newPassword.length>128)throw Error('密码需要 8 至 128 个字符');
}
$('#account-password').onclick=()=>{if(!loggedIn)return;const f=$('#password-form');f.reset();f.querySelector('.form-error').textContent='';$('#password-account').textContent='当前账号:'+currentUser.username;$('#password-dialog').showModal();};
$('#password-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{const data=formValues(f);validateNewPassword(data);await api('/api/account/password',{currentPassword:data.currentPassword,newPassword:data.newPassword});clearPrivateView();await auth();toast('密码已修改,请用新密码重新登录');}
catch(err){if(!err.stale)f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}
});
+56
View File
@@ -0,0 +1,56 @@
'use strict';
let cameraSettingsContext=null,cameraSettingsGeneration=0;
const videoLabels={resolution:'分辨率',fps:'帧率(帧/秒)',bitrate:'码率上限(kbps)',quality:'画质等级',gop:'关键帧间隔(帧)'};
function clearCameraSettings(){cameraSettingsGeneration++;cameraSettingsContext=null;for(const id of ['camera-settings-info','camera-image-fields','camera-video-fields','camera-settings-status'])$('#'+id).replaceChildren();}
function settingsInput(key,label,value,rule){
const enumLabel=v=>key==='dayNight'?({AUTO:'自动日夜',ON:'日间(红外截止)',OFF:'夜间(红外通过)'})[v]||v:({ON:'开启',OFF:'关闭',AUTO:'自动',MANUAL:'手动'})[v]||v;
return `<label>${esc(label)}${rule.choices?`<select name="${esc(key)}">${rule.choices.map(v=>`<option value="${esc(v)}" ${v===value?'selected':''}>${esc(enumLabel(v))}</option>`).join('')}</select>`:`<input name="${esc(key)}" type="number" value="${esc(value)}" min="${rule.min}" max="${rule.max}" step="${['fps','bitrate','gop'].includes(key)?'1':'any'}" required><small>${rule.min}–${rule.max}</small>`}</label>`;
}
function renderCameraSettings(data){
const d=data.device;
$('#camera-settings-info').innerHTML=`<dl>${[['厂商',d.Manufacturer],['型号',d.Model],['固件',d.FirmwareVersion],['镜头视频源',data.source],['码流配置',data.profile],['设备报告编码',data.video.codec]].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v||'未提供')}</dd></div>`).join('')}</dl>`;
$('#camera-image-fields').innerHTML=Object.entries(data.imaging).map(([k,r])=>settingsInput(k,r.label,r.value,r)).join('')||'<p class="description">设备未提供可调图像参数。</p>';
$('#camera-video-fields').innerHTML=Object.entries(data.video.options).map(([k,r])=>settingsInput(k,videoLabels[k],data.video.values[k],r)).join('')||'<p class="description">设备未提供可调码流参数。</p>';
$('#camera-settings-status').textContent=data.notes.join(';');
$('#camera-image-form button[type=submit]').disabled=!Object.keys(data.imaging).length;
$('#camera-video-form button[type=submit]').disabled=!Object.keys(data.video.options).length;
}
async function loadCameraSettings(){
if(!cameraSettingsContext)return;const context=cameraSettingsContext,generation=++cameraSettingsGeneration;
context.settings=null;context.quality=$('#camera-settings-quality').value;
$('#camera-settings-status').textContent='正在读取设备实际参数…';
$$('#camera-settings-dialog form input,#camera-settings-dialog form select,#camera-settings-dialog form button[type=submit]').forEach(b=>b.disabled=true);$('#camera-settings-reload').disabled=true;
try{const result=await api('/api/camera/settings?'+new URLSearchParams({camera:context.id,quality:context.quality}));
if(generation!==cameraSettingsGeneration||context!==cameraSettingsContext||!loggedIn)return;context.settings=result;renderCameraSettings(result);
}catch(e){if(!e.stale&&generation===cameraSettingsGeneration)$('#camera-settings-status').textContent=e.message;}
finally{if(generation===cameraSettingsGeneration)$('#camera-settings-reload').disabled=false;}
}
async function openCameraSettings(id){
if(!isAdmin())return;clearCameraSettings();cameraSettingsContext={id,settings:null,quality:'main'};
$('#camera-settings-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像机')+' · 参数设置';$('#camera-settings-quality').value='main';$('#camera-settings-quality').disabled=false;
$('#camera-settings-dialog').showModal();await loadCameraSettings();
}
function changedCameraValues(form,section,settings){
const rules=section==='imaging'?settings.imaging:settings.video.options,values={};
for(const [key,rule]of Object.entries(rules)){
const value=rule.choices?form.elements[key].value:Number(form.elements[key].value);
if(value!==(section==='imaging'?rule.value:settings.video.values[key]))values[key]=value;
}
return values;
}
async function saveCameraSettings(form,section){
const context=cameraSettingsContext;if(!context?.settings)return;
const values=changedCameraValues(form,section,context.settings);if(!Object.keys(values).length){toast('参数没有变化');return;}
const generation=++cameraSettingsGeneration;
$$('#camera-settings-dialog form input,#camera-settings-dialog form select,#camera-settings-dialog form button[type=submit]').forEach(b=>b.disabled=true);$('#camera-settings-reload').disabled=true;$('#camera-settings-quality').disabled=true;
$('#camera-settings-status').textContent='正在保存并读取设备返回值…';
try{const result=await api('/api/camera/settings',{camera:context.id,quality:context.quality,revision:context.settings.revision,section,values});
if(generation!==cameraSettingsGeneration||context!==cameraSettingsContext||!loggedIn)return;
context.settings=result.settings;if(result.settings)renderCameraSettings(result.settings);$('#camera-settings-status').textContent=result.message;toast(result.message);
}catch(e){if(!e.stale&&generation===cameraSettingsGeneration){context.settings=null;$('#camera-settings-status').textContent=e.message+'。请点击“重新读取”核对设备状态。';}}
finally{if(generation===cameraSettingsGeneration){$('#camera-settings-reload').disabled=false;$('#camera-settings-quality').disabled=false;}}
}
for(const [id,section]of [['camera-image-form','imaging'],['camera-video-form','video']])$('#'+id).addEventListener('submit',e=>{e.preventDefault();saveCameraSettings(e.currentTarget,section);});
$('#camera-settings-reload').onclick=loadCameraSettings;$('#camera-settings-quality').onchange=loadCameraSettings;
$('#camera-settings-dialog').addEventListener('close',clearCameraSettings);
document.addEventListener('click',e=>{const b=e.target.closest('[data-camera-settings]');if(b)openCameraSettings(b.dataset.cameraSettings);});
+3 -3
View File
@@ -20,11 +20,11 @@ function initDateFields(scope=$('#person-form')){
for(const field of scope.querySelectorAll('[data-date-field]')){ for(const field of scope.querySelectorAll('[data-date-field]')){
field.querySelector('[data-calendar]').value='solar';field.querySelector('[data-date-leap]').checked=false; field.querySelector('[data-calendar]').value='solar';field.querySelector('[data-date-leap]').checked=false;
field.querySelector('[data-date-value]').value=field.querySelector('input[type=hidden]').value;dateHint(field); field.querySelector('[data-date-value]').value=field.querySelector('input[type=hidden]').value;dateHint(field);
}syncLifeStatus(); }if(scope.id==='person-form')syncLifeStatus();
} }
function syncDateFields(strict=false,scope=$('#person-form')){ function syncDateFields(strict=false,scope=$('#person-form')){
syncLifeStatus();for(const field of scope.querySelectorAll('[data-date-field]'))if(!field.hidden)dateHint(field,strict); if(scope.id==='person-form')syncLifeStatus();for(const field of scope.querySelectorAll('[data-date-field]'))if(!field.hidden)dateHint(field,strict);
if($('[data-date-field="deathDate"]').hidden)$('#person-form').elements.deathDate.value=''; if(scope.id==='person-form'&&$('[data-date-field="deathDate"]').hidden)scope.elements.deathDate.value='';
} }
for(const field of $$('[data-date-field]')){ for(const field of $$('[data-date-field]')){
field.querySelector('[data-date-value]').addEventListener('input',()=>dateHint(field)); field.querySelector('[data-date-value]').addEventListener('input',()=>dateHint(field));
+7 -5
View File
@@ -35,7 +35,7 @@ function openFamilyEvent(id){
const e=(family.events||[]).find(e=>e.id===id);if(!e)return; const e=(family.events||[]).find(e=>e.id===id);if(!e)return;
// All content is plain text. External reports are links, never embedded HTML. // All content is plain text. External reports are links, never embedded HTML.
const safeSource=/^https?:\/\//i.test(e.sourceUrl||'')?e.sourceUrl:''; const safeSource=/^https?:\/\//i.test(e.sourceUrl||'')?e.sourceUrl:'';
$('#event-detail-content').innerHTML=`<div class="event-detail-meta"><time>${esc(ZhaoCalendar.describe(e.eventDate))}</time><span class="badge">${esc(eventStatus(e))}</span></div><h2 id="event-detail-title">${esc(e.title)}</h2>${e.summary?`<p class="event-lead">${esc(e.summary)}</p>`:''}<div class="event-associated"><span class="description">关联人物</span>${e.personIds.length?e.personIds.map(id=>`<button class="quiet" data-event-person="${esc(id)}">${esc(personName(id))} ↗</button>`).join(''):'<span>全家记事</span>'}</div><div class="family-prose event-article">${esc(e.body||'此条记事暂未填写详细正文。')}</div>${safeSource?`<a class="button secondary event-source" href="${esc(safeSource)}" target="_blank" rel="noopener noreferrer">阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗</a>`:e.sourceName?`<p class="description">资料来源:${esc(e.sourceName)}</p>`:''}<p class="description event-updated">最近整理:${esc(new Date(e.updatedAt).toLocaleString())}</p>${family.canEdit?`<button data-edit-event="${e.id}">编辑这条记事</button>`:''}`; $('#event-detail-content').innerHTML=`<div class="event-detail-meta"><time>${esc(ZhaoCalendar.describe(e.eventDate))}</time><span class="badge">${esc(eventStatus(e))}</span></div><h2 id="event-detail-title">${esc(e.title)}</h2>${e.summary?`<p class="event-lead">${esc(e.summary)}</p>`:''}<div class="event-associated"><span class="description">关联人物</span>${e.personIds.length?e.personIds.map(id=>`<button class="quiet" data-event-person="${esc(id)}">${esc(personName(id))} ↗</button>`).join(''):'<span>全家记事</span>'}</div><div class="family-prose event-article">${esc(e.body||'此条记事暂未填写详细正文。')}</div>${safeSource?`<a class="button secondary event-source" href="${esc(safeSource)}" target="_blank" rel="noopener noreferrer">阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗</a>`:e.sourceName?`<p class="description">资料来源:${esc(e.sourceName)}</p>`:''}<p class="description event-updated">最近整理:${esc(new Date(e.updatedAt).toLocaleString())}</p>${family.canEdit?`<button data-edit-event="${e.id}">编辑这条记事</button><button class="quiet" data-history-kind="event" data-history-id="${e.id}">修改记录</button>`:''}`;
$('#event-detail-dialog').showModal(); $('#event-detail-dialog').showModal();
} }
function editFamilyEvent(id,personId=''){ function editFamilyEvent(id,personId=''){
@@ -45,15 +45,17 @@ function editFamilyEvent(id,personId=''){
$('#event-editor-title').textContent=e?'编辑家族记事':'写一则家族记事'; $('#event-editor-title').textContent=e?'编辑家族记事':'写一则家族记事';
const selected=new Set(e?.personIds||(personId?[personId]:[])); const selected=new Set(e?.personIds||(personId?[personId]:[]));
$('#event-person-choices').innerHTML=family.people.map(p=>`<label class="check"><input type="checkbox" name="personIds" value="${esc(p.id)}" ${selected.has(p.id)?'checked':''}>${esc(p.name)}${p.archived?'(已归档)':''}</label>`).join('')||'<p class="description">家谱尚无人名,此条记事将记为全家记事。</p>'; $('#event-person-choices').innerHTML=family.people.map(p=>`<label class="check"><input type="checkbox" name="personIds" value="${esc(p.id)}" ${selected.has(p.id)?'checked':''}>${esc(p.name)}${p.archived?'(已归档)':''}</label>`).join('')||'<p class="description">家谱尚无人名,此条记事将记为全家记事。</p>';
$('#event-person-search').value='';f.querySelector('[value=published]').textContent=e?.status==='published'?'保存已发布内容':'发布记事'; $('#event-person-search').value='';f.dataset.status=e?.status||'draft';
f.querySelector('[data-save-event]').value=f.dataset.status;f.querySelector('[data-save-event]').textContent=e?'保存修改':'保存草稿';
f.querySelector('[data-publish-event]').hidden=f.dataset.status==='published';f.querySelector('[data-withdraw-event]').hidden=f.dataset.status!=='published';
initDateFields(f);if($('#event-detail-dialog').open)$('#event-detail-dialog').close();$('#event-dialog').showModal(); initDateFields(f);if($('#event-detail-dialog').open)$('#event-detail-dialog').close();$('#event-dialog').showModal();
} }
$('#event-form').addEventListener('submit',async e=>{ $('#event-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,buttons=[...f.querySelectorAll('[type=submit]')];buttons.forEach(b=>b.disabled=true); e.preventDefault();const f=e.currentTarget,buttons=[...f.querySelectorAll('[type=submit]')];buttons.forEach(b=>b.disabled=true);
try{syncDateFields(true,f);const data=formValues(f);data.revision=Number(data.revision);data.personIds=[...f.querySelectorAll('[name=personIds]:checked')].map(el=>el.value);data.archived=f.elements.archived.checked;data.status=e.submitter?.value||'draft'; try{syncDateFields(true,f);const data=formValues(f);data.revision=Number(data.revision);data.personIds=[...f.querySelectorAll('[name=personIds]:checked')].map(el=>el.value);data.archived=f.elements.archived.checked;data.status=e.submitter?.value||f.dataset.status||'draft';
const result=await api('/api/family/event',data);$('#event-dialog').close();await loadFamily();familyView='timeline';renderFamily(); const result=await api('/api/family/event',data);$('#event-dialog').close();if(!await loadFamily()){if(loggedIn)toast('记事已保存,读取最新资料失败,请重新载入');return;}familyView='timeline';renderFamily();
$('#family-event-person').value='';$('#family-event-year').value='';$('#family-event-search').value='';$('#family-event-state').value=data.archived?'archived':data.status;renderFamilyEvents();openFamilyEvent(result.item.id);toast(data.archived?'记事已归档,可在“已归档”中恢复':data.status==='published'?'记事已发布到家族时间线':'草稿已保存'); $('#family-event-person').value='';$('#family-event-year').value='';$('#family-event-search').value='';$('#family-event-state').value=data.archived?'archived':data.status;renderFamilyEvents();openFamilyEvent(result.item.id);toast(data.archived?'记事已归档,可在“已归档”中恢复':data.status==='published'?'记事已发布到家族时间线':'草稿已保存');
}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{buttons.forEach(b=>b.disabled=false);} }catch(err){familyFormError(f,err);}finally{buttons.forEach(b=>b.disabled=false);}
}); });
$('#family-view-timeline').onclick=()=>{familyView='timeline';renderFamily();}; $('#family-view-timeline').onclick=()=>{familyView='timeline';renderFamily();};
$('#family-new-event').onclick=()=>editFamilyEvent(); $('#family-new-event').onclick=()=>editFamilyEvent();
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const historyFields={name:'姓名',alias:'别名',gender:'性别',lifeStatus:'在世情况',birthDate:'出生日期',deathDate:'去世日期',birthplace:'籍贯',biography:'生平',note:'备注',archived:'归档',fromId:'关系一方',toId:'关系另一方',kind:'关系类型',lineage:'亲子属性',active:'有效关系',title:'记事标题',eventDate:'发生日期',summary:'摘要',body:'正文',personIds:'关联人物',status:'发布状态',sourceName:'资料来源',sourceUrl:'报道链接'};
let historyContext=null,historySelection=null,historyGeneration=0,historyDetailGeneration=0;
function clearFamilyHistory(){historyGeneration++;historyDetailGeneration++;historyContext=null;historySelection=null;for(const id of ['history-list','history-detail','history-error'])$('#'+id).replaceChildren();}
function historyValue(key,value){
if(value===undefined||value===null||value==='')return '未填写';
if(key==='personIds')return value.length?value.map(personName).join('、'):'全家';
if(key==='fromId'||key==='toId')return personName(value);
if(typeof value==='boolean')return value?'是':'否';
const labels={male:'男',female:'女',unknown:'待确认',alive:'在世',deceased:'已故',draft:'草稿',published:'已发布',parent:'父母 → 子女',spouse:'配偶',biological:'亲生',adoptive:'收养',step:'继亲',unspecified:'未注明'};
return ['gender','lifeStatus','status','kind','lineage'].includes(key)?labels[value]||String(value):String(value);
}
async function openFamilyHistory(kind,id){
if(!family.canEdit)return;clearFamilyHistory();
if($('#event-detail-dialog').open)$('#event-detail-dialog').close();
historyContext={kind,id,revision:family.revision,nextBefore:null};
$('#history-title').textContent='修改记录 · '+({person:personName(id),link:'亲属关系',event:family.events.find(e=>e.id===id)?.title||'家族记事'})[kind];
$('#history-dialog').showModal();await loadFamilyHistory();
}
async function loadFamilyHistory(more=false){
if(!historyContext)return;const context=historyContext,generation=++historyGeneration,b=$('#history-more');b.disabled=true;
try{const result=await api('/api/family/history?'+new URLSearchParams({kind:context.kind,id:context.id,...(more&&context.nextBefore?{before:context.nextBefore}:{})}));
if(!loggedIn||generation!==historyGeneration||context!==historyContext)return;
context.nextBefore=result.nextBefore;if(!more)context.revision=result.revision;
const html=result.items.map(c=>`<button class="history-entry quiet" data-history-change="${c.revision}"><strong>版本 ${c.revision} · ${c.restoredFrom?'恢复旧版':c.created?'创建记录':'修改记录'}</strong><span>${esc(c.actor)} · ${esc(new Date(c.at).toLocaleString())}</span><small>${esc(c.fields.map(f=>historyFields[f]||f).join('、')||'内容未变化')}</small></button>`).join('');
if(more)$('#history-list').insertAdjacentHTML('beforeend',html);else $('#history-list').innerHTML=html||'<p class="description">暂无修改记录。此功能从本次升级开始记录;升级前的内容会在下一次修改时保留。</p>';
b.hidden=!context.nextBefore;$('#history-error').textContent='';
}catch(e){if(!e.stale&&generation===historyGeneration)$('#history-error').textContent=e.message;}finally{if(generation===historyGeneration)b.disabled=false;}
}
async function showFamilyChange(revision){
const context=historyContext,generation=++historyDetailGeneration;historySelection=null;$('#history-detail').textContent='正在读取版本…';
try{const c=await api('/api/family/change?revision='+revision);
if(!loggedIn||generation!==historyDetailGeneration||context!==historyContext)return;
historySelection=c;
const keys=Object.keys(historyFields).filter(k=>k in c.after||k in (c.before||{}));
$('#history-detail').innerHTML=`<h3>版本 ${c.revision} 的内容对照</h3><p class="description">恢复只更新这条记录。已发生的后续修改仍可在历史中查阅。</p><div class="history-columns">${['before','after'].map(side=>`<section><h4>${side==='before'?'本次修改前':'本次修改后'}</h4>${c[side]?`<dl>${keys.map(k=>`<div class="${JSON.stringify(c.before?.[k])!==JSON.stringify(c.after[k])?'history-changed':''}"><dt>${esc(historyFields[k])}</dt><dd>${esc(historyValue(k,c[side][k]))}</dd></div>`).join('')}</dl><button class="secondary" data-history-prepare="${side}">选择恢复${side==='before'?'修改前':'此版本'}</button>`:'<p>创建前没有记录。</p>'}</section>`).join('')}</div><div id="history-confirm" hidden></div>`;
$('#history-error').textContent='';
}catch(e){if(!e.stale&&generation===historyDetailGeneration)$('#history-detail').textContent=e.message;}
}
function prepareFamilyRestore(side){
const c=historySelection;if(!c?.[side])return;const value=c[side],box=$('#history-confirm');
box.hidden=false;box.innerHTML=`<p>将用版本 ${c.revision} ${side==='before'?'修改前':'修改后'}的内容替换这条记录的当前内容。${c.kind==='event'?'恢复后记事状态:'+esc(eventStatus(value))+'。':''}</p><button data-history-restore="${side}">确认恢复并生成新版本</button><button class="quiet" id="history-cancel-restore">取消恢复</button>`;box.scrollIntoView({block:'nearest'});
}
async function restoreFamilyChange(side){
const context=historyContext,c=historySelection;if(!context||!c?.[side])return;
const generation=historyDetailGeneration;$$('#history-confirm button').forEach(b=>b.disabled=true);
try{await api('/api/family/restore',{changeRevision:c.revision,side,revision:context.revision});
if(!loggedIn||context!==historyContext)return;
historyDetailGeneration++;historySelection=null;$('#history-detail').replaceChildren();
await loadFamilyHistory();const loaded=await loadFamily();if(loggedIn)toast(loaded?'已恢复,原有版本仍保留在修改记录中':'已恢复,读取最新家谱失败,请重新载入');
}catch(e){if(!e.stale&&context===historyContext){$('#history-error').textContent=e.message;if(e.status===409)$('#history-error').append(document.createTextNode('。请关闭并重新打开修改记录,核对当前内容后再选择恢复。'));}}
finally{if(generation===historyDetailGeneration)$$('#history-confirm button').forEach(b=>b.disabled=false);}
}
$('#history-more').onclick=()=>loadFamilyHistory(true);
$('#history-dialog').addEventListener('close',clearFamilyHistory);
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
if(b.dataset.historyKind)openFamilyHistory(b.dataset.historyKind,b.dataset.historyId);
if(b.dataset.historyChange)showFamilyChange(Number(b.dataset.historyChange));
if(b.dataset.historyPrepare)prepareFamilyRestore(b.dataset.historyPrepare);
if(b.dataset.historyRestore)restoreFamilyChange(b.dataset.historyRestore);
if(b.id==='history-cancel-restore')$('#history-confirm').hidden=true;
});
+12
View File
@@ -6,3 +6,15 @@
@media(max-width:600px){.family-event-intro{flex-wrap:wrap;padding:8px 15px 15px}.family-event-filters{padding:15px;gap:10px}.family-event-filters label{font-size:11px}.family-event-filters .check{line-height:1.8;flex-wrap:nowrap}.family-timeline{padding:0 15px 22px}.timeline-event{gap:8px}.timeline-event>time{font-size:10px;flex-basis:55px}.timeline-event:before{left:62px}.timeline-event:after{left:58px}.timeline-event-body{padding:13px;margin-left:9px}.timeline-event-body>strong{font-size:15px}.family-view-tabs button{padding:8px 10px}#event-detail-title{font-size:22px}.event-detail-meta{flex-wrap:wrap}.event-editor-actions{flex-wrap:wrap}} @media(max-width:600px){.family-event-intro{flex-wrap:wrap;padding:8px 15px 15px}.family-event-filters{padding:15px;gap:10px}.family-event-filters label{font-size:11px}.family-event-filters .check{line-height:1.8;flex-wrap:nowrap}.family-timeline{padding:0 15px 22px}.timeline-event{gap:8px}.timeline-event>time{font-size:10px;flex-basis:55px}.timeline-event:before{left:62px}.timeline-event:after{left:58px}.timeline-event-body{padding:13px;margin-left:9px}.timeline-event-body>strong{font-size:15px}.family-view-tabs button{padding:8px 10px}#event-detail-title{font-size:22px}.event-detail-meta{flex-wrap:wrap}.event-editor-actions{flex-wrap:wrap}}
.family-workspace.family-expanded{position:fixed;inset:0;z-index:8;} .family-workspace.family-expanded{position:fixed;inset:0;z-index:8;}
/* Versions remain readable on a small screen, including long family narratives. */
#history-dialog{width:min(1040px,94vw)}
#history-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;max-height:260px;overflow:auto;margin:16px 0}
.history-entry{text-align:left;display:flex;flex-direction:column;align-items:flex-start;gap:6px;white-space:normal}
.history-entry small,.history-entry span{color:var(--muted);font-size:12px}
.history-columns{display:grid;grid-template-columns:1fr 1fr;gap:20px}
.history-columns section{min-width:0}.history-columns dd{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto}
.history-columns dl>div{padding:10px;border-bottom:1px solid #30434a}.history-changed{background:#27443e66;border-radius:8px}
#history-confirm{padding:16px;border:1px solid #74d0b7;border-radius:12px;margin-top:18px}
#history-confirm button{margin:6px}#history-error:empty{display:none}
@media(max-width:650px){.history-columns{grid-template-columns:1fr}#history-list{max-height:200px}}
+2 -2
View File
@@ -15,7 +15,7 @@ class FamilyMap {
reset(){this.key='';this.data=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';} reset(){this.key='';this.data=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
render(data) { render(data) {
this.data=data; this.data=data;
const key=JSON.stringify([data.people,data.links,data.showArchived]); const key=JSON.stringify([data.people,data.links,data.showArchived,data.selfId]);
if(key!==this.key) { if(key!==this.key) {
this.key=key;this.graph=ZhaoFamilyGraph.layout(data.people,data.links,data); this.key=key;this.graph=ZhaoFamilyGraph.layout(data.people,data.links,data);
const g=this.graph; const g=this.graph;
@@ -30,7 +30,7 @@ class FamilyMap {
this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{ this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{
const p=byId.get(b.dataset.mapPerson),self=p.id===data.selfId,selected=p.id===data.focusId; const p=byId.get(b.dataset.mapPerson),self=p.id===data.selfId,selected=p.id===data.focusId;
const matched=data.term&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(data.term); const matched=data.term&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(data.term);
const relation=ZhaoKinship.describe(data.people,data.links,data.reference,p.id); const relation=p.id===data.reference&&!self?'参照人物':ZhaoKinship.describe(data.people,data.links,data.reference,p.id);
b.classList.toggle('self',self);b.classList.toggle('selected',selected);b.classList.toggle('search-hit',!!matched);b.classList.toggle('archived',!!p.archived); b.classList.toggle('self',self);b.classList.toggle('selected',selected);b.classList.toggle('search-hit',!!matched);b.classList.toggle('archived',!!p.archived);
b.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案'); b.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案');
b.title=p.name+' · '+relation+(p.archived?' · 已归档':''); b.title=p.name+' · '+relation+(p.archived?' · 已归档':'');
+17 -8
View File
@@ -5,18 +5,27 @@ let family={people:[],links:[],events:[],revision:0,canEdit:false}, users=[], fo
const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id)); const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id));
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事']; const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join(''); $('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
function clearFamilyState(){closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;} function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamilyHistory();closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';} function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');} function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}} function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
function familyFormError(form,error){
if(error.stale)return;
const box=form.querySelector('.form-error');box.textContent=error.message;
if(error.status!==409||!error.message.startsWith('家谱已被更新'))return;
box.append(document.createTextNode('。本次输入仍保留,请先复制需要保留的内容,再读取最新版本。'));
const button=document.createElement('button');button.type='button';button.className='quiet';button.textContent='读取最新版本(覆盖本次输入)';
button.onclick=async()=>{button.disabled=true;try{if(await loadFamily()){const id=form.elements.id.value;form.closest('dialog').close();if(form.id==='person-form')openPerson(id);else if(form.id==='relation-form')openRelation(id);else editFamilyEvent(id);}}finally{button.disabled=false;}};
box.append(button);
}
async function loadFamily(resetView=false){ async function loadFamily(resetView=false){
const generation=++familyGeneration; const generation=++familyGeneration;
try{const result=await api('/api/family');if(!loggedIn||generation!==familyGeneration)return;family=result; try{const result=await api('/api/family');if(!loggedIn||generation!==familyGeneration)return;family=result;
if(resetView===true||!family.people.some(p=>p.id===focusPerson))focusPerson=family.selfId||family.people.find(p=>!p.archived)?.id||''; if(resetView===true||!family.people.some(p=>p.id===focusPerson))focusPerson=family.selfId||family.people.find(p=>!p.archived)?.id||'';
setOptions($('#family-reference'),peopleOptions(false));if(!$('#family-reference').value&&family.selfId)$('#family-reference').value=family.selfId; setOptions($('#family-reference'),peopleOptions(false));if(!$('#family-reference').value&&family.selfId)$('#family-reference').value=family.selfId;
if(!$('#family-reference').dataset.initialized&&family.selfId){$('#family-reference').value=family.selfId;$('#family-reference').dataset.initialized='true';} if(!$('#family-reference').dataset.initialized&&family.selfId){$('#family-reference').value=family.selfId;$('#family-reference').dataset.initialized='true';}
$('#family-error').textContent='';if(resetView===true){familyView='tree';$('#family-search').value='';}renderFamily();if(resetView===true)familyMap.fit(); $('#family-error').textContent='';if(resetView===true){familyView='tree';$('#family-search').value='';}renderFamily();if(resetView===true)familyMap.fit();return true;
}catch(e){$('#family-error').textContent=e.message;} }catch(e){if(!e.stale&&loggedIn&&generation===familyGeneration)$('#family-error').textContent=e.message;return false;}
} }
function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');} function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');}
function renderFamily(){ function renderFamily(){
@@ -25,13 +34,13 @@ function renderFamily(){
$('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'+(term?' · 找到 '+people.length+' 位':''); $('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'+(term?' · 找到 '+people.length+' 位':'');
$('#family-find').hidden=!term;$('#family-find').disabled=!people.length; $('#family-find').hidden=!term;$('#family-find').disabled=!people.length;
$('#new-person').hidden=!family.canEdit; $('#new-person').hidden=!family.canEdit;
$('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''} ${p.id===family.selfId?'self':''}" data-person="${p.id}" aria-pressed="${p.id===focusPerson}"><strong>${esc(p.name)} ${p.id===family.selfId?'<span class="self-badge">本人</span>':''}</strong><span>${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。'); $('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''} ${p.id===family.selfId?'self':''}" data-person="${p.id}" aria-pressed="${p.id===focusPerson}"><strong>${esc(p.name)} ${p.id===family.selfId?'<span class="self-badge">本人</span>':''}</strong><span>${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。');
renderFamilyTree(); renderFamilyTree();
const p=family.people.find(p=>p.id===focusPerson); const p=family.people.find(p=>p.id===focusPerson);
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p); $('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;} if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id); const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`; $('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button><button class="quiet" data-history-kind="person" data-history-id="${p.id}">修改记录</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button><button class="quiet" data-history-kind="link" data-history-id="${e.id}">历史</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id)); if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`); $('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
} }
@@ -44,15 +53,15 @@ function renderFamilyTree(){
$('#family-view-tree').setAttribute('aria-pressed',String(familyView==='tree'));$('#family-view-list').setAttribute('aria-pressed',String(familyView==='list')); $('#family-view-tree').setAttribute('aria-pressed',String(familyView==='tree'));$('#family-view-list').setAttribute('aria-pressed',String(familyView==='list'));
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked}); familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
} }
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate)familyView='tree';renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});} function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});initDateFields();$('#person-dialog').showModal();} function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});initDateFields();$('#person-dialog').showModal();}
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();} function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();}
for(const [name,path]of [['person','person'],['relation','link']]){ for(const [name,path]of [['person','person'],['relation','link']]){
$('#'+name+'-form').addEventListener('submit',async e=>{ $('#'+name+'-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true; e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{const d=formValues(f);d.revision=Number(d.revision);if(name==='person'){syncDateFields(true);Object.assign(d,formValues(f));d.revision=Number(d.revision);d.archived=f.elements.archived.checked;}else d.active=f.elements.active.checked; try{const d=formValues(f);d.revision=Number(d.revision);if(name==='person'){syncDateFields(true);Object.assign(d,formValues(f));d.revision=Number(d.revision);d.archived=f.elements.archived.checked;}else d.active=f.elements.active.checked;
const result=await api('/api/family/'+path,d);if(name==='person')focusPerson=result.item.id;f.closest('dialog').close();await loadFamily();toast('家谱已保存'); const result=await api('/api/family/'+path,d);if(name==='person')focusPerson=result.item.id;f.closest('dialog').close();const loaded=await loadFamily();if(loggedIn)toast(loaded?'家谱已保存':'家谱已保存,读取最新资料失败,请重新载入');
}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;} }catch(err){familyFormError(f,err);}finally{b.disabled=false;}
}); });
} }
async function loadUsers(){ async function loadUsers(){
+11 -4
View File
@@ -4,6 +4,9 @@
<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="#10191d"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.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="/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">
@@ -19,20 +22,20 @@
<button data-page="storage">▤ <span>录像与存储</span></button> <button data-page="storage">▤ <span>录像与存储</span></button>
</nav> </nav>
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div> <div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
<p id="current-user" class="description"></p><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><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>
<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>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><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>PLAYBACK</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>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-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>CAMERA ASSETS</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
@@ -72,8 +75,12 @@
<datalist id="relationship-choices"></datalist> <datalist id="relationship-choices"></datalist>
<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" value="draft" class="secondary">保存草稿</button><button type="submit" value="published">发布记事</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">FAMILY CHRONICLE</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="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>
<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);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=(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);
+15
View File
@@ -11,3 +11,18 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
.brand-icon{width:44px;height:44px;object-fit:cover;border-radius:12px;flex-shrink:0}.wall-toolbar{display:flex;align-items:center;gap:15px;flex-wrap:wrap;margin:16px 0}.wall-toolbar select{padding:8px}.wall-paging{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12px}#wall-clock{margin-left:auto;color:var(--muted);font-size:12px}.live-grid[data-layout="1"]{grid-template-columns:1fr}.live-grid[data-layout="2"],.live-grid[data-layout="4"]{grid-template-columns:repeat(2,minmax(0,1fr))}.live-grid[data-layout="6"],.live-grid[data-layout="9"]{grid-template-columns:repeat(3,minmax(0,1fr))}.live-grid[data-layout="16"]{grid-template-columns:repeat(4,minmax(0,1fr))}.wall-reorder{display:none;padding:8px 12px;align-items:center;gap:8px}.arranging .wall-reorder{display:flex}.wall-reorder span{cursor:grab;margin-right:auto;font-size:12px;color:var(--muted)}.wall-reorder button{padding:4px 9px;font-size:11px}#page-live:fullscreen{background:#070f12;padding:12px;width:100vw;height:100dvh;display:flex;flex-direction:column;overflow:hidden}#page-live:fullscreen>.section-head,#page-live:fullscreen>#quality-note{display:none}#page-live:fullscreen .wall-toolbar{margin:0 0 10px;flex-shrink:0}#page-live:fullscreen .live-grid{flex:1;min-height:0;gap:8px;grid-template-rows:repeat(2,minmax(0,1fr))}#page-live:fullscreen .live-grid[data-layout="1"],#page-live:fullscreen .live-grid[data-layout="2"]{grid-template-rows:1fr}#page-live:fullscreen .live-grid[data-layout="9"]{grid-template-rows:repeat(3,minmax(0,1fr))}#page-live:fullscreen .live-grid[data-layout="16"]{grid-template-rows:repeat(4,minmax(0,1fr))}#page-live:fullscreen .camera-card{display:flex;flex-direction:column;min-height:0;position:relative}#page-live:fullscreen .video-panel{flex:1;min-height:0;aspect-ratio:auto}#page-live:fullscreen .camera-meta{padding:7px 10px;align-items:center}#page-live:fullscreen .camera-meta p,#page-live:fullscreen .camera-meta .badge{display:none}#page-live:fullscreen .camera-meta strong{font-size:12px}#page-live:fullscreen .camera-actions{position:absolute;bottom:34px;right:6px;padding:4px;background:#10191ddd;border-radius:6px;opacity:0}#page-live:fullscreen .camera-card:hover .camera-actions,#page-live:fullscreen .camera-card:focus-within .camera-actions{opacity:1}#page-live:fullscreen .camera-actions [data-edit-camera],#page-live:fullscreen .camera-actions [data-play-camera]{display:none}.ptz-panel{background:#1a3038;border:1px solid #41656c;border-radius:12px;padding:16px;display:flex;align-items:center;gap:22px;position:fixed;bottom:22px;right:22px;z-index:4;max-width:calc(100vw - 32px);box-shadow:0 12px 50px #0008}.ptz-panel p{font-size:12px;color:var(--muted);max-width:320px;margin:8px 0 0}.ptz-pad{display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,40px);gap:4px}.ptz-pad button{padding:5px;font-size:17px}.ptz-pad [data-ptz-action="up"]{grid-column:2}.ptz-pad [data-ptz-action="left"]{grid-column:1;grid-row:2}.ptz-pad [data-ptz-action="stop"]{grid-column:2;grid-row:2;background:#f0bda6;font-size:12px}.ptz-pad [data-ptz-action="right"]{grid-column:3;grid-row:2}.ptz-pad [data-ptz-action="down"]{grid-column:2;grid-row:3}.family-date{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0;min-width:0}.family-date legend{font-size:13px;color:#a5bec1}.family-date label{margin-bottom:10px}.family-date output{display:block;font-size:12px;line-height:1.7;color:var(--accent)}@media(max-width:760px){.wall-toolbar{gap:9px}#wall-clock{display:none}.camera-meta strong{font-size:12px}.camera-actions{flex-wrap:wrap;padding:0 8px 8px}.camera-meta{padding:9px}.ptz-panel{flex-wrap:wrap;gap:12px}.ptz-panel>div:first-child{flex:1;min-width:160px}.family-date{grid-column:1/-1}#page-live:fullscreen .camera-actions{opacity:1}.live-grid[data-layout="16"] .camera-meta strong{font-size:9px}} .brand-icon{width:44px;height:44px;object-fit:cover;border-radius:12px;flex-shrink:0}.wall-toolbar{display:flex;align-items:center;gap:15px;flex-wrap:wrap;margin:16px 0}.wall-toolbar select{padding:8px}.wall-paging{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12px}#wall-clock{margin-left:auto;color:var(--muted);font-size:12px}.live-grid[data-layout="1"]{grid-template-columns:1fr}.live-grid[data-layout="2"],.live-grid[data-layout="4"]{grid-template-columns:repeat(2,minmax(0,1fr))}.live-grid[data-layout="6"],.live-grid[data-layout="9"]{grid-template-columns:repeat(3,minmax(0,1fr))}.live-grid[data-layout="16"]{grid-template-columns:repeat(4,minmax(0,1fr))}.wall-reorder{display:none;padding:8px 12px;align-items:center;gap:8px}.arranging .wall-reorder{display:flex}.wall-reorder span{cursor:grab;margin-right:auto;font-size:12px;color:var(--muted)}.wall-reorder button{padding:4px 9px;font-size:11px}#page-live:fullscreen{background:#070f12;padding:12px;width:100vw;height:100dvh;display:flex;flex-direction:column;overflow:hidden}#page-live:fullscreen>.section-head,#page-live:fullscreen>#quality-note{display:none}#page-live:fullscreen .wall-toolbar{margin:0 0 10px;flex-shrink:0}#page-live:fullscreen .live-grid{flex:1;min-height:0;gap:8px;grid-template-rows:repeat(2,minmax(0,1fr))}#page-live:fullscreen .live-grid[data-layout="1"],#page-live:fullscreen .live-grid[data-layout="2"]{grid-template-rows:1fr}#page-live:fullscreen .live-grid[data-layout="9"]{grid-template-rows:repeat(3,minmax(0,1fr))}#page-live:fullscreen .live-grid[data-layout="16"]{grid-template-rows:repeat(4,minmax(0,1fr))}#page-live:fullscreen .camera-card{display:flex;flex-direction:column;min-height:0;position:relative}#page-live:fullscreen .video-panel{flex:1;min-height:0;aspect-ratio:auto}#page-live:fullscreen .camera-meta{padding:7px 10px;align-items:center}#page-live:fullscreen .camera-meta p,#page-live:fullscreen .camera-meta .badge{display:none}#page-live:fullscreen .camera-meta strong{font-size:12px}#page-live:fullscreen .camera-actions{position:absolute;bottom:34px;right:6px;padding:4px;background:#10191ddd;border-radius:6px;opacity:0}#page-live:fullscreen .camera-card:hover .camera-actions,#page-live:fullscreen .camera-card:focus-within .camera-actions{opacity:1}#page-live:fullscreen .camera-actions [data-edit-camera],#page-live:fullscreen .camera-actions [data-play-camera]{display:none}.ptz-panel{background:#1a3038;border:1px solid #41656c;border-radius:12px;padding:16px;display:flex;align-items:center;gap:22px;position:fixed;bottom:22px;right:22px;z-index:4;max-width:calc(100vw - 32px);box-shadow:0 12px 50px #0008}.ptz-panel p{font-size:12px;color:var(--muted);max-width:320px;margin:8px 0 0}.ptz-pad{display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,40px);gap:4px}.ptz-pad button{padding:5px;font-size:17px}.ptz-pad [data-ptz-action="up"]{grid-column:2}.ptz-pad [data-ptz-action="left"]{grid-column:1;grid-row:2}.ptz-pad [data-ptz-action="stop"]{grid-column:2;grid-row:2;background:#f0bda6;font-size:12px}.ptz-pad [data-ptz-action="right"]{grid-column:3;grid-row:2}.ptz-pad [data-ptz-action="down"]{grid-column:2;grid-row:3}.family-date{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0;min-width:0}.family-date legend{font-size:13px;color:#a5bec1}.family-date label{margin-bottom:10px}.family-date output{display:block;font-size:12px;line-height:1.7;color:var(--accent)}@media(max-width:760px){.wall-toolbar{gap:9px}#wall-clock{display:none}.camera-meta strong{font-size:12px}.camera-actions{flex-wrap:wrap;padding:0 8px 8px}.camera-meta{padding:9px}.ptz-panel{flex-wrap:wrap;gap:12px}.ptz-panel>div:first-child{flex:1;min-width:160px}.family-date{grid-column:1/-1}#page-live:fullscreen .camera-actions{opacity:1}.live-grid[data-layout="16"] .camera-meta strong{font-size:9px}}
#page-live:fullscreen .live-grid[data-layout="auto"]{grid-template-columns:repeat(3,minmax(0,1fr))} #page-live:fullscreen .live-grid[data-layout="auto"]{grid-template-columns:repeat(3,minmax(0,1fr))}
.wall-reorder [data-wall-drag]{touch-action:none;user-select:none;padding:5px 0}.camera-card.wall-drop-target{outline:2px solid var(--accent);outline-offset:-3px} .wall-reorder [data-wall-drag]{touch-action:none;user-select:none;padding:5px 0}.camera-card.wall-drop-target{outline:2px solid var(--accent);outline-offset:-3px}
#camera-settings-dialog{width:min(1060px,94vw)}
#camera-settings-info dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:18px 0 24px;padding:16px;border:1px solid var(--line);border-radius:10px}
#camera-settings-info dt{font-size:12px;color:var(--muted);margin-bottom:6px}
#camera-settings-info dd{font-size:13px;margin:0;overflow-wrap:anywhere}
#camera-image-fields,#camera-video-fields{grid-template-columns:repeat(3,minmax(0,1fr))}
#camera-settings-status:empty{display:none}
#camera-settings-dialog details{margin-top:20px;color:var(--muted);font-size:13px;line-height:1.8}
#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: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%}}