Compare commits

..

6 Commits

31 changed files with 2199 additions and 127 deletions
+55 -5
View File
@@ -9,7 +9,9 @@
## 对象关系
```text
空间档案(家庭 / 公司 / 其他)
管理归属(家庭 / 办公场所 / 工厂,独立权限)
└─ 自定义区域树(parentId,不限制层数,禁止循环及跨归属父节点)
└─ 空间档案(名称、区域类别及可选地址)
├─ 地区:省、市、区县、街道
├─ 地址:小区或园区、楼栋、单元、楼层、门牌
├─ 所属:家庭或单位
@@ -17,6 +19,7 @@
│ ├─ 镜头通道 1 → 主码流 / 子码流
│ └─ 镜头通道 2 → 主码流 / 子码流
└─ 录像机对象(地址、接入驱动、账号、空间引用)
└─ 其他设备档案(灯、手机、冰箱或自定类型,暂不含控制驱动)
每个镜头通道分别关联:
实时来源:直接摄像头 / 录像机
@@ -27,11 +30,11 @@
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。摄像机参数与云台按设备开放的 ONVIF 能力设置,不自动导入不存在的通道,不把服务可达当成画面接通。
## 管理 API
所有数据接口(除会话状态与健康检查)需要管理员 Cookie 会话。写接口接受 JSON,检查同源请求。
所有数据接口(除会话状态与健康检查)需要账号 Cookie 会话,并依据家庭、角色和空间核验访问权限。写接口接受 JSON,检查同源请求。
| 接口 | 用途 |
| --- | --- |
@@ -53,14 +56,14 @@
本阶段实现独立运行、空间建模、三类视觉对象、实时视频接入和本机可选录像。摄像头真实接入结果由部署记录和主机运行状态说明。
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ、语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。
下一阶段可加入具体录像机的历史回放适配器;随后扩展其他设备类型和联动规则。PTZ 已支持方向控制、停止和预置位;语音对讲、AI 检测目前没有实现,不作为已具备的能力展示。
配置写入 SQLite 并生成运行时媒体配置,录像数据独立存储。当前为单机模式;升级前备份整个数据目录。凭据和录像不包含在公开源码中。
## 身份与家谱
`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。家谱目前是同一部署的一份共享家庭档案,按账号授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。
`identity.py` 管理账号、scrypt 密码散列、旧账户迁移、会话撤销和空间授权。`genealogy.py` 管理独立人物、亲子/配偶边和并发修订号。账号通过 `personId` 可选关联人物;祖先无需账号。`households.py` 管理家庭归属、负责人、交接记录与请求范围。每个家庭有独立家谱与修订号,按账号另授予 read/edit 权限。管理员与家庭成员的角色不由家谱辈分决定。
| API | 权限 |
| --- | --- |
@@ -72,3 +75,50 @@
| GET /media/live/*、/media/playback、/api/recordings | 登录并具备对应空间权限 |
人物与关系修订在同一数据库事务中保存;修改关系时迭代检查亲子图,拒绝循环。前端 `kinship.js` 通过最短关系路径提供阅读称呼,未知长幼与复杂旁系明确显示不确定性或关系链。家谱页面不启动摄像头播放;返回实时画面时重新建立所需流。
## 家庭边界与管理连续性
账号 → 一个管理归属(家庭 / 办公场所 / 工厂)→ 多级区域 → 设备对象 / 镜头。人物、亲属关系、记事和历史版本也属于同一管理归属。兼容已有接口,数据库仍使用 `households`、`familyId` 命名。
- `X-Household-Id` 仅供超级管理员选择工作家庭;其余账号只能使用自己的家庭。所有写操作均核验所属家庭与关联对象,不能通过 body 迁移归属。
- API 状态与账号列表按家庭过滤。媒体 URL 不依赖前端过滤,每个清单、分片与下载请求均核验摄像头归属和空间授权;超级管理员是明确的特权例外。
- 平台级扫描、存储策略、审计只允许超级管理员。重复接入已属于其他家庭的已知设备 IP 会被拒绝。
- `/api/households` 供超级管理员创建、家庭管理员修改本家庭名称和备注。`/api/households/leadership` 仅允许当前家主或超级管理员办理家主及备用负责人安排。
- 家主、备用负责人必须是本家庭已启用的家庭管理员且不能相同。备用负责人预先持有管理权限,意外发生不影响设备管理;身份交接需要有权限的账号明确操作、填写原因、检查修订号。
- 任内负责人不能被直接停用、降级;先交接,再调整账号。交接后原家主保留家庭管理员,必要时由新家主或超级管理员处理其账号。
- 家谱人物的去世日期、账号长期未登录、亲属称谓均不触发权限变化。
- 首次迁移只回填 `familyId`,不修改凭据或设备路径;旧历史快照也加归属。旧版本没有此隔离能力,多家庭运行后禁止只回退代码。
## 设备分类与区域授权
`inventory.py` 保存通用设备档案以及所有人、负责人、标签。视频设备继续使用原 `assets`、`recorders`、`cameras` 结构,不迁移凭据、不修改录像配置。前端 `device-model.js` 将三类档案统一为只读目录,按每个归属保存的维度顺序分组。修改分组只改变展示,不移动设备或放宽权限。
区域以 `parentId` 邻接表表示,创建/移动时在写锁内检查父节点归属、有效性与祖先循环。父链和后代集合均迭代计算,避免递归栈形成层数限制。用户的 `includeSubspaces` 默认关闭;开启后按当前区域树实时扩展 `siteIds`。修改该授权开关会撤销已有会话。人员归属是文本档案,不参与权限计算。
新增接口:`POST /api/devices` 保存通用设备(要求当前归属管理员);`POST /api/device-view` 保存当前归属默认分组顺序。统一状态的 `devices` 数组也经过归属和成员区域过滤。
## 场所、设备上下文与传承(v0.1.24)
`steward.js` 管理入口和当前设备上下文。根入口是归属列表;进入归属后才展示内部导航。`deviceCameras` 按摄像头 `assetId` 或录像机关联字段 `recorderId` / `archiveRecorderId` 选择已有授权通道,不推断不存在的镜头。通用设备没有视频页签。前端上下文缩小显示范围,后端的家庭与区域授权始终独立执行。
`heritage.py` 通过 `GET /api/heritage` 和 `POST /api/heritage` 维护 `heritage_entries`,kind 为 culture 或 asset,只允许 home 类归属。写入在全局写锁内检查归属、编辑权限、不可变类别和每条档案的 revision。关联设备需要同归属,普通编辑者还须具备该设备区域权限。文字统一转义;外链限定无嵌入凭据的 HTTP/HTTPS,浏览器用 noopener/noreferrer 打开,不由服务器抓取。
`heritageAccess` 独立于 `familyAccess`,普通账号默认 none,read 只能读,edit 可创建、编辑及归档;管理员限定于其管理归属,超级管理员可明确切换归属。权限改变撤销现有会话。新表无默认真实资料;旧账户未补写该字段也按 none 处理。
前端 `heritage.js` 用独立请求代次防止晚返回的数据覆盖其他页面;退出、切换家庭清空资料和表单。更新使用修订号防并发,归档可恢复;目前保留当前版本及最近修改人、时间,不保存该档案的逐次历史快照。所有人、保管人、意向接收人均是档案文本,不是认证标识,不参与权限或所有权计算。
当前架构边界:非超级管理员一账号一归属;财产记录与系统角色交接是独立流程;通用设备登记不代表支持控制;录像机既有历史录像仍需品牌适配。未来多归属成员表与正式交接流程须先完成明确授权模型,再扩展数据结构。
## 发布设置、个人偏好与备份(v0.1.25)
`appearance.py` 将平台配置保存在 settings.appearance,个人偏好保存在 settings 的 `preferences:<user-id>` 键;修改账号资料不会覆盖偏好。选项通过有限枚举校验,使用修订号阻止并发覆盖。公开 `/api/branding` 只返回允许公开的文案与图标 URL;完整配置 GET/POST `/api/appearance` 仅超级管理员。GET/POST `/api/preferences` 固定当前会话账号,不接受目标 userId。主题只修改 HTML data 属性,不保存任意 CSS 或脚本。
PNG 上传校验签名、尺寸、CRC、压缩数据长度、颜色模式及结束块;不允许 SVG、外链图片或附加尾部内容。Logo 与 favicon 是公开品牌图片;家庭 coverData 不进入状态接口,仅 `/api/households/cover` 在验证用户归属后按 no-store 返回。地级市只是展示索引,与 familyId 授权边界独立。
`backups.py` 通过独立 SQLite 连接复制运行数据库,关闭连接后校验及原子完成文件。备份只保留账号散列与业务数据,删除复制库内的会话,不改变原库会话。文件权限 0600;网络副本先临时复制再核验 SHA-256。实例随机前缀界定清理范围。配置、最近结果和最多 100 条结果历史保存在 settings,UI 仅显示最近 20 条;GET `/api/backups`、POST `/api/backups/config`、POST `/api/backups/run` 全部由后端强制超级管理员权限。
每日调度为应用进程内线程,使用北京时间和已尝试日期防重复,启动晚于当天设定时间会补尝试一次;不添加外部 cron。网络 IO 依赖挂载自身的超时和可用性;不会挂载网盘、保存网盘登录密码或声称云端同步已确认。服务实例必须单进程部署,跨进程任务互斥尚不支持。
+78 -4
View File
@@ -8,7 +8,8 @@ v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点
## 对象与空间
- **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。
- **区域与空间**:各家庭、办公场所、工厂独立设置区域树,自定义名称、类别与上级区域,不设固定层数。地址可记录省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌。
- **统一设备管理**:摄像机、录像机与其他设备统一展示,支持按类型、区域、负责人、所有人、自定义标签调整分组顺序。灯、手机、冰箱等当前提供档案登记,实际控制需要另行接入协议。
- **摄像头对象**:实体设备的稳定 ID、品牌、型号、序列号、镜头数、安装位置和空间引用。
- **镜头通道**:每个物理镜头一个通道,关联摄像头对象,配置主、子码流。双镜头摄像头的主、子码流不能当作两个物理镜头。
- **录像机**:独立的设备与凭据档案,通道可以引用录像机获取画面。也可以直接连接摄像头。
@@ -53,7 +54,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml
```
回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets test_households test_heritage test_appearance test_backups` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js test_households_ui.js test_devices.js test_steward.js test_personalization.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 使用管理员与个人账号分权。
@@ -76,10 +77,10 @@ mediamtx ./data/mediamtx.yml
## 账号与家谱(v0.1.11)
- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置、网络发现和审计接口仅管理员可用。媒体播放请求逐次检查空间权限。
- 管理员维护设备、空间、账号和权限。个人账号只可观看授权空间内的实时画面与本机回放,设备配置归属本家庭管理员;网络发现、全局存储与审计仅超级管理员可用。媒体播放请求逐次检查空间权限。
- 每个账号具有独立用户名和密码,密码使用 scrypt 散列存储;更新密码、角色、空间范围、家谱权限或停用状态会撤销该账号会话。亲属关系不自动授予权限。
- 管理员在“账号与关系”中设置显示名称、相对于哪个账号的称呼,以及关联的家谱人物。支持常用称呼与自定义称呼。默认新个人账号没有空间或家谱权限。
- 家谱是本部署内的一份家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。未实现跨家庭租户隔离。
- 家谱是各家庭独立的家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。v0.1.23 起按家庭隔离人物、记事、历史版本与导出。
- 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。日期可按公历或农历录入,并自动换算;不确定日期可留空,在备注中保留原记载。
- 亲子关系按“父母一方 → 子女”逐条连接,支持亲生、收养、继亲和未注明;配偶关系为双向。禁止自我关系、重复关系、祖先循环、直系祖先与配偶冲突。关系可以停用后恢复,人物可归档后恢复。
- 支持关系参照人物、搜索、全家关系图、人物名册和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。
@@ -183,3 +184,76 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
- 本版不创建新的家庭数据或修改已有账户和录像策略;新增界面及设备操作继续使用既有空间、管理与云台权限。录像机原有历史录像仍未适配,以上续播与片段工具用于小电脑本机录像。
预置位协议依据:[ONVIF PTZ Service 5.4](https://www.onvif.org/specs/srv/ptz/ONVIF-PTZ-Service-Spec.pdf)。自动回归包含权限、并发、版本冲突、设备拒绝或未确认写入、停止、回放空档、日期边界和退出账号后的异步响应清理;模拟设备测试不代表所有品牌均已实机验证。
## 家庭隔离与权限交接(v0.1.23)
- **超级管理员**(`admin` 角色):可建立家庭、切换家庭和管理全部资料;现有 `admin` 账号保持此角色。
- **一家之主**:本家庭一位已启用的家庭管理员,由超级管理员首次指定。可管理本家庭设备、成员及其他家庭管理员,并办理家主交接。
- **家庭管理员**:管理本家庭设备、空间、家谱与普通成员;有家主后,其他管理员账号的设置由家主或超级管理员办理。
- **家庭成员**:仅能查看本家庭已授权空间的画面与录像;家谱、云台另行授权。新账号默认不获得设备和家谱权限。亲属称呼不授予权限。
在「家庭与场所」新建家庭、办公场所或工厂,进入该家庭,在「账号与关系」建立家庭管理员。回到「家庭与场所 → 负责人安排与交接」,指定一家之主和另一位备用负责人,填写说明并保存。**备用负责人已经拥有本家庭管理权限**,家主意外无法操作时可以继续管理设备和成员;家主身份由原家主或超级管理员正式交接,原家主保留家庭管理员权限。不会依据去世日期或未登录时长自动授权。不允许停用或降级尚在任的家主、备用负责人;须先更换安排。至少保留一位启用的家庭管理员。交接使用修订号防止并发覆盖,并保留最近 50 条记录。普通成员不能读取交接说明。
家庭隔离由服务器执行:账号、空间、对象、镜头、录像机、家谱人物、关系、事件与历史版本均有家庭归属。HLS 播放清单、分片、回放、下载及设备控制均逐请求核验;修改请求检查每个关联对象的家庭。普通账号伪造家庭请求头或猜测其他家庭的对象编号也不能获得资料。超级管理员具有跨家庭权限。家庭切换先清除播放器、表单和旧请求;各家庭独立计算家谱修订号。
升级前用 SQLite backup API 备份数据库。首次启动把旧数据归入「赵府」家庭,保留原 ID、密码散列、设备连接和会话;家主与备用负责人须在界面明确指定,不从姓名或辈分猜测。已有 `admin` 角色均保留超级权限,请按实际职责设为家庭管理员。**建立第二个家庭后,不能直接回退到 v0.1.22 或更早版本**,旧版本不执行家庭隔离;应修复前进,或连同升级前数据库及部署版本一起恢复并确保后续数据另有备份。
这是单台受信主机上的应用权限隔离。各家庭的设备仍共享媒体服务和存储容量,家庭管理员不具有操作系统权限;尚未提供按家庭磁盘配额、独立网络、硬盘加密或无人审批的自动继承。
### 多维设备分类与区域树(v0.1.23)
- 在「家庭与场所」选择管理归属,再在「区域与空间」添加区域;选择上级区域即可继续细分,例如「厂区 → 车间 → 产线 → 工位」。名称、区域类别、地址按需填写,程序没有固定层数上限。不能把区域放到自身或后代下,也不能跨家庭/场所挂接。
- 在「设备管理」登记设备。摄像机与录像机保留已有接入、通道功能,其他设备使用可自定的类型;同一设备只存一份档案。所有人、负责人是责任信息,不会自动授予账号查看或控制权限。
- 调整「依次分组」,可保存「类型 → 区域」「区域 → 类型」「负责人 → 类型 → 区域」等顺序为当前归属的默认视图。支持五种维度自由组合:类型、区域、负责人、所有人、标签。区域维度按完整自定义层级展示;五种维度不限制区域本身层数。设备可在多个标签组出现,总设备数仍按档案计算。
- 区域筛选默认包含其后代。账号权限另行设置:默认只授权明确勾选的区域,可显式勾选「包含所有下级区域(含以后新增)」。服务器实时计算后代集合;移动区域会立即改变其继承的访问范围。跨家庭/场所隔离始终生效。
- 灯、手机、冰箱等目前支持档案、位置、责任归属及标签管理,不能通过登记档案直接控制硬件。需按设备实际协议接入控制功能。
## 智能数字管家的入口与传承档案(v0.1.24)
系统从「我的家庭与场所」开始。进入一个家庭、办公室或工厂后,左侧才显示该归属下的「场所总览」「区域与设备」「成员与权限」「区域设置」。家庭另有「家庭传承」。平台存储与审计仅向超级管理员开放。
### 从设备进入功能
在「区域与设备」点击设备名称:摄像头内部提供「设备档案 / 实时画面 / 录像回放 / 镜头设置」;实时画面与回放仅列出该摄像头的已授权镜头。普通成员没有镜头设置入口。录像机同样作为设备存在,展示明确关联的通道;通用设备先提供档案。场所总览的「本场所监控墙」可集中查看本场所已授权镜头,分屏与顺序按账号、归属及设备分别保存。
切换设备会释放之前的播放与云台会话;切换归属会清空私有视图并使旧请求失效。新版入口不自动拉取所有摄像头视频。原始摄像机接入参数、媒体配置和录像策略保持原值。
### 家庭传承
- 「人物与家谱」打开关系图,「家庭记事」直接进入时间线。
- 「文化与家风」保存故事、家训、手艺、整理人、日期、正文及资料链接。
- 「家庭物品与财产档案」保存来历、所有人或共有说明、保管人、意向接收人和交接说明,可关联本家庭已有设备。
- 档案支持搜索、查看详情、编辑、归档和恢复。编辑时检查修订号,拒绝用旧表单覆盖新内容。资料按家庭隔离;仅保存文字及链接,不上传或备份链接目标文件。
- 在「成员与权限」独立设置「文化与财产档案权限」为不开放、查看或编辑。家庭管理员管理本家庭全部档案;普通成员已有的家谱权限不会自动开放财产资料。改变该权限会撤销该账号现有会话。
交接说明只是记录意愿,不自动改变财产所有权、设备责任人、账号角色或家主安排。实际系统权限交接继续走「我的家庭与场所 → 负责人安排与交接」。当前非超级管理员账号仍只属于一个家庭或场所,多归属成员身份尚未实现。
升级幂等创建 `heritage_entries` 表,不自动修改现有账号、设备或家谱。未新增授权的普通成员默认无法读取此表。仍需在升级前备份数据库。录像机历史录像适配、通用设备实际控制、物品正式交接流程尚待后续实现;本版不宣称解决摄像机源流全部卡顿问题。
## 统一发布配置与个人后台(v0.1.25)
“设置中心”提供两层设置:
- **我的后台**:每个账号独立选择庭院书卷、清爽明亮或静夜墨色主题,左侧/顶部目录、疏密、山水/纯色背景、标题字体及登录首页。保存至服务器后跨设备沿用;未保存预览在离开设置时撤销。“跟随平台默认”会使用管理员的当前默认值。
- **发布与品牌配置**:仅超级管理员可改系统名称、副标题、题词、登录文案、页脚、Logo 与浏览器图标。Logo、favicon 可分别上传或恢复默认。导出/导入仅包含品牌和默认外观,不包含账号、家庭资料、设备凭据或备份目录;导入先填入表单,核对后才保存。
家庭/场所档案增加省份、地级市、区县及封面照片。卡片按省份和地级市归组,没有填写的归入“未填写地级市”。分组不改变家庭授权。封面原图最多 5 MB,浏览器转成最长边 960 像素以内的 PNG,必要时继续缩小;Logo 最长边 256,favicon 96。图片保存在数据库内;家庭封面接口须登录并拥有该归属权限。无照片时显示默认文字封面。
## 数据库自动备份(v0.1.25)
超级管理员进入“设置中心 → 数据库备份”,可立即备份,或启用按北京时间执行的每日计划。默认关闭计划、网络目录留空,每个目录保留本实例最近 14 份,可设置 1–90 份。
1. SQLite online backup API 生成一致性副本,不停止视频服务;副本完整性检查后移除会话,计算 SHA-256 并以临时文件原子改名完成本机保存。
2. 本机目录为 `VISION_DATA/managed-backups`。副本包含所有家庭、密码散列、设备连接凭据、家谱、记事、照片和设置;**不包含录像文件,未对副本进行额外加密**,按敏感数据保管。恢复后须重新登录。
3. 网络目录必须是服务器已经挂载且服务账号可写的绝对目录。默认 systemd 服务只允许写 `/var/lib/zhaovision`,建议把网络盘挂载在其下的 `network-backup` 目录。服务启动后新增挂载需确认该挂载在服务的文件系统视图中可见;必要时重启应用服务。
4. 本版不内置百度网盘登录/API。百度网页地址或分享链接不能作为备份路径;需要主机已有可用的网络挂载/同步服务。识别受支持的网络文件系统并确认网络目录副本字节校验一致,**不能证明百度云端已同步完成**。只有普通本机同步目录而无挂载时,可让外部同步程序读取本机备份目录,其云端状态由外部程序负责。
5. 网络盘不存在或没有检测到受支持的网络挂载时明确报错;本机成功副本仍保留。本机保留策略独立执行,网络失败不会让本机备份无限累积。只清理本实例命名的旧副本,不清理目录内其他文件。同一时间只运行一次,计划每天最多尝试一次,失败可手动重试。
6. 数据库恢复由主机管理员进行:停止 `zhaovision` 和 `zhaovision-media`,先保存当前完整数据目录;校验选定副本的 SHA-256 与 SQLite integrity_check,将它恢复为 `/var/lib/zhaovision/vision.db` 并正确处理旧数据库的 WAL/SHM 文件,修正服务账号所有权,再启动服务。选用不早于副本生成版本的程序;数据库副本不会恢复录像文件或 `/etc/zhaovision.env`、Tailscale/反向代理配置。
### v0.1.26 备份目录识别修正
Linux 按目标目录最近一层挂载识别网络文件系统,支持 NFS、SMB、WebDAV、SSHFS、rclone、s3fs/goofys;本机 ext4、tmpfs 等不作为网络目标。Windows 可使用已连接的 UNC 共享目录。网络类型校验不代替云服务同步状态确认。
+1 -1
View File
@@ -1 +1 @@
0.1.22
0.1.27
+127 -28
View File
@@ -31,6 +31,11 @@ import onvif
import playback
import identity
import genealogy
import households
import inventory
import heritage
import appearance
import backups
import ptz
ROOT = Path(__file__).resolve().parent
@@ -73,6 +78,11 @@ class Problem(Exception):
ACCOUNTS = identity.Accounts(sys.modules[__name__])
FAMILY = genealogy.Family(sys.modules[__name__])
HOUSEHOLDS = households.Households(sys.modules[__name__])
INVENTORY = inventory.Inventory(sys.modules[__name__])
HERITAGE = heritage.Heritage(sys.modules[__name__])
APPEARANCE = appearance.Appearance(sys.modules[__name__])
BACKUPS = backups.Backups(sys.modules[__name__])
def setting(key, default=None):
@@ -144,9 +154,9 @@ SITE_FIELDS = ('name', 'province', 'city', 'district', 'street', 'community', 'o
def serialized_save(function):
@functools.wraps(function)
def save(body):
def save(body, *args, **kwargs):
with LOCK:
return function(body)
return function(body, *args, **kwargs)
return save
@@ -162,20 +172,30 @@ def object_key(table, body):
@serialized_save
def save_site(body):
def save_site(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'sites', body, (('sites', 'parentId'),))
key = object_key('sites', body)
parent = clean_text(body.get('parentId', ''), 32)
ancestor, seen = parent, {key}
while ancestor:
if ancestor in seen:
raise Problem('区域不能放到自己或自己的下级区域中')
seen.add(ancestor)
ancestor = (get_object('sites', ancestor) or {}).get('parentId', '')
item = {k: clean_text(body.get(k, ''), 240 if k in ('address', 'note') else 100,
required=k == 'name') for k in SITE_FIELDS}
item.update(id=key, kind=body.get('kind', 'home'))
if item['kind'] not in ('home', 'company', 'other'):
raise Problem('请选择家庭、公司或其他用途')
item.update(id=key, kind=body.get('kind', 'home'), familyId=family, parentId=parent,
category=clean_text(body.get('category', ''), 40))
if item['kind'] not in ('home', 'company', 'office', 'factory', 'other'):
raise Problem('请选择有效区域用途')
save_object('sites', item)
audit('保存空间', item['name'])
return item
@serialized_save
def save_camera(body):
def save_camera(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'cameras', body, (('assets', 'assetId'), ('recorders', 'recorderId'), ('recorders', 'archiveRecorderId')))
key = object_key('cameras', body)
old = get_object('cameras', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
@@ -201,7 +221,8 @@ def save_camera(body):
raise Problem('关联的录像存储设备不存在')
item['archiveRecorderId'] = archive_recorder
item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')),
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535))
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535), familyId=family)
HOUSEHOLDS.endpoint(actor, item['host'])
for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]:
path = clean_text(body.get(k, default), 500, required=True)
if not path.startswith('/') or path.startswith('//') or '#' in path:
@@ -224,14 +245,16 @@ def save_camera(body):
@serialized_save
def save_asset(body):
def save_asset(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'assets', body, (('sites', 'siteId'),))
key = object_key('assets', body)
site = body.get('siteId')
if not get_object('sites', site):
raise Problem('请选择所属空间')
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'brand', 'model', 'serial', 'point', 'note')}
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16))
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16), familyId=family)
item.update(INVENTORY.metadata(body, get_object('assets', key)))
attached = [c for c in objects('cameras') if c.get('assetId') == key]
if len(attached) > item['lensCount']:
raise Problem('镜头数不能小于已经建立的通道数')
@@ -245,7 +268,8 @@ def save_asset(body):
@serialized_save
def save_recorder(body):
def save_recorder(body, actor=None):
family = HOUSEHOLDS.prepare(actor, 'recorders', body, (('sites', 'siteId'),))
key = object_key('recorders', body)
old = get_object('recorders', key) or {}
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
@@ -254,13 +278,15 @@ def save_recorder(body):
if not get_object('sites', site):
raise Problem('请选择录像机所属空间')
item.update(id=key, siteId=site, host=host_address(body.get('host', '')),
port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'))
port=integer(body.get('port', 554), 1, 65535), driver=body.get('driver', 'generic-rtsp'), familyId=family)
HOUSEHOLDS.endpoint(actor, item['host'])
if item['driver'] not in ('generic-rtsp', 'hikvision-rtsp', 'tplink-rtsp', 'dahua-rtsp'):
raise Problem('接入驱动不正确')
password = body.get('password', '')
if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password):
raise Problem('密码格式不正确')
item['password'] = password or old.get('password', '')
item.update(INVENTORY.metadata(body, old))
save_object('recorders', item)
write_media_config()
audit('保存录像机', item['name'])
@@ -403,7 +429,7 @@ def status():
storage = dict(STORAGE)
scan = dict(SCAN)
return {'cameras': cameras, 'assets': objects('assets'), 'recorders': [public_camera(r) for r in objects('recorders')],
'sites': objects('sites'), 'mediaOnline': media,
'sites': objects('sites'), 'devices': objects('devices'), 'mediaOnline': media,
'storage': {**setting('storage'), **storage}, 'scan': scan,
'version': (ROOT / 'VERSION').read_text().strip(), 'uptime': round(time.time() - STARTED)}
@@ -493,6 +519,15 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
def image(self, raw, public=False):
self.send_response(200)
self.send_header('Content-Type', 'image/png')
self.send_header('Content-Length', str(len(raw)))
self.send_header('Cache-Control', 'no-cache' if public else 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.end_headers()
self.wfile.write(raw)
def token(self):
try:
c = http.cookies.SimpleCookie(self.headers.get('Cookie', ''))
@@ -514,6 +549,11 @@ class Handler(BaseHTTPRequestHandler):
self.user = self.authenticated()
if not self.user:
raise Problem('请先登录', 401)
self.user = HOUSEHOLDS.context(self.user, self.headers.get('X-Household-Id'))
def require_manager(self):
if not HOUSEHOLDS.manager(self.user):
raise Problem('此操作需要家庭管理员权限', 403)
def require_admin(self):
if self.user['role'] != 'admin':
@@ -590,20 +630,31 @@ class Handler(BaseHTTPRequestHandler):
try:
path = url.urlsplit(self.path).path
# 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB.
data = self.body(131072 if path == '/api/family/event' else 16384)
data = self.body(3*1024*1024 if path in ('/api/appearance','/api/households') else 131072 if path in ('/api/family/event','/api/heritage') else 16384)
self.validate_origin()
if path in ('/api/login', '/api/setup'):
self.login(data, path == '/api/setup')
return
self.require_auth()
if path not in ('/api/logout', '/api/account/password', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/ptz', '/api/ptz/presets'):
self.require_admin()
if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets'):
self.require_manager()
if path == '/api/logout':
with LOCK:
DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),))
DB.commit()
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
elif path == '/api/preferences':
self.answer(APPEARANCE.save_preferences(data, self.user))
elif path == '/api/appearance':
self.require_admin()
self.answer(APPEARANCE.save_config(data, self.user))
elif path == '/api/backups/config':
self.require_admin()
self.answer(BACKUPS.save(data))
elif path == '/api/backups/run':
self.require_admin()
self.answer(BACKUPS.start())
elif path == '/api/account/password':
result = ACCOUNTS.change_password(data, self.user)
self.answer(result, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
@@ -622,7 +673,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == '/api/ptz/presets':
c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera')))
if data.get('action') != 'goto':
self.require_admin()
self.require_manager()
try:
result = ptz.CONTROLLER.preset_command(c, data)
except ValueError as e:
@@ -643,19 +694,32 @@ class Handler(BaseHTTPRequestHandler):
self.answer(result)
elif path == '/api/users':
self.answer(ACCOUNTS.save(data, self.user))
elif path == '/api/households':
self.answer(HOUSEHOLDS.save(data, self.user))
elif path == '/api/households/leadership':
self.answer(HOUSEHOLDS.leadership(data, self.user))
elif path == '/api/device-view':
self.answer(HOUSEHOLDS.save_view(data, self.user))
elif path == '/api/heritage':
self.answer(HERITAGE.save(data, self.user))
elif path == '/api/devices':
self.answer(INVENTORY.save(data, self.user))
elif path in ('/api/family/person', '/api/family/link', '/api/family/event'):
self.answer(FAMILY.save(path.rsplit('/', 1)[-1], data, self.user))
elif path == '/api/sites':
self.answer(save_site(data))
self.answer(save_site(data, self.user))
elif path == '/api/cameras':
self.answer(save_camera(data))
self.answer(save_camera(data, self.user))
elif path == '/api/assets':
self.answer(save_asset(data))
self.answer(save_asset(data, self.user))
elif path == '/api/recorders':
self.answer(save_recorder(data))
self.answer(save_recorder(data, self.user))
elif path == '/api/onvif':
old = get_object('cameras', data.get('id')) or {}
if data.get('id'):
HOUSEHOLDS.owns(self.user, old)
host = host_address(data.get('host', ''))
HOUSEHOLDS.endpoint(self.user, host)
port = integer(data.get('port', 80), 1, 65535)
username = clean_text(data.get('username', ''), 120, required=True)
password = data.get('password') or old.get('password', '')
@@ -667,6 +731,7 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('ONVIF 读取未成功,请检查账号、设备时间、服务端口及 ONVIF 是否启用', 502)
self.answer(result)
elif path == '/api/storage':
self.require_admin()
policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365),
'maxGB': integer(data.get('maxGB'), 5, 100000),
'reserveGB': integer(data.get('reserveGB'), 2, 1000)}
@@ -675,6 +740,7 @@ class Handler(BaseHTTPRequestHandler):
audit('更新录像保留策略')
self.answer({'ok': True})
elif path == '/api/discover':
self.require_admin()
try:
network = ipaddress.ip_network(data.get('network', ''), strict=False)
if network.version != 4 or network.prefixlen < 24 or network.prefixlen > 30:
@@ -775,10 +841,19 @@ class Handler(BaseHTTPRequestHandler):
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/branding':
self.answer(APPEARANCE.public())
return
if path in ('/brand-logo.png', '/brand-icon.png'):
value = APPEARANCE.config().get('logoData' if path == '/brand-logo.png' else 'iconData', '')
raw = base64.b64decode(value.split(',', 1)[1]) if value else (ROOT/'web/zhao-icon.png').read_bytes()
self.image(raw, public=True)
return
if path == '/api/session':
user = self.authenticated()
user = HOUSEHOLDS.context(user, self.headers.get('X-Household-Id'))
self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user),
'setupRequired': not bool(objects('users'))})
'setupRequired': not bool(objects('users')), 'preferences': APPEARANCE.preferences(user) if user else None})
return
if path == '/healthz':
self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()})
@@ -787,9 +862,28 @@ class Handler(BaseHTTPRequestHandler):
self.require_auth()
if path == '/api/state':
self.answer(ACCOUNTS.state(self.user, status()))
elif path == '/api/users':
elif path == '/api/preferences':
self.answer(APPEARANCE.preferences(self.user))
elif path == '/api/appearance':
self.require_admin()
self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]})
self.answer(APPEARANCE.config())
elif path == '/api/backups':
self.require_admin()
self.answer(BACKUPS.status())
elif path == '/api/households/cover':
home = get_object('households', query.get('id'))
if not home or self.user['role'] != 'admin' and home['id'] != HOUSEHOLDS.scope(self.user):
raise Problem('封面不存在或未获授权', 404)
if not home.get('coverData'): raise Problem('尚未上传封面', 404)
self.image(base64.b64decode(home['coverData'].split(',', 1)[1]))
elif path == '/api/users':
self.require_manager()
self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users')
if self.user['role'] == 'admin' or u['role'] != 'admin']})
elif path == '/api/heritage':
self.answer(HERITAGE.snapshot(self.user))
elif path == '/api/households':
self.answer({'households': HOUSEHOLDS.list(self.user), 'activeId': HOUSEHOLDS.scope(self.user)})
elif path == '/api/ptz/presets':
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
try:
@@ -798,7 +892,7 @@ class Handler(BaseHTTPRequestHandler):
raise Problem(str(e), 400)
except Exception:
raise Problem('未能读取预置位,设备可能未开放该接口;方向微调仍可单独使用', 502)
self.answer(dict(result, canManage=self.user['role'] == 'admin'))
self.answer(dict(result, canManage=HOUSEHOLDS.manager(self.user)))
elif path == '/api/ptz':
c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera')))
try:
@@ -811,7 +905,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == '/api/family':
self.answer(FAMILY.snapshot(self.user))
elif path == '/api/camera/settings':
self.require_admin()
self.require_manager()
c = ACCOUNTS.camera(self.user, get_object('cameras', query.get('camera')))
try:
result, _ = camera_settings.CONTROLLER.read(c, query.get('quality', 'main'))
@@ -873,7 +967,9 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('接口不存在', 404)
else:
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js', '/personalization.js': 'personalization.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js',
@@ -911,6 +1007,8 @@ def initialize():
global MEDIA_AUTH
FAMILY.initialize()
ACCOUNTS.initialize()
HOUSEHOLDS.initialize()
HERITAGE.initialize()
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
if not setting('mediaSecret'):
@@ -926,6 +1024,7 @@ if __name__ == '__main__':
initialize()
threading.Thread(target=storage_watch, daemon=True).start()
threading.Thread(target=media_config_watch, daemon=True).start()
threading.Thread(target=BACKUPS.watch, daemon=True).start()
bindings = [h.strip() for h in os.environ.get('VISION_BIND', '127.0.0.1').split(',') if h.strip()]
port = int(os.environ.get('VISION_PORT', '8790'))
servers = []
+152
View File
@@ -0,0 +1,152 @@
"""Public installation branding and private, account-scoped presentation preferences."""
import base64
import binascii
import copy
import struct
import zlib
CHOICES = {
'theme': ('paper', 'clean', 'ink'),
'layout': ('sidebar', 'top'),
'density': ('comfortable', 'compact'),
'backdrop': ('mountains', 'plain'),
'font': ('serif', 'sans'),
'startPage': ('households', 'overview', 'objects', 'heritage'),
}
DEFAULTS = dict(theme='paper', layout='sidebar', density='comfortable', backdrop='mountains',
font='serif', startPage='households')
BRAND = dict(name='赵府', subtitle='智能数字管家', motto='赵氏家宅 · 起居有序',
loginTitle='欢迎回来', loginNote='输入用户名和密码,进入家庭空间。',
footer='赵府 · 智能数字管家', logoData='', iconData='', defaults=DEFAULTS, revision=0)
TEXT_LIMITS = dict(name=40, subtitle=60, motto=100, loginTitle=40, loginNote=160, footer=120)
def png_bytes(value, maximum=256*1024):
"""Only bounded, canonical 8-bit RGB/RGBA PNGs; never accept SVG or external URLs."""
if not isinstance(value, str) or not value.startswith('data:image/png;base64,'):
raise ValueError('Logo 格式不正确,请通过上传按钮选择图片')
try:
raw = base64.b64decode(value.split(',', 1)[1], validate=True)
except (ValueError, binascii.Error):
raise ValueError('Logo 编码不正确')
if len(raw) > maximum or not raw.startswith(b'\x89PNG\r\n\x1a\n'):
raise ValueError(f'图片须为 {maximum//1024} KB 以内的 PNG 图片')
offset, header, compressed, ended = 8, None, bytearray(), False
while offset < len(raw):
if offset + 12 > len(raw):
raise ValueError('Logo 图片不完整')
size = struct.unpack('>I', raw[offset:offset+4])[0]
kind = raw[offset+4:offset+8]
if offset + size + 12 > len(raw):
raise ValueError('Logo 图片不完整')
data = raw[offset+8:offset+8+size]
crc = struct.unpack('>I', raw[offset+8+size:offset+12+size])[0]
if zlib.crc32(kind+data) != crc:
raise ValueError('Logo 图片校验失败')
if header is None:
if kind != b'IHDR' or size != 13:
raise ValueError('Logo 图片头不正确')
w, h, bits, color, compression, filter_type, interlace = struct.unpack('>IIBBBBB', data)
if not (1 <= w <= 1024 and 1 <= h <= 1024 and bits == 8 and color in (2, 6)
and compression == filter_type == interlace == 0):
raise ValueError('Logo 图片须经上传按钮转换,最长边不超过 1024 像素')
header = (w, h, 4 if color == 6 else 3)
elif kind == b'IHDR':
raise ValueError('Logo 图片头重复')
elif kind == b'IDAT':
compressed.extend(data)
elif kind == b'IEND':
ended = size == 0 and offset + 12 == len(raw)
break
elif kind[:1].isupper() and kind != b'PLTE':
raise ValueError('Logo 图片包含不支持的数据')
offset += size + 12
if not header or not ended or not compressed:
raise ValueError('Logo 图片不完整')
w, h, channels = header
expected = h * (1 + w * channels)
try:
decoder = zlib.decompressobj()
pixels = decoder.decompress(compressed, expected+1)
if not decoder.eof or decoder.unused_data or len(pixels) != expected:
raise ValueError()
if any(pixels[row*(1+w*channels)] > 4 for row in range(h)):
raise ValueError()
except (ValueError, zlib.error):
raise ValueError('Logo 图片像素数据不正确')
return raw
class Appearance:
def __init__(self, app):
self.a = app
def config(self):
return self.a.setting('appearance', copy.deepcopy(BRAND))
def public(self):
value = self.config()
return {**{k: value[k] for k in TEXT_LIMITS}, 'defaults': value['defaults'],
'revision': value['revision'],
'logoUrl': '/brand-logo.png?v='+str(value['revision']) if value.get('logoData') else '/zhao-icon.png',
'iconUrl': '/brand-icon.png?v='+str(value['revision']) if value.get('iconData') else '/zhao-icon.png'}
def validate_values(self, values, inherit=False):
if not isinstance(values, dict) or set(values) != set(CHOICES):
raise self.a.Problem('外观选项不完整,请重新读取设置')
if any(not isinstance(v, str) or v not in CHOICES[k]+(('default',) if inherit else ()) for k, v in values.items()):
raise self.a.Problem('外观选项不正确')
return dict(values)
def check_revision(self, data, previous):
revision = data.get('revision')
if type(revision) is not int or revision != previous.get('revision', 0):
raise self.a.Problem('设置已变化,请重新读取后保存', 409)
def save_config(self, data, actor):
a = self.a
if actor['role'] != 'admin':
raise a.Problem('发布配置仅限超级管理员修改', 403)
if set(data) - (set(TEXT_LIMITS) | {'logoData', 'iconData', 'defaults', 'revision'}):
raise a.Problem('发布配置包含未知字段')
with a.LOCK:
old = self.config()
self.check_revision(data, old)
fields = {k: a.clean_text(data.get(k, ''), limit, k in ('name', 'subtitle', 'loginTitle'))
for k, limit in TEXT_LIMITS.items()}
defaults = self.validate_values(data.get('defaults'))
logo = data.get('logoData', old.get('logoData', ''))
if not isinstance(logo, str):
raise a.Problem('Logo 格式不正确')
if logo:
try:
png_bytes(logo)
except ValueError as error:
raise a.Problem(str(error))
icon = data.get('iconData', old.get('iconData', ''))
if not isinstance(icon, str):
raise a.Problem('图标格式不正确')
if icon:
try: png_bytes(icon)
except ValueError as error: raise a.Problem(str(error))
result = dict(fields, defaults=defaults, logoData=logo, iconData=icon, revision=old['revision']+1)
a.set_setting('appearance', result)
a.audit('更新发布与品牌配置', actor['username'])
return result
def preferences(self, actor):
saved = self.a.setting('preferences:'+actor['id'], {'revision': 0, 'values': dict.fromkeys(CHOICES, 'default')})
defaults = self.config()['defaults']
effective = {k: defaults[k] if v == 'default' else v for k, v in saved['values'].items()}
return dict(saved, defaults=defaults, effective=effective)
def save_preferences(self, data, actor):
if set(data) != {'revision', 'values'}:
raise self.a.Problem('只能修改当前账号的外观偏好')
with self.a.LOCK:
old = self.preferences(actor)
self.check_revision(data, old)
values = self.validate_values(data.get('values'), inherit=True)
self.a.set_setting('preferences:'+actor['id'], dict(revision=old['revision']+1, values=values))
return self.preferences(actor)
+176
View File
@@ -0,0 +1,176 @@
"""Online SQLite snapshots with optional copy to a pre-mounted network directory."""
import datetime as dt
from contextlib import closing
import hashlib
import os
from pathlib import Path, PurePosixPath
import re
import secrets
import shutil
import sqlite3
import threading
import time
class Backups:
def __init__(self, app):
self.a = app
self.guard = threading.Lock()
self.running = False
def config(self):
return self.a.setting('backup_config', dict(enabled=False, time='03:00', keep=14, networkPath='', revision=0))
def status(self):
return dict(config=self.config(), running=self.running,
localPath=str(self.a.DATA/'managed-backups'),
last=self.a.setting('backup_last', None),
history=self.a.setting('backup_history', [])[-20:][::-1])
@staticmethod
def network_mount(folder):
if os.name == 'nt':
return folder.drive.startswith('\\\\')
try:
records = Path('/proc/self/mountinfo').read_text().splitlines()
except OSError:
return False
return Backups.linux_network_mount(PurePosixPath(str(folder)), records)
@staticmethod
def linux_network_mount(folder, records):
matches = []
for line in records:
left, separator, right = line.partition(' - ')
fields, detail = left.split(), right.split()
if not separator or len(fields) < 5 or not detail:
continue
mount = PurePosixPath(re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), fields[4]))
if folder.is_relative_to(mount):
matches.append((len(mount.parts), detail[0]))
# The nearest mount wins: a tmpfs inside an NFS share is still local.
filesystem = max(matches, default=(0, ''), key=lambda x:x[0])[1]
return filesystem in {'cifs', 'smb3', 'nfs', 'nfs4', 'davfs',
'fuse.sshfs', 'fuse.rclone', 'fuse.davfs', 'fuse.davfs2',
'fuse.wdfs', 'fuse.s3fs', 'fuse.goofys'}
def network_folder(self, text):
if not text:
return None
if not isinstance(text, str) or len(text) > 500 or any(ord(c) < 32 for c in text) or '://' in text:
raise self.a.Problem('请填写服务器已挂载的绝对目录,不能填写网页或分享链接')
folder = Path(text)
if not folder.is_absolute():
raise self.a.Problem('备份路径须为服务器上的绝对目录')
folder = folder.resolve()
if not folder.is_dir():
raise self.a.Problem('备份目录不存在,请先在服务器挂载网络存储并授予服务账号写入权限')
# A disconnected mount must not silently become a local-only cloud backup.
if not self.network_mount(folder):
raise self.a.Problem('该目录未检测到支持的网络挂载,请先连接 NFS、SMB、WebDAV 或 rclone 网络盘')
if folder == (self.a.DATA/'managed-backups').resolve():
raise self.a.Problem('网络副本与本机备份目录不能相同')
return folder
def save(self, data):
if set(data) != {'enabled','time','keep','networkPath','revision'}:
raise self.a.Problem('备份设置字段不完整')
with self.guard:
if self.running:
raise self.a.Problem('备份正在执行,请完成后再调整设置', 409)
with self.a.LOCK:
old = self.config()
if type(data['revision']) is not int or data['revision'] != old['revision']:
raise self.a.Problem('设置已变化,请重新读取后保存', 409)
if not isinstance(data['enabled'], bool) or not isinstance(data['time'], str) or not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', data['time']):
raise self.a.Problem('请选择有效的每日备份时间')
self.a.integer(data['keep'], 1, 90)
if not isinstance(data['networkPath'], str):
raise self.a.Problem('备份目录格式不正确')
self.network_folder(data['networkPath'])
self.a.set_setting('backup_config', dict(data, revision=old['revision']+1))
return self.status()
def start(self, reason='manual'):
with self.guard:
if self.running:
raise self.a.Problem('已有备份正在执行', 409)
self.running = True
threading.Thread(target=self.run, args=(reason,), daemon=True).start()
return dict(started=True)
def prune(self, folder, prefix, keep):
owned = [p for p in folder.glob(prefix+'*.sqlite') if p.is_file() and not p.is_symlink()]
for path in sorted(owned, key=lambda p:p.name, reverse=True)[keep:]:
path.unlink()
def run(self, reason='manual'):
a = self.a
config = self.config()
result = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), reason=reason,
localOk=False, networkOk=False, networkRequested=bool(config['networkPath']))
temporary = network_temp = None
try:
local = a.DATA/'managed-backups'
local.mkdir(mode=0o700, exist_ok=True)
instance = a.setting('backup_instance')
if not instance:
instance = secrets.token_hex(8); a.set_setting('backup_instance', instance)
prefix = 'vision-'+instance+'-'
name = prefix+dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')+'.sqlite'
target = local/name
temporary = local/(name+'.partial')
# Separate read connection avoids holding the live write lock during IO.
with closing(sqlite3.connect(a.DATA/'vision.db', timeout=10)) as source, closing(sqlite3.connect(temporary)) as dest:
source.backup(dest, pages=256, sleep=0.01)
if dest.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
raise RuntimeError('数据库副本完整性检查失败')
dest.execute('DELETE FROM sessions'); dest.commit()
os.chmod(temporary, 0o600)
with temporary.open('rb') as f:
digest = hashlib.file_digest(f, 'sha256').hexdigest()
os.replace(temporary, target)
result.update(localOk=True, file=name, bytes=target.stat().st_size, sha256=digest)
self.prune(local, prefix, config['keep'])
if config['networkPath']:
network = self.network_folder(config['networkPath'])
network_temp = network/(name+'.partial')
shutil.copyfile(target, network_temp)
os.chmod(network_temp, 0o600)
with network_temp.open('rb') as f:
if hashlib.file_digest(f, 'sha256').hexdigest() != digest:
raise RuntimeError('网络副本校验不一致')
os.replace(network_temp, network/name)
result['networkOk'] = True
self.prune(network, prefix, config['keep'])
except Exception as error:
result['error'] = str(error)[:300] or '备份未完成'
finally:
for temporary_path in (temporary, network_temp):
try:
if temporary_path and temporary_path.exists(): temporary_path.unlink()
except OSError:
pass
with a.LOCK:
a.set_setting('backup_last', result)
history = a.setting('backup_history', [])
a.set_setting('backup_history', (history+[result])[-100:])
self.running = False
return result
def tick(self, now=None):
now = now or dt.datetime.now(dt.timezone(dt.timedelta(hours=8)))
config = self.config(); day = now.strftime('%Y-%m-%d')
if not config['enabled'] or now.strftime('%H:%M') < config['time'] or self.running:
return False
if self.a.setting('backup_attempt_day') == day:
return False
self.a.set_setting('backup_attempt_day', day)
self.start('scheduled')
return True
def watch(self):
while True:
try: self.tick()
except Exception: pass
time.sleep(60)
+23 -11
View File
@@ -24,35 +24,44 @@ class Family:
self.a.DB.commit()
def authorize(self, user, edit=False):
if user['role'] != 'admin' and user.get('familyAccess', 'none') not in (('edit',) if edit else ('read', 'edit')):
self.a.HOUSEHOLDS.scope(user)
if not self.a.HOUSEHOLDS.manager(user) and user.get('familyAccess', 'none') not in (('edit',) if edit else ('read', 'edit')):
raise self.a.Problem('没有家谱' + ('编辑' if edit else '查看') + '权限', 403)
def snapshot(self, user):
self.authorize(user)
with self.a.LOCK:
can_edit = user['role'] == 'admin' or user.get('familyAccess') == 'edit'
return {'revision': self.a.setting('familyRevision', 0), 'people': self.a.objects('people'),
'links': self.a.objects('family_links'),
'events': [e for e in self.a.objects('family_events')
can_edit = self.a.HOUSEHOLDS.manager(user) or user.get('familyAccess') == 'edit'
people = self.a.HOUSEHOLDS.objects(user, 'people')
return {'revision': self.a.setting(self.a.HOUSEHOLDS.revision_key(user), 0), 'people': people,
'links': self.a.HOUSEHOLDS.objects(user, 'family_links'),
'events': [e for e in self.a.HOUSEHOLDS.objects(user, 'family_events')
if can_edit or (e.get('status') == 'published' and not e.get('archived'))],
'canEdit': can_edit,
'selfId': user.get('personId', '')}
'selfId': user.get('personId', '') if any(p['id'] == user.get('personId') for p in people) else '',
'familyId': self.a.HOUSEHOLDS.scope(user)}
def save(self, kind, data, user, restored_from=None):
self.authorize(user, True)
a = self.a
with a.LOCK:
revision = a.setting('familyRevision', 0)
revision_key = a.HOUSEHOLDS.revision_key(user)
revision = a.setting(revision_key, 0)
if type(data.get('revision')) is not int or data['revision'] != revision:
raise a.Problem('家谱已被更新,请重新载入后再编辑', 409)
if kind not in ('person', 'link', 'event'):
raise a.Problem('家谱记录类型不存在', 404)
table = {'person': 'people', 'link': 'family_links', 'event': 'family_events'}[kind]
family_id = a.HOUSEHOLDS.prepare(user, table, data,
(('people', 'fromId'), ('people', 'toId')) if kind == 'link' else ())
if kind == 'event' and isinstance(data.get('personIds', []), list):
for person in data.get('personIds', []):
a.HOUSEHOLDS.owns(user, a.get_object('people', person))
old = a.get_object(table, data.get('id'))
if data.get('id') and not old:
raise a.Problem('家谱记录不存在', 404)
item = {'person': self.person, 'link': self.link, 'event': self.event}[kind](data, old)
item.update(updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username'])
item.update(familyId=family_id, updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['username'])
if kind == 'event':
item.update(createdAt=old.get('createdAt', item['updatedAt']) if old else item['updatedAt'],
createdBy=old.get('createdBy', user['username']) if old else user['username'])
@@ -63,10 +72,11 @@ class Family:
item['publishedAt'] = item['updatedAt']
# Revision and record are committed together, so concurrent forms cannot overwrite silently.
with a.DB:
change_revision = a.DB.execute('SELECT COALESCE(MAX(revision),0)+1 FROM family_changes').fetchone()[0]
a.DB.execute('INSERT OR REPLACE INTO ' + table + ' VALUES (?,?)', (item['id'], json.dumps(item)))
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', ('familyRevision', json.dumps(revision + 1)))
a.DB.execute('INSERT OR REPLACE INTO settings VALUES (?,?)', (revision_key, json.dumps(revision + 1)))
a.DB.execute('INSERT INTO family_changes VALUES (?,?,?,?,?,?,?,?)',
(revision + 1, kind, item['id'], json.dumps(old) if old else None,
(change_revision, kind, item['id'], json.dumps(old) if old else None,
json.dumps(item), user['username'], item['updatedAt'], restored_from))
a.audit({'person': '更新家谱人物', 'link': '更新家谱关系', 'event': '更新家族记事'}[kind], user['username'])
return {'item': item, 'revision': revision + 1}
@@ -79,6 +89,7 @@ class Family:
with a.LOCK:
if not table or not a.get_object(table, target_id):
raise a.Problem('家谱记录不存在', 404)
a.HOUSEHOLDS.owns(user, a.get_object(table, target_id))
if before is not None:
a.integer(before, 1, 2**53 - 1)
rows = a.DB.execute('SELECT * FROM family_changes WHERE kind=? AND target_id=? AND revision<? '
@@ -91,7 +102,7 @@ class Family:
items.append(dict(revision=row['revision'], at=row['at'], actor=row['actor'], fields=fields,
created=row['before_json'] is None, restoredFrom=row['restored_from']))
return dict(items=items, nextBefore=items[-1]['revision'] if len(rows) > 20 else None,
revision=a.setting('familyRevision', 0))
revision=a.setting(a.HOUSEHOLDS.revision_key(user), 0))
def change(self, user, change_revision):
self.authorize(user, True)
@@ -100,6 +111,7 @@ class Family:
row = self.a.DB.execute('SELECT * FROM family_changes WHERE revision=?', (change_revision,)).fetchone()
if not row:
raise self.a.Problem('修改记录不存在', 404)
self.a.HOUSEHOLDS.owns(user, json.loads(row['after_json']))
return dict(revision=row['revision'], kind=row['kind'], targetId=row['target_id'],
before=json.loads(row['before_json']) if row['before_json'] else None,
after=json.loads(row['after_json']), at=row['at'], actor=row['actor'])
+78
View File
@@ -0,0 +1,78 @@
"""Scoped cultural and property records; records never transfer access or ownership."""
import datetime as dt
from urllib.parse import urlsplit
class Heritage:
def __init__(self, app):
self.a = app
def initialize(self):
with self.a.LOCK, self.a.DB:
self.a.DB.execute('CREATE TABLE IF NOT EXISTS heritage_entries (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
def require(self, user, edit=False):
a = self.a
home = a.get_object('households', a.HOUSEHOLDS.scope(user))
if home.get('kind', 'home') != 'home':
raise a.Problem('家庭传承资料属于家庭,请先进入对应家庭', 403)
if not a.HOUSEHOLDS.manager(user) and user.get('heritageAccess', 'none') not in (('edit',) if edit else ('read', 'edit')):
raise a.Problem('尚未获得文化与财产档案权限,请联系家庭管理员', 403)
def snapshot(self, user):
self.require(user)
return dict(entries=self.a.HOUSEHOLDS.objects(user, 'heritage_entries'),
canEdit=self.a.HOUSEHOLDS.manager(user) or user.get('heritageAccess') == 'edit')
def save(self, data, user):
a = self.a
with a.LOCK:
self.require(user, True)
family = a.HOUSEHOLDS.prepare(user, 'heritage_entries', data)
key = a.object_key('heritage_entries', data)
old = a.get_object('heritage_entries', key) or {}
if data.get('revision', 0) != old.get('revision', 0):
raise a.Problem('档案已被更新,请重新打开后再保存', 409)
kind = data.get('kind')
if kind not in ('culture', 'asset') or old and kind != old['kind']:
raise a.Problem('请选择文化资料或财产档案;已有档案不能变更类别')
archived = data.get('archived', False)
if not isinstance(archived, bool):
raise a.Problem('归档状态不正确')
name = a.clean_text(data.get('name', ''), 120, True)
date = a.clean_text(data.get('date', ''), 10)
if date:
try:
if dt.date.fromisoformat(date).isoformat() != date:
raise ValueError()
except ValueError:
raise a.Problem('日期格式不正确')
link = a.clean_text(data.get('sourceUrl', ''), 1500)
if link:
try:
parts = urlsplit(link)
if parts.scheme not in ('http', 'https') or not parts.hostname or parts.username or parts.password:
raise ValueError()
except ValueError:
raise a.Problem('资料链接须为不含账号密码的 HTTP 或 HTTPS 地址')
related = a.clean_text(data.get('deviceId', ''), 32)
source = data.get('deviceSource', '')
if related:
table = {'camera': 'assets', 'recorder': 'recorders', 'device': 'devices'}.get(source)
if not table:
raise a.Problem('关联设备类型不正确')
a.HOUSEHOLDS.owns(user, a.get_object(table, related))
# Ordinary record editors may reference only their own authorized areas.
device = a.get_object(table, related)
if not a.HOUSEHOLDS.manager(user) and device.get('siteId') not in a.HOUSEHOLDS.site_ids(user):
raise a.Problem('未获授权关联这台设备', 403)
fields = {k: a.clean_text(data.get(k, ''), 80) for k in ('ownerName', 'custodianName', 'successorName', 'contributor')}
item = dict(id=key, familyId=family, kind=kind, name=name, date=date, sourceUrl=link,
content=a.clean_text(data.get('content', ''), 8000),
handoverNote=a.clean_text(data.get('handoverNote', ''), 2000),
deviceId=related, deviceSource=source if related else '', archived=archived,
revision=old.get('revision', 0)+1, **fields,
updatedAt=dt.datetime.now(dt.timezone.utc).isoformat(), updatedBy=user['name'])
a.save_object('heritage_entries', item)
a.audit('保存传承资料', user['username']+' / '+kind)
return item
+198
View File
@@ -0,0 +1,198 @@
"""Household ownership and request scope. A site is a location within one household."""
import datetime as dt
import json
import re
import secrets
import hashlib
from appearance import png_bytes
class Households:
TABLES = ('sites', 'assets', 'recorders', 'cameras', 'devices', 'people', 'family_links', 'family_events', 'users')
def __init__(self, app):
self.a = app
@staticmethod
def manager(user):
return bool(user and user.get('role') in ('admin', 'family_admin'))
def initialize(self):
a = self.a
with a.LOCK, a.DB:
a.DB.execute('CREATE TABLE IF NOT EXISTS households (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
a.DB.execute('CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
default = a.setting('defaultHousehold')
if not default:
default = secrets.token_hex(8)
a.DB.execute('INSERT INTO settings VALUES (?,?)', ('defaultHousehold', json.dumps(default)))
item = dict(id=default, name='赵府', note='原有家庭资料', createdAt=dt.datetime.now(dt.timezone.utc).isoformat())
a.DB.execute('INSERT INTO households VALUES (?,?)', (default, json.dumps(item)))
# One-time ownership backfill preserves identifiers, credentials and device paths.
for table in self.TABLES:
for row in a.DB.execute('SELECT id,body FROM ' + table).fetchall():
item = json.loads(row['body'])
if 'familyId' not in item:
item['familyId'] = default
a.DB.execute('UPDATE ' + table + ' SET body=? WHERE id=?', (json.dumps(item), row['id']))
for row in a.DB.execute('SELECT revision,before_json,after_json FROM family_changes').fetchall():
for column in ('before_json', 'after_json'):
item = json.loads(row[column]) if row[column] else None
if item is not None and 'familyId' not in item:
item['familyId'] = default
a.DB.execute('UPDATE family_changes SET ' + column + '=? WHERE revision=?',
(json.dumps(item), row['revision']))
def family_of(self, item):
# Legacy in-process import helpers remain in the migrated default household.
return item.get('familyId', self.a.setting('defaultHousehold')) if item is not None else None
def scope(self, user):
family = (user or {}).get('_familyId') or self.family_of(user or {})
if not family or not self.a.get_object('households', family):
raise self.a.Problem('账号尚未分配有效家庭,请联系平台管理员', 403)
return family
def context(self, user, requested=None):
if not user:
return None
own = self.family_of(user)
if requested and user['role'] != 'admin' and requested != own:
raise self.a.Problem('没有这个家庭的访问权限', 403)
result = dict(user, _familyId=requested or own)
self.scope(result)
return result
def owns(self, user, item):
if not item or self.family_of(item) != self.scope(user):
raise self.a.Problem('记录不存在或不属于当前家庭', 404)
return item
def objects(self, user, table):
family = self.scope(user)
return [x for x in self.a.objects(table) if self.family_of(x) == family]
def site_ids(self, user):
"""Optional branch grants, always restricted to the account's own organization."""
sites = self.objects(user, 'sites')
ids = {s['id'] for s in sites if s['id'] in user.get('siteIds', [])}
if user.get('includeSubspaces', False):
children = {}
for site in sites:
children.setdefault(site.get('parentId'), []).append(site['id'])
queue = list(ids)
for parent in queue:
for child in children.get(parent, []):
if child not in ids:
ids.add(child)
queue.append(child)
return ids
def prepare(self, user, table, body, references=()):
family = self.scope(user)
if body.get('familyId', family) != family:
raise self.a.Problem('不能修改记录的家庭归属', 403)
if body.get('id'):
if not isinstance(body['id'], str) or not re.fullmatch('[a-f0-9]{16}', body['id']):
raise self.a.Problem('对象编号不正确')
self.owns(user, self.a.get_object(table, body['id']))
for target, key in references:
if body.get(key):
self.owns(user, self.a.get_object(target, body[key]))
return family
def endpoint(self, user, host):
family = self.scope(user)
if any(item.get('host') == host and self.family_of(item) != family
for table in ('cameras', 'recorders') for item in self.a.objects(table)):
raise self.a.Problem('该设备地址已归属其他家庭,不能重复接入', 403)
def list(self, user):
rows = self.a.objects('households') if user['role'] == 'admin' else [self.a.get_object('households', self.scope(user))]
def label(uid):
account = self.a.get_object('users', uid)
return account.get('name', '') if account else ''
return [dict({k: v for k, v in row.items() if k != 'coverData' and (k != 'leadershipHistory' or self.manager(user))}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')),
coverUrl=('/api/households/cover?id='+row['id']+'&v='+row.get('coverRevision','0')) if row.get('coverData') else '',
canAssignOwner=user['role'] == 'admin' or row.get('ownerId') == user['id'],
members=sum(self.family_of(u) == row['id'] for u in self.a.objects('users')),
sites=sum(self.family_of(s) == row['id'] for s in self.a.objects('sites'))) for row in rows]
def leadership(self, data, user):
"""Explicit handover; a backup already has management access during an emergency."""
a = self.a
with a.LOCK:
home = a.get_object('households', data.get('id'))
if not home or home['id'] != self.scope(user):
raise a.Problem('请先进入要管理的家庭', 404)
if user['role'] != 'admin' and user['id'] != home.get('ownerId'):
raise a.Problem('只有一家之主或超级管理员可以交接管理权', 403)
if data.get('revision') != home.get('leadershipRevision', 0):
raise a.Problem('家庭负责人已变更,请重新载入', 409)
owner_id = a.clean_text(data.get('ownerId', ''), 32, True)
successor_id = a.clean_text(data.get('successorId', ''), 32)
reason = a.clean_text(data.get('reason', ''), 300, True)
if owner_id == successor_id:
raise a.Problem('备用负责人必须是另一位家庭管理员')
for uid in filter(None, (owner_id, successor_id)):
account = self.owns(user, a.get_object('users', uid))
if account['role'] != 'family_admin' or account['disabled']:
raise a.Problem('负责人须为本家庭已启用的家庭管理员')
record = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), actor=user['username'], reason=reason,
previousOwnerId=home.get('ownerId', ''), ownerId=owner_id, successorId=successor_id)
home.update(ownerId=owner_id, successorId=successor_id,
leadershipRevision=home.get('leadershipRevision', 0) + 1,
leadershipHistory=(home.get('leadershipHistory', []) + [record])[-50:])
a.save_object('households', home)
a.audit('家庭负责人交接', user['username'] + ' / ' + home['name'])
return {'ok': True}
def save(self, data, user):
a = self.a
if not self.manager(user):
raise a.Problem('需要家庭管理员权限', 403)
with a.LOCK:
old = a.get_object('households', data.get('id'))
if data.get('id') and not old:
raise a.Problem('家庭不存在', 404)
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
raise a.Problem('只能管理自己所属的家庭', 403)
kind = data.get('kind', old.get('kind', 'home') if old else 'home')
if kind not in ('home', 'office', 'factory'):
raise a.Problem('请选择家庭、办公场所或工厂')
item = dict(old or {}, id=old['id'] if old else secrets.token_hex(8),
name=a.clean_text(data.get('name', ''), 80, True),
kind=kind,
note=a.clean_text(data.get('note', ''), 300),
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
for field in ('province','city','district'):
item[field] = a.clean_text(data.get(field, (old or {}).get(field, '')), 60)
if 'coverData' in data:
cover = data['coverData']
if not isinstance(cover, str): raise a.Problem('封面图片格式不正确')
if cover:
try: png_bytes(cover, 2*1024*1024)
except ValueError as error: raise a.Problem(str(error).replace('Logo', '封面'))
item['coverData'] = cover
item['coverRevision'] = hashlib.sha256(cover.encode()).hexdigest()[:16]
a.save_object('households', item)
a.audit('保存家庭', item['name'])
return item
def revision_key(self, user):
family = self.scope(user)
return 'familyRevision' if family == self.a.setting('defaultHousehold') else 'familyRevision:' + family
def save_view(self, data, user):
if not self.manager(user):
raise self.a.Problem('需要场所管理员权限', 403)
groups = data.get('groups')
if (not isinstance(groups, list) or len(groups) > 5 or
any(g not in ('type', 'space', 'manager', 'owner', 'tag') for g in groups) or len(set(groups)) != len(groups)):
raise self.a.Problem('分组维度不可重复,请选择类型、区域、负责人、所有人或标签')
with self.a.LOCK:
home = self.a.get_object('households', self.scope(user))
home['deviceGrouping'] = groups
self.a.save_object('households', home)
self.a.audit('更新设备分组方式', home['name'])
return {'ok': True}
+50 -9
View File
@@ -30,7 +30,7 @@ class Accounts:
@staticmethod
def public(user):
return {k: v for k, v in user.items() if k not in ('salt', 'hash')} if user else None
return {k: v for k, v in user.items() if k not in ('salt', 'hash') and not k.startswith('_')} if user else None
def password(self, password):
if not isinstance(password, str) or not 8 <= len(password) <= 128:
@@ -50,6 +50,9 @@ class Accounts:
def save(self, data, actor):
a = self.a
with a.LOCK:
if actor and not a.HOUSEHOLDS.manager(actor):
raise a.Problem('需要家庭管理员权限', 403)
family = a.HOUSEHOLDS.prepare(actor, 'users', data)
old = a.get_object('users', data.get('id'))
if data.get('id') and not old:
raise a.Problem('账号不存在', 404)
@@ -61,29 +64,53 @@ class Accounts:
raise a.Problem('用户名已存在', 409)
role = data.get('role', 'member')
access = data.get('familyAccess', 'none')
if role not in ('admin', 'member') or access not in ('none', 'read', 'edit'):
heritage_access = data.get('heritageAccess', old.get('heritageAccess', 'none') if old else 'none')
if heritage_access not in ('none', 'read', 'edit'):
raise a.Problem('文化与财产档案权限不正确')
if role not in ('admin', 'family_admin', 'member') or access not in ('none', 'read', 'edit'):
raise a.Problem('权限选项不正确')
if actor and actor['role'] != 'admin' and (role == 'admin' or (old and old['role'] == 'admin')):
raise a.Problem('家庭管理员不能创建或修改超级管理员', 403)
home = a.get_object('households', family)
if actor and actor['role'] != 'admin' and home.get('ownerId') and actor['id'] != home['ownerId']:
if (role == 'family_admin' and not old) or (old and old['id'] != actor['id'] and
(role == 'family_admin' or old['role'] == 'family_admin')):
raise a.Problem('请由一家之主或超级管理员管理家庭管理员账号', 403)
sites = data.get('siteIds', [])
if not isinstance(sites, list) or any(not isinstance(s, str) or not a.get_object('sites', s) for s in sites):
raise a.Problem('请选择有效空间')
for site in sites:
a.HOUSEHOLDS.owns(actor, a.get_object('sites', site))
ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False)
if not isinstance(ptz_control, bool):
raise a.Problem('云台权限格式不正确')
descendants = data.get('includeSubspaces', old.get('includeSubspaces', False) if old else False)
if not isinstance(descendants, bool):
raise a.Problem('下级区域授权格式不正确')
disabled = data.get('disabled', False)
if not isinstance(disabled, bool):
raise a.Problem('账号状态不正确')
if old and uid in (home.get('ownerId'), home.get('successorId')) and (disabled or role != 'family_admin'):
raise a.Problem('请先交接家主或更换备用负责人,再停用或调整此账号')
if old and old['role'] == 'admin' and (role != 'admin' or disabled):
if not any(u['id'] != uid and u['role'] == 'admin' and not u['disabled'] for u in a.objects('users')):
raise a.Problem('至少保留一个启用的管理员')
if old and old['role'] == 'family_admin' and (role != 'family_admin' or disabled):
if not any(u['id'] != uid and u['role'] == 'family_admin' and not u['disabled']
for u in a.HOUSEHOLDS.objects(actor, 'users')):
raise a.Problem('至少保留一个启用的家庭管理员')
if actor and actor['id'] == uid and (disabled or role != actor['role']):
raise a.Problem('不能停用或降低当前登录账号的管理权限')
related = a.clean_text(data.get('relatedToId', ''), 32)
person = a.clean_text(data.get('personId', ''), 32)
if related and related != uid and not a.get_object('users', related):
raise a.Problem('关系参照账号不存在')
if related and related != uid:
a.HOUSEHOLDS.owns(actor, a.get_object('users', related))
if person:
if not a.get_object('people', person):
raise a.Problem('家谱人物不存在')
a.HOUSEHOLDS.owns(actor, a.get_object('people', person))
if any(u.get('personId') == person and u['id'] != uid for u in a.objects('users')):
raise a.Problem('这个人物已经关联其他账号')
password = data.get('password', '')
@@ -91,11 +118,11 @@ class Accounts:
raise a.Problem('密码格式不正确')
credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')}
user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True),
role=role, disabled=disabled, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control,
personId=person, relatedToId=related,
role=role, disabled=disabled, familyId=family, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control,
personId=person, relatedToId=related, includeSubspaces=descendants, heritageAccess=heritage_access,
relationship=a.clean_text(data.get('relationship', ''), 40), **credentials)
# Revoke sessions when credentials or authorization change, not for label edits.
if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'siteIds', 'familyAccess', 'disabled', 'username', 'ptzControl')):
if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'heritageAccess', 'disabled', 'username', 'ptzControl', 'personId')):
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,))
a.save_object('users', user)
a.audit('更新账号' if old else '建立账号', username)
@@ -128,13 +155,18 @@ class Accounts:
def camera(self, user, camera):
if not camera:
raise self.a.Problem('摄像头不存在', 404)
if user['role'] != 'admin' and camera.get('siteId') not in user['siteIds']:
if user['role'] != 'admin':
self.a.HOUSEHOLDS.owns(user, camera)
site = self.a.get_object('sites', camera.get('siteId'))
if site:
self.a.HOUSEHOLDS.owns(user, site)
if not self.a.HOUSEHOLDS.manager(user) and camera.get('siteId') not in self.a.HOUSEHOLDS.site_ids(user):
raise self.a.Problem('没有这个空间的访问权限', 403)
return camera
def control(self, user, camera):
self.camera(user, camera)
if user['role'] != 'admin' and not user.get('ptzControl', False):
if not self.a.HOUSEHOLDS.manager(user) and not user.get('ptzControl', False):
raise self.a.Problem('没有云台控制权限,请联系管理员授权', 403)
if not camera.get('enabled'):
raise self.a.Problem('摄像头已停用', 409)
@@ -142,11 +174,20 @@ class Accounts:
def state(self, user, state):
state['user'] = self.public(user)
if user['role'] == 'admin':
family = self.a.HOUSEHOLDS.scope(user)
state['familyId'] = family
state['households'] = self.a.HOUSEHOLDS.list(user)
for key in ('sites', 'assets', 'cameras', 'recorders', 'devices'):
state[key] = [x for x in state.get(key, []) if self.a.HOUSEHOLDS.family_of(x) == family]
if user['role'] != 'admin':
state['scan'] = {}
state['storage'] = {}
if self.a.HOUSEHOLDS.manager(user):
return state
permitted = set(user['siteIds'])
permitted = self.a.HOUSEHOLDS.site_ids(user)
state['sites'] = [s for s in state['sites'] if s['id'] in permitted]
state['assets'] = [s for s in state['assets'] if s['siteId'] in permitted]
state['devices'] = [s for s in state['devices'] if s['siteId'] in permitted]
fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'archiveSource')
state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted]
state['recorders'] = []
+30
View File
@@ -0,0 +1,30 @@
"""Common device responsibility fields and non-video device records."""
class Inventory:
def __init__(self, app):
self.a = app
def metadata(self, data, old=None):
old = old or {}
result = {k: self.a.clean_text(data.get(k, old.get(k, '')), 80)
for k in ('ownerName', 'managerName')}
tags = data.get('tags', old.get('tags', []))
if not isinstance(tags, list) or len(tags) > 12:
raise self.a.Problem('自定义标签最多 12 个')
result['tags'] = sorted({self.a.clean_text(tag, 40, True) for tag in tags})
return result
def save(self, data, user):
a = self.a
with a.LOCK:
family = a.HOUSEHOLDS.prepare(user, 'devices', data, (('sites', 'siteId'),))
key = a.object_key('devices', data)
old = a.get_object('devices', key)
item = {k: a.clean_text(data.get(k, ''), 300 if k == 'note' else 120, k in ('name', 'deviceType'))
for k in ('name', 'deviceType', 'brand', 'model', 'serial', 'note')}
if item['deviceType'] in ('摄像头', '摄像机', '录像机'):
raise a.Problem('视频设备请使用摄像机或录像机档案,以保留取流与通道配置')
item.update(id=key, familyId=family, siteId=a.clean_text(data.get('siteId', ''), 32),
**self.metadata(data, old))
a.save_object('devices', item)
a.audit('保存设备档案', item['name'])
return item
+2 -1
View File
@@ -105,7 +105,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
self.assertEqual(len(assets), 19)
self.assertEqual(len(assets), 25)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
@@ -256,6 +256,7 @@ class WebTests(unittest.TestCase):
self.assertIn('pipe:1', command)
def test_ptz_requires_both_space_and_control_permission(self):
app.save_object('sites', dict(id='permitted', name='Permitted'))
camera = dict(id='e'*16, name='Test', siteId='permitted', enabled=True)
viewer = dict(role='member', siteIds=['permitted'], ptzControl=False)
with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera)
+86
View File
@@ -0,0 +1,86 @@
import base64
import copy
import http.client
import struct
import unittest
import zlib
from test_app import app
import test_households as fixture
from appearance import BRAND, png_bytes
def image_data():
def chunk(kind, data):
return struct.pack('>I',len(data))+kind+data+struct.pack('>I',zlib.crc32(kind+data))
raw=b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',2,2,8,6,0,0,0))+chunk(b'IDAT',zlib.compress((b'\x00'+b'\x33\x66\x55\xff'*2)*2))+chunk(b'IEND',b'')
return 'data:image/png;base64,'+base64.b64encode(raw).decode()
class AppearanceTests(unittest.TestCase):
setUp=fixture.HouseholdTests.setUp
stop_server=fixture.HouseholdTests.stop_server
request=fixture.HouseholdTests.request
def test_platform_branding_is_admin_only_and_public_values_are_allowlisted(self):
data=copy.deepcopy(BRAND);data['name']='自有品牌';data['logoData']=image_data();data['iconData']=image_data()
self.assertEqual(self.request('/api/appearance',self.owner,data)[0],403)
self.assertEqual(self.request('/api/appearance',self.members[self.aid])[0],403)
self.assertEqual(self.request('/api/appearance',self.root,data)[0],200)
c=http.client.HTTPConnection(*self.server.server_address)
c.request('GET','/api/branding');response=c.getresponse();raw=response.read();c.close()
self.assertEqual(response.status,200)
self.assertNotIn(b'logoData',raw);self.assertNotIn(b'password',raw)
self.assertEqual(app.APPEARANCE.public()['name'],'自有品牌')
self.assertTrue(app.APPEARANCE.public()['iconUrl'].startswith('/brand-icon.png'))
def test_brand_config_conflict_and_invalid_values(self):
original=copy.deepcopy(BRAND)
self.assertEqual(self.request('/api/appearance',self.root,original)[0],200)
self.assertEqual(self.request('/api/appearance',self.root,original)[0],409)
data=app.APPEARANCE.config();data['defaults']['layout']='arbitrary-css'
self.assertEqual(self.request('/api/appearance',self.root,data)[0],400)
data=app.APPEARANCE.config();data['logoData']='https://example.org/private.png'
self.assertEqual(self.request('/api/appearance',self.root,data)[0],400)
def test_preferences_are_account_scoped_and_survive_account_edits(self):
one,two=self.members[self.aid],self.members[self.bid]
config=self.request('/api/preferences',one)[1]
values=config['values'];values['theme']='ink'
self.assertEqual(self.request('/api/preferences',one,dict(revision=0,values=values))[0],200)
self.assertEqual(self.request('/api/preferences',two)[1]['effective']['theme'],'paper')
self.assertEqual(self.request('/api/preferences',one,dict(revision=1,values=values,userId=two['id']))[0],400)
app.ACCOUNTS.save(dict(app.get_object('users',one['id']),name='重命名'),self.root)
self.assertEqual(app.APPEARANCE.preferences(one)['effective']['theme'],'ink')
def test_defaults_inheritance_and_personal_conflicts(self):
user=self.members[self.aid];values=app.APPEARANCE.preferences(user)['values'];values['layout']='top'
app.APPEARANCE.save_preferences(dict(revision=0,values=values),user)
config=copy.deepcopy(BRAND);config['defaults']['theme']='clean'
app.APPEARANCE.save_config(config,self.root)
effective=app.APPEARANCE.preferences(user)['effective']
self.assertEqual(effective['theme'],'clean');self.assertEqual(effective['layout'],'top')
self.assertEqual(self.request('/api/preferences',user,dict(revision=0,values=values))[0],409)
def test_png_validation_rejects_svg_corruption_and_trailing_data(self):
good=image_data();self.assertGreater(len(png_bytes(good)),10)
for bad in ['data:image/svg+xml;base64,AAA','data:image/png;base64,!!',good[:-2],
'data:image/png;base64,'+base64.b64encode(png_bytes(good)+b'extra').decode()]:
with self.assertRaises(ValueError):png_bytes(bad)
def test_city_and_cover_are_preserved_without_exposing_inline_image_in_state(self):
home=app.get_object('households',self.aid)
self.assertEqual(self.request('/api/households',self.root,dict(home,province='山东省',city='烟台市',district='莱阳市',coverData=image_data()))[0],200)
listed=self.request('/api/households',self.owner)[1]['households'][0]
self.assertEqual(listed['city'],'烟台市');self.assertNotIn('coverData',listed)
self.assertIn('/api/households/cover?',listed['coverUrl'])
saved=self.request('/api/households',self.owner,dict(id=self.aid,name='新名称'))[1]
self.assertEqual(saved['city'],'烟台市');self.assertEqual(saved['coverData'],image_data())
for user,expected in [(self.members[self.aid],200),(self.members[self.bid],404)]:
c=http.client.HTTPConnection(*self.server.server_address)
c.request('GET',listed['coverUrl'],headers={'Cookie':'vision='+self.tokens[user['id']]})
r=c.getresponse();r.read();c.close();self.assertEqual(r.status,expected)
def test_untrusted_fields_do_not_become_executable_or_change_roles(self):
user=self.members[self.aid];data=app.APPEARANCE.preferences(user)
self.assertEqual(self.request('/api/preferences',user,dict(revision=0,values=dict(data['values'],css='url(x)')))[0],400)
self.assertEqual(self.request('/api/appearance',self.root,dict(copy.deepcopy(BRAND),role='admin'))[0],400)
+82
View File
@@ -0,0 +1,82 @@
import datetime as dt
from contextlib import closing
from pathlib import PurePosixPath
import sqlite3
import unittest
from unittest.mock import patch
from test_app import app
import test_households as fixture
from backups import Backups
class BackupTests(unittest.TestCase):
setUp=fixture.HouseholdTests.setUp
stop_server=fixture.HouseholdTests.stop_server
request=fixture.HouseholdTests.request
def source_file(self):
with closing(sqlite3.connect(app.DATA/'vision.db')) as dest:app.DB.backup(dest)
return Backups(app)
def test_backup_permissions_and_link_validation(self):
for user in (self.owner,self.members[self.aid]):
self.assertEqual(self.request('/api/backups',user)[0],403)
self.assertEqual(self.request('/api/backups/run',user,{})[0],403)
config=app.BACKUPS.config();config['networkPath']='https://pan.baidu.com/share/example'
self.assertEqual(self.request('/api/backups/config',self.root,config)[0],400)
config['networkPath']=str(app.DATA)
self.assertEqual(self.request('/api/backups/config',self.root,config)[0],400)
def test_local_snapshot_is_integral_and_does_not_copy_active_sessions(self):
backup=self.source_file();before=app.DB.execute('select count(*) from sessions').fetchone()[0]
result=backup.run();self.assertTrue(result['localOk'],result)
with closing(sqlite3.connect(app.DATA/'managed-backups'/result['file'])) as copy:
self.assertEqual(copy.execute('pragma integrity_check').fetchone()[0],'ok')
self.assertEqual(copy.execute('select count(*) from users').fetchone()[0],len(app.objects('users')))
self.assertEqual(copy.execute('select count(*) from sessions').fetchone()[0],0)
self.assertEqual(app.DB.execute('select count(*) from sessions').fetchone()[0],before)
self.assertEqual(len(result['sha256']),64)
def test_network_failure_keeps_local_success_and_is_not_reported_as_cloud_success(self):
backup=self.source_file();app.set_setting('backup_config',dict(backup.config(),networkPath=str(app.DATA/'missing')))
result=backup.run()
self.assertTrue(result['localOk']);self.assertFalse(result['networkOk']);self.assertIn('error',result)
def test_network_copy_checksum_and_retention_only_touch_owned_files(self):
backup=self.source_file();network=app.DATA/'network';network.mkdir();unrelated=network/'keep.sqlite';unrelated.write_text('unrelated')
app.set_setting('backup_config',dict(backup.config(),networkPath=str(network),keep=1))
with patch.object(backup,'network_mount',return_value=True):
first=backup.run();second=backup.run()
self.assertTrue(first['networkOk'],first);self.assertTrue(second['networkOk'],second)
self.assertTrue(unrelated.exists());self.assertFalse((network/first['file']).exists());self.assertTrue((network/second['file']).exists())
def test_daily_schedule_once_per_beijing_date_and_disabled_by_default(self):
backup=Backups(app);now=dt.datetime(2026,10,4,4)
with patch.object(backup,'start') as start:
self.assertFalse(backup.tick(now))
app.set_setting('backup_config',dict(backup.config(),enabled=True))
self.assertTrue(backup.tick(now));self.assertFalse(backup.tick(now))
self.assertTrue(backup.tick(now+dt.timedelta(days=1)));self.assertEqual(start.call_count,2)
def test_config_revision_and_busy_guard(self):
backup=Backups(app);data=backup.config();backup.save(data)
with self.assertRaises(app.Problem):backup.save(data)
backup.running=True
with self.assertRaises(app.Problem):backup.start()
with self.assertRaises(app.Problem):backup.save(backup.config())
def test_local_filesystems_are_never_reported_as_network_mounts(self):
records=['1 0 8:1 / / rw - ext4 /dev/sda1 rw',
'2 1 0:1 / /tmp rw - tmpfs tmpfs rw',
'3 1 0:2 / /mnt/cloud rw - nfs4 nas:/share rw',
'4 3 0:3 / /mnt/cloud/local rw - tmpfs tmpfs rw']
for path in ('/var/lib/backups','/tmp/backups','/mnt/cloud/local/backups'):
self.assertFalse(Backups.linux_network_mount(PurePosixPath(path),records))
self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud/backups'),records))
def test_network_mount_paths_with_spaces_and_supported_fuse_types(self):
for filesystem in ('cifs','fuse.rclone','fuse.davfs','fuse.sshfs'):
records=['1 0 8:1 / / rw - ext4 /dev/sda1 rw',
'2 1 0:2 / /mnt/cloud\\040drive rw - '+filesystem+' remote rw']
self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud drive/backups'),records))
self.assertFalse(Backups.linux_network_mount(PurePosixPath('/tmp'),['invalid data']))
+30
View File
@@ -0,0 +1,30 @@
const test=require('node:test'),assert=require('node:assert/strict'),m=require('./web/device-model.js');
const sites=[{id:'floor',name:'一楼'},{id:'bed',name:'卧室',parentId:'floor'},{id:'office',name:'办公室'}];
const devices=[{id:'lamp',name:'卧室台灯',deviceType:'灯',siteId:'bed',managerName:'乙',ownerName:'甲',tags:['照明','常用']},{id:'desk',name:'办公灯',deviceType:'灯',siteId:'office',managerName:'甲',ownerName:'乙',tags:['照明']},{id:'phone',name:'手机',deviceType:'手机',siteId:'bed',managerName:'乙',ownerName:'乙',tags:[]}];
test('type then area and area then type reuse the same device identities',()=>{
const a=m.groups(devices,['type','space'],sites),b=m.groups(devices,['space','type'],sites);
assert.deepEqual(a.find(g=>g.items.some(x=>x.id==='lamp')).labels,['灯','一楼','卧室']);
assert.deepEqual(b.find(g=>g.items.some(x=>x.id==='lamp')).labels,['一楼','卧室','灯']);
assert.equal(a.flatMap(g=>g.items).length,3);assert.equal(b.flatMap(g=>g.items).length,3);
});
test('responsible person differs from owner and supports multitag grouping',()=>{
assert.equal(m.groups(devices,['manager'],sites).find(g=>g.labels[0]==='乙').items.length,2);
assert.equal(m.groups(devices,['owner'],sites).find(g=>g.labels[0]==='乙').items.length,2);
assert.equal(m.groups(devices,['tag'],sites).find(g=>g.labels[0]==='照明').items.length,2);
assert.equal(m.groups(devices,['tag'],sites).find(g=>g.labels[0]==='未标记').items[0].id,'phone');
});
test('spaces have no fixed depth and traversal handles corrupt cycles safely',()=>{
const deep=Array.from({length:2500},(_,i)=>({id:String(i),name:'Area '+i,parentId:i?String(i-1):''}));
assert.equal(m.path(deep,'2499').length,2500);assert.equal(m.descendants(deep,'0').size,2500);
assert.equal(m.path([{id:'a',parentId:'b'},{id:'b',parentId:'a'}],'a').length,2);
assert.equal(m.descendants([{id:'a',parentId:'b'},{id:'b',parentId:'a'}],'a').size,2);
});
test('identical area names remain separate groups by stable identifiers',()=>{
const areas=[{id:'a',name:'卧室'},{id:'b',name:'卧室'}];
assert.equal(m.groups([{siteId:'a',id:1},{siteId:'b',id:2}],['space'],areas).length,2);
});
test('camera recorder and general device records appear in one catalog without mutation',()=>{
const state={assets:[{id:'a',name:'Camera'}],recorders:[{id:'b',name:'Recorder'}],devices:[{id:'c',deviceType:'冰箱'}]},before=JSON.stringify(state);
assert.deepEqual(m.catalog(state).map(d=>d.deviceType),['摄像机','录像机','冰箱']);assert.equal(JSON.stringify(state),before);
assert.equal(m.groups(devices,[],sites).length,1);
});
+78
View File
@@ -0,0 +1,78 @@
"""Real HTTP coverage for scoped cultural and property records."""
import json
import unittest
from test_app import app
import test_households as fixture
class HeritageTests(unittest.TestCase):
setUp = fixture.HouseholdTests.setUp
stop_server = fixture.HouseholdTests.stop_server
request = fixture.HouseholdTests.request
def create(self, **fields):
data = dict(name='家书', kind='culture'); data.update(fields)
code, row = self.request('/api/heritage', data=data)
self.assertEqual(code, 200, row)
return row
def allow(self, level):
user = app.get_object('users', self.members[self.aid]['id'])
user['heritageAccess'] = level
app.save_object('users', user)
return user
def test_genealogy_access_does_not_grant_property_access(self):
self.create()
member = self.members[self.aid]
self.assertEqual(self.request('/api/family', member)[0], 200)
self.assertEqual(self.request('/api/heritage', member)[0], 403)
self.assertEqual(self.request('/api/heritage', member, dict(name='x', kind='asset'))[0], 403)
reader = self.allow('read')
self.assertEqual(len(self.request('/api/heritage', reader)[1]['entries']), 1)
self.assertFalse(self.request('/api/heritage', reader)[1]['canEdit'])
self.assertEqual(self.request('/api/heritage', reader, dict(name='x', kind='asset'))[0], 403)
def test_cross_household_reads_edits_and_device_references_are_rejected(self):
row = self.create()
self.assertEqual(self.request('/api/heritage', self.other)[1]['entries'], [])
self.assertIn(self.request('/api/heritage', self.other, row)[0], (403,404))
self.assertEqual(self.request('/api/heritage', data=dict(name='x', kind='culture',
deviceId=self.assets[self.bid]['id'], deviceSource='camera'))[0], 404)
self.assertEqual(self.request('/api/heritage', self.owner, home=self.bid)[0], 403)
def test_revision_archive_and_restore(self):
row = self.create()
editor = self.allow('edit')
code, archived = self.request('/api/heritage', editor, dict(row, archived=True))
self.assertEqual(code, 200);self.assertEqual(archived['revision'], 2)
self.assertEqual(self.request('/api/heritage', editor, row)[0], 409)
code, restored = self.request('/api/heritage', editor, dict(archived, archived=False))
self.assertEqual(code, 200);self.assertFalse(restored['archived'])
self.assertEqual(restored['revision'], 3)
def test_validation_and_non_home_scope(self):
for values in [dict(sourceUrl='javascript:alert(1)'),dict(sourceUrl='https://user:pass@example.com'),
dict(date='2026-02-30'),dict(archived='false'),dict(kind='unsupported')]:
data=dict(name='x',kind='culture');data.update(values)
self.assertEqual(self.request('/api/heritage',data=data)[0],400)
home=app.get_object('households',self.bid)
app.HOUSEHOLDS.save(dict(home,kind='office'),self.root)
self.assertEqual(self.request('/api/heritage',self.other)[0],403)
def test_handover_intent_never_changes_accounts_or_device_ownership(self):
users=json.dumps(app.objects('users'),sort_keys=True)
devices=json.dumps(app.objects('assets'),sort_keys=True)
row=self.create(kind='asset',ownerName='甲',custodianName='乙',successorName='丙',handoverNote='记录意愿')
self.assertEqual(row['successorName'],'丙')
self.assertEqual(json.dumps(app.objects('users'),sort_keys=True),users)
self.assertEqual(json.dumps(app.objects('assets'),sort_keys=True),devices)
def test_new_permission_revokes_session_and_editor_needs_device_grant(self):
member=app.get_object('users',self.members[self.aid]['id'])
app.ACCOUNTS.save(dict(member,heritageAccess='edit'),self.root)
self.assertEqual(self.request('/api/heritage',member)[0],401)
member=app.get_object('users',member['id']);member['siteIds']=[]
app.save_object('users',member)
with self.assertRaises(app.Problem):
app.HERITAGE.save(dict(name='未授权设备',kind='asset',deviceSource='camera',deviceId=self.assets[self.aid]['id']),member)
+266
View File
@@ -0,0 +1,266 @@
"""Two-household HTTP isolation and management succession regressions."""
import hashlib
import http.client
import json
import secrets
import sqlite3
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
# Reuse only module initialization; never inherit the original WebTests.
from test_app import app
class HouseholdTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
db = sqlite3.connect(':memory:', check_same_thread=False)
db.row_factory = sqlite3.Row
self.addCleanup(db.close)
db.executescript('''
CREATE TABLE settings (key TEXT PRIMARY KEY,value TEXT NOT NULL);
CREATE TABLE sessions (hash TEXT PRIMARY KEY,expires REAL NOT NULL);
CREATE TABLE audit (id INTEGER PRIMARY KEY,at TEXT,action TEXT,name TEXT);
''')
for table in ('sites', 'assets', 'recorders', 'cameras'):
db.execute('CREATE TABLE '+table+' (id TEXT PRIMARY KEY,body TEXT NOT NULL)')
for attr, value in [('DB', db), ('DATA', Path(self.temp.name)),
('write_media_config', lambda: None)]:
p = patch.object(app, attr, value); p.start(); self.addCleanup(p.stop)
app.initialize()
self.root = app.ACCOUNTS.save(dict(username='admin', password='fixture-only', role='admin'), None)
self.aid = self.root['familyId']
self.bid = app.HOUSEHOLDS.save({'name': '乙家庭'}, self.root)['id']
self.roots = {h: app.HOUSEHOLDS.context(self.root, h) for h in (self.aid, self.bid)}
self.managers, self.members, self.sites, self.cameras, self.assets = {}, {}, {}, {}, {}
for i, home in enumerate((self.aid, self.bid)):
actor = self.roots[home]
self.sites[home] = app.save_site({'name': 'home-'+str(i)}, actor)
self.assets[home] = app.save_asset(dict(name='asset-'+str(i), siteId=self.sites[home]['id']), actor)
self.cameras[home] = app.save_camera(dict(name='camera-'+str(i), assetId=self.assets[home]['id'],
host='192.168.3.'+str(10+i), enabled=True), actor)
self.managers[home] = app.ACCOUNTS.save(dict(username='manager'+str(i), password='fixture-only', role='family_admin'), actor)
self.members[home] = app.ACCOUNTS.save(dict(username='member'+str(i), password='fixture-only',
siteIds=[self.sites[home]['id']], familyAccess='edit', ptzControl=True), actor)
self.owner, self.other = self.managers[self.aid], self.managers[self.bid]
self.backup = app.ACCOUNTS.save(dict(username='backup', password='fixture-only', role='family_admin'), self.root)
self.tokens = {}
for user in [self.root, self.backup, *self.managers.values(), *self.members.values()]:
token = secrets.token_hex(32); self.tokens[user['id']] = token
db.execute('INSERT INTO sessions VALUES (?,?,?)', (hashlib.sha256(token.encode()).hexdigest(), time.time()+600, user['id']))
db.commit()
def state():
return {**{t: app.objects(t) for t in ('sites','assets','recorders','cameras','devices')}, 'scan': {'network':'private'}, 'storage': {'maxGB':40}}
p = patch.object(app, 'status', state); p.start(); self.addCleanup(p.stop)
self.server = app.ThreadingHTTPServer(('127.0.0.1', 0), app.Handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start()
self.addCleanup(self.stop_server)
def stop_server(self):
self.server.shutdown(); self.server.server_close(); self.thread.join()
def request(self, path, user=None, data=None, home=None):
user = user or self.owner
c = http.client.HTTPConnection(*self.server.server_address, timeout=3)
headers = {'Cookie': 'vision='+self.tokens[user['id']]}
if home: headers['X-Household-Id'] = home
if data is not None: headers['Content-Type'] = 'application/json'
c.request('GET' if data is None else 'POST', path, json.dumps(data) if data is not None else None, headers)
response = c.getresponse(); raw = response.read(); code = response.status; c.close()
return code, json.loads(raw)
def leadership(self, user=None, **fields):
data = dict(id=self.aid, ownerId=self.owner['id'], successorId=self.backup['id'], revision=0, reason='提前安排备用负责人')
return self.request('/api/households/leadership', user or self.root, data=dict(data, **fields))
def test_state_and_accounts_are_scoped(self):
for home in (self.aid, self.bid):
for user in (self.managers[home], self.members[home]):
code, body = self.request('/api/state', user)
self.assertEqual(code, 200)
for table in ('sites','assets','cameras'):
self.assertEqual(len(body[table]), 1)
self.assertEqual(body['cameras'][0]['id'], self.cameras[home]['id'])
self.assertEqual([h['id'] for h in body['households']], [home])
self.assertEqual(body['storage'], {}); self.assertEqual(body['scan'], {})
code, body = self.request('/api/users')
self.assertEqual(code, 200)
self.assertTrue(all(u['familyId']==self.aid and u['role']!='admin' for u in body['users']))
self.assertNotIn('hash', json.dumps(body)); self.assertNotIn('salt', json.dumps(body))
def test_forged_household_header_denied_root_can_switch(self):
for user in (self.owner, self.members[self.aid]):
for path in ('/api/session','/api/state','/api/family','/api/households'):
self.assertEqual(self.request(path, user, home=self.bid)[0], 403)
code, body = self.request('/api/state', self.root, home=self.bid)
self.assertEqual(code, 200); self.assertEqual(body['familyId'], self.bid)
self.assertEqual(body['cameras'][0]['id'], self.cameras[self.bid]['id'])
def test_guessed_camera_ids_cannot_access_media_or_controls(self):
camera = self.cameras[self.bid]['id']
paths = [f'/media/live/cam_{camera}_{quality}/{file}' for quality in ('main','sub','hd','compat')
for file in ('index.m3u8','init.mp4','segment1.mp4')]
paths += [f'/api/recordings?camera={camera}', f'/media/playback?camera={camera}',
f'/media/playback?camera={camera}&download=1', f'/api/ptz?camera={camera}',
f'/api/ptz/presets?camera={camera}', f'/api/camera/settings?camera={camera}']
with patch.object(app.Handler, 'proxy') as proxy:
for user in (self.owner,self.members[self.aid]):
for path in paths:
with self.subTest(path=path): self.assertIn(self.request(path,user)[0], (403,404))
for path in ('/api/ptz','/api/ptz/presets','/api/camera/settings'):
self.assertIn(self.request(path,user,dict(camera=camera, action='stop'))[0], (403,404))
proxy.assert_not_called()
def test_own_media_access_and_ungranted_site(self):
def proxy(handler,*args,**kwargs): handler.answer({'ok':True})
camera = self.cameras[self.aid]['id']; path = f'/media/live/cam_{camera}_hd/index.m3u8'
with patch.object(app.Handler,'proxy',proxy):
self.assertEqual(self.request(path,self.members[self.aid])[0],200)
member=dict(self.members[self.aid],siteIds=[])
# Update DB directly to retain token and exercise per-request permission lookup.
app.save_object('users',dict(app.get_object('users',member['id']),siteIds=[]))
self.assertEqual(self.request(path,member)[0],403)
self.assertEqual(self.request(path,self.owner)[0],200)
def test_cross_household_edits_references_and_ownership_blocked(self):
cases=[('/api/sites',dict(self.sites[self.bid],name='stolen')),
('/api/assets',dict(name='bad',siteId=self.sites[self.bid]['id'])),
('/api/cameras',dict(name='bad',assetId=self.assets[self.bid]['id'])),
('/api/recorders',dict(name='bad',siteId=self.sites[self.bid]['id'])),
('/api/sites',dict(self.sites[self.aid],familyId=self.bid))]
for path,data in cases: self.assertIn(self.request(path,data=data)[0],(403,404))
self.assertEqual(self.request('/api/sites',data=dict(self.sites[self.aid],name='own edit'))[0],200)
def test_other_device_address_and_onvif_credentials_protected(self):
with patch.object(app.onvif,'inspect') as inspect:
for data in (dict(id=self.cameras[self.bid]['id']),dict(host=self.cameras[self.bid]['host'],username='admin')):
self.assertIn(self.request('/api/onvif',data=data)[0],(403,404))
inspect.assert_not_called()
self.assertEqual(self.request('/api/cameras',data=dict(name='duplicate',assetId=self.assets[self.aid]['id'],host=self.cameras[self.bid]['host']))[0],403)
def test_manager_cannot_escalate_or_touch_another_family_user(self):
for data in (dict(username='root2',password='fixture-only',role='admin'),
dict(self.other,name='stolen'), dict(self.root,name='hijack'),
dict(username='cross',password='fixture-only',siteIds=[self.sites[self.bid]['id']]),
dict(username='cross',password='fixture-only',relatedToId=self.other['id'])):
self.assertIn(self.request('/api/users',data=data)[0],(403,404))
def test_global_operations_require_super_admin(self):
for path,data in (('/api/storage',{}),('/api/discover',{}),('/api/households',{'name':'unauthorized'})):
self.assertEqual(self.request(path,data=data)[0],403)
self.assertEqual(self.request('/api/audit')[0],403)
self.assertEqual(self.request('/api/households',self.members[self.aid],{'name':'unauthorized'})[0],403)
def test_genealogy_revisions_history_restore_and_refs_isolated(self):
a = app.FAMILY.save('person',dict(name='A private',revision=0),self.owner)['item']
b = app.FAMILY.save('person',dict(name='B private',revision=0),self.other)['item']
self.assertEqual(self.request('/api/family')[1]['revision'],1)
self.assertEqual([p['name'] for p in self.request('/api/family')[1]['people']],['A private'])
revision=app.FAMILY.history(self.other,'person',b['id'])['items'][0]['revision']
for path in (f'/api/family/history?kind=person&id={b["id"]}', f'/api/family/change?revision={revision}'):
self.assertEqual(self.request(path)[0],404)
for path,data in (('/api/family/restore',dict(changeRevision=revision,side='after',revision=1)),
('/api/family/person',dict(b,revision=1)),
('/api/family/link',dict(fromId=a['id'],toId=b['id'],kind='parent',revision=1)),
('/api/family/event',dict(personIds=[b['id']],revision=1))):
self.assertIn(self.request(path,data=data)[0],(403,404))
self.assertEqual(self.request('/api/users',data=dict(username='crossperson',password='fixture-only',personId=b['id']))[0],404)
def test_leadership_backup_and_explicit_emergency_handover(self):
self.assertEqual(self.leadership()[0],200)
# Backup remains operational even if the owner's browser is unavailable.
self.assertEqual(self.request('/api/sites',self.backup,dict(name='emergency operation'))[0],200)
self.assertEqual(self.leadership(self.backup,revision=1,ownerId=self.backup['id'],successorId='')[0],403)
self.assertEqual(self.leadership(self.root,revision=1,ownerId=self.backup['id'],successorId=self.owner['id'],reason='家主无法操作,由超级管理员办理交接')[0],200)
home=app.get_object('households',self.aid)
self.assertEqual(home['ownerId'],self.backup['id']);self.assertEqual(len(home['leadershipHistory']),2)
self.assertEqual(app.get_object('users',self.owner['id'])['role'],'family_admin')
self.assertEqual(self.leadership(self.owner,revision=2)[0],403)
self.assertEqual(self.request('/api/households',self.members[self.aid])[1]['households'][0].get('leadershipHistory'),None)
def test_leadership_conflicts_and_account_lockout_prevention(self):
self.assertEqual(self.leadership()[0],200)
self.assertEqual(self.leadership()[0],409)
for uid in (self.owner['id'],self.backup['id']):
user=app.get_object('users',uid)
for fields in ({'disabled':True},{'role':'member'}):
self.assertEqual(self.request('/api/users',self.root,dict(user,**fields))[0],400)
self.assertEqual(self.request('/api/users',self.backup,dict(self.owner,password='hijack-password'))[0],403)
self.assertEqual(self.leadership(revision=1,successorId=self.other['id'])[0],404)
self.assertEqual(self.leadership(revision=1,successorId=self.owner['id'])[0],400)
self.assertEqual(self.leadership(revision=1,ownerId=self.members[self.aid]['id'])[0],400)
# Editing household metadata must preserve leadership and its history.
self.assertEqual(self.request('/api/households',data=dict(id=self.aid,name='renamed'))[0],200)
self.assertEqual(app.get_object('households',self.aid)['ownerId'],self.owner['id'])
def test_legacy_migration_idempotent_preserves_credentials(self):
user=app.get_object('users',self.owner['id']);user.pop('familyId')
app.save_object('users',user)
before=app.DB.execute('SELECT * FROM sessions ORDER BY hash').fetchall()
app.HOUSEHOLDS.initialize();app.HOUSEHOLDS.initialize()
saved=app.get_object('users',user['id']);self.assertEqual(saved.pop('familyId'),self.aid)
self.assertEqual(saved,user)
self.assertEqual([tuple(r) for r in before],[tuple(r) for r in app.DB.execute('SELECT * FROM sessions ORDER BY hash')])
def test_office_factory_and_custom_grouping_are_independent(self):
for kind in ('office','factory'):
code, scope=self.request('/api/households',self.root,dict(name=kind,kind=kind))
self.assertEqual(code,200);self.assertEqual(scope['kind'],kind)
self.assertEqual(self.request('/api/device-view',data={'groups':['manager','type','space']})[0],200)
self.assertEqual(app.get_object('households',self.aid)['deviceGrouping'],['manager','type','space'])
self.assertNotIn('deviceGrouping',app.get_object('households',self.bid))
self.assertEqual(self.request('/api/device-view',data={'groups':[]})[0],200)
for groups in (['type','type'],['unknown'],'type',[{}]):
self.assertEqual(self.request('/api/device-view',data={'groups':groups})[0],400)
self.assertEqual(self.request('/api/device-view',self.members[self.aid],{'groups':['type']})[0],403)
def test_region_hierarchy_has_no_fixed_depth_and_rejects_cycles(self):
parent=self.sites[self.aid]
for n in range(160):
parent=app.save_site(dict(name='Region '+str(n),parentId=parent['id'],category='custom '+str(n)),self.owner)
self.assertEqual(len(app.HOUSEHOLDS.objects(self.owner,'sites')),161)
for child in (parent,self.sites[self.aid]):
self.assertEqual(self.request('/api/sites',data=dict(child,parentId=parent['id']))[0],400)
self.assertEqual(self.request('/api/sites',data=dict(parent,parentId=self.sites[self.bid]['id']))[0],404)
def test_subregion_grants_are_explicit_and_checked_per_request(self):
child=app.save_site(dict(name='Bedroom',parentId=self.sites[self.aid]['id']),self.owner)
asset=app.save_asset(dict(self.assets[self.aid],siteId=child['id']),self.owner)
camera=app.get_object('cameras',self.cameras[self.aid]['id'])
member=app.get_object('users',self.members[self.aid]['id'])
with self.assertRaises(app.Problem):app.ACCOUNTS.camera(member,camera)
app.save_object('users',dict(member,includeSubspaces=True))
self.assertEqual(len(self.request('/api/state',member)[1]['cameras']),1)
# Moving the region to another branch immediately removes inherited access.
app.save_site(dict(child,parentId=''),self.owner)
self.assertEqual(len(self.request('/api/state',member)[1]['cameras']),0)
self.assertEqual(app.get_object('assets',asset['id'])['siteId'],child['id'])
def test_generic_devices_responsibilities_and_cross_family_access(self):
data=dict(name='卧室台灯',deviceType='灯',siteId=self.sites[self.aid]['id'],ownerName='家人甲',managerName='家人乙',tags=['照明','常用'])
code,device=self.request('/api/devices',data=data)
self.assertEqual(code,200);self.assertEqual(device['managerName'],'家人乙')
self.assertEqual([d['id'] for d in self.request('/api/state')[1]['devices']],[device['id']])
self.assertEqual(self.request('/api/state',self.other)[1]['devices'],[])
self.assertIn(self.request('/api/devices',self.other,dict(device,name='hijack'))[0],(403,404))
self.assertEqual(self.request('/api/devices',data=dict(data,siteId=self.sites[self.bid]['id']))[0],404)
self.assertEqual(self.request('/api/devices',self.members[self.aid],data)[0],403)
for invalid in (dict(data,tags='tag'),dict(data,deviceType='录像机'),dict(data,tags=['x']*13)):
self.assertEqual(self.request('/api/devices',data=invalid)[0],400)
def test_video_and_other_devices_share_responsibility_metadata(self):
body=dict(self.assets[self.aid],ownerName='设备所有人',managerName='设备负责人',tags=['视频'])
asset=app.save_asset(body,self.owner)
recorder=app.save_recorder(dict(name='Recorder',siteId=self.sites[self.aid]['id'],host='192.168.3.90',ownerName='设备所有人',managerName='设备负责人',tags=['视频']),self.owner)
self.assertEqual(asset['managerName'],recorder['managerName'])
self.assertEqual(asset['tags'],recorder['tags'])
body.pop('ownerName');body.pop('managerName');body.pop('tags')
self.assertEqual(app.save_asset(body,self.owner)['managerName'],'设备负责人')
if __name__=='__main__': unittest.main()
+32
View File
@@ -0,0 +1,32 @@
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
const app=fs.readFileSync('web/app.js','utf8'),homes=fs.readFileSync('web/households.js','utf8');
const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length));
test('API uses current household scope for each new request',async()=>{
const calls=[];const c=vm.createContext({sessionGeneration:0,householdScope:'house-a',fetch:async(p,o)=>{calls.push(o);return {ok:true,json:async()=>({})};}});
vm.runInContext(part(app,'async function api(','function toast('),c);
await c.api('/api/state');c.householdScope='house-b';await c.api('/api/users',{});
assert.equal(calls[0].headers['X-Household-Id'],'house-a');assert.equal(calls[1].headers['X-Household-Id'],'house-b');
});
test('switch destroys old private state before requesting another household',async()=>{
const events=[];const c=vm.createContext({state:{familyId:'a'},currentUser:{id:'root'},isPlatformAdmin:()=>true,
clearPrivateView(){events.push('clear');c.currentUser=null;c.state={};},auth:async()=>{events.push('auth');assert.equal(c.householdScope,'b');assert.equal(c.currentUser.id,'root');}});
vm.runInContext(part(homes,'async function switchHousehold(','function openHousehold('),c);await c.switchHousehold('b');
assert.deepEqual(events,['clear','auth']);
});
test('family administrator cannot invoke household switching',async()=>{
const c=vm.createContext({state:{familyId:'a'},isPlatformAdmin:()=>false,clearPrivateView:()=>{throw Error('should not switch');}});
vm.runInContext(part(homes,'async function switchHousehold(','function openHousehold('),c);await c.switchHousehold('b');
});
test('household switch invalidates outstanding API data',async()=>{
let finish;const c=vm.createContext({sessionGeneration:0,householdScope:'a',fetch:()=>new Promise(r=>finish=r)});
vm.runInContext(part(app,'async function api(','function toast('),c);
const pending=c.api('/api/users');c.sessionGeneration++;c.householdScope='b';finish({ok:true,json:async()=>({users:['private-a']})});
await assert.rejects(pending,e=>e.stale===true);
});
test('owner is visibly distinct from family and super administrators',()=>{
const c=vm.createContext({state:{households:[{ownerId:'owner'}]}});
vm.runInContext(part(app,'const roleName =','function applyPermissions(')+';globalThis.label=roleName;',c);
assert.equal(c.label({id:'owner',role:'family_admin'}),'一家之主');
assert.equal(c.label({id:'backup',role:'family_admin'}),'家庭管理员');
assert.equal(c.label({id:'root',role:'admin'}),'超级管理员');
});
+62
View File
@@ -0,0 +1,62 @@
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs');
const source=fs.readFileSync('web/personalization.js','utf8'),homes=fs.readFileSync('web/households.js','utf8');
const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length));
function preferences(){const c=vm.createContext({pageInfo:{},document:{documentElement:{dataset:{}}}});vm.runInContext(source.slice(0,source.indexOf('function applyBranding(')),c);return c;}
test('unset preferences inherit platform defaults and unknown values cannot become CSS',()=>{
const c=preferences();const values=c.effectiveAppearance({theme:'javascript:alert(1)',layout:'top'},{theme:'ink'});
assert.equal(values.theme,'ink');assert.equal(values.layout,'top');assert.equal(values.density,'comfortable');
c.applyAppearance({theme:'clean',font:'sans'});assert.equal(c.document.documentElement.dataset.theme,'clean');
assert.equal(c.document.documentElement.dataset.font,'sans');
});
test('explicit personal appearance is preserved when the platform default changes',()=>{
const c=preferences();assert.equal(c.effectiveAppearance({theme:'paper'},{theme:'ink'}).theme,'paper');
assert.equal(c.effectiveAppearance({theme:'default'},{theme:'ink'}).theme,'ink');
});
test('leaving settings invalidates outstanding work and restores the saved appearance',()=>{
let applied=0,c=vm.createContext({appearanceGeneration:3,settingsTimer:7,clearTimeout:()=>{},applyAppearance:()=>applied++});
vm.runInContext(part(source,'function leaveSettings()',"$('#preferences-form')"),c);c.leaveSettings();
assert.equal(c.appearanceGeneration,4);assert.equal(c.settingsTimer,null);assert.equal(applied,1);
});
test('a late backup response cannot disclose details after leaving settings',async()=>{
let finish,writes=0;const c=vm.createContext({appearanceGeneration:1,activePage:'settings',api:()=>new Promise(r=>finish=r),renderBackup:()=>writes++});
vm.runInContext(part(source,'async function pollBackup()',"$('#backup-form')"),c);
const pending=c.pollBackup();c.appearanceGeneration++;c.activePage='households';finish({private:true});await pending;assert.equal(writes,0);
});
test('late backup save or run cannot update a newly opened account',async()=>{
let save,finish,writes=0;const button={},elements={enabled:{checked:true},time:{value:'03:00'},keep:{value:'14'},networkPath:{value:''}},node={addEventListener:(name,fn)=>save=fn};
const c=vm.createContext({appearanceGeneration:1,backupRevision:0,$:()=>node,fillBackup:()=>writes++,pollBackup:()=>writes++,api:()=>new Promise(r=>finish=r)});
vm.runInContext(source.slice(source.indexOf("$('#backup-form').addEventListener")),c);
const pending=save({preventDefault(){},currentTarget:{elements,querySelector:()=>button}});c.appearanceGeneration++;finish({});await pending;assert.equal(writes,0);
const run=node.onclick();c.appearanceGeneration++;finish({});await run;assert.equal(writes,0);
});
test('backup messages distinguish local-only, network copy, and failure',()=>{
const c=vm.createContext({});vm.runInContext(part(source,'function backupLabel(','function renderBackup('),c);
assert.match(c.backupLabel({localOk:true,networkOk:false}),/本机/);assert.doesNotMatch(c.backupLabel({localOk:true}),/网络|云端/);
assert.match(c.backupLabel({error:'offline',localOk:true}),/未完成/);assert.match(c.backupLabel({networkOk:true}),/网络目录/);
});
test('place cards group by city without exposing an inline photo payload',()=>{
const out={innerHTML:''},filter={};const c=vm.createContext({state:{familyId:'a',households:[{id:'a',name:'Home',province:'Province',city:'City A',members:1,sites:1,coverUrl:'/api/households/cover?id=a',coverData:'PRIVATE-PHOTO'},{id:'b',name:'Office',city:'City B',kind:'office',members:1,sites:0}]},
activePage:'households',setOptions:()=>{},$:s=>s==='#household-list'?out:filter,isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'});
vm.runInContext(part(homes,'function renderHouseholds(','async function switchHousehold('),c);c.renderHouseholds();
assert.match(out.innerHTML,/City A/);assert.match(out.innerHTML,/City B/);assert.match(out.innerHTML,/\/api\/households\/cover\?id=a/);
assert.doesNotMatch(out.innerHTML,/PRIVATE-PHOTO|data-edit-household/);
});
test('publication text is rendered as text and the export omits private state',()=>{
const apply=part(source,'function applyBranding(','async function loadBranding(');assert.doesNotMatch(apply,/innerHTML/);
const payload=part(source,'function brandPayload()',"\n$('#brand-form').addEventListener");assert.doesNotMatch(payload,/password|currentUser|backupRevision|networkPath/);
assert.match(source,/digital-steward-appearance/);
});
test('image conversion resizes oversized output and closes its decoded bitmap',async()=>{
let closed=0,draws=0;const bitmap={width:1600,height:800,close(){closed++;}},canvas={width:0,height:0,getContext:()=>({drawImage(){draws++;}}),toDataURL:()=>draws===1?'data:image/png;base64,'+'A'.repeat(3000000):'data:image/png;base64,AA=='};
const c=vm.createContext({createImageBitmap:async()=>bitmap,document:{createElement:()=>canvas}});
vm.runInContext(part(source,'async function readImage(','for(const [id,type,max]'),c);
await assert.rejects(c.readImage({type:'image/svg+xml',size:20}),/PNG/);
assert.equal(await c.readImage({type:'image/png',size:100},960),'data:image/png;base64,AA==');
assert.equal(canvas.width,768);assert.equal(canvas.height,384);assert.equal(closed,1);
});
test('settings opened before login resolves reloads once after the account is known',()=>{
let loads=0;const c=vm.createContext({applyBranding(){},applyAppearance(){},currentUser:{id:'root'},appearanceOwner:'',activePage:'settings',branding:{},loadSettings(){loads++;}});
vm.runInContext(part(source,'function onAppearanceSession(','function resetAppearance('),c);
c.onAppearanceSession({values:{},defaults:{},revision:0});c.onAppearanceSession({values:{},defaults:{},revision:0});
assert.equal(loads,1);assert.equal(c.appearanceOwner,'root');
});
+51
View File
@@ -0,0 +1,51 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
const code=fs.readFileSync('web/steward.js','utf8'),html=fs.readFileSync('web/index.html','utf8'),app=fs.readFileSync('web/app.js','utf8');
const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length));
test('navigation starts at places and contains no global camera functions',()=>{
const nav=html.match(/<nav aria-label="主导航">([\s\S]*?)<\/nav>/)[1];
assert.doesNotMatch(nav,/data-page="(?:live|playback)"/);
assert.match(nav,/scope-navigation/);assert.match(app,/activePage = 'households'/);
assert.match(html,/aria-label="设备功能"/);
});
test('one device exposes its lenses while place wall exposes all authorized lenses',()=>{
const c=vm.createContext({deviceContext:null});vm.runInContext(part(code,'function deviceCameras(','function enterDevice('),c);
const cameras=[{id:'1',assetId:'a'},{id:'2',assetId:'a'},{id:'3',assetId:'b'}];
assert.equal(c.deviceCameras(cameras).length,3);
assert.deepEqual(c.deviceCameras(cameras,{source:'camera',id:'a'}).map(x=>x.id),['1','2']);
assert.equal(c.deviceCameras(cameras,{source:'camera',id:'missing'}).length,0);
});
test('recorder relation is explicit and generic devices have no video operations',()=>{
const c=vm.createContext({deviceContext:null});vm.runInContext(part(code,'function deviceCameras(','function enterDevice('),c);
const cameras=[{id:'1',recorderId:'r'},{id:'2',archiveRecorderId:'r'},{id:'3',assetId:'r'}];
assert.equal(c.deviceCameras(cameras,{source:'recorder',id:'r'}).length,2);
assert.equal(c.deviceCameras(cameras,{source:'device',id:'r'}).length,0);
});
test('switching devices cancels prior playback before changing context',()=>{
const order=[],c=vm.createContext({state:{cameras:[]},ZhaoDevices:{catalog:()=>[{source:'camera',id:'a'}]},
$:()=>({value:''}),stopPlayers:()=>order.push('stop'),resetPlayback:()=>order.push('reset'),closePtz:()=>order.push('ptz'),
deviceCameras:()=>[{}],showPage:page=>order.push(page),toast:()=>{throw Error('not expected');}});
vm.runInContext(part(code,'function enterDevice(','function renderSteward('),c);c.enterDevice('camera','a');
assert.deepEqual(order,['stop','reset','ptz','live']);assert.equal(c.deviceContext.id,'a');
});
test('an unknown or unauthorized device cannot open a player',()=>{
let warned=0;const c=vm.createContext({state:{},ZhaoDevices:{catalog:()=>[]},toast:()=>warned++,stopPlayers:()=>{throw Error('must not open');}});
vm.runInContext(part(code,'function enterDevice(','function renderSteward('),c);c.enterDevice('camera','foreign');assert.equal(warned,1);
});
test('late heritage response cannot populate a different view',async()=>{
let finish,writes=0;const c=vm.createContext({heritageGeneration:0,heritageKind:'culture',activePage:'heritage-records',
$:()=>({replaceChildren(){},hidden:false,textContent:''}),api:()=>new Promise(r=>finish=r),renderHeritageRecords:()=>writes++});
const source=fs.readFileSync('web/heritage.js','utf8');
vm.runInContext(part(source,'async function loadHeritage(','function renderHeritageRecords('),c);
const pending=c.loadHeritage();c.heritageGeneration++;c.activePage='households';finish({entries:['private']});await pending;assert.equal(writes,0);
});
test('family timeline entry preserves its explicitly selected view',()=>{
assert.match(code,/familyView=b.dataset.familySection;showPage\('family',\{keepFamilyView:true\}\)/);
assert.match(app,/loadFamily\(!options.keepFamilyView\)/);
});
test('culture and property entry requires its separate permission',()=>{
const c=vm.createContext({isAdmin:()=>false,currentUser:{familyAccess:'read',heritageAccess:'none'},currentHome:()=>({name:'赵府'}),esc:s=>s,$:()=>output}),output={innerHTML:''};
vm.runInContext(part(code,'function renderHeritageHome(','function resetSteward('),c);c.renderHeritageHome();
assert.match(output.innerHTML,/data-family-section/);assert.doesNotMatch(output.innerHTML,/data-heritage-kind/);
c.currentUser.heritageAccess='read';c.renderHeritageHome();assert.match(output.innerHTML,/data-heritage-kind/);
});
+58 -37
View File
@@ -2,64 +2,87 @@
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
const kind = (v) => ({home:'家庭',company:'办公',office:'办公',factory:'工厂',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], devices:[], sites:[], storage:{}, scan:{}}, activePage = 'households', loggedIn = false, setup = false;
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
let currentUser = null, sessionGeneration = 0, refreshGeneration = 0, authGeneration = 0;
const isAdmin = () => currentUser?.role === 'admin';
let householdScope = '';
const isPlatformAdmin = () => currentUser?.role === 'admin';
const isAdmin = () => ['admin','family_admin'].includes(currentUser?.role);
const roleName = user => {const h=(state.households||[]).find(h=>h.id===state.familyId)||{},home=!h.kind||h.kind==='home';return user.role==='admin'?'超级管理员':user.role==='family_admin'?(h.ownerId===user.id?(home?'一家之主':'场所负责人'):(home?'家庭管理员':'场所管理员')):(home?'家庭成员':'场所成员');};
function applyPermissions(){
const admin=isAdmin();
$('nav [data-page="storage"]').hidden=!isPlatformAdmin();
$('#account-password').hidden=!loggedIn;
$$('[data-admin],nav [data-page="objects"],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin);
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
$('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : '';
$$('[data-admin],nav [data-page="recorders"],nav [data-page="cameras"],nav [data-page="sites"],nav [data-page="storage"],[data-action],[data-edit-camera],[data-camera-settings]').forEach(el=>el.hidden=!admin);
$('nav [data-page="storage"]').hidden=!isPlatformAdmin();
$('.discovery').hidden=!isPlatformAdmin()||!!deviceContext;
$('#scan-result').hidden=!isPlatformAdmin();
$('#new-household').hidden=!isPlatformAdmin();
$('#household-context').hidden=!loggedIn;
$('#household-filter').disabled=!isPlatformAdmin();
if(typeof renderSteward==='function')renderSteward();
$('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : '';
}
function clearPrivateView(){
if(typeof resetAppearance==='function')resetAppearance();
if(typeof clearSnapshot==='function')clearSnapshot();
if(typeof resetDeviceView==='function')resetDeviceView();
if(typeof resetSteward==='function')resetSteward();
if(typeof resetHeritage==='function')resetHeritage();
if(typeof clearCameraSettings==='function')clearCameraSettings();
sessionGeneration++;refreshGeneration++;authGeneration++;loggedIn=false;
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
currentUser=null;stopPlayers();resetPlayback();
state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}};
for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list'])$('#'+id).replaceChildren();
currentUser=null;householdScope='';stopPlayers();resetPlayback();
state={cameras:[],assets:[],recorders:[],devices:[],sites:[],storage:{},scan:{}};
for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list','household-list'])$('#'+id).replaceChildren();
if(typeof clearFamilyState==='function')clearFamilyState();
$$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close());
$$('dialog form').forEach(f=>f.reset());
activePage='live';showPage('live');applyPermissions();
activePage='households';showPage('households');applyPermissions();
}
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['设备管理','按类型、区域、负责人或所有人,找到每一台设备。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['区域与空间','层级与名称由你定义,设备各有归属。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
async function api(path, data) {
const generation=sessionGeneration;
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)});
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:{...(data === undefined ? {} : {'Content-Type':'application/json'}),...(typeof householdScope!=='undefined'&&householdScope?{'X-Household-Id':householdScope}:{})}, body:data === undefined ? undefined : JSON.stringify(data)});
let result; try { result = await r.json(); } catch { throw Error('服务返回异常,请稍后重试'); }
if(generation!==sessionGeneration)throw Object.assign(Error('登录会话已变化,请重新操作'),{stale:true});
if (!r.ok) { if (r.status === 401 && loggedIn) { clearPrivateView(); auth(); } throw Object.assign(Error(result.error || '操作未完成'),{status:r.status}); }
return result;
}
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
function siteName(id) { return state.sites.find(s => s.id === id)?.name || '未关联空间'; }
function siteName(id) { return ZhaoDevices.path(state.sites,id).map(s=>s.name).join(' / ') || '未关联空间'; }
function address(s) { return [s.province,s.city,s.district,s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; }
function filtered() { return state.cameras.filter(c => !$('#site-filter').value || c.siteId === $('#site-filter').value); }
function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const cameras=state.cameras.filter(c=>!range||range.has(c.siteId));return typeof deviceCameras==='function'?deviceCameras(cameras):cameras; }
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
function showPage(page) {
if(!isAdmin()&&!['live','playback','family'].includes(page))return;
function showPage(page, options={}) {
if(typeof leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings();
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings'].includes(page))return;
if(['heritage','family','heritage-records'].includes(page)&&currentHome()?.kind&&currentHome().kind!=='home')return;
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
if(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage','settings'].includes(page);
if(page==='storage'&&!isPlatformAdmin())return;
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
activePage = page;
$('.metrics').hidden=['family','users'].includes(page);
$('.filter-row').hidden=['family','users'].includes(page);
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings'].includes(page);
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page));
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
if (page !== 'playback') resetPlayback();
render();
if(page==='playback')$('#archive-source').value=state.cameras.find(c=>c.id===$('#play-camera').value)?.archiveSource||'local';
if (page === 'storage') loadAudit();
if (page === 'family') loadFamily(true);
if (page === 'family') loadFamily(!options.keepFamilyView);
if (page === 'users') loadUsers();
if(page==='heritage-records')loadHeritage();
if(page==='settings'&&typeof loadSettings==='function')loadSettings();
}
function render() {
$('#metric-all').textContent = state.assets.length;
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
$('#metric-ready').textContent = state.cameras.filter(c=>c.ready).length;
$('#metric-sites').textContent = state.sites.length;
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
@@ -68,7 +91,7 @@ function render() {
$('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有';
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
$('#banner').textContent = warning; $('#banner').hidden = !warning;
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind) + ' · ' + s.name)}</option>`).join(''));
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join(''));
if (activePage === 'live') renderLive();
if (activePage === 'cameras') renderCameras();
if (activePage === 'sites') renderSites();
@@ -79,16 +102,17 @@ function render() {
drawTimeline();
}
if (activePage === 'storage') renderStorage();
if(typeof renderHouseholds==='function')renderHouseholds();
applyPermissions();
if(activePage==='live'&&typeof wallSync==='function')wallSync();
}
function empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
function renderLive() {
const cameras = filtered(), quality = $('#quality').value;
const key = ZhaoLivePlayer.key(cameras, quality);
const key = (currentUser?.role||'')+':'+(state.familyId||'')+':'+ZhaoLivePlayer.key(cameras, quality);
if (key === liveKey) { updateLiveStatus(cameras); return; }
stopPlayers(); liveKey = key;
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-camera="${c.id}">截图</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-view-device="${c.assetId}" data-device-source="camera">摄像头详情</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-camera="${c.id}">截图</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
for (const c of cameras.filter(c=>c.enabled)) {
const video = $(`video[data-camera="${c.id}"]`);
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
@@ -113,8 +137,8 @@ function renderCameras() {
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
}
function renderSites() {
const sites=state.sites.filter(s=>!$('#site-filter').value||s.id===$('#site-filter').value);
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${kind(s.kind)}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>摄像头</dt><dd>${state.assets.filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','填写地区、小区、用途、楼栋和门牌,让摄像头有明确的归属。','<button data-action="new-site">+ 新建空间</button>');
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id));
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
}
function renderStorage() {
const s=state.storage;
@@ -123,8 +147,8 @@ function renderStorage() {
}
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
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();}if(typeof onAppearanceSession==='function')onAppearanceSession(s.preferences);}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
function openSite(id) {const f=$('#site-form');f.reset();f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
@@ -133,28 +157,25 @@ 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;$('#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));});
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(deviceContext?.source==='camera'){$('#camera-form').elements.assetId.value=deviceContext.id;syncEndpoint();}}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){const c=state.cameras.find(c=>c.id===b.dataset.playCamera);if(c)enterDevice('camera',c.assetId,'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];}};
window.addEventListener('beforeunload',stopPlayers);
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',auth,{once:true});
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',async()=>{showPage('households');if(typeof loadBranding==='function')await loadBranding();await auth();},{once:true});
function renderAssets(){
const assets=state.assets.filter(a=>!$('#site-filter').value||a.siteId===$('#site-filter').value);
$('#asset-list').innerHTML=assets.length?assets.map(a=>`<article class="site-card"><div class="site-heading"><span class="badge">摄像头对象</span><button class="quiet" data-edit-asset="${a.id}">编辑</button></div><h3>${esc(a.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(a.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([a.brand,a.model].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>物理镜头</dt><dd>${a.lensCount} 个</dd></div><div><dt>已配置通道</dt><dd>${state.cameras.filter(c=>c.assetId===a.id).length} 个</dd></div><div><dt>安装位置</dt><dd>${esc(a.point||'待填写')}</dd></div></dl></article>`).join(''):empty('先建立实体摄像头对象','三台双镜头摄像头,建立三个对象;每个对象下分别配置镜头通道。','<button data-action="new-asset">+ 新建摄像头对象</button>');
}
function renderAssets(){renderDevices();}
function renderRecorders(){
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;$('#recorder-dialog').showModal();}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(!a&&$('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;}
$('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint;
handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)}));
handleForm('asset-form','/api/assets',d=>({...deviceValues(d),lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...deviceValues(d),port:Number(d.port)}));
$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}};
+51
View File
@@ -76,6 +76,9 @@ header p { color: #686f60; font-size: 12px; letter-spacing: .5px; }
.metrics span { color: #6f7260; }
.metrics strong { font-family: Georgia, var(--serif); color: #365846; font-size: 29px; }
.filter-row { color: #6e6b5f; margin: 24px 0; }
.household-context { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; margin: -5px 0 24px; padding: 12px 16px; border-left: 3px solid var(--gold); background: #eae8dc; }
.household-context span { font-size: 11px; color: var(--muted); }
.household-context select { min-width: 140px; max-width: 240px; }
.section-head { margin-top: 25px; }
.section-head small { color: #8a7859; font-size: 9px; letter-spacing: 2px; }
.wall-toolbar { padding: 12px 15px; background: #eae8dc; border: 1px solid var(--line); border-radius: 5px; gap: 14px; }
@@ -243,3 +246,51 @@ footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
footer { gap: 16px; flex-wrap: wrap; font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
.household-actions{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0}.household-context select{max-width:100%}.household-context label{min-width:0}
.device-actions,.device-search-row,.device-group-order,.group-step{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.device-search-row{align-items:end}.device-search-row label{flex:1;min-width:160px}.device-group-order{margin:12px 0}.group-step{padding:7px;background:var(--paper);border:1px solid var(--line);border-radius:5px}.group-step select{width:auto;max-width:145px}.group-step button{padding:7px 11px}.group-step>span{color:var(--muted)}
.device-group{margin:26px 0}.device-group>h3{font-family:var(--serif);font-size:20px;overflow-wrap:anywhere}.device-group>h3>span{color:var(--gold);padding:0 8px}.device-group>h3 small{font:12px var(--sans);color:var(--muted);white-space:nowrap}.device-tags{display:flex;flex-wrap:wrap;gap:6px}.device-tags span{font-size:11px;padding:3px 8px;background:var(--paper);border:1px solid var(--line)}.site-card dd,.site-card .description{overflow-wrap:anywhere;min-width:0}
@media(max-width:600px){.device-actions{width:100%}.device-actions button{flex:1;white-space:normal}.group-step{width:100%}.group-step select{flex:1;max-width:none;min-width:0}.device-group>h3{font-size:18px}}
/* Contextual steward navigation and one-device workspaces. */
#scope-navigation{border-left:1px solid #6d8270;margin:12px 0 12px 10px;padding-left:9px}#scope-navigation .nav-label{padding:10px 12px;overflow-wrap:anywhere}
.device-context{padding:18px 22px;margin:20px 0;border:1px solid var(--line);border-left:4px solid var(--accent);background:var(--panel)}.device-context p{color:var(--muted);overflow-wrap:anywhere;margin:0 0 14px}.device-context [role=group]{display:flex;flex-wrap:wrap;gap:8px}.device-context button.active{color:var(--paper);background:var(--accent)}
.device-title{border:0;padding:0;background:transparent;color:var(--ink);font:inherit;text-align:left;box-shadow:none;white-space:normal;overflow-wrap:anywhere}.device-title:hover{color:var(--accent);background:transparent}.device-title span{color:var(--accent)}
.scope-intro{padding:28px 0}.scope-intro h2{font:500 32px var(--serif);margin:12px 0}.scope-intro p{color:var(--muted);line-height:1.8}.steward-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.steward-tile{display:flex;flex-direction:column;text-align:left;gap:12px;min-height:190px;padding:28px;border:1px solid var(--line);border-top:3px solid var(--accent);background:var(--panel);color:var(--ink);white-space:normal;box-shadow:0 3px 12px #22332208}.steward-tile strong{font-size:23px;font-family:var(--serif);font-weight:500}.steward-tile span,.steward-tile small{color:var(--muted);line-height:1.7}.steward-tile:hover{background:var(--paper);border-color:var(--accent)}
#device-detail dl,#heritage-detail dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}#device-detail dl>div,#heritage-detail dl>div{border-bottom:1px solid var(--line);padding-bottom:12px}#device-detail dt,#heritage-detail dt{font-size:12px;color:var(--muted);margin-bottom:6px}#device-detail dd,#heritage-detail dd{margin:0;overflow-wrap:anywhere}.heritage-prose{white-space:pre-wrap;line-height:1.9;overflow-wrap:anywhere;margin:24px 0}#heritage-detail{margin-top:24px}.heritage-controls{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin-bottom:22px}.heritage-controls label{min-width:0}.household-context{flex-wrap:wrap}
@media(max-width:720px){.steward-grid{grid-template-columns:1fr}.steward-tile{min-height:150px;padding:22px}.device-context{padding:16px}.device-context button{flex:1 1 40%;min-width:0}#device-detail dl,#heritage-detail dl{grid-template-columns:1fr}#scope-navigation{display:flex;flex-wrap:wrap;border:0;margin:0;padding:0;width:100%}#scope-navigation .nav-label{width:100%;padding:8px 4px}.scope-intro h2{font-size:26px}}
/* Installation branding, private presentation choices, and place photo cards. */
#household-list{display:block}.home-city-group{margin:24px 0 36px}.home-city-group>h3{font-size:21px}.home-city-group>h3 small{font:12px sans-serif;color:var(--muted);margin-left:12px}.home-card{padding:0;overflow:hidden}.home-cover{position:relative;min-height:210px;background:linear-gradient(145deg,#4b6957,#253d34);isolation:isolate;color:#fffdf6}.home-cover>img{position:absolute;width:100%;height:100%;object-fit:cover;inset:0;z-index:-2}.home-cover:after{content:"";position:absolute;inset:0;background:linear-gradient(0deg,#102019db,transparent);z-index:-1}.home-cover-content{padding:24px;min-height:210px;display:flex;flex-direction:column;justify-content:end}.home-cover .site-heading{margin-bottom:auto}.home-cover h3{margin:26px 0 4px;color:#fffdf6;text-shadow:0 1px 3px #000a;font-size:25px}.home-cover .quiet{background:#172a25ba;color:#fff;border-color:#bccabd80}.home-cover .badge{background:#f9f5e5e8;color:#244638;border-color:transparent}.home-location{font-size:12px;margin:8px 0 0;color:#fffdf6}.home-cover-placeholder{position:absolute;top:88px;right:22px;opacity:.3;font:18px var(--serif);letter-spacing:5px}.home-card-body{padding:24px}.home-card-body .household-actions{margin-bottom:0}.cover-editor img{width:100%;max-height:230px;object-fit:contain;border:1px solid var(--line);margin-bottom:12px;background:var(--paper)}
#page-settings h3{font-size:21px;margin-top:8px}#page-settings .form-grid{margin:22px 0}#page-settings input:not([type=checkbox]),#page-settings select{width:100%;min-width:0}.brand-image-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin:28px 0}.brand-image-grid>div{border:1px solid var(--line);padding:18px;border-radius:6px;min-width:0}.brand-image-grid img{width:80px;height:80px;object-fit:contain;background:var(--paper);border-radius:6px;display:block;margin-bottom:18px}.brand-image-grid label{display:grid;gap:12px;margin-bottom:14px}.config-import{font-size:12px;max-width:300px}.backup-row{display:grid;gap:8px;padding:18px 0;border-bottom:1px solid var(--line);font-size:12px}.backup-row small,.backup-row code{overflow-wrap:anywhere}.backup-row span{color:var(--muted)}#settings-error,#brand-status,#preferences-status,#backup-save-status,#backup-result{line-height:1.8;font-size:13px;overflow-wrap:anywhere}.brand #brand-name{overflow-wrap:anywhere;letter-spacing:1px}.brand #brand-subtitle{overflow-wrap:anywhere;line-height:1.8;letter-spacing:1px}
html[data-backdrop=plain] body{background-image:none}html[data-backdrop=plain] .courtyard-landscape{display:none}html[data-font=sans]{--serif:"Microsoft YaHei","PingFang SC",system-ui,sans-serif}
html[data-density=compact] main{padding:20px 26px}html[data-density=compact] main>header{min-height:100px;padding:6px 0 15px;margin-bottom:20px}html[data-density=compact] .panel,html[data-density=compact] .site-card:not(.home-card),html[data-density=compact] .home-card-body{padding:17px}html[data-density=compact] .sidebar nav button{padding:9px 12px}html[data-density=compact] .steward-tile{min-height:150px;padding:20px}html[data-density=compact] .metrics>div{padding:13px 18px}html[data-density=compact] .home-cover,html[data-density=compact] .home-cover-content{min-height:180px}
html[data-theme=clean]{--paper:#f3f6fa;--ink:#203349;--muted:#53667d;--panel:#ffffff;--line:#d2dce7;--accent:#285ca0;--gold:#52657f;--cinnabar:#386ea8;--timeline-paper:#edf2f8;--timeline-grid:#cbd6e4;--timeline-range:#4c72a5;--surface-alt:#e8eff8;--side:#1e3551;--on-accent:#ffffff;color-scheme:light}
html[data-theme=ink]{--paper:#151d22;--ink:#e3e9e5;--muted:#afbdb5;--panel:#202c32;--line:#46565a;--accent:#a2cab4;--gold:#d6bd83;--cinnabar:#e0a390;--timeline-paper:#26343b;--timeline-grid:#516369;--timeline-range:#91bda6;--surface-alt:#2a3940;--side:#132020;--on-accent:#13231c;color-scheme:dark}
html[data-theme=clean] body,html[data-theme=ink] body{background-color:var(--paper);color:var(--ink)}
html:is([data-theme=clean],[data-theme=ink]) .sidebar{background:var(--side);border-color:var(--line)}
html:is([data-theme=clean],[data-theme=ink]) main>header{border-color:var(--line)}
html:is([data-theme=clean],[data-theme=ink]) :is(.metrics,.panel,.site-card,.camera-card,.empty,.family-canvas-panel,.scan-chip,dialog){background-color:var(--panel);border-color:var(--line);color:var(--ink)}
html:is([data-theme=clean],[data-theme=ink]) :is(.household-context,.wall-toolbar,.device-table th,.family-view-tabs,.family-map-viewport,#preset-confirmation){background-color:var(--surface-alt);color:var(--ink);border-color:var(--line)}
html:is([data-theme=clean],[data-theme=ink]) :is(input,select,textarea){background:var(--panel);border-color:var(--line);color:var(--ink)}
html:is([data-theme=clean],[data-theme=ink]) :is(input,textarea)::placeholder{color:var(--muted)}
html:is([data-theme=clean],[data-theme=ink]) :is(label,header p,.header-status,.metrics span,.filter-row,.section-head small,.camera-meta p,.empty,.time-labels,.recording-item small,.device-table td small,.site-card dt,.audit-item,.audit-item small,footer,#auth-form p,.family-date legend){color:var(--muted)}
html:is([data-theme=clean],[data-theme=ink]) :is(.metrics strong,.empty h3,.site-card dd){color:var(--ink)}
html:is([data-theme=clean],[data-theme=ink]) button:not(.quiet):not(.secondary):not(.device-title):not(.steward-tile),html:is([data-theme=clean],[data-theme=ink]) .button{background:var(--accent);color:var(--on-accent)}
html:is([data-theme=clean],[data-theme=ink]) :is(.quiet,.secondary,.recording-item,.camera-actions .quiet){background:var(--surface-alt);color:var(--ink);border-color:var(--line)}
html:is([data-theme=clean],[data-theme=ink]) .sidebar nav button{background:transparent;color:#dbe5e4}
html:is([data-theme=clean],[data-theme=ink]) .sidebar nav button.active{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
html:is([data-theme=clean],[data-theme=ink]) .sidebar>.quiet{background:transparent;color:#dbe5e4}
html:is([data-theme=clean],[data-theme=ink]) #auth-dialog{background-image:none;background-color:var(--panel)}
html:is([data-theme=clean],[data-theme=ink]) .device-table tbody tr:hover{background:var(--surface-alt)}
html:is([data-theme=clean],[data-theme=ink]) .family-node{background:var(--panel);border-color:var(--line);color:var(--ink)}
html:is([data-theme=clean],[data-theme=ink]) .family-node.self{background:#ebd6aa;color:#2b3b30;border-color:#b79a59}
html:is([data-theme=clean],[data-theme=ink]) .family-node.self :is(small,span,strong){color:#2b3b30}
html[data-theme=ink] :is(.form-error,.family-warning){color:#ffc8b6}
html[data-theme=ink] .ptz-panel{background:var(--panel);color:var(--ink);border-color:var(--line)}
html[data-theme=ink] .home-cover .quiet{background:#172a25df;color:#fff}
html[data-theme=ink] body{background-image:radial-gradient(ellipse at 90% 0,#263c3340,transparent 65%)}
@media(min-width:761px){html[data-layout=top] .sidebar{position:relative;width:100%;padding:16px 30px;display:flex;align-items:center;flex-wrap:wrap;gap:12px 22px;min-height:0;height:auto;border-right:0;border-bottom:1px solid var(--line)}html[data-layout=top] .sidebar .brand{padding:0;max-width:250px}html[data-layout=top] .sidebar>.nav-label,html[data-layout=top] .side-note{display:none}html[data-layout=top] .sidebar nav{display:flex;flex:1;flex-wrap:wrap;min-width:250px;gap:6px}html[data-layout=top] #scope-navigation{display:flex;flex-wrap:wrap;width:100%;order:1;margin:0;padding:0;border:0}html[data-layout=top] #scope-navigation .nav-label{display:none}html[data-layout=top] .sidebar #current-user{margin:0;padding:0;border:0}html[data-layout=top] .sidebar>.quiet{margin:0}html[data-layout=top] main{margin-left:0;margin-right:auto;margin-left:auto}html[data-layout=top] .sidebar nav button{width:auto;white-space:nowrap}}
@media(max-width:760px){.brand-image-grid{grid-template-columns:1fr}.home-cover-content{padding:20px}.home-card-body{padding:20px}.home-cover-placeholder{font-size:15px}.sidebar #logout{display:block}#page-settings .form-grid{grid-template-columns:1fr}html[data-density=compact] main{padding:18px 14px}#household-form .form-grid{grid-template-columns:1fr}.home-city-group>h3 small{display:block;margin:8px 0}}
+35
View File
@@ -0,0 +1,35 @@
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.ZhaoDevices=api;})(typeof window!=='undefined'?window:this,function(){
'use strict';
function path(sites,id){
const map=new Map(sites.map(s=>[s.id,s])),seen=new Set(),result=[];
while(id&&map.has(id)&&!seen.has(id)){seen.add(id);const s=map.get(id);result.push(s);id=s.parentId;}
return result.reverse();
}
function descendants(sites,id){
const children=new Map();for(const s of sites){if(!children.has(s.parentId))children.set(s.parentId,[]);children.get(s.parentId).push(s.id);}
const found=new Set(),queue=[id];for(let i=0;i<queue.length;i++){const key=queue[i];if(found.has(key))continue;found.add(key);queue.push(...(children.get(key)||[]));}return found;
}
function catalog(state){return [
...(state.assets||[]).map(x=>({...x,source:'camera',deviceType:'摄像机'})),
...(state.recorders||[]).map(x=>({...x,source:'recorder',deviceType:'录像机'})),
...(state.devices||[]).map(x=>({...x,source:'device'}))];}
function groups(devices,dimensions,sites){
const result=new Map();
for(const device of devices){
let branches=[[]];
for(const dimension of dimensions){
let options;
if(dimension==='space'){
const parts=path(sites,device.siteId);options=[parts.length?parts.map(s=>({key:'space:'+s.id,label:s.name})):[{key:'space:none',label:'未指定区域'}]];
}else{
const values=dimension==='tag'?(device.tags?.length?device.tags:['未标记']):[dimension==='type'?device.deviceType:dimension==='manager'?(device.managerName||'负责人待指定'):(device.ownerName||'所有人待指定')];
options=[...new Set(values)].map(value=>[{key:dimension+':'+value,label:value}]);
}
branches=branches.flatMap(branch=>options.map(option=>branch.concat(option)));
}
for(const branch of branches){const key=JSON.stringify(branch.map(x=>x.key));if(!result.has(key))result.set(key,{key,labels:branch.map(x=>x.label),items:[]});result.get(key).items.push(device);}
}
return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN'));
}
return {path,descendants,catalog,groups};
});
+49
View File
@@ -0,0 +1,49 @@
'use strict';
let deviceGroups=['type','space'],deviceGroupScope='',deviceViewSignature='';
const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'};
function renderDeviceGrouping(){
$('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`<div class="group-step"><span>${i+1}</span><select data-group-index="${i}" aria-label="第 ${i+1} 分组">${Object.entries(deviceDimensions).map(([key,label])=>`<option value="${key}" ${key===g?'selected':''} ${deviceGroups.includes(key)&&key!==g?'disabled':''}>${label}</option>`).join('')}</select><button class="quiet" data-group-up="${i}" aria-label="第 ${i+1} 分组前移" ${i===0?'disabled':''}>↑</button><button class="quiet" data-group-remove="${i}" aria-label="移除第 ${i+1} 分组">×</button></div>`).join('');
$('#device-group-add').disabled=deviceGroups.length>=Object.keys(deviceDimensions).length;
}
function deviceCard(d){
const location=ZhaoDevices.path(state.sites,d.siteId).map(s=>s.name).join(' / ')||'未指定区域';
const attr=d.source==='camera'?'edit-asset':d.source==='recorder'?'edit-recorder':'edit-device';
const detail=d.source==='camera'?`<div><dt>镜头 / 通道</dt><dd>${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道</dd></div>`:d.source==='recorder'?`<div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===d.id||c.archiveRecorderId===d.id).length} 个</dd></div>`:'<div><dt>接入状态</dt><dd>仅登记档案,尚未接入控制</dd></div>';
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span>${isAdmin()?`<button class="quiet" data-${attr}="${esc(d.id)}">编辑</button>`:''}</div><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} <span aria-hidden="true">›</span></button></h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${detail}</dl>${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
}
function renderDevices(){
const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
const signature=JSON.stringify(saved);
if(deviceGroupScope!==state.familyId||deviceViewSignature!==signature){deviceGroupScope=state.familyId;deviceViewSignature=signature;deviceGroups=[...saved];renderDeviceGrouping();}
const search=$('#device-search').value.trim().toLowerCase();
const all=ZhaoDevices.catalog(state),types=[...new Set(all.map(d=>d.deviceType))].sort();
setOptions($('#device-type'),'<option value="">所有类型</option>'+types.map(type=>`<option value="${esc(type)}">${esc(type)}</option>`).join(''));
const category=$('#device-type').value;
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&(!search||[d.name,d.deviceType,d.ownerName,d.managerName,...(d.tags||[])].join(' ').toLowerCase().includes(search)));
$('#device-count').textContent=`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
$('#asset-list').innerHTML=devices.length?ZhaoDevices.groups(devices,deviceGroups,state.sites).map(group=>`<section class="device-group"><h3>${group.labels.map(esc).join(' <span aria-hidden="true">›</span> ')||'全部设备'} <small>${group.items.length} 台</small></h3><div class="site-grid">${group.items.map(deviceCard).join('')}</div></section>`).join(''):empty('当前条件下没有设备','可以清除筛选,或添加摄像机、录像机、灯、手机等设备档案。');
}
function resetDeviceView(){deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='<option value="">所有类型</option>';$('#device-group-order').replaceChildren();}
function responsibilityFields(form,item){for(const key of ['ownerName','managerName'])form.elements[key].value=item?.[key]||'';form.elements.tags.value=(item?.tags||[]).join('、');}
function deviceValues(data){return {...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};}
function openDevice(id){
const item=(state.devices||[]).find(d=>d.id===id)||{},f=$('#device-form');
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);responsibilityFields(f,item);$('#device-dialog').showModal();
}
function spaceOptions(exclude=''){
const omitted=exclude?ZhaoDevices.descendants(state.sites,exclude):new Set();
return state.sites.filter(s=>!omitted.has(s.id)).map(s=>`<option value="${esc(s.id)}">${esc(siteName(s.id))}</option>`).join('');
}
$('#device-search').oninput=renderDevices;$('#device-type').onchange=renderDevices;
$('#device-group-order').addEventListener('change',e=>{if(e.target.dataset.groupIndex!==undefined){deviceGroups[Number(e.target.dataset.groupIndex)]=e.target.value;renderDeviceGrouping();renderDevices();}});
$('#device-group-add').onclick=()=>{const next=Object.keys(deviceDimensions).find(k=>!deviceGroups.includes(k));if(next){deviceGroups.push(next);renderDeviceGrouping();renderDevices();}};
$('#device-view-save').onclick=async()=>{try{await api('/api/device-view',{groups:deviceGroups});await refresh();toast('已保存为本家庭或场所的默认分组');}catch(e){if(!e.stale)toast(e.message);}};
$('#new-device').onclick=()=>openDevice();
handleForm('device-form','/api/devices',deviceValues);
document.addEventListener('click',e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.editDevice)openDevice(b.dataset.editDevice);
if(b.dataset.groupRemove!==undefined){deviceGroups.splice(Number(b.dataset.groupRemove),1);renderDeviceGrouping();renderDevices();}
if(b.dataset.groupUp!==undefined){const i=Number(b.dataset.groupUp);if(i>0){[deviceGroups[i-1],deviceGroups[i]]=[deviceGroups[i],deviceGroups[i-1]];renderDeviceGrouping();renderDevices();}}
});
+4 -3
View File
@@ -67,16 +67,17 @@ for(const [name,path]of [['person','person'],['relation','link']]){
async function loadUsers(){
if(!isAdmin())return;const id=currentUser.id;
try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f;
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱权限</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${u.role==='admin'?'管理员':'个人账号'}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role==='admin'?'管理':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}</td><td>${u.role==='admin'?'全部空间':esc(u.siteIds.map(siteName).join('、')||'未授权')}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱 / 传承资料</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}<small>文化与财产:${u.role!=='member'?'管理':({none:'不可访问',read:'查看',edit:'维护'}[u.heritageAccess||'none'])}</small></td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
}catch(e){toast(e.message);}
}
function openUser(id){
if(!isAdmin())return;const f=$('#user-form'),u=users.find(u=>u.id===id)||{};
f.elements.personId.innerHTML=peopleOptions();f.elements.relatedToId.innerHTML='<option value="">尚未指定</option>'+users.map(u=>`<option value="${u.id}">${esc(u.name+' / '+u.username)}</option>`).join('');
fillForm(f,u);f.elements.password.required=!u.id;
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(s.name)}</label>`).join('');$('#user-dialog').showModal();
f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin();
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(siteName(s.id))}</label>`).join('');$('#user-dialog').showModal();
}
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;d.includeSubspaces=f.elements.includeSubspaces.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#new-person').onclick=()=>openPerson();$('#new-user').onclick=()=>openUser();$('#family-reload').onclick=loadFamily;
$('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderFamily;$('#family-archived').onchange=renderFamily;
$('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
+53
View File
@@ -0,0 +1,53 @@
'use strict';
let heritageKind='culture',heritageData={entries:[],canEdit:false},heritageGeneration=0,heritageSelection='';
pageInfo['heritage-records']=['传承档案','记录资料来源、物品来历与交接意愿。'];
function resetHeritage(){heritageGeneration++;heritageData={entries:[],canEdit:false};heritageSelection='';$('#heritage-list').replaceChildren();$('#heritage-detail').replaceChildren();$('#heritage-detail').hidden=true;$('#heritage-error').textContent='';$('#heritage-new').hidden=true;}
async function loadHeritage(){
const generation=++heritageGeneration,kind=heritageKind;
$('#heritage-list').replaceChildren();$('#heritage-detail').hidden=true;$('#heritage-new').hidden=true;
$('#heritage-records-title').textContent=kind==='culture'?'文化与家风':'家庭物品与财产档案';
$('#heritage-error').textContent='正在读取…';
try{
const data=await api('/api/heritage');
if(generation!==heritageGeneration||activePage!=='heritage-records'||kind!==heritageKind)return;
heritageData=data;$('#heritage-error').textContent='';renderHeritageRecords();
}catch(e){if(!e.stale&&generation===heritageGeneration&&activePage==='heritage-records'){$('#heritage-error').textContent=e.message;heritageData={entries:[],canEdit:false};}}
}
function renderHeritageRecords(){
$('#heritage-new').hidden=!heritageData.canEdit;
const q=$('#heritage-search').value.trim().toLowerCase();
const rows=heritageData.entries.filter(r=>r.kind===heritageKind&&(!r.archived||$('#heritage-archived').checked)&&(!q||[r.name,r.content,r.contributor,r.ownerName,r.custodianName,r.successorName].join(' ').toLowerCase().includes(q)));
$('#heritage-list').innerHTML=rows.length?rows.map(r=>`<article class="site-card"><span class="badge">${r.archived?'已归档':r.kind==='culture'?'文化资料':'财产档案'}</span><h3><button class="device-title" data-heritage-detail="${esc(r.id)}">${esc(r.name)} ›</button></h3><p class="description">${esc((r.content||'尚未填写详细内容').slice(0,120))}</p><small>${esc(r.date||'日期待记')} · ${esc(r.contributor||r.updatedBy||'')}</small></article>`).join(''):empty('从一份档案开始','记录来历、故事与相关人员,资料仅向获授权的本家庭成员开放。');
}
function showHeritageRecord(id){
const r=heritageData.entries.find(x=>x.id===id);if(!r)return;
heritageSelection=id;const device=ZhaoDevices.catalog(state).find(d=>d.id===r.deviceId&&d.source===r.deviceSource);
const fields=[['相关日期',r.date],['讲述 / 整理',r.contributor],...(r.kind==='asset'?[['所有人 / 共有说明',r.ownerName],['保管人',r.custodianName],['意向接收人',r.successorName]]:[])];
$('#heritage-detail').hidden=false;
$('#heritage-detail').innerHTML=`<div class="section-head"><h2>${esc(r.name)}</h2>${heritageData.canEdit?`<button data-heritage-edit="${esc(r.id)}">编辑档案</button>`:''}<button class="quiet" id="heritage-detail-close">收起详情</button></div><dl>${fields.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v||'未填写')}</dd></div>`).join('')}</dl><div class="heritage-prose">${esc(r.content||'')}</div>${r.sourceUrl?`<p><a href="${esc(r.sourceUrl)}" target="_blank" rel="noopener noreferrer">打开相关资料或报道 ↗</a></p>`:''}${device?`<button class="quiet" data-view-device="${esc(device.id)}" data-device-source="${device.source}">查看关联设备:${esc(device.name)}</button>`:''}${r.kind==='asset'?`<h3>交接意愿与说明</h3><div class="heritage-prose">${esc(r.handoverNote||'尚未记录')}</div><p class="description">档案中的交接意愿不自动改变所有权、保管安排或账号权限。</p>`:''}<p class="description">最近整理:${esc(r.updatedBy)} · ${esc(new Date(r.updatedAt).toLocaleString('zh-CN'))} · 版本 ${r.revision}</p>`;
}
function openHeritage(id){
if(!heritageData.canEdit)return;
const r=heritageData.entries.find(x=>x.id===id)||{kind:heritageKind,revision:0};
const f=$('#heritage-form');
f.elements.deviceReference.innerHTML='<option value="">不关联设备</option>'+ZhaoDevices.catalog(state).map(d=>`<option value="${d.source}:${esc(d.id)}">${esc(d.name+' · '+siteName(d.siteId))}</option>`).join('');
fillForm(f,r);f.elements.deviceReference.value=r.deviceId?r.deviceSource+':'+r.deviceId:'';
$('#heritage-form-title').textContent=r.kind==='culture'?'文化资料':'物品与财产档案';
$$('[data-heritage-asset]').forEach(el=>el.hidden=r.kind!=='asset');
$('#heritage-dialog').showModal();
}
$('#heritage-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';
try{const data=formValues(f),[source,id]=(data.deviceReference||'').split(':');delete data.deviceReference;
data.revision=Number(data.revision);data.archived=f.elements.archived.checked;data.deviceSource=source||'';data.deviceId=id||'';
await api('/api/heritage',data);f.closest('dialog').close();await loadHeritage();toast('档案已保存');
}catch(e){if(!e.stale)f.querySelector('.form-error').textContent=e.message;}finally{b.disabled=false;}
});
$('#heritage-new').onclick=()=>openHeritage();$('#heritage-reload').onclick=loadHeritage;
$('#heritage-search').oninput=renderHeritageRecords;$('#heritage-archived').onchange=renderHeritageRecords;
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
if(b.dataset.heritageKind){resetHeritage();heritageKind=b.dataset.heritageKind;$('#heritage-search').value='';$('#heritage-archived').checked=false;showPage('heritage-records');}
if(b.dataset.heritageDetail)showHeritageRecord(b.dataset.heritageDetail);
if(b.dataset.heritageEdit)openHeritage(b.dataset.heritageEdit);
if(b.id==='heritage-detail-close'){$('#heritage-detail').hidden=true;heritageSelection='';}
});
+62
View File
@@ -0,0 +1,62 @@
'use strict';
pageInfo.households=['我的家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
function renderHouseholds(){
const homes=state.households||[];
setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));
$('#household-filter').value=state.familyId||'';
if(activePage!=='households')return;
const cards=h=>`<article class="site-card home-card"><div class="home-cover ${h.coverUrl?'has-photo':''}">${h.coverUrl?`<img src="${esc(h.coverUrl)}" alt="${esc(h.name)}封面照片" loading="lazy">`:'<div class="home-cover-placeholder" aria-hidden="true">山川有归处 · 灯火有人家</div>'}<div class="home-cover-content"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span>${isAdmin()?`<button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button>`:''}</div><h3>${esc(h.name)}</h3><p class="home-location">${esc([h.province,h.city,h.district].filter(Boolean).join(' · ')||'地区待填写')}</p></div></div><div class="home-card-body"><p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">进入${esc(h.name)}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</div></article>`;
const groups=new Map();for(const h of homes){const key=[h.province,h.city||'未填写地级市'].filter(Boolean).join(' / ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(h);}
$('#household-list').innerHTML=[...groups].sort(([a],[b])=>a.localeCompare(b,'zh-CN')).map(([city,rows])=>`<section class="home-city-group"><h3>${esc(city)} <small>${rows.length} 个家庭或场所</small></h3><div class="site-grid">${rows.map(cards).join('')}</div></section>`).join('');
}
async function switchHousehold(id){
if(!isPlatformAdmin()||!id||id===state.familyId)return;
const user=currentUser;
// Invalidate outstanding API responses, video players and dialogs before changing scope.
clearPrivateView();
householdScope=id;currentUser=user;loggedIn=true;
await auth();
}
function openHousehold(id){
if(!isAdmin())return;
const item=(state.households||[]).find(h=>h.id===id);
if(!item&&!isPlatformAdmin())return;
fillForm($('#household-form'),item||{});coverDraft=undefined;$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='照片仅向有权进入该家庭或场所的账号开放。';$('#household-dialog').showModal();
}
async function openLeadership(id){
try{
if(id!==state.familyId)await switchHousehold(id);
const home=(state.households||[]).find(h=>h.id===id);
if(!home?.canAssignOwner)return;
const result=await api('/api/users');
const candidates=result.users.filter(u=>u.role==='family_admin'&&!u.disabled);
const f=$('#leadership-form'),options=candidates.map(u=>`<option value="${esc(u.id)}">${esc(u.name+' / '+u.username)}</option>`).join('');
f.elements.ownerId.innerHTML='<option value="">请选择主要负责人</option>'+options;
f.elements.successorId.innerHTML='<option value="">暂未指定</option>'+options;
fillForm(f,{id:home.id,revision:home.leadershipRevision||0,ownerId:home.ownerId||'',successorId:home.successorId||''});
$('#leadership-dialog').showModal();
}catch(error){if(!error.stale)toast(error.message);}
}
$('#leadership-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
});
$('#household-filter').onchange=async e=>{await switchHousehold(e.target.value);if(loggedIn)showPage('overview');};
$('#new-household').onclick=()=>openHousehold();
$('#household-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{await api('/api/households',{...formValues(f),...(coverDraft!==undefined?{coverData:coverDraft}:{})});f.closest('dialog').close();await refresh();toast('归属档案已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
finally{b.disabled=false;}
});
document.addEventListener('click',async e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.editHousehold)openHousehold(b.dataset.editHousehold);
if(b.dataset.leadership)await openLeadership(b.dataset.leadership);
if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('overview');}
});
$('#household-cover-file').onchange=async e=>{const householdId=$('#household-form').elements.id.value,generation=sessionGeneration;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!$('#household-dialog').open||$('#household-form').elements.id.value!==householdId)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){$('#household-cover-status').textContent=e.message;}};
$('#household-cover-reset').onclick=()=>{coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';};
+48 -25
View File
@@ -2,46 +2,64 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
<title>赵府 · 智能数字管家</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/device-model.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script>
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script><script defer src="/steward.js?v=__ASSET_VERSION__"></script><script defer src="/heritage.js?v=__ASSET_VERSION__"></script><script defer src="/personalization.js?v=__ASSET_VERSION__"></script>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>家和 · 岁岁安宁</small></span></a>
<a class="brand" href="/"><img id="brand-image" class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span><span id="brand-name">赵府</span><small id="brand-subtitle">智能数字管家</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>
<button data-page="households" class="active">⌂ <span>我的家庭与场所</span></button>
<div id="scope-navigation" hidden><div id="scope-nav-name" class="nav-label"></div>
<button data-page="overview">◫ <span>场所总览</span></button>
<button data-page="objects">▣ <span>区域与设备</span></button>
<button data-page="heritage">♧ <span>家庭传承</span></button>
<button data-page="users" data-admin>♙ <span>成员与权限</span></button>
<button data-page="sites">⌂ <span>区域设置</span></button>
</div>
<button data-page="settings">⚙ <span>设置中心</span></button>
<button data-page="storage">▤ <span>平台存储与审计</span></button>
</nav>
<div class="side-note"><span class="dot"></span> 自有部署 · 本地存储<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
</aside>
<main>
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<div id="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>守望</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow" id="brand-motto">赵氏家宅 · 起居有序</div><h1 id="page-title">我的家庭与场所</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<div id="household-context" class="household-context" hidden><label>当前归属 <select id="household-filter" aria-label="当前归属"></select></label><span id="household-context-note"></span><button id="scope-back" class="quiet">返回场所</button></div>
<div id="device-context" class="device-context" hidden><p id="device-breadcrumb"></p><div role="group" aria-label="设备功能"><button class="quiet" data-device-tab="device">设备档案</button><button class="quiet" data-device-tab="live">实时画面</button><button class="quiet" data-device-tab="playback">录像回放</button><button class="quiet" data-device-tab="cameras">镜头与设置</button></div></div><div id="banner" class="banner" hidden></div>
<section class="metrics"><div><span>设备</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">可选择任意区域,包含其下级区域</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
<section id="page-settings" class="page" hidden>
<div class="section-head"><h2>把这里布置成你习惯的样子</h2><button id="settings-reload" class="quiet">重新读取设置</button></div><p id="settings-error" role="status"></p>
<form id="preferences-form" class="panel"><h3>我的后台</h3><p class="description">只影响当前账号,保存后换电脑或手机登录也会沿用。切换选项可以预览,离开未保存的预览会撤销。</p><div id="personal-choices" class="form-grid"></div><p id="preferences-status" role="status"></p><div class="device-actions"><button type="submit">保存我的偏好</button><button type="button" id="personal-follow" class="quiet">全部跟随平台</button><button type="button" id="personal-cancel" class="quiet">撤销预览</button></div></form>
<div id="settings-private" hidden>
<form id="brand-form" class="panel"><h3>发布与品牌配置</h3><p class="description">仅超级管理员可修改。名称、Logo、浏览器图标及登录文案会公开显示;适用于整套系统。已有个人偏好优先于平台默认。</p>
<div class="form-grid"><label>系统名称<input name="name" required maxlength="40"></label><label>系统副标题<input name="subtitle" required maxlength="60"></label><label class="full">页首题词<input name="motto" maxlength="100"></label><label>登录标题<input name="loginTitle" required maxlength="40"></label><label>登录说明<input name="loginNote" maxlength="160"></label><label class="full">页脚说明<input name="footer" maxlength="120"></label></div>
<div class="brand-image-grid"><div><img id="brand-logo-preview" alt="Logo 预览" src="/zhao-icon.png"><label>更换系统 Logo<input id="brand-logo-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-logo-reset" class="quiet">使用默认 Logo</button></div><div><img id="brand-icon-preview" alt="浏览器图标预览" src="/zhao-icon.png"><label>更换浏览器图标<input id="brand-icon-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-icon-reset" class="quiet">使用默认图标</button></div></div><p class="description">支持 PNG、JPEG、WebP,原图最多 5 MB;上传时自动缩小,图片随系统数据库保存。</p>
<h3>新账号与跟随平台账号的默认外观</h3><div id="brand-defaults" class="form-grid"></div><p id="brand-status" role="status"></p><div class="device-actions"><button type="submit">保存发布配置</button><button type="button" id="brand-export" class="quiet">导出发布配置</button><label class="config-import">导入配置到表单<input id="brand-import" type="file" accept="application/json,.json"></label></div><p class="description">导入后先核对再保存。导出的配置只含品牌与默认外观,不含账号、设备凭据、家庭资料和网络备份目录。</p>
</form>
<section class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>每个目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。百度网盘需先有挂载或同步服务;网页地址、分享链接不能直接使用。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本系统生成的旧备份。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
</div>
</section>
<section id="page-overview" class="page" hidden><div id="scope-overview"></div></section><section id="page-device" class="page" hidden><div id="device-detail"></div></section><section id="page-heritage" class="page" hidden><div id="heritage-home"></div></section>
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</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 id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
<div class="panel playback-controls"><label>摄像头<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
<div class="panel playback-controls"><label>镜头 / 通道<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
<div class="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="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div>
</section>
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>器物</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="名称、负责人、所有人、标签"></label></div><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div><p id="device-count" class="description"></p></div><div id="asset-list"></div></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>家宅</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-heritage-records" class="page" hidden><div class="section-head"><h2 id="heritage-records-title">文化与家风</h2><div><button class="quiet" data-page="heritage">返回家庭传承</button><button id="heritage-new">+ 新建档案</button></div></div><div class="panel heritage-controls"><label>查找档案<input id="heritage-search" type="search" placeholder="名称、内容、相关人员"></label><label class="check"><input id="heritage-archived" type="checkbox">包含归档资料</label><button id="heritage-reload" class="quiet">重新载入</button></div><p id="heritage-error" class="form-error" role="status"></p><div id="heritage-list" class="site-grid"></div><aside id="heritage-detail" class="panel" hidden></aside></section>
<section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
@@ -63,15 +81,20 @@
<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>
<section id="page-households" class="page" hidden><div class="section-head"><h2>各有归属 <small>家庭 · 办公 · 工厂</small></h2><button id="new-household">+ 新建家庭或场所</button></div><p class="description">每个家庭、办公场所或工厂独立管理自己的成员、区域和设备。内部区域与分组顺序由各自定制;管理员只管理自己的归属,超级管理员可管理全部。</p><div id="household-list" class="site-grid"></div></section>
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">这里只管理当前家庭或场所的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。</p><div id="user-list"></div></section>
<footer><span id="brand-footer">赵府 · 智能数字管家</span><span id="version">独立部署 / 数据自有</span></footer>
</main>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
<dialog id="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="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"><option value="手机"><option value="冰箱"><option value="空调"><option value="传感器"><option value="电脑"><option value="其他"></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="heritage-dialog"><form id="heritage-form"><div class="dialog-heading"><h2 id="heritage-form-title">传承资料</h2><button type="button" class="quiet" data-close="heritage-dialog">关闭</button></div><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="kind"><div class="form-grid"><label class="full">档案名称 *<input name="name" required maxlength="120"></label><label>相关日期<input name="date" type="date"></label><label>讲述人 / 整理人<input name="contributor" maxlength="80"></label><label class="full">相关内容与来历<textarea name="content" rows="6" maxlength="8000"></textarea></label><label class="full">资料 / 报道链接<input name="sourceUrl" type="url" maxlength="1500" placeholder="https://"></label><label class="full">关联已有设备<select name="deviceReference"><option value="">不关联设备</option></select></label><label data-heritage-asset>所有人 / 共有说明<input name="ownerName" maxlength="80"></label><label data-heritage-asset>保管人<input name="custodianName" maxlength="80"></label><label data-heritage-asset>意向接收人<input name="successorName" maxlength="80"></label><label class="full" data-heritage-asset>交接意愿与说明<textarea name="handoverNote" rows="3" maxlength="2000"></textarea></label><label class="check full"><input type="checkbox" name="archived">归档(保留资料,可恢复)</label></div><p class="description">这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存档案</button></div></form></dialog>
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>系统角色<select name="role"><option value="member">个人账号</option><option value="admin">管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">个人账号仅能观看已选空间的实时画面和回放;管理员可以管理全部空间。</p></fieldset><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><label>省份<input name="province" maxlength="60" placeholder="例如:山东省"></label><label>地级市<input name="city" maxlength="60" placeholder="例如:烟台市"></label><label>区 / 县 / 县级市<input name="district" maxlength="60" placeholder="例如:莱阳市"></label><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>文化与财产档案权限<select name="heritageAccess"><option value="none">不可访问</option><option value="read">查看资料</option><option value="edit">整理与维护</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<datalist id="relationship-choices"></datalist>
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
+3 -3
View File
@@ -1,11 +1,11 @@
'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 wallStorageKey(){return 'zhaofu-monitor-'+(currentUser?.id||'guest')+'-'+(state.familyId||'')+'-'+(deviceContext?.id||'wall');}
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{}
if(wallOwner!==wallStorageKey()){
wallOwner=wallStorageKey();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';
}
+113
View File
@@ -0,0 +1,113 @@
'use strict';
const appearanceChoices={theme:{paper:'庭院书卷',clean:'清爽明亮',ink:'静夜墨色'},layout:{sidebar:'左侧目录',top:'顶部目录'},density:{comfortable:'舒展',compact:'紧凑'},backdrop:{mountains:'山水背景',plain:'纯色背景'},font:{serif:'宋体标题',sans:'简洁标题'},startPage:{households:'我的家庭与场所',overview:'场所总览',objects:'区域与设备',heritage:'家庭传承'}};
const appearanceLabels={theme:'配色主题',layout:'后台版式',density:'内容疏密',backdrop:'页面背景',font:'标题字体',startPage:'登录后首页'};
const initialDefaults={theme:'paper',layout:'sidebar',density:'comfortable',backdrop:'mountains',font:'serif',startPage:'households'};
let branding={name:'赵府',subtitle:'智能数字管家',motto:'赵氏家宅 · 起居有序',loginTitle:'欢迎回来',loginNote:'输入用户名和密码,进入家庭空间。',footer:'赵府 · 智能数字管家',logoUrl:'/zhao-icon.png',iconUrl:'/zhao-icon.png',defaults:initialDefaults};
let personalValues=Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default'])),personalRevision=0,appearanceOwner='',appearanceGeneration=0,settingsTimer=null;
let brandDraft=null,brandLogo='',brandIcon='',backupRevision=0,coverDraft;
pageInfo.settings=['设置中心','发布配置统一管理,每个账号保留自己的使用习惯。'];
function effectiveAppearance(values=personalValues,defaults=branding.defaults){return Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,appearanceChoices[k][values[k]]?values[k]:appearanceChoices[k][defaults?.[k]]?defaults[k]:initialDefaults[k]]));}
function applyAppearance(values=personalValues,defaults=branding.defaults){
const effective=effectiveAppearance(values,defaults);
for(const key of ['theme','layout','density','backdrop','font'])document.documentElement.dataset[key]=effective[key];
return effective;
}
function applyBranding(){
document.title=branding.name+' · '+branding.subtitle;
$('#brand-name').textContent=branding.name;$('#brand-subtitle').textContent=branding.subtitle;
$('#brand-motto').textContent=branding.motto;$('#brand-footer').textContent=branding.footer;
$('#brand-image').src=branding.logoUrl;$('#brand-image').alt=branding.name+'标识';
$('link[rel="icon"]').href=branding.iconUrl;$('link[rel="apple-touch-icon"]').href=branding.iconUrl;
if(!setup){$('#auth-title').textContent=branding.loginTitle;$('#auth-note').textContent=branding.loginNote;}
applyAppearance();
}
async function loadBranding(){try{branding=await api('/api/branding');applyBranding();}catch(e){if(!e.stale)applyBranding();}}
function onAppearanceSession(preferences){
applyBranding();if(!currentUser||!preferences)return;
const first=appearanceOwner!==currentUser.id;appearanceOwner=currentUser.id;
personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;applyAppearance();
if(first&&activePage==='settings'){loadSettings();return;}
if(first&&activePage==='households'){
let page=effectiveAppearance().startPage;
if(page==='heritage'&&(currentHome()?.kind&&currentHome().kind!=='home'||!isAdmin()&&currentUser.familyAccess==='none'&&!['read','edit'].includes(currentUser.heritageAccess)))page='overview';
if(page!=='households'&&currentHome())showPage(page);
}
}
function resetAppearance(){
appearanceGeneration++;clearTimeout(settingsTimer);settingsTimer=null;appearanceOwner='';brandDraft=null;brandLogo='';brandIcon='';coverDraft=undefined;
personalValues=Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default']));personalRevision=0;applyAppearance();
for(const selector of ['#brand-form','#preferences-form','#backup-form'])$(selector).reset();
$('#settings-private').hidden=true;$('#backup-result').replaceChildren();$('#backup-history').replaceChildren();
}
function appearanceFields(container,values,inherit){
container.innerHTML=Object.entries(appearanceChoices).map(([k,choices])=>`<label>${appearanceLabels[k]}<select name="${k}">${inherit?'<option value="default">跟随平台默认</option>':''}${Object.entries(choices).map(([value,label])=>`<option value="${value}">${label}</option>`).join('')}</select></label>`).join('');
for(const k of Object.keys(appearanceChoices))container.querySelector(`[name="${k}"]`).value=values[k];
}
function fillBrandForm(value){
brandDraft=value;brandLogo=value.logoData||'';brandIcon=value.iconData||'';
const form=$('#brand-form');form.reset();
for(const k of ['name','subtitle','motto','loginTitle','loginNote','footer'])form.elements[k].value=value[k];
appearanceFields($('#brand-defaults'),value.defaults,false);renderBrandImages();
}
function renderBrandImages(){
$('#brand-logo-preview').src=brandLogo||'/zhao-icon.png';$('#brand-icon-preview').src=brandIcon||'/zhao-icon.png';
}
async function loadSettings(){
const generation=++appearanceGeneration;$('#settings-error').textContent='正在读取…';$('#settings-private').hidden=true;
try{
const [preferences,config,backup]=await Promise.all([api('/api/preferences'),isPlatformAdmin()?api('/api/appearance'):null,isPlatformAdmin()?api('/api/backups'):null]);
if(generation!==appearanceGeneration||activePage!=='settings')return;
personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;
appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();
if(config){fillBrandForm(config);fillBackup(backup);$('#settings-private').hidden=false;}
$('#settings-error').textContent='';
}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#settings-error').textContent=e.message;}
}
function readAppearanceFields(container){return Object.fromEntries([...container.querySelectorAll('select')].map(el=>[el.name,el.value]));}
function leaveSettings(){appearanceGeneration++;clearTimeout(settingsTimer);settingsTimer=null;applyAppearance();}
$('#preferences-form').addEventListener('submit',async e=>{
e.preventDefault();const generation=appearanceGeneration,b=e.currentTarget.querySelector('[type=submit]');b.disabled=true;
try{const saved=await api('/api/preferences',{revision:personalRevision,values:readAppearanceFields($('#personal-choices'))});if(generation!==appearanceGeneration)return;
personalValues=saved.values;personalRevision=saved.revision;applyAppearance();$('#preferences-status').textContent='已保存到当前账号,重新登录及换设备后仍生效。';
}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#preferences-status').textContent=e.message;}finally{b.disabled=false;}
});
$('#personal-choices').onchange=()=>applyAppearance(readAppearanceFields($('#personal-choices')));
$('#personal-follow').onclick=()=>{appearanceFields($('#personal-choices'),Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default'])),true);applyAppearance({});$('#preferences-status').textContent='已预览平台默认,点击保存后生效。';};
$('#personal-cancel').onclick=()=>{appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();$('#preferences-status').textContent='已恢复已保存的偏好。';};
$('#settings-reload').onclick=loadSettings;
async function readImage(file,maxSide=512){
if(!file||!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>5*1024*1024)throw Error('请选择 5 MB 以内的 PNG、JPEG 或 WebP 图片');
const bitmap=await createImageBitmap(file);
try{if(bitmap.width*bitmap.height>40000000)throw Error('图片尺寸过大,请先缩小');
let scale=Math.min(1,maxSide/Math.max(bitmap.width,bitmap.height));const canvas=document.createElement('canvas'),limit=maxSide>256?2*1024*1024:256*1024;
for(let attempt=0;attempt<5;attempt++){
canvas.width=Math.max(1,Math.round(bitmap.width*scale));canvas.height=Math.max(1,Math.round(bitmap.height*scale));
canvas.getContext('2d').drawImage(bitmap,0,0,canvas.width,canvas.height);const result=canvas.toDataURL('image/png');
if((result.length-22)*.75<=limit)return result;scale*=.8;
}
throw Error('图片转换后仍过大,请选择较小的图片');
}finally{bitmap.close();}
}
for(const [id,type,max] of [['brand-logo-file','logo',256],['brand-icon-file','icon',96]]){
$('#'+id).onchange=async e=>{const generation=appearanceGeneration;try{const image=await readImage(e.target.files[0],max);if(generation!==appearanceGeneration)return;if(type==='logo')brandLogo=image;else brandIcon=image;renderBrandImages();$('#brand-status').textContent='图片已准备,保存发布配置后使用。';}catch(e){if(generation===appearanceGeneration)$('#brand-status').textContent=e.message;}};
}
$('#brand-logo-reset').onclick=()=>{brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{brandIcon='';renderBrandImages();};
function brandPayload(){const form=$('#brand-form');return {...Object.fromEntries(['name','subtitle','motto','loginTitle','loginNote','footer'].map(k=>[k,form.elements[k].value])),defaults:readAppearanceFields($('#brand-defaults')),logoData:brandLogo,iconData:brandIcon,revision:brandDraft?.revision};}
$('#brand-form').addEventListener('submit',async e=>{
e.preventDefault();const b=e.currentTarget.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;
try{const result=await api('/api/appearance',brandPayload());if(generation!==appearanceGeneration)return;fillBrandForm(result);await loadBranding();if(generation!==appearanceGeneration)return;$('#brand-status').textContent='发布配置已保存。已有个人偏好继续保留。';}
catch(e){if(!e.stale&&generation===appearanceGeneration)$('#brand-status').textContent=e.message;}finally{b.disabled=false;}
});
$('#brand-export').onclick=()=>{if(!brandDraft)return;const {revision,...value}=brandPayload(),blob=new Blob([JSON.stringify({format:'digital-steward-appearance',version:1,config:value},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='digital-steward-appearance.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
$('#brand-import').onchange=async e=>{const generation=appearanceGeneration;try{const file=e.target.files[0];if(!file||file.size>1024*1024)throw Error('配置文件须小于 1 MB');const value=JSON.parse(await file.text());if(generation!==appearanceGeneration)return;if(value.format!=='digital-steward-appearance'||value.version!==1||!value.config||!brandDraft)throw Error('发布配置格式不正确');const config=value.config;if(Object.keys(config).sort().join('|')!==['name','subtitle','motto','loginTitle','loginNote','footer','defaults','logoData','iconData'].sort().join('|'))throw Error('配置字段不匹配');for(const k of Object.keys(appearanceChoices))if(!appearanceChoices[k][config.defaults?.[k]])throw Error('默认外观选项不正确');for(const k of ['logoData','iconData'])if(config[k]&&!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(config[k]))throw Error('图片格式不正确');fillBrandForm({...config,revision:brandDraft.revision});$('#brand-status').textContent='已读入表单,请核对名称、图片与默认外观后保存。';}catch(e){if(generation===appearanceGeneration)$('#brand-status').textContent=e.message;}};
function fillBackup(value){const c=value.config,f=$('#backup-form');backupRevision=c.revision;f.elements.enabled.checked=c.enabled;for(const k of ['time','keep','networkPath'])f.elements[k].value=c[k];renderBackup(value);}
function backupLabel(r){return !r?'尚未备份':r.error?(r.localOk?'本机成功;网络复制或清理未完成':'备份失败'):r.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';}
function renderBackup(value){
$('#backup-run').disabled=value.running;$('#backup-result').textContent=value.running?'正在生成数据库副本…':backupLabel(value.last)+(value.last?.error?':'+value.last.error:'');
$('#backup-local-path').textContent='本机目录:'+value.localPath;
$('#backup-history').innerHTML=value.history.map(r=>`<article class="backup-row"><strong>${esc(backupLabel(r))}</strong><span>${esc(new Date(r.at).toLocaleString('zh-CN'))}</span><small>${esc(r.file||'')} ${r.bytes?Math.round(r.bytes/1024)+' KB':''}</small>${r.sha256?`<details><summary>完整性校验值</summary><code>${esc(r.sha256)}</code></details>`:''}</article>`).join('');
clearTimeout(settingsTimer);if(value.running&&activePage==='settings')settingsTimer=setTimeout(pollBackup,2000);
}
async function pollBackup(){const generation=appearanceGeneration;try{const data=await api('/api/backups');if(generation===appearanceGeneration&&activePage==='settings')renderBackup(data);}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-result').textContent=e.message;}}
$('#backup-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;try{const data=await api('/api/backups/config',{revision:backupRevision,enabled:f.elements.enabled.checked,time:f.elements.time.value,keep:Number(f.elements.keep.value),networkPath:f.elements.networkPath.value.trim()});if(generation!==appearanceGeneration)return;fillBackup(data);$('#backup-save-status').textContent='备份设置已保存。';}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-save-status').textContent=e.message;}finally{b.disabled=false;}});
$('#backup-run').onclick=async()=>{const generation=appearanceGeneration;$('#backup-run').disabled=true;try{await api('/api/backups/run',{});if(generation===appearanceGeneration)pollBackup();}catch(e){if(!e.stale&&generation===appearanceGeneration){$('#backup-result').textContent=e.message;$('#backup-run').disabled=false;}}};
+66
View File
@@ -0,0 +1,66 @@
'use strict';
let deviceContext=null,scopeEntered=false;
Object.assign(pageInfo,{overview:['场所总览','人、区域与设备,在这里各有归属。'],device:['设备档案','查看这台设备的位置、责任人与功能。'],heritage:['家庭传承','把人物、记忆、文化与物品的来历留存下来。']});
function currentHome(){return (state.households||[]).find(h=>h.id===state.familyId);}
function selectedDevice(){return deviceContext?ZhaoDevices.catalog(state).find(d=>d.id===deviceContext.id&&d.source===deviceContext.source):null;}
function deviceCameras(cameras,context=deviceContext){
if(!context)return cameras;
if(context.source==='camera')return cameras.filter(c=>c.assetId===context.id);
if(context.source==='recorder')return cameras.filter(c=>c.recorderId===context.id||c.archiveRecorderId===context.id);
return [];
}
function enterDevice(source,id,tab='device'){
const device=ZhaoDevices.catalog(state).find(d=>d.source===source&&d.id===id);
if(!device){toast('该设备不存在或未获授权');return;}
stopPlayers();resetPlayback();if(typeof closePtz==='function')closePtz();
$('#site-filter').value='';deviceContext={source,id};scopeEntered=true;
showPage(tab==='device'&&source==='camera'&&deviceCameras(state.cameras).length?'live':tab);
}
function renderSteward(){
const home=currentHome(),device=selectedDevice(),within=loggedIn&&scopeEntered&&!!home;
if(deviceContext&&!device&&loggedIn){deviceContext=null;stopPlayers();resetPlayback();showPage('objects');return;}
$('#scope-navigation').hidden=!within;
$('#scope-nav-name').textContent=home?.name||'';
$('#household-context').hidden=!within;
$('#household-context-note').textContent=home?scopeKind(home)+' · '+home.name:'';
$('#device-context').hidden=!device||!['device','live','playback','cameras'].includes(activePage);
$('#wall-context').hidden=activePage!=='live'||!!device;
$('#device-breadcrumb').textContent=device?[home?.name,siteName(device.siteId),device.name].filter(Boolean).join(' / '):'';
$('#scope-back').textContent='返回 '+(home?.name||'场所');
const hasVideo=device&&['camera','recorder'].includes(device.source);
$$('[data-device-tab]').forEach(b=>{b.hidden=b.dataset.deviceTab!=='device'&&(!hasVideo||b.dataset.deviceTab==='cameras'&&!isAdmin());b.classList.toggle('active',b.dataset.deviceTab===activePage);});
if(device&&['device','live','playback','cameras'].includes(activePage)){
$('#page-title').textContent=device.name;
$('#page-note').textContent=pageInfo[activePage][0]+' · '+(home?.name||'');
}
if(activePage==='live')$('#live-heading').textContent=device?'实时画面 · 镜头':'本场所监控墙';
if(activePage==='overview')renderOverview();
if(activePage==='device')renderDeviceDetail(device);
if(activePage==='heritage')renderHeritageHome();
const homeKind=!home?.kind||home.kind==='home';
$('nav [data-page="heritage"]').hidden=!homeKind||(!isAdmin()&&currentUser?.familyAccess==='none'&&currentUser?.heritageAccess!=='read'&&currentUser?.heritageAccess!=='edit');
}
function renderOverview(){
const home=currentHome();if(!home){$('#scope-overview').replaceChildren();return;}
const devices=ZhaoDevices.catalog(state),homeKind=!home.kind||home.kind==='home';
$('#scope-overview').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(scopeKind(home))} · 日常有序</span><h2>${esc(home.name)}</h2><p>${esc(home.note||'从每一处空间,照看每一件事物。')}</p></div><div class="steward-grid"><button class="steward-tile" data-page="objects"><strong>区域与设备</strong><span>${devices.length} 台设备 · ${state.sites.length} 处区域</span><small>按位置、类型或负责人查找,再进入具体设备</small></button><button class="steward-tile" data-open-wall><strong>本场所监控墙</strong><span>${state.cameras.length} 个已授权镜头</span><small>集中观看、排列或全屏;回放可进入对应摄像头</small></button>${homeKind&&(isAdmin()||currentUser?.familyAccess!=='none'||['read','edit'].includes(currentUser?.heritageAccess))?'<button class="steward-tile" data-page="heritage"><strong>家庭传承</strong><span>人物 · 记事 · 文化 · 物品</span><small>从家谱追溯记忆,也保存资料与物品来历</small></button>':''}${isAdmin()?'<button class="steward-tile" data-page="users"><strong>成员与权限</strong><span>访问范围 · 管理职责</span><small>亲属关系、责任归属与系统权限分别设置</small></button>':''}</div>`;
}
function renderDeviceDetail(device){
if(!device){$('#device-detail').innerHTML=empty('设备暂不可用','返回设备目录重新选择');return;}
const fields=[['类型',device.deviceType],['所属场所',currentHome()?.name],['所在区域',siteName(device.siteId)],['所有人',device.ownerName||'待指定'],['负责人',device.managerName||'待指定'],['品牌 / 型号',[device.brand,device.model].filter(Boolean).join(' / ')||'待填写'],['序列号',device.serial||'待填写']];
const attr=device.source==='camera'?'edit-asset':device.source==='recorder'?'edit-recorder':'edit-device';
$('#device-detail').innerHTML=`<section class="panel"><div class="section-head"><h2>一物一档</h2>${isAdmin()?`<button class="quiet" data-${attr}="${esc(device.id)}">编辑设备档案</button>`:''}</div><dl>${fields.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl><p class="description">${esc(device.note||'')}</p>${device.source==='device'?'<p class="description">当前已登记档案;自动控制需按实际设备协议接入。</p>':''}${device.source==='recorder'?'<p class="description">可查看已关联镜头。录像机硬盘的历史检索与回放仍待设备适配。</p>':''}</section>`;
}
function renderHeritageHome(){
const canFamily=isAdmin()||currentUser?.familyAccess!=='none',canHeritage=isAdmin()||['read','edit'].includes(currentUser?.heritageAccess);
$('#heritage-home').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(currentHome()?.name||'')} · 薪火相传</span><h2>留住来处,也照看未来</h2><p>人物、记事、文化资料与家庭物品分别保存,关联同一个家庭。</p></div><div class="steward-grid">${canFamily?'<button class="steward-tile" data-family-section="tree"><strong>人物与家谱</strong><span>亲属关系 · 人物档案</span><small>从祖辈到后辈,查看全家关系</small></button><button class="steward-tile" data-family-section="timeline"><strong>家庭记事</strong><span>时间线 · 相关人物</span><small>追溯重要时刻与详细记载</small></button>':''}${canHeritage?'<button class="steward-tile" data-heritage-kind="culture"><strong>文化与家风</strong><span>家训 · 故事 · 手艺 · 资料</span><small>记录来源、整理人和相关内容</small></button><button class="steward-tile" data-heritage-kind="asset"><strong>家庭物品与财产档案</strong><span>来历 · 保管 · 交接意愿</span><small>档案记录不自动改变所有权或系统权限</small></button>':'<p class="description">文化与财产档案须由家庭管理员另行授权。</p>'}</div>`;
}
function resetSteward(){deviceContext=null;scopeEntered=false;for(const id of ['scope-overview','device-detail','heritage-home'])$('#'+id).replaceChildren();}
document.addEventListener('click',e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.viewDevice)enterDevice(b.dataset.deviceSource,b.dataset.viewDevice);
if(b.dataset.deviceTab)showPage(b.dataset.deviceTab);
if(b.hasAttribute('data-open-wall')){deviceContext=null;showPage('live');}
if(b.id==='scope-back')showPage('overview');
if(b.dataset.familySection){familyView=b.dataset.familySection;showPage('family',{keepFamilyView:true});}
});