Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68be32e269 | |||
| 14f722fa69 | |||
| 96f0ef31fc | |||
| 56a24c80e2 |
@@ -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)。自动回归包含权限、并发、版本冲突、设备拒绝或未确认写入、停止、回放空档、日期边界和退出账号后的异步响应清理;模拟设备测试不代表所有品牌均已实机验证。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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__':
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 & 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()
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -5,15 +5,19 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'&
|
|||||||
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;}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(()=>{});});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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%}}
|
||||||
|
|||||||
Reference in New Issue
Block a user