Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f0ef31fc | |||
| 56a24c80e2 | |||
| 80f8bd72af | |||
| 8d58ad0a2f | |||
| de6eae176d | |||
| 38fad1a46d | |||
| 52b5a6b389 | |||
| de6933c411 | |||
| b3aff9f2c4 | |||
| 84a84d9af0 | |||
| 2649854386 |
+18
-2
@@ -27,7 +27,7 @@
|
||||
|
||||
当前 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 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
|
||||
|
||||
## 管理 API
|
||||
|
||||
@@ -53,6 +53,22 @@
|
||||
|
||||
本阶段实现独立运行、空间建模、三类视觉对象、实时视频接入和本机可选录像。摄像头真实接入结果由部署记录和主机运行状态说明。
|
||||
|
||||
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测、多用户细分权限目前没有实现,不作为已具备的能力展示。
|
||||
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。
|
||||
|
||||
配置写入 SQLite 并生成运行时媒体配置,录像数据独立存储。当前为单机模式;升级前备份整个数据目录。凭据和录像不包含在公开源码中。
|
||||
|
||||
|
||||
## 身份与家谱
|
||||
|
||||
`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。家谱目前是同一部署的一份共享家庭档案,按账号授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。
|
||||
|
||||
| API | 权限 |
|
||||
| --- | --- |
|
||||
| GET/POST /api/users | 管理员 |
|
||||
| GET /api/family | 家谱查看或编辑 |
|
||||
| POST /api/family/person | 家谱编辑 |
|
||||
| POST /api/family/link | 家谱编辑 |
|
||||
| GET /api/state | 按空间过滤,个人账号不返回设备连接凭据与配置 |
|
||||
| GET /media/live/*、/media/playback、/api/recordings | 登录并具备对应空间权限 |
|
||||
|
||||
人物与关系修订在同一数据库事务中保存;修改关系时迭代检查亲子图,拒绝循环。前端 `kinship.js` 通过最短关系路径提供阅读称呼,未知长幼与复杂旁系明确显示不确定性或关系链。家谱页面不启动摄像头播放;返回实时画面时重新建立所需流。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 标识
|
||||
|
||||
资产:`web/zhao-icon.png`。使用内置 imagegen 生成,作为网页品牌和 favicon;无需外部图片服务。
|
||||
|
||||
“赵”字的甲骨文风格设计。香港中文大学[汉语多功能字库的「趙」条目](https://humanum.arts.cuhk.edu.hk/Lexis/lexi-mf/search.php?word=%E8%B6%99)收录金文、小篆等字例,其甲骨栏目未列出字例。这里的标识是现代风格化创作,不宣称为已考证甲骨文字形。
|
||||
|
||||
生成提示词:
|
||||
|
||||
Use case: logo-brand. Create one polished app icon for 赵府智家, a private Chinese family home monitoring and genealogy application. Square app icon, dark deep teal #10191d rounded-square ground with generous mint #74d5bb single-color linework. The only subject is a single ancient-Chinese-style character emblem for the surname 趙 (modern simplified 赵), inspired by oracle bone carved strokes and the ancient components 走 and 肖. It is a contemporary oracle-bone-style design, NOT a claimed archaeological facsimile. Keep the ancient walking-person/foot component on the left and 肖-derived component on the right recognizable as one cohesive character. Thick clean slightly tapering carved strokes, balanced quiet confident composition, very legible as a small favicon, no photographic texture, no gradients, no glow, no shadows, no extra symbols, no English or captions, no surrounding words, no multiple options or mockup. Character occupies 72 percent of square, centered. Flat professional icon asset ready for website navigation and browser tab.
|
||||
@@ -18,7 +18,9 @@
|
||||
|
||||
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
|
||||
|
||||
HLS 会有数秒延迟。网页默认使用 FFmpeg 按需生成的 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒,供小电脑上的多镜头预览。“高清 · 主码流兼容”使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。用户仍可选择原始主、子码流,但 H.265 的兼容性取决于浏览器。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
|
||||
HLS 会有数秒延迟。网页清晰度仅提供“主码流”和“子码流”两个选项,默认主码流。主码流预览使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。子码流预览使用实际子码流,由 FFmpeg 按需生成 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。网页不提供原始编码直出的预览选项。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
|
||||
|
||||
实时预览按镜头独立恢复连接:普通状态轮询只更新标签,不重建播放器;断流后按 2–30 秒退避重连,画面长时间不前进时也会自动恢复。用户主动暂停会保留暂停状态,离开页面会清理播放器和重试任务。
|
||||
|
||||
## Ubuntu x86_64 部署
|
||||
|
||||
@@ -30,7 +32,7 @@ sudo VISION_BIND=<本机的Tailscale-IPv4> bash deploy/install.sh
|
||||
|
||||
安装脚本从系统软件源安装 FFmpeg;检测到 Intel renderD128 时安装 Intel 媒体驱动,授予服务用户 render 组权限,并校验安装固定版本 MediaMTX v1.21.1,创建无登录权限的服务用户,数据写入 `/var/lib/zhaovision`,应用入口为 `http://<Tailscale-IP>:8790/`。可用逗号分隔的 `VISION_BIND` 同时指定本机局域网与 Tailscale 地址;已安装系统在 `/etc/zhaovision.env` 中修改并重启服务。视频服务的 HTTP、RTSP 与管理 API 全部仅监听回环地址,由应用进行登录鉴权并代理。
|
||||
|
||||
首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 10 位管理密码。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。
|
||||
首次打开页面,读取服务器上的 `/var/lib/zhaovision/setup-code.txt`,输入初始化码,设置至少 8 位管理密码(初始用户名为 admin)。初始化后码文件删除。摄像头与录像机密码在自己的界面填写。
|
||||
|
||||
```sh
|
||||
systemctl status zhaovision zhaovision-media
|
||||
@@ -47,7 +49,9 @@ python3 app.py
|
||||
mediamtx ./data/mediamtx.yml
|
||||
```
|
||||
|
||||
访问 `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 是单管理员模型。
|
||||
回归测试:`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`。测试使用临时数据库和模拟播放器,不连接真实设备。
|
||||
|
||||
访问 `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 使用管理员与个人账号分权。
|
||||
|
||||
## 数据与恢复
|
||||
|
||||
@@ -65,3 +69,96 @@ mediamtx ./data/mediamtx.yml
|
||||
[系统架构与对象关系](ARCHITECTURE.md)。
|
||||
|
||||
本项目源码以 MIT 许可证发布。v0.1.x 为初始版本,真实录像机的历史回放兼容性需完成型号适配后另行验证。
|
||||
|
||||
## 账号与家谱(v0.1.11)
|
||||
|
||||
- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置、网络发现和审计接口仅管理员可用。媒体播放请求逐次检查空间权限。
|
||||
- 每个账号具有独立用户名和密码,密码使用 scrypt 散列存储;更新密码、角色、空间范围、家谱权限或停用状态会撤销该账号会话。亲属关系不自动授予权限。
|
||||
- 管理员在“账号与关系”中设置显示名称、相对于哪个账号的称呼,以及关联的家谱人物。支持常用称呼与自定义称呼。默认新个人账号没有空间或家谱权限。
|
||||
- 家谱是本部署内的一份家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。未实现跨家庭租户隔离。
|
||||
- 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。日期可按公历或农历录入,并自动换算;不确定日期可留空,在备注中保留原记载。
|
||||
- 亲子关系按“父母一方 → 子女”逐条连接,支持亲生、收养、继亲和未注明;配偶关系为双向。禁止自我关系、重复关系、祖先循环、直系祖先与配偶冲突。关系可以停用后恢复,人物可归档后恢复。
|
||||
- 支持关系参照人物、搜索、全家关系图、人物名册和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。
|
||||
- 并发编辑使用家谱修订号,旧表单提交会被拒绝,避免覆盖他人的更新。导出包含人物和关系,不含密码和设备凭据。当前导出用于保存副本,尚未提供 JSON 导入界面。
|
||||
|
||||
### 从 v0.1.10 升级
|
||||
|
||||
部署前用 SQLite backup API 保存 `/var/lib/zhaovision/vision.db`。首次启动 v0.1.11 会将原有单密码账户迁移为 `admin`,保留原密码散列,并把既有会话关联到管理员;没有固定出厂密码。新增 `users`、`people`、`family_links` 表和 `sessions.user_id` 列。以后可以直接升级;若回退到 v0.1.10,必须同时恢复升级前数据库副本,因为旧版本使用旧账户模型。人物和实际账号数据仅在受限数据目录中,不包含在公开源码内。
|
||||
|
||||
## 监控墙、云台与日期(v0.1.13)
|
||||
|
||||
- 监控墙支持自适应(每页最多六路)、1/2/4/6/9/16 分屏、翻页、拖动和前移/后移排列。布局和顺序按账号保存在当前浏览器;不跨设备同步。排列和分屏只改 CSS 与可见性,不重建播放器。全屏使用浏览器 Fullscreen API,Esc 退出。属于网页全屏模式,尚非可离线运行的安装包。
|
||||
- 当前监控页的通道保持连接,翻页隐藏的通道也继续播放,以便切回时不重复连接;大规模通道应评估转码和网络开销。浏览器处于前台并保持系统唤醒时可持续监看。
|
||||
- 云台读取 ONVIF PTZ 能力,仅对有匹配媒体配置、标准连续方向速度空间和一秒超时支持的直连摄像头提供四方向短距离微调。每次移动明确设置 PT1S,随后发 Stop;同一物理节点串行控制,独立停止按钮不排队。设备未声明的变焦、回到原点等功能不显示。协议参考:[ONVIF PTZ](https://www.onvif.org/ver20/ptz/wsdl/)。ONVIF 服务端口默认为 80。
|
||||
- 管理员具有云台权限;个人账号需同时具备空间访问和独立的云台控制权限。新账号默认不允许转动。两个镜头如果指向同一 PTZ 节点,控制的是同一物理云台,不能据镜头数量推断独立电机。
|
||||
- 人物默认在世;只在“不在世”时显示去世日期,旧人物的“待确认”状态保持原样。出生/去世各使用一个日期输入,选择历法,农历可勾选闰月;数据库统一保存公历日期,避免两份日期不一致。使用浏览器 ICU Chinese calendar,不联网。公历转农历范围 1901–2100,农历录入范围 1901–2099。更早祖先生卒日期可填公历并保留原始记载;不对古代历法作精确推断。
|
||||
- 站点与标签页图标为“赵”字的甲骨文风格原创设计,并非考古字形摹本。参考字库及生成说明见 [ICON.md](ICON.md)。
|
||||
|
||||
|
||||
## 完整关系图与家族记事(v0.1.16)
|
||||
|
||||
### 看全家
|
||||
|
||||
进入“家谱”默认打开完整关系图,包含有效的亲子和配偶关系;归档人物默认隐藏,可勾选显示。每位人物只出现一次,不按代数截断。父母在上、子女在下,配偶在不违反亲子方向时并列;跨层配偶和复杂多重关系保留连线及提示。尚未建立关系的人单独展示,不推测亲属身份。
|
||||
|
||||
暖金色和“本人”标识只用于当前账号关联的家谱人物,由管理员在“账号与关系 → 关联家谱人物”设置。关系参照和当前选中人物可另外切换,不改变本人身份。管理员未关联人物时会提示,不把其他人误标为本人。支持点击档案、名册、人物搜索、放大缩小、拖动空白处、滚动条、查看全谱、定位本人、全屏和 Esc 退出。浏览器拒绝原生全屏时使用网页内铺满显示。
|
||||
|
||||
### 记事的完整流程
|
||||
|
||||
1. 在“家族时间线 → 写一则记事”,或人物档案“记一件事”开始记录。
|
||||
2. 填写标题、发生日期、摘要、正文,并可关联多位人物、附报道来源和 HTTP/HTTPS 链接。发生日期支持公历或农历输入,沿用日期换算范围,统一存储公历。
|
||||
3. 可保存草稿,编辑完成后发布。发布范围是拥有家谱权限的账号,不是互联网匿名访问。家谱编辑者及管理员可查看和维护草稿、已发布内容、归档记录;只读成员仅能查看未归档的已发布记事。
|
||||
4. 时间线默认从近到远,按发生年份分组,支持年份、人物、关键词、顺序和发布状态筛选。人物筛选可额外包括其直接父母、配偶和子女;每条记事按关联人物追溯,全家记事在“全家”范围显示。
|
||||
5. 点击记事阅读详情或跳转原始报道;点击关联人物返回图谱和档案。人物档案可以再进入该人物的时间线,形成双向浏览。关系图默认不展开记事内容。
|
||||
6. 归档保留数据;在“已归档”打开记录并编辑,取消归档后保存即可恢复。记事与人物、关系共用修订号,并发旧表单拒绝覆盖新版本。正文是纯文本,不执行 HTML 或嵌入第三方页面。
|
||||
|
||||
JSON 导出升级为格式 version 2,包含人物、关系和当前账号有权看到的记事。导出用于保存副本,尚无导入界面。外部报道只保存链接和用户填写的内容,不自动抓取网页;链接失效时,已填写的正文仍保存在本机。
|
||||
|
||||
### 升级及数据
|
||||
|
||||
启动时幂等创建 `family_events` 表,无需修改既有人物、账号、摄像头或录像记录。部署前使用 SQLite backup API 备份数据库。还原备份会回退备份后的数据,旧版本看不到记事表;应保留升级后的数据库备份,不以旧版本界面作为记事恢复工具。源码中不含真实家族记事和人物信息。
|
||||
|
||||
### 代理缓存兼容(v0.1.17)
|
||||
|
||||
入口 HTML 禁止缓存;页面引用的脚本和样式自动带上发布版本号,避免 NPM 等反向代理为旧资源设置长缓存后,新页面混用旧脚本。升级时普通重新载入即可获取与页面一致的资源。
|
||||
|
||||
## 逻辑一致性修复(v0.1.18)
|
||||
|
||||
- 记事正文实际支持 12000 字中文、摘要支持换行;该接口的 JSON 请求上限为 128 KiB,其他接口保持 16 KiB,并拒绝无效 Content-Length。
|
||||
- 编辑已发布记事默认“保存修改”保持发布状态,Enter 保存也不会撤回。撤回为草稿使用独立按钮;首次发布时间在撤回、重发和归档恢复之间保留。
|
||||
- 遇到家谱并发修订冲突,当前输入保留;复制需要保留的内容后,可明确点击“读取最新版本(覆盖本次输入)”继续编辑。服务器保存成功而页面重新读取失败时,提示已保存,避免误以为需要再次提交。
|
||||
- 从记事跳到归档人物时自动显示归档人物。当前账号的本人标记与关系参照人物区分;变更本人关联会重新布局。
|
||||
- 新建、恢复亲生关系及修改人物出生日期时,已知父母出生日期必须早于子女;日期未知以及收养、继亲关系不据此推断或拒绝。
|
||||
- 退出或切换账号后,旧请求不得重新写入私有视图;过时的状态刷新不会覆盖较新的结果。共享浏览器标签页切换账号时,在下一次状态刷新或会话检查中清理前一账号的视图。
|
||||
- 日期表单按人物或记事分别校验,编辑记事不会清除另一张人物表单的去世日期。
|
||||
|
||||
|
||||
## 摄像机参数、版本留存与账号自助(v0.1.19)
|
||||
|
||||
### 摄像机设置
|
||||
|
||||
管理员在实时画面或镜头通道中点击“摄像机设置”。服务通过已保存的设备凭据读取能力,精确匹配当前镜头的主 / 子码流路径(包括查询参数)。不接受客户端提供的任意设备服务地址。
|
||||
|
||||
- 显示设备型号、固件、镜头视频源及编码。图像设置作用于该物理镜头,主、子码流共用。
|
||||
- 按设备实际支持范围提供亮度、对比度、饱和度、锐度、日夜模式、背光补偿、宽动态及白平衡。
|
||||
- 主、子码流分别提供分辨率、帧率、码率上限、画质和关键帧间隔;未开放的参数不会出现。
|
||||
- 保留现有视频编码及设备扩展字段。有些设备使用 H264 兼容结构报告 H265,不能仅凭标准 Encoding 字段就改写编码。
|
||||
- 保存前重新读取、验证范围并检查参数版本,同一物理设备串行保存。保存后回读实际值,未确认或不一致会提示重新核对,不自动反复写入。
|
||||
- 修改码流可能短暂中断实时画面,并影响录像机收到的码流。当前设备参数写入仅管理员可用。
|
||||
- 双向对讲、自动跟踪、告警灯 / 警笛、画面翻转、隐私遮蔽、云录像、消息推送尚未接入;界面明确列出这些边界。设备宣告支持某个 ONVIF 服务不等于本系统已经实现该功能。
|
||||
|
||||
协议依据:[ONVIF Imaging Service](https://www.onvif.org/specs/srv/img/ONVIF-Imaging-Service-Spec-v1606.pdf)、[ONVIF Media Service](https://www.onvif.org/specs/srv/media/ONVIF-Media-Service-Spec-v240.pdf)。本系统按设备返回的选项校验与设置,不依赖萤石云账号。
|
||||
|
||||
### 家谱修改记录
|
||||
|
||||
人物档案、亲属关系及记事详情都有历史入口。每次保存将修改前 / 后内容、操作者与时间、家谱版本号写入 `family_changes`,与实际记录一起提交。可以分页查阅、对照旧版并选择恢复;恢复会重新验证现有家谱约束,并生成新版本,不删除后续历史。不提供创建前的空版本恢复。
|
||||
|
||||
历史包含曾撤回的内容,只对家谱编辑者和管理员开放。读取版本后若家谱发生变化,恢复会拒绝并要求重新读取。历史从本版本开始记录;升级前的内容会在下一次修改时作为“修改前”保存。历史属于私有数据库,不进入公共源码或普通家谱 JSON 导出。
|
||||
|
||||
### 我的密码
|
||||
|
||||
所有登录账号都可通过侧栏“修改我的密码”填写当前密码、新密码及确认。服务器验证当前密码,并限制连续错误次数。成功后撤销该账号全部登录会话,其他账号不受影响;账号角色和人物关联不变。系统不会把密码记录到操作日志。
|
||||
|
||||
### 并发与数据一致性
|
||||
|
||||
设备对象的校验与保存串行执行;镜头数量限制不能被同时提交绕过。摄像头对象移动空间时,所有镜头归属在同一数据库事务更新。录像机地址或账号更新后,关联通道读取最新端点。无效对象编号、错误 Origin 和非法地址类型会返回明确错误。
|
||||
|
||||
@@ -3,6 +3,8 @@ import base64
|
||||
import concurrent.futures
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import functools
|
||||
import camera_settings
|
||||
import hmac
|
||||
import http.client
|
||||
import http.cookies
|
||||
@@ -27,6 +29,9 @@ import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import onvif
|
||||
import playback
|
||||
import identity
|
||||
import genealogy
|
||||
import ptz
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
os.umask(0o077)
|
||||
@@ -66,6 +71,10 @@ class Problem(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
ACCOUNTS = identity.Accounts(sys.modules[__name__])
|
||||
FAMILY = genealogy.Family(sys.modules[__name__])
|
||||
|
||||
|
||||
def setting(key, default=None):
|
||||
with LOCK:
|
||||
row = DB.execute('SELECT value FROM settings WHERE key=?', (key,)).fetchone()
|
||||
@@ -119,6 +128,8 @@ def integer(value, lo, hi):
|
||||
|
||||
def host_address(value):
|
||||
try:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError()
|
||||
ip = ipaddress.ip_address(value)
|
||||
if ip.version != 4 or ip.is_global or ip.is_loopback or ip.is_multicast or ip.is_unspecified or ip.is_reserved:
|
||||
raise ValueError()
|
||||
@@ -131,10 +142,28 @@ SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'o
|
||||
'building', 'unit', 'floor', 'door', 'address', 'note')
|
||||
|
||||
|
||||
def serialized_save(function):
|
||||
@functools.wraps(function)
|
||||
def save(body):
|
||||
with LOCK:
|
||||
return function(body)
|
||||
return save
|
||||
|
||||
|
||||
def object_key(table, body):
|
||||
key = body.get('id', '')
|
||||
if key in ('', None):
|
||||
return secrets.token_hex(8)
|
||||
if not isinstance(key, str) or not re.fullmatch('[a-f0-9]{16}', key):
|
||||
raise Problem('对象编号不正确')
|
||||
if not get_object(table, key):
|
||||
raise Problem('对象不存在,请重新载入', 404)
|
||||
return key
|
||||
|
||||
|
||||
@serialized_save
|
||||
def save_site(body):
|
||||
key = body.get('id') or secrets.token_hex(8)
|
||||
if not re.fullmatch('[a-f0-9]{16}', key):
|
||||
raise Problem('空间编号不正确')
|
||||
key = object_key('sites', body)
|
||||
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'))
|
||||
@@ -145,10 +174,9 @@ def save_site(body):
|
||||
return item
|
||||
|
||||
|
||||
@serialized_save
|
||||
def save_camera(body):
|
||||
key = body.get('id') or secrets.token_hex(8)
|
||||
if not re.fullmatch('[a-f0-9]{16}', key):
|
||||
raise Problem('设备编号不正确')
|
||||
key = object_key('cameras', body)
|
||||
old = get_object('cameras', key) or {}
|
||||
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
|
||||
for k in ('name', 'point', 'username', 'model')}
|
||||
@@ -195,10 +223,9 @@ def save_camera(body):
|
||||
return public_camera(item)
|
||||
|
||||
|
||||
@serialized_save
|
||||
def save_asset(body):
|
||||
key = body.get('id') or secrets.token_hex(8)
|
||||
if not re.fullmatch('[a-f0-9]{16}', key):
|
||||
raise Problem('对象编号不正确')
|
||||
key = object_key('assets', body)
|
||||
site = body.get('siteId')
|
||||
if not get_object('sites', site):
|
||||
raise Problem('请选择所属空间')
|
||||
@@ -208,19 +235,18 @@ def save_asset(body):
|
||||
attached = [c for c in objects('cameras') if c.get('assetId') == key]
|
||||
if len(attached) > item['lensCount']:
|
||||
raise Problem('镜头数不能小于已经建立的通道数')
|
||||
with LOCK:
|
||||
save_object('assets', item)
|
||||
with DB:
|
||||
DB.execute('INSERT OR REPLACE INTO assets VALUES (?,?)', (key, json.dumps(item)))
|
||||
for c in attached:
|
||||
c['siteId'] = site
|
||||
save_object('cameras', c)
|
||||
DB.execute('UPDATE cameras SET body=? WHERE id=?', (json.dumps(c), c['id']))
|
||||
audit('保存摄像头对象', item['name'])
|
||||
return item
|
||||
|
||||
|
||||
@serialized_save
|
||||
def save_recorder(body):
|
||||
key = body.get('id') or secrets.token_hex(8)
|
||||
if not re.fullmatch('[a-f0-9]{16}', key):
|
||||
raise Problem('录像机编号不正确')
|
||||
key = object_key('recorders', body)
|
||||
old = get_object('recorders', key) or {}
|
||||
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
|
||||
for k in ('name', 'brand', 'model', 'username')}
|
||||
@@ -242,7 +268,13 @@ def save_recorder(body):
|
||||
|
||||
|
||||
def public_camera(c):
|
||||
return {**{k: v for k, v in c.items() if k != 'password'}, 'passwordConfigured': bool(c['password'])}
|
||||
result = {**{k: v for k, v in c.items() if k != 'password'}, 'passwordConfigured': bool(c.get('password'))}
|
||||
if c.get('sourceKind') == 'recorder':
|
||||
recorder = get_object('recorders', c.get('recorderId'))
|
||||
if recorder:
|
||||
result.update({k: recorder[k] for k in ('host', 'port', 'username')})
|
||||
result['passwordConfigured'] = bool(recorder.get('password'))
|
||||
return result
|
||||
|
||||
|
||||
def stream_name(c, quality='main'):
|
||||
@@ -470,20 +502,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def authenticated(self):
|
||||
token = self.token()
|
||||
if not re.fullmatch('[a-f0-9]{64}', token):
|
||||
return False
|
||||
return None
|
||||
with LOCK:
|
||||
return bool(DB.execute('SELECT 1 FROM sessions WHERE hash=? AND expires>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone())
|
||||
row = DB.execute('SELECT user_id FROM sessions WHERE hash=? AND expires>?',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), time.time())).fetchone()
|
||||
user = get_object('users', row['user_id']) if row else None
|
||||
return user if user and not user['disabled'] else None
|
||||
|
||||
def require_auth(self):
|
||||
if not self.authenticated():
|
||||
self.user = self.authenticated()
|
||||
if not self.user:
|
||||
raise Problem('请先登录', 401)
|
||||
|
||||
def body(self):
|
||||
def require_admin(self):
|
||||
if self.user['role'] != 'admin':
|
||||
raise Problem('此操作需要管理员权限', 403)
|
||||
|
||||
def body(self, maximum=16384):
|
||||
if self.headers.get_content_type() != 'application/json':
|
||||
raise Problem('请求需要 JSON 格式')
|
||||
n = int(self.headers.get('Content-Length', '0'))
|
||||
if not 0 < n <= 16384:
|
||||
try:
|
||||
n = int(self.headers.get('Content-Length', '0'))
|
||||
except ValueError:
|
||||
raise Problem('请求长度不正确')
|
||||
if not 0 < n <= maximum:
|
||||
raise Problem('请求长度不正确')
|
||||
try:
|
||||
data = json.loads(self.rfile.read(n))
|
||||
@@ -495,8 +537,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def validate_origin(self):
|
||||
origin = self.headers.get('Origin')
|
||||
if origin and url.urlsplit(origin).netloc != self.headers.get('Host'):
|
||||
raise Problem('请从系统页面提交操作', 403)
|
||||
if origin:
|
||||
try:
|
||||
parsed = url.urlsplit(origin)
|
||||
valid = parsed.scheme in ('http', 'https') and parsed.netloc == self.headers.get('Host')
|
||||
except ValueError:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise Problem('请从系统页面提交操作', 403)
|
||||
|
||||
def login(self, data, setup=False):
|
||||
ip = self.client_address[0]
|
||||
@@ -509,31 +557,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
ATTEMPTS[ip] = (count + 1, expiry)
|
||||
if count >= 12:
|
||||
raise Problem('尝试次数过多,请 15 分钟后重试', 429)
|
||||
account = setting('account')
|
||||
accounts = objects('users')
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or not 10 <= len(password) <= 128:
|
||||
raise Problem('管理密码需要 10 至 128 个字符')
|
||||
if setup:
|
||||
if account:
|
||||
if accounts:
|
||||
raise Problem('管理员已经初始化', 409)
|
||||
code = data.get('code', '')
|
||||
expected = (DATA / 'setup-code.txt').read_text().strip()
|
||||
if not isinstance(code, str) or not hmac.compare_digest(code, expected):
|
||||
raise Problem('初始化码不正确', 403)
|
||||
salt = secrets.token_hex(16)
|
||||
derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()
|
||||
set_setting('account', {'salt': salt, 'hash': derived})
|
||||
account = ACCOUNTS.save({'username': 'admin', 'name': '管理员', 'role': 'admin',
|
||||
'password': password, 'familyAccess': 'edit'}, None)
|
||||
(DATA / 'setup-code.txt').unlink(missing_ok=True)
|
||||
audit('初始化管理员')
|
||||
else:
|
||||
if not account:
|
||||
if not accounts:
|
||||
raise Problem('请先初始化管理员', 409)
|
||||
derived = hashlib.scrypt(password.encode(), salt=account['salt'].encode(), n=16384, r=8, p=1).hex()
|
||||
if not hmac.compare_digest(derived, account['hash']):
|
||||
raise Problem('密码不正确', 403)
|
||||
account = ACCOUNTS.verify(data.get('username', ''), password)
|
||||
if not account:
|
||||
raise Problem('用户名或密码不正确', 403)
|
||||
token = secrets.token_hex(32)
|
||||
DB.execute('DELETE FROM sessions WHERE expires<?', (now,))
|
||||
DB.execute('INSERT INTO sessions VALUES (?,?)', (hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7))
|
||||
DB.execute('INSERT INTO sessions (hash,expires,user_id) VALUES (?,?,?)',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), now + 86400 * 7, account['id']))
|
||||
DB.commit()
|
||||
ATTEMPTS.pop(ip, None)
|
||||
secure = '; Secure' if os.environ.get('VISION_SECURE_COOKIE') == '1' else ''
|
||||
@@ -541,18 +587,51 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
self.validate_origin()
|
||||
path = url.urlsplit(self.path).path
|
||||
data = self.body()
|
||||
# 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB.
|
||||
data = self.body(131072 if path == '/api/family/event' else 16384)
|
||||
self.validate_origin()
|
||||
if path in ('/api/login', '/api/setup'):
|
||||
self.login(data, path == '/api/setup')
|
||||
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()
|
||||
if path == '/api/logout':
|
||||
with LOCK:
|
||||
DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),))
|
||||
DB.commit()
|
||||
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
|
||||
elif path == '/api/account/password':
|
||||
result = ACCOUNTS.change_password(data, self.user)
|
||||
self.answer(result, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
|
||||
elif path == '/api/family/restore':
|
||||
self.answer(FAMILY.restore(data, self.user))
|
||||
elif path == '/api/camera/settings':
|
||||
c = ACCOUNTS.camera(self.user, get_object('cameras', data.get('camera')))
|
||||
try:
|
||||
result = camera_settings.CONTROLLER.save(c, data)
|
||||
except ValueError as e:
|
||||
raise Problem(str(e))
|
||||
except Exception:
|
||||
raise Problem('设备保存请求未确认,可能已经生效;请重新读取参数后核对,勿反复提交', 502)
|
||||
audit('修改摄像机参数 · ' + data['section'], c['name'] + ' / ' + self.user['username'])
|
||||
self.answer(result)
|
||||
elif path == '/api/ptz':
|
||||
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
|
||||
try:
|
||||
result = ptz.CONTROLLER.command(c, data.get('action'))
|
||||
except ValueError as e:
|
||||
raise Problem(str(e), 400)
|
||||
except Exception:
|
||||
raise Problem('云台通信未成功,请检查设备连接;设备端的一秒超时保护会停止本次转动', 502)
|
||||
audit('云台 ' + data['action'], c['name'])
|
||||
self.answer(result)
|
||||
elif path == '/api/users':
|
||||
self.answer(ACCOUNTS.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))
|
||||
elif path == '/api/cameras':
|
||||
@@ -684,7 +763,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
path = parsed.path
|
||||
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
|
||||
if path == '/api/session':
|
||||
self.answer({'authenticated': self.authenticated(), 'setupRequired': not bool(setting('account'))})
|
||||
user = self.authenticated()
|
||||
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
|
||||
'setupRequired': not bool(objects('users'))})
|
||||
return
|
||||
if path == '/healthz':
|
||||
self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()})
|
||||
@@ -692,8 +773,40 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path.startswith(('/api/', '/media/')):
|
||||
self.require_auth()
|
||||
if path == '/api/state':
|
||||
self.answer(status())
|
||||
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')]})
|
||||
elif path == '/api/ptz':
|
||||
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
|
||||
try:
|
||||
result = ptz.CONTROLLER.capabilities(c)
|
||||
except ValueError as e:
|
||||
raise Problem(str(e), 400)
|
||||
except Exception:
|
||||
raise Problem('未能读取云台能力,请确认摄像头的 ONVIF 服务和连接', 502)
|
||||
self.answer(result)
|
||||
elif path == '/api/family':
|
||||
self.answer(FAMILY.snapshot(self.user))
|
||||
elif path == '/api/camera/settings':
|
||||
self.require_admin()
|
||||
c = ACCOUNTS.camera(self.user, get_object('cameras', query.get('camera')))
|
||||
try:
|
||||
result, _ = camera_settings.CONTROLLER.read(c, query.get('quality', 'main'))
|
||||
except ValueError as e:
|
||||
raise Problem(str(e))
|
||||
except Exception:
|
||||
raise Problem('读取摄像机参数失败,请检查连接、设备账号或 ONVIF 服务', 502)
|
||||
self.answer(result)
|
||||
elif path in ('/api/family/history', '/api/family/change'):
|
||||
try:
|
||||
number = int(query['before' if path.endswith('history') else 'revision']) if ('before' if path.endswith('history') else 'revision') in query else None
|
||||
except ValueError:
|
||||
raise Problem('修改记录编号不正确')
|
||||
self.answer(FAMILY.history(self.user, query.get('kind'), query.get('id'), number)
|
||||
if path.endswith('history') else FAMILY.change(self.user, number))
|
||||
elif path == '/api/audit':
|
||||
self.require_admin()
|
||||
with LOCK:
|
||||
rows = [dict(r) for r in DB.execute('SELECT * FROM audit ORDER BY id DESC LIMIT 50')]
|
||||
self.answer(rows)
|
||||
@@ -701,6 +814,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
c = get_object('cameras', query.get('camera'))
|
||||
if not c:
|
||||
raise Problem('请选择摄像头')
|
||||
ACCOUNTS.camera(self.user, c)
|
||||
start, end = parse_time(query.get('start')), parse_time(query.get('end'))
|
||||
if not 0 < (end - start).total_seconds() <= 172800:
|
||||
raise Problem('每次查询最多两天')
|
||||
@@ -712,11 +826,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
suffix = path[len('/media/live/'):]
|
||||
if not re.fullmatch(r'cam_[a-f0-9]{16}_(main|sub|compat|hd)/[a-zA-Z0-9_.-]+', suffix):
|
||||
raise Problem('视频路径不正确')
|
||||
ACCOUNTS.camera(self.user, get_object('cameras', suffix[4:20]))
|
||||
self.proxy(HLS_PORT, '/' + suffix + ('?' + parsed.query if parsed.query else ''))
|
||||
elif path == '/media/playback':
|
||||
c = get_object('cameras', query.get('camera'))
|
||||
if not c:
|
||||
raise Problem('摄像头不存在', 404)
|
||||
ACCOUNTS.camera(self.user, c)
|
||||
start = parse_time(query.get('start'))
|
||||
try:
|
||||
duration = float(query.get('duration', '300'))
|
||||
@@ -734,12 +850,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
raise Problem('接口不存在', 404)
|
||||
else:
|
||||
files = {'/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css',
|
||||
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
|
||||
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
|
||||
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
|
||||
'/family-map.css': 'family-map.css',
|
||||
'/family-events.js': 'family-events.js',
|
||||
'/family-history.js': 'family-history.js',
|
||||
'/camera-settings.js': 'camera-settings.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'}
|
||||
if path not in files:
|
||||
raise Problem('页面不存在', 404)
|
||||
file = ROOT / 'web' / files[path]
|
||||
raw = file.read_bytes()
|
||||
if path == '/':
|
||||
raw = raw.replace(b'__ASSET_VERSION__', (ROOT / 'VERSION').read_bytes().strip())
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', (mimetypes.guess_type(file.name)[0] or 'application/octet-stream') + '; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(raw)))
|
||||
@@ -747,7 +873,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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('Cache-Control', 'no-cache')
|
||||
self.send_header('Cache-Control', 'no-store' if path == '/' else 'no-cache')
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
except Problem as e:
|
||||
@@ -760,12 +886,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def initialize():
|
||||
global MEDIA_AUTH
|
||||
FAMILY.initialize()
|
||||
ACCOUNTS.initialize()
|
||||
if not setting('storage'):
|
||||
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
|
||||
if not setting('mediaSecret'):
|
||||
set_setting('mediaSecret', secrets.token_hex(32))
|
||||
MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode()
|
||||
if not setting('account') and not (DATA / 'setup-code.txt').exists():
|
||||
if not objects('users') and not (DATA / 'setup-code.txt').exists():
|
||||
(DATA / 'setup-code.txt').write_text(secrets.token_hex(12), encoding='utf8')
|
||||
os.chmod(DATA / 'setup-code.txt', 0o600)
|
||||
write_media_config()
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Capability-driven, per-lens ONVIF settings. Never accepts an endpoint from a client."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
import urllib.parse as url
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
import onvif
|
||||
|
||||
IMAGING = 'http://www.onvif.org/ver20/imaging/wsdl'
|
||||
T = '{' + onvif.SCHEMA + '}'
|
||||
M = '{' + onvif.MEDIA + '}'
|
||||
I = '{' + IMAGING + '}'
|
||||
IMAGE_FIELDS = {
|
||||
'brightness': ('Brightness', 'Brightness', '亮度'),
|
||||
'contrast': ('Contrast', 'Contrast', '对比度'),
|
||||
'saturation': ('ColorSaturation', 'ColorSaturation', '饱和度'),
|
||||
'sharpness': ('Sharpness', 'Sharpness', '锐度'),
|
||||
'dayNight': ('IrCutFilter', 'IrCutFilterModes', '日夜模式'),
|
||||
'backlight': ('BacklightCompensation/Mode', 'BacklightCompensation/Mode', '背光补偿'),
|
||||
'wdr': ('WideDynamicRange/Mode', 'WideDynamicRange/Mode', '宽动态'),
|
||||
'wdrLevel': ('WideDynamicRange/Level', 'WideDynamicRange/Level', '宽动态强度'),
|
||||
'whiteBalance': ('WhiteBalance/Mode', 'WhiteBalance/Mode', '白平衡'),
|
||||
}
|
||||
ENUMS = {'dayNight': ('ON', 'OFF', 'AUTO'), 'backlight': ('ON', 'OFF'),
|
||||
'wdr': ('ON', 'OFF'), 'whiteBalance': ('AUTO', 'MANUAL')}
|
||||
VIDEO_FIELDS = {'fps': 'RateControl/FrameRateLimit', 'bitrate': 'RateControl/BitrateLimit',
|
||||
'quality': 'Quality', 'gop': 'H264/GovLength'}
|
||||
|
||||
|
||||
def path(value):
|
||||
return '/'.join(T + part for part in value.split('/'))
|
||||
|
||||
|
||||
def number(value):
|
||||
try:
|
||||
result = float(value)
|
||||
return result if math.isfinite(result) else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def limits(root, name):
|
||||
if root is None:
|
||||
return None
|
||||
minimum, maximum = number(root.findtext(path(name + '/Min'))), number(root.findtext(path(name + '/Max')))
|
||||
return dict(min=minimum, max=maximum) if minimum is not None and maximum is not None and minimum <= maximum else None
|
||||
|
||||
|
||||
def uri_path(value):
|
||||
parsed = url.urlsplit(value)
|
||||
return parsed.path, sorted(url.parse_qsl(parsed.query, keep_blank_values=True))
|
||||
|
||||
|
||||
class Settings:
|
||||
def __init__(self):
|
||||
self.guard = threading.Lock()
|
||||
self.locks = {}
|
||||
|
||||
def call(self, camera, endpoint, namespace, operation, body=''):
|
||||
return onvif.request(camera['host'], endpoint[0], endpoint[1], namespace, operation,
|
||||
body, camera['username'], camera['password'])
|
||||
|
||||
@staticmethod
|
||||
def endpoint(camera, address):
|
||||
parsed = url.urlsplit(address or '')
|
||||
if parsed.scheme != 'http' or parsed.hostname != camera['host'] or parsed.username or parsed.password:
|
||||
raise ValueError('设备返回了不匹配的服务地址')
|
||||
return parsed.port or 80, parsed.path + ('?' + parsed.query if parsed.query else '')
|
||||
|
||||
def discover(self, camera, quality):
|
||||
if camera.get('sourceKind', 'direct') != 'direct':
|
||||
raise ValueError('摄像机参数需要直连摄像头;录像机通道请在录像机中配置')
|
||||
if not camera.get('enabled'):
|
||||
raise ValueError('请先启用这个镜头通道')
|
||||
if quality not in ('main', 'sub'):
|
||||
raise ValueError('请选择主码流或子码流')
|
||||
device = (camera.get('onvifPort', 80), '/onvif/device_service')
|
||||
caps = self.call(camera, device, onvif.DEVICE, 'GetCapabilities', '<m:Category>All</m:Category>')
|
||||
services = {}
|
||||
for name in ('Media', 'Imaging', 'PTZ', 'Events', 'Recording', 'Replay'):
|
||||
address = caps.findtext('.//' + T + name + '/' + T + 'XAddr')
|
||||
if address:
|
||||
services[name] = self.endpoint(camera, address)
|
||||
if 'Media' not in services:
|
||||
raise ValueError('设备没有提供媒体配置服务')
|
||||
profiles = self.call(camera, services['Media'], onvif.MEDIA, 'GetProfiles')
|
||||
wanted = camera['mainPath' if quality == 'main' else 'subPath']
|
||||
selected = None
|
||||
# Exact path AND query matching prevents selecting the other lens on shared paths.
|
||||
for profile in profiles.findall('.//' + M + 'Profiles')[:16]:
|
||||
token = profile.get('token', '')
|
||||
content = ('<m:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol>'
|
||||
'</tt:Transport></m:StreamSetup><m:ProfileToken>' + escape(token) + '</m:ProfileToken>')
|
||||
response = self.call(camera, services['Media'], onvif.MEDIA, 'GetStreamUri', content)
|
||||
uri = response.findtext('.//' + T + 'Uri')
|
||||
if uri and url.urlsplit(uri).hostname == camera['host'] and uri_path(uri) == uri_path(wanted):
|
||||
selected = profile
|
||||
break
|
||||
if selected is None:
|
||||
raise ValueError('未找到与当前镜头及码流路径精确匹配的配置,请先核对通道')
|
||||
source = selected.findtext(path('VideoSourceConfiguration/SourceToken'))
|
||||
encoder = selected.find(T + 'VideoEncoderConfiguration')
|
||||
if not source or encoder is None:
|
||||
raise ValueError('设备没有返回镜头或编码器配置')
|
||||
return dict(device=device, services=services, source=source, profile=selected.get('token'), encoder=encoder)
|
||||
|
||||
def read(self, camera, quality='main'):
|
||||
info = self.discover(camera, quality)
|
||||
return self.details(camera, info), info
|
||||
|
||||
def details(self, camera, info):
|
||||
result = dict(profile=info['profile'], source=info['source'], device={}, imaging={}, video={}, notes=[],
|
||||
advertisedServices=list(info['services']))
|
||||
try:
|
||||
device = self.call(camera, info['device'], onvif.DEVICE, 'GetDeviceInformation')
|
||||
result['device'] = {key: device.findtext('.//{' + onvif.DEVICE + '}' + key, '')
|
||||
for key in ('Manufacturer', 'Model', 'FirmwareVersion', 'HardwareId')}
|
||||
except Exception:
|
||||
result['notes'].append('设备型号读取失败,可重试')
|
||||
encoder = info['encoder']
|
||||
values = {key: number(encoder.findtext(path(field))) for key, field in VIDEO_FIELDS.items()}
|
||||
values['resolution'] = encoder.findtext(path('Resolution/Width'), '') + 'x' + encoder.findtext(path('Resolution/Height'), '')
|
||||
codec = encoder.get('encoding') or encoder.findtext(T + 'Encoding', '')
|
||||
result['video'] = dict(values=values, options={}, codec=codec)
|
||||
try:
|
||||
body = '<m:ConfigurationToken>' + escape(encoder.get('token', '')) + '</m:ConfigurationToken>'
|
||||
opts = self.call(camera, info['services']['Media'], onvif.MEDIA, 'GetVideoEncoderConfigurationOptions', body).find('.//' + M + 'Options')
|
||||
# Some EZVIZ devices expose H265 through the H264 compatibility schema.
|
||||
# Keep the entire original XML (including vendor encoding attribute) when writing.
|
||||
schema_codec = encoder.findtext(T + 'Encoding', '')
|
||||
if schema_codec not in ('H264', 'MPEG4', 'JPEG'):
|
||||
raise ValueError('unsupported encoding schema')
|
||||
codec_opts = opts.find(T + schema_codec)
|
||||
for key, field in [('fps', 'FrameRateRange'), ('gop', 'GovLengthRange')]:
|
||||
limit = limits(codec_opts, field)
|
||||
if limit and values[key] is not None:result['video']['options'][key] = limit
|
||||
bitrate = limits(opts, 'Extension/' + schema_codec + '/BitrateRange')
|
||||
quality = limits(opts, 'QualityRange')
|
||||
if bitrate and values['bitrate'] is not None:result['video']['options']['bitrate'] = bitrate
|
||||
if quality and values['quality'] is not None:result['video']['options']['quality'] = quality
|
||||
resolutions = [r.findtext(T + 'Width', '') + 'x' + r.findtext(T + 'Height', '')
|
||||
for r in codec_opts.findall(T + 'ResolutionsAvailable')]
|
||||
if resolutions:result['video']['options']['resolution'] = {'choices': list(dict.fromkeys(resolutions))}
|
||||
except Exception:
|
||||
result['notes'].append('码流可调范围读取失败,暂不可修改码流参数')
|
||||
result['video']['options'] = {}
|
||||
if 'Imaging' in info['services']:
|
||||
try:
|
||||
body = '<m:VideoSourceToken>' + escape(info['source']) + '</m:VideoSourceToken>'
|
||||
settings = self.call(camera, info['services']['Imaging'], IMAGING, 'GetImagingSettings', body).find('.//' + I + 'ImagingSettings')
|
||||
options = self.call(camera, info['services']['Imaging'], IMAGING, 'GetOptions', body).find('.//' + I + 'ImagingOptions')
|
||||
if settings is None or options is None:raise ValueError('missing imaging response')
|
||||
info['imagingXML'] = settings
|
||||
for key, (field, option, label) in IMAGE_FIELDS.items():
|
||||
value = settings.findtext(path(field))
|
||||
if value is None:continue
|
||||
if key in ENUMS:
|
||||
choices = [v.text for v in options.findall(path(option)) if v.text in ENUMS[key]]
|
||||
rule = {'choices': list(dict.fromkeys(choices))} if choices else None
|
||||
else:
|
||||
value, rule = number(value), limits(options, option)
|
||||
if rule and value is not None:result['imaging'][key] = dict(value=value, label=label, **rule)
|
||||
except Exception:
|
||||
result['notes'].append('图像参数读取失败或设备未开放,暂不可修改图像参数')
|
||||
else:
|
||||
result['notes'].append('设备未开放图像调节服务')
|
||||
fingerprint = dict(source=info['source'], profile=info['profile'], encoder=encoder.get('token'),
|
||||
imaging=result['imaging'], video=result['video'])
|
||||
result['revision'] = hashlib.sha256(json.dumps(fingerprint, sort_keys=True).encode()).hexdigest()
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def validate(values, rules, integer_fields=()):
|
||||
if not isinstance(values, dict) or not values or any(k not in rules for k in values):
|
||||
raise ValueError('提交的参数未由设备开放,或没有需要保存的参数')
|
||||
for key, value in values.items():
|
||||
rule = rules[key]
|
||||
if 'choices' in rule:
|
||||
if not isinstance(value, str) or value not in rule['choices']:raise ValueError('参数选项不受设备支持')
|
||||
elif (type(value) not in (int, float) or not math.isfinite(value) or not rule['min'] <= value <= rule['max']
|
||||
or (key in integer_fields and int(value) != value)):
|
||||
raise ValueError('参数超出设备允许范围')
|
||||
|
||||
def save(self, camera, data):
|
||||
key = (camera['host'], camera.get('onvifPort', 80))
|
||||
with self.guard:lock = self.locks.setdefault(key, threading.Lock())
|
||||
if not lock.acquire(blocking=False):raise ValueError('这台摄像头正在保存参数,请稍后重试')
|
||||
try:
|
||||
current, info = self.read(camera, data.get('quality', 'main'))
|
||||
if data.get('revision') != current['revision']:
|
||||
raise ValueError('设备参数已变化,请重新读取后再修改')
|
||||
section, values = data.get('section'), data.get('values')
|
||||
if section == 'imaging':
|
||||
self.validate(values, current['imaging'])
|
||||
settings = copy.deepcopy(info['imagingXML'])
|
||||
for key, value in values.items():settings.find(path(IMAGE_FIELDS[key][0])).text = str(value)
|
||||
body = '<m:VideoSourceToken>' + escape(info['source']) + '</m:VideoSourceToken>' + ET.tostring(settings, encoding='unicode') + '<m:ForcePersistence>true</m:ForcePersistence>'
|
||||
endpoint, namespace, operation = info['services']['Imaging'], IMAGING, 'SetImagingSettings'
|
||||
elif section == 'video':
|
||||
self.validate(values, current['video']['options'], ('fps', 'bitrate', 'gop'))
|
||||
settings = copy.deepcopy(info['encoder']);settings.tag = M + 'Configuration'
|
||||
for key, value in values.items():
|
||||
if key == 'resolution':
|
||||
for field, size in zip(('Width', 'Height'), value.split('x')):settings.find(path('Resolution/' + field)).text = size
|
||||
else:settings.find(path(VIDEO_FIELDS[key])).text = str(int(value) if key in ('fps', 'bitrate', 'gop') else value)
|
||||
body = ET.tostring(settings, encoding='unicode') + '<m:ForcePersistence>true</m:ForcePersistence>'
|
||||
endpoint, namespace, operation = info['services']['Media'], onvif.MEDIA, 'SetVideoEncoderConfiguration'
|
||||
else:raise ValueError('请选择图像或码流设置')
|
||||
self.call(camera, endpoint, namespace, operation, body)
|
||||
try:
|
||||
fresh, _ = self.read(camera, data.get('quality', 'main'))
|
||||
actual = {k: v['value'] for k, v in fresh['imaging'].items()} if section == 'imaging' else fresh['video']['values']
|
||||
confirmed = all(actual.get(k) == v for k, v in values.items())
|
||||
return dict(ok=True, verified=confirmed, settings=fresh,
|
||||
message='设备已保存并回读确认' if confirmed else '设备已响应,但回读值与提交值不同,请核对设备实际参数')
|
||||
except Exception:
|
||||
return dict(ok=True, verified=False, settings=None, message='设备已响应,暂未读回参数;请重新读取确认,不要反复提交')
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
CONTROLLER = Settings()
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
"""A private family register, independent from login accounts and camera roles."""
|
||||
import datetime as dt
|
||||
import json
|
||||
import secrets
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class Family:
|
||||
def __init__(self, app):
|
||||
self.a = app
|
||||
|
||||
def initialize(self):
|
||||
with self.a.LOCK:
|
||||
self.a.DB.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS people (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS family_links (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS family_events (id TEXT PRIMARY KEY, body TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS family_changes (
|
||||
revision INTEGER PRIMARY KEY, kind TEXT NOT NULL, target_id TEXT NOT NULL,
|
||||
before_json TEXT, after_json TEXT NOT NULL, actor TEXT NOT NULL,
|
||||
at TEXT NOT NULL, restored_from INTEGER);
|
||||
CREATE INDEX IF NOT EXISTS family_changes_target ON family_changes(kind,target_id,revision);
|
||||
''')
|
||||
self.a.DB.commit()
|
||||
|
||||
def authorize(self, user, edit=False):
|
||||
if user['role'] != 'admin' 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')
|
||||
if can_edit or (e.get('status') == 'published' and not e.get('archived'))],
|
||||
'canEdit': can_edit,
|
||||
'selfId': user.get('personId', '')}
|
||||
|
||||
def save(self, kind, data, user, restored_from=None):
|
||||
self.authorize(user, True)
|
||||
a = self.a
|
||||
with a.LOCK:
|
||||
revision = a.setting('familyRevision', 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]
|
||||
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'])
|
||||
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'])
|
||||
# Withdrawal and restoration must retain the first publication time.
|
||||
if old and old.get('publishedAt'):
|
||||
item['publishedAt'] = old['publishedAt']
|
||||
elif item['status'] == 'published':
|
||||
item['publishedAt'] = item['updatedAt']
|
||||
# Revision and record are committed together, so concurrent forms cannot overwrite silently.
|
||||
with a.DB:
|
||||
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 INTO family_changes VALUES (?,?,?,?,?,?,?,?)',
|
||||
(revision + 1, kind, item['id'], json.dumps(old) if old else None,
|
||||
json.dumps(item), user['username'], item['updatedAt'], restored_from))
|
||||
a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username'])
|
||||
return {'item': item, 'revision': revision + 1}
|
||||
|
||||
def history(self, user, kind, target_id, before=None):
|
||||
# Previous drafts and withdrawn text are visible only to family editors.
|
||||
self.authorize(user, True)
|
||||
a = self.a
|
||||
table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}.get(kind)
|
||||
with a.LOCK:
|
||||
if not table or not a.get_object(table, target_id):
|
||||
raise a.Problem('家谱记录不存在', 404)
|
||||
if before is not None:
|
||||
a.integer(before, 1, 2**53 - 1)
|
||||
rows = a.DB.execute('SELECT * FROM family_changes WHERE kind=? AND target_id=? AND revision<? '
|
||||
'ORDER BY revision DESC LIMIT 21', (kind, target_id, before or 2**53 - 1)).fetchall()
|
||||
items = []
|
||||
for row in rows[:20]:
|
||||
old, new = json.loads(row['before_json'] or '{}'), json.loads(row['after_json'])
|
||||
fields = [k for k in new if k not in ('id', 'updatedAt', 'updatedBy', 'createdAt', 'createdBy', 'publishedAt')
|
||||
and old.get(k) != new[k]]
|
||||
items.append(dict(revision=row['revision'], at=row['at'], actor=row['actor'], fields=fields,
|
||||
created=row['before_json'] is None, restoredFrom=row['restored_from']))
|
||||
return dict(items=items, nextBefore=items[-1]['revision'] if len(rows) > 20 else None,
|
||||
revision=a.setting('familyRevision', 0))
|
||||
|
||||
def change(self, user, change_revision):
|
||||
self.authorize(user, True)
|
||||
self.a.integer(change_revision, 1, 2**53 - 1)
|
||||
with self.a.LOCK:
|
||||
row = self.a.DB.execute('SELECT * FROM family_changes WHERE revision=?', (change_revision,)).fetchone()
|
||||
if not row:
|
||||
raise self.a.Problem('修改记录不存在', 404)
|
||||
return dict(revision=row['revision'], kind=row['kind'], targetId=row['target_id'],
|
||||
before=json.loads(row['before_json']) if row['before_json'] else None,
|
||||
after=json.loads(row['after_json']), at=row['at'], actor=row['actor'])
|
||||
|
||||
def restore(self, data, user):
|
||||
self.authorize(user, True)
|
||||
with self.a.LOCK:
|
||||
change = self.change(user, data.get('changeRevision'))
|
||||
side = data.get('side')
|
||||
if side not in ('before', 'after') or change[side] is None:
|
||||
raise self.a.Problem('请选择有效的历史版本;创建前没有可恢复的内容')
|
||||
# Reuse all current relationship/date/publication validation. Restore is a new edit,
|
||||
# not an overwrite of history or a rollback of other people's records.
|
||||
return self.save(change['kind'], dict(change[side], revision=data.get('revision')),
|
||||
user, restored_from=change['revision'])
|
||||
|
||||
def event(self, data, old):
|
||||
a = self.a
|
||||
item = {'id': old['id'] if old else secrets.token_hex(8)}
|
||||
for key, maximum, required in [('title', 160, True), ('eventDate', 10, True),
|
||||
('sourceName', 160, False),
|
||||
('sourceUrl', 2000, False)]:
|
||||
item[key] = a.clean_text(data.get(key, ''), maximum, required)
|
||||
try:
|
||||
if dt.date.fromisoformat(item['eventDate']).isoformat() != item['eventDate']:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
raise a.Problem('记事日期请填写有效日期,格式为 YYYY-MM-DD')
|
||||
for key, maximum, label in [('summary', 400, '摘要'), ('body', 12000, '正文')]:
|
||||
value = data.get(key, '')
|
||||
if not isinstance(value, str) or len(value) > maximum or any(ord(c) < 32 and c not in '\r\n\t' for c in value):
|
||||
raise a.Problem(f'记事{label}格式不正确,最多 {maximum} 字')
|
||||
item[key] = value.strip()
|
||||
item['personIds'] = data.get('personIds', [])
|
||||
if (not isinstance(item['personIds'], list) or len(item['personIds']) > 200
|
||||
or any(not isinstance(p, str) or not a.get_object('people', p) for p in item['personIds'])):
|
||||
raise a.Problem('请选择有效的关联人物,最多 200 位')
|
||||
item['personIds'] = list(dict.fromkeys(item['personIds']))
|
||||
item['status'] = data.get('status', old.get('status', 'draft') if old else 'draft')
|
||||
item['archived'] = data.get('archived', old.get('archived', False) if old else False)
|
||||
if item['status'] not in ('draft', 'published') or not isinstance(item['archived'], bool):
|
||||
raise a.Problem('记事状态不正确')
|
||||
if item['sourceUrl']:
|
||||
try:
|
||||
parsed = urlsplit(item['sourceUrl'])
|
||||
if parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username or parsed.password:
|
||||
raise ValueError()
|
||||
parsed.port
|
||||
except ValueError:
|
||||
raise a.Problem('报道链接请填写完整的 http 或 https 地址,不可包含账号密码')
|
||||
if item['status'] == 'published' and not (item['body'] or item['summary'] or item['sourceUrl']):
|
||||
raise a.Problem('发布前请填写记事正文、摘要或报道链接')
|
||||
return item
|
||||
|
||||
def person(self, data, old):
|
||||
a = self.a
|
||||
item = {'id': old['id'] if old else secrets.token_hex(8)}
|
||||
for key, maximum in [('name', 80), ('alias', 120), ('birthDate', 10), ('deathDate', 10),
|
||||
('birthplace', 160), ('biography', 2000), ('note', 500)]:
|
||||
value = data.get(key, '')
|
||||
if key in ('biography', 'note'):
|
||||
if not isinstance(value, str) or len(value) > maximum or any(ord(c) < 32 and c not in '\r\n\t' for c in value):
|
||||
raise a.Problem('生平或备注格式不正确')
|
||||
item[key] = value.strip()
|
||||
else:
|
||||
item[key] = a.clean_text(value, maximum, key == 'name')
|
||||
for key in ('birthDate', 'deathDate'):
|
||||
if item[key]:
|
||||
try:
|
||||
if dt.date.fromisoformat(item[key]).isoformat() != item[key]:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
raise a.Problem('日期请使用公历 YYYY-MM-DD;不确定可留空,在备注记录')
|
||||
if item['birthDate'] and item['deathDate'] and item['birthDate'] > item['deathDate']:
|
||||
raise a.Problem('逝世日期不能早于出生日期')
|
||||
item['gender'] = data.get('gender', 'unknown')
|
||||
item['lifeStatus'] = data.get('lifeStatus', 'alive')
|
||||
if item['gender'] not in ('male', 'female', 'unknown') or item['lifeStatus'] not in ('alive', 'deceased', 'unknown'):
|
||||
raise a.Problem('人物属性不正确')
|
||||
if item['deathDate'] and item['lifeStatus'] != 'deceased':
|
||||
raise a.Problem('填写逝世日期时,请选择已故')
|
||||
item['archived'] = data.get('archived', False)
|
||||
if not isinstance(item['archived'], bool):
|
||||
raise a.Problem('归档状态不正确')
|
||||
if not old or item['birthDate'] != old.get('birthDate', ''):
|
||||
self.check_birth_order([e for e in a.objects('family_links')
|
||||
if item['id'] in (e['fromId'], e['toId'])], item)
|
||||
return item
|
||||
|
||||
def check_birth_order(self, links, proposed_person=None):
|
||||
people = {p['id']: p for p in self.a.objects('people')}
|
||||
if proposed_person:
|
||||
people[proposed_person['id']] = proposed_person
|
||||
for edge in links:
|
||||
if not edge.get('active') or edge['kind'] != 'parent' or edge.get('lineage') != 'biological':
|
||||
continue
|
||||
parent, child = people.get(edge['fromId']), people.get(edge['toId'])
|
||||
if parent and child and parent.get('birthDate') and child.get('birthDate') and parent['birthDate'] >= child['birthDate']:
|
||||
raise self.a.Problem('亲生父母的出生日期必须早于子女,请核对日期或亲子属性')
|
||||
|
||||
def link(self, data, old):
|
||||
a = self.a
|
||||
left, right = data.get('fromId'), data.get('toId')
|
||||
if not isinstance(left, str) or not isinstance(right, str) or not a.get_object('people', left) or not a.get_object('people', right):
|
||||
raise a.Problem('请先录入关系双方的人物')
|
||||
if left == right:
|
||||
raise a.Problem('不能把人物与自己建立亲属关系')
|
||||
kind = data.get('kind')
|
||||
if kind not in ('parent', 'spouse'):
|
||||
raise a.Problem('关系类型不正确')
|
||||
lineage = data.get('lineage', 'unspecified')
|
||||
if lineage not in ('biological', 'adoptive', 'step', 'unspecified'):
|
||||
raise a.Problem('亲子关系属性不正确')
|
||||
active = data.get('active', True)
|
||||
if not isinstance(active, bool):
|
||||
raise a.Problem('关系状态不正确')
|
||||
item = dict(id=old['id'] if old else secrets.token_hex(8), fromId=left, toId=right, kind=kind,
|
||||
lineage=lineage if kind == 'parent' else 'unspecified', active=active,
|
||||
note=a.clean_text(data.get('note', ''), 300))
|
||||
self.check_birth_order([item])
|
||||
links = [e for e in a.objects('family_links') if e.get('active') and e['id'] != item['id']]
|
||||
if active:
|
||||
for e in links:
|
||||
same = (e['fromId'], e['toId']) == (left, right)
|
||||
if kind == 'spouse':
|
||||
same = {e['fromId'], e['toId']} == {left, right}
|
||||
if e['kind'] == kind and same:
|
||||
raise a.Problem('这条关系已经存在', 409)
|
||||
graph = {}
|
||||
for e in links + [item]:
|
||||
if e['kind'] == 'parent':
|
||||
graph.setdefault(e['fromId'], []).append(e['toId'])
|
||||
# Iterative walk supports deep ancestry without Python recursion limits.
|
||||
def reaches(start, target):
|
||||
pending, seen = [start], set()
|
||||
while pending:
|
||||
p = pending.pop()
|
||||
if p in seen:
|
||||
continue
|
||||
seen.add(p)
|
||||
for child in graph.get(p, []):
|
||||
if child == target:
|
||||
return True
|
||||
pending.append(child)
|
||||
return False
|
||||
if kind == 'parent' and reaches(right, left):
|
||||
raise a.Problem('这条关系会形成祖先与后代循环,请检查方向')
|
||||
for e in links + [item]:
|
||||
if e['kind'] == 'spouse' and (reaches(e['fromId'], e['toId']) or reaches(e['toId'], e['fromId'])):
|
||||
raise a.Problem('配偶不能同时是直系祖先或后代,请检查已有关系')
|
||||
return item
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
"""Local accounts. Passwords never appear in public account representations."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
|
||||
|
||||
class Accounts:
|
||||
def __init__(self, app):
|
||||
self.a = app
|
||||
self.password_attempts = {}
|
||||
|
||||
def initialize(self):
|
||||
a = self.a
|
||||
with a.LOCK:
|
||||
a.DB.execute('CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
|
||||
if 'user_id' not in [r['name'] for r in a.DB.execute('PRAGMA table_info(sessions)')]:
|
||||
a.DB.execute('ALTER TABLE sessions ADD COLUMN user_id TEXT')
|
||||
legacy = a.setting('account')
|
||||
if legacy and not a.objects('users'):
|
||||
user = dict(id=secrets.token_hex(8), username='admin', name='管理员', role='admin',
|
||||
disabled=False, siteIds=[], familyAccess='edit', personId='',
|
||||
relatedToId='', relationship='', **legacy)
|
||||
a.DB.execute('INSERT INTO users VALUES (?,?)', (user['id'], json.dumps(user)))
|
||||
a.DB.execute('UPDATE sessions SET user_id=? WHERE user_id IS NULL', (user['id'],))
|
||||
a.DB.execute("DELETE FROM settings WHERE key='account'")
|
||||
a.DB.commit()
|
||||
|
||||
@staticmethod
|
||||
def public(user):
|
||||
return {k: v for k, v in user.items() if k not in ('salt', 'hash')} if user else None
|
||||
|
||||
def password(self, password):
|
||||
if not isinstance(password, str) or not 8 <= len(password) <= 128:
|
||||
raise self.a.Problem('密码需要 8 至 128 个字符')
|
||||
salt = secrets.token_hex(16)
|
||||
return {'salt': salt, 'hash': hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()}
|
||||
|
||||
def verify(self, username, password):
|
||||
if not isinstance(username, str) or not isinstance(password, str) or len(password) > 128:
|
||||
return None
|
||||
user = next((u for u in self.a.objects('users') if u['username'] == username.strip().lower()), None)
|
||||
# Also perform derivation for nonexistent accounts.
|
||||
salt = user['salt'] if user else '0' * 32
|
||||
derived = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1).hex()
|
||||
return user if user and not user['disabled'] and hmac.compare_digest(derived, user['hash']) else None
|
||||
|
||||
def save(self, data, actor):
|
||||
a = self.a
|
||||
with a.LOCK:
|
||||
old = a.get_object('users', data.get('id'))
|
||||
if data.get('id') and not old:
|
||||
raise a.Problem('账号不存在', 404)
|
||||
uid = old['id'] if old else secrets.token_hex(8)
|
||||
username = a.clean_text(data.get('username', ''), 32, True).lower()
|
||||
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,31}', username):
|
||||
raise a.Problem('用户名使用 3 至 32 位英文字母、数字、点、下划线或短横线')
|
||||
if any(u['username'] == username and u['id'] != uid for u in a.objects('users')):
|
||||
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'):
|
||||
raise a.Problem('权限选项不正确')
|
||||
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('请选择有效空间')
|
||||
ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False)
|
||||
if not isinstance(ptz_control, bool):
|
||||
raise a.Problem('云台权限格式不正确')
|
||||
disabled = data.get('disabled', False)
|
||||
if not isinstance(disabled, bool):
|
||||
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 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 person:
|
||||
if not a.get_object('people', person):
|
||||
raise a.Problem('家谱人物不存在')
|
||||
if any(u.get('personId') == person and u['id'] != uid for u in a.objects('users')):
|
||||
raise a.Problem('这个人物已经关联其他账号')
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str):
|
||||
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,
|
||||
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')):
|
||||
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,))
|
||||
a.save_object('users', user)
|
||||
a.audit('更新账号' if old else '建立账号', username)
|
||||
return self.public(user)
|
||||
|
||||
def change_password(self, data, actor):
|
||||
a = self.a
|
||||
with a.LOCK:
|
||||
current = a.get_object('users', actor['id'])
|
||||
if not current or current['disabled']:
|
||||
raise a.Problem('请重新登录', 401)
|
||||
now = time.time()
|
||||
self.password_attempts = {k: v for k, v in self.password_attempts.items() if v[1] > now}
|
||||
count, expiry = self.password_attempts.get(current['id'], (0, now + 900))
|
||||
if count >= 8:
|
||||
raise a.Problem('当前密码尝试次数过多,请 15 分钟后重试', 429)
|
||||
if not self.verify(current['username'], data.get('currentPassword')):
|
||||
self.password_attempts[current['id']] = (count + 1, expiry)
|
||||
raise a.Problem('当前密码不正确', 403)
|
||||
if data.get('newPassword') == data.get('currentPassword'):
|
||||
raise a.Problem('新密码不能与当前密码相同')
|
||||
credentials = self.password(data.get('newPassword'))
|
||||
with a.DB:
|
||||
a.DB.execute('UPDATE users SET body=? WHERE id=?', (json.dumps(dict(current, **credentials)), current['id']))
|
||||
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (current['id'],))
|
||||
self.password_attempts.pop(current['id'], None)
|
||||
a.audit('本人修改密码', current['username'])
|
||||
return {'ok': True}
|
||||
|
||||
def camera(self, user, camera):
|
||||
if not camera:
|
||||
raise self.a.Problem('摄像头不存在', 404)
|
||||
if user['role'] != 'admin' and camera.get('siteId') not in user['siteIds']:
|
||||
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):
|
||||
raise self.a.Problem('没有云台控制权限,请联系管理员授权', 403)
|
||||
if not camera.get('enabled'):
|
||||
raise self.a.Problem('摄像头已停用', 409)
|
||||
return camera
|
||||
|
||||
def state(self, user, state):
|
||||
state['user'] = self.public(user)
|
||||
if user['role'] == 'admin':
|
||||
return state
|
||||
permitted = set(user['siteIds'])
|
||||
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]
|
||||
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'] = []
|
||||
state['scan'] = {}
|
||||
state['storage'] = {}
|
||||
return state
|
||||
@@ -13,6 +13,12 @@ MEDIA = 'http://www.onvif.org/ver10/media/wsdl'
|
||||
SCHEMA = 'http://www.onvif.org/ver10/schema'
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
# Camera services must stay on the validated device endpoint.
|
||||
return None
|
||||
|
||||
|
||||
def request(host, port, endpoint, namespace, operation, content, username, password):
|
||||
created = dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
nonce = secrets.token_bytes(16)
|
||||
@@ -30,7 +36,7 @@ def request(host, port, endpoint, namespace, operation, content, username, passw
|
||||
target = 'http://' + host + ':' + str(port) + endpoint
|
||||
passwords = urllib.request.HTTPPasswordMgrWithDefaultRealm()
|
||||
passwords.add_password(None, target, username, password)
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPDigestAuthHandler(passwords))
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPDigestAuthHandler(passwords))
|
||||
req = urllib.request.Request(target, data=envelope.encode(), headers={
|
||||
'Content-Type': 'application/soap+xml; charset=utf-8; action="' + namespace + '/' + operation + '"'})
|
||||
with opener.open(req, timeout=5) as response:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Capability-driven ONVIF pan/tilt: bounded nudges, never latched movement."""
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse as url
|
||||
from xml.sax.saxutils import escape
|
||||
import onvif
|
||||
|
||||
PTZ = 'http://www.onvif.org/ver20/ptz/wsdl'
|
||||
SPACE = 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace'
|
||||
T = '{' + onvif.SCHEMA + '}'
|
||||
DIRECTIONS = {'left': (-0.25, 0), 'right': (0.25, 0), 'up': (0, 0.25), 'down': (0, -0.25)}
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self):
|
||||
self.guard = threading.RLock()
|
||||
self.cache = {}
|
||||
self.locks = {}
|
||||
|
||||
def call(self, c, endpoint, namespace, operation, body=''):
|
||||
port, path = endpoint
|
||||
return onvif.request(c['host'], port, path, namespace, operation, body, c['username'], c['password'])
|
||||
|
||||
@staticmethod
|
||||
def endpoint(c, text):
|
||||
parsed = url.urlsplit(text)
|
||||
if parsed.scheme != 'http' or parsed.hostname != c['host'] or parsed.username or parsed.password:
|
||||
raise ValueError('设备返回了不匹配的云台服务地址')
|
||||
return parsed.port or 80, parsed.path + ('?' + parsed.query if parsed.query else '')
|
||||
|
||||
def discover(self, c):
|
||||
if c.get('sourceKind', 'direct') != 'direct':
|
||||
raise ValueError('请使用摄像头直连通道控制云台')
|
||||
key = (c['host'], c.get('onvifPort', 80), c['username'], hashlib.sha256(c['password'].encode()).hexdigest(), c['mainPath'])
|
||||
with self.guard:
|
||||
old = self.cache.get(key)
|
||||
if old and old[0] > time.monotonic():
|
||||
return old[1]
|
||||
caps = self.call(c, (c.get('onvifPort', 80), '/onvif/device_service'), onvif.DEVICE, 'GetCapabilities', '<m:Category>All</m:Category>')
|
||||
def service(name):
|
||||
value = caps.find('.//' + T + name + '/' + T + 'XAddr')
|
||||
if value is None or not value.text:
|
||||
raise ValueError('设备没有提供云台服务')
|
||||
return self.endpoint(c, value.text)
|
||||
endpoint, media = service('PTZ'), service('Media')
|
||||
profiles = self.call(c, media, onvif.MEDIA, 'GetProfiles')
|
||||
available = [p for p in profiles.findall('.//{' + onvif.MEDIA + '}Profiles') if p.find(T + 'PTZConfiguration') is not None]
|
||||
wanted = url.parse_qs(url.urlsplit(c['mainPath']).query).get('profile', [''])[0]
|
||||
selected = next((p for p in available if p.get('token') == wanted), None)
|
||||
if selected is None:
|
||||
for p in available[:32]:
|
||||
body = '<m:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></m:StreamSetup><m:ProfileToken>' + escape(p.get('token', '')) + '</m:ProfileToken>'
|
||||
root = self.call(c, media, onvif.MEDIA, 'GetStreamUri', body)
|
||||
uri = root.find('.//' + T + 'Uri')
|
||||
actual, wanted_uri = url.urlsplit(uri.text if uri is not None else ''), url.urlsplit(c['mainPath'])
|
||||
if (actual.hostname == c['host'] and actual.path == wanted_uri.path
|
||||
and sorted(url.parse_qsl(actual.query, keep_blank_values=True)) == sorted(url.parse_qsl(wanted_uri.query, keep_blank_values=True))):
|
||||
selected = p
|
||||
break
|
||||
if selected is None:
|
||||
raise ValueError('这个镜头没有匹配的云台配置')
|
||||
conf = selected.find(T + 'PTZConfiguration')
|
||||
options = self.call(c, endpoint, PTZ, 'GetConfigurationOptions', '<m:ConfigurationToken>' + escape(conf.get('token')) + '</m:ConfigurationToken>')
|
||||
spaces = options.findall('.//' + T + 'ContinuousPanTiltVelocitySpace')
|
||||
space = next((s for s in spaces if s.findtext(T + 'URI') == SPACE), None)
|
||||
if space is None:
|
||||
raise ValueError('设备不支持通用方向微调')
|
||||
for axis in ('XRange', 'YRange'):
|
||||
if float(space.findtext(T + axis + '/' + T + 'Min')) > -0.25 or float(space.findtext(T + axis + '/' + T + 'Max')) < 0.25:
|
||||
raise ValueError('设备云台速度范围不兼容')
|
||||
# Do not issue movement unless the device advertises a one-second watchdog.
|
||||
minimum = options.findtext('.//' + T + 'PTZTimeout/' + T + 'Min')
|
||||
maximum = options.findtext('.//' + T + 'PTZTimeout/' + T + 'Max')
|
||||
def seconds(value):
|
||||
import re
|
||||
m = re.fullmatch(r'PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?', value or '')
|
||||
return sum(float(v or 0) * scale for v, scale in zip(m.groups(), (3600, 60, 1))) if m else -1
|
||||
if not 0 <= seconds(minimum) <= 1 <= seconds(maximum):
|
||||
raise ValueError('设备不支持一秒自动停止保护')
|
||||
result = {'endpoint': endpoint, 'profile': selected.get('token'), 'node': conf.findtext(T + 'NodeToken'), 'supported': True}
|
||||
with self.guard:
|
||||
if len(self.cache) > 128:
|
||||
self.cache.clear()
|
||||
self.cache[key] = (time.monotonic() + 300, result)
|
||||
return result
|
||||
|
||||
def capabilities(self, c):
|
||||
self.discover(c)
|
||||
return {'supported': True, 'directions': list(DIRECTIONS), 'mode': 'nudge',
|
||||
'note': '点击一次短距离转动并自动停止。同一摄像头的镜头可能共用云台。'}
|
||||
|
||||
def command(self, c, action):
|
||||
if action not in (*DIRECTIONS, 'stop'):
|
||||
raise ValueError('请选择上、下、左、右或停止')
|
||||
info = self.discover(c)
|
||||
body = '<m:ProfileToken>' + escape(info['profile']) + '</m:ProfileToken>'
|
||||
def stop():
|
||||
self.call(c, info['endpoint'], PTZ, 'Stop', body + '<m:PanTilt>true</m:PanTilt>')
|
||||
if action == 'stop':
|
||||
stop()
|
||||
return {'ok': True, 'stopped': True}
|
||||
# Lock by physical host/node, including commands sent through another lens/account.
|
||||
key = (c['host'], info['endpoint'][0], info['node'])
|
||||
with self.guard:
|
||||
lock = self.locks.setdefault(key, threading.Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ValueError('云台正在执行操作,请稍后再点')
|
||||
try:
|
||||
x, y = DIRECTIONS[action]
|
||||
try:
|
||||
self.call(c, info['endpoint'], PTZ, 'ContinuousMove', body +
|
||||
f'<m:Velocity><tt:PanTilt x="{x}" y="{y}" space="{SPACE}"/></m:Velocity><m:Timeout>PT1S</m:Timeout>')
|
||||
time.sleep(0.3)
|
||||
finally:
|
||||
# Also stop after an ambiguous network failure. Device timeout is a second guard.
|
||||
stop()
|
||||
return {'ok': True, 'stopped': True}
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
CONTROLLER = Controller()
|
||||
+411
-5
@@ -4,6 +4,7 @@ import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -21,13 +22,14 @@ class WebTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
app.initialize()
|
||||
cls.admin = app.ACCOUNTS.save({'username': 'admin', 'password': 'test-password', 'role': 'admin'}, None)
|
||||
cls.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?)',
|
||||
(hashlib.sha256(b'f' * 64).hexdigest(), time.time() + 60))
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)',
|
||||
(hashlib.sha256(b'f' * 64).hexdigest(), time.time() + 3600, cls.admin['id']))
|
||||
app.DB.commit()
|
||||
app.save_object('cameras', {'id': 'a' * 16, 'name': 'Example', 'password': 'private'})
|
||||
app.save_object('cameras', {'id': 'a' * 16, 'name': 'Example', 'siteId': 'test-site', 'password': 'private'})
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
@@ -37,9 +39,9 @@ class WebTests(unittest.TestCase):
|
||||
app.DB.close()
|
||||
_data.cleanup()
|
||||
|
||||
def request(self, path, authenticated=True, data=None, origin=None):
|
||||
def request(self, path, authenticated=True, data=None, origin=None, token=None):
|
||||
c = http.client.HTTPConnection(*self.server.server_address, timeout=3)
|
||||
headers = {'Cookie': 'vision=' + 'f' * 64} if authenticated else {}
|
||||
headers = {'Cookie': 'vision=' + (token or 'f' * 64)} if authenticated else {}
|
||||
if origin:
|
||||
headers['Origin'] = origin
|
||||
if data is not None:
|
||||
@@ -63,6 +65,25 @@ class WebTests(unittest.TestCase):
|
||||
self.assertEqual(headers['X-Frame-Options'], 'DENY')
|
||||
self.assertIn("frame-ancestors 'none'", headers['Content-Security-Policy'])
|
||||
|
||||
def test_live_quality_has_only_main_and_sub(self):
|
||||
code, _, body = self.request('/', False)
|
||||
self.assertEqual(code, 200)
|
||||
select = re.search(r'<select id="quality">(.*?)</select>', body.decode()).group(1)
|
||||
self.assertEqual(re.findall(r'<option value="(.*?)">(.*?)</option>', select),
|
||||
[('hd', '主码流'), ('compat', '子码流')])
|
||||
|
||||
def test_asset_urls_follow_release_version(self):
|
||||
code, headers, body = self.request('/', False)
|
||||
self.assertEqual(code, 200)
|
||||
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)
|
||||
version = (app.ROOT / 'VERSION').read_text().strip()
|
||||
for asset in assets:
|
||||
self.assertTrue(asset.endswith('?v=' + version))
|
||||
self.assertEqual(self.request(asset, False)[0], 200)
|
||||
|
||||
def test_origin_rejected(self):
|
||||
self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403)
|
||||
|
||||
@@ -105,6 +126,84 @@ class WebTests(unittest.TestCase):
|
||||
def test_password_redacted(self):
|
||||
self.assertNotIn('password', app.public_camera(app.get_object('cameras', 'a' * 16)))
|
||||
|
||||
def test_accounts_and_authorization(self):
|
||||
member = app.ACCOUNTS.save({'username': 'member', 'password': 'password8', 'familyAccess': 'read'}, self.admin)
|
||||
self.assertNotIn('hash', member)
|
||||
self.assertNotIn('salt', member)
|
||||
self.assertIsNotNone(app.ACCOUNTS.verify('member', 'password8'))
|
||||
self.assertIsNone(app.ACCOUNTS.verify('member', 'incorrect'))
|
||||
code, _, body = self.request('/api/users')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertNotIn(b'"hash"', body)
|
||||
token='e'*64
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)',(hashlib.sha256(token.encode()).hexdigest(),time.time()+3600,member['id']))
|
||||
app.DB.commit()
|
||||
def request(path,data=None):
|
||||
c=http.client.HTTPConnection(*self.server.server_address,timeout=3)
|
||||
c.request('POST' if data is not None else 'GET',path,None if data is None else json.dumps(data),
|
||||
{'Cookie':'vision='+token,'Content-Type':'application/json'})
|
||||
r=c.getresponse();status=r.status;r.read();c.close();return status
|
||||
for path in ['/api/users','/api/audit','/media/live/cam_'+'a'*16+'_hd/index.m3u8',
|
||||
'/api/recordings?camera='+'a'*16]:
|
||||
self.assertEqual(request(path),403)
|
||||
for path in ['/api/storage','/api/users','/api/family/person','/api/family/event']:
|
||||
self.assertEqual(request(path,{}),403)
|
||||
self.assertEqual(request('/api/family'),200)
|
||||
app.ACCOUNTS.save(dict(member,disabled=True),self.admin)
|
||||
self.assertEqual(request('/api/family'),401)
|
||||
|
||||
def test_account_constraints(self):
|
||||
with self.assertRaises(app.Problem):
|
||||
app.ACCOUNTS.save(dict(self.admin,disabled=True),self.admin)
|
||||
with self.assertRaises(app.Problem):
|
||||
app.ACCOUNTS.save({'username':'admin','password':'password8'},self.admin)
|
||||
with self.assertRaises(app.Problem):
|
||||
app.ACCOUNTS.save({'username':'short','password':'123'},self.admin)
|
||||
|
||||
def test_family_graph_validation_and_revision(self):
|
||||
def save(kind,data):
|
||||
return app.FAMILY.save(kind,dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
|
||||
p=save('person',{'name':'祖辈','biography':'第一行\n第二行'})
|
||||
q=save('person',{'name':'子辈'})
|
||||
r=save('person',{'name':'孙辈'})
|
||||
first=save('link',{'fromId':p['id'],'toId':q['id'],'kind':'parent'})
|
||||
save('link',{'fromId':q['id'],'toId':r['id'],'kind':'parent'})
|
||||
for value in [{'fromId':r['id'],'toId':p['id'],'kind':'parent'},
|
||||
{'fromId':p['id'],'toId':p['id'],'kind':'parent'},
|
||||
{'fromId':p['id'],'toId':r['id'],'kind':'spouse'},
|
||||
{'fromId':p['id'],'toId':q['id'],'kind':'parent'}]:
|
||||
with self.assertRaises(app.Problem):save('link',value)
|
||||
with self.assertRaises(app.Problem):
|
||||
app.FAMILY.save('person',{'name':'过期编辑','revision':0},self.admin)
|
||||
with self.assertRaises(app.Problem):
|
||||
save('person',{'name':'错误日期','birthDate':'2026-02-30'})
|
||||
with self.assertRaises(app.Problem):
|
||||
save('person',{'name':'错误日期','birthDate':'2020-01-01','deathDate':'1900-01-01','lifeStatus':'deceased'})
|
||||
save('link',dict(first,active=False))
|
||||
self.assertFalse(app.get_object('family_links',first['id'])['active'])
|
||||
save('link',dict(first,active=True))
|
||||
save('person',dict(p,archived=True))
|
||||
self.assertTrue(app.get_object('people',p['id'])['archived'])
|
||||
save('person',dict(p,archived=False))
|
||||
|
||||
def test_legacy_migration_preserves_password_and_sessions(self):
|
||||
# Exercise migration in a separate database without touching the HTTP fixture.
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
db=sqlite3.connect(Path(folder)/'migration.db');db.row_factory=sqlite3.Row
|
||||
db.executescript('CREATE TABLE settings(key TEXT PRIMARY KEY,value TEXT NOT NULL);CREATE TABLE sessions(hash TEXT PRIMARY KEY,expires REAL NOT NULL);')
|
||||
credentials=app.ACCOUNTS.password('old-password')
|
||||
db.execute('INSERT INTO settings VALUES (?,?)',('account',json.dumps(credentials)))
|
||||
db.execute('INSERT INTO sessions VALUES (?,?)',('legacy',time.time()+60));db.commit()
|
||||
with patch.object(app,'DB',db):
|
||||
app.ACCOUNTS.initialize();app.ACCOUNTS.initialize()
|
||||
self.assertEqual(len(app.objects('users')),1)
|
||||
self.assertIsNotNone(app.ACCOUNTS.verify('admin','old-password'))
|
||||
self.assertEqual(db.execute('SELECT user_id FROM sessions').fetchone()[0],app.objects('users')[0]['id'])
|
||||
self.assertIsNone(app.setting('account'))
|
||||
db.close()
|
||||
|
||||
def test_playback_has_bound_and_cleanup(self):
|
||||
playback.SLOTS.acquire()
|
||||
playback.SLOTS.acquire()
|
||||
@@ -129,6 +228,111 @@ class WebTests(unittest.TestCase):
|
||||
self.assertIn('h264_vaapi', command)
|
||||
self.assertIn('pipe:1', command)
|
||||
|
||||
def test_ptz_requires_both_space_and_control_permission(self):
|
||||
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)
|
||||
viewer['ptzControl'] = True
|
||||
self.assertEqual(app.ACCOUNTS.control(viewer, camera), camera)
|
||||
viewer['siteIds'] = []
|
||||
with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera)
|
||||
self.assertEqual(self.request('/api/ptz?camera='+'e'*16,False)[0],401)
|
||||
|
||||
def test_family_defaults_alive_and_rejects_death_for_alive(self):
|
||||
p=app.FAMILY.person({'name':'Example'},None)
|
||||
self.assertEqual(p['lifeStatus'],'alive')
|
||||
with self.assertRaises(app.Problem):app.FAMILY.person({'name':'Example','deathDate':'2020-01-01'},None)
|
||||
self.assertEqual(app.FAMILY.person({'name':'Example','lifeStatus':'deceased','deathDate':'2020-01-01'},None)['deathDate'],'2020-01-01')
|
||||
|
||||
def test_family_event_draft_publish_archive_and_restore(self):
|
||||
def save(data):
|
||||
return app.FAMILY.save('event',dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
|
||||
p=app.FAMILY.save('person',{'name':'Event person','revision':app.setting('familyRevision',0)},self.admin)['item']
|
||||
reader={'role':'member','familyAccess':'read','personId':p['id']}
|
||||
e=save({'title':'First event','eventDate':'2024-02-10','summary':'Summary','body':'Line one\nLine two','personIds':[p['id'],p['id']]})
|
||||
self.assertEqual(e['status'],'draft');self.assertEqual(e['personIds'],[p['id']])
|
||||
self.assertNotIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(reader)['events']])
|
||||
e=save(dict(e,status='published'))
|
||||
self.assertIn('publishedAt',e)
|
||||
self.assertIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(reader)['events']])
|
||||
e=save(dict(e,archived=True))
|
||||
self.assertNotIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(reader)['events']])
|
||||
self.assertIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(self.admin)['events']])
|
||||
e=save(dict(e,archived=False))
|
||||
self.assertIn(e['id'],[r['id'] for r in app.FAMILY.snapshot(reader)['events']])
|
||||
with self.assertRaises(app.Problem):app.FAMILY.save('event',dict(e,revision=0),self.admin)
|
||||
with self.assertRaises(app.Problem):app.FAMILY.save('event',dict(e,revision=app.setting('familyRevision',0)),reader)
|
||||
with self.assertRaises(app.Problem):app.FAMILY.snapshot({'role':'member','familyAccess':'none'})
|
||||
|
||||
def test_family_event_validation(self):
|
||||
good={'title':'Event','eventDate':'2020-02-29','body':'Text','status':'published'}
|
||||
for changes in [{'eventDate':'2023-02-29'},{'title':''},{'sourceUrl':'javascript:alert(1)'},
|
||||
{'sourceUrl':'https://user:secret@example.com'},{'personIds':['missing']},
|
||||
{'body':'x'*12001},{'status':'public'},{'archived':'false'},{'personIds':'wrong'},
|
||||
{'body':'','summary':'','sourceUrl':''}]:
|
||||
with self.subTest(changes=list(changes)):
|
||||
with self.assertRaises(app.Problem):app.FAMILY.event(dict(good,**changes),None)
|
||||
event=app.FAMILY.event(dict(good,sourceUrl='https://example.org/report'),None)
|
||||
self.assertEqual(event['sourceUrl'],'https://example.org/report')
|
||||
|
||||
def test_event_accepts_full_chinese_body_and_multiline_summary(self):
|
||||
payload={'title':'中文长文','eventDate':'2026-10-03','body':'家'*12000,'summary':'第一段\n第二段',
|
||||
'status':'published','revision':app.setting('familyRevision',0)}
|
||||
self.assertGreater(len(json.dumps(payload).encode()),16384)
|
||||
code,_,body=self.request('/api/family/event',data=payload)
|
||||
self.assertEqual(code,200)
|
||||
saved=json.loads(body)['item']
|
||||
self.assertEqual(saved['body'],payload['body'])
|
||||
self.assertEqual(saved['summary'],payload['summary'])
|
||||
for changes in [{'body':'家'*12001},{'summary':'家'*401},{'summary':'bad\x00text'}]:
|
||||
invalid=dict(payload,**changes,revision=app.setting('familyRevision',0))
|
||||
self.assertEqual(self.request('/api/family/event',data=invalid)[0],400)
|
||||
|
||||
def test_event_keeps_publication_history_and_omitted_status(self):
|
||||
def save(data):return app.FAMILY.save('event',dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
|
||||
event=save({'title':'记事','eventDate':'2026-10-03','body':'正文','status':'published'})
|
||||
published=event['publishedAt'];created=event['createdAt']
|
||||
edited=dict(event,title='更新标题');edited.pop('status');edited.pop('publishedAt')
|
||||
event=save(edited)
|
||||
self.assertEqual(event['status'],'published');self.assertEqual(event['publishedAt'],published)
|
||||
event=save(dict(event,status='draft'))
|
||||
self.assertEqual(event['publishedAt'],published)
|
||||
reader={'role':'member','familyAccess':'read'}
|
||||
self.assertNotIn(event['id'],[e['id'] for e in app.FAMILY.snapshot(reader)['events']])
|
||||
event=save(dict(event,status='published'))
|
||||
self.assertEqual(event['publishedAt'],published);self.assertEqual(event['createdAt'],created)
|
||||
|
||||
def test_biological_birth_order_checked_on_link_and_person_changes(self):
|
||||
def save(kind,data):return app.FAMILY.save(kind,dict(data,revision=app.setting('familyRevision',0)),self.admin)['item']
|
||||
parent=save('person',{'name':'Parent','birthDate':'1980-01-01'})
|
||||
child=save('person',{'name':'Child','birthDate':'2000-01-01'})
|
||||
relation=save('link',{'fromId':parent['id'],'toId':child['id'],'kind':'parent','lineage':'biological'})
|
||||
for person,date in [(parent,'2000-01-01'),(parent,'2001-01-01'),(child,'1979-01-01')]:
|
||||
revision=app.setting('familyRevision',0)
|
||||
with self.assertRaises(app.Problem):save('person',dict(person,birthDate=date))
|
||||
self.assertEqual(app.setting('familyRevision',0),revision)
|
||||
self.assertEqual(app.get_object('people',person['id'])['birthDate'],person['birthDate'])
|
||||
save('link',dict(relation,active=False))
|
||||
save('person',dict(parent,birthDate='2001-01-01'))
|
||||
with self.assertRaises(app.Problem):save('link',dict(relation,active=True))
|
||||
save('link',dict(relation,active=True,lineage='adoptive'))
|
||||
|
||||
def test_request_body_limit_is_bounded_and_invalid_length_is_400(self):
|
||||
from email.message import Message
|
||||
from types import SimpleNamespace
|
||||
for length in ['invalid','-1','0','131073']:
|
||||
headers=Message();headers['Content-Type']='application/json';headers['Content-Length']=length
|
||||
fake=SimpleNamespace(headers=headers,rfile=io.BytesIO(b'{}'))
|
||||
with self.assertRaises(app.Problem) as caught:app.Handler.body(fake,131072)
|
||||
self.assertEqual(caught.exception.status,400)
|
||||
|
||||
def test_event_endpoint_and_static_modules(self):
|
||||
self.assertEqual(self.request('/api/family/event',False,{'title':'Denied'})[0],401)
|
||||
payload={'title':'API event','eventDate':'2026-01-01','body':'Test only','status':'published','revision':app.setting('familyRevision',0)}
|
||||
self.assertEqual(self.request('/api/family/event',data=payload)[0],200)
|
||||
for path in ['/family-graph.js','/family-map.js','/family-map.css','/family-events.js']:
|
||||
self.assertEqual(self.request(path,False)[0],200)
|
||||
|
||||
def test_empty_recording_day_is_not_service_failure(self):
|
||||
error = app.urllib.error.HTTPError('http://localhost/list', 404, 'Not Found', {},
|
||||
io.BytesIO(b'{"error":"no recording segments found"}'))
|
||||
@@ -141,6 +345,208 @@ class WebTests(unittest.TestCase):
|
||||
with self.assertRaises(app.Problem):
|
||||
app.media_json('/list?path=example', playback=True)
|
||||
|
||||
def family_save(self, kind, data):
|
||||
return app.FAMILY.save(kind, dict(data, revision=app.setting('familyRevision', 0)), self.admin)
|
||||
|
||||
def test_history_records_before_after_and_restore_as_new_version(self):
|
||||
first = self.family_save('person', {'name': 'History original', 'biography': 'Original story'})
|
||||
second = self.family_save('person', dict(first['item'], name='History changed'))
|
||||
change = app.FAMILY.change(self.admin, second['revision'])
|
||||
self.assertEqual(change['before']['name'], 'History original')
|
||||
self.assertEqual(change['after']['name'], 'History changed')
|
||||
restored = app.FAMILY.restore({'changeRevision': second['revision'], 'side': 'before',
|
||||
'revision': app.setting('familyRevision')}, self.admin)
|
||||
self.assertEqual(restored['item']['name'], 'History original')
|
||||
self.assertGreater(restored['revision'], second['revision'])
|
||||
entries = app.FAMILY.history(self.admin, 'person', first['item']['id'])['items']
|
||||
self.assertEqual(len(entries), 3)
|
||||
self.assertEqual(entries[0]['restoredFrom'], second['revision'])
|
||||
self.assertEqual(app.FAMILY.change(self.admin, second['revision']), change)
|
||||
|
||||
def test_history_rejects_stale_restore_without_writing(self):
|
||||
first = self.family_save('person', {'name': 'Stale restore'})
|
||||
second = self.family_save('person', dict(first['item'], name='Latest'))
|
||||
with self.assertRaises(app.Problem) as caught:
|
||||
app.FAMILY.restore(dict(changeRevision=first['revision'], side='after', revision=first['revision']), self.admin)
|
||||
self.assertEqual(caught.exception.status, 409)
|
||||
self.assertEqual(app.get_object('people', first['item']['id'])['name'], 'Latest')
|
||||
self.assertEqual(app.setting('familyRevision'), second['revision'])
|
||||
|
||||
def test_restore_revalidates_current_birth_order(self):
|
||||
old = self.family_save('person', {'name': 'Restore parent', 'birthDate': '1980-01-01'})
|
||||
parent = self.family_save('person', dict(old['item'], birthDate='1960-01-01'))['item']
|
||||
child = self.family_save('person', {'name': 'Restore child', 'birthDate': '1970-01-01'})['item']
|
||||
self.family_save('link', dict(fromId=parent['id'], toId=child['id'], kind='parent', lineage='biological'))
|
||||
with self.assertRaises(app.Problem):
|
||||
app.FAMILY.restore(dict(changeRevision=old['revision'], side='after', revision=app.setting('familyRevision')), self.admin)
|
||||
self.assertEqual(app.get_object('people', parent['id'])['birthDate'], '1960-01-01')
|
||||
|
||||
def test_restore_link_cannot_create_new_cycle(self):
|
||||
a = self.family_save('person', {'name': 'Cycle A'})['item']
|
||||
b = self.family_save('person', {'name': 'Cycle B'})['item']
|
||||
old = self.family_save('link', dict(fromId=a['id'], toId=b['id'], kind='parent'))
|
||||
self.family_save('link', dict(old['item'], active=False))
|
||||
self.family_save('link', dict(fromId=b['id'], toId=a['id'], kind='parent'))
|
||||
with self.assertRaises(app.Problem):
|
||||
app.FAMILY.restore(dict(changeRevision=old['revision'], side='after', revision=app.setting('familyRevision')), self.admin)
|
||||
self.assertFalse(app.get_object('family_links', old['item']['id'])['active'])
|
||||
|
||||
def test_restore_event_preserves_first_publication_and_validates_scope(self):
|
||||
draft = self.family_save('event', dict(title='Restored story', eventDate='2020-01-01', body='Draft', status='draft'))
|
||||
published = self.family_save('event', dict(draft['item'], body='Published', status='published'))
|
||||
restored = app.FAMILY.restore(dict(changeRevision=draft['revision'], side='after', revision=app.setting('familyRevision')), self.admin)
|
||||
self.assertEqual(restored['item']['status'], 'draft')
|
||||
self.assertEqual(restored['item']['publishedAt'], published['item']['publishedAt'])
|
||||
self.assertNotIn(draft['item']['id'], [e['id'] for e in app.FAMILY.snapshot(dict(role='member', familyAccess='read'))['events']])
|
||||
for side in ['before', 'invalid']:
|
||||
with self.assertRaises(app.Problem):
|
||||
app.FAMILY.restore(dict(changeRevision=draft['revision'], side=side, revision=app.setting('familyRevision')), self.admin)
|
||||
|
||||
def test_history_pagination_is_complete_and_omits_story_bodies(self):
|
||||
item = self.family_save('person', {'name': 'Pagination', 'biography': 'Private full story'})['item']
|
||||
for i in range(23):item = self.family_save('person', dict(item, note=str(i)))['item']
|
||||
first = app.FAMILY.history(self.admin, 'person', item['id'])
|
||||
second = app.FAMILY.history(self.admin, 'person', item['id'], first['nextBefore'])
|
||||
revisions = [v['revision'] for v in first['items'] + second['items']]
|
||||
self.assertEqual(len(revisions), 24)
|
||||
self.assertEqual(len(set(revisions)), 24)
|
||||
self.assertIsNone(second['nextBefore'])
|
||||
self.assertNotIn('Private full story', json.dumps(first))
|
||||
|
||||
def test_history_and_restore_endpoints_enforce_edit_permission(self):
|
||||
item = self.family_save('person', {'name': 'Permission history'})
|
||||
reader = app.ACCOUNTS.save(dict(username='history-reader', password='test-reader', familyAccess='read'), self.admin)
|
||||
token = 'd' * 64
|
||||
with app.LOCK:
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, reader['id']))
|
||||
app.DB.commit()
|
||||
path = '/api/family/history?kind=person&id=' + item['item']['id']
|
||||
for target in [path, '/api/family/change?revision=' + str(item['revision'])]:
|
||||
self.assertEqual(self.request(target, token=token)[0], 403)
|
||||
self.assertEqual(self.request(target, authenticated=False)[0], 401)
|
||||
self.assertEqual(self.request(target)[0], 200)
|
||||
self.assertEqual(self.request('/api/family/restore', token=token, data={})[0], 403)
|
||||
for number in ['bad', '-1', '0', '99999999999999999999999']:
|
||||
self.assertEqual(self.request('/api/family/change?revision=' + number)[0], 400)
|
||||
|
||||
def test_family_history_transaction_rolls_back_on_storage_failure(self):
|
||||
before = app.setting('familyRevision', 0)
|
||||
with app.LOCK:
|
||||
app.DB.execute("CREATE TRIGGER fail_history BEFORE INSERT ON family_changes BEGIN SELECT RAISE(ABORT, 'test failure'); END")
|
||||
app.DB.commit()
|
||||
try:
|
||||
import sqlite3
|
||||
with self.assertRaises(sqlite3.IntegrityError):self.family_save('person', {'name': 'Must not persist'})
|
||||
self.assertEqual(app.setting('familyRevision', 0), before)
|
||||
self.assertFalse(any(p['name']=='Must not persist' for p in app.objects('people')))
|
||||
finally:
|
||||
with app.LOCK:app.DB.execute('DROP TRIGGER fail_history');app.DB.commit()
|
||||
|
||||
def test_concurrent_family_saves_accept_only_one_revision(self):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
revision = app.setting('familyRevision', 0); barrier = threading.Barrier(2)
|
||||
def save(index):
|
||||
barrier.wait()
|
||||
try:app.FAMILY.save('person', dict(name='Concurrent '+str(index), revision=revision), self.admin);return 200
|
||||
except app.Problem as e:return e.status
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:self.assertEqual(sorted(pool.map(save, range(2))), [200, 409])
|
||||
self.assertEqual(app.setting('familyRevision'), revision+1)
|
||||
|
||||
def test_password_change_revokes_all_own_sessions_and_keeps_other_accounts(self):
|
||||
member = app.ACCOUNTS.save(dict(username='password-member', password='old-password', familyAccess='read'), self.admin)
|
||||
tokens = ['1'*64, '2'*64]
|
||||
with app.LOCK:
|
||||
for token in tokens:app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+3600, member['id']))
|
||||
app.DB.commit()
|
||||
code, headers, _ = self.request('/api/account/password', token=tokens[0], data=dict(currentPassword='old-password', newPassword='new-password', id=self.admin['id'], role='admin'))
|
||||
self.assertEqual(code, 200);self.assertIn('Max-Age=0', headers['Set-Cookie'])
|
||||
self.assertIsNone(app.ACCOUNTS.verify(member['username'], 'old-password'))
|
||||
self.assertEqual(app.ACCOUNTS.verify(member['username'], 'new-password')['role'], 'member')
|
||||
for token in tokens:self.assertEqual(self.request('/api/family', token=token)[0], 401)
|
||||
self.assertEqual(self.request('/api/users')[0], 200)
|
||||
|
||||
def test_password_validation_and_rate_limit(self):
|
||||
actor = app.ACCOUNTS.save(dict(username='password-validation', password='old-password'), self.admin)
|
||||
for data in [dict(currentPassword='old-password', newPassword='tiny'),dict(currentPassword='old-password', newPassword='old-password')]:
|
||||
with self.assertRaises(app.Problem):app.ACCOUNTS.change_password(data, actor)
|
||||
for _ in range(8):
|
||||
with self.assertRaises(app.Problem) as caught:app.ACCOUNTS.change_password(dict(currentPassword='wrong', newPassword='new-password'), actor)
|
||||
self.assertEqual(caught.exception.status, 403)
|
||||
with self.assertRaises(app.Problem) as caught:app.ACCOUNTS.change_password(dict(currentPassword='old-password', newPassword='new-password'), actor)
|
||||
self.assertEqual(caught.exception.status, 429)
|
||||
self.assertIsNotNone(app.ACCOUNTS.verify(actor['username'], 'old-password'))
|
||||
self.assertEqual(self.request('/api/account/password', authenticated=False, data={})[0], 401)
|
||||
self.assertEqual(self.request('/api/account/password', data={}, origin='http://[bad')[0], 403)
|
||||
|
||||
def test_object_ids_and_hosts_reject_malformed_input_without_500(self):
|
||||
for endpoint in ['sites', 'assets', 'cameras', 'recorders']:
|
||||
for value in [[], {}, 17, True]:
|
||||
self.assertEqual(self.request('/api/'+endpoint, data={'id':value})[0], 400)
|
||||
self.assertEqual(self.request('/api/'+endpoint, data={'id':'9'*16})[0], 404)
|
||||
for value in [None, 123, [], {}, True]:
|
||||
with self.assertRaises(app.Problem):app.host_address(value)
|
||||
|
||||
def test_concurrent_camera_capacity_and_asset_space_move(self):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
site = app.save_site({'name':'Capacity space'})
|
||||
other = app.save_site({'name':'Moved space'})
|
||||
asset = app.save_asset(dict(name='Single lens', siteId=site['id'], lensCount=1))
|
||||
barrier = threading.Barrier(2)
|
||||
def save(index):
|
||||
barrier.wait()
|
||||
try:app.save_camera(dict(name='Concurrent lens '+str(index), assetId=asset['id'], host='192.168.0.2'));return 200
|
||||
except app.Problem as e:return e.status
|
||||
with patch.object(app, 'write_media_config'):
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:self.assertEqual(sorted(pool.map(save, range(2))), [200, 400])
|
||||
attached = [c for c in app.objects('cameras') if c.get('assetId')==asset['id']]
|
||||
self.assertEqual(len(attached), 1)
|
||||
app.save_asset(dict(asset, siteId=other['id']))
|
||||
self.assertEqual(app.get_object('cameras', attached[0]['id'])['siteId'], other['id'])
|
||||
|
||||
def test_recorder_updates_are_reflected_in_channel_endpoint_without_password(self):
|
||||
site = app.save_site({'name':'Recorder test'})
|
||||
asset = app.save_asset(dict(name='Recorder lens', siteId=site['id'], lensCount=1))
|
||||
with patch.object(app, 'write_media_config'):
|
||||
r = app.save_recorder(dict(name='NVR test', siteId=site['id'], host='192.168.0.8', username='test', password='private'))
|
||||
camera = app.save_camera(dict(name='Channel', assetId=asset['id'], sourceKind='recorder', recorderId=r['id']))
|
||||
app.save_recorder(dict(r, host='192.168.0.9', username='updated'))
|
||||
current = app.public_camera(app.get_object('cameras', camera['id']))
|
||||
self.assertEqual(current['host'], '192.168.0.9')
|
||||
self.assertEqual(current['username'], 'updated')
|
||||
self.assertTrue(current['passwordConfigured']);self.assertNotIn('password', current)
|
||||
|
||||
def test_camera_settings_admin_only_and_errors_are_explicit(self):
|
||||
user = app.ACCOUNTS.save(dict(username='settings-member', password='settings-pass', siteIds=['test-site'] if app.get_object('sites','test-site') else []), self.admin)
|
||||
token = '3'*64
|
||||
with app.LOCK:
|
||||
app.DB.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(),time.time()+3600,user['id']));app.DB.commit()
|
||||
path = '/api/camera/settings?camera='+'a'*16
|
||||
self.assertEqual(self.request(path,token=token)[0],403)
|
||||
self.assertEqual(self.request('/api/camera/settings',data={},token=token)[0],403)
|
||||
self.assertEqual(self.request(path,authenticated=False)[0],401)
|
||||
with patch.object(app.camera_settings.CONTROLLER,'read',return_value=({'device':{'Model':'Example'},'revision':'one'},{})):
|
||||
self.assertEqual(self.request(path)[0],200)
|
||||
with patch.object(app.camera_settings.CONTROLLER,'save',side_effect=ValueError('unsupported')):
|
||||
self.assertEqual(self.request('/api/camera/settings',data={'camera':'a'*16})[0],400)
|
||||
with patch.object(app.camera_settings.CONTROLLER,'save',side_effect=OSError()):
|
||||
self.assertEqual(self.request('/api/camera/settings',data={'camera':'a'*16})[0],502)
|
||||
|
||||
def test_asset_move_rolls_back_all_channels_on_failure(self):
|
||||
import sqlite3
|
||||
site = app.save_site({'name':'Original site atomic'})
|
||||
other = app.save_site({'name':'New site atomic'})
|
||||
asset = app.save_asset(dict(name='Atomic move',siteId=site['id'],lensCount=1))
|
||||
with patch.object(app,'write_media_config'):
|
||||
camera = app.save_camera(dict(name='Atomic lens',assetId=asset['id'],host='192.168.0.2'))
|
||||
with app.LOCK:
|
||||
app.DB.execute("CREATE TRIGGER fail_camera_move BEFORE UPDATE ON cameras BEGIN SELECT RAISE(ABORT,'test'); END");app.DB.commit()
|
||||
try:
|
||||
with self.assertRaises(sqlite3.IntegrityError):app.save_asset(dict(asset,siteId=other['id']))
|
||||
self.assertEqual(app.get_object('assets',asset['id'])['siteId'],site['id'])
|
||||
self.assertEqual(app.get_object('cameras',camera['id'])['siteId'],site['id'])
|
||||
finally:
|
||||
with app.LOCK:app.DB.execute('DROP TRIGGER fail_camera_move');app.DB.commit()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),cal=require('./web/calendar.js');
|
||||
test('Spring Festival and leap month are bidirectional',()=>{
|
||||
assert.deepEqual(cal.lunar('2024-02-10'),{year:2024,month:1,day:1,leap:false});
|
||||
assert.equal(cal.solar('2024-01-01'),'2024-02-10');
|
||||
assert.deepEqual(cal.lunar('2023-03-22'),{year:2023,month:2,day:1,leap:true});
|
||||
assert.equal(cal.solar('2023-02-01',true),'2023-03-22');
|
||||
assert.equal(cal.solar('2023-02-01',false),'2023-02-20');
|
||||
});
|
||||
test('all days across a leap year round trip',()=>{
|
||||
for(let n=0;n<366;n++){const s=new Date(Date.UTC(2024,0,1+n,12)).toISOString().slice(0,10),l=cal.lunar(s);assert.equal(cal.solar(cal.iso(l),l.leap),s);}
|
||||
});
|
||||
test('invalid dates, nonexistent leap months and range are explicit',()=>{
|
||||
for(const s of ['2026-02-30','2026-13-01','bad'])assert.throws(()=>cal.solarDate(s));
|
||||
assert.throws(()=>cal.solar('2024-02-01',true));assert.throws(()=>cal.solar('2024-12-31'));assert.throws(()=>cal.lunar('1800-01-01'));
|
||||
assert.equal(cal.describe('1800-01-01'),'1800-01-01(公历)');
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
"""ONVIF contract tests against synthetic devices; no real camera is modified."""
|
||||
import copy
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree as ET
|
||||
import camera_settings as cs
|
||||
import onvif
|
||||
|
||||
|
||||
def xml(inner):
|
||||
return ET.fromstring('<root xmlns:t="'+onvif.SCHEMA+'" xmlns:m="'+onvif.MEDIA+'" xmlns:i="'+cs.IMAGING+'">'+inner+'</root>')
|
||||
|
||||
|
||||
class SettingsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.controller=cs.Settings()
|
||||
self.camera=dict(host='192.168.0.2',username='test',password='private',sourceKind='direct',enabled=True,
|
||||
mainPath='/stream?profile=lens2',subPath='/sub?profile=lens2')
|
||||
self.encoder=xml('<t:VideoEncoderConfiguration token="encoder2" encoding="H265"><t:Encoding>H264</t:Encoding>'
|
||||
'<t:Resolution><t:Width>1920</t:Width><t:Height>1080</t:Height></t:Resolution><t:Quality>3</t:Quality>'
|
||||
'<t:RateControl><t:FrameRateLimit>15</t:FrameRateLimit><t:BitrateLimit>2048</t:BitrateLimit></t:RateControl>'
|
||||
'<t:H264><t:GovLength>60</t:GovLength><t:H264Profile>Main</t:H264Profile></t:H264>'
|
||||
'<t:Multicast><t:TTL>1</t:TTL></t:Multicast><t:SessionTimeout>PT5S</t:SessionTimeout></t:VideoEncoderConfiguration>')[0]
|
||||
self.info=dict(device=(80,'/onvif/device_service'),services={'Media':(80,'/media'),'Imaging':(80,'/imaging')},
|
||||
source='source2',profile='lens2',encoder=self.encoder)
|
||||
self.imaging=xml('<i:ImagingSettings><t:Brightness>50</t:Brightness><t:IrCutFilter>AUTO</t:IrCutFilter>'
|
||||
'<t:Focus><t:AutoFocusMode>MANUAL</t:AutoFocusMode></t:Focus></i:ImagingSettings>')
|
||||
self.image_options=xml('<i:ImagingOptions><t:Brightness><t:Min>0</t:Min><t:Max>100</t:Max></t:Brightness>'
|
||||
'<t:IrCutFilterModes>AUTO</t:IrCutFilterModes><t:IrCutFilterModes>ON</t:IrCutFilterModes></i:ImagingOptions>')
|
||||
self.video_options=xml('<m:Options><t:QualityRange><t:Min>0</t:Min><t:Max>5</t:Max></t:QualityRange>'
|
||||
'<t:H264><t:FrameRateRange><t:Min>1</t:Min><t:Max>15</t:Max></t:FrameRateRange>'
|
||||
'<t:ResolutionsAvailable><t:Width>1920</t:Width><t:Height>1080</t:Height></t:ResolutionsAvailable></t:H264>'
|
||||
'<t:Extension><t:H264><t:BitrateRange><t:Min>256</t:Min><t:Max>2048</t:Max></t:BitrateRange></t:H264></t:Extension></m:Options>')
|
||||
self.calls=[]
|
||||
|
||||
def call(self,camera,endpoint,ns,operation,body=''):
|
||||
self.calls.append((operation,body))
|
||||
if operation=='GetDeviceInformation':return xml('')
|
||||
if operation=='GetImagingSettings':return copy.deepcopy(self.imaging)
|
||||
if operation=='GetOptions':return self.image_options
|
||||
if operation=='GetVideoEncoderConfigurationOptions':return self.video_options
|
||||
if operation=='SetImagingSettings':
|
||||
root=xml(body.replace('xmlns:', 'xmlns:'))
|
||||
self.imaging=xml(ET.tostring(root.find(cs.I+'ImagingSettings'),encoding='unicode'))
|
||||
return xml('')
|
||||
return xml('')
|
||||
|
||||
def test_device_ranges_and_vendor_codec_are_exposed_without_credentials(self):
|
||||
with patch.object(self.controller,'call',self.call):data=self.controller.details(self.camera,self.info)
|
||||
self.assertEqual(data['imaging']['brightness']['max'],100)
|
||||
self.assertEqual(data['imaging']['dayNight']['choices'],['AUTO','ON'])
|
||||
self.assertEqual(data['video']['codec'],'H265')
|
||||
self.assertEqual(data['video']['options']['bitrate']['max'],2048)
|
||||
self.assertNotIn('private',str(data))
|
||||
self.assertNotIn('sharpness',data['imaging'])
|
||||
|
||||
def test_rejects_bad_values_and_unsupported_controls(self):
|
||||
rule={'brightness':dict(min=0,max=100)}
|
||||
for value in [True,101,-1,float('nan'),float('inf'),'50']:
|
||||
with self.assertRaises(ValueError):self.controller.validate({'brightness':value},rule)
|
||||
for values in [{},{'unknown':1},None,[]]:
|
||||
with self.assertRaises(ValueError):self.controller.validate(values,rule)
|
||||
with self.assertRaises(ValueError):self.controller.validate({'fps':1.5},{'fps':dict(min=1,max=15)},('fps',))
|
||||
with self.assertRaises(ValueError):self.controller.validate({'mode':'OFF'},{'mode':dict(choices=['AUTO'])})
|
||||
|
||||
def test_imaging_write_preserves_other_fields_and_verifies_readback(self):
|
||||
with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call):
|
||||
data,_=self.controller.read(self.camera)
|
||||
result=self.controller.save(self.camera,dict(revision=data['revision'],section='imaging',values={'brightness':60},quality='main'))
|
||||
self.assertTrue(result['verified'])
|
||||
sent=next(body for op,body in self.calls if op=='SetImagingSettings')
|
||||
self.assertIn('MANUAL',sent);self.assertIn('source2',sent);self.assertIn('ForcePersistence',sent)
|
||||
|
||||
def test_stale_revision_never_issues_device_write(self):
|
||||
with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call):
|
||||
with self.assertRaises(ValueError):self.controller.save(self.camera,dict(revision='old',section='imaging',values={'brightness':60}))
|
||||
self.assertFalse(any(op.startswith('Set') for op,_ in self.calls))
|
||||
|
||||
def test_video_write_retains_codec_extension_multicast_and_other_settings(self):
|
||||
with patch.object(self.controller,'discover',return_value=self.info),patch.object(self.controller,'call',self.call):
|
||||
data,_=self.controller.read(self.camera)
|
||||
result=self.controller.save(self.camera,dict(revision=data['revision'],section='video',values={'fps':10}))
|
||||
sent=next(body for op,body in self.calls if op=='SetVideoEncoderConfiguration')
|
||||
self.assertIn('encoding="H265"',sent);self.assertIn('Multicast',sent);self.assertIn('SessionTimeout',sent)
|
||||
self.assertIn('>10<',sent);self.assertFalse(result['verified']) # Synthetic device deliberately ignores this write.
|
||||
|
||||
def test_device_timeout_after_save_does_not_falsely_claim_confirmation(self):
|
||||
with patch.object(self.controller,'call',self.call):data=self.controller.details(self.camera,self.info)
|
||||
with patch.object(self.controller,'read',side_effect=[(data,self.info),OSError()]),patch.object(self.controller,'call',return_value=xml('')):
|
||||
result=self.controller.save(self.camera,dict(revision=data['revision'],section='imaging',values={'brightness':60}))
|
||||
self.assertFalse(result['verified']);self.assertIsNone(result['settings'])
|
||||
|
||||
def test_onvif_requests_do_not_follow_redirects(self):
|
||||
self.assertIsNone(onvif.NoRedirect().redirect_request(None, None, 302, 'Found', {}, 'http://other.example/'))
|
||||
|
||||
def test_network_endpoints_and_recorder_channels_are_rejected(self):
|
||||
for address in ['http://evil.example/api','https://192.168.0.2/api','http://u:p@192.168.0.2/api','file:///etc/passwd']:
|
||||
with self.assertRaises(ValueError):self.controller.endpoint(self.camera,address)
|
||||
for overrides in [dict(sourceKind='recorder'),dict(enabled=False)]:
|
||||
with self.assertRaises(ValueError):self.controller.discover(dict(self.camera,**overrides),'main')
|
||||
with self.assertRaises(ValueError):self.controller.discover(self.camera,'bad')
|
||||
|
||||
def test_exact_profile_path_and_query_selects_second_lens(self):
|
||||
selected=[]
|
||||
def call(c,e,ns,op,body=''):
|
||||
if op=='GetCapabilities':return xml('<t:Media><t:XAddr>http://192.168.0.2/media</t:XAddr></t:Media>')
|
||||
if op=='GetProfiles':return xml(''.join('<m:Profiles token="lens'+str(i)+'"><t:VideoSourceConfiguration><t:SourceToken>source'+str(i)+'</t:SourceToken></t:VideoSourceConfiguration>'+ET.tostring(self.encoder,encoding='unicode')+'</m:Profiles>' for i in [1,2]))
|
||||
if op=='GetStreamUri':
|
||||
token='lens2' if 'lens2' in body else 'lens1';selected.append(token)
|
||||
return xml('<t:Uri>rtsp://192.168.0.2/stream?profile='+token+'</t:Uri>')
|
||||
with patch.object(self.controller,'call',call):info=self.controller.discover(self.camera,'main')
|
||||
self.assertEqual(info['source'],'source2');self.assertEqual(selected,['lens1','lens2'])
|
||||
|
||||
def test_failed_options_disable_writes_but_keep_other_section_readable(self):
|
||||
def call(*args):
|
||||
if args[3]=='GetVideoEncoderConfigurationOptions':raise OSError()
|
||||
return self.call(*args)
|
||||
with patch.object(self.controller,'call',call):data=self.controller.details(self.camera,self.info)
|
||||
self.assertEqual(data['video']['options'],{});self.assertIn('brightness',data['imaging']);self.assertTrue(data['notes'])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,12 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),G=require('./web/family-graph.js');
|
||||
const person=(id,extra={})=>({id,name:id,birthDate:'',...extra});
|
||||
const edge=(fromId,toId,kind='parent',extra={})=>({id:fromId+toId,fromId,toId,kind,active:true,...extra});
|
||||
function valid(g,count){assert.equal(g.nodes.length,count);assert.equal(new Set(g.nodes.map(n=>n.id)).size,count);for(const n of g.nodes){assert.ok(Number.isFinite(n.x)&&Number.isFinite(n.y));assert.ok(n.x>=0&&n.y>=0&&n.x+n.width<=g.width&&n.y+n.height<=g.height);}for(let i=0;i<g.nodes.length;i++)for(let j=i+1;j<g.nodes.length;j++){const a=g.nodes[i],b=g.nodes[j];assert.ok(a.x+a.width<=b.x||b.x+b.width<=a.x||a.y+a.height<=b.y||b.y+b.height<=a.y,'nodes overlap');}}
|
||||
test('both parents, spouses, siblings, children appear once in one complete graph',()=>{const p=['grandfather','grandmother','father','mother','self','sister','partner','son'].map(x=>person(x));const e=[edge('grandfather','grandmother','spouse'),edge('grandfather','father'),edge('grandmother','father'),edge('father','mother','spouse'),edge('father','self'),edge('mother','self'),edge('father','sister'),edge('mother','sister'),edge('self','partner','spouse'),edge('self','son'),edge('partner','son')];const g=G.layout(p,e,{selfId:'self'});valid(g,8);assert.equal(g.paths.length,e.length);const m=new Map(g.nodes.map(n=>[n.id,n]));assert.equal(m.get('father').y,m.get('mother').y);assert.equal(m.get('self').y,m.get('sister').y);assert.ok(m.get('father').y<m.get('self').y);assert.ok(m.get('son').y>m.get('self').y);});
|
||||
test('archives, inactive edges, missing endpoints are explicit filters',()=>{const p=[person('a'),person('b',{archived:true}),person('c')],e=[edge('a','b'),edge('a','c','parent',{active:false}),edge('missing','a')];const g=G.layout(p,e);valid(g,2);assert.equal(g.paths.length,0);valid(G.layout(p,e,{showArchived:true}),3);});
|
||||
test('disconnected family branches and isolated people are retained',()=>{const p=['a','b','c','d','e'].map(x=>person(x)),g=G.layout(p,[edge('a','b'),edge('c','d')],{selfId:'c'});valid(g,5);assert.equal(g.components,3);assert.equal(g.nodes[0].id,'c');});
|
||||
test('cross-generation spouse constraints fall back without changing parent order',()=>{const p=['a','b','c','d'].map(x=>person(x));const g=G.layout(p,[edge('a','b','spouse'),edge('c','d','spouse'),edge('a','c'),edge('d','b')]);valid(g,4);assert.ok(g.warnings.length);const m=new Map(g.nodes.map(n=>[n.id,n]));assert.ok(m.get('c').y>m.get('a').y);assert.ok(m.get('b').y>m.get('d').y);});
|
||||
test('ancestry cycles from malformed data terminate with warning',()=>{const g=G.layout([person('a'),person('b')],[edge('a','b'),edge('b','a')]);valid(g,2);assert.ok(g.warnings.length);});
|
||||
test('deep ancestry has no recursive limit and no silent truncation',()=>{const p=Array.from({length:1000},(_,i)=>person(String(i)));const e=p.slice(1).map((p,i)=>edge(String(i),p.id));const g=G.layout(p,e);assert.equal(g.nodes.length,1000);assert.equal(g.paths.length,999);assert.ok(g.nodes[999].y>g.nodes[0].y);});
|
||||
test('empty and one-person family have valid bounds',()=>{valid(G.layout([],[]),0);valid(G.layout([person('self')],[],{selfId:'self'}),1);});
|
||||
test('layout never mutates stored people or relationships',()=>{const p=[person('a'),person('b')],e=[edge('a','b')],before=JSON.stringify([p,e]);G.layout(p,e);assert.equal(JSON.stringify([p,e]),before);});
|
||||
@@ -0,0 +1,7 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),K=require('./web/kinship.js');
|
||||
const people=Array.from({length:10},(_,i)=>({id:String(i),gender:i===9?'female':'male',birthDate:''}));
|
||||
const links=Array.from({length:8},(_,i)=>({id:String(i),fromId:String(i),toId:String(i+1),kind:'parent',active:true,lineage:'biological'}));
|
||||
test('descendants are derived across generations',()=>{assert.equal(K.describe(people,links,'0','1'),'儿子');assert.equal(K.describe(people,links,'0','2'),'孙子');assert.equal(K.describe(people,links,'0','3'),'曾孙');assert.equal(K.describe(people,links,'0','4'),'玄孙');assert.equal(K.describe(people,links,'0','8'),'第 8 代后裔');});
|
||||
test('ancestors, self and disconnected people',()=>{assert.equal(K.describe(people,links,'4','0'),'高祖辈');assert.equal(K.describe(people,links,'0','0'),'本人');assert.equal(K.describe(people,links,'0','9'),'关系待补充');});
|
||||
test('siblings do not invent age order',()=>{const siblings=[...links,{fromId:'0',toId:'9',kind:'parent',active:true}];assert.equal(K.describe(people,siblings,'1','9'),'姐妹(长幼待确认)');const dated=people.map(p=>({...p,birthDate:p.id==='1'?'2000-01-01':p.id==='9'?'2002-01-01':''}));assert.equal(K.describe(dated,siblings,'1','9'),'妹妹');});
|
||||
test('inactive and adoptive relationships remain explicit',()=>{assert.equal(K.describe(people,links.map(e=>({...e,active:false})),'0','1'),'关系待补充');assert.match(K.describe(people,links.map(e=>({...e,lineage:'adoptive'})),'0','1'),/收养/);});
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const LivePlayer = require('./web/live-player.js');
|
||||
|
||||
function fixture() {
|
||||
let now = 100, serial = 0;
|
||||
const timeouts = new Map(), intervals = new Map();
|
||||
const clock = {
|
||||
setTimeout(fn, ms) {const id=++serial;timeouts.set(id,{fn,ms});return id;},
|
||||
clearTimeout(id) {timeouts.delete(id);},
|
||||
setInterval(fn, ms) {const id=++serial;intervals.set(id,{fn,ms});return id;},
|
||||
clearInterval(id) {intervals.delete(id);}
|
||||
};
|
||||
const listeners = new Map(), indicators = {'.video-empty':{hidden:false},'.video-error':{hidden:true}};
|
||||
const video = {
|
||||
currentTime:0,readyState:0,videoWidth:0,paused:false,ended:false,error:null,
|
||||
addEventListener(e,f) {listeners.set(e,f);},removeEventListener(e) {listeners.delete(e);},
|
||||
emit(e) {listeners.get(e)?.();},
|
||||
play() {this.paused=false;this.emit('play');return Promise.resolve();},
|
||||
pause() {this.paused=true;this.emit('pause');},removeAttribute() {},load() {},
|
||||
canPlayType() {return '';}
|
||||
};
|
||||
const instances=[];
|
||||
class Hls {
|
||||
static Events={ERROR:'error',MANIFEST_PARSED:'manifest'};
|
||||
static isSupported() {return true;}
|
||||
constructor() {this.events={};instances.push(this);}
|
||||
on(e,f) {this.events[e]=f;}
|
||||
loadSource() {} attachMedia() {} destroy() {this.destroyed=true;}
|
||||
fail() {this.events.error(null,{fatal:true});}
|
||||
}
|
||||
const player=new LivePlayer(video,{querySelector:s=>indicators[s]},'/example',{Hls,clock,now:()=>now});
|
||||
return {player,video,instances,timeouts,intervals,indicators,
|
||||
advance(ms) {now+=ms;player.watch();},
|
||||
playing() {video.readyState=4;video.videoWidth=1920;video.emit('playing');},
|
||||
retry() {const [id,t]=timeouts.entries().next().value;timeouts.delete(id);now+=t.ms;t.fn();}
|
||||
};
|
||||
}
|
||||
|
||||
test('readiness, recording status and labels do not change playback identity',()=>{
|
||||
const camera={id:'one',enabled:true,ready:true,name:'A',recordingActive:false};
|
||||
const original=LivePlayer.key([camera],'hd');
|
||||
assert.equal(LivePlayer.key([{...camera,ready:false,name:'B',recordingActive:true}],'hd'),original);
|
||||
assert.notEqual(LivePlayer.key([camera],'compat'),original);
|
||||
});
|
||||
test('fatal error reconnects only its own player with bounded backoff',()=>{
|
||||
const a=fixture(),b=fixture();
|
||||
for(let i=0;i<8;i++) {
|
||||
a.instances.at(-1).fail();
|
||||
assert.equal(a.timeouts.size,1);
|
||||
assert.ok([...a.timeouts.values()][0].ms<=30000);
|
||||
a.retry();
|
||||
}
|
||||
assert.equal(b.instances.length,1);
|
||||
a.player.destroy();b.player.destroy();
|
||||
});
|
||||
test('duplicate errors schedule only one retry',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.video.emit('error');
|
||||
assert.equal(f.timeouts.size,1);f.player.destroy();
|
||||
});
|
||||
test('continuous playback does not restart during repeated health checks',()=>{
|
||||
const f=fixture();f.playing();
|
||||
for(let i=0;i<72;i++) {f.video.currentTime+=5;f.advance(5000);}
|
||||
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);f.player.destroy();
|
||||
});
|
||||
test('stalled playback automatically reconnects',()=>{
|
||||
const f=fixture();f.playing();f.advance(30000);
|
||||
assert.equal(f.timeouts.size,1);f.retry();assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
test('startup has enough time to wait for a source',()=>{
|
||||
const f=fixture();f.advance(60000);assert.equal(f.timeouts.size,0);
|
||||
f.advance(6000);assert.equal(f.timeouts.size,1);f.player.destroy();
|
||||
});
|
||||
test('intentional pause is preserved and resume can recover a failed connection',()=>{
|
||||
const f=fixture();f.playing();f.video.pause();f.instances[0].fail();f.advance(90000);
|
||||
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);
|
||||
f.video.play();assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
test('destroy cancels timers and releases the old player',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.player.destroy();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.intervals.size,0);assert.equal(f.instances[0].destroyed,true);
|
||||
});
|
||||
test('recovery before timeout cancels pending retry',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.playing();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.indicators['.video-error'].hidden,true);f.player.destroy();
|
||||
});
|
||||
test('error from an old HLS instance cannot restart its replacement',()=>{
|
||||
const f=fixture();const old=f.instances[0];old.fail();f.retry();old.fail();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree.ElementTree import fromstring
|
||||
import ptz
|
||||
|
||||
|
||||
class PtzTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.controller = ptz.Controller()
|
||||
self.camera = dict(host='192.168.0.2', username='admin', password='example', mainPath='/stream1')
|
||||
self.info = dict(endpoint=(80, '/onvif/PTZ'), profile='profile1', node='shared')
|
||||
|
||||
def test_nudge_has_device_deadline_and_explicit_stop(self):
|
||||
with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call') as call, patch('ptz.time.sleep'):
|
||||
self.assertTrue(self.controller.command(self.camera, 'left')['stopped'])
|
||||
self.assertEqual([c.args[3] for c in call.call_args_list], ['ContinuousMove', 'Stop'])
|
||||
self.assertIn('<m:Timeout>PT1S</m:Timeout>', call.call_args_list[0].args[4])
|
||||
self.assertIn('x="-0.25"', call.call_args_list[0].args[4])
|
||||
|
||||
def test_failed_move_still_stops(self):
|
||||
with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call', side_effect=[OSError(), None]) as call:
|
||||
with self.assertRaises(OSError):
|
||||
self.controller.command(self.camera, 'up')
|
||||
self.assertEqual(call.call_args_list[-1].args[3], 'Stop')
|
||||
self.assertFalse(self.controller.locks[('192.168.0.2', 80, 'shared')].locked())
|
||||
|
||||
def test_shared_node_rejects_overlapping_moves_but_allows_stop(self):
|
||||
lock=threading.Lock();lock.acquire();self.controller.locks[('192.168.0.2',80,'shared')]=lock
|
||||
with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call') as call:
|
||||
with self.assertRaises(ValueError):self.controller.command(self.camera,'right')
|
||||
self.controller.command(self.camera,'stop')
|
||||
self.assertEqual(call.call_args.args[3],'Stop')
|
||||
lock.release()
|
||||
|
||||
def test_arbitrary_commands_and_foreign_services_rejected(self):
|
||||
for action in ['zoom','home','SetPreset',None]:
|
||||
with self.assertRaises(ValueError):self.controller.command(self.camera,action)
|
||||
for endpoint in ['http://example.org/onvif/PTZ','http://admin:secret@192.168.0.2/onvif/PTZ','https://192.168.0.2/onvif/PTZ']:
|
||||
with self.assertRaises(ValueError):self.controller.endpoint(self.camera,endpoint)
|
||||
|
||||
def test_discovery_and_short_timeout_requirement(self):
|
||||
def xml(text):return fromstring('<root xmlns:tt="'+ptz.onvif.SCHEMA+'" xmlns:m="'+ptz.onvif.MEDIA+'">'+text+'</root>')
|
||||
caps=xml('<tt:PTZ><tt:XAddr>http://192.168.0.2/onvif/PTZ</tt:XAddr></tt:PTZ><tt:Media><tt:XAddr>http://192.168.0.2/onvif/Media</tt:XAddr></tt:Media>')
|
||||
profiles=xml('<m:Profiles token="profile1"><tt:PTZConfiguration token="config"><tt:NodeToken>shared</tt:NodeToken></tt:PTZConfiguration></m:Profiles>')
|
||||
template='<tt:ContinuousPanTiltVelocitySpace><tt:URI>'+ptz.SPACE+'</tt:URI><tt:XRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:XRange><tt:YRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:YRange></tt:ContinuousPanTiltVelocitySpace><tt:PTZTimeout><tt:Min>{minimum}</tt:Min><tt:Max>PT600S</tt:Max></tt:PTZTimeout>'
|
||||
self.camera['mainPath']='/stream1?profile=profile1'
|
||||
with patch.object(self.controller,'call',side_effect=[caps,profiles,xml(template.format(minimum='PT1S'))]):
|
||||
self.assertTrue(self.controller.capabilities(self.camera)['supported'])
|
||||
self.controller.cache.clear()
|
||||
with patch.object(self.controller,'call',side_effect=[caps,profiles,xml(template.format(minimum='PT5S'))]):
|
||||
with self.assertRaises(ValueError):self.controller.discover(self.camera)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
// Run actual UI functions in isolated contexts; no live accounts or browser state.
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
|
||||
const source=name=>fs.readFileSync('web/'+name,'utf8');
|
||||
function section(file,start,end){const s=source(file),a=s.indexOf(start),b=s.indexOf(end,a+start.length);assert.ok(a>=0&&b>a);return s.slice(a,b);}
|
||||
const apiSource=section('app.js','async function api(','function toast(');
|
||||
const refreshSource=section('app.js','async function refresh(','async function auth(');
|
||||
function context(extra={}){return vm.createContext({sessionGeneration:0,refreshGeneration:0,authGeneration:0,loggedIn:true,currentUser:{id:'a'},state:{},Error,...extra});}
|
||||
function deferred(){let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};}
|
||||
|
||||
test('late successful response cannot restore private data after account switch',async()=>{
|
||||
const pending=deferred(),c=context({fetch:()=>pending.promise});vm.runInContext(apiSource,c);
|
||||
const request=c.api('/api/family');c.sessionGeneration++;c.currentUser={id:'b'};
|
||||
pending.resolve({ok:true,json:async()=>({privatePerson:'previous account'})});
|
||||
await assert.rejects(request,e=>e.stale===true);
|
||||
});
|
||||
test('old unauthorized response cannot log out a new session',async()=>{
|
||||
const pending=deferred();let clears=0;const c=context({fetch:()=>pending.promise,clearPrivateView:()=>clears++,auth:()=>{}});vm.runInContext(apiSource,c);
|
||||
const request=c.api('/api/state');c.sessionGeneration++;
|
||||
pending.resolve({ok:false,status:401,json:async()=>({error:'old session'})});
|
||||
await assert.rejects(request,e=>e.stale===true);assert.equal(clears,0);
|
||||
});
|
||||
test('API errors retain conflict status for explicit reload workflow',async()=>{
|
||||
const c=context({fetch:async()=>({ok:false,status:409,json:async()=>({error:'家谱已被更新'})})});vm.runInContext(apiSource,c);
|
||||
await assert.rejects(c.api('/api/family/person',{}),e=>e.status===409);
|
||||
});
|
||||
test('late state refresh cannot overwrite newer state',async()=>{
|
||||
const first=deferred(),second=deferred(),requests=[first,second];let renders=0;
|
||||
const c=context({api:()=>requests.shift().promise,render:()=>renders++});vm.runInContext(refreshSource,c);
|
||||
const a=c.refresh(),b=c.refresh();second.resolve({version:'new',user:{id:'a'}});await b;
|
||||
first.resolve({version:'old',user:{id:'a'}});await a;
|
||||
assert.equal(c.state.version,'new');assert.equal(renders,1);
|
||||
});
|
||||
test('late refresh failure cannot cover a newer successful screen',async()=>{
|
||||
const first=deferred(),second=deferred(),requests=[first,second];let banners=0;
|
||||
const c=context({api:()=>requests.shift().promise,render:()=>{},$:()=>{banners++;return {};}});vm.runInContext(refreshSource,c);
|
||||
const a=c.refresh(),b=c.refresh();second.resolve({user:{id:'a'}});await b;first.reject(Error('old failure'));await a;assert.equal(banners,0);
|
||||
});
|
||||
test('another tab changing the shared login clears the previous private view',async()=>{
|
||||
let clears=0;const c=context({api:async()=>({user:{id:'b'},cameras:[]}),render:()=>{},
|
||||
clearPrivateView:()=>{clears++;c.loggedIn=false;c.currentUser=null;}});
|
||||
vm.runInContext(refreshSource,c);await c.refresh();assert.equal(clears,1);assert.equal(c.currentUser.id,'b');assert.equal(c.loggedIn,true);
|
||||
});
|
||||
test('expired session clears private view when session endpoint itself returns 200',async()=>{
|
||||
const fields=new Map();const $=s=>{if(!fields.has(s))fields.set(s,{elements:{username:{}},showModal(){this.open=true;}});return fields.get(s);};
|
||||
let clears=0;const c=context({api:async()=>({authenticated:false,user:null,setupRequired:false}),$,
|
||||
clearPrivateView:()=>{clears++;c.currentUser=null;c.loggedIn=false;},applyPermissions:()=>{},refresh:()=>{},toast:()=>{}});
|
||||
vm.runInContext(section('app.js','async function auth(','function openSite('),c);await c.auth();
|
||||
assert.equal(clears,1);assert.equal(c.loggedIn,false);assert.equal($('#auth-dialog').open,true);
|
||||
});
|
||||
test('archived person reached from an event is revealed before graph location',()=>{
|
||||
const toggle={checked:false};let rendered=false,located='';
|
||||
const c=context({family:{people:[{id:'ancestor',archived:true}]},$:()=>toggle,window:{innerWidth:1200},document:{},
|
||||
renderFamily:()=>{assert.equal(toggle.checked,true);rendered=true;},familyMap:{locate:id=>located=id}});
|
||||
vm.runInContext(section('family.js','function selectFamilyPerson(','function openPerson('),c);c.selectFamilyPerson('ancestor',true);
|
||||
assert.ok(rendered);assert.equal(c.familyView,'tree');assert.equal(located,'ancestor');
|
||||
});
|
||||
test('event default save preserves published state and exposes explicit withdrawal',()=>{
|
||||
const controls=new Map(),form={dataset:{},querySelector:s=>{if(!controls.has(s))controls.set(s,{});return controls.get(s);}};
|
||||
const elements=new Map([['#event-form',form]]);const $=s=>{if(!elements.has(s))elements.set(s,{showModal(){},close(){}});return elements.get(s);};
|
||||
const c=context({family:{canEdit:true,revision:3,people:[],events:[{id:'published',status:'published',personIds:[]}]},$,fillForm:()=>{},initDateFields:()=>{},esc:String});
|
||||
vm.runInContext(section('family-events.js','function editFamilyEvent(',"$('#event-form').addEventListener"),c);
|
||||
c.editFamilyEvent('published');assert.equal(form.querySelector('[data-save-event]').value,'published');assert.equal(form.querySelector('[data-save-event]').textContent,'保存修改');assert.equal(form.querySelector('[data-publish-event]').hidden,true);assert.equal(form.querySelector('[data-withdraw-event]').hidden,false);
|
||||
c.editFamilyEvent();assert.equal(form.querySelector('[data-save-event]').value,'draft');assert.equal(form.querySelector('[data-withdraw-event]').hidden,true);
|
||||
});
|
||||
test('event date validation never clears a separately edited person death date',()=>{
|
||||
const c=context({$:()=>{throw Error('unexpected person form access');},syncLifeStatus:()=>{throw Error('unexpected life state change');},dateHint:()=>{}});
|
||||
vm.runInContext(section('date-fields.js','function syncDateFields(','for(const field of $$'),c);
|
||||
c.syncDateFields(true,{id:'event-form',querySelectorAll:()=>[]});
|
||||
});
|
||||
test('older family request errors cannot repopulate logged-out view',async()=>{
|
||||
const pending=deferred();let messages=0;const c=context({familyGeneration:0,api:()=>pending.promise,$:()=>{messages++;return {};}});
|
||||
vm.runInContext(section('family.js','async function loadFamily(','function relationText('),c);
|
||||
const p=c.loadFamily();c.loggedIn=false;c.familyGeneration++;pending.reject(Error('late error'));assert.equal(await p,false);assert.equal(messages,0);
|
||||
});
|
||||
|
||||
const historySource=section('family-history.js','async function showFamilyChange(','function prepareFamilyRestore(');
|
||||
test('history prose is never translated as an enum value',()=>{
|
||||
const c=context({personName:id=>'Person '+id});vm.runInContext(section('family-history.js','function historyValue(','async function openFamilyHistory('),c);
|
||||
assert.equal(c.historyValue('body','published'),'published');assert.equal(c.historyValue('status','published'),'已发布');
|
||||
assert.equal(c.historyValue('personIds',['a','b']),'Person a、Person b');assert.equal(c.historyValue('active',false),'否');
|
||||
});
|
||||
test('history detail from a previous selection cannot replace newer detail',async()=>{
|
||||
const a=deferred(),b=deferred(),calls=[a,b],elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{});return elements.get(s);};
|
||||
const c=context({historyContext:{},historyDetailGeneration:0,historySelection:null,api:()=>calls.shift().promise,$,historyFields:{name:'Name'},esc:String,historyValue:(_,v)=>v});
|
||||
vm.runInContext(historySource,c);const old=c.showFamilyChange(1),latest=c.showFamilyChange(2);
|
||||
b.resolve({revision:2,before:null,after:{name:'New'}});await latest;const html=$('#history-detail').innerHTML;
|
||||
a.resolve({revision:1,before:null,after:{name:'Old'}});await old;assert.equal($('#history-detail').innerHTML,html);assert.equal(c.historySelection.revision,2);
|
||||
});
|
||||
test('closing history prevents late private detail from rendering',async()=>{
|
||||
const pending=deferred(),detail={};const c=context({historyContext:{},historyDetailGeneration:0,historySelection:null,api:()=>pending.promise,$:()=>detail});
|
||||
vm.runInContext(historySource,c);const request=c.showFamilyChange(1);c.historyContext=null;c.historyDetailGeneration++;pending.resolve({after:{name:'Private'}});await request;
|
||||
assert.equal(detail.innerHTML,undefined);assert.equal(c.historySelection,null);
|
||||
});
|
||||
test('password form requires matching, distinct and bounded new password',()=>{
|
||||
const c=context();vm.runInContext(section('app.js','function validateNewPassword(','$(\'#account-password\').onclick='),c);
|
||||
for(const data of [{newPassword:'different',confirmPassword:'mismatch'},{newPassword:'samepass',confirmPassword:'samepass',currentPassword:'samepass'},{newPassword:'short',confirmPassword:'short'},{newPassword:'x'.repeat(129),confirmPassword:'x'.repeat(129)}])assert.throws(()=>c.validateNewPassword(data));
|
||||
assert.doesNotThrow(()=>c.validateNewPassword({newPassword:'new-password',confirmPassword:'new-password',currentPassword:'old-password'}));
|
||||
});
|
||||
test('history restore sends the reviewed revision and never the full text',async()=>{
|
||||
let sent;const elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){}});return elements.get(s);};
|
||||
const c=context({historyContext:{revision:10},historySelection:{revision:3,before:{name:'Original'}},historyDetailGeneration:1,$,$$:()=>[],
|
||||
api:async(path,data)=>{sent={path,data};},loadFamilyHistory:async()=>{},loadFamily:async()=>true,toast:()=>{}});
|
||||
vm.runInContext(section('family-history.js','async function restoreFamilyChange(','$(\'#history-more\').onclick='),c);await c.restoreFamilyChange('before');
|
||||
assert.equal(sent.path,'/api/family/restore');assert.equal(JSON.stringify(sent.data),JSON.stringify({changeRevision:3,side:'before',revision:10}));assert.equal(c.historySelection,null);
|
||||
});
|
||||
|
||||
test('camera form sends only changed and advertised parameters',()=>{
|
||||
const c=context();vm.runInContext(section('camera-settings.js','function changedCameraValues(','async function saveCameraSettings('),c);
|
||||
const form={elements:{brightness:{value:'60'},dayNight:{value:'AUTO'},malicious:{value:'anything'}}};
|
||||
const settings={imaging:{brightness:{value:50,min:0,max:100},dayNight:{value:'AUTO',choices:['AUTO','ON']}}};
|
||||
assert.equal(JSON.stringify(c.changedCameraValues(form,'imaging',settings)),JSON.stringify({brightness:60}));
|
||||
});
|
||||
test('late camera settings cannot populate a closed or different camera dialog',async()=>{
|
||||
const pending=deferred(),elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{value:'main'});return elements.get(s);};let renders=0;
|
||||
const c=context({cameraSettingsContext:{id:'old'},cameraSettingsGeneration:0,$,$$:()=>[],URLSearchParams,api:()=>pending.promise,renderCameraSettings:()=>renders++});
|
||||
vm.runInContext(section('camera-settings.js','async function loadCameraSettings(','async function openCameraSettings('),c);const request=c.loadCameraSettings();c.cameraSettingsContext={id:'new'};c.cameraSettingsGeneration++;pending.resolve({device:{}});await request;assert.equal(renders,0);
|
||||
});
|
||||
test('ambiguous device save requires re-read before another write',async()=>{
|
||||
const elements=new Map(),$=s=>{if(!elements.has(s))elements.set(s,{});return elements.get(s);};const buttons=[{disabled:false}];
|
||||
const c=context({cameraSettingsContext:{id:'camera',quality:'main',settings:{revision:'old'}},cameraSettingsGeneration:0,$,$$:()=>buttons,
|
||||
changedCameraValues:()=>({brightness:60}),api:async()=>{throw Error('connection lost');},toast:()=>{}});
|
||||
vm.runInContext(section('camera-settings.js','async function saveCameraSettings(','for(const [id,section]'),c);await c.saveCameraSettings({},'imaging');
|
||||
assert.equal(c.cameraSettingsContext.settings,null);assert.equal(buttons[0].disabled,true);assert.equal($('#camera-settings-reload').disabled,false);
|
||||
});
|
||||
+69
-29
@@ -5,11 +5,34 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'&
|
||||
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
|
||||
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
|
||||
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
|
||||
let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
|
||||
const isAdmin = () => currentUser?.role === 'admin';
|
||||
function applyPermissions(){
|
||||
const admin=isAdmin();
|
||||
$('#account-password').hidden=!loggedIn;
|
||||
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin);
|
||||
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
|
||||
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
|
||||
}
|
||||
function clearPrivateView(){
|
||||
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();
|
||||
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:['录像与存储','让记录持续,也让磁盘留有余地。']};
|
||||
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)});
|
||||
let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); }
|
||||
if (!r.ok) { if (r.status === 401 && loggedIn) { loggedIn = false; stopPlayers(); auth(); } throw Error(result.error || '操作未完成'); }
|
||||
if(generation!==sessionGeneration)throw Object.assign(Error('登录会话已变化,请重新操作'),{stale:true});
|
||||
if (!r.ok) { if (r.status === 401 && loggedIn) { clearPrivateView(); auth(); } throw Object.assign(Error(result.error || '操作未完成'),{status:r.status}); }
|
||||
return result;
|
||||
}
|
||||
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
||||
@@ -19,14 +42,20 @@ function filtered() { return state.cameras.filter(c => !$('#site-filter').value
|
||||
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 (activePage !== page) stopPlayers();
|
||||
if(!isAdmin()&&!['live','playback','family'].includes(page))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);
|
||||
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
|
||||
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page));
|
||||
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
|
||||
if (page !== 'playback') resetPlayback();
|
||||
render();
|
||||
if (page === 'storage') loadAudit();
|
||||
if (page === 'family') loadFamily(true);
|
||||
if (page === 'users') loadUsers();
|
||||
}
|
||||
function render() {
|
||||
$('#metric-all').textContent = state.assets.length;
|
||||
@@ -35,7 +64,7 @@ function render() {
|
||||
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
|
||||
$('#service-status').textContent = state.mediaOnline ? '视频服务已连接' : '视频服务未连接';
|
||||
$('#service-status').classList.toggle('off', !state.mediaOnline);
|
||||
$('#version').textContent = 'v' + state.version + ' / 数据存于本机';
|
||||
$('#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(''));
|
||||
@@ -49,39 +78,38 @@ function render() {
|
||||
drawTimeline();
|
||||
}
|
||||
if (activePage === 'storage') renderStorage();
|
||||
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 = JSON.stringify(cameras.map(c=>[c.id,c.name,c.siteId,c.point,c.ready,c.enabled,c.record,c.recordingActive])) + quality;
|
||||
if (key === liveKey) return;
|
||||
const key = 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="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button></div></article>`).join('') : empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>');
|
||||
$('#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('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
|
||||
for (const c of cameras.filter(c=>c.enabled)) {
|
||||
const video = $(`video[data-camera="${c.id}"]`), panel = video.parentElement;
|
||||
const src = `/media/live/cam_${c.id}_${quality}/index.m3u8`;
|
||||
const error = text => {const el=panel.querySelector('.video-error');el.textContent=text;el.hidden=false;};
|
||||
video.onplaying=()=>{panel.querySelector('.video-empty').hidden=true;panel.querySelector('.video-error').hidden=true;};
|
||||
video.onerror=()=>error('浏览器无法解码此码流,请在清晰度中选择“流畅 · 兼容画面”或“高清 · 主码流兼容”。');
|
||||
if (window.Hls?.isSupported()) {
|
||||
const hls = new Hls({maxBufferLength:10,backBufferLength:5,liveSyncDurationCount:3,manifestLoadingTimeOut:60000,levelLoadingTimeOut:60000,manifestLoadingMaxRetry:2,levelLoadingMaxRetry:2});
|
||||
hls.on(Hls.Events.ERROR,(_,data)=>{if(data.fatal){
|
||||
const codecError = /codec|bufferAddCodec|manifestIncompatible/i.test(data.details || '');
|
||||
error(codecError ? '浏览器不支持当前视频编码,请选择“流畅 · 兼容画面”或“高清 · 主码流兼容”。' : '画面加载失败('+(data.details || data.type)+')。点击刷新状态可重试。');
|
||||
console.warn('Video playback failed', c.id, data.type, data.details);
|
||||
hls.destroy();
|
||||
}});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED,()=>video.play().catch(()=>{}));
|
||||
hls.loadSource(src);hls.attachMedia(video);players.push(hls);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {video.src=src;video.play().catch(()=>{});}
|
||||
else error('当前浏览器不支持 HLS 播放,请使用较新的浏览器。');
|
||||
const video = $(`video[data-camera="${c.id}"]`);
|
||||
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
|
||||
}
|
||||
}
|
||||
function updateLiveStatus(cameras) {
|
||||
for (const c of cameras) {
|
||||
const card=$(`video[data-camera="${c.id}"]`)?.closest('.camera-card');
|
||||
if (!card) continue;
|
||||
card.querySelector('.camera-meta strong').textContent=c.name;
|
||||
card.querySelector('.camera-meta p').textContent=siteName(c.siteId)+' · '+(c.point || '安装位置待填写');
|
||||
const badge=card.querySelector('.badge');
|
||||
badge.textContent=c.recordingActive?'● 正在录像':c.ready?'码流已接通':c.enabled?'等待接通':'已停用';
|
||||
badge.classList.toggle('off',!c.ready);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCameras() {
|
||||
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
|
||||
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
|
||||
const cameras=filtered();
|
||||
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
|
||||
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
|
||||
}
|
||||
function renderSites() {
|
||||
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
|
||||
@@ -92,9 +120,9 @@ function renderStorage() {
|
||||
if (!$('#storage-form').contains(document.activeElement)) for(const k of ['retentionDays','maxGB','reserveGB']) $('#storage-form').elements[k].value=s[k]??'';
|
||||
$('#storage-status').innerHTML=[['录像占用',(s.usedGB??'—')+' GB'],['磁盘可用',(s.freeGB??'—')+' GB'],['视频服务',state.mediaOnline?'运行中':'未连接'],['空间保护',s.paused?s.reason:'未触发'],['录像方式','主码流持续录像 / 分段保存'],['系统运行',Math.floor(state.uptime/3600)+' 小时 '+Math.floor(state.uptime%3600/60)+' 分钟']].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');
|
||||
}
|
||||
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){toast(e.message);} }
|
||||
async function refresh() {if(!loggedIn)return;try{state=await api('/api/state');render();}catch(e){$('#banner').textContent=e.message;$('#banner').hidden=false;}}
|
||||
async function auth() {try{const s=await api('/api/session');loggedIn=s.authenticated;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-title').textContent=setup?'建立你的视觉空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置你自己的管理密码。':'输入管理密码,进入视觉工作空间。';if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){toast('无法连接管理服务:'+e.message);}}
|
||||
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
|
||||
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,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 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());}
|
||||
@@ -105,8 +133,8 @@ handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d)
|
||||
$('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api(setup?'/api/setup':'/api/login',formValues(f));f.reset();f.querySelector('.form-error').textContent='';await auth();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
|
||||
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault());
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera')openCamera();if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
|
||||
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();resetPlayback();await auth();};
|
||||
$('#refresh').onclick=()=>{liveKey='';refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
|
||||
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;clearPrivateView();await auth();};
|
||||
$('#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='';}
|
||||
@@ -144,3 +172,15 @@ $('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').el
|
||||
handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)}));
|
||||
|
||||
$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}};
|
||||
|
||||
function validateNewPassword(values){
|
||||
if(values.newPassword!==values.confirmPassword)throw Error('两次输入的新密码不一致');
|
||||
if(values.newPassword===values.currentPassword)throw Error('新密码不能与当前密码相同');
|
||||
if(values.newPassword.length<8||values.newPassword.length>128)throw Error('密码需要 8 至 128 个字符');
|
||||
}
|
||||
$('#account-password').onclick=()=>{if(!loggedIn)return;const f=$('#password-form');f.reset();f.querySelector('.form-error').textContent='';$('#password-account').textContent='当前账号:'+currentUser.username;$('#password-dialog').showModal();};
|
||||
$('#password-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||
try{const data=formValues(f);validateNewPassword(data);await api('/api/account/password',{currentPassword:data.currentPassword,newPassword:data.newPassword});clearPrivateView();await auth();toast('密码已修改,请用新密码重新登录');}
|
||||
catch(err){if(!err.stale)f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Chinese calendar conversion supplied by the browser's ICU calendar, offline. */
|
||||
(function(root){
|
||||
'use strict';
|
||||
const fmt=new Intl.DateTimeFormat('en-u-ca-chinese',{year:'numeric',month:'numeric',day:'numeric',timeZone:'Asia/Shanghai'});
|
||||
const cache=new Map();
|
||||
function solarDate(value){
|
||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(value))throw Error('日期请填写 YYYY-MM-DD');
|
||||
const d=new Date(value+'T12:00:00Z');
|
||||
if(!Number.isFinite(+d)||d.toISOString().slice(0,10)!==value)throw Error('日期不存在');
|
||||
return d;
|
||||
}
|
||||
function lunar(value){
|
||||
const d=solarDate(value),year=d.getUTCFullYear();
|
||||
if(year<1901||year>2100)throw Error('自动换算支持 1901—2100 年;更早日期可填写公历并在备注保留原记载');
|
||||
if(fmt.resolvedOptions().calendar!=='chinese')throw Error('当前浏览器不支持农历换算');
|
||||
const parts=Object.fromEntries(fmt.formatToParts(d).map(p=>[p.type,p.value]));
|
||||
return {year:Number(parts.relatedYear),month:parseInt(parts.month,10),day:Number(parts.day),leap:parts.month.includes('bis')};
|
||||
}
|
||||
function solar(value,leap=false){
|
||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(value))throw Error('农历请填写 YYYY-MM-DD,例如 2023-02-01');
|
||||
const [year,month,day]=value.split('-').map(Number);
|
||||
if(year<1901||year>2099)throw Error('农历输入支持 1901—2099 年');
|
||||
if(month<1||month>12||day<1||day>30)throw Error('农历日期不存在');
|
||||
if(!cache.has(year)){
|
||||
const days=new Map(),start=Date.UTC(year,0,1,12),end=Date.UTC(year+1,2,1,12);
|
||||
for(let t=start;t<end;t+=86400000){const iso=new Date(t).toISOString().slice(0,10),l=lunar(iso);if(l.year===year)days.set(`${l.month}-${l.day}-${l.leap}`,iso);}
|
||||
if(cache.size>8)cache.clear();cache.set(year,days);
|
||||
}
|
||||
const result=cache.get(year).get(`${month}-${day}-${!!leap}`);
|
||||
if(!result)throw Error('该农历日期或闰月不存在');
|
||||
return result;
|
||||
}
|
||||
const pad=n=>String(n).padStart(2,'0');
|
||||
function iso(l){return `${l.year}-${pad(l.month)}-${pad(l.day)}`;}
|
||||
function describe(value){if(!value)return '';try{const l=lunar(value);return `${value} · 农历 ${l.year}年${l.leap?'闰':''}${l.month}月${l.day}日`;}catch{return value+'(公历)';}}
|
||||
root.ZhaoCalendar={solarDate,lunar,solar,iso,describe};
|
||||
if(typeof module!=='undefined')module.exports=root.ZhaoCalendar;
|
||||
})(typeof window==='undefined'?globalThis:window);
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
let cameraSettingsContext=null,cameraSettingsGeneration=0;
|
||||
const videoLabels={resolution:'分辨率',fps:'帧率(帧/秒)',bitrate:'码率上限(kbps)',quality:'画质等级',gop:'关键帧间隔(帧)'};
|
||||
function clearCameraSettings(){cameraSettingsGeneration++;cameraSettingsContext=null;for(const id of ['camera-settings-info','camera-image-fields','camera-video-fields','camera-settings-status'])$('#'+id).replaceChildren();}
|
||||
function settingsInput(key,label,value,rule){
|
||||
const enumLabel=v=>key==='dayNight'?({AUTO:'自动日夜',ON:'日间(红外截止)',OFF:'夜间(红外通过)'})[v]||v:({ON:'开启',OFF:'关闭',AUTO:'自动',MANUAL:'手动'})[v]||v;
|
||||
return `<label>${esc(label)}${rule.choices?`<select name="${esc(key)}">${rule.choices.map(v=>`<option value="${esc(v)}" ${v===value?'selected':''}>${esc(enumLabel(v))}</option>`).join('')}</select>`:`<input name="${esc(key)}" type="number" value="${esc(value)}" min="${rule.min}" max="${rule.max}" step="${['fps','bitrate','gop'].includes(key)?'1':'any'}" required><small>${rule.min}–${rule.max}</small>`}</label>`;
|
||||
}
|
||||
function renderCameraSettings(data){
|
||||
const d=data.device;
|
||||
$('#camera-settings-info').innerHTML=`<dl>${[['厂商',d.Manufacturer],['型号',d.Model],['固件',d.FirmwareVersion],['镜头视频源',data.source],['码流配置',data.profile],['设备报告编码',data.video.codec]].map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v||'未提供')}</dd></div>`).join('')}</dl>`;
|
||||
$('#camera-image-fields').innerHTML=Object.entries(data.imaging).map(([k,r])=>settingsInput(k,r.label,r.value,r)).join('')||'<p class="description">设备未提供可调图像参数。</p>';
|
||||
$('#camera-video-fields').innerHTML=Object.entries(data.video.options).map(([k,r])=>settingsInput(k,videoLabels[k],data.video.values[k],r)).join('')||'<p class="description">设备未提供可调码流参数。</p>';
|
||||
$('#camera-settings-status').textContent=data.notes.join(';');
|
||||
$('#camera-image-form button[type=submit]').disabled=!Object.keys(data.imaging).length;
|
||||
$('#camera-video-form button[type=submit]').disabled=!Object.keys(data.video.options).length;
|
||||
}
|
||||
async function loadCameraSettings(){
|
||||
if(!cameraSettingsContext)return;const context=cameraSettingsContext,generation=++cameraSettingsGeneration;
|
||||
context.settings=null;context.quality=$('#camera-settings-quality').value;
|
||||
$('#camera-settings-status').textContent='正在读取设备实际参数…';
|
||||
$$('#camera-settings-dialog form button[type=submit]').forEach(b=>b.disabled=true);$('#camera-settings-reload').disabled=true;
|
||||
try{const result=await api('/api/camera/settings?'+new URLSearchParams({camera:context.id,quality:context.quality}));
|
||||
if(generation!==cameraSettingsGeneration||context!==cameraSettingsContext||!loggedIn)return;context.settings=result;renderCameraSettings(result);
|
||||
}catch(e){if(!e.stale&&generation===cameraSettingsGeneration)$('#camera-settings-status').textContent=e.message;}
|
||||
finally{if(generation===cameraSettingsGeneration)$('#camera-settings-reload').disabled=false;}
|
||||
}
|
||||
async function openCameraSettings(id){
|
||||
if(!isAdmin())return;clearCameraSettings();cameraSettingsContext={id,settings:null,quality:'main'};
|
||||
$('#camera-settings-title').textContent=(state.cameras.find(c=>c.id===id)?.name||'摄像机')+' · 参数设置';$('#camera-settings-quality').value='main';$('#camera-settings-quality').disabled=false;
|
||||
$('#camera-settings-dialog').showModal();await loadCameraSettings();
|
||||
}
|
||||
function changedCameraValues(form,section,settings){
|
||||
const rules=section==='imaging'?settings.imaging:settings.video.options,values={};
|
||||
for(const [key,rule]of Object.entries(rules)){
|
||||
const value=rule.choices?form.elements[key].value:Number(form.elements[key].value);
|
||||
if(value!==(section==='imaging'?rule.value:settings.video.values[key]))values[key]=value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
async function saveCameraSettings(form,section){
|
||||
const context=cameraSettingsContext;if(!context?.settings)return;
|
||||
const values=changedCameraValues(form,section,context.settings);if(!Object.keys(values).length){toast('参数没有变化');return;}
|
||||
const generation=++cameraSettingsGeneration;
|
||||
$$('#camera-settings-dialog form button[type=submit]').forEach(b=>b.disabled=true);$('#camera-settings-reload').disabled=true;$('#camera-settings-quality').disabled=true;
|
||||
$('#camera-settings-status').textContent='正在保存并读取设备返回值…';
|
||||
try{const result=await api('/api/camera/settings',{camera:context.id,quality:context.quality,revision:context.settings.revision,section,values});
|
||||
if(generation!==cameraSettingsGeneration||context!==cameraSettingsContext||!loggedIn)return;
|
||||
context.settings=result.settings;if(result.settings)renderCameraSettings(result.settings);$('#camera-settings-status').textContent=result.message;toast(result.message);
|
||||
}catch(e){if(!e.stale&&generation===cameraSettingsGeneration){context.settings=null;$('#camera-settings-status').textContent=e.message+'。请点击“重新读取”核对设备状态。';}}
|
||||
finally{if(generation===cameraSettingsGeneration){$('#camera-settings-reload').disabled=false;$('#camera-settings-quality').disabled=false;}}
|
||||
}
|
||||
for(const [id,section]of [['camera-image-form','imaging'],['camera-video-form','video']])$('#'+id).addEventListener('submit',e=>{e.preventDefault();saveCameraSettings(e.currentTarget,section);});
|
||||
$('#camera-settings-reload').onclick=loadCameraSettings;$('#camera-settings-quality').onchange=loadCameraSettings;
|
||||
$('#camera-settings-dialog').addEventListener('close',clearCameraSettings);
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('[data-camera-settings]');if(b)openCameraSettings(b.dataset.cameraSettings);});
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
function dateHint(field,strict=false){
|
||||
const input=field.querySelector('[data-date-value]'),hint=field.querySelector('[data-date-hint]'),hidden=field.querySelector('input[type=hidden]');
|
||||
const mode=field.querySelector('[data-calendar]').value,value=input.value.trim(),leap=field.querySelector('[data-date-leap]').checked;
|
||||
field.querySelector('[data-leap-label]').hidden=mode!=='lunar';input.setCustomValidity('');
|
||||
if(!value){hidden.value='';hint.textContent=input.required?'请选择事情发生的日期':'日期不详可留空';return;}
|
||||
try{
|
||||
const solar=mode==='solar'?(ZhaoCalendar.solarDate(value),value):ZhaoCalendar.solar(value,leap);
|
||||
hidden.value=solar;
|
||||
if(mode==='lunar')hint.textContent='公历 '+solar;
|
||||
else{try{const l=ZhaoCalendar.lunar(solar);hint.textContent=`农历 ${l.year}年${l.leap?'闰':''}${l.month}月${l.day}日`;}catch(e){hint.textContent=e.message;}}
|
||||
}catch(e){hidden.value='';input.setCustomValidity(e.message);hint.textContent=e.message;if(strict)throw e;}
|
||||
}
|
||||
function syncLifeStatus(){
|
||||
const deceased=$('#person-form').elements.lifeStatus.value==='deceased',field=$('[data-date-field="deathDate"]');field.hidden=!deceased;
|
||||
field.querySelectorAll('input,select').forEach(e=>e.disabled=!deceased);
|
||||
if(!deceased)field.querySelector('[data-date-value]').setCustomValidity('');
|
||||
}
|
||||
function initDateFields(scope=$('#person-form')){
|
||||
for(const field of scope.querySelectorAll('[data-date-field]')){
|
||||
field.querySelector('[data-calendar]').value='solar';field.querySelector('[data-date-leap]').checked=false;
|
||||
field.querySelector('[data-date-value]').value=field.querySelector('input[type=hidden]').value;dateHint(field);
|
||||
}if(scope.id==='person-form')syncLifeStatus();
|
||||
}
|
||||
function syncDateFields(strict=false,scope=$('#person-form')){
|
||||
if(scope.id==='person-form')syncLifeStatus();for(const field of scope.querySelectorAll('[data-date-field]'))if(!field.hidden)dateHint(field,strict);
|
||||
if(scope.id==='person-form'&&$('[data-date-field="deathDate"]').hidden)scope.elements.deathDate.value='';
|
||||
}
|
||||
for(const field of $$('[data-date-field]')){
|
||||
field.querySelector('[data-date-value]').addEventListener('input',()=>dateHint(field));
|
||||
field.querySelector('[data-date-leap]').addEventListener('change',()=>dateHint(field));
|
||||
field.querySelector('[data-calendar]').addEventListener('change',()=>{
|
||||
const hidden=field.querySelector('input[type=hidden]'),input=field.querySelector('[data-date-value]'),mode=field.querySelector('[data-calendar]');
|
||||
if(hidden.value){try{if(mode.value==='solar')input.value=hidden.value;else{const l=ZhaoCalendar.lunar(hidden.value);input.value=ZhaoCalendar.iso(l);field.querySelector('[data-date-leap]').checked=l.leap;}}catch(e){mode.value='solar';toast(e.message);}}
|
||||
dateHint(field);
|
||||
});
|
||||
}
|
||||
$('#person-form').elements.lifeStatus.addEventListener('change',syncLifeStatus);
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
function resetFamilyEvents(purge=false){
|
||||
for(const id of ['family-event-person','family-event-year','family-event-search','event-person-search'])$('#'+id).value='';
|
||||
if(purge){$('#family-event-person').replaceChildren();$('#family-event-year').replaceChildren();}
|
||||
$('#family-event-relatives').checked=false;$('#family-event-state').value='published';$('#family-event-order').value='desc';
|
||||
for(const id of ['family-timeline','event-detail-content','event-person-choices','family-event-count'])$('#'+id).replaceChildren();
|
||||
}
|
||||
function eventStatus(e){return e.archived?'已归档':e.status==='draft'?'草稿':'已发布';}
|
||||
function eventPeople(e){return e.personIds.map(id=>personName(id)).join('、')||'全家记事';}
|
||||
function filteredFamilyEvents(){
|
||||
const person=$('#family-event-person').value,year=$('#family-event-year').value,term=$('#family-event-search').value.trim().toLowerCase();
|
||||
const status=family.canEdit?$('#family-event-state').value:'published',ids=new Set(person?[person]:[]);
|
||||
if(person&&$('#family-event-relatives').checked)for(const e of family.links.filter(e=>e.active)){if(e.fromId===person)ids.add(e.toId);if(e.toId===person)ids.add(e.fromId);}
|
||||
return (family.events||[]).filter(e=>(status==='all'||(status==='archived'?e.archived:!e.archived&&e.status===status))&&(!year||e.eventDate.startsWith(year+'-'))&&(!person||e.personIds.some(id=>ids.has(id)))&&[e.title,e.summary,e.body,e.sourceName].join(' ').toLowerCase().includes(term)).sort((a,b)=>($('#family-event-order').value==='asc'?1:-1)*a.eventDate.localeCompare(b.eventDate)||a.id.localeCompare(b.id));
|
||||
}
|
||||
function renderFamilyEvents(){
|
||||
setOptions($('#family-event-person'),'<option value="">全家</option>'+peopleOptions(false));
|
||||
const years=[...new Set((family.events||[]).map(e=>e.eventDate.slice(0,4)))].sort().reverse();
|
||||
setOptions($('#family-event-year'),'<option value="">所有年份</option>'+years.map(y=>`<option value="${esc(y)}">${esc(y)} 年</option>`).join(''));
|
||||
$('#family-new-event').hidden=!family.canEdit;$('#family-event-state-label').hidden=!family.canEdit;
|
||||
$('#family-event-relatives').disabled=!$('#family-event-person').value;
|
||||
const events=filteredFamilyEvents();$('#family-event-count').textContent=`${events.length} 条记事`+($('#family-event-person').value?' · '+personName($('#family-event-person').value)+($('#family-event-relatives').checked?'及相关亲人':''):' · 全家');
|
||||
let year='';
|
||||
$('#family-timeline').innerHTML=events.map(e=>{
|
||||
const next=e.eventDate.slice(0,4),heading=next!==year?`<h3 class="timeline-year">${esc(next)}<span>年</span></h3>`:'';year=next;
|
||||
return `${heading}<button class="timeline-event ${e.status==='draft'?'draft':''}" data-view-event="${e.id}"><time datetime="${esc(e.eventDate)}">${esc(e.eventDate.slice(5).replace('-','月'))}日</time><span class="timeline-event-body"><span class="timeline-event-status">${esc(eventStatus(e))}</span><strong>${esc(e.title)}</strong><span class="timeline-summary">${esc(e.summary||e.body.slice(0,130)||'查看原始报道')}</span><small>${esc(eventPeople(e))}</small><span class="timeline-read">查看详情${e.sourceUrl?' · 附报道':''} →</span></span></button>`;
|
||||
}).join('')||empty('这里还没有记事','可调整筛选条件,或写下第一件值得记住的事。');
|
||||
}
|
||||
function showPersonEvents(id){familyView='timeline';renderFamily();$('#family-event-person').value=id;$('#family-event-year').value='';$('#family-event-search').value='';$('#family-event-relatives').checked=false;$('#family-event-state').value='published';renderFamilyEvents();$('#family-events').scrollIntoView({behavior:'smooth',block:'start'});}
|
||||
function personEventSummary(id){
|
||||
const events=(family.events||[]).filter(e=>e.personIds.includes(id)&&!e.archived&&e.status==='published').sort((a,b)=>b.eventDate.localeCompare(a.eventDate));
|
||||
return `<section class="person-events"><div class="section-head"><h3>相关重大记事 <small>${events.length}</small></h3>${family.canEdit?`<button class="quiet" data-new-person-event="${id}">+ 记一件事</button>`:''}</div>${(familyView==='timeline'?events.slice(0,3):[]).map(e=>`<button class="person-event-link" data-view-event="${e.id}"><time>${esc(e.eventDate)}</time><span>${esc(e.title)}</span></button>`).join('')||(familyView==='timeline'?'<p class="description">尚无已发布的相关记事。</p>':'')}<button class="quiet" data-person-events="${id}">在时间线追溯 →</button></section>`;
|
||||
}
|
||||
function openFamilyEvent(id){
|
||||
const e=(family.events||[]).find(e=>e.id===id);if(!e)return;
|
||||
// All content is plain text. External reports are links, never embedded HTML.
|
||||
const safeSource=/^https?:\/\//i.test(e.sourceUrl||'')?e.sourceUrl:'';
|
||||
$('#event-detail-content').innerHTML=`<div class="event-detail-meta"><time>${esc(ZhaoCalendar.describe(e.eventDate))}</time><span class="badge">${esc(eventStatus(e))}</span></div><h2 id="event-detail-title">${esc(e.title)}</h2>${e.summary?`<p class="event-lead">${esc(e.summary)}</p>`:''}<div class="event-associated"><span class="description">关联人物</span>${e.personIds.length?e.personIds.map(id=>`<button class="quiet" data-event-person="${esc(id)}">${esc(personName(id))} ↗</button>`).join(''):'<span>全家记事</span>'}</div><div class="family-prose event-article">${esc(e.body||'此条记事暂未填写详细正文。')}</div>${safeSource?`<a class="button secondary event-source" href="${esc(safeSource)}" target="_blank" rel="noopener noreferrer">阅读原始报道${e.sourceName?' · '+esc(e.sourceName):''} ↗</a>`:e.sourceName?`<p class="description">资料来源:${esc(e.sourceName)}</p>`:''}<p class="description event-updated">最近整理:${esc(new Date(e.updatedAt).toLocaleString())}</p>${family.canEdit?`<button data-edit-event="${e.id}">编辑这条记事</button><button class="quiet" data-history-kind="event" data-history-id="${e.id}">修改记录</button>`:''}`;
|
||||
$('#event-detail-dialog').showModal();
|
||||
}
|
||||
function editFamilyEvent(id,personId=''){
|
||||
if(!family.canEdit)return;
|
||||
const e=(family.events||[]).find(e=>e.id===id),f=$('#event-form');
|
||||
fillForm(f,{...(e||{}),revision:family.revision});
|
||||
$('#event-editor-title').textContent=e?'编辑家族记事':'写一则家族记事';
|
||||
const selected=new Set(e?.personIds||(personId?[personId]:[]));
|
||||
$('#event-person-choices').innerHTML=family.people.map(p=>`<label class="check"><input type="checkbox" name="personIds" value="${esc(p.id)}" ${selected.has(p.id)?'checked':''}>${esc(p.name)}${p.archived?'(已归档)':''}</label>`).join('')||'<p class="description">家谱尚无人名,此条记事将记为全家记事。</p>';
|
||||
$('#event-person-search').value='';f.dataset.status=e?.status||'draft';
|
||||
f.querySelector('[data-save-event]').value=f.dataset.status;f.querySelector('[data-save-event]').textContent=e?'保存修改':'保存草稿';
|
||||
f.querySelector('[data-publish-event]').hidden=f.dataset.status==='published';f.querySelector('[data-withdraw-event]').hidden=f.dataset.status!=='published';
|
||||
initDateFields(f);if($('#event-detail-dialog').open)$('#event-detail-dialog').close();$('#event-dialog').showModal();
|
||||
}
|
||||
$('#event-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const f=e.currentTarget,buttons=[...f.querySelectorAll('[type=submit]')];buttons.forEach(b=>b.disabled=true);
|
||||
try{syncDateFields(true,f);const data=formValues(f);data.revision=Number(data.revision);data.personIds=[...f.querySelectorAll('[name=personIds]:checked')].map(el=>el.value);data.archived=f.elements.archived.checked;data.status=e.submitter?.value||f.dataset.status||'draft';
|
||||
const result=await api('/api/family/event',data);$('#event-dialog').close();if(!await loadFamily()){if(loggedIn)toast('记事已保存,读取最新资料失败,请重新载入');return;}familyView='timeline';renderFamily();
|
||||
$('#family-event-person').value='';$('#family-event-year').value='';$('#family-event-search').value='';$('#family-event-state').value=data.archived?'archived':data.status;renderFamilyEvents();openFamilyEvent(result.item.id);toast(data.archived?'记事已归档,可在“已归档”中恢复':data.status==='published'?'记事已发布到家族时间线':'草稿已保存');
|
||||
}catch(err){familyFormError(f,err);}finally{buttons.forEach(b=>b.disabled=false);}
|
||||
});
|
||||
$('#family-view-timeline').onclick=()=>{familyView='timeline';renderFamily();};
|
||||
$('#family-new-event').onclick=()=>editFamilyEvent();
|
||||
for(const id of ['family-event-person','family-event-year','family-event-order','family-event-relatives','family-event-state'])$('#'+id).onchange=renderFamilyEvents;
|
||||
$('#family-event-search').oninput=renderFamilyEvents;
|
||||
$('#event-person-search').oninput=()=>{const term=$('#event-person-search').value.trim().toLowerCase();$('#event-person-choices').querySelectorAll('label').forEach(l=>l.hidden=!l.textContent.toLowerCase().includes(term));};
|
||||
$('#family-event-clear').onclick=()=>{resetFamilyEvents();renderFamilyEvents();};
|
||||
document.addEventListener('click',e=>{
|
||||
const b=e.target.closest('button');if(!b)return;
|
||||
if(b.dataset.viewEvent)openFamilyEvent(b.dataset.viewEvent);
|
||||
if(b.dataset.editEvent)editFamilyEvent(b.dataset.editEvent);
|
||||
if(b.dataset.newPersonEvent)editFamilyEvent('',b.dataset.newPersonEvent);
|
||||
if(b.dataset.personEvents)showPersonEvents(b.dataset.personEvents);
|
||||
if(b.dataset.eventPerson){$('#event-detail-dialog').close();selectFamilyPerson(b.dataset.eventPerson,true);}
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
(function(root) {
|
||||
const W=184,H=104,GAP=34,ROW=166,PAD=36;
|
||||
// People appear once. Spouse groups share a row when this preserves the parent DAG.
|
||||
function layout(inputPeople,inputLinks,{selfId='',showArchived=false}={}) {
|
||||
const people=inputPeople.filter(p=>showArchived||!p.archived),byId=new Map(people.map(p=>[p.id,p]));
|
||||
const links=inputLinks.filter(e=>e.active&&byId.has(e.fromId)&&byId.has(e.toId)&&e.fromId!==e.toId);
|
||||
const adjacency=new Map(people.map(p=>[p.id,[]]));
|
||||
links.forEach(e=>{adjacency.get(e.fromId).push(e.toId);adjacency.get(e.toId).push(e.fromId);});
|
||||
const seen=new Set(),components=[];
|
||||
for(const p of people) {
|
||||
if(seen.has(p.id))continue;
|
||||
const ids=[p.id];seen.add(p.id);
|
||||
for(let i=0;i<ids.length;i++)for(const next of adjacency.get(ids[i]))if(!seen.has(next)){seen.add(next);ids.push(next);}
|
||||
components.push(ids);
|
||||
}
|
||||
const order=(a,b)=>{
|
||||
const x=byId.get(a),y=byId.get(b);
|
||||
return (x.birthDate||'9999').localeCompare(y.birthDate||'9999')||x.name.localeCompare(y.name,'zh-CN')||a.localeCompare(b);
|
||||
};
|
||||
components.sort((a,b)=>Number(b.includes(selfId))-Number(a.includes(selfId))||b.length-a.length||order(a[0],b[0]));
|
||||
const nodes=[],sections=[],warnings=[];let offset=PAD,maxWidth=400;
|
||||
const connected=components.filter(c=>c.length>1),isolated=components.filter(c=>c.length===1).map(c=>c[0]);
|
||||
for(const [componentIndex,ids] of connected.entries()) {
|
||||
const idSet=new Set(ids),edges=links.filter(e=>idSet.has(e.fromId));
|
||||
function ranked(alignSpouses) {
|
||||
const roots=new Map(ids.map(id=>[id,id]));
|
||||
function find(id){let r=id;while(roots.get(r)!==r)r=roots.get(r);while(roots.get(id)!==id){const next=roots.get(id);roots.set(id,r);id=next;}return r;}
|
||||
if(alignSpouses)for(const e of edges.filter(e=>e.kind==='spouse'))roots.set(find(e.toId),find(e.fromId));
|
||||
const units=new Map();for(const id of ids){const key=find(id);if(!units.has(key))units.set(key,{id:key,ids:[],rank:0,out:new Set(),incoming:new Set()});units.get(key).ids.push(id);}
|
||||
for(const e of edges.filter(e=>e.kind==='parent')) {
|
||||
const a=units.get(find(e.fromId)),b=units.get(find(e.toId));if(a===b)return null;
|
||||
a.out.add(b.id);b.incoming.add(a.id);
|
||||
}
|
||||
const degree=new Map([...units.values()].map(u=>[u.id,u.incoming.size]));
|
||||
const queue=[...units.values()].filter(u=>!degree.get(u.id));
|
||||
for(let i=0;i<queue.length;i++)for(const id of queue[i].out){const u=units.get(id);u.rank=Math.max(u.rank,queue[i].rank+1);degree.set(id,degree.get(id)-1);if(!degree.get(id))queue.push(u);}
|
||||
return queue.length===units.size?units:null;
|
||||
}
|
||||
let units=ranked(true);
|
||||
if(!units){units=ranked(false);warnings.push('部分配偶跨层排列,请以亲子连线确认关系。');}
|
||||
if(!units) {
|
||||
// The API rejects ancestry cycles; imported/malformed input must still terminate.
|
||||
units=new Map(ids.map(id=>[id,{id,ids:[id],rank:0,out:new Set(),incoming:new Set()}]));
|
||||
warnings.push('检测到循环关系,请核对人物关系。');
|
||||
}
|
||||
const rows=[];
|
||||
for(const u of units.values()){u.ids.sort(order);u.width=u.ids.length*W+(u.ids.length-1)*GAP;(rows[u.rank]??=[]).push(u);}
|
||||
rows.forEach(row=>row.sort((a,b)=>order(a.ids[0],b.ids[0])));
|
||||
// Barycentric passes keep sibling branches together without changing generations.
|
||||
const positions=new Map();
|
||||
function measure(){for(const row of rows){let x=0;for(const u of row){positions.set(u.id,x+u.width/2);x+=u.width+GAP*2;}}}
|
||||
for(let pass=0;pass<4;pass++) {
|
||||
measure();const indices=rows.map((_,i)=>i);if(pass%2)indices.reverse();
|
||||
for(const i of indices)rows[i].sort((a,b)=>{
|
||||
const center=u=>{const ns=[...(pass%2?u.out:u.incoming)];return ns.length?ns.reduce((n,id)=>n+positions.get(id),0)/ns.length:positions.get(u.id);};
|
||||
return center(a)-center(b)||order(a.ids[0],b.ids[0]);
|
||||
});
|
||||
}
|
||||
const widths=rows.map(row=>row.reduce((n,u)=>n+u.width,0)+Math.max(0,row.length-1)*GAP*2);
|
||||
const width=Math.max(...widths)+PAD*2+70,height=rows.length*ROW+20;
|
||||
sections.push({x:PAD,y:offset,width:width-PAD*2,height,label:`家族分支 ${componentIndex+1} · ${ids.length} 位人物`});
|
||||
rows.forEach((row,rank)=>{
|
||||
let x=PAD+(width-PAD*2-70-widths[rank])/2;
|
||||
for(const u of row){for(const id of u.ids){nodes.push({id,x,y:offset+42+rank*ROW,width:W,height:H,rank,component:componentIndex});x+=W+GAP;}x+=GAP;}
|
||||
});
|
||||
for(const node of nodes.filter(n=>n.component===componentIndex))node.corridor=width-PAD-46;
|
||||
offset+=height+48;maxWidth=Math.max(maxWidth,width);
|
||||
}
|
||||
if(isolated.length) {
|
||||
isolated.sort((a,b)=>Number(b===selfId)-Number(a===selfId)||order(a,b));
|
||||
const beside=connected.length===1&&isolated.length<=4;
|
||||
const columns=beside?1:Math.min(5,isolated.length),width=columns*(W+GAP)-GAP+PAD*2;
|
||||
const x=beside?maxWidth:PAD,y=beside?PAD:offset,height=Math.ceil(isolated.length/columns)*150+42;
|
||||
sections.push({x,y,width:width-PAD*2,height,label:people.length===1?'从自己开始,连接每一代亲人':'待连接的人物'});
|
||||
isolated.forEach((id,i)=>nodes.push({id,x:x+i%columns*(W+GAP),y:y+42+Math.floor(i/columns)*150,width:W,height:H,rank:0,component:-1}));
|
||||
offset=Math.max(offset,y+height);maxWidth=beside?maxWidth+width:Math.max(maxWidth,width);
|
||||
}
|
||||
const located=new Map(nodes.map(n=>[n.id,n]));
|
||||
const paths=links.map((e,i)=>{
|
||||
const a=located.get(e.fromId),b=located.get(e.toId);let d;
|
||||
if(e.kind==='parent') {
|
||||
const sx=a.x+W/2,sy=a.y+H,tx=b.x+W/2,ty=b.y;
|
||||
if(b.rank===a.rank+1){const mid=(sy+ty)/2+(i%3-1)*9;d=`M${sx},${sy} V${mid} H${tx} V${ty}`;}
|
||||
else {const x=(a.corridor||maxWidth-PAD)+(i%5)*8;d=`M${sx},${sy} V${sy+18} H${x} V${ty-18} H${tx} V${ty}`;}
|
||||
}else{
|
||||
const [left,right]=a.x<=b.x?[a,b]:[b,a];
|
||||
if(a.y===b.y&&right.x-left.x<=W+GAP+1)d=`M${left.x+W},${left.y+H/2} H${right.x}`;
|
||||
else if(a.y===b.y){const top=a.y-16-(i%3)*8;d=`M${a.x+W/2},${a.y} V${top} H${b.x+W/2} V${b.y}`;}
|
||||
else {const x=(a.corridor||maxWidth-PAD)+(i%5)*8;d=`M${a.x+W},${a.y+H/2} H${x} V${b.y+H/2} H${b.x+W}`;}
|
||||
}
|
||||
return {...e,d};
|
||||
});
|
||||
return {nodes,paths,sections,width:maxWidth,height:Math.max(220,offset+PAD),warnings:[...new Set(warnings)],components:components.length};
|
||||
}
|
||||
const api={layout};if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.ZhaoFamilyGraph=api;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
const historyFields={name:'姓名',alias:'别名',gender:'性别',lifeStatus:'在世情况',birthDate:'出生日期',deathDate:'去世日期',birthplace:'籍贯',biography:'生平',note:'备注',archived:'归档',fromId:'关系一方',toId:'关系另一方',kind:'关系类型',lineage:'亲子属性',active:'有效关系',title:'记事标题',eventDate:'发生日期',summary:'摘要',body:'正文',personIds:'关联人物',status:'发布状态',sourceName:'资料来源',sourceUrl:'报道链接'};
|
||||
let historyContext=null,historySelection=null,historyGeneration=0,historyDetailGeneration=0;
|
||||
function clearFamilyHistory(){historyGeneration++;historyDetailGeneration++;historyContext=null;historySelection=null;for(const id of ['history-list','history-detail','history-error'])$('#'+id).replaceChildren();}
|
||||
function historyValue(key,value){
|
||||
if(value===undefined||value===null||value==='')return '未填写';
|
||||
if(key==='personIds')return value.length?value.map(personName).join('、'):'全家';
|
||||
if(key==='fromId'||key==='toId')return personName(value);
|
||||
if(typeof value==='boolean')return value?'是':'否';
|
||||
const labels={male:'男',female:'女',unknown:'待确认',alive:'在世',deceased:'已故',draft:'草稿',published:'已发布',parent:'父母 → 子女',spouse:'配偶',biological:'亲生',adoptive:'收养',step:'继亲',unspecified:'未注明'};
|
||||
return ['gender','lifeStatus','status','kind','lineage'].includes(key)?labels[value]||String(value):String(value);
|
||||
}
|
||||
async function openFamilyHistory(kind,id){
|
||||
if(!family.canEdit)return;clearFamilyHistory();
|
||||
if($('#event-detail-dialog').open)$('#event-detail-dialog').close();
|
||||
historyContext={kind,id,revision:family.revision,nextBefore:null};
|
||||
$('#history-title').textContent='修改记录 · '+({person:personName(id),link:'亲属关系',event:family.events.find(e=>e.id===id)?.title||'家族记事'})[kind];
|
||||
$('#history-dialog').showModal();await loadFamilyHistory();
|
||||
}
|
||||
async function loadFamilyHistory(more=false){
|
||||
if(!historyContext)return;const context=historyContext,generation=++historyGeneration,b=$('#history-more');b.disabled=true;
|
||||
try{const result=await api('/api/family/history?'+new URLSearchParams({kind:context.kind,id:context.id,...(more&&context.nextBefore?{before:context.nextBefore}:{})}));
|
||||
if(!loggedIn||generation!==historyGeneration||context!==historyContext)return;
|
||||
context.nextBefore=result.nextBefore;if(!more)context.revision=result.revision;
|
||||
const html=result.items.map(c=>`<button class="history-entry quiet" data-history-change="${c.revision}"><strong>版本 ${c.revision} · ${c.restoredFrom?'恢复旧版':c.created?'创建记录':'修改记录'}</strong><span>${esc(c.actor)} · ${esc(new Date(c.at).toLocaleString())}</span><small>${esc(c.fields.map(f=>historyFields[f]||f).join('、')||'内容未变化')}</small></button>`).join('');
|
||||
if(more)$('#history-list').insertAdjacentHTML('beforeend',html);else $('#history-list').innerHTML=html||'<p class="description">暂无修改记录。此功能从本次升级开始记录;升级前的内容会在下一次修改时保留。</p>';
|
||||
b.hidden=!context.nextBefore;$('#history-error').textContent='';
|
||||
}catch(e){if(!e.stale&&generation===historyGeneration)$('#history-error').textContent=e.message;}finally{if(generation===historyGeneration)b.disabled=false;}
|
||||
}
|
||||
async function showFamilyChange(revision){
|
||||
const context=historyContext,generation=++historyDetailGeneration;historySelection=null;$('#history-detail').textContent='正在读取版本…';
|
||||
try{const c=await api('/api/family/change?revision='+revision);
|
||||
if(!loggedIn||generation!==historyDetailGeneration||context!==historyContext)return;
|
||||
historySelection=c;
|
||||
const keys=Object.keys(historyFields).filter(k=>k in c.after||k in (c.before||{}));
|
||||
$('#history-detail').innerHTML=`<h3>版本 ${c.revision} 的内容对照</h3><p class="description">恢复只更新这条记录。已发生的后续修改仍可在历史中查阅。</p><div class="history-columns">${['before','after'].map(side=>`<section><h4>${side==='before'?'本次修改前':'本次修改后'}</h4>${c[side]?`<dl>${keys.map(k=>`<div class="${JSON.stringify(c.before?.[k])!==JSON.stringify(c.after[k])?'history-changed':''}"><dt>${esc(historyFields[k])}</dt><dd>${esc(historyValue(k,c[side][k]))}</dd></div>`).join('')}</dl><button class="secondary" data-history-prepare="${side}">选择恢复${side==='before'?'修改前':'此版本'}</button>`:'<p>创建前没有记录。</p>'}</section>`).join('')}</div><div id="history-confirm" hidden></div>`;
|
||||
$('#history-error').textContent='';
|
||||
}catch(e){if(!e.stale&&generation===historyDetailGeneration)$('#history-detail').textContent=e.message;}
|
||||
}
|
||||
function prepareFamilyRestore(side){
|
||||
const c=historySelection;if(!c?.[side])return;const value=c[side],box=$('#history-confirm');
|
||||
box.hidden=false;box.innerHTML=`<p>将用版本 ${c.revision} ${side==='before'?'修改前':'修改后'}的内容替换这条记录的当前内容。${c.kind==='event'?'恢复后记事状态:'+esc(eventStatus(value))+'。':''}</p><button data-history-restore="${side}">确认恢复并生成新版本</button><button class="quiet" id="history-cancel-restore">取消恢复</button>`;box.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
async function restoreFamilyChange(side){
|
||||
const context=historyContext,c=historySelection;if(!context||!c?.[side])return;
|
||||
const generation=historyDetailGeneration;$$('#history-confirm button').forEach(b=>b.disabled=true);
|
||||
try{await api('/api/family/restore',{changeRevision:c.revision,side,revision:context.revision});
|
||||
if(!loggedIn||context!==historyContext)return;
|
||||
historyDetailGeneration++;historySelection=null;$('#history-detail').replaceChildren();
|
||||
await loadFamilyHistory();const loaded=await loadFamily();if(loggedIn)toast(loaded?'已恢复,原有版本仍保留在修改记录中':'已恢复,读取最新家谱失败,请重新载入');
|
||||
}catch(e){if(!e.stale&&context===historyContext){$('#history-error').textContent=e.message;if(e.status===409)$('#history-error').append(document.createTextNode('。请关闭并重新打开修改记录,核对当前内容后再选择恢复。'));}}
|
||||
finally{if(generation===historyDetailGeneration)$$('#history-confirm button').forEach(b=>b.disabled=false);}
|
||||
}
|
||||
$('#history-more').onclick=()=>loadFamilyHistory(true);
|
||||
$('#history-dialog').addEventListener('close',clearFamilyHistory);
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
|
||||
if(b.dataset.historyKind)openFamilyHistory(b.dataset.historyKind,b.dataset.historyId);
|
||||
if(b.dataset.historyChange)showFamilyChange(Number(b.dataset.historyChange));
|
||||
if(b.dataset.historyPrepare)prepareFamilyRestore(b.dataset.historyPrepare);
|
||||
if(b.dataset.historyRestore)restoreFamilyChange(b.dataset.historyRestore);
|
||||
if(b.id==='history-cancel-restore')$('#history-confirm').hidden=true;
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
class FamilyMap {
|
||||
constructor(viewport,onSelect) {
|
||||
this.viewport=viewport;this.onSelect=onSelect;this.scale=1;this.autoFit=true;this.key='';this.data=null;
|
||||
viewport.addEventListener('click',e=>{const b=e.target.closest('[data-map-person]');if(b)this.onSelect(b.dataset.mapPerson);});
|
||||
viewport.addEventListener('pointerdown',e=>{
|
||||
if(e.pointerType!=='mouse'||e.button!==0||e.target.closest('button'))return;
|
||||
this.drag={x:e.clientX,y:e.clientY,left:viewport.scrollLeft,top:viewport.scrollTop};viewport.setPointerCapture(e.pointerId);viewport.classList.add('panning');e.preventDefault();
|
||||
});
|
||||
viewport.addEventListener('pointermove',e=>{if(!this.drag)return;viewport.scrollLeft=this.drag.left+this.drag.x-e.clientX;viewport.scrollTop=this.drag.top+this.drag.y-e.clientY;});
|
||||
const release=()=>{this.drag=null;viewport.classList.remove('panning');};
|
||||
viewport.addEventListener('pointerup',release);viewport.addEventListener('pointercancel',release);viewport.addEventListener('lostpointercapture',release);
|
||||
this.resize=new ResizeObserver(()=>{if(this.autoFit)this.fit();else this.applyScale();});this.resize.observe(viewport);
|
||||
}
|
||||
reset(){this.key='';this.data=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
|
||||
render(data) {
|
||||
this.data=data;
|
||||
const key=JSON.stringify([data.people,data.links,data.showArchived,data.selfId]);
|
||||
if(key!==this.key) {
|
||||
this.key=key;this.graph=ZhaoFamilyGraph.layout(data.people,data.links,data);
|
||||
const g=this.graph;
|
||||
this.viewport.innerHTML=g.nodes.length?`<div class="family-map-space"><div class="family-map-surface"><svg class="family-map-lines" width="${g.width}" height="${g.height}" aria-hidden="true"><defs><marker id="family-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L8 4 L0 8 Z" fill="#679d93"/></marker></defs>${g.paths.map(e=>`<path class="family-edge ${e.kind==='spouse'?'spouse':e.lineage==='adoptive'||e.lineage==='step'?'nonbiological':'parent'}" data-from="${esc(e.fromId)}" data-to="${esc(e.toId)}" d="${e.d}" ${e.kind==='parent'?'marker-end="url(#family-arrow)"':''}><title>${esc(data.people.find(p=>p.id===e.fromId)?.name)}${e.kind==='parent'?' → 子女:':' ↔ 配偶:'}${esc(data.people.find(p=>p.id===e.toId)?.name)}${e.lineage==='adoptive'?'(收养)':e.lineage==='step'?'(继亲)':''}</title></path>`).join('')}</svg>${g.sections.map(s=>`<div class="family-branch-label">${esc(s.label)}</div>`).join('')}${g.nodes.map(n=>`<button type="button" class="family-node" data-map-person="${esc(n.id)}"></button>`).join('')}</div></div>`:empty('家谱从一个人开始','先录入本人,再连接父母、配偶与子女。');
|
||||
const surface=this.viewport.querySelector('.family-map-surface');
|
||||
if(surface){surface.style.width=g.width+'px';surface.style.height=g.height+'px';
|
||||
surface.querySelectorAll('.family-branch-label').forEach((b,i)=>{b.style.left=g.sections[i].x+'px';b.style.top=g.sections[i].y+'px';});
|
||||
surface.querySelectorAll('[data-map-person]').forEach((b,i)=>{const n=g.nodes[i];Object.assign(b.style,{left:n.x+'px',top:n.y+'px',width:n.width+'px',height:n.height+'px'});});}
|
||||
this.autoFit=true;requestAnimationFrame(()=>this.fit());
|
||||
}
|
||||
const byId=new Map(data.people.map(p=>[p.id,p]));
|
||||
this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{
|
||||
const p=byId.get(b.dataset.mapPerson),self=p.id===data.selfId,selected=p.id===data.focusId;
|
||||
const matched=data.term&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(data.term);
|
||||
const relation=p.id===data.reference&&!self?'参照人物':ZhaoKinship.describe(data.people,data.links,data.reference,p.id);
|
||||
b.classList.toggle('self',self);b.classList.toggle('selected',selected);b.classList.toggle('search-hit',!!matched);b.classList.toggle('archived',!!p.archived);
|
||||
b.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案');
|
||||
b.title=p.name+' · '+relation+(p.archived?' · 已归档':'');
|
||||
b.innerHTML=`<span class="family-node-top"><span class="family-node-avatar" aria-hidden="true">${esc(Array.from(p.name)[0]||'人')}</span><span class="family-node-flags">${self?'<span class="self-badge">本人</span>':''}${p.archived?'<small>归档</small>':''}${p.lifeStatus==='deceased'?'<small>已故</small>':''}</span></span><strong>${esc(p.name)}</strong><span class="family-node-relation">${esc(relation)}</span><small>${esc(p.birthDate?p.birthDate.slice(0,4)+' 年生':'生年待考')}</small>`;
|
||||
});
|
||||
this.viewport.querySelectorAll('.family-edge').forEach(e=>e.classList.toggle('focused',e.dataset.from===data.focusId||e.dataset.to===data.focusId));
|
||||
const messages=[...this.graph.warnings];
|
||||
if(!data.selfId)messages.unshift('当前账号尚未关联家谱人物,可在“账号与关系”中设置。');
|
||||
else if(!this.graph.nodes.some(n=>n.id===data.selfId))messages.unshift('本人已归档,勾选“显示归档人物”后可以定位。');
|
||||
if(data.people.filter(p=>!p.archived).length===1)messages.push('目前仅录入一位人物,连接亲属后会自动展开家谱。');
|
||||
$('#family-map-note').textContent=messages.join(' ');
|
||||
$('#family-locate-self').disabled=!this.graph.nodes.some(n=>n.id===data.selfId);
|
||||
}
|
||||
applyScale() {
|
||||
const v=this.viewport,g=this.graph,space=v.querySelector('.family-map-space'),surface=v.querySelector('.family-map-surface');
|
||||
if(!g||!space||!v.clientWidth)return;
|
||||
const w=Math.max(v.clientWidth,g.width*this.scale+32),h=Math.max(v.clientHeight,g.height*this.scale+32);
|
||||
space.style.width=w+'px';space.style.height=h+'px';
|
||||
this.left=(w-g.width*this.scale)/2;this.top=(h-g.height*this.scale)/2;
|
||||
surface.style.transform=`translate(${this.left}px,${this.top}px) scale(${this.scale})`;
|
||||
$('#family-zoom-value').textContent=Math.round(this.scale*100)+'%';
|
||||
}
|
||||
fit() {
|
||||
const v=this.viewport,g=this.graph;if(!g||!v.clientWidth||!v.clientHeight)return;
|
||||
this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(v.clientWidth-40)/g.width,(v.clientHeight-40)/g.height));
|
||||
this.applyScale();v.scrollTo(0,0);
|
||||
}
|
||||
zoom(factor) {
|
||||
if(!this.graph)return;
|
||||
const v=this.viewport,x=(v.scrollLeft+v.clientWidth/2-(this.left||0))/this.scale,y=(v.scrollTop+v.clientHeight/2-(this.top||0))/this.scale;
|
||||
this.autoFit=false;this.scale=Math.max(.001,Math.min(2,this.scale*factor));this.applyScale();
|
||||
v.scrollTo(x*this.scale+this.left-v.clientWidth/2,y*this.scale+this.top-v.clientHeight/2);
|
||||
}
|
||||
locate(id) {
|
||||
const n=this.graph?.nodes.find(n=>n.id===id);if(!n)return;
|
||||
this.autoFit=false;this.scale=Math.max(this.scale,.9);this.applyScale();
|
||||
this.viewport.scrollTo((n.x+n.width/2)*this.scale+this.left-this.viewport.clientWidth/2,(n.y+n.height/2)*this.scale+this.top-this.viewport.clientHeight/2);
|
||||
const b=[...this.viewport.querySelectorAll('[data-map-person]')].find(b=>b.dataset.mapPerson===id);b?.focus({preventScroll:true});
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
pageInfo.family=['家谱','记下亲人的故事,让世代之间有迹可循。'];
|
||||
pageInfo.users=['账号与关系','每个人有自己的账号,权限与亲属关系分别设置。'];
|
||||
let family={people:[],links:[],events:[],revision:0,canEdit:false}, users=[], focusPerson='',familyGeneration=0,familyView='tree';
|
||||
const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id));
|
||||
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
|
||||
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
|
||||
function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamilyHistory();closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
|
||||
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
|
||||
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
|
||||
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
|
||||
function familyFormError(form,error){
|
||||
if(error.stale)return;
|
||||
const box=form.querySelector('.form-error');box.textContent=error.message;
|
||||
if(error.status!==409||!error.message.startsWith('家谱已被更新'))return;
|
||||
box.append(document.createTextNode('。本次输入仍保留,请先复制需要保留的内容,再读取最新版本。'));
|
||||
const button=document.createElement('button');button.type='button';button.className='quiet';button.textContent='读取最新版本(覆盖本次输入)';
|
||||
button.onclick=async()=>{button.disabled=true;try{if(await loadFamily()){const id=form.elements.id.value;form.closest('dialog').close();if(form.id==='person-form')openPerson(id);else if(form.id==='relation-form')openRelation(id);else editFamilyEvent(id);}}finally{button.disabled=false;}};
|
||||
box.append(button);
|
||||
}
|
||||
async function loadFamily(resetView=false){
|
||||
const generation=++familyGeneration;
|
||||
try{const result=await api('/api/family');if(!loggedIn||generation!==familyGeneration)return;family=result;
|
||||
if(resetView===true||!family.people.some(p=>p.id===focusPerson))focusPerson=family.selfId||family.people.find(p=>!p.archived)?.id||'';
|
||||
setOptions($('#family-reference'),peopleOptions(false));if(!$('#family-reference').value&&family.selfId)$('#family-reference').value=family.selfId;
|
||||
if(!$('#family-reference').dataset.initialized&&family.selfId){$('#family-reference').value=family.selfId;$('#family-reference').dataset.initialized='true';}
|
||||
$('#family-error').textContent='';if(resetView===true){familyView='tree';$('#family-search').value='';}renderFamily();if(resetView===true)familyMap.fit();return true;
|
||||
}catch(e){if(!e.stale&&loggedIn&&generation===familyGeneration)$('#family-error').textContent=e.message;return false;}
|
||||
}
|
||||
function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');}
|
||||
function renderFamily(){
|
||||
const reference=$('#family-reference').value,term=$('#family-search').value.trim().toLowerCase();
|
||||
const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term));
|
||||
$('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'+(term?' · 找到 '+people.length+' 位':'');
|
||||
$('#family-find').hidden=!term;$('#family-find').disabled=!people.length;
|
||||
$('#new-person').hidden=!family.canEdit;
|
||||
$('#family-people').innerHTML=people.map(p=>`<button class="person-card ${p.id===focusPerson?'selected':''} ${p.id===family.selfId?'self':''}" data-person="${p.id}" aria-pressed="${p.id===focusPerson}"><strong>${esc(p.name)} ${p.id===family.selfId?'<span class="self-badge">本人</span>':''}</strong><span>${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</span><small>${esc(p.birthDate||'出生待考')}${p.archived?' · 已归档':''}</small></button>`).join('')||empty(term?'没有找到这个人物':'从一个人物开始',term?'可换用姓名、别名或籍贯查找。':'可先录入自己,再逐步补齐父母、祖先和后辈。');
|
||||
renderFamilyTree();
|
||||
const p=family.people.find(p=>p.id===focusPerson);
|
||||
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
|
||||
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
|
||||
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
|
||||
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button><button class="quiet" data-history-kind="person" data-history-id="${p.id}">修改记录</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button><button class="quiet" data-history-kind="link" data-history-id="${e.id}">历史</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
|
||||
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
|
||||
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
|
||||
}
|
||||
function renderFamilyTree(){
|
||||
$('#family-events').hidden=familyView!=='timeline';$('#family-map-footer').hidden=familyView==='timeline';$('.family-toolbar').hidden=familyView==='timeline';
|
||||
$('#family-view-title').textContent=({tree:'全家关系图',list:'人物名册',timeline:'家族时间线'})[familyView];
|
||||
$('#family-view-timeline').setAttribute('aria-pressed',String(familyView==='timeline'));
|
||||
if(typeof renderFamilyEvents==='function')renderFamilyEvents();
|
||||
$('#family-tree').hidden=familyView!=='tree';$('#family-people').hidden=familyView!=='list';$('#family-map-controls').hidden=familyView!=='tree';
|
||||
$('#family-view-tree').setAttribute('aria-pressed',String(familyView==='tree'));$('#family-view-list').setAttribute('aria-pressed',String(familyView==='list'));
|
||||
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
|
||||
}
|
||||
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
|
||||
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});initDateFields();$('#person-dialog').showModal();}
|
||||
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();}
|
||||
for(const [name,path]of [['person','person'],['relation','link']]){
|
||||
$('#'+name+'-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);if(name==='person'){syncDateFields(true);Object.assign(d,formValues(f));d.revision=Number(d.revision);d.archived=f.elements.archived.checked;}else d.active=f.elements.active.checked;
|
||||
const result=await api('/api/family/'+path,d);if(name==='person')focusPerson=result.item.id;f.closest('dialog').close();const loaded=await loadFamily();if(loggedIn)toast(loaded?'家谱已保存':'家谱已保存,读取最新资料失败,请重新载入');
|
||||
}catch(err){familyFormError(f,err);}finally{b.disabled=false;}
|
||||
});
|
||||
}
|
||||
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>`;
|
||||
}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();
|
||||
}
|
||||
$('#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;}});
|
||||
$('#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();};
|
||||
$('#family-zoom-in').onclick=()=>familyMap.zoom(1.25);$('#family-zoom-out').onclick=()=>familyMap.zoom(.8);$('#family-fit').onclick=()=>familyMap.fit();
|
||||
$('#family-locate-self').onclick=()=>selectFamilyPerson(family.selfId,true);
|
||||
$('#family-find').onclick=()=>{const term=$('#family-search').value.trim().toLowerCase(),matches=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term));if(!matches.length)return;const index=matches.findIndex(p=>p.id===focusPerson);selectFamilyPerson(matches[(index+1)%matches.length].id,true);};
|
||||
let familyBodyOverflow='';
|
||||
function syncFamilyFullscreen(){
|
||||
const expanded=document.fullscreenElement===$('#family-workspace')||$('#family-workspace').classList.contains('family-expanded');
|
||||
$('#family-fullscreen').textContent=expanded?'退出全屏 · Esc':'全屏家谱';familyMap.fit();
|
||||
}
|
||||
function closeFamilyFullscreen(){
|
||||
if($('#family-workspace').classList.contains('family-expanded')){$('#family-workspace').classList.remove('family-expanded');document.body.style.overflow=familyBodyOverflow;}
|
||||
if(document.fullscreenElement===$('#family-workspace'))document.exitFullscreen().catch(()=>{});
|
||||
syncFamilyFullscreen();
|
||||
}
|
||||
$('#family-fullscreen').onclick=async()=>{
|
||||
if(document.fullscreenElement===$('#family-workspace')||$('#family-workspace').classList.contains('family-expanded')){closeFamilyFullscreen();return;}
|
||||
try{await $('#family-workspace').requestFullscreen();}catch{familyBodyOverflow=document.body.style.overflow;document.body.style.overflow='hidden';$('#family-workspace').classList.add('family-expanded');}
|
||||
syncFamilyFullscreen();
|
||||
};
|
||||
document.addEventListener('fullscreenchange',syncFamilyFullscreen);
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!document.querySelector('dialog[open]'))closeFamilyFullscreen();});
|
||||
$('#family-export').onclick=async()=>{try{const data=await api('/api/family');const blob=new Blob([JSON.stringify({format:'zhaofu-family',version:2,exportedAt:new Date().toISOString(),people:data.people,links:data.links,events:data.events||[]},null,2)],{type:'application/json'});const link=document.createElement('a'),href=URL.createObjectURL(blob);link.href=href;link.download='zhaofu-family-'+new Date().toISOString().slice(0,10)+'.json';link.click();setTimeout(()=>URL.revokeObjectURL(href),60000);}catch(e){toast(e.message);}};
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.person)selectFamilyPerson(b.dataset.person);if(b.id==='family-show-person')selectFamilyPerson(focusPerson,true);if(b.dataset.editPerson)openPerson(b.dataset.editPerson);if(b.id==='add-relation')openRelation();if(b.dataset.editRelation)openRelation(b.dataset.editRelation);if(b.dataset.editUser)openUser(b.dataset.editUser);});
|
||||
+44
-9
@@ -2,29 +2,33 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>赵府智家 · 视觉</title><link rel="stylesheet" href="/style.css">
|
||||
<script defer src="/vendor/hls.min.js"></script><script defer src="/app.js"></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="#10191d"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__">
|
||||
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/"><span class="brand-icon">◉</span><span>赵府智家<small>VISION / 本地视觉系统</small></span></a>
|
||||
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>HOME / 本地家庭空间</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="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="storage">▤ <span>录像与存储</span></button>
|
||||
</nav>
|
||||
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
|
||||
<button id="logout" class="quiet">退出登录</button>
|
||||
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
|
||||
</aside>
|
||||
<main>
|
||||
<header><div><div class="eyebrow">ZHAOFU HOME / VISION</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
|
||||
<header><div><div class="eyebrow">ZHAOFU HOME / FAMILY</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<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="compat">流畅 · 兼容画面</option><option value="hd">高清 · 主码流兼容</option><option value="sub">原始 · 子码流</option><option value="main">原始 · 主码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">流畅画面最高 768×432;高清画面从主码流转换,最高 1920×1080、15 帧/秒。原始画面需要浏览器支持摄像头编码。</p><div id="live-grid" class="live-grid"></div></section>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><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="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>
|
||||
@@ -36,14 +40,45 @@
|
||||
<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">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用 H.264 流畅画面(最高 768×432、10 帧/秒),也可选择从主码流转换的 1080p 高清画面。原始码流与本机录像保留设备编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
|
||||
<footer><span>赵府智家 · 视觉系统</span><span id="version">独立部署 / 数据自有</span></footer>
|
||||
<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-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>
|
||||
<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 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>
|
||||
<section id="family-events" hidden aria-label="家族重大记事">
|
||||
<div class="family-event-intro"><div><h3>把值得记住的事,留给后来的人</h3><p class="description">按事情发生的日期排列,可关联多位亲人,并附上详细记载或报道链接。</p></div><button id="family-new-event">+ 写一则记事</button></div>
|
||||
<div class="family-event-filters"><label>关联人物<select id="family-event-person"><option value="">全家</option></select></label><label>年份<select id="family-event-year"><option value="">所有年份</option></select></label><label>排列<select id="family-event-order"><option value="desc">从近到远</option><option value="asc">从远到近</option></select></label><label>搜索记事<input id="family-event-search" type="search" placeholder="标题、摘要或正文"></label><label class="check"><input id="family-event-relatives" type="checkbox">包括此人的父母、配偶和子女</label><label id="family-event-state-label">状态<select id="family-event-state"><option value="published">已发布</option><option value="draft">草稿箱</option><option value="archived">已归档</option><option value="all">全部</option></select></label><button id="family-event-clear" class="quiet">清除筛选</button></div>
|
||||
<p id="family-event-count" class="description" role="status"></p><div id="family-timeline" class="family-timeline"></div>
|
||||
</section>
|
||||
<div id="family-map-footer" class="family-map-footer"><div class="family-map-legend"><span><i class="legend-self"></i>本人</span><span><i class="legend-selected"></i>正在查看</span><span><i class="legend-parent"></i>父母 → 子女</span><span><i class="legend-spouse"></i>配偶</span><span><i class="legend-adoptive"></i>收养 / 继亲</span></div><p id="family-map-note" class="description" role="status"></p><span class="description">拖动空白处移动 · 点击人物查看档案 · 关系称谓按参照人物计算</span></div>
|
||||
</section>
|
||||
<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>
|
||||
<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="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">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="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>
|
||||
<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="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog>
|
||||
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
(function(root){
|
||||
function describe(people, links, reference, target) {
|
||||
const byId = new Map(people.map(p=>[p.id,p]));
|
||||
if (!byId.has(reference) || !byId.has(target)) return '尚未关联';
|
||||
if (reference === target) return '本人';
|
||||
const edges=links.filter(e=>e.active), graph=new Map();
|
||||
function add(a,b,kind,lineage){if(!graph.has(a))graph.set(a,[]);graph.get(a).push({id:b,kind,lineage});}
|
||||
for(const e of edges){add(e.fromId,e.toId,e.kind==='parent'?'child':'spouse',e.lineage);add(e.toId,e.fromId,e.kind==='parent'?'parent':'spouse',e.lineage);}
|
||||
const queue=[{id:reference,path:[]}],seen=new Set([reference]);let path;
|
||||
for(let i=0;i<queue.length;i++){
|
||||
const node=queue[i];
|
||||
for(const next of graph.get(node.id)||[]){if(seen.has(next.id))continue;seen.add(next.id);const steps=[...node.path,next];if(next.id===target){path=steps;break;}queue.push({id:next.id,path:steps});}
|
||||
if(path)break;
|
||||
}
|
||||
if(!path)return '关系待补充';
|
||||
const gender=byId.get(target).gender, word=(m,f,u)=>gender==='male'?m:gender==='female'?f:u;
|
||||
const type=path.map(e=>e.kind),n=path.length;
|
||||
let result;
|
||||
if(type.every(k=>k==='child')){
|
||||
result=n===1?word('儿子','女儿','子女'):n===2?word('孙子','孙女','孙辈'):n===3?word('曾孙','曾孙女','曾孙辈'):n===4?word('玄孙','玄孙女','玄孙辈'):`第 ${n} 代后裔`;
|
||||
if(n===2 && byId.get(path[0].id)?.gender==='female')result=word('外孙','外孙女','外孙辈');
|
||||
}else if(type.every(k=>k==='parent')){
|
||||
result=n===1?word('爸爸','妈妈','父母'):n===2?(byId.get(path[0].id)?.gender==='female'?word('姥爷','姥姥','外祖辈'):byId.get(path[0].id)?.gender==='male'?word('爷爷','奶奶','祖辈'):'祖父母辈'):n===3?'曾祖辈':n===4?'高祖辈':`第 ${n} 代祖先`;
|
||||
}else if(n===1 && type[0]==='spouse') result='配偶';
|
||||
else if(n===2 && type.join('/')==='parent/child'){
|
||||
const a=byId.get(reference).birthDate,b=byId.get(target).birthDate;
|
||||
result=a&&b&&a!==b?(b<a?word('哥哥','姐姐','年长的手足'):word('弟弟','妹妹','年幼的手足')):word('兄弟(长幼待确认)','姐妹(长幼待确认)','手足(长幼待确认)');
|
||||
}else{
|
||||
result=path.map(e=>e.kind==='spouse'?'配偶':e.kind==='parent'?(byId.get(e.id)?.gender==='male'?'父亲':byId.get(e.id)?.gender==='female'?'母亲':'父母'):(byId.get(e.id)?.gender==='male'?'儿子':byId.get(e.id)?.gender==='female'?'女儿':'子女')).join(' → ');
|
||||
}
|
||||
return result+(path.some(e=>e.lineage==='adoptive'||e.lineage==='step')?'(含收养 / 继亲关系)':'');
|
||||
}
|
||||
const api={describe}; if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.ZhaoKinship=api;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
(function (root) {
|
||||
class LivePlayer {
|
||||
static key(cameras, quality) {
|
||||
// Polling readiness, labels and recording status must not recreate video.
|
||||
return JSON.stringify(cameras.map(c => [c.id, !!c.enabled])) + quality;
|
||||
}
|
||||
constructor(video, panel, source, options = {}) {
|
||||
this.video = video; this.panel = panel; this.source = source;
|
||||
this.Hls = options.Hls || root.Hls;
|
||||
this.clock = options.clock || root;
|
||||
this.now = options.now || (() => Date.now());
|
||||
this.hls = null; this.timer = null; this.stopped = false;
|
||||
this.attempt = 0; this.played = false; this.userPaused = false;
|
||||
this.connecting = true; this.failed = false;
|
||||
this.lastTime = 0; this.lastProgress = this.now(); this.stableSince = 0;
|
||||
this.handlers = {
|
||||
playing: () => {
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.played = true; this.connecting = false; this.failed = false;
|
||||
this.userPaused = false; this.lastProgress = this.now();
|
||||
this.lastTime = video.currentTime; this.stableSince = this.now();
|
||||
panel.querySelector('.video-empty').hidden = true;
|
||||
panel.querySelector('.video-error').hidden = true;
|
||||
},
|
||||
pause: () => { if (!this.connecting && this.played && !video.ended && !video.error && video.readyState >= 2) this.userPaused = true; },
|
||||
play: () => { this.userPaused = false; this.lastProgress = this.now(); if (this.failed) this.retryIfNeeded(); },
|
||||
error: () => this.retry('画面暂时中断'),
|
||||
ended: () => this.retry('视频连接已结束')
|
||||
};
|
||||
for (const [event, handler] of Object.entries(this.handlers)) video.addEventListener(event, handler);
|
||||
this.watchdog = this.clock.setInterval(() => this.watch(), 5000);
|
||||
this.start();
|
||||
}
|
||||
message(text) {
|
||||
const el = this.panel.querySelector('.video-error'); el.textContent = text; el.hidden = false;
|
||||
}
|
||||
start() {
|
||||
if (this.stopped) return;
|
||||
this.connecting = true; this.failed = false;
|
||||
this.release();
|
||||
this.lastProgress = this.now(); this.lastTime = 0;
|
||||
const Hls = this.Hls;
|
||||
if (Hls?.isSupported()) {
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 20, backBufferLength: 5,
|
||||
liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 8,
|
||||
maxLiveSyncPlaybackRate: 1.2,
|
||||
manifestLoadingTimeOut: 60000, levelLoadingTimeOut: 15000,
|
||||
fragLoadingTimeOut: 15000, manifestLoadingMaxRetry: 2, levelLoadingMaxRetry: 2
|
||||
});
|
||||
this.hls = hls;
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (this.hls === hls && data.fatal) this.retry('画面暂时中断');
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (this.hls === hls && !this.stopped) this.video.play().catch(() => {});
|
||||
});
|
||||
hls.loadSource(this.source); hls.attachMedia(this.video);
|
||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
this.video.src = this.source; this.video.play().catch(() => {});
|
||||
} else {
|
||||
this.userPaused = true;
|
||||
this.message('当前浏览器不支持视频播放,请使用较新的浏览器。');
|
||||
}
|
||||
}
|
||||
retry(reason) {
|
||||
if (this.stopped || this.timer !== null) return;
|
||||
this.failed = true;
|
||||
if (this.userPaused) return;
|
||||
const delay = Math.min(30000, 2000 * 2 ** Math.min(this.attempt++, 4));
|
||||
this.message(reason + ',正在自动重新连接…');
|
||||
this.timer = this.clock.setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (!this.stopped) this.start();
|
||||
}, delay);
|
||||
}
|
||||
watch() {
|
||||
if (this.stopped || this.timer !== null || this.userPaused) return;
|
||||
const current = this.video.currentTime;
|
||||
if (this.video.readyState >= 2 && this.video.videoWidth > 0 && current > this.lastTime + 0.1) {
|
||||
this.lastTime = current; this.lastProgress = this.now();
|
||||
if (this.stableSince && this.now() - this.stableSince > 30000) this.attempt = 0;
|
||||
return;
|
||||
}
|
||||
const grace = this.connecting ? 65000 : 25000;
|
||||
if (this.now() - this.lastProgress > grace) this.retry('画面停顿');
|
||||
}
|
||||
retryIfNeeded() {
|
||||
if (!this.failed || this.stopped) return;
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null; this.start();
|
||||
}
|
||||
release() {
|
||||
const old = this.hls; this.hls = null;
|
||||
if (old) old.destroy();
|
||||
this.video.pause(); this.video.removeAttribute('src'); this.video.load();
|
||||
}
|
||||
destroy() {
|
||||
this.stopped = true; this.connecting = true;
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null; this.clock.clearInterval(this.watchdog);
|
||||
for (const [event, handler] of Object.entries(this.handlers)) this.video.removeEventListener(event, handler);
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = LivePlayer;
|
||||
else root.ZhaoLivePlayer = LivePlayer;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
let wallOwner='',wallOrder=[],wallPage=0,wallDragging='',ptzCamera='',ptzGeneration=0;
|
||||
const WALL_LAYOUTS={auto:[0,0],1:[1,1],2:[2,1],4:[2,2],6:[3,2],9:[3,3],16:[4,4]};
|
||||
function wallStorageKey(){return 'zhaofu-monitor-'+(currentUser?.id||'guest');}
|
||||
function wallSave(){try{localStorage.setItem(wallStorageKey(),JSON.stringify({order:wallOrder,layout:$('#wall-layout').value}));}catch{}}
|
||||
function wallSync(){
|
||||
if(wallOwner!==(currentUser?.id||'')){
|
||||
wallOwner=currentUser?.id||'';wallPage=0;let saved={};try{saved=JSON.parse(localStorage.getItem(wallStorageKey()))||{};}catch{}
|
||||
wallOrder=Array.isArray(saved.order)?saved.order.filter(x=>/^[a-f0-9]{16}$/.test(x)):[];
|
||||
$('#wall-layout').value=Object.hasOwn(WALL_LAYOUTS,saved.layout)?saved.layout:'auto';
|
||||
}
|
||||
const cameras=filtered(),ids=cameras.map(c=>c.id);
|
||||
const allIds=new Set(state.cameras.map(c=>c.id));wallOrder=wallOrder.filter(id=>allIds.has(id));
|
||||
for(const id of ids)if(!wallOrder.includes(id))wallOrder.push(id);
|
||||
const ordered=wallOrder.filter(id=>ids.includes(id)),layout=$('#wall-layout').value;
|
||||
const capacity=Number(layout)||6,pages=Math.max(1,Math.ceil(ordered.length/capacity));
|
||||
wallPage=Math.min(wallPage,pages-1);const visible=new Set(ordered.slice(wallPage*capacity,(wallPage+1)*capacity));
|
||||
$('#live-grid').dataset.layout=layout;
|
||||
for(const card of $$('#live-grid .camera-card')){
|
||||
const id=card.querySelector('video').dataset.camera;
|
||||
card.style.order=ordered.indexOf(id);card.hidden=!visible.has(id);
|
||||
card.querySelector('[data-wall-before]').disabled=ordered.indexOf(id)===0;
|
||||
card.querySelector('[data-wall-after]').disabled=ordered.indexOf(id)===ordered.length-1;
|
||||
}
|
||||
$('#wall-page').textContent=`${wallPage+1} / ${pages} 页 · ${ordered.length} 个画面`;
|
||||
$('#wall-prev').disabled=wallPage===0;$('#wall-next').disabled=wallPage===pages-1;
|
||||
$('#live-grid').classList.toggle('arranging',$('#wall-arrange').checked);
|
||||
$$('[data-ptz-camera]').forEach(b=>b.hidden=!(isAdmin()||currentUser?.ptzControl));
|
||||
}
|
||||
function wallMove(id,target){
|
||||
const from=wallOrder.indexOf(id),to=wallOrder.indexOf(target);if(from<0||to<0||from===to)return;
|
||||
wallOrder.splice(from,1);wallOrder.splice(to,0,id);wallSave();wallSync();
|
||||
}
|
||||
$('#wall-layout').onchange=()=>{wallPage=0;wallSave();wallSync();};
|
||||
$('#wall-arrange').onchange=wallSync;
|
||||
$('#wall-prev').onclick=()=>{wallPage--;wallSync();};$('#wall-next').onclick=()=>{wallPage++;wallSync();};
|
||||
$('#wall-fullscreen').onclick=async()=>{
|
||||
try{if(document.fullscreenElement)await document.exitFullscreen();else await $('#page-live').requestFullscreen();}
|
||||
catch{toast('浏览器未允许全屏,可按 F11 进入浏览器全屏');}
|
||||
};
|
||||
document.addEventListener('fullscreenchange',()=>{
|
||||
$('#wall-fullscreen').textContent=document.fullscreenElement?'退出全屏 · Esc':'全屏监控';
|
||||
document.body.classList.toggle('wall-fullscreen',document.fullscreenElement===$('#page-live'));
|
||||
});
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key==='Escape'&&document.fullscreenElement===$('#page-live'))document.exitFullscreen().catch(()=>{});
|
||||
});
|
||||
setInterval(()=>{$('#wall-clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
|
||||
let wallPointer=null;
|
||||
function wallDragEnd(){wallPointer=null;wallDragging='';$$('.wall-drop-target').forEach(c=>c.classList.remove('wall-drop-target'));}
|
||||
$('#live-grid').addEventListener('pointerdown',e=>{
|
||||
const handle=e.target.closest('[data-wall-drag]');if(!handle||e.button!==0||!$('#wall-arrange').checked)return;
|
||||
e.preventDefault();handle.setPointerCapture(e.pointerId);wallDragging=handle.dataset.wallDrag;
|
||||
wallPointer={x:e.clientX,y:e.clientY,id:e.pointerId,moved:false};
|
||||
});
|
||||
$('#live-grid').addEventListener('pointermove',e=>{
|
||||
if(!wallPointer||wallPointer.id!==e.pointerId)return;
|
||||
wallPointer.moved ||= Math.hypot(e.clientX-wallPointer.x,e.clientY-wallPointer.y)>8;
|
||||
$$('.wall-drop-target').forEach(c=>c.classList.remove('wall-drop-target'));
|
||||
if(wallPointer.moved)document.elementFromPoint(e.clientX,e.clientY)?.closest('.camera-card')?.classList.add('wall-drop-target');
|
||||
});
|
||||
$('#live-grid').addEventListener('pointerup',e=>{
|
||||
if(!wallPointer||wallPointer.id!==e.pointerId)return;
|
||||
const target=document.elementFromPoint(e.clientX,e.clientY)?.closest('.camera-card')?.querySelector('video')?.dataset.camera;
|
||||
if(wallPointer.moved&&target)wallMove(wallDragging,target);wallDragEnd();
|
||||
});
|
||||
$('#live-grid').addEventListener('pointercancel',wallDragEnd);
|
||||
document.addEventListener('click',e=>{
|
||||
const b=e.target.closest('button');if(!b)return;
|
||||
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);
|
||||
});
|
||||
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;}
|
||||
}
|
||||
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);}
|
||||
}
|
||||
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(()=>{});});
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1004 KiB |
Reference in New Issue
Block a user