Compare commits

..

4 Commits

29 changed files with 1786 additions and 148 deletions
+31 -5
View File
@@ -9,7 +9,9 @@
## 对象关系
```text
空间档案(家庭 / 公司 / 其他)
管理归属(家庭 / 办公场所 / 工厂,独立权限)
└─ 自定义区域树(parentId,不限制层数,禁止循环及跨归属父节点)
└─ 空间档案(名称、区域类别及可选地址)
├─ 地区:省、市、区县、街道
├─ 地址:小区或园区、楼栋、单元、楼层、门牌
├─ 所属:家庭或单位
@@ -17,6 +19,7 @@
│ ├─ 镜头通道 1 → 主码流 / 子码流
│ └─ 镜头通道 2 → 主码流 / 子码流
└─ 录像机对象(地址、接入驱动、账号、空间引用)
└─ 其他设备档案(灯、手机、冰箱或自定类型,暂不含控制驱动)
每个镜头通道分别关联:
实时来源:直接摄像头 / 录像机
@@ -27,11 +30,11 @@
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。摄像机参数与云台按设备开放的 ONVIF 能力设置,不自动导入不存在的通道,不把服务可达当成画面接通。
## 管理 API
所有数据接口(除会话状态与健康检查)需要管理员 Cookie 会话。写接口接受 JSON,检查同源请求。
所有数据接口(除会话状态与健康检查)需要账号 Cookie 会话,并依据家庭、角色和空间核验访问权限。写接口接受 JSON,检查同源请求。
| 接口 | 用途 |
| --- | --- |
@@ -53,14 +56,14 @@
本阶段实现独立运行、空间建模、三类视觉对象、实时视频接入和本机可选录像。摄像头真实接入结果由部署记录和主机运行状态说明。
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ 已支持方向控制、停止和预置位;语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。
配置写入 SQLite 并生成运行时媒体配置,录像数据独立存储。当前为单机模式;升级前备份整个数据目录。凭据和录像不包含在公开源码中。
## 身份与家谱
`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。家谱目前是同一部署的一份共享家庭档案,按账号授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。
`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。`households.py` 管理家庭归属、负责人、交接记录与请求范围。每个家庭有独立家谱与修订号,按账号另授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。
| API | 权限 |
| --- | --- |
@@ -72,3 +75,26 @@
| GET /media/live/*、/media/playback、/api/recordings | 登录并具备对应空间权限 |
人物与关系修订在同一数据库事务中保存;修改关系时迭代检查亲子图,拒绝循环。前端 `kinship.js` 通过最短关系路径提供阅读称呼,未知长幼与复杂旁系明确显示不确定性或关系链。家谱页面不启动摄像头播放;返回实时画面时重新建立所需流。
## 家庭边界与管理连续性
账号 → 一个管理归属(家庭 / 办公场所 / 工厂)→ 多级区域 → 设备对象 / 镜头。人物、亲属关系、记事和历史版本也属于同一管理归属。兼容已有接口,数据库仍使用 `households`、`familyId` 命名。
- `X-Household-Id` 仅供超级管理员选择工作家庭;其余账号只能使用自己的家庭。所有写操作均核验所属家庭与关联对象,不能通过 body 迁移归属。
- API 状态与账号列表按家庭过滤。媒体 URL 不依赖前端过滤,每个清单、分片与下载请求均核验摄像头归属和空间授权;超级管理员是明确的特权例外。
- 平台级扫描、存储策略、审计只允许超级管理员。重复接入已属于其他家庭的已知设备 IP 会被拒绝。
- `/api/households` 供超级管理员创建、家庭管理员修改本家庭名称和备注。`/api/households/leadership` 仅允许当前家主或超级管理员办理家主及备用负责人安排。
- 家主、备用负责人必须是本家庭已启用的家庭管理员且不能相同。备用负责人预先持有管理权限,意外发生不影响设备管理;身份交接需要有权限的账号明确操作、填写原因、检查修订号。
- 任内负责人不能被直接停用、降级;先交接,再调整账号。交接后原家主保留家庭管理员,必要时由新家主或超级管理员处理其账号。
- 家谱人物的去世日期、账号长期未登录、亲属称谓均不触发权限变化。
- 首次迁移只回填 `familyId`,不修改凭据或设备路径;旧历史快照也加归属。旧版本没有此隔离能力,多家庭运行后禁止只回退代码。
## 设备分类与区域授权
`inventory.py` 保存通用设备档案以及所有人、负责人、标签。视频设备继续使用原 `assets`、`recorders`、`cameras` 结构,不迁移凭据、不修改录像配置。前端 `device-model.js` 将三类档案统一为只读目录,按每个归属保存的维度顺序分组。修改分组只改变展示,不移动设备或放宽权限。
区域以 `parentId` 邻接表表示,创建/移动时在写锁内检查父节点归属、有效性与祖先循环。父链和后代集合均迭代计算,避免递归栈形成层数限制。用户的 `includeSubspaces` 默认关闭;开启后按当前区域树实时扩展 `siteIds`。修改该授权开关会撤销已有会话。人员归属是文本档案,不参与权限计算。
新增接口:`POST /api/devices` 保存通用设备(要求当前归属管理员);`POST /api/device-view` 保存当前归属默认分组顺序。统一状态的 `devices` 数组也经过归属和成员区域过滤。
+46 -4
View File
@@ -2,9 +2,14 @@
运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。
## 界面风格
v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点缀与淡金本人标记。统一实时监控、家谱树、家族记事、设备档案、表单和登录界面;全屏监控保留深色背景。样式集中在 `web/chinese-theme.css`,本地系统宋体用于标题,不请求外部字体。桌面和手机使用同一套功能与自适应布局。
## 对象与空间
- **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。
- **区域与空间**:各家庭、办公场所、工厂独立设置区域树,自定义名称、类别与上级区域,不设固定层数。地址可记录省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌。
- **统一设备管理**:摄像机、录像机与其他设备统一展示,支持按类型、区域、负责人、所有人、自定义标签调整分组顺序。灯、手机、冰箱等当前提供档案登记,实际控制需要另行接入协议。
- **摄像头对象**:实体设备的稳定 ID、品牌、型号、序列号、镜头数、安装位置和空间引用。
- **镜头通道**:每个物理镜头一个通道,关联摄像头对象,配置主、子码流。双镜头摄像头的主、子码流不能当作两个物理镜头。
- **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。
@@ -49,7 +54,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml
```
回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets test_households` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js test_households_ui.js test_devices.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 使用管理员与个人账号分权。
@@ -72,10 +77,10 @@ mediamtx ./data/mediamtx.yml
## 账号与家谱(v0.1.11)
- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置、网络发现和审计接口仅管理员可用。媒体播放请求逐次检查空间权限。
- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置归属本家庭管理员;网络发现、全局存储与审计仅超级管理员可用。媒体播放请求逐次检查空间权限。
- 每个账号具有独立用户名和密码,密码使用 scrypt 散列存储;更新密码、角色、空间范围、家谱权限或停用状态会撤销该账号会话。亲属关系不自动授予权限。
- 管理员在“账号与关系”中设置显示名称、相对于哪个账号的称呼,以及关联的家谱人物。支持常用称呼与自定义称呼。默认新个人账号没有空间或家谱权限。
- 家谱是本部署内的一份家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。未实现跨家庭租户隔离。
- 家谱是各家庭独立的家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。v0.1.23 起按家庭隔离人物、记事、历史版本与导出。
- 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。日期可按公历或农历录入,并自动换算;不确定日期可留空,在备注中保留原记载。
- 亲子关系按“父母一方 → 子女”逐条连接,支持亲生、收养、继亲和未注明;配偶关系为双向。禁止自我关系、重复关系、祖先循环、直系祖先与配偶冲突。关系可以停用后恢复,人物可归档后恢复。
- 支持关系参照人物、搜索、全家关系图、人物名册和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。
@@ -167,3 +172,40 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
### 实机验收完善(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)。自动回归包含权限、并发、版本冲突、设备拒绝或未确认写入、停止、回放空档、日期边界和退出账号后的异步响应清理;模拟设备测试不代表所有品牌均已实机验证。
## 家庭隔离与权限交接(v0.1.23)
- **超级管理员**(`admin` 角色):可建立家庭、切换家庭和管理全部资料;现有 `admin` 账号保持此角色。
- **一家之主**:本家庭一位已启用的家庭管理员,由超级管理员首次指定。可管理本家庭设备、成员及其他家庭管理员,并办理家主交接。
- **家庭管理员**:管理本家庭设备、空间、家谱与普通成员;有家主后,其他管理员账号的设置由家主或超级管理员办理。
- **家庭成员**:仅能查看本家庭已授权空间的画面与录像;家谱、云台另行授权。新账号默认不获得设备和家谱权限。亲属称呼不授予权限。
在「家庭与场所」新建家庭、办公场所或工厂,进入该家庭,在「账号与关系」建立家庭管理员。回到「家庭与场所 → 负责人安排与交接」,指定一家之主和另一位备用负责人,填写说明并保存。**备用负责人已经拥有本家庭管理权限**,家主意外无法操作时可以继续管理设备和成员;家主身份由原家主或超级管理员正式交接,原家主保留家庭管理员权限。不会依据去世日期或未登录时长自动授权。不允许停用或降级尚在任的家主、备用负责人;须先更换安排。至少保留一位启用的家庭管理员。交接使用修订号防止并发覆盖,并保留最近 50 条记录。普通成员不能读取交接说明。
家庭隔离由服务器执行:账号、空间、对象、镜头、录像机、家谱人物、关系、事件与历史版本均有家庭归属。HLS 播放清单、分片、回放、下载及设备控制均逐请求核验;修改请求检查每个关联对象的家庭。普通账号伪造家庭请求头或猜测其他家庭的对象编号也不能获得资料。超级管理员具有跨家庭权限。家庭切换先清除播放器、表单和旧请求;各家庭独立计算家谱修订号。
升级前用 SQLite backup API 备份数据库。首次启动把旧数据归入「赵府」家庭,保留原 ID、密码散列、设备连接和会话;家主与备用负责人须在界面明确指定,不从姓名或辈分猜测。已有 `admin` 角色均保留超级权限,请按实际职责设为家庭管理员。**建立第二个家庭后,不能直接回退到 v0.1.22 或更早版本**,旧版本不执行家庭隔离;应修复前进,或连同升级前数据库及部署版本一起恢复并确保后续数据另有备份。
这是单台受信主机上的应用权限隔离。各家庭的设备仍共享媒体服务和存储容量,家庭管理员不具有操作系统权限;尚未提供按家庭磁盘配额、独立网络、硬盘加密或无人审批的自动继承。
### 多维设备分类与区域树(v0.1.23)
- 在「家庭与场所」选择管理归属,再在「区域与空间」添加区域;选择上级区域即可继续细分,例如「厂区 → 车间 → 产线 → 工位」。名称、区域类别、地址按需填写,程序没有固定层数上限。不能把区域放到自身或后代下,也不能跨家庭/场所挂接。
- 在「设备管理」登记设备。摄像机与录像机保留已有接入、通道功能,其他设备使用可自定的类型;同一设备只存一份档案。所有人、负责人是责任信息,不会自动授予账号查看或控制权限。
- 调整「依次分组」,可保存「类型 → 区域」「区域 → 类型」「负责人 → 类型 → 区域」等顺序为当前归属的默认视图。支持五种维度自由组合:类型、区域、负责人、所有人、标签。区域维度按完整自定义层级展示;五种维度不限制区域本身层数。设备可在多个标签组出现,总设备数仍按档案计算。
- 区域筛选默认包含其后代。账号权限另行设置:默认只授权明确勾选的区域,可显式勾选「包含所有下级区域(含以后新增)」。服务器实时计算后代集合;移动区域会立即改变其继承的访问范围。跨家庭/场所隔离始终生效。
- 灯、手机、冰箱等目前支持档案、位置、责任归属及标签管理,不能通过登记档案直接控制硬件。需按设备实际协议接入控制功能。
+1 -1
View File
@@ -1 +1 @@
0.1.20
0.1.23
+91 -25
View File
@@ -31,6 +31,8 @@ import onvif
import playback
import identity
import genealogy
import households
import inventory
import ptz
ROOT = Path(__file__).resolve().parent
@@ -73,6 +75,8 @@ class Problem(Exception):
ACCOUNTS = identity.Accounts(sys.modules[__name__])
FAMILY = genealogy.Family(sys.modules[__name__])
HOUSEHOLDS = households.Households(sys.modules[__name__])
INVENTORY = inventory.Inventory(sys.modules[__name__])
def setting(key, default=None):
@@ -144,9 +148,9 @@ SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'o
def serialized_save(function):
@functools.wraps(function)
def save(body):
def save(body, *args, **kwargs):
with LOCK:
return function(body)
return function(body, *args, **kwargs)
return save
@@ -162,20 +166,30 @@ def object_key(table, body):
@serialized_save
def save_site(body):
def save_site(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'sites', body, (('sites', 'parentId'),))
key = object_key('sites', body)
parent = clean_text(body.get('parentId', ''), 32)
ancestor, seen = parent, {key}
while ancestor:
if ancestor in seen:
raise Problem('区域不能放到自己或自己的下级区域中')
seen.add(ancestor)
ancestor = (get_object('sites', ancestor) or {}).get('parentId', '')
item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100,
required=k == 'name') for k in SITE_FIELDS}
item.update(id=key, kind=body.get('kind', 'home'))
if item['kind'] not in ('home', 'company', 'other'):
raise Problem('请选择家庭、公司或其他用途')
item.update(id=key, kind=body.get('kind', 'home'), familyId=family, parentId=parent,
category=clean_text(body.get('category', ''), 40))
if item['kind'] not in ('home', 'company', 'office', 'factory', 'other'):
raise Problem('请选择有效区域用途')
save_object('sites', item)
audit('保存空间', item['name'])
return item
@serialized_save
def save_camera(body):
def save_camera(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'cameras', body, (('assets', 'assetId'), ('recorders', 'recorderId'), ('recorders', 'archiveRecorderId')))
key = object_key('cameras', body)
old = get_object('cameras', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
@@ -201,7 +215,8 @@ def save_camera(body):
raise Problem('关联的录像存储设备不存在')
item['archiveRecorderId'] = archive_recorder
item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')),
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535))
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535), familyId=family)
HOUSEHOLDS.endpoint(actor, item['host'])
for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]:
path = clean_text(body.get(k, default), 500, required=True)
if not path.startswith('/') or path.startswith('//') or '#' in path:
@@ -224,14 +239,16 @@ def save_camera(body):
@serialized_save
def save_asset(body):
def save_asset(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'assets', body, (('sites', 'siteId'),))
key = object_key('assets', body)
site = body.get('siteId')
if not get_object('sites', site):
raise Problem('请选择所属空间')
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'brand', 'model', 'serial', 'point', 'note')}
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16))
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16), familyId=family)
item.update(INVENTORY.metadata(body, get_object('assets', key)))
attached = [c for c in objects('cameras') if c.get('assetId') == key]
if len(attached) > item['lensCount']:
raise Problem('镜头数不能小于已经建立的通道数')
@@ -245,7 +262,8 @@ def save_asset(body):
@serialized_save
def save_recorder(body):
def save_recorder(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'recorders', body, (('sites', 'siteId'),))
key = object_key('recorders', body)
old = get_object('recorders', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
@@ -254,13 +272,15 @@ def save_recorder(body):
if not get_object('sites', site):
raise Problem('请选择录像机所属空间')
item.update(id=key, siteId=site, host=host_address(body.get('host', '')),
port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'))
port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'), familyId=family)
HOUSEHOLDS.endpoint(actor, item['host'])
if item['driver'] not in ('generic-rtsp', 'hikvision-rtsp', 'tplink-rtsp', 'dahua-rtsp'):
raise Problem('接入驱动不正确')
password = body.get('password', '')
if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password):
raise Problem('密码格式不正确')
item['password'] = password or old.get('password', '')
item.update(INVENTORY.metadata(body, old))
save_object('recorders', item)
write_media_config()
audit('保存录像机', item['name'])
@@ -403,7 +423,7 @@ def status():
storage = dict(STORAGE)
scan = dict(SCAN)
return {'cameras': cameras, 'assets': objects('assets'), 'recorders': [public_camera(r) for r in objects('recorders')],
'sites': objects('sites'), 'mediaOnline': media,
'sites': objects('sites'), 'devices': objects('devices'), 'mediaOnline': media,
'storage': {**setting('storage'), **storage}, 'scan': scan,
'version': (ROOT / 'VERSION').read_text().strip(), 'uptime': round(time.time() - STARTED)}
@@ -514,6 +534,11 @@ class Handler(BaseHTTPRequestHandler):
self.user = self.authenticated()
if not self.user:
raise Problem('请先登录', 401)
self.user = HOUSEHOLDS.context(self.user, self.headers.get('X-Household-Id'))
def require_manager(self):
if not HOUSEHOLDS.manager(self.user):
raise Problem('此操作需要家庭管理员权限', 403)
def require_admin(self):
if self.user['role'] != 'admin':
@@ -597,8 +622,8 @@ class Handler(BaseHTTPRequestHandler):
return
self.require_auth()
if path not in ('/api/logout', '/api/account/password', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz'):
self.require_admin()
'/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz', '/api/ptz/presets'):
self.require_manager()
if path == '/api/logout':
with LOCK:
DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),))
@@ -619,6 +644,18 @@ class Handler(BaseHTTPRequestHandler):
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_manager()
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':
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
try:
@@ -631,19 +668,30 @@ class Handler(BaseHTTPRequestHandler):
self.answer(result)
elif path == '/api/users':
self.answer(ACCOUNTS.save(data, self.user))
elif path == '/api/households':
self.answer(HOUSEHOLDS.save(data, self.user))
elif path == '/api/households/leadership':
self.answer(HOUSEHOLDS.leadership(data, self.user))
elif path == '/api/device-view':
self.answer(HOUSEHOLDS.save_view(data, self.user))
elif path == '/api/devices':
self.answer(INVENTORY.save(data, self.user))
elif path in ('/api/family/person', '/api/family/link', '/api/family/event'):
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
elif path == '/api/sites':
self.answer(save_site(data))
self.answer(save_site(data, self.user))
elif path == '/api/cameras':
self.answer(save_camera(data))
self.answer(save_camera(data, self.user))
elif path == '/api/assets':
self.answer(save_asset(data))
self.answer(save_asset(data, self.user))
elif path == '/api/recorders':
self.answer(save_recorder(data))
self.answer(save_recorder(data, self.user))
elif path == '/api/onvif':
old = get_object('cameras', data.get('id')) or {}
if data.get('id'):
HOUSEHOLDS.owns(self.user, old)
host = host_address(data.get('host', ''))
HOUSEHOLDS.endpoint(self.user, host)
port = integer(data.get('port', 80), 1, 65535)
username = clean_text(data.get('username', ''), 120, required=True)
password = data.get('password') or old.get('password', '')
@@ -655,6 +703,7 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('ONVIF 读取未成功,请检查账号、设备时间、服务端口及 ONVIF 是否启用', 502)
self.answer(result)
elif path == '/api/storage':
self.require_admin()
policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365),
'maxGB': integer(data.get('maxGB'), 5, 100000),
'reserveGB': integer(data.get('reserveGB'), 2, 1000)}
@@ -663,6 +712,7 @@ class Handler(BaseHTTPRequestHandler):
audit('更新录像保留策略')
self.answer({'ok': True})
elif path == '/api/discover':
self.require_admin()
try:
network = ipaddress.ip_network(data.get('network', ''), strict=False)
if network.version != 4 or network.prefixlen < 24 or network.prefixlen > 30:
@@ -765,6 +815,7 @@ class Handler(BaseHTTPRequestHandler):
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
if path == '/api/session':
user = self.authenticated()
user = HOUSEHOLDS.context(user, self.headers.get('X-Household-Id'))
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
'setupRequired': not bool(objects('users'))})
return
@@ -776,8 +827,20 @@ class Handler(BaseHTTPRequestHandler):
if path == '/api/state':
self.answer(ACCOUNTS.state(self.user, status()))
elif path == '/api/users':
self.require_admin()
self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]})
self.require_manager()
self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users')
if self.user['role'] == 'admin' or u['role'] != 'admin']})
elif path == '/api/households':
self.answer({'households': HOUSEHOLDS.list(self.user), 'activeId': HOUSEHOLDS.scope(self.user)})
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=HOUSEHOLDS.manager(self.user)))
elif path == '/api/ptz':
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
try:
@@ -790,7 +853,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == '/api/family':
self.answer(FAMILY.snapshot(self.user))
elif path == '/api/camera/settings':
self.require_admin()
self.require_manager()
c = ACCOUNTS.camera(self.user, get_object('cameras', query.get('camera')))
try:
result, _ = camera_settings.CONTROLLER.read(c, query.get('quality', 'main'))
@@ -852,12 +915,14 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('接口不存在', 404)
else:
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js',
'/family-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',
'/zhao-icon.png': 'zhao-icon.png',
'/vendor/hls.min.js': 'vendor/hls.min.js'}
@@ -873,7 +938,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
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.end_headers()
self.wfile.write(raw)
@@ -889,6 +954,7 @@ def initialize():
global MEDIA_AUTH
FAMILY.initialize()
ACCOUNTS.initialize()
HOUSEHOLDS.initialize()
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
if not setting('mediaSecret'):
+23 -11
View File
@@ -24,35 +24,44 @@ class Family:
self.a.DB.commit()
def authorize(self, user, edit=False):
if user['role'] != 'admin' and user.get('familyAccess', 'none') not in (('edit',) if edit else ('read', 'edit')):
self.a.HOUSEHOLDS.scope(user)
if not self.a.HOUSEHOLDS.manager(user) and user.get('familyAccess', 'none') not in (('edit',) if edit else ('read', 'edit')):
raise self.a.Problem('没有家谱' + ('编辑' if edit else '查看') + '权限', 403)
def snapshot(self, user):
self.authorize(user)
with self.a.LOCK:
can_edit = user['role'] == 'admin' or user.get('familyAccess') == 'edit'
return {'revision': self.a.setting('familyRevision', 0), 'people': self.a.objects('people'),
'links': self.a.objects('family_links'),
'events': [e for e in self.a.objects('family_events')
can_edit = self.a.HOUSEHOLDS.manager(user) or user.get('familyAccess') == 'edit'
people = self.a.HOUSEHOLDS.objects(user, 'people')
return {'revision': self.a.setting(self.a.HOUSEHOLDS.revision_key(user), 0), 'people': people,
'links': self.a.HOUSEHOLDS.objects(user, 'family_links'),
'events': [e for e in self.a.HOUSEHOLDS.objects(user, 'family_events')
if can_edit or (e.get('status') == 'published' and not e.get('archived'))],
'canEdit': can_edit,
'selfId': user.get('personId', '')}
'selfId': user.get('personId', '') if any(p['id'] == user.get('personId') for p in people) else '',
'familyId': self.a.HOUSEHOLDS.scope(user)}
def save(self, kind, data, user, restored_from=None):
self.authorize(user, True)
a = self.a
with a.LOCK:
revision = a.setting('familyRevision', 0)
revision_key = a.HOUSEHOLDS.revision_key(user)
revision = a.setting(revision_key, 0)
if type(data.get('revision')) is not int or data['revision'] != revision:
raise a.Problem('家谱已被更新,请重新载入后再编辑', 409)
if kind not in ('person', 'link', 'event'):
raise a.Problem('家谱记录类型不存在', 404)
table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}[kind]
family_id = a.HOUSEHOLDS.prepare(user, table, data,
(('people', 'fromId'), ('people', 'toId')) if kind == 'link' else ())
if kind == 'event' and isinstance(data.get('personIds', []), list):
for person in data.get('personIds', []):
a.HOUSEHOLDS.owns(user, a.get_object('people', person))
old = a.get_object(table, data.get('id'))
if data.get('id') and not old:
raise a.Problem('家谱记录不存在', 404)
item = {'person': self.person, 'link': self.link, 'event': self.event}[kind](data, old)
item.update(updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username'])
item.update(familyId=family_id, updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username'])
if kind == 'event':
item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'],
createdBy=old.get('createdBy', user['username']) if old else user['username'])
@@ -63,10 +72,11 @@ class Family:
item['publishedAt'] = item['updatedAt']
# Revision and record are committed together, so concurrent forms cannot overwrite silently.
with a.DB:
change_revision = a.DB.execute('SELECT COALESCE(MAX(revision),0)+1 FROM family_changes').fetchone()[0]
a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item)))
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1)))
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', (revision_key, json.dumps(revision + 1)))
a.DB.execute('INSERT INTO family_changes VALUES (?,?,?,?,?,?,?,?)',
(revision + 1, kind, item['id'], json.dumps(old) if old else None,
(change_revision, 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'])
return {'item': item, 'revision': revision + 1}
@@ -79,6 +89,7 @@ class Family:
with a.LOCK:
if not table or not a.get_object(table, target_id):
raise a.Problem('家谱记录不存在', 404)
a.HOUSEHOLDS.owns(user, a.get_object(table, target_id))
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<? '
@@ -91,7 +102,7 @@ class Family:
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))
revision=a.setting(a.HOUSEHOLDS.revision_key(user), 0))
def change(self, user, change_revision):
self.authorize(user, True)
@@ -100,6 +111,7 @@ class Family:
row = self.a.DB.execute('SELECT * FROM family_changes WHERE revision=?', (change_revision,)).fetchone()
if not row:
raise self.a.Problem('修改记录不存在', 404)
self.a.HOUSEHOLDS.owns(user, json.loads(row['after_json']))
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'])
+185
View File
@@ -0,0 +1,185 @@
"""Household ownership and request scope. A site is a location within one household."""
import datetime as dt
import json
import re
import secrets
class Households:
TABLES = ('sites', 'assets', 'recorders', 'cameras', 'devices', 'people', 'family_links', 'family_events', 'users')
def __init__(self, app):
self.a = app
@staticmethod
def manager(user):
return bool(user and user.get('role') in ('admin', 'family_admin'))
def initialize(self):
a = self.a
with a.LOCK, a.DB:
a.DB.execute('CREATE TABLE IF NOT EXISTS households (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
a.DB.execute('CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
default = a.setting('defaultHousehold')
if not default:
default = secrets.token_hex(8)
a.DB.execute('INSERT INTO settings VALUES (?,?)', ('defaultHousehold', json.dumps(default)))
item = dict(id=default, name='赵府', note='原有家庭资料', createdAt=dt.datetime.now(dt.timezone.utc).isoformat())
a.DB.execute('INSERT INTO households VALUES (?,?)', (default, json.dumps(item)))
# One-time ownership backfill preserves identifiers, credentials and device paths.
for table in self.TABLES:
for row in a.DB.execute('SELECT id,body FROM ' + table).fetchall():
item = json.loads(row['body'])
if 'familyId' not in item:
item['familyId'] = default
a.DB.execute('UPDATE ' + table + ' SET body=? WHERE id=?', (json.dumps(item), row['id']))
for row in a.DB.execute('SELECT revision,before_json,after_json FROM family_changes').fetchall():
for column in ('before_json', 'after_json'):
item = json.loads(row[column]) if row[column] else None
if item is not None and 'familyId' not in item:
item['familyId'] = default
a.DB.execute('UPDATE family_changes SET ' + column + '=? WHERE revision=?',
(json.dumps(item), row['revision']))
def family_of(self, item):
# Legacy in-process import helpers remain in the migrated default household.
return item.get('familyId', self.a.setting('defaultHousehold')) if item is not None else None
def scope(self, user):
family = (user or {}).get('_familyId') or self.family_of(user or {})
if not family or not self.a.get_object('households', family):
raise self.a.Problem('账号尚未分配有效家庭,请联系平台管理员', 403)
return family
def context(self, user, requested=None):
if not user:
return None
own = self.family_of(user)
if requested and user['role'] != 'admin' and requested != own:
raise self.a.Problem('没有这个家庭的访问权限', 403)
result = dict(user, _familyId=requested or own)
self.scope(result)
return result
def owns(self, user, item):
if not item or self.family_of(item) != self.scope(user):
raise self.a.Problem('记录不存在或不属于当前家庭', 404)
return item
def objects(self, user, table):
family = self.scope(user)
return [x for x in self.a.objects(table) if self.family_of(x) == family]
def site_ids(self, user):
"""Optional branch grants, always restricted to the account's own organization."""
sites = self.objects(user, 'sites')
ids = {s['id'] for s in sites if s['id'] in user.get('siteIds', [])}
if user.get('includeSubspaces', False):
children = {}
for site in sites:
children.setdefault(site.get('parentId'), []).append(site['id'])
queue = list(ids)
for parent in queue:
for child in children.get(parent, []):
if child not in ids:
ids.add(child)
queue.append(child)
return ids
def prepare(self, user, table, body, references=()):
family = self.scope(user)
if body.get('familyId', family) != family:
raise self.a.Problem('不能修改记录的家庭归属', 403)
if body.get('id'):
if not isinstance(body['id'], str) or not re.fullmatch('[a-f0-9]{16}', body['id']):
raise self.a.Problem('对象编号不正确')
self.owns(user, self.a.get_object(table, body['id']))
for target, key in references:
if body.get(key):
self.owns(user, self.a.get_object(target, body[key]))
return family
def endpoint(self, user, host):
family = self.scope(user)
if any(item.get('host') == host and self.family_of(item) != family
for table in ('cameras', 'recorders') for item in self.a.objects(table)):
raise self.a.Problem('该设备地址已归属其他家庭,不能重复接入', 403)
def list(self, user):
rows = self.a.objects('households') if user['role'] == 'admin' else [self.a.get_object('households', self.scope(user))]
def label(uid):
account = self.a.get_object('users', uid)
return account.get('name', '') if account else ''
return [dict({k: v for k, v in row.items() if k != 'leadershipHistory' or self.manager(user)}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')),
canAssignOwner=user['role'] == 'admin' or row.get('ownerId') == user['id'],
members=sum(self.family_of(u) == row['id'] for u in self.a.objects('users')),
sites=sum(self.family_of(s) == row['id'] for s in self.a.objects('sites'))) for row in rows]
def leadership(self, data, user):
"""Explicit handover; a backup already has management access during an emergency."""
a = self.a
with a.LOCK:
home = a.get_object('households', data.get('id'))
if not home or home['id'] != self.scope(user):
raise a.Problem('请先进入要管理的家庭', 404)
if user['role'] != 'admin' and user['id'] != home.get('ownerId'):
raise a.Problem('只有一家之主或超级管理员可以交接管理权', 403)
if data.get('revision') != home.get('leadershipRevision', 0):
raise a.Problem('家庭负责人已变更,请重新载入', 409)
owner_id = a.clean_text(data.get('ownerId', ''), 32, True)
successor_id = a.clean_text(data.get('successorId', ''), 32)
reason = a.clean_text(data.get('reason', ''), 300, True)
if owner_id == successor_id:
raise a.Problem('备用负责人必须是另一位家庭管理员')
for uid in filter(None, (owner_id, successor_id)):
account = self.owns(user, a.get_object('users', uid))
if account['role'] != 'family_admin' or account['disabled']:
raise a.Problem('负责人须为本家庭已启用的家庭管理员')
record = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), actor=user['username'], reason=reason,
previousOwnerId=home.get('ownerId', ''), ownerId=owner_id, successorId=successor_id)
home.update(ownerId=owner_id, successorId=successor_id,
leadershipRevision=home.get('leadershipRevision', 0) + 1,
leadershipHistory=(home.get('leadershipHistory', []) + [record])[-50:])
a.save_object('households', home)
a.audit('家庭负责人交接', user['username'] + ' / ' + home['name'])
return {'ok': True}
def save(self, data, user):
a = self.a
if not self.manager(user):
raise a.Problem('需要家庭管理员权限', 403)
with a.LOCK:
old = a.get_object('households', data.get('id'))
if data.get('id') and not old:
raise a.Problem('家庭不存在', 404)
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
raise a.Problem('只能管理自己所属的家庭', 403)
kind = data.get('kind', old.get('kind', 'home') if old else 'home')
if kind not in ('home', 'office', 'factory'):
raise a.Problem('请选择家庭、办公场所或工厂')
item = dict(old or {}, id=old['id'] if old else secrets.token_hex(8),
name=a.clean_text(data.get('name', ''), 80, True),
kind=kind,
note=a.clean_text(data.get('note', ''), 300),
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
a.save_object('households', item)
a.audit('保存家庭', item['name'])
return item
def revision_key(self, user):
family = self.scope(user)
return 'familyRevision' if family == self.a.setting('defaultHousehold') else 'familyRevision:' + family
def save_view(self, data, user):
if not self.manager(user):
raise self.a.Problem('需要场所管理员权限', 403)
groups = data.get('groups')
if (not isinstance(groups, list) or len(groups) > 5 or
any(g not in ('type', 'space', 'manager', 'owner', 'tag') for g in groups) or len(set(groups)) != len(groups)):
raise self.a.Problem('分组维度不可重复,请选择类型、区域、负责人、所有人或标签')
with self.a.LOCK:
home = self.a.get_object('households', self.scope(user))
home['deviceGrouping'] = groups
self.a.save_object('households', home)
self.a.audit('更新设备分组方式', home['name'])
return {'ok': True}
+47 -9
View File
@@ -30,7 +30,7 @@ class Accounts:
@staticmethod
def public(user):
return {k: v for k, v in user.items() if k not in ('salt', 'hash')} if user else None
return {k: v for k, v in user.items() if k not in ('salt', 'hash') and not k.startswith('_')} if user else None
def password(self, password):
if not isinstance(password, str) or not 8 <= len(password) <= 128:
@@ -50,6 +50,9 @@ class Accounts:
def save(self, data, actor):
a = self.a
with a.LOCK:
if actor and not a.HOUSEHOLDS.manager(actor):
raise a.Problem('需要家庭管理员权限', 403)
family = a.HOUSEHOLDS.prepare(actor, 'users', data)
old = a.get_object('users', data.get('id'))
if data.get('id') and not old:
raise a.Problem('账号不存在', 404)
@@ -61,29 +64,50 @@ class Accounts:
raise a.Problem('用户名已存在', 409)
role = data.get('role', 'member')
access = data.get('familyAccess', 'none')
if role not in ('admin', 'member') or access not in ('none', 'read', 'edit'):
if role not in ('admin', 'family_admin', 'member') or access not in ('none', 'read', 'edit'):
raise a.Problem('权限选项不正确')
if actor and actor['role'] != 'admin' and (role == 'admin' or (old and old['role'] == 'admin')):
raise a.Problem('家庭管理员不能创建或修改超级管理员', 403)
home = a.get_object('households', family)
if actor and actor['role'] != 'admin' and home.get('ownerId') and actor['id'] != home['ownerId']:
if (role == 'family_admin' and not old) or (old and old['id'] != actor['id'] and
(role == 'family_admin' or old['role'] == 'family_admin')):
raise a.Problem('请由一家之主或超级管理员管理家庭管理员账号', 403)
sites = data.get('siteIds', [])
if not isinstance(sites, list) or any(not isinstance(s, str) or not a.get_object('sites', s) for s in sites):
raise a.Problem('请选择有效空间')
for site in sites:
a.HOUSEHOLDS.owns(actor, a.get_object('sites', site))
ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False)
if not isinstance(ptz_control, bool):
raise a.Problem('云台权限格式不正确')
descendants = data.get('includeSubspaces', old.get('includeSubspaces', False) if old else False)
if not isinstance(descendants, bool):
raise a.Problem('下级区域授权格式不正确')
disabled = data.get('disabled', False)
if not isinstance(disabled, bool):
raise a.Problem('账号状态不正确')
if old and uid in (home.get('ownerId'), home.get('successorId')) and (disabled or role != 'family_admin'):
raise a.Problem('请先交接家主或更换备用负责人,再停用或调整此账号')
if old and old['role'] == 'admin' and (role != 'admin' or disabled):
if not any(u['id'] != uid and u['role'] == 'admin' and not u['disabled'] for u in a.objects('users')):
raise a.Problem('至少保留一个启用的管理员')
if old and old['role'] == 'family_admin' and (role != 'family_admin' or disabled):
if not any(u['id'] != uid and u['role'] == 'family_admin' and not u['disabled']
for u in a.HOUSEHOLDS.objects(actor, 'users')):
raise a.Problem('至少保留一个启用的家庭管理员')
if actor and actor['id'] == uid and (disabled or role != actor['role']):
raise a.Problem('不能停用或降低当前登录账号的管理权限')
related = a.clean_text(data.get('relatedToId', ''), 32)
person = a.clean_text(data.get('personId', ''), 32)
if related and related != uid and not a.get_object('users', related):
raise a.Problem('关系参照账号不存在')
if related and related != uid:
a.HOUSEHOLDS.owns(actor, a.get_object('users', related))
if person:
if not a.get_object('people', person):
raise a.Problem('家谱人物不存在')
a.HOUSEHOLDS.owns(actor, a.get_object('people', person))
if any(u.get('personId') == person and u['id'] != uid for u in a.objects('users')):
raise a.Problem('这个人物已经关联其他账号')
password = data.get('password', '')
@@ -91,11 +115,11 @@ class Accounts:
raise a.Problem('密码格式不正确')
credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')}
user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True),
role=role, disabled=disabled, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control,
personId=person, relatedToId=related,
role=role, disabled=disabled, familyId=family, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control,
personId=person, relatedToId=related, includeSubspaces=descendants,
relationship=a.clean_text(data.get('relationship', ''), 40), **credentials)
# Revoke sessions when credentials or authorization change, not for label edits.
if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'siteIds', 'familyAccess', 'disabled', 'username', 'ptzControl')):
if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'disabled', 'username', 'ptzControl', 'personId')):
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,))
a.save_object('users', user)
a.audit('更新账号' if old else '建立账号', username)
@@ -128,13 +152,18 @@ class Accounts:
def camera(self, user, camera):
if not camera:
raise self.a.Problem('摄像头不存在', 404)
if user['role'] != 'admin' and camera.get('siteId') not in user['siteIds']:
if user['role'] != 'admin':
self.a.HOUSEHOLDS.owns(user, camera)
site = self.a.get_object('sites', camera.get('siteId'))
if site:
self.a.HOUSEHOLDS.owns(user, site)
if not self.a.HOUSEHOLDS.manager(user) and camera.get('siteId') not in self.a.HOUSEHOLDS.site_ids(user):
raise self.a.Problem('没有这个空间的访问权限', 403)
return camera
def control(self, user, camera):
self.camera(user, camera)
if user['role'] != 'admin' and not user.get('ptzControl', False):
if not self.a.HOUSEHOLDS.manager(user) and not user.get('ptzControl', False):
raise self.a.Problem('没有云台控制权限,请联系管理员授权', 403)
if not camera.get('enabled'):
raise self.a.Problem('摄像头已停用', 409)
@@ -142,11 +171,20 @@ class Accounts:
def state(self, user, state):
state['user'] = self.public(user)
if user['role'] == 'admin':
family = self.a.HOUSEHOLDS.scope(user)
state['familyId'] = family
state['households'] = self.a.HOUSEHOLDS.list(user)
for key in ('sites', 'assets', 'cameras', 'recorders', 'devices'):
state[key] = [x for x in state.get(key, []) if self.a.HOUSEHOLDS.family_of(x) == family]
if user['role'] != 'admin':
state['scan'] = {}
state['storage'] = {}
if self.a.HOUSEHOLDS.manager(user):
return state
permitted = set(user['siteIds'])
permitted = self.a.HOUSEHOLDS.site_ids(user)
state['sites'] = [s for s in state['sites'] if s['id'] in permitted]
state['assets'] = [s for s in state['assets'] if s['siteId'] in permitted]
state['devices'] = [s for s in state['devices'] if s['siteId'] in permitted]
fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'archiveSource')
state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted]
state['recorders'] = []
+30
View File
@@ -0,0 +1,30 @@
"""Common device responsibility fields and non-video device records."""
class Inventory:
def __init__(self, app):
self.a = app
def metadata(self, data, old=None):
old = old or {}
result = {k: self.a.clean_text(data.get(k, old.get(k, '')), 80)
for k in ('ownerName', 'managerName')}
tags = data.get('tags', old.get('tags', []))
if not isinstance(tags, list) or len(tags) > 12:
raise self.a.Problem('自定义标签最多 12 个')
result['tags'] = sorted({self.a.clean_text(tag, 40, True) for tag in tags})
return result
def save(self, data, user):
a = self.a
with a.LOCK:
family = a.HOUSEHOLDS.prepare(user, 'devices', data, (('sites', 'siteId'),))
key = a.object_key('devices', data)
old = a.get_object('devices', key)
item = {k: a.clean_text(data.get(k, ''), 300 if k == 'note' else 120, k in ('name', 'deviceType'))
for k in ('name', 'deviceType', 'brand', 'model', 'serial', 'note')}
if item['deviceType'] in ('摄像头', '摄像机', '录像机'):
raise a.Problem('视频设备请使用摄像机或录像机档案,以保留取流与通道配置')
item.update(id=key, familyId=family, siteId=a.clean_text(data.get('siteId', ''), 32),
**self.metadata(data, old))
a.save_object('devices', item)
a.audit('保存设备档案', item['name'])
return item
+7 -2
View File
@@ -4,6 +4,7 @@ import datetime as dt
import hashlib
import secrets
import urllib.request
import urllib.error
import urllib.parse
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape
@@ -39,8 +40,12 @@ def request(host, port, endpoint, namespace, operation, content, username, passw
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPDigestAuthHandler(passwords))
req = urllib.request.Request(target, data=envelope.encode(), headers={
'Content-Type': 'application/soap+xml; charset=utf-8; action="' + namespace + '/' + operation + '"'})
with opener.open(req, timeout=5) as response:
root = ET.fromstring(response.read(1024 * 1024))
try:
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:
raise ValueError('ONVIF returned a fault; check device credentials and permissions')
return root
+100
View File
@@ -1,8 +1,10 @@
"""Capability-driven ONVIF pan/tilt: bounded nudges, never latched movement."""
import hashlib
import json
import threading
import time
import urllib.parse as url
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape
import onvif
@@ -17,6 +19,100 @@ class Controller:
self.guard = threading.RLock()
self.cache = {}
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=''):
port, path = endpoint
@@ -98,6 +194,10 @@ class Controller:
def stop():
self.call(c, info['endpoint'], PTZ, 'Stop', body + '<m:PanTilt>true</m:PanTilt>')
if action == 'stop':
with self.guard:
cancel = self.cancellations.get(self.node_key(c, info))
if cancel:
cancel.set()
stop()
return {'ok': True, 'stopped': True}
# Lock by physical host/node, including commands sent through another lens/account.
+29 -1
View File
@@ -59,6 +59,33 @@ class WebTests(unittest.TestCase):
with self.subTest(path=path):
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):
code, headers, _ = self.request('/', False)
self.assertEqual(code, 200)
@@ -78,7 +105,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
self.assertEqual(len(assets), 15)
self.assertEqual(len(assets), 22)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
@@ -229,6 +256,7 @@ class WebTests(unittest.TestCase):
self.assertIn('pipe:1', command)
def test_ptz_requires_both_space_and_control_permission(self):
app.save_object('sites', dict(id='permitted', name='Permitted'))
camera = dict(id='e'*16, name='Test', siteId='permitted', enabled=True)
viewer = dict(role='member', siteIds=['permitted'], ptzControl=False)
with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera)
+30
View File
@@ -0,0 +1,30 @@
const test=require('node:test'),assert=require('node:assert/strict'),m=require('./web/device-model.js');
const sites=[{id:'floor',name:'一楼'},{id:'bed',name:'卧室',parentId:'floor'},{id:'office',name:'办公室'}];
const devices=[{id:'lamp',name:'卧室台灯',deviceType:'灯',siteId:'bed',managerName:'乙',ownerName:'甲',tags:['照明','常用']},{id:'desk',name:'办公灯',deviceType:'灯',siteId:'office',managerName:'甲',ownerName:'乙',tags:['照明']},{id:'phone',name:'手机',deviceType:'手机',siteId:'bed',managerName:'乙',ownerName:'乙',tags:[]}];
test('type then area and area then type reuse the same device identities',()=>{
const a=m.groups(devices,['type','space'],sites),b=m.groups(devices,['space','type'],sites);
assert.deepEqual(a.find(g=>g.items.some(x=>x.id==='lamp')).labels,['灯','一楼','卧室']);
assert.deepEqual(b.find(g=>g.items.some(x=>x.id==='lamp')).labels,['一楼','卧室','灯']);
assert.equal(a.flatMap(g=>g.items).length,3);assert.equal(b.flatMap(g=>g.items).length,3);
});
test('responsible person differs from owner and supports multitag grouping',()=>{
assert.equal(m.groups(devices,['manager'],sites).find(g=>g.labels[0]==='乙').items.length,2);
assert.equal(m.groups(devices,['owner'],sites).find(g=>g.labels[0]==='乙').items.length,2);
assert.equal(m.groups(devices,['tag'],sites).find(g=>g.labels[0]==='照明').items.length,2);
assert.equal(m.groups(devices,['tag'],sites).find(g=>g.labels[0]==='未标记').items[0].id,'phone');
});
test('spaces have no fixed depth and traversal handles corrupt cycles safely',()=>{
const deep=Array.from({length:2500},(_,i)=>({id:String(i),name:'Area '+i,parentId:i?String(i-1):''}));
assert.equal(m.path(deep,'2499').length,2500);assert.equal(m.descendants(deep,'0').size,2500);
assert.equal(m.path([{id:'a',parentId:'b'},{id:'b',parentId:'a'}],'a').length,2);
assert.equal(m.descendants([{id:'a',parentId:'b'},{id:'b',parentId:'a'}],'a').size,2);
});
test('identical area names remain separate groups by stable identifiers',()=>{
const areas=[{id:'a',name:'卧室'},{id:'b',name:'卧室'}];
assert.equal(m.groups([{siteId:'a',id:1},{siteId:'b',id:2}],['space'],areas).length,2);
});
test('camera recorder and general device records appear in one catalog without mutation',()=>{
const state={assets:[{id:'a',name:'Camera'}],recorders:[{id:'b',name:'Recorder'}],devices:[{id:'c',deviceType:'冰箱'}]},before=JSON.stringify(state);
assert.deepEqual(m.catalog(state).map(d=>d.deviceType),['摄像机','录像机','冰箱']);assert.equal(JSON.stringify(state),before);
assert.equal(m.groups(devices,[],sites).length,1);
});
+266
View File
@@ -0,0 +1,266 @@
"""Two-household HTTP isolation and management succession regressions."""
import hashlib
import http.client
import json
import secrets
import sqlite3
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
# Reuse only module initialization; never inherit the original WebTests.
from test_app import app
class HouseholdTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
db = sqlite3.connect(':memory:', check_same_thread=False)
db.row_factory = sqlite3.Row
self.addCleanup(db.close)
db.executescript('''
CREATE TABLE settings (key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE sessions (hash TEXT PRIMARY KEY,expires REAL NOT NULL);
CREATE TABLE audit (id INTEGER PRIMARY KEY,at TEXT,action TEXT,name TEXT);
''')
for table in ('sites', 'assets', 'recorders', 'cameras'):
db.execute('CREATE TABLE '+table+' (id TEXT PRIMARY KEY,body TEXT NOT NULL)')
for attr, value in [('DB', db), ('DATA', Path(self.temp.name)),
('write_media_config', lambda: None)]:
p = patch.object(app, attr, value); p.start(); self.addCleanup(p.stop)
app.initialize()
self.root = app.ACCOUNTS.save(dict(username='admin', password='fixture-only', role='admin'), None)
self.aid = self.root['familyId']
self.bid = app.HOUSEHOLDS.save({'name': '乙家庭'}, self.root)['id']
self.roots = {h: app.HOUSEHOLDS.context(self.root, h) for h in (self.aid, self.bid)}
self.managers, self.members, self.sites, self.cameras, self.assets = {}, {}, {}, {}, {}
for i, home in enumerate((self.aid, self.bid)):
actor = self.roots[home]
self.sites[home] = app.save_site({'name': 'home-'+str(i)}, actor)
self.assets[home] = app.save_asset(dict(name='asset-'+str(i), siteId=self.sites[home]['id']), actor)
self.cameras[home] = app.save_camera(dict(name='camera-'+str(i), assetId=self.assets[home]['id'],
host='192.168.3.'+str(10+i), enabled=True), actor)
self.managers[home] = app.ACCOUNTS.save(dict(username='manager'+str(i), password='fixture-only', role='family_admin'), actor)
self.members[home] = app.ACCOUNTS.save(dict(username='member'+str(i), password='fixture-only',
siteIds=[self.sites[home]['id']], familyAccess='edit', ptzControl=True), actor)
self.owner, self.other = self.managers[self.aid], self.managers[self.bid]
self.backup = app.ACCOUNTS.save(dict(username='backup', password='fixture-only', role='family_admin'), self.root)
self.tokens = {}
for user in [self.root, self.backup, *self.managers.values(), *self.members.values()]:
token = secrets.token_hex(32); self.tokens[user['id']] = token
db.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+600, user['id']))
db.commit()
def state():
return {**{t: app.objects(t) for t in ('sites','assets','recorders','cameras','devices')}, 'scan': {'network':'private'}, 'storage': {'maxGB':40}}
p = patch.object(app, 'status', state); p.start(); self.addCleanup(p.stop)
self.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start()
self.addCleanup(self.stop_server)
def stop_server(self):
self.server.shutdown(); self.server.server_close(); self.thread.join()
def request(self, path, user=None, data=None, home=None):
user = user or self.owner
c = http.client.HTTPConnection(*self.server.server_address, timeout=3)
headers = {'Cookie': 'vision='+self.tokens[user['id']]}
if home: headers['X-Household-Id'] = home
if data is not None: headers['Content-Type'] = 'application/json'
c.request('GET' if data is None else 'POST', path, json.dumps(data) if data is not None else None, headers)
response = c.getresponse(); raw = response.read(); code = response.status; c.close()
return code, json.loads(raw)
def leadership(self, user=None, **fields):
data = dict(id=self.aid, ownerId=self.owner['id'], successorId=self.backup['id'], revision=0, reason='提前安排备用负责人')
return self.request('/api/households/leadership', user or self.root, data=dict(data, **fields))
def test_state_and_accounts_are_scoped(self):
for home in (self.aid, self.bid):
for user in (self.managers[home], self.members[home]):
code, body = self.request('/api/state', user)
self.assertEqual(code, 200)
for table in ('sites','assets','cameras'):
self.assertEqual(len(body[table]), 1)
self.assertEqual(body['cameras'][0]['id'], self.cameras[home]['id'])
self.assertEqual([h['id'] for h in body['households']], [home])
self.assertEqual(body['storage'], {}); self.assertEqual(body['scan'], {})
code, body = self.request('/api/users')
self.assertEqual(code, 200)
self.assertTrue(all(u['familyId']==self.aid and u['role']!='admin' for u in body['users']))
self.assertNotIn('hash', json.dumps(body)); self.assertNotIn('salt', json.dumps(body))
def test_forged_household_header_denied_root_can_switch(self):
for user in (self.owner, self.members[self.aid]):
for path in ('/api/session','/api/state','/api/family','/api/households'):
self.assertEqual(self.request(path, user, home=self.bid)[0], 403)
code, body = self.request('/api/state', self.root, home=self.bid)
self.assertEqual(code, 200); self.assertEqual(body['familyId'], self.bid)
self.assertEqual(body['cameras'][0]['id'], self.cameras[self.bid]['id'])
def test_guessed_camera_ids_cannot_access_media_or_controls(self):
camera = self.cameras[self.bid]['id']
paths = [f'/media/live/cam_{camera}_{quality}/{file}' for quality in ('main','sub','hd','compat')
for file in ('index.m3u8','init.mp4','segment1.mp4')]
paths += [f'/api/recordings?camera={camera}', f'/media/playback?camera={camera}',
f'/media/playback?camera={camera}&download=1', f'/api/ptz?camera={camera}',
f'/api/ptz/presets?camera={camera}', f'/api/camera/settings?camera={camera}']
with patch.object(app.Handler, 'proxy') as proxy:
for user in (self.owner,self.members[self.aid]):
for path in paths:
with self.subTest(path=path): self.assertIn(self.request(path,user)[0], (403,404))
for path in ('/api/ptz','/api/ptz/presets','/api/camera/settings'):
self.assertIn(self.request(path,user,dict(camera=camera, action='stop'))[0], (403,404))
proxy.assert_not_called()
def test_own_media_access_and_ungranted_site(self):
def proxy(handler,*args,**kwargs): handler.answer({'ok':True})
camera = self.cameras[self.aid]['id']; path = f'/media/live/cam_{camera}_hd/index.m3u8'
with patch.object(app.Handler,'proxy',proxy):
self.assertEqual(self.request(path,self.members[self.aid])[0],200)
member=dict(self.members[self.aid],siteIds=[])
# Update DB directly to retain token and exercise per-request permission lookup.
app.save_object('users',dict(app.get_object('users',member['id']),siteIds=[]))
self.assertEqual(self.request(path,member)[0],403)
self.assertEqual(self.request(path,self.owner)[0],200)
def test_cross_household_edits_references_and_ownership_blocked(self):
cases=[('/api/sites',dict(self.sites[self.bid],name='stolen')),
('/api/assets',dict(name='bad',siteId=self.sites[self.bid]['id'])),
('/api/cameras',dict(name='bad',assetId=self.assets[self.bid]['id'])),
('/api/recorders',dict(name='bad',siteId=self.sites[self.bid]['id'])),
('/api/sites',dict(self.sites[self.aid],familyId=self.bid))]
for path,data in cases: self.assertIn(self.request(path,data=data)[0],(403,404))
self.assertEqual(self.request('/api/sites',data=dict(self.sites[self.aid],name='own edit'))[0],200)
def test_other_device_address_and_onvif_credentials_protected(self):
with patch.object(app.onvif,'inspect') as inspect:
for data in (dict(id=self.cameras[self.bid]['id']),dict(host=self.cameras[self.bid]['host'],username='admin')):
self.assertIn(self.request('/api/onvif',data=data)[0],(403,404))
inspect.assert_not_called()
self.assertEqual(self.request('/api/cameras',data=dict(name='duplicate',assetId=self.assets[self.aid]['id'],host=self.cameras[self.bid]['host']))[0],403)
def test_manager_cannot_escalate_or_touch_another_family_user(self):
for data in (dict(username='root2',password='fixture-only',role='admin'),
dict(self.other,name='stolen'), dict(self.root,name='hijack'),
dict(username='cross',password='fixture-only',siteIds=[self.sites[self.bid]['id']]),
dict(username='cross',password='fixture-only',relatedToId=self.other['id'])):
self.assertIn(self.request('/api/users',data=data)[0],(403,404))
def test_global_operations_require_super_admin(self):
for path,data in (('/api/storage',{}),('/api/discover',{}),('/api/households',{'name':'unauthorized'})):
self.assertEqual(self.request(path,data=data)[0],403)
self.assertEqual(self.request('/api/audit')[0],403)
self.assertEqual(self.request('/api/households',self.members[self.aid],{'name':'unauthorized'})[0],403)
def test_genealogy_revisions_history_restore_and_refs_isolated(self):
a = app.FAMILY.save('person',dict(name='A private',revision=0),self.owner)['item']
b = app.FAMILY.save('person',dict(name='B private',revision=0),self.other)['item']
self.assertEqual(self.request('/api/family')[1]['revision'],1)
self.assertEqual([p['name'] for p in self.request('/api/family')[1]['people']],['A private'])
revision=app.FAMILY.history(self.other,'person',b['id'])['items'][0]['revision']
for path in (f'/api/family/history?kind=person&id={b["id"]}', f'/api/family/change?revision={revision}'):
self.assertEqual(self.request(path)[0],404)
for path,data in (('/api/family/restore',dict(changeRevision=revision,side='after',revision=1)),
('/api/family/person',dict(b,revision=1)),
('/api/family/link',dict(fromId=a['id'],toId=b['id'],kind='parent',revision=1)),
('/api/family/event',dict(personIds=[b['id']],revision=1))):
self.assertIn(self.request(path,data=data)[0],(403,404))
self.assertEqual(self.request('/api/users',data=dict(username='crossperson',password='fixture-only',personId=b['id']))[0],404)
def test_leadership_backup_and_explicit_emergency_handover(self):
self.assertEqual(self.leadership()[0],200)
# Backup remains operational even if the owner's browser is unavailable.
self.assertEqual(self.request('/api/sites',self.backup,dict(name='emergency operation'))[0],200)
self.assertEqual(self.leadership(self.backup,revision=1,ownerId=self.backup['id'],successorId='')[0],403)
self.assertEqual(self.leadership(self.root,revision=1,ownerId=self.backup['id'],successorId=self.owner['id'],reason='家主无法操作,由超级管理员办理交接')[0],200)
home=app.get_object('households',self.aid)
self.assertEqual(home['ownerId'],self.backup['id']);self.assertEqual(len(home['leadershipHistory']),2)
self.assertEqual(app.get_object('users',self.owner['id'])['role'],'family_admin')
self.assertEqual(self.leadership(self.owner,revision=2)[0],403)
self.assertEqual(self.request('/api/households',self.members[self.aid])[1]['households'][0].get('leadershipHistory'),None)
def test_leadership_conflicts_and_account_lockout_prevention(self):
self.assertEqual(self.leadership()[0],200)
self.assertEqual(self.leadership()[0],409)
for uid in (self.owner['id'],self.backup['id']):
user=app.get_object('users',uid)
for fields in ({'disabled':True},{'role':'member'}):
self.assertEqual(self.request('/api/users',self.root,dict(user,**fields))[0],400)
self.assertEqual(self.request('/api/users',self.backup,dict(self.owner,password='hijack-password'))[0],403)
self.assertEqual(self.leadership(revision=1,successorId=self.other['id'])[0],404)
self.assertEqual(self.leadership(revision=1,successorId=self.owner['id'])[0],400)
self.assertEqual(self.leadership(revision=1,ownerId=self.members[self.aid]['id'])[0],400)
# Editing household metadata must preserve leadership and its history.
self.assertEqual(self.request('/api/households',data=dict(id=self.aid,name='renamed'))[0],200)
self.assertEqual(app.get_object('households',self.aid)['ownerId'],self.owner['id'])
def test_legacy_migration_idempotent_preserves_credentials(self):
user=app.get_object('users',self.owner['id']);user.pop('familyId')
app.save_object('users',user)
before=app.DB.execute('SELECT * FROM sessions ORDER BY hash').fetchall()
app.HOUSEHOLDS.initialize();app.HOUSEHOLDS.initialize()
saved=app.get_object('users',user['id']);self.assertEqual(saved.pop('familyId'),self.aid)
self.assertEqual(saved,user)
self.assertEqual([tuple(r) for r in before],[tuple(r) for r in app.DB.execute('SELECT * FROM sessions ORDER BY hash')])
def test_office_factory_and_custom_grouping_are_independent(self):
for kind in ('office','factory'):
code, scope=self.request('/api/households',self.root,dict(name=kind,kind=kind))
self.assertEqual(code,200);self.assertEqual(scope['kind'],kind)
self.assertEqual(self.request('/api/device-view',data={'groups':['manager','type','space']})[0],200)
self.assertEqual(app.get_object('households',self.aid)['deviceGrouping'],['manager','type','space'])
self.assertNotIn('deviceGrouping',app.get_object('households',self.bid))
self.assertEqual(self.request('/api/device-view',data={'groups':[]})[0],200)
for groups in (['type','type'],['unknown'],'type',[{}]):
self.assertEqual(self.request('/api/device-view',data={'groups':groups})[0],400)
self.assertEqual(self.request('/api/device-view',self.members[self.aid],{'groups':['type']})[0],403)
def test_region_hierarchy_has_no_fixed_depth_and_rejects_cycles(self):
parent=self.sites[self.aid]
for n in range(160):
parent=app.save_site(dict(name='Region '+str(n),parentId=parent['id'],category='custom '+str(n)),self.owner)
self.assertEqual(len(app.HOUSEHOLDS.objects(self.owner,'sites')),161)
for child in (parent,self.sites[self.aid]):
self.assertEqual(self.request('/api/sites',data=dict(child,parentId=parent['id']))[0],400)
self.assertEqual(self.request('/api/sites',data=dict(parent,parentId=self.sites[self.bid]['id']))[0],404)
def test_subregion_grants_are_explicit_and_checked_per_request(self):
child=app.save_site(dict(name='Bedroom',parentId=self.sites[self.aid]['id']),self.owner)
asset=app.save_asset(dict(self.assets[self.aid],siteId=child['id']),self.owner)
camera=app.get_object('cameras',self.cameras[self.aid]['id'])
member=app.get_object('users',self.members[self.aid]['id'])
with self.assertRaises(app.Problem):app.ACCOUNTS.camera(member,camera)
app.save_object('users',dict(member,includeSubspaces=True))
self.assertEqual(len(self.request('/api/state',member)[1]['cameras']),1)
# Moving the region to another branch immediately removes inherited access.
app.save_site(dict(child,parentId=''),self.owner)
self.assertEqual(len(self.request('/api/state',member)[1]['cameras']),0)
self.assertEqual(app.get_object('assets',asset['id'])['siteId'],child['id'])
def test_generic_devices_responsibilities_and_cross_family_access(self):
data=dict(name='卧室台灯',deviceType='灯',siteId=self.sites[self.aid]['id'],ownerName='家人甲',managerName='家人乙',tags=['照明','常用'])
code,device=self.request('/api/devices',data=data)
self.assertEqual(code,200);self.assertEqual(device['managerName'],'家人乙')
self.assertEqual([d['id'] for d in self.request('/api/state')[1]['devices']],[device['id']])
self.assertEqual(self.request('/api/state',self.other)[1]['devices'],[])
self.assertIn(self.request('/api/devices',self.other,dict(device,name='hijack'))[0],(403,404))
self.assertEqual(self.request('/api/devices',data=dict(data,siteId=self.sites[self.bid]['id']))[0],404)
self.assertEqual(self.request('/api/devices',self.members[self.aid],data)[0],403)
for invalid in (dict(data,tags='tag'),dict(data,deviceType='录像机'),dict(data,tags=['x']*13)):
self.assertEqual(self.request('/api/devices',data=invalid)[0],400)
def test_video_and_other_devices_share_responsibility_metadata(self):
body=dict(self.assets[self.aid],ownerName='设备所有人',managerName='设备负责人',tags=['视频'])
asset=app.save_asset(body,self.owner)
recorder=app.save_recorder(dict(name='Recorder',siteId=self.sites[self.aid]['id'],host='192.168.3.90',ownerName='设备所有人',managerName='设备负责人',tags=['视频']),self.owner)
self.assertEqual(asset['managerName'],recorder['managerName'])
self.assertEqual(asset['tags'],recorder['tags'])
body.pop('ownerName');body.pop('managerName');body.pop('tags')
self.assertEqual(app.save_asset(body,self.owner)['managerName'],'设备负责人')
if __name__=='__main__': unittest.main()
+32
View File
@@ -0,0 +1,32 @@
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
const app=fs.readFileSync('web/app.js','utf8'),homes=fs.readFileSync('web/households.js','utf8');
const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length));
test('API uses current household scope for each new request',async()=>{
const calls=[];const c=vm.createContext({sessionGeneration:0,householdScope:'house-a',fetch:async(p,o)=>{calls.push(o);return {ok:true,json:async()=>({})};}});
vm.runInContext(part(app,'async function api(','function toast('),c);
await c.api('/api/state');c.householdScope='house-b';await c.api('/api/users',{});
assert.equal(calls[0].headers['X-Household-Id'],'house-a');assert.equal(calls[1].headers['X-Household-Id'],'house-b');
});
test('switch destroys old private state before requesting another household',async()=>{
const events=[];const c=vm.createContext({state:{familyId:'a'},currentUser:{id:'root'},isPlatformAdmin:()=>true,
clearPrivateView(){events.push('clear');c.currentUser=null;c.state={};},auth:async()=>{events.push('auth');assert.equal(c.householdScope,'b');assert.equal(c.currentUser.id,'root');}});
vm.runInContext(part(homes,'async function switchHousehold(','function openHousehold('),c);await c.switchHousehold('b');
assert.deepEqual(events,['clear','auth']);
});
test('family administrator cannot invoke household switching',async()=>{
const c=vm.createContext({state:{familyId:'a'},isPlatformAdmin:()=>false,clearPrivateView:()=>{throw Error('should not switch');}});
vm.runInContext(part(homes,'async function switchHousehold(','function openHousehold('),c);await c.switchHousehold('b');
});
test('household switch invalidates outstanding API data',async()=>{
let finish;const c=vm.createContext({sessionGeneration:0,householdScope:'a',fetch:()=>new Promise(r=>finish=r)});
vm.runInContext(part(app,'async function api(','function toast('),c);
const pending=c.api('/api/users');c.sessionGeneration++;c.householdScope='b';finish({ok:true,json:async()=>({users:['private-a']})});
await assert.rejects(pending,e=>e.stale===true);
});
test('owner is visibly distinct from family and super administrators',()=>{
const c=vm.createContext({state:{households:[{ownerId:'owner'}]}});
vm.runInContext(part(app,'const roleName =','function applyPermissions(')+';globalThis.label=roleName;',c);
assert.equal(c.label({id:'owner',role:'family_admin'}),'一家之主');
assert.equal(c.label({id:'backup',role:'family_admin'}),'家庭管理员');
assert.equal(c.label({id:'root',role:'admin'}),'超级管理员');
});
+104
View File
@@ -0,0 +1,104 @@
import threading
import unittest
from unittest.mock import patch, Mock
from xml.etree.ElementTree import fromstring
import ptz
def xml(body):
return fromstring('<root xmlns:t="' + ptz.onvif.SCHEMA + '" xmlns:p="' + ptz.PTZ + '">' + body + '</root>')
class PresetTests(unittest.TestCase):
def setUp(self):
self.ctrl = ptz.Controller()
self.camera = dict(host='192.168.0.2', username='admin', password='example', mainPath='/stream1')
self.info = dict(endpoint=(80, '/onvif/PTZ'), profile='p1', node='shared')
self.snapshot = dict(revision='v1', maximum=12, items=[dict(token='1', name='Door')])
def test_read_presets_validates_device_and_list(self):
node = xml('<t:MaximumNumberOfPresets>12</t:MaximumNumberOfPresets>')
items = xml('<p:Preset token="1"><t:Name>Door</t:Name></p:Preset>')
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', side_effect=[node, items]):
result, _ = self.ctrl.read_presets(self.camera)
self.assertEqual(result['items'], self.snapshot['items'])
self.assertEqual(len(result['revision']), 64)
self.assertNotIn('endpoint', result)
for response in [xml('<t:MaximumNumberOfPresets>0</t:MaximumNumberOfPresets>'), xml('<t:MaximumNumberOfPresets>999999</t:MaximumNumberOfPresets>')]:
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', return_value=response):
with self.assertRaises(ValueError): self.ctrl.read_presets(self.camera)
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'call', side_effect=[node, xml('<p:Preset token="1"/><p:Preset token="1"/>')]):
with self.assertRaises(ValueError): self.ctrl.read_presets(self.camera)
def command(self, data, latest=None, response=None):
versions = [(self.snapshot, self.info)]
if latest is not None: versions.append((latest, self.info))
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'read_presets', side_effect=versions), patch.object(self.ctrl, 'call', return_value=response) as call:
result = self.ctrl.preset_command(self.camera, dict(revision='v1', **data))
return result, call
def test_creation_escapes_name_and_confirms_returned_token(self):
latest = dict(self.snapshot, items=[*self.snapshot['items'], dict(token='2', name='Yard & Gate')])
result, call = self.command(dict(action='create', name='Yard & Gate'), latest, xml('<p:PresetToken>2</p:PresetToken>'))
self.assertTrue(result['verified'])
self.assertIn('Yard &amp; Gate', call.call_args.args[4])
self.assertNotIn('<m:PresetToken>', call.call_args.args[4])
def test_stale_snapshot_and_unknown_token_cannot_write(self):
for data in [dict(action='create', name='Yard', revision='old'), dict(action='goto', token='unknown', revision='v1')]:
with patch.object(self.ctrl, 'discover', return_value=self.info), patch.object(self.ctrl, 'read_presets', return_value=(self.snapshot,self.info)), patch.object(self.ctrl, 'call') as call:
with self.assertRaises(ValueError): self.ctrl.preset_command(self.camera, data)
call.assert_not_called()
self.assertFalse(self.ctrl.locks[self.ctrl.node_key(self.camera,self.info)].locked())
def test_invalid_names_duplicate_names_and_full_device_rejected(self):
for name in ['', 'x'*41, 'bad\nname', None, ' door ']:
with self.assertRaises(ValueError): self.command(dict(action='create', name=name))
self.snapshot['maximum']=1
with self.assertRaises(ValueError): self.command(dict(action='create',name='Yard'))
def test_remove_readback_and_unsupported_replace_rejected(self):
result,call=self.command(dict(action='remove',token='1'),dict(self.snapshot,items=[]))
self.assertTrue(result['verified']);self.assertEqual(call.call_args.args[3],'RemovePreset')
with patch.object(self.ctrl,'call') as call:
with self.assertRaises(ValueError):self.ctrl.preset_command(self.camera,dict(action='replace',token='1',name='Door'))
call.assert_not_called()
def test_ignored_write_is_not_reported_as_verified(self):
result,_=self.command(dict(action='create',name='Yard'),self.snapshot,xml('<p:PresetToken>2</p:PresetToken>'))
self.assertFalse(result['verified'])
def test_readback_failure_releases_lock_and_is_not_retried(self):
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',side_effect=[(self.snapshot,self.info),OSError()]),patch.object(self.ctrl,'call') as call:
with self.assertRaises(OSError):self.ctrl.preset_command(self.camera,dict(action='remove',token='1',revision='v1'))
self.assertEqual(call.call_count,1)
self.assertFalse(self.ctrl.locks[self.ctrl.node_key(self.camera,self.info)].locked())
def test_goto_polls_device_and_always_stops(self):
status=xml('<t:MoveStatus><t:PanTilt>IDLE</t:PanTilt></t:MoveStatus>')
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',side_effect=[None,status,None]) as call,patch('ptz.threading.Event',return_value=Mock(wait=Mock(return_value=False))):
result=self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
self.assertTrue(result['completed'])
self.assertEqual([c.args[3] for c in call.call_args_list],['GotoPreset','GetStatus','Stop'])
self.assertFalse(self.ctrl.cancellations)
def test_goto_network_failure_still_attempts_stop(self):
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',side_effect=[OSError(),None]) as call:
with self.assertRaises(OSError):self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
self.assertEqual(call.call_args.args[3],'Stop');self.assertFalse(self.ctrl.cancellations)
def test_timeout_reports_incomplete_and_stops(self):
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'read_presets',return_value=(self.snapshot,self.info)),patch.object(self.ctrl,'call',return_value=xml('<t:MoveStatus><t:PanTilt>MOVING</t:PanTilt></t:MoveStatus>')) as call,patch('ptz.time.monotonic',side_effect=[0,0,11]),patch('ptz.threading.Event',return_value=Mock(wait=Mock(return_value=False))):
result=self.ctrl.preset_command(self.camera,dict(action='goto',token='1',revision='v1'))
self.assertFalse(result['completed']);self.assertEqual(call.call_args.args[3],'Stop')
def test_shared_node_blocks_preset_writes_but_stop_cancels(self):
key=self.ctrl.node_key(self.camera,self.info);lock=threading.Lock();lock.acquire();self.ctrl.locks[key]=lock
event=threading.Event();self.ctrl.cancellations[key]=event
with patch.object(self.ctrl,'discover',return_value=self.info),patch.object(self.ctrl,'call'):
with self.assertRaises(ValueError):self.ctrl.preset_command(self.camera,dict(action='create',name='Yard'))
self.ctrl.command(self.camera,'stop');self.assertTrue(event.is_set())
lock.release()
if __name__=='__main__':unittest.main()
+60
View File
@@ -0,0 +1,60 @@
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),Ranges=require('./web/recording-ranges.js');
const start=Date.parse('2026-10-04T00:00:00Z'),date=n=>new Date(start+n*1000);
test('ranges clip to selected day and merge overlaps without filling gaps',()=>{
const items=[{start:date(-10),duration:40},{start:date(20),duration:30},{start:date(60),duration:100},{start:'invalid',duration:20},{start:date(3),duration:-4},{start:date(3),duration:Infinity}];
assert.deepEqual(Ranges.normalize(items,date(0),date(100)),[{start:+date(0),end:+date(50)},{start:+date(60),end:+date(100)}]);
});
test('long recording continues after each one-hour session',()=>{
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(3600)});
assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null);
});
test('next segment skips the actual gap and does not replay the previous end',()=>{
const ranges=[{start:+date(0),end:+date(10)},{start:+date(30),end:+date(40)}];assert.equal(Ranges.at(ranges,date(10)),null);
assert.deepEqual(Ranges.next(ranges,date(10)),{start:+date(30),end:+date(40)});
});
test('clip requires valid, positive, fully recorded interval up to one hour',()=>{
const r=[{start:+date(0),end:+date(4000)},{start:+date(5000),end:+date(8000)}];
for(const [a,b] of [[date(1),date(1)],[date(4),date(3)],[date(0),date(3601)],[date(3900),date(5100)],[date(-1),date(5)],[new Date(NaN),date(5)]])assert.throws(()=>Ranges.clip(r,a,b));
assert.deepEqual(Ranges.clip(r,date(0),date(3600)),{start:+date(0),end:+date(3600)});
});
function section(file,a,b){const s=fs.readFileSync('web/'+file,'utf8'),i=s.indexOf(a),j=s.indexOf(b,i+a.length);assert.ok(i>=0&&j>i);return s.slice(i,j);}
function deferred(){let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};}
function ui(extra={}){const elements=new Map();const $=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){},reset(){},value:'',hidden:false});return elements.get(s);};return vm.createContext({$,Number,Date,URLSearchParams,loggedIn:true,activePage:'playback',RecordingRanges:Ranges,...extra});}
test('auto-continue preserves user choice, stops short clips and ends at last segment',()=>{
const c=ui({playbackRange:{start:0,end:10000},spans:[{start:0,end:10000},{start:20000,end:30000}],playAt:d=>c.played=+d});
vm.runInContext(section('recording-ui.js','function continuePlayback(','function makeClip('),c);
c.$('#play-video').ended=true;c.$('#play-video').currentTime=10;c.$('#play-autonext').checked=false;c.continuePlayback();assert.equal(c.played,undefined);
c.$('#play-autonext').checked=true;c.$('#play-video').currentTime=1;c.continuePlayback();assert.match(c.$('#play-info').textContent,/提前结束/);assert.equal(c.played,undefined);
c.$('#play-video').currentTime=10;c.continuePlayback();assert.equal(c.played,20000);assert.match(c.$('#play-info').textContent,/跳过/);
c.playbackRange={start:20000,end:30000};c.continuePlayback();assert.match(c.$('#play-info').textContent,/播放完毕/);
});
test('late recording list cannot replace another camera or a logged-out view',async()=>{
const p=deferred(),c=ui({playbackGeneration:2,queryCamera:'',resetPlayback(){},api:()=>p.promise,drawTimeline(){throw Error('late draw');}});
c.$('#play-camera').value='camera';c.$('#play-date').value='2026-10-04';c.$('#archive-source').value='local';
vm.runInContext(section('recording-ui.js','async function queryRecordings(','function drawTimeline('),c);
const work=c.queryRecordings();c.playbackGeneration++;p.resolve({items:[]});await work;assert.equal(c.spans,undefined);
});
test('download generation refuses gaps and invalidates old links first',()=>{
const c=ui({queryCamera:'camera',spans:[{start:+date(0),end:+date(60)}],playbackParams:()=>new URLSearchParams({start:'test'}),Error});
vm.runInContext(section('recording-ui.js','function makeClip(',"$('#query-recordings')"),c);
c.$('#clip-start').value=date(0).toISOString();c.$('#clip-end').value=date(61).toISOString();c.makeClip();assert.equal(c.$('#clip-download').hidden,true);assert.match(c.$('#clip-status').textContent,/超出/);
});
test('preset load from a closed panel is ignored',async()=>{
const p=deferred(),c=ui({ptzCamera:'first',ptzBusy:false,ptzPresetGeneration:0,clearPresetConfirmation(){},api:()=>p.promise,renderPresets(){throw Error('late render');}});
vm.runInContext(section('monitor.js','async function loadPresets(','async function openPtz('),c);
const work=c.loadPresets();c.ptzPresetGeneration++;c.ptzCamera='';p.resolve({items:[]});await work;
});
test('late goto result cannot overwrite a newer stop response',async()=>{
const p=deferred(),c=ui({ptzBusy:false,ptzCamera:'camera',ptzPresets:{revision:'1'},ptzGeneration:1,ptzCommandGeneration:1,clearPresetConfirmation(){},setPtzBusy(){},api:()=>p.promise});
vm.runInContext(section('monitor.js','async function presetCommand(','function closePtz('),c);
const work=c.presetCommand('goto','1');c.ptzCommandGeneration++;c.$('#preset-status').textContent='stopped';p.resolve({message:'old goto'});await work;assert.equal(c.$('#preset-status').textContent,'stopped');
});
test('snapshot callback cannot reveal a prior login frame after logout',async()=>{
let callback,created=0;const c=ui({sessionGeneration:1,snapshotGeneration:0,clearSnapshot(){c.snapshotGeneration++;},toast(){},URL:{createObjectURL(){created++;}},document:{createElement(){return{getContext:()=>({drawImage(){}}),toBlob:f=>callback=f};}}});
vm.runInContext(section('snapshot.js','async function captureFrame(','document.addEventListener'),c);
const work=c.captureFrame({readyState:4,videoWidth:100,videoHeight:50},'camera','Test');c.sessionGeneration++;c.loggedIn=false;callback({});await work;assert.equal(created,0);
});
test('snapshot rejects empty or errored video without producing a blob',async()=>{
let messages=0;const c=ui({toast:()=>messages++});vm.runInContext(section('snapshot.js','async function captureFrame(','document.addEventListener'),c);
await c.captureFrame({readyState:0,videoWidth:0});await c.captureFrame({readyState:4,videoWidth:100,error:{code:3}});assert.equal(messages,2);
});
+61
View File
@@ -0,0 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, 'web/chinese-theme.css'), 'utf8');
const html = fs.readFileSync(path.join(__dirname, 'web/index.html'), 'utf8');
const root = css.match(/:root\s*\{([^}]+)\}/)[1];
const token = name => root.match(new RegExp('--' + name + ':\\s*(#[0-9a-f]{6})'))[1];
function luminance(hex) {
const values = hex.slice(1).match(/../g).map(v => parseInt(v, 16) / 255)
.map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4);
return values[0] * .2126 + values[1] * .7152 + values[2] * .0722;
}
function contrast(a, b) {
const x = luminance(a), y = luminance(b);
return (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
}
test('paper theme is loaded last, versioned and uses only local fonts', () => {
const styles = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map(x => x[1]);
assert.equal(styles.at(-1), '/chinese-theme.css?v=__ASSET_VERSION__');
assert.doesNotMatch(css, /@import|https?:|@font-face/i);
assert.match(html, /name="theme-color" content="#213d34"/);
assert.match(html, /class="courtyard-landscape"[^>]+aria-hidden="true"/);
});
test('primary text, muted text and actions have at least 4.5:1 contrast', () => {
for (const [fg, bg] of [
[token('ink'), token('paper')], [token('muted'), token('paper')],
[token('muted'), token('panel')], ['#fffdf6', token('accent')],
['#c9d1be', '#213d34'], ['#244638', '#efebdc'],
['#355b3d', '#e4eddf'], ['#835428', '#f2e8d9'],
['#994437', '#fbf8ef'], ['#d9dfca', '#24372b'],
]) assert.ok(contrast(fg, bg) >= 4.5, `${fg} on ${bg}: ${contrast(fg, bg).toFixed(2)}`);
});
test('monitor fullscreen, reduced motion and mobile navigation are explicitly styled', () => {
assert.match(css, /#page-live:fullscreen\s*\{[^}]+color-scheme: dark/);
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(css, /overflow-x: auto/);
assert.match(css, /\.family-node\.self\s*\{[^}]+background: #f1ddb0/);
assert.match(css, /--timeline-paper:/);
});
test('recording timeline paints with theme tokens and keeps range geometry', () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, 'web/recording-ui.js'), 'utf8');
const draw = source.slice(source.indexOf('function drawTimeline()'), source.indexOf('function playbackParams('));
const rectangles = [];
const ctx = { scale() {}, beginPath() {}, moveTo() {}, lineTo() {}, stroke() {},
fillRect(...coordinates) { rectangles.push({ color: this.fillStyle, coordinates }); } };
const canvas = { clientWidth: 480, getContext: () => ctx };
const values = { '--timeline-paper': '#ede8dd', '--timeline-grid': '#d3ccbc', '--timeline-range': '#4c7761' };
const scope = vm.createContext({ $: () => canvas, window: { devicePixelRatio: 2 }, document: { documentElement: {} },
getComputedStyle: () => ({ getPropertyValue: name => values[name] }),
queryStart: new Date(0), queryEnd: new Date(60000), spans: [{ start: 10000, end: 20000 }] });
vm.runInContext(draw + ';drawTimeline()', scope);
assert.equal(canvas.width, 960);
assert.equal(canvas.height, 152);
assert.deepEqual(rectangles, [
{ color: '#ede8dd', coordinates: [0, 0, 480, 76] },
{ color: '#4c7761', coordinates: [80, 25, 80, 25] },
]);
assert.equal(ctx.strokeStyle, '#d3ccbc');
});
+41 -46
View File
@@ -2,54 +2,67 @@
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
const kind = (v) => ({home:'家庭',company:'办公',office:'办公',factory:'工厂',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], devices:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
const isAdmin = () => currentUser?.role === 'admin';
let householdScope = '';
const isPlatformAdmin = () => currentUser?.role === 'admin';
const isAdmin = () => ['admin','family_admin'].includes(currentUser?.role);
const roleName = user => {const h=(state.households||[]).find(h=>h.id===state.familyId)||{},home=!h.kind||h.kind==='home';return user.role==='admin'?'超级管理员':user.role==='family_admin'?(h.ownerId===user.id?(home?'一家之主':'场所负责人'):(home?'家庭管理员':'场所管理员')):(home?'家庭成员':'场所成员');};
function applyPermissions(){
const admin=isAdmin();
$('nav [data-page="storage"]').hidden=!isPlatformAdmin();
$('#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="storage"]').hidden=!isPlatformAdmin();
$('.discovery').hidden=!isPlatformAdmin();
$('#scan-result').hidden=!isPlatformAdmin();
$('#new-household').hidden=!isPlatformAdmin();
$('#household-context').hidden=!loggedIn;
$('#household-filter').disabled=!isPlatformAdmin();
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
$('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : '';
}
function clearPrivateView(){
if(typeof clearSnapshot==='function')clearSnapshot();
if(typeof resetDeviceView==='function')resetDeviceView();
if(typeof clearCameraSettings==='function')clearCameraSettings();
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
currentUser=null;stopPlayers();resetPlayback();
state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list'])$('#'+id).replaceChildren();
currentUser=null;householdScope='';stopPlayers();resetPlayback();
state={cameras:[],assets:[],recorders:[],devices:[],sites:[],storage:{},scan:{}};
for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list','household-list'])$('#'+id).replaceChildren();
if(typeof clearFamilyState==='function')clearFamilyState();
$$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close());
$$('dialog form').forEach(f=>f.reset());
activePage='live';showPage('live');applyPermissions();
}
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['设备管理','按类型、区域、负责人或所有人,找到每一台设备。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['区域与空间','层级与名称由你定义,设备各有归属。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
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'}),...(typeof householdScope!=='undefined'&&householdScope?{'X-Household-Id':householdScope}:{})}, body:data === undefined ? undefined : JSON.stringify(data)});
let result; try { result = await r.json(); } catch { throw 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;
}
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
function siteName(id) { return state.sites.find(s => s.id === id)?.name || '未关联空间'; }
function siteName(id) { return ZhaoDevices.path(state.sites,id).map(s=>s.name).join(' / ') || '未关联空间'; }
function address(s) { return [s.province,s.city,s.district,s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; }
function filtered() { return state.cameras.filter(c => !$('#site-filter').value || c.siteId === $('#site-filter').value); }
function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;return state.cameras.filter(c=>!range||range.has(c.siteId)); }
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
function showPage(page) {
if(!isAdmin()&&!['live','playback','family'].includes(page))return;
if(page==='storage'&&!isPlatformAdmin())return;
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
activePage = page;
$('.metrics').hidden=['family','users'].includes(page);
$('.filter-row').hidden=['family','users'].includes(page);
$('.metrics').hidden=['family','users','households'].includes(page);
$('.filter-row').hidden=['family','users','households'].includes(page);
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page));
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['cameras','recorders'].includes(page)));
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
if (page !== 'playback') resetPlayback();
render();
@@ -58,7 +71,7 @@ function showPage(page) {
if (page === 'users') loadUsers();
}
function render() {
$('#metric-all').textContent = state.assets.length;
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
$('#metric-ready').textContent = state.cameras.filter(c=>c.ready).length;
$('#metric-sites').textContent = state.sites.length;
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
@@ -67,7 +80,7 @@ function render() {
$('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有';
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
$('#banner').textContent = warning; $('#banner').hidden = !warning;
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind) + ' · ' + s.name)}</option>`).join(''));
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join(''));
if (activePage === 'live') renderLive();
if (activePage === 'cameras') renderCameras();
if (activePage === 'sites') renderSites();
@@ -78,16 +91,17 @@ function render() {
drawTimeline();
}
if (activePage === 'storage') renderStorage();
if(typeof renderHouseholds==='function')renderHouseholds();
applyPermissions();
if(activePage==='live'&&typeof wallSync==='function')wallSync();
}
function empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
function renderLive() {
const cameras = filtered(), quality = $('#quality').value;
const key = ZhaoLivePlayer.key(cameras, quality);
const key = (currentUser?.role||'')+':'+(state.familyId||'')+':'+ZhaoLivePlayer.key(cameras, quality);
if (key === liveKey) { updateLiveStatus(cameras); return; }
stopPlayers(); liveKey = key;
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-camera="${c.id}">截图</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
for (const c of cameras.filter(c=>c.enabled)) {
const video = $(`video[data-camera="${c.id}"]`);
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
@@ -112,8 +126,8 @@ function renderCameras() {
$('#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() {
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.assets.filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>');
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id));
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
}
function renderStorage() {
const s=state.storage;
@@ -123,7 +137,7 @@ function renderStorage() {
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,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() {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.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);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 formValues(f) {return Object.fromEntries(new FormData(f).entries());}
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
@@ -137,39 +151,20 @@ $('#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();};
$('#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];}};
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='';}
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);
window.addEventListener('beforeunload',stopPlayers);
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(){
const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
$('#asset-list').innerHTML=assets.length?assets.map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
}
function renderAssets(){renderDevices();}
function renderRecorders(){
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;$('#recorder-dialog').showModal();}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;}
$('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint;
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=>({...deviceValues(d),lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...deviceValues(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;}};
+255
View File
@@ -0,0 +1,255 @@
/* 赵府智家 · 庭院书卷。Keep visual tokens separate from layout and playback. */
:root {
color-scheme: light;
--paper: #f4f0e7;
--ink: #2d3932;
--muted: #6e6b5f;
--line: #dcd5c6;
--panel: #fffdf8;
--accent: #315b4d;
--cinnabar: #9e4b3d;
--gold: #86682f;
--serif: "Songti SC", "STSong", "SimSun", serif;
--timeline-paper: #ede8dd;
--timeline-grid: #d3ccbc;
--timeline-range: #4c7761;
color: var(--ink);
background: var(--paper);
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
}
body {
background-image: radial-gradient(ellipse at 85% 0, #e4e9df80, transparent 50%);
min-width: 0;
}
::selection { background: #e8d7ad; color: #2d3932; }
button, .button, input, select, textarea, fieldset { border-radius: 5px; }
button, .button { background: var(--accent); color: #fffdf6; font-weight: 500; transition: background .15s, border-color .15s, box-shadow .15s; }
button:hover, .button:hover { filter: brightness(.95); }
.secondary { background: #e8ede5; color: #315b4d; border-color: #c7d3c4; }
.quiet { background: transparent; color: #586653; border-color: #cfcfbf; }
input, select, textarea { color: var(--ink); background: #fffefa; border-color: #cbc5b6; }
input::placeholder, textarea::placeholder { color: #847f72; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #315b4d12; }
input[type=range], input[type=checkbox] { accent-color: var(--accent); }
label, .family-date legend { color: #625f53; }
h1, h2, h3 { font-family: var(--serif); font-weight: 600; }
h1 { font-size: 34px; letter-spacing: 4px; margin-top: 17px; }
h2 { letter-spacing: 1px; }
.sidebar {
width: 222px;
background: #213d34;
background-image: linear-gradient(145deg, #2d4b3d, transparent 60%);
border-right: 1px solid #425344;
padding: 30px 18px 20px;
color: #ebe7d8;
scrollbar-width: thin;
scrollbar-color: #6f8271 #213d34;
}
.sidebar .brand { gap: 11px; padding: 4px 5px 30px; color: #f3eddf; font-family: var(--serif); font-size: 22px; letter-spacing: 2px; }
.brand-icon { width: 44px; height: 44px; border-radius: 5px; outline: 1px solid #c6b98c66; outline-offset: 3px; }
.brand small { font-family: "Microsoft YaHei", sans-serif; color: #c0c7b5; font-size: 9px; letter-spacing: 2px; margin-top: 10px; }
.nav-label { color: #b2bba6; font-size: 10px; padding: 12px 13px 17px; letter-spacing: 3px; border-top: 1px solid #b9c2aa24; }
.sidebar nav { gap: 5px; }
.sidebar nav button { color: #c9d1be; border-radius: 4px; padding: 12px 13px; gap: 13px; position: relative; border: 1px solid transparent; background: transparent; }
.sidebar nav button span { font-size: 13px; letter-spacing: .6px; }
.sidebar nav button:hover { background: #ffffff09; filter: none; }
.sidebar nav button.active { background: #efebdc; border-color: #e0d6b8; color: #244638; }
.sidebar nav button.active::after { content: ""; width: 5px; height: 5px; background: var(--cinnabar); position: absolute; right: 13px; transform: rotate(45deg); }
.sidebar .side-note { padding: 22px 5px 8px; color: #c4ccb9; font-size: 11px; }
.sidebar .dot { background: #afc39c; width: 5px; height: 5px; }
.sidebar #current-user { color: #d3d7c8; padding: 10px 5px 0; border-top: 1px solid #b9c2aa24; }
.sidebar .quiet { color: #d1d6c6; border-color: #8b9a813f; padding: 9px; font-size: 11px; }
.sidebar #account-password { margin-bottom: 8px; }
.sidebar :focus-visible { outline-color: #e7c887; }
main { margin-left: 222px; padding: 30px 38px 22px; max-width: 1900px; }
main > header { min-height: 142px; padding: 13px 0 23px; margin-bottom: 25px; border-bottom: 1px solid #b4b5a2; position: relative; gap: 24px; isolation: isolate; }
main > header::after { content: ""; position: absolute; bottom: -2px; left: 0; height: 3px; width: 48px; background: var(--cinnabar); }
main > header > div { position: relative; z-index: 1; }
.eyebrow { color: var(--gold); font-size: 10px; letter-spacing: 3px; }
header p { color: #686f60; font-size: 12px; letter-spacing: .5px; }
.header-status { color: #666e5e; gap: 12px; font-size: 11px; flex-shrink: 0; }
.courtyard-landscape { position: absolute; width: 330px; height: 125px; right: 5px; bottom: 0; z-index: 0; pointer-events: none; opacity: .55; }
.badge { background: #e4eddf; color: #355b3d; border: 1px solid #c8d6be; border-radius: 3px; font-size: 10px; }
.badge.off { background: #f2e8d9; color: #835428; border-color: #dfc6a3; }
.metrics { background: #fcfaf4; border-radius: 6px; box-shadow: 0 3px 12px #3a402905; }
.metrics > div { padding: 20px 22px; align-items: baseline; }
.metrics span { color: #6f7260; }
.metrics strong { font-family: Georgia, var(--serif); color: #365846; font-size: 29px; }
.filter-row { color: #6e6b5f; margin: 24px 0; }
.household-context { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; margin: -5px 0 24px; padding: 12px 16px; border-left: 3px solid var(--gold); background: #eae8dc; }
.household-context span { font-size: 11px; color: var(--muted); }
.household-context select { min-width: 140px; max-width: 240px; }
.section-head { margin-top: 25px; }
.section-head small { color: #8a7859; font-size: 9px; letter-spacing: 2px; }
.wall-toolbar { padding: 12px 15px; background: #eae8dc; border: 1px solid var(--line); border-radius: 5px; gap: 14px; }
.wall-toolbar select { background: #f9f6ed; }
.live-grid { gap: 22px; }
.camera-card, .site-card, .panel, .empty, .family-canvas-panel { border-radius: 6px; box-shadow: 0 4px 18px #39422a05; }
.camera-card { border-color: #d2cebe; }
.camera-meta { padding: 17px 18px 13px; }
.camera-meta strong { font-family: var(--serif); font-size: 16px; letter-spacing: .5px; }
.camera-meta p { color: #737464; margin-top: 7px; }
.camera-actions { flex-wrap: wrap; gap: 7px; padding-bottom: 16px; }
.camera-actions .quiet { border-color: #dbd9cb; color: #52604d; background: #faf9f2; }
.video-panel { border-radius: 5px; border-color: #c1c6b8; }
.video-empty { color: #b0bdae; background: radial-gradient(ellipse at center, #243d34 0, transparent 70%); }
.video-empty .lens { color: #a5bca4; }
.video-error { color: #f6d7c1; background: #472c26ed; }
.empty { color: #6c7462; border-color: #c3c6b6; }
.empty h3 { color: #384e3b; font-size: 21px; }
.panel { padding: 24px; }
.time-labels { color: #6e705e; }
.recording-item { color: #35533f; background: #f9f7ee; border-color: #d5d1c0; }
.recording-item small { color: #737261; }
.scan-chip { background: #fffdf7; border-radius: 5px; }
.device-table { border-radius: 5px; }
.device-table th { color: #6a6d5c; background: #ecebde; }
.device-table td small { color: #737261; }
.device-table tbody tr:hover { background: #f2f1e7; }
.site-card h3 { font-size: 20px; }
.site-card dt { color: #737261; }
.site-card dd { color: #384e3b; }
.audit-item { color: #56654f; }
.audit-item small { color: #767461; }
.banner { color: #784c26; background: #f6e9cf; border-color: #dfc797; }
dialog { background: #fbf8ef; color: var(--ink); border: 1px solid #d5c8ac; border-radius: 8px; box-shadow: 0 28px 100px #172c3455; padding: 30px; }
dialog::backdrop { background: #20382cb8; backdrop-filter: blur(5px); }
.dialog-heading { border-bottom: 1px solid var(--line); padding-bottom: 20px; gap: 16px; }
.dialog-heading h2 { font-size: 23px; }
.dialog-footer { gap: 10px; flex-wrap: wrap; }
#auth-dialog { border-top: 4px solid var(--cinnabar); padding: 38px; background-image: radial-gradient(ellipse at 100% 0, #e4e9dd, transparent 65%); }
#auth-dialog .brand-icon { width: 52px; height: 52px; }
#auth-form h2 { font-size: 32px; letter-spacing: 3px; margin-top: 26px; }
#auth-form p { color: #6b725d; }
.form-error { color: #994437; }
footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
#toast { background: #2a4c3c; color: #f3efdc; border-color: #819672; border-radius: 5px; box-shadow: 0 8px 25px #30433726; max-width: calc(100vw - 30px); }
.ptz-panel { background: #f8f5eb; border-color: #b9c5ad; border-radius: 7px; box-shadow: 0 12px 50px #26392d44; color: var(--ink); }
.ptz-pad [data-ptz-action=stop] { color: #8b4236; background: #efdbd0; border-color: #d5b2a4; }
#preset-confirmation { background: #f4e2d5; }
#snapshot-image { border-radius: 4px; }
/* Family register: paper, ink lines and a restrained gold highlight for oneself. */
.family-canvas-panel { background: #fcfaf3; border-color: #c7cbb7; }
.family-map-heading { padding-top: 24px; }
.family-map-heading h2 { letter-spacing: 3px; font-size: 25px; }
.family-view-tabs { background: #f1eee2; border-color: #d7d0bf; border-radius: 4px; }
.family-view-tabs button { color: #606650; border-radius: 2px; }
.family-view-tabs [aria-pressed=true] { background: #365945; color: #fffbed; }
.family-map-viewport { background-color: #efece0; background-image: radial-gradient(#c9cbbb 1px, transparent 1px); scrollbar-color: #8b9a7b #e8e6d8; }
.family-map-lines marker path { fill: #678b71; }
.family-edge { stroke: #94a58a; }
.family-edge.focused { stroke: #446b50; }
.family-edge.spouse { stroke: #a9854f; }
.family-edge.nonbiological { stroke: #93829e; }
.family-node { background: #fffdf6; border-color: #c9cdb9; border-radius: 5px; color: #304632; box-shadow: 0 3px 12px #3943250d; }
.family-node > strong { font-family: var(--serif); font-size: 17px; }
.family-node-avatar { background: #e8ecdf; color: #536e45; border-radius: 4px; }
.family-node .family-node-relation { color: #53664a; }
.family-node > small, .family-node-flags > small { color: #6f775f; }
.family-node.self { background: #f1ddb0; border-color: #af873f; box-shadow: 0 5px 18px #b4872425; }
.family-node.self .family-node-avatar { background: #f8eac9; }
.family-node.selected { outline-color: #426c50; }
.family-node.search-hit::after { background: #596b78; }
.self-badge { background: #94682f; color: #fff8e9; border-radius: 2px; }
.family-map-footer { border-color: #d0d3c0; }
.family-map-legend { color: #69705b; }
.family-map-footer #family-map-note:not(:empty) { color: #87652f; }
.family-inspector.is-self { background: linear-gradient(135deg, #f3e3bd, #fcfaf1 65%); border-color: #c3a66b; }
.family-self-heading { color: #88652d; letter-spacing: 1px; }
.family-register { background: #eeecdf; }
.person-card { color: #344732; border-color: #c9cbb9; }
.person-card.selected { background: #e6ecdc; outline-color: #66845b; }
.person-card.self { background: #f1ddb0; border-color: #b89553; color: #5f461f; }
.person-card.self span:not(.self-badge), .person-card.self small { color: #765b2e; }
.family-tree ul { border-color: #a2b197; }
.family-workspace:is(:fullscreen, .family-expanded) { background: var(--paper); }
.family-event-filters { background: #f0eee2; }
.timeline-year { color: #456344; font-family: var(--serif); }
.timeline-event::before { border-color: #b7c2a5; }
.timeline-event::after { background: #768d5f; box-shadow: 0 0 0 4px #e7ebdc; }
.timeline-event > time { color: #6c745d; }
.timeline-event-body { background: #fffdf6; border-color: #d3d6c2; border-radius: 5px; }
.timeline-event:hover .timeline-event-body { background: #f7f7ec; border-color: #8caa78; }
.timeline-event-body > strong { color: #344d32; font-family: var(--serif); font-size: 18px; }
.timeline-event-status, .timeline-summary, .timeline-event-body > small { color: #6b725b; }
.timeline-read { color: #41623b; }
.timeline-event.draft .timeline-event-body { background: #efede3; }
.timeline-event.draft::after { background: #a28a62; }
.event-detail-meta, .person-event-link time { color: #71745e; }
.event-lead { background: #eaecdd; border-color: #8e9f77; color: #46553b; }
.event-article { color: #424c3a; }
.person-event-link { color: #455a3c; }
.history-columns dl > div { border-color: #d9d6c4; }
.history-changed { background: #e8ecda; border-radius: 4px; }
#history-confirm { border-color: #b3be98; border-radius: 5px; background: #efefdf; }
/* The monitoring wall stays dark to keep attention on live video. */
#page-live:fullscreen { --muted: #c0c9b4; --line: #52614d; --panel: #25352c; color: #efecdd; background: #111c16; color-scheme: dark; }
#page-live:fullscreen .wall-toolbar { background: #24372b; border-color: #40543e; }
#page-live:fullscreen label { color: #d9deca; }
#page-live:fullscreen select { background: #1a2a20; color: #ebeede; border-color: #65765a; }
#page-live:fullscreen .quiet { color: #d9dfca; border-color: #66765a; background: transparent; }
#page-live:fullscreen .camera-card { border-color: #394f38; }
#page-live:fullscreen .camera-actions { background: #17251ee8; }
#page-live:fullscreen .ptz-panel { --muted: #6e6b5f; --line: #dcd5c6; color-scheme: light; }
#page-live:fullscreen .ptz-panel label, #page-live:fullscreen .ptz-panel .quiet { color: #586653; border-color: #cfcfbf; }
@media (min-width: 1600px) { main { padding-left: 46px; padding-right: 46px; } }
@media (max-width: 1100px) {
.sidebar { width: 200px; padding: 27px 14px 18px; }
.sidebar .brand { font-size: 20px; gap: 9px; }
.brand-icon { width: 38px; height: 38px; }
main { margin-left: 200px; padding: 24px; }
.metrics > div { padding: 18px 14px; }
.metrics strong { font-size: 25px; }
.courtyard-landscape { width: 260px; opacity: .4; }
}
@media (max-width: 760px) {
.sidebar { position: static; width: auto; padding: 22px 17px 14px; display: grid; grid-template-columns: 1fr auto; gap: 0 10px; overflow: visible; }
.sidebar .brand { padding: 2px 4px 20px; font-size: 22px; grid-column: 1/-1; }
.brand-icon { width: 38px; height: 38px; }
.brand small { font-size: 9px; }
.sidebar nav { grid-column: 1/-1; display: flex; overflow-x: auto; gap: 5px; padding-bottom: 12px; scrollbar-width: thin; scrollbar-color: #819573 #213d34; }
.sidebar nav button { padding: 10px 12px; gap: 7px; flex-shrink: 0; }
.sidebar nav button span { font-size: 12px; }
.sidebar nav button.active::after { display: none; }
.sidebar #current-user { position: static; grid-column: 1/-1; margin: 4px 0 10px; padding-top: 12px; font-size: 11px; }
.sidebar #account-password, .sidebar #logout { margin: 0; font-size: 11px; padding: 8px 12px; align-self: center; width: fit-content; }
.sidebar #logout { justify-self: end; }
main { margin: 0; padding: 20px 17px; }
main > header { min-height: 132px; padding-top: 9px; gap: 10px; margin-bottom: 20px; }
h1 { font-size: 28px; letter-spacing: 3px; }
.eyebrow { font-size: 9px; letter-spacing: 2px; }
.header-status { font-size: 9px; gap: 9px; max-width: 123px; padding-top: 2px; }
.header-status .badge { font-size: 9px; }
header p { max-width: 205px; font-size: 11px; line-height: 1.8; }
.courtyard-landscape { width: 170px; height: 90px; right: 0; opacity: .38; }
.metrics > div { padding: 16px; }
.metrics strong { font-size: 25px; }
.metrics > div:nth-last-child(-n+2) { border-bottom: 0; }
.filter-row { margin: 20px 0; }
.filter-row select { max-width: 155px; }
.filter-row > button { padding: 9px; }
.section-head { flex-wrap: wrap; }
.section-head > div { gap: 10px; }
.wall-toolbar { padding: 11px; gap: 10px; }
.wall-toolbar label { font-size: 12px; }
#wall-fullscreen { padding: 10px 12px; }
.camera-meta { padding: 14px; }
.camera-meta strong { font-size: 15px; }
.camera-actions { padding: 0 14px 14px; gap: 8px; }
.camera-actions button { padding: 9px 11px; }
.panel, .site-card { padding: 18px; }
.family-map-heading h2 { font-size: 23px; }
dialog { padding: 22px; }
#auth-dialog { padding: 30px 26px; }
.dialog-heading h2 { font-size: 21px; }
.dialog-footer { gap: 8px; }
footer { gap: 16px; flex-wrap: wrap; font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
.household-actions{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.household-context select{max-width:100%}.household-context label{min-width:0}
.device-actions,.device-search-row,.device-group-order,.group-step{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.device-search-row{align-items:end}.device-search-row label{flex:1;min-width:160px}.device-group-order{margin:12px 0}.group-step{padding:7px;background:var(--paper);border:1px solid var(--line);border-radius:5px}.group-step select{width:auto;max-width:145px}.group-step button{padding:7px 11px}.group-step>span{color:var(--muted)}
.device-group{margin:26px 0}.device-group>h3{font-family:var(--serif);font-size:20px;overflow-wrap:anywhere}.device-group>h3>span{color:var(--gold);padding:0 8px}.device-group>h3 small{font:12px var(--sans);color:var(--muted);white-space:nowrap}.device-tags{display:flex;flex-wrap:wrap;gap:6px}.device-tags span{font-size:11px;padding:3px 8px;background:var(--paper);border:1px solid var(--line)}.site-card dd,.site-card .description{overflow-wrap:anywhere;min-width:0}
@media(max-width:600px){.device-actions{width:100%}.device-actions button{flex:1;white-space:normal}.group-step{width:100%}.group-step select{flex:1;max-width:none;min-width:0}.device-group>h3{font-size:18px}}
+35
View File
@@ -0,0 +1,35 @@
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.ZhaoDevices=api;})(typeof window!=='undefined'?window:this,function(){
'use strict';
function path(sites,id){
const map=new Map(sites.map(s=>[s.id,s])),seen=new Set(),result=[];
while(id&&map.has(id)&&!seen.has(id)){seen.add(id);const s=map.get(id);result.push(s);id=s.parentId;}
return result.reverse();
}
function descendants(sites,id){
const children=new Map();for(const s of sites){if(!children.has(s.parentId))children.set(s.parentId,[]);children.get(s.parentId).push(s.id);}
const found=new Set(),queue=[id];for(let i=0;i<queue.length;i++){const key=queue[i];if(found.has(key))continue;found.add(key);queue.push(...(children.get(key)||[]));}return found;
}
function catalog(state){return [
...(state.assets||[]).map(x=>({...x,source:'camera',deviceType:'摄像机'})),
...(state.recorders||[]).map(x=>({...x,source:'recorder',deviceType:'录像机'})),
...(state.devices||[]).map(x=>({...x,source:'device'}))];}
function groups(devices,dimensions,sites){
const result=new Map();
for(const device of devices){
let branches=[[]];
for(const dimension of dimensions){
let options;
if(dimension==='space'){
const parts=path(sites,device.siteId);options=[parts.length?parts.map(s=>({key:'space:'+s.id,label:s.name})):[{key:'space:none',label:'未指定区域'}]];
}else{
const values=dimension==='tag'?(device.tags?.length?device.tags:['未标记']):[dimension==='type'?device.deviceType:dimension==='manager'?(device.managerName||'负责人待指定'):(device.ownerName||'所有人待指定')];
options=[...new Set(values)].map(value=>[{key:dimension+':'+value,label:value}]);
}
branches=branches.flatMap(branch=>options.map(option=>branch.concat(option)));
}
for(const branch of branches){const key=JSON.stringify(branch.map(x=>x.key));if(!result.has(key))result.set(key,{key,labels:branch.map(x=>x.label),items:[]});result.get(key).items.push(device);}
}
return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN'));
}
return {path,descendants,catalog,groups};
});
+49
View File
@@ -0,0 +1,49 @@
'use strict';
let deviceGroups=['type','space'],deviceGroupScope='',deviceViewSignature='';
const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'};
function renderDeviceGrouping(){
$('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`<div class="group-step"><span>${i+1}</span><select data-group-index="${i}" aria-label="第 ${i+1} 分组">${Object.entries(deviceDimensions).map(([key,label])=>`<option value="${key}" ${key===g?'selected':''} ${deviceGroups.includes(key)&&key!==g?'disabled':''}>${label}</option>`).join('')}</select><button class="quiet" data-group-up="${i}" aria-label="第 ${i+1} 分组前移" ${i===0?'disabled':''}>↑</button><button class="quiet" data-group-remove="${i}" aria-label="移除第 ${i+1} 分组">×</button></div>`).join('');
$('#device-group-add').disabled=deviceGroups.length>=Object.keys(deviceDimensions).length;
}
function deviceCard(d){
const location=ZhaoDevices.path(state.sites,d.siteId).map(s=>s.name).join(' / ')||'未指定区域';
const attr=d.source==='camera'?'edit-asset':d.source==='recorder'?'edit-recorder':'edit-device';
const detail=d.source==='camera'?`<div><dt>镜头 / 通道</dt><dd>${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道</dd></div>`:d.source==='recorder'?`<div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===d.id).length} 个</dd></div>`:'<div><dt>接入状态</dt><dd>仅登记档案,尚未接入控制</dd></div>';
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span><button class="quiet" data-${attr}="${esc(d.id)}">编辑</button></div><h3>${esc(d.name)}</h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${detail}</dl>${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
}
function renderDevices(){
const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
const signature=JSON.stringify(saved);
if(deviceGroupScope!==state.familyId||deviceViewSignature!==signature){deviceGroupScope=state.familyId;deviceViewSignature=signature;deviceGroups=[...saved];renderDeviceGrouping();}
const search=$('#device-search').value.trim().toLowerCase();
const all=ZhaoDevices.catalog(state),types=[...new Set(all.map(d=>d.deviceType))].sort();
setOptions($('#device-type'),'<option value="">所有类型</option>'+types.map(type=>`<option value="${esc(type)}">${esc(type)}</option>`).join(''));
const category=$('#device-type').value;
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&(!search||[d.name,d.deviceType,d.ownerName,d.managerName,...(d.tags||[])].join(' ').toLowerCase().includes(search)));
$('#device-count').textContent=`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
$('#asset-list').innerHTML=devices.length?ZhaoDevices.groups(devices,deviceGroups,state.sites).map(group=>`<section class="device-group"><h3>${group.labels.map(esc).join(' <span aria-hidden="true">›</span> ')||'全部设备'} <small>${group.items.length} 台</small></h3><div class="site-grid">${group.items.map(deviceCard).join('')}</div></section>`).join(''):empty('当前条件下没有设备','可以清除筛选,或添加摄像机、录像机、灯、手机等设备档案。');
}
function resetDeviceView(){deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='<option value="">所有类型</option>';$('#device-group-order').replaceChildren();}
function responsibilityFields(form,item){for(const key of ['ownerName','managerName'])form.elements[key].value=item?.[key]||'';form.elements.tags.value=(item?.tags||[]).join('、');}
function deviceValues(data){return {...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};}
function openDevice(id){
const item=(state.devices||[]).find(d=>d.id===id)||{},f=$('#device-form');
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);responsibilityFields(f,item);$('#device-dialog').showModal();
}
function spaceOptions(exclude=''){
const omitted=exclude?ZhaoDevices.descendants(state.sites,exclude):new Set();
return state.sites.filter(s=>!omitted.has(s.id)).map(s=>`<option value="${esc(s.id)}">${esc(siteName(s.id))}</option>`).join('');
}
$('#device-search').oninput=renderDevices;$('#device-type').onchange=renderDevices;
$('#device-group-order').addEventListener('change',e=>{if(e.target.dataset.groupIndex!==undefined){deviceGroups[Number(e.target.dataset.groupIndex)]=e.target.value;renderDeviceGrouping();renderDevices();}});
$('#device-group-add').onclick=()=>{const next=Object.keys(deviceDimensions).find(k=>!deviceGroups.includes(k));if(next){deviceGroups.push(next);renderDeviceGrouping();renderDevices();}};
$('#device-view-save').onclick=async()=>{try{await api('/api/device-view',{groups:deviceGroups});await refresh();toast('已保存为本家庭或场所的默认分组');}catch(e){if(!e.stale)toast(e.message);}};
$('#new-device').onclick=()=>openDevice();
handleForm('device-form','/api/devices',deviceValues);
document.addEventListener('click',e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.editDevice)openDevice(b.dataset.editDevice);
if(b.dataset.groupRemove!==undefined){deviceGroups.splice(Number(b.dataset.groupRemove),1);renderDeviceGrouping();renderDevices();}
if(b.dataset.groupUp!==undefined){const i=Number(b.dataset.groupUp);if(i>0){[deviceGroups[i-1],deviceGroups[i]]=[deviceGroups[i],deviceGroups[i-1]];renderDeviceGrouping();renderDevices();}}
});
+4 -3
View File
@@ -67,16 +67,17 @@ for(const [name,path]of [['person','person'],['relation','link']]){
async function loadUsers(){
if(!isAdmin())return;const id=currentUser.id;
try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f;
$('#user-list').innerHTML=`<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>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${u.role==='admin'?'管理员':'个人账号'}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role==='admin'?'管理':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}</td><td>${u.role==='admin'?'全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
$('#user-list').innerHTML=`<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>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}</td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
}catch(e){toast(e.message);}
}
function openUser(id){
if(!isAdmin())return;const f=$('#user-form'),u=users.find(u=>u.id===id)||{};
f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML='<option value="">尚未指定</option>'+users.map(u=>`<option value="${u.id}">${esc(u.name+' / '+u.username)}</option>`).join('');
fillForm(f,u);f.elements.password.required=!u.id;
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(s.name)}</label>`).join('');$('#user-dialog').showModal();
f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin();
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(siteName(s.id))}</label>`).join('');$('#user-dialog').showModal();
}
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;d.includeSubspaces=f.elements.includeSubspaces.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#new-person').onclick=()=>openPerson();$('#new-user').onclick=()=>openUser();$('#family-reload').onclick=loadFamily;
$('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderFamily;$('#family-archived').onchange=renderFamily;
$('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
+57
View File
@@ -0,0 +1,57 @@
'use strict';
pageInfo.households=['家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
function renderHouseholds(){
const homes=state.households||[];
setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));
$('#household-filter').value=state.familyId||'';
if(activePage!=='households')return;
$('#household-list').innerHTML=homes.map(h=>`<article class="site-card"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span><button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button></div><h3>${esc(h.name)}</h3><p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">${h.id===state.familyId?'管理本归属成员':'进入该归属'}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</article>`).join('');
}
async function switchHousehold(id){
if(!isPlatformAdmin()||!id||id===state.familyId)return;
const user=currentUser;
// Invalidate outstanding API responses, video players and dialogs before changing scope.
clearPrivateView();
householdScope=id;currentUser=user;loggedIn=true;
await auth();
}
function openHousehold(id){
if(!isAdmin())return;
const item=(state.households||[]).find(h=>h.id===id);
if(!item&&!isPlatformAdmin())return;
fillForm($('#household-form'),item||{});$('#household-dialog').showModal();
}
async function openLeadership(id){
try{
if(id!==state.familyId)await switchHousehold(id);
const home=(state.households||[]).find(h=>h.id===id);
if(!home?.canAssignOwner)return;
const result=await api('/api/users');
const candidates=result.users.filter(u=>u.role==='family_admin'&&!u.disabled);
const f=$('#leadership-form'),options=candidates.map(u=>`<option value="${esc(u.id)}">${esc(u.name+' / '+u.username)}</option>`).join('');
f.elements.ownerId.innerHTML='<option value="">请选择主要负责人</option>'+options;
f.elements.successorId.innerHTML='<option value="">暂未指定</option>'+options;
fillForm(f,{id:home.id,revision:home.leadershipRevision||0,ownerId:home.ownerId||'',successorId:home.successorId||''});
$('#leadership-dialog').showModal();
}catch(error){if(!error.stale)toast(error.message);}
}
$('#leadership-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
});
$('#household-filter').onchange=e=>switchHousehold(e.target.value);
$('#new-household').onclick=()=>openHousehold();
$('#household-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{await api('/api/households',formValues(f));f.closest('dialog').close();await refresh();toast('归属档案已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
finally{b.disabled=false;}
});
document.addEventListener('click',async e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.editHousehold)openHousehold(b.dataset.editHousehold);
if(b.dataset.leadership)await openLeadership(b.dataset.leadership);
if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('users');}
});
+35 -27
View File
@@ -2,53 +2,56 @@
<html lang="zh-CN">
<head>
<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__">
<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>
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/device-model.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><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>HOME / 本地家庭空间</small></span></a>
<div class="nav-label">工作空间</div>
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>家和 · 岁岁安宁</small></span></a>
<div class="nav-label">家宅 · 起居</div>
<nav aria-label="主导航">
<button data-page="live" class="active">◫ <span>实时画面</span></button>
<button data-page="playback">◷ <span>录像回放</span></button>
<button data-page="family">♧ <span>家谱</span></button>
<button data-page="households" data-admin>⌂ <span>家庭与场所</span></button>
<button data-page="users" data-admin>♙ <span>账号与关系</span></button>
<button data-page="objects">▣ <span>摄像头对象</span></button><button data-page="recorders">▤ <span>录像机</span></button><button data-page="cameras">◫ <span>镜头通道</span></button>
<button data-page="sites">⌂ <span>空间档案</span></button>
<button data-page="objects">▣ <span>设备管理</span></button>
<button data-page="sites">⌂ <span>区域与空间</span></button>
<button data-page="storage">▤ <span>录像与存储</span></button>
</nav>
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
</aside>
<main>
<header><div><div class="eyebrow">ZHAOFU HOME / FAMILY</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<div id="household-context" class="household-context" hidden><label>当前归属 <select id="household-filter" aria-label="当前归属"></select></label><span>家庭、办公场所与工厂独立授权</span></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>
<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 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>
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>守望</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div>
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
<div class="panel playback-controls"><label>摄像头<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
<div class="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 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 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-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>RECORDERS</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>PLACES</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device">+ 其他设备</button><button data-page="cameras" class="quiet">镜头通道与接入</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="名称、负责人、所有人、标签"></label></div><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button id="device-view-save" class="secondary">保存为本归属的默认视图</button></div><p id="device-count" class="description"></p></div><div id="asset-list"></div></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>FAMILY REGISTER</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><button id="family-find" class="quiet" hidden>定位搜索结果</button><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
<div id="family-error" class="form-error" role="status"></div>
<div id="family-workspace" class="family-workspace">
<section id="family-canvas-panel" class="family-canvas-panel" aria-label="全家关系图">
<div class="family-map-heading"><div><span class="eyebrow">FAMILY CONNECTIONS</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
<div class="family-map-heading"><div><span class="eyebrow">枝叶相连 · 世代相承</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
<div id="family-map-controls" class="family-map-controls"><div class="family-zoom-controls"><button id="family-zoom-out" class="quiet" aria-label="缩小家谱">−</button><output id="family-zoom-value" aria-live="polite">100%</output><button id="family-zoom-in" class="quiet" aria-label="放大家谱">+</button><button id="family-fit" class="quiet">查看全谱</button></div><button id="family-locate-self" class="quiet">◎ 定位本人</button><button id="family-fullscreen" class="quiet">全屏家谱</button></div>
<div id="family-tree" class="family-map-viewport" tabindex="0" role="region" aria-label="家谱关系图,可拖动空白处或使用滚动条浏览"></div>
<div id="family-people" class="family-people family-register" hidden></div>
@@ -62,23 +65,28 @@
<aside id="family-detail" class="panel family-inspector" aria-label="人物档案"></aside>
</div>
</section>
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">亲属称呼用于描述人物关系。摄像头空间权限、家谱权限和管理员身份分别设置。无需为每一位家谱人物创建账号。</p><div id="user-list"></div></section>
<section id="page-households" class="page" hidden><div class="section-head"><h2>各有归属 <small>家庭 · 办公 · 工厂</small></h2><button id="new-household">+ 新建家庭或场所</button></div><p class="description">每个家庭、办公场所或工厂独立管理自己的成员、区域和设备。内部区域与分组顺序由各自定制;管理员只管理自己的归属,超级管理员可管理全部。</p><div id="household-list" class="site-grid"></div></section>
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">这里只管理当前家庭或场所的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。</p><div id="user-list"></div></section>
<footer><span>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer>
</main>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:莱阳老家"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
<dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"><option value="手机"><option value="冰箱"><option value="空调"><option value="传感器"><option value="电脑"><option value="其他"></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>系统角色<select name="role"><option value="member">个人账号</option><option value="admin">管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">个人账号仅能观看已选空间的实时画面和回放;管理员可以管理全部空间。</p></fieldset><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<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="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">FAMILY CHRONICLE</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">家事 · 家声</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
<dialog id="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog>
<dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog>
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调与预置位:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
<div id="toast" role="status" hidden></div>
<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>
</html>
+64 -14
View File
@@ -70,23 +70,73 @@ document.addEventListener('click',e=>{
if(b.dataset.wallBefore||b.dataset.wallAfter){const ids=wallOrder.filter(id=>filtered().some(c=>c.id===id)),id=b.dataset.wallBefore||b.dataset.wallAfter;wallMove(id,ids[ids.indexOf(id)+(b.dataset.wallBefore?-1:1)]);}
if(b.dataset.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){
const generation=++ptzGeneration;ptzCamera=id;$('#ptz-panel').hidden=false;
$('#ptz-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像头')+' · 云台';
$('#ptz-status').textContent='正在读取云台能力…';$$('[data-ptz-action]').forEach(b=>b.disabled=true);
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);}
catch(e){if(generation===ptzGeneration)$('#ptz-status').textContent=e.message;}
if(ptzCamera&&ptzCamera!==id)closePtz();
const generation=++ptzGeneration;ptzCamera=id;ptzReady=false;$('#ptz-panel').hidden=false;
$('#ptz-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像头')+' · 云台与预置位';
$('#ptz-status').textContent='正在读取云台能力…';setPtzBusy(false);loadPresets();
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){
if(!ptzCamera)return;const id=ptzCamera,generation=ptzGeneration;
$$('[data-ptz-action]:not([data-ptz-action="stop"])').forEach(b=>b.disabled=true);
$('#ptz-status').textContent=action==='stop'?'正在停止…':'正在微调…';
try{await api('/api/ptz',{camera:id,action});if(generation===ptzGeneration)$('#ptz-status').textContent='停止指令已确认,可继续微调';}
catch(e){if(generation===ptzGeneration)$('#ptz-status').textContent=e.message;}
finally{if(generation===ptzGeneration)$$('[data-ptz-action]').forEach(b=>b.disabled=false);}
if(!ptzCamera||(!ptzReady&&action!=='stop')||(ptzBusy&&action!=='stop'))return;
const id=ptzCamera,generation=ptzGeneration,command=++ptzCommandGeneration;
setPtzBusy(true);$('#ptz-status').textContent=action==='stop'?'正在停止…':'正在微调…';
try{await api('/api/ptz',{camera:id,action});if(generation===ptzGeneration&&command===ptzCommandGeneration)$('#ptz-status').textContent='停止指令已确认,可继续微调';}
catch(e){if(!e.stale&&generation===ptzGeneration&&command===ptzCommandGeneration)$('#ptz-status').textContent=e.message;}
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;
$$('[data-ptz-action]').forEach(b=>b.onclick=()=>ptzCommand(b.dataset.ptzAction));
document.addEventListener('visibilitychange',()=>{if(document.hidden&&ptzCamera)ptzCommand('stop');});
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-reload').onclick=loadPresets;
$('#preset-form').onsubmit=e=>{e.preventDefault();presetCommand('create',undefined,$('#preset-name').value);};
$('#preset-confirm-cancel').onclick=clearPresetConfirmation;
$('#preset-confirm-yes').onclick=()=>{if(ptzConfirmation){const p=ptzConfirmation;presetCommand(p.action,p.token,p.name);}};
$('#preset-list').onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.presetGoto)presetCommand('goto',b.dataset.presetGoto);if(b.dataset.presetRemove)confirmPreset('remove',b.dataset.presetRemove);};
document.addEventListener('visibilitychange',()=>{if(document.hidden&&ptzCamera&&ptzBusy)ptzCommand('stop');});
window.addEventListener('pagehide',()=>{if(ptzCamera&&ptzBusy)fetch('/api/ptz',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:ptzCamera,action:'stop'})}).catch(()=>{});});
+21
View File
@@ -0,0 +1,21 @@
'use strict';
// Pure range logic shared by the UI and regression tests. All ranges are [start,end).
const RecordingRanges=(()=>{
function normalize(items,start,end){
const lo=+start,hi=+end;if(!Number.isFinite(lo)||!Number.isFinite(hi)||hi<=lo)return [];
const ranges=(Array.isArray(items)?items:[]).filter(s=>s&&Number.isFinite(+new Date(s.start))&&Number.isFinite(Number(s.duration))&&Number(s.duration)>0).map(s=>({start:Math.max(lo,+new Date(s.start)),end:Math.min(hi,+new Date(s.start)+Number(s.duration)*1000)}))
.filter(s=>Number.isFinite(s.start)&&Number.isFinite(s.end)&&s.end>s.start).sort((a,b)=>a.start-b.start);
const result=[];for(const r of ranges){const last=result.at(-1);if(last&&r.start<=last.end)last.end=Math.max(last.end,r.end);else result.push({...r});}return result;
}
function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+3600000)}:null;}
function next(ranges,end){const s=ranges.find(s=>s.end>+end);return s?at(ranges,Math.max(s.start,+end)):null;}
function clip(ranges,start,end){
const a=+start,b=+end;
if(!Number.isFinite(a)||!Number.isFinite(b)||b<=a)throw Error('结束时间必须晚于开始时间');
if(b-a>3600000)throw Error('一次下载最多一小时,请缩短片段');
if(!ranges.some(s=>a>=s.start&&b<=s.end))throw Error('所选片段跨越无录像时段或超出查询日期,请选择同一连续录像时段');
return {start:a,end:b};
}
return {normalize,at,next,clip};
})();
if(typeof module!=='undefined')module.exports=RecordingRanges;
+61
View File
@@ -0,0 +1,61 @@
'use strict';
let playbackRange=null;
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
function resetPlayback(){
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
$('#play-empty').hidden=false;$('#download').hidden=true;$('#clip-download').hidden=true;$('#clip-status').textContent='';$('#clip-form').reset();
spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';$('#play-position').textContent='';drawTimeline();$('#recording-list').innerHTML='';
}
async function queryRecordings(){
resetPlayback();const generation=playbackGeneration,id=$('#play-camera').value,date=$('#play-date').value;
if(!id||!date){toast('请选择摄像头和日期');return;}
if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。现有录像仍可在录像机或原厂 App 中查看。';return;}
queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id;
$('#play-info').textContent='正在查找录像…';
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));
if(generation!==playbackGeneration||!loggedIn)return;
spans=RecordingRanges.normalize(d.items,queryStart,queryEnd);
$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();
$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${new Date(s.start).toISOString()}">${new Date(s.start).toLocaleTimeString()} — ${new Date(s.end).toLocaleTimeString()}<small>${((s.end-s.start)/60000).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');
if(spans.length)playAt(new Date(spans[0].start));
}catch(e){if(!e.stale&&generation===playbackGeneration)$('#play-info').textContent=e.message;}
}
function drawTimeline(){
const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);const theme=getComputedStyle(document.documentElement);ctx.fillStyle=theme.getPropertyValue('--timeline-paper').trim()||'#ede8dd';ctx.fillRect(0,0,w,76);ctx.strokeStyle=theme.getPropertyValue('--timeline-grid').trim()||'#d3ccbc';
for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}
if(!queryStart||!queryEnd)return;ctx.fillStyle=theme.getPropertyValue('--timeline-range').trim()||'#4c7761';const total=queryEnd-queryStart;
for(const s of spans){const a=(s.start-queryStart)/total,b=(s.end-queryStart)/total;if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}
}
function playbackParams(range){return new URLSearchParams({camera:queryCamera,start:new Date(range.start).toISOString(),duration:String((range.end-range.start)/1000)});}
function playAt(date,preserveClip=false){
const range=RecordingRanges.at(spans,date);if(!range){toast('这个时间没有录像,请选择绿色时段');return;}
playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{});
$('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每次最多一小时,结束后按续播选项处理';$('#play-empty').hidden=true;
$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;
if(!preserveClip){$('#clip-start').value=localDateTime(range.start);$('#clip-end').value=localDateTime(range.end);$('#clip-download').hidden=true;$('#clip-status').textContent='可修改起止时间后生成下载链接';}
}
function continuePlayback(){
const v=$('#play-video');if(!loggedIn||activePage!=='playback'||!playbackRange||!v.ended)return;
if(v.currentTime+2<(playbackRange.end-playbackRange.start)/1000){$('#play-info').textContent='录像提前结束,已停止续播;请核对该片段或重新查询';return;}
if(!$('#play-autonext').checked){$('#play-info').textContent='本段播放结束,自动续播已关闭';return;}
const end=playbackRange.end,next=RecordingRanges.next(spans,end);
if(!next){$('#play-info').textContent='所选日期的已查询录像播放完毕';return;}
playAt(new Date(next.start),true);if(next.start>end)$('#play-info').textContent=`已跳过无录像时段,续播 ${new Date(next.start).toLocaleString()}`;
}
function makeClip(){
$('#clip-download').hidden=true;
try{if(!queryCamera)throw Error('请先查询录像');const range=RecordingRanges.clip(spans,new Date($('#clip-start').value),new Date($('#clip-end').value));
const a=$('#clip-download');a.href='/media/playback?'+playbackParams(range)+'&download=1';a.setAttribute('download','recording.mp4');a.hidden=false;$('#clip-status').textContent=`已选 ${((range.end-range.start)/60e3).toFixed(2)} 分钟,下载保留原始编码。`;
}catch(e){$('#clip-status').textContent=e.message;}
}
$('#query-recordings').onclick=queryRecordings;
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=resetPlayback;$('#play-date').onchange=resetPlayback;
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
$('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};$('#play-video').onended=continuePlayback;
$('#play-video').ontimeupdate=()=>{if(playbackRange)$('#play-position').textContent='录像时间 '+new Date(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000)).toLocaleString();};
$('#clip-form').onsubmit=e=>{e.preventDefault();makeClip();};
for(const id of ['clip-start','clip-end'])$('#'+id).oninput=()=>{$('#clip-download').hidden=true;$('#clip-status').textContent='起止时间已修改,请重新生成链接';};
$$('[data-clip-mark]').forEach(b=>b.onclick=()=>{if(!playbackRange||!$('#play-video').videoWidth){toast('请先播放录像');return;}$('#clip-'+b.dataset.clipMark).value=localDateTime(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000));$('#clip-download').hidden=true;$('#clip-status').textContent='已使用当前播放位置,请重新生成链接';});
$('#play-date').value=localDateTime(Date.now()).slice(0,10);window.addEventListener('resize',drawTimeline);
+17
View File
@@ -0,0 +1,17 @@
'use strict';
let snapshotUrl='',snapshotGeneration=0;
function clearSnapshot(){snapshotGeneration++;if(snapshotUrl)URL.revokeObjectURL(snapshotUrl);snapshotUrl='';$('#snapshot-image').removeAttribute('src');$('#snapshot-save').removeAttribute('href');$('#snapshot-info').textContent='';}
async function captureFrame(video,cameraId,label){
if(!loggedIn||!video||video.readyState<2||!video.videoWidth||video.error){toast('画面尚未就绪,请播放后再截图');return;}
clearSnapshot();const generation=snapshotGeneration,session=sessionGeneration,canvas=document.createElement('canvas');
canvas.width=video.videoWidth;canvas.height=video.videoHeight;const captured=new Date();
try{canvas.getContext('2d').drawImage(video,0,0);const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
if(!loggedIn||session!==sessionGeneration||generation!==snapshotGeneration)return;
if(!blob)throw Error('图片生成失败');snapshotUrl=URL.createObjectURL(blob);$('#snapshot-image').src=snapshotUrl;
$('#snapshot-title').textContent=label+' · 画面截图';$('#snapshot-info').textContent=`${canvas.width} × ${canvas.height} · 本次截取时间 ${captured.toLocaleString()}。图片取自当前播放器画面,包含播放缓冲延迟;不上传到服务器。`;
$('#snapshot-save').href=snapshotUrl;$('#snapshot-save').download='snapshot-'+cameraId+'-'+captured.toISOString().replace(/[:.]/g,'-')+'.png';$('#snapshot-dialog').showModal();
}catch(e){if(generation===snapshotGeneration)toast('截图失败,请重新播放后再试');}
}
document.addEventListener('click',e=>{const b=e.target.closest('[data-snapshot-camera]');if(b){const id=b.dataset.snapshotCamera;captureFrame(b.closest('.camera-card').querySelector('video'),id,state.cameras.find(c=>c.id===id)?.name||'摄像头');}});
$('#play-snapshot').onclick=()=>captureFrame($('#play-video'),queryCamera,'录像回放');
$('#snapshot-dialog').addEventListener('close',clearSnapshot);
+4
View File
@@ -22,3 +22,7 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
#page-live:fullscreen .camera-actions [data-camera-settings]{display:none}
@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%}}