Compare commits

..

13 Commits

33 changed files with 2159 additions and 91 deletions
+18 -2
View File
@@ -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` 通过最短关系路径提供阅读称呼,未知长幼与复杂旁系明确显示不确定性或关系链。家谱页面不启动摄像头播放;返回实时画面时重新建立所需流。
+9
View File
@@ -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.
+69 -3
View File
@@ -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 的兼容性取决于浏览器。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。设备状态基于 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` 和 `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,65 @@ 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 保存也不会撤回。撤回为草稿使用独立按钮;首次发布时间在撤回、重发和归档恢复之间保留。
- 遇到家谱并发修订冲突,当前输入保留;复制需要保留的内容后,可明确点击“读取最新版本(覆盖本次输入)”继续编辑。服务器保存成功而页面重新读取失败时,提示已保存,避免误以为需要再次提交。
- 从记事跳到归档人物时自动显示归档人物。当前账号的本人标记与关系参照人物区分;变更本人关联会重新布局。
- 新建、恢复亲生关系及修改人物出生日期时,已知父母出生日期必须早于子女;日期未知以及收养、继亲关系不据此推断或拒绝。
- 退出或切换账号后,旧请求不得重新写入私有视图;过时的状态刷新不会覆盖较新的结果。共享浏览器标签页切换账号时,在下一次状态刷新或会话检查中清理前一账号的视图。
- 日期表单按人物或记事分别校验,编辑记事不会清除另一张人物表单的去世日期。
+1 -1
View File
@@ -1 +1 @@
0.1.5
0.1.18
+142 -33
View File
@@ -26,6 +26,10 @@ import urllib.parse as url
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)
@@ -65,6 +69,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()
@@ -79,7 +87,8 @@ def set_setting(key, value):
def objects(table):
with LOCK:
return [dict(json.loads(r['body']), id=r['id']) for r in DB.execute('SELECT * FROM ' + table)]
rows = [dict(json.loads(r['body']), id=r['id']) for r in DB.execute('SELECT * FROM ' + table)]
return sorted(rows, key=lambda row: (row.get('name', ''), row['id']))
def get_object(table, key):
@@ -308,6 +317,15 @@ def media_json(path, playback=False):
req = urllib.request.Request(target, headers={'Authorization': MEDIA_AUTH})
with urllib.request.urlopen(req, timeout=5) as r:
return json.load(r)
except urllib.error.HTTPError as exc:
if playback and path.startswith('/list?') and exc.code == 404:
try:
detail = json.loads(exc.read(4096))
if detail.get('error') == 'no recording segments found':
return []
except (ValueError, AttributeError):
pass
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
except Exception:
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
@@ -400,9 +418,9 @@ def storage_watch():
def discover(network):
def probe(ip):
try:
with socket.create_connection((str(ip), 554), timeout=0.7) as s:
s.settimeout(1)
s.sendall(('OPTIONS rtsp://' + str(ip) + '/ RTSP/1.0\r\nCSeq: 1\r\n\r\n').encode())
with socket.create_connection((str(ip), 554), timeout=1.5) as s:
s.settimeout(3)
s.sendall(('OPTIONS rtsp://' + str(ip) + ':554/ RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: ZhaoVision\r\n\r\n').encode())
response = s.recv(1024).decode(errors='replace').split('\r\n')[0]
if response.startswith('RTSP/'):
return {'host': str(ip), 'port': 554, 'response': response, 'authenticated': False}
@@ -459,20 +477,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))
@@ -498,31 +526,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 ''
@@ -530,18 +556,35 @@ 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/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/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':
@@ -594,7 +637,7 @@ class Handler(BaseHTTPRequestHandler):
except Exception:
self.answer({'error': '操作未完成,请检查输入或服务状态'}, 500)
def proxy(self, port, path):
def proxy(self, port, path, download=False):
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=55)
headers = {'Authorization': MEDIA_AUTH}
if self.headers.get('Range'):
@@ -615,6 +658,8 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('视频服务返回了无效的播放跳转', 502)
location = '/media/live' + target.path + ('?' + target.query if target.query else '')
self.send_response(r.status)
if download:
self.send_header('Content-Disposition', 'attachment; filename="recording.mp4"')
if location:
self.send_header('Location', location)
for k in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'):
@@ -635,13 +680,45 @@ class Handler(BaseHTTPRequestHandler):
finally:
conn.close()
def compatible_playback(self, path, duration):
started = False
old_timeout = self.connection.gettimeout()
try:
with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration) as output:
first = output.read1(65536)
if not first:
raise Problem('录像转换失败,请检查录像是否完整及服务器视频驱动', 503)
self.connection.settimeout(15)
self.send_response(200)
self.send_header('Content-Type', 'video/mp4')
self.send_header('Cache-Control', 'no-store')
self.send_header('Accept-Ranges', 'none')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Connection', 'close')
self.end_headers()
started = True
self.close_connection = True
self.wfile.write(first)
while chunk := output.read1(65536):
self.wfile.write(chunk)
except playback.PlaybackError as exc:
if not started:
raise Problem(str(exc), 503)
except OSError:
if not started:
raise Problem('录像转换连接失败', 503)
finally:
self.connection.settimeout(old_timeout)
def do_GET(self):
try:
parsed = url.urlsplit(self.path)
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()})
@@ -649,8 +726,23 @@ 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/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)
@@ -658,6 +750,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('每次查询最多两天')
@@ -669,11 +762,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'))
@@ -681,18 +776,30 @@ class Handler(BaseHTTPRequestHandler):
raise ValueError()
except ValueError:
raise Problem('单次回放最多一小时')
download = query.get('download') == '1'
q = url.urlencode({'path': stream_name(c), 'start': start.isoformat(), 'duration': duration,
'format': 'mp4' if query.get('download') == '1' else 'fmp4'})
self.proxy(PLAYBACK_PORT, '/get?' + q)
'format': 'mp4' if download else 'fmp4'})
if download:
self.proxy(PLAYBACK_PORT, '/get?' + q, download=True)
else:
self.compatible_playback('/get?' + q, duration)
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',
'/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)))
@@ -700,7 +807,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:
@@ -713,12 +820,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()
+1
View File
@@ -13,6 +13,7 @@ EnvironmentFile=/etc/zhaovision.env
ExecStart=/usr/local/lib/zhaovision/mediamtx /var/lib/zhaovision/mediamtx.yml
Restart=always
RestartSec=4
TimeoutStopSec=15s
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
+2 -1
View File
@@ -21,7 +21,8 @@ PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/zhaovision
MemoryMax=512M
MemoryMax=900M
TimeoutStopSec=15s
[Install]
WantedBy=multi-user.target
+200
View File
@@ -0,0 +1,200 @@
"""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);
''')
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):
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.
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.commit()
a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username'])
return {'item': item, 'revision': revision + 1}
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
+129
View File
@@ -0,0 +1,129 @@
"""Local accounts. Passwords never appear in public account representations."""
import hashlib
import hmac
import json
import re
import secrets
class Accounts:
def __init__(self, app):
self.a = app
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 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
+70
View File
@@ -0,0 +1,70 @@
"""Bounded, ephemeral H.264 playback; original recordings remain untouched."""
from contextlib import contextmanager
import os
import shutil
import subprocess
import threading
SLOTS = threading.BoundedSemaphore(2)
class PlaybackError(Exception):
pass
def command(source, authorization, duration):
executable = shutil.which('ffmpeg')
if not executable:
raise PlaybackError('回放转换需要服务器安装 FFmpeg')
device = os.environ.get('VISION_VAAPI_DEVICE', '/dev/dri/renderD128')
acceleration = []
if os.access(device, os.R_OK | os.W_OK):
acceleration = ['-hwaccel', 'vaapi', '-hwaccel_device', device,
'-hwaccel_output_format', 'vaapi', '-extra_hw_frames', '8']
video = ['-vf', 'fps=15,scale_vaapi=w=1920:h=1080:format=nv12:force_original_aspect_ratio=decrease:force_divisible_by=2',
'-c:v', 'h264_vaapi', '-profile:v', 'main', '-level:v', '4.1',
'-rc_mode', 'CQP', '-qp', '25', '-g', '30', '-bf', '0']
else:
video = ['-vf', 'fps=10,scale=768:432:force_original_aspect_ratio=decrease:force_divisible_by=2',
'-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast',
'-tune', 'zerolatency', '-profile:v', 'baseline', '-pix_fmt', 'yuv420p',
'-crf', '26', '-g', '20', '-bf', '0']
return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
'-threads', '1', '-filter_threads', '1'] + acceleration + [
'-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
'-readrate', '1', '-i', source, '-t', str(duration),
'-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [
'-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k',
'-movflags', '+frag_keyframe+empty_moov+default_base_moof',
'-frag_duration', '1000000', '-f', 'mp4', 'pipe:1']
@contextmanager
def stream(source, authorization, duration):
if not SLOTS.acquire(blocking=False):
raise PlaybackError('已有两个回放正在进行,请关闭其他回放后重试')
process = timer = None
try:
# No shell and no diagnostics containing authentication headers in logs.
process = subprocess.Popen(command(source, authorization, duration),
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
timer = threading.Timer(duration + 45, process.kill)
timer.daemon = True
timer.start()
yield process.stdout
except OSError as exc:
raise PlaybackError('录像转换服务暂不可用') from exc
finally:
if timer:
timer.cancel()
if process:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
process.stdout.close()
SLOTS.release()
+3 -2
View File
@@ -37,8 +37,9 @@ def main():
'-hwaccel_output_format', 'vaapi', '-extra_hw_frames', '8']
video = ['-vf', 'fps=15,scale_vaapi=w=1920:h=1080:format=nv12:force_original_aspect_ratio=decrease:force_divisible_by=2',
'-c:v', 'h264_vaapi', '-profile:v', 'main', '-level:v', '4.1',
'-rc_mode', 'VBR', '-b:v', '3500k', '-maxrate', '5000k',
'-bufsize', '10000k', '-g', '30', '-bf', '0']
# Jasper Lake can expose CQP only when HuC bitrate control is
# unavailable; fixed quality does not require a firmware change.
'-rc_mode', 'CQP', '-qp', '25', '-g', '30', '-bf', '0']
else:
video = ['-vf', 'fps=10,scale=768:432:force_original_aspect_ratio=decrease:force_divisible_by=2',
'-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast',
+121
View File
@@ -0,0 +1,121 @@
"""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')
if uri is not None and url.urlsplit(uri.text).path == url.urlsplit(c['mainPath']).path:
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()
+350
View File
@@ -0,0 +1,350 @@
"""Isolated regression tests: python -m unittest -v test_app."""
import hashlib
import http.client
import io
import json
import os
import re
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
_data = tempfile.TemporaryDirectory()
os.environ['VISION_DATA'] = _data.name
os.environ['VISION_RECORDINGS'] = _data.name + '/recordings'
import app
import playback
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() + 3600, cls.admin['id']))
app.DB.commit()
app.save_object('cameras', {'id': 'a' * 16, 'name': 'Example', 'siteId': 'test-site', 'password': 'private'})
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()
app.DB.close()
_data.cleanup()
def request(self, path, authenticated=True, data=None, origin=None):
c = http.client.HTTPConnection(*self.server.server_address, timeout=3)
headers = {'Cookie': 'vision=' + 'f' * 64} if authenticated else {}
if origin:
headers['Origin'] = origin
if data is not None:
headers['Content-Type'] = 'application/json'
c.request('GET' if data is None else 'POST', path,
None if data is None else json.dumps(data), headers)
r = c.getresponse()
result = r.status, dict(r.getheaders()), r.read()
c.close()
return result
def test_media_and_api_require_login(self):
for path in ['/api/state', '/api/recordings', '/media/playback',
'/media/live/cam_' + 'a' * 16 + '_hd/index.m3u8']:
with self.subTest(path=path):
self.assertEqual(self.request(path, False)[0], 401)
def test_static_security_headers(self):
code, headers, _ = self.request('/', False)
self.assertEqual(code, 200)
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), 13)
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)
def test_invalid_ranges_and_paths(self):
base = '/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration='
for duration in ['nan', 'inf', '-1', '3601']:
self.assertEqual(self.request(base + duration)[0], 400)
self.assertEqual(self.request('/media/live/../../vision.db')[0], 400)
self.assertEqual(self.request('/vision.db', False)[0], 404)
def test_compatible_playback_dispatch(self):
calls = []
def compatible(handler, path, duration):
calls.append((path, duration))
handler.answer({'ok': True})
with patch.object(app.Handler, 'compatible_playback', compatible):
code, _, _ = self.request('/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration=10')
self.assertEqual(code, 200)
self.assertEqual(calls[0][1], 10)
self.assertIn('format=fmp4', calls[0][0])
def test_original_download_dispatch(self):
calls = []
def proxy(handler, port, path, download=False):
calls.append((path, download))
handler.answer({'ok': True})
with patch.object(app.Handler, 'proxy', proxy):
code, _, _ = self.request('/media/playback?camera=' + 'a' * 16 + '&start=2026-01-01T00:00:00Z&duration=10&download=1')
self.assertEqual(code, 200)
self.assertIn('format=mp4', calls[0][0])
self.assertTrue(calls[0][1])
def test_stable_object_order(self):
app.save_object('sites', {'id': 'b', 'name': 'Second'})
app.save_object('sites', {'id': 'a', 'name': 'First'})
first = app.objects('sites')
app.save_object('sites', first[0])
self.assertEqual(app.objects('sites'), first)
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()
try:
with self.assertRaises(playback.PlaybackError):
with playback.stream('unused', 'private', 10):
self.fail('No third conversion allowed')
finally:
playback.SLOTS.release()
playback.SLOTS.release()
with patch('playback.shutil.which', return_value=None):
with self.assertRaises(playback.PlaybackError):
with playback.stream('unused', 'private', 10):
pass
self.assertTrue(playback.SLOTS.acquire(blocking=False))
playback.SLOTS.release()
def test_vaapi_uses_supported_cqp(self):
with patch('playback.shutil.which', return_value='ffmpeg'), patch('playback.os.access', return_value=True):
command = playback.command('http://127.0.0.1/source', 'private', 10)
self.assertEqual(command[command.index('-rc_mode') + 1], 'CQP')
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"}'))
with patch('app.urllib.request.urlopen', side_effect=error):
self.assertEqual(app.media_json('/list?path=example', playback=True), [])
def test_real_media_service_error_is_preserved(self):
error = app.urllib.error.HTTPError('http://localhost/list', 500, 'Error', {}, io.BytesIO(b'{}'))
with patch('app.urllib.request.urlopen', side_effect=error):
with self.assertRaises(app.Problem):
app.media_json('/list?path=example', playback=True)
if __name__ == '__main__':
unittest.main()
+17
View File
@@ -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(公历)');
});
+12
View File
@@ -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);});
+7
View File
@@ -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'),/收养/);});
+91
View File
@@ -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
View File
@@ -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()
+74
View File
@@ -0,0 +1,74 @@
// 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);
});
+69 -39
View File
@@ -4,12 +4,33 @@ const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer;
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();
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera]').forEach(el=>el.hidden=!admin);
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
}
function clearPrivateView(){
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 +40,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') $('#play-video').pause();
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 +62,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,34 +76,33 @@ 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-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>' : '');
@@ -84,16 +110,17 @@ function renderCameras() {
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
}
function renderSites() {
$('#site-list').innerHTML=state.sites.length?state.sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.cameras.filter(c=>c.siteId===s.id).length} 台</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>');
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.assets.filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>');
}
function renderStorage() {
const s=state.storage;
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());}
@@ -103,35 +130,38 @@ handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),enable
handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d).map(([k,v])=>[k,Number(v)])),loadAudit);
$('#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;queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();$('#play-video').pause();await auth();};
$('#refresh').onclick=()=>{liveKey='';refresh();};$('#site-filter').onchange=()=>{stopPlayers();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
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;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() {const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';drawTimeline();$('#recording-list').innerHTML='';}
function resetPlayback() {playbackGeneration++;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';drawTimeline();$('#recording-list').innerHTML='';}
async function queryRecordings() {
resetPlayback();const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;}
resetPlayback();const generation=playbackGeneration;const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;}
const channel=state.cameras.find(c=>c.id===id);if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。请先提供录像机型号,在录像机或原厂 App 中可继续查看已有录像。';return;}
queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id;
$('#play-info').textContent='正在查找录像…';
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${esc(s.start)}">${s.date.toLocaleTimeString()} — ${new Date(+s.date+s.duration*1000).toLocaleTimeString()}<small>${(s.duration/60).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){$('#play-info').textContent=e.message;}
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));if(generation!==playbackGeneration)return;spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${esc(s.start)}">${s.date.toLocaleTimeString()} — ${new Date(+s.date+s.duration*1000).toLocaleTimeString()}<small>${(s.duration/60).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){if(generation===playbackGeneration)$('#play-info').textContent=e.message;}
}
function drawTimeline() {const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);ctx.fillStyle='#101f25';ctx.fillRect(0,0,w,76);ctx.strokeStyle='#2b454e';for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}if(!queryStart||!queryEnd)return;ctx.fillStyle='#72cdb5';const total=queryEnd-queryStart;for(const s of spans){const a=Math.max(0,(s.date-queryStart)/total),b=Math.min(1,(+s.date+s.duration*1000-queryStart)/total);if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}}
function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 当前片段最多一小时';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;}
function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 兼容回放,最多一小时;使用下方时间轴定位';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;}
$('#query-recordings').onclick=queryRecordings;$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=resetPlayback;$('#play-date').onchange=resetPlayback;
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
$('#timeline').onclick=e=>{if(!queryStart)return;const rect=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-rect.left)/rect.width));};
$('#play-video').onerror=()=>{$('#play-info').textContent='回放失败:可能没有完整录像,或浏览器不支持录像编码;可下载片段后播放。';};
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};
const today=new Date();$('#play-date').value=[today.getFullYear(),String(today.getMonth()+1).padStart(2,'0'),String(today.getDate()).padStart(2,'0')].join('-');
window.addEventListener('resize',drawTimeline);window.addEventListener('beforeunload',stopPlayers);
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);auth();
function renderAssets(){
$('#asset-list').innerHTML=state.assets.length?state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value).map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
$('#asset-list').innerHTML=assets.length?assets.map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
}
function renderRecorders(){
$('#recorder-list').innerHTML=state.recorders.length?state.recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;$('#recorder-dialog').showModal();}
+38
View File
@@ -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);
+38
View File
@@ -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);
+73
View File
@@ -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>`:''}`;
$('#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);}
});
+97
View File
@@ -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);
File diff suppressed because one or more lines are too long
+73
View File
@@ -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
View File
@@ -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(){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>`:''}</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>`:''}</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);});
+40 -10
View File
@@ -2,48 +2,78 @@
<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>
</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="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>
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载当前片段</a></div></div>
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载原始片段</a></div></div>
<div id="recording-list" class="recording-list"></div>
</section>
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>CHANNELS</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>CAMERA ASSETS</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>RECORDERS</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>PLACES</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用 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>
<div id="toast" role="status" hidden></div>
</body>
</html>
+35
View File
@@ -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);
+110
View File
@@ -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);
+92
View File
@@ -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(()=>{});});
+11
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 KiB