Compare commits

...

6 Commits

35 changed files with 2745 additions and 66 deletions
+2
View File
@@ -5,6 +5,8 @@ runtime/
*.log
*.key
*.env
baidu-secrets.json
baidu-secrets.partial
__pycache__/
.venv/
mediamtx
+24
View File
@@ -98,3 +98,27 @@
区域以 `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 依赖挂载自身的超时和可用性;不会挂载网盘、保存网盘登录密码或声称云端同步已确认。服务实例必须单进程部署,跨进程任务互斥尚不支持。
+75 -1
View File
@@ -1,5 +1,31 @@
# 赵府智家 · 视觉系统
## v0.1.29 · 记事预览、完整地域与云备份保留
- 记事照片默认显示,点击放大;语音、视频自动加载播放信息,提供播放、暂停、进度和音量控制,不自动出声。页面原生播放器按需缓冲,图片延迟解码;媒体仍保存在百度网盘,由同源鉴权接口转发,服务器不保存个人媒体文件。
- 本次附件采用卡片,支持预览、从本条移除、删除和恢复。附件库默认折叠。保存成功清空文字与本次附件、结束录音,再读取下方个人阅历;失败则保留草稿。附件库删除是可恢复的整理操作,保留已发布引用与网盘原文件;彻底删除云文件仍由用户在网盘管理。
- 籍贯(祖籍)与出生地分别支持国家、省、地级市、区县、乡镇街道、村社区、详细地址及历史地名。层级可留空,原有地域文本保留,搜索和修改记录涵盖新字段;升级不会猜测或覆盖已有地址。
- 百度数据库备份保留最近 **一个自然月**(北京时间,以备份文件时间为准,月末按上月最后一天处理)。上传成功后流式读回校验 SHA-256,再清理本应用、当前实例目录下的过期 SQLite 文件;最新副本、其他实例和个人媒体不参与清理。上传或校验失败不清理旧云备份,清理失败单独报告,可在下次备份重试。新备份使用应用目录下的 `数据库备份/实例/文件.sqlite`,兼容旧版多一级同名目录的文件。
- 本机和挂载目录仍按设置中的份数保留;每日自动备份开关与时间保持用户原设置。不会自动清空百度回收站。云备份是完整数据库,含所有家庭数据及设备配置;私有 OAuth 凭据文件不在其中。
验证:136 项 Python 回归、98 项 JavaScript 回归;浏览器验收包括默认照片与音视频控件、预览与 Esc、附件删除恢复、保存后草稿清空及地域分别保存。接口依据:[百度官方 SDK 文件管理](https://github.com/baidu-netdisk/baidu-drive-sdk-go/blob/main/baidudriver/api/file_manager.go)、[目录分页](https://github.com/baidu-netdisk/baidu-drive-sdk-go/blob/main/baidudriver/api/file_list.go)。
## v0.1.28 · 个人记事与百度网盘
- 场所内新增「记事与阅历」。文字、日期、分类、作者、可见范围、点赞和评论存于本机数据库,同一条记事可从动态或个人阅历查询。支持关键词、人物、分类及日期筛选,每页 20 条。默认仅自己可见,选择「当前家庭/场所」才会共享;其他家庭不可查看,超级管理员的普通记事 API 也不越过他人的私密范围。
- 支持照片、视频、音频附件和浏览器直接录音。每条最多 9 个附件,单文件上限 256 MiB,录音最多 5 分钟;录音需要 HTTPS 与用户的麦克风授权。只上传原始媒体,不进行服务端转码或语音识别;实际播放还取决于浏览器编码支持。
- **一个后台百度网盘账号统一存放媒体**,应用账号与网盘账号分别管理。用户权限通过本平台的家庭归属和记事可见范围检查。该网盘账号持有人可以直接管理网盘中的全部文件。开发者应用的个人场景限制以百度后台为准。
- 个人记事的媒体不写入服务器磁盘。浏览器逐个计算 4 MiB 分片 MD5,以 512 KiB 请求发送;服务器最多保留两个 4 MiB 中转缓冲,转发至百度预上传/分片上传/创建文件接口。中断后选择同一原文件,重新读取云端缺失分片继续。数据库仅保存附件归属、名称、大小、校验值、网盘路径和文件 ID 等元数据,不保存媒体内容或永久公开分享链接。
- 展示时逐次检查记事访问权限,再获取临时下载地址并同源转发。照片和音视频的加载交互已在 v0.1.29 更新,浏览器负责展示与缓冲。退出、离开记事页或切换家庭会释放页面媒体资源。不使用服务端文件缓存、Service Worker 离线缓存或浏览器本地存储保存附件。删除记事/移除引用不删除网盘原文件;云端整理在百度网盘进行。
- 设置中心由超级管理员填写 AppKey、SecretKey、百度后台应用名称及 `https://你的域名/api/baidu/callback`。回调地址须在百度开发者后台登记且完全一致;通过这个 HTTPS 域名登录后发起授权。SignKey 不用于当前 OAuth 接入。授权 state 一次性、10 分钟有效,并绑定原登录会话与浏览器 HttpOnly/Lax 回调 Cookie;原账号退出、停用或降权后不可完成授权。重新连接须使用原网盘账号,避免已有附件失联。
- AppKey、SecretKey 和网盘令牌保存在 `VISION_DATA/baidu-secrets.json`,权限 0600;不进入 Git、品牌配置导出或数据库备份。主机管理员能读取该文件。数据库恢复后需要重新配置并授权原网盘;请单独安全保管开发者凭据。授权失败不会使本系统账号退出登录。
- 数据库备份可独立勾选「同时上传到百度网盘」,默认关闭。本机 SQLite 一致性副本校验后上传,界面分别报告本机、网络目录和百度网盘结果。数据库备份包含各家庭的业务数据和设备连接配置,因此仅超级管理员可操作;不含个人媒体原文件。云端保留规则已在 v0.1.29 更新,本机保留份数只作用于本机和挂载目录。
- 稳定性:家庭档案使用独立修订号防止相互覆盖;Logo、图标及封面采用最新图片任务校验,取消或重新选择后旧解码结果不会覆盖新选择;备份配置读取失败也会释放执行状态。
升级会新增 `journal_entries`、`journal_media`、`journal_comments` 和 `journal_likes` 表,现有设备、家谱和家庭数据保持原样。当前版本的「个人阅历」以系统账号为人物主体,尚未提供导入网盘已有文件、自动关联祖先人物、分享链接或语音转文字。自定义品牌小图及家庭封面仍沿用原有外观配置,新的记事附件全部使用上述云端流程。
回归验证增加 `python3 -m unittest -q test_journal` 与 `node --test test_journal.js`,应和原有完整测试集一起执行。接口参考:[百度 OAuth](https://pan.baidu.com/union/doc/al0rwqzzl)、[上传](https://pan.baidu.com/union/doc/3ksg0s9ye)、[下载](https://pan.baidu.com/union/doc/pkuo3snyp)及[百度网盘官方 Go SDK](https://github.com/baidu-netdisk/baidu-drive-sdk-go)。真实上传、播放和云端备份仍须在合法网盘授权后验收;模拟接口测试不能代替真实平台验收。
运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。
## 界面风格
@@ -54,7 +80,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml
```
回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets test_households` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js test_households_ui.js test_devices.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
回归测试:`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 使用管理员与个人账号分权。
@@ -209,3 +235,51 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
- 调整「依次分组」,可保存「类型 → 区域」「区域 → 类型」「负责人 → 类型 → 区域」等顺序为当前归属的默认视图。支持五种维度自由组合:类型、区域、负责人、所有人、标签。区域维度按完整自定义层级展示;五种维度不限制区域本身层数。设备可在多个标签组出现,总设备数仍按档案计算。
- 区域筛选默认包含其后代。账号权限另行设置:默认只授权明确勾选的区域,可显式勾选「包含所有下级区域(含以后新增)」。服务器实时计算后代集合;移动区域会立即改变其继承的访问范围。跨家庭/场所隔离始终生效。
- 灯、手机、冰箱等目前支持档案、位置、责任归属及标签管理,不能通过登记档案直接控制硬件。需按设备实际协议接入控制功能。
## 智能数字管家的入口与传承档案(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.23
0.1.29
+137 -4
View File
@@ -33,6 +33,11 @@ import identity
import genealogy
import households
import inventory
import heritage
import appearance
import backups
import baidu
import journal
import ptz
ROOT = Path(__file__).resolve().parent
@@ -77,6 +82,11 @@ 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__])
BAIDU = baidu.Baidu(sys.modules[__name__])
JOURNAL = journal.Journal(sys.modules[__name__])
def setting(key, default=None):
@@ -513,6 +523,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', ''))
@@ -614,21 +633,65 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
try:
path = url.urlsplit(self.path).path
if path == '/api/journal/chunk':
self.validate_origin(); self.require_auth()
query = dict(url.parse_qsl(url.urlsplit(self.path).query))
try:
size = int(self.headers.get('Content-Length', '0')); offset = int(query.get('offset', '-1'))
except ValueError: raise Problem('上传分片参数不正确')
if not 0 < size <= journal.CHUNK: raise Problem('上传分片大小不正确')
self.connection.settimeout(45)
raw = self.rfile.read(size)
if len(raw) != size: raise Problem('分片未完整接收,请重试')
self.answer(JOURNAL.chunk(self.user, query.get('id'), offset, raw))
return
# 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','/api/journal') 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'):
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',
'/api/journal', '/api/journal/action', '/api/journal/upload', '/api/journal/finish', '/api/journal/remove-upload', '/api/journal/restore-upload'):
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/baidu/config':
self.require_admin(); self.answer(BAIDU.configure(data, self.user))
elif path == '/api/baidu/connect':
self.require_admin()
result, cookie = BAIDU.begin(self.user, 'media', self.token(), self.headers.get('Host'))
self.answer(result, cookie=cookie)
elif path == '/api/baidu/disconnect':
self.require_admin(); self.answer(BAIDU.disconnect(self.user, 'media'))
elif path == '/api/journal':
self.answer(JOURNAL.save(self.user, data))
elif path == '/api/journal/action':
self.answer(JOURNAL.action(self.user, data))
elif path == '/api/journal/upload':
self.answer(JOURNAL.begin_upload(self.user, data))
elif path == '/api/journal/finish':
self.answer(JOURNAL.finish_upload(self.user, data.get('id')))
elif path == '/api/journal/remove-upload':
self.answer(JOURNAL.remove_upload(self.user, data.get('id')))
elif path == '/api/journal/restore-upload':
self.answer(JOURNAL.restore_upload(self.user, data.get('id')))
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')
@@ -674,6 +737,8 @@ class Handler(BaseHTTPRequestHandler):
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'):
@@ -813,11 +878,30 @@ 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 == baidu.CALLBACK:
try:
BAIDU.callback(query, self.headers.get('Cookie', '')); result = 'ok'
except Problem: result = 'failed'
self.send_response(303)
self.send_header('Location', '/?baidu='+result)
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('Set-Cookie', 'baidu_flow=; Path=/api/baidu/callback; HttpOnly; SameSite=Lax; Secure; Max-Age=0')
self.send_header('Content-Length', '0'); self.end_headers()
return
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()})
@@ -826,10 +910,54 @@ class Handler(BaseHTTPRequestHandler):
self.require_auth()
if path == '/api/state':
self.answer(ACCOUNTS.state(self.user, status()))
elif path == '/api/baidu/config':
self.require_admin(); self.answer(BAIDU.public_config())
elif path == '/api/baidu/status':
self.answer(BAIDU.status(self.user))
elif path == '/api/journal':
self.answer(JOURNAL.snapshot(self.user, query))
elif path == '/media/journal':
self.user = HOUSEHOLDS.context(self.user, query.get('family'))
item = JOURNAL.media(self.user, query.get('id'))
with BAIDU.open_file('shared', item, self.headers.get('Range', '')) as response:
if response.status not in (200, 206): raise Problem('网盘媒体暂时不可用', 502)
self.send_response(response.status)
self.send_header('Content-Type', item['mime'])
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('Accept-Ranges', 'bytes')
self.send_header('Content-Disposition', ('attachment' if query.get('download') == '1' else 'inline')+"; filename*=UTF-8''"+url.quote(item['name']))
for header in ('Content-Length', 'Content-Range'):
if response.headers.get(header): self.send_header(header, response.headers[header])
self.end_headers()
remaining = item['size']
try:
while remaining > 0:
chunk = response.read(min(65536, remaining))
if not chunk: break
self.wfile.write(chunk); remaining -= len(chunk)
except (OSError, http.client.HTTPException): self.close_connection = True
elif path == '/api/preferences':
self.answer(APPEARANCE.preferences(self.user))
elif path == '/api/appearance':
self.require_admin()
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':
@@ -917,6 +1045,8 @@ class Handler(BaseHTTPRequestHandler):
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', '/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',
'/journal.js': 'journal.js', '/file-hash.js': 'file-hash.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',
@@ -955,6 +1085,8 @@ def initialize():
FAMILY.initialize()
ACCOUNTS.initialize()
HOUSEHOLDS.initialize()
HERITAGE.initialize()
JOURNAL.initialize()
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
if not setting('mediaSecret'):
@@ -970,6 +1102,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)
+230
View File
@@ -0,0 +1,230 @@
"""Online SQLite snapshots with optional copy to a pre-mounted network directory."""
import datetime as dt
import calendar
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
self.stage = ''
def config(self):
return {'baidu': False, **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, stage=self.stage,
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):
data = {'baidu': False, **data}
if set(data) != {'enabled','time','keep','networkPath','revision','baidu'}:
raise self.a.Problem('备份设置字段不完整')
with self.guard:
if self.running:
raise self.a.Problem('备份正在执行,请完成后再调整设置', 409)
if not isinstance(data['baidu'], bool): raise self.a.Problem('网盘备份设置不正确')
if data['baidu']: self.a.BAIDU.token('shared')
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
try: threading.Thread(target=self.run, args=(reason,), daemon=True).start()
except Exception:
with self.guard: self.running = False
raise
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()
@staticmethod
def month_before(now):
year, month = (now.year, now.month-1) if now.month > 1 else (now.year-1, 12)
return now.replace(year=year, month=month, day=min(now.day, calendar.monthrange(year, month)[1]))
def prune_cloud(self, instance, newest, result, now=None):
now = now or dt.datetime.now(dt.timezone(dt.timedelta(hours=8)))
cutoff = self.month_before(now)
result.update(cloudRetentionMonths=1, cloudDeleted=0, cloudCutoff=cutoff.isoformat())
root = '/apps/'+self.a.BAIDU.public_config()['appFolder']+'/数据库备份/'+instance
pattern = re.compile('vision-'+re.escape(instance)+r'-(\d{8}T\d{12}Z)\.sqlite')
# Collect all pages before deleting: changing a directory while paging can skip entries.
for row in self.a.BAIDU.list_directory(root):
path = row.get('path', '')
if not path.startswith(root+'/'): continue
name = path[len(root)+1:]; match = pattern.fullmatch(name)
if not match: continue
try: created = dt.datetime.strptime(match[1], '%Y%m%dT%H%M%S%fZ').replace(tzinfo=dt.timezone.utc)
except ValueError: continue
if created >= cutoff: continue
candidates = self.a.BAIDU.list_directory(path) if row.get('isdir') == 1 else [row]
for item in candidates:
target = item.get('path', '')
expected = path+'/'+name if row.get('isdir') == 1 else path
if target != expected or target == newest or item.get('isdir') != 0: continue
self.a.BAIDU.delete_backup_file(target)
result['cloudDeleted'] += 1
result['cloudCleanupOk'] = True
def run(self, reason='manual'):
a = self.a
result = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), reason=reason,
localOk=False, networkOk=False, baiduOk=False, networkRequested=False, baiduRequested=False)
temporary = network_temp = None
try:
config = self.config()
result.update(networkRequested=bool(config['networkPath']), baiduRequested=config['baidu'])
self.stage = '正在生成本机数据库副本'
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']:
self.stage = '正在复制到网络目录'
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'])
if config['baidu']:
self.stage = '正在上传百度网盘'
remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/数据库备份/'+instance+'/'+name
uploaded = a.BAIDU.upload('shared', target, remote)
self.stage = '正在校验百度网盘副本'
a.BAIDU.verify_backup(remote, uploaded['fs_id'], result['bytes'], digest)
result.update(baiduOk=True, baiduPath=remote, baiduFileId=str(uploaded['fs_id']))
self.stage = '正在清理超过一个月的云端备份'
try: self.prune_cloud(instance, remote, result)
except Exception as error:
result.update(cloudCleanupOk=False, cloudCleanupError=str(error)[:300] or '过期备份尚未清理')
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
try:
with a.LOCK:
a.set_setting('backup_last', result)
history = a.setting('backup_history', [])
a.set_setting('backup_history', (history+[result])[-100:])
finally:
with self.guard:
self.running = False; self.stage = ''
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)
+356
View File
@@ -0,0 +1,356 @@
"""Baidu OAuth and bounded uploads. Secrets stay in a private file, outside DB backups."""
import datetime as dt
import hashlib
import hmac
import http.cookies
import json
import os
import re
import secrets
import threading
import time
import urllib.error
import urllib.parse as url
import urllib.request
BLOCK = 4 * 1024 * 1024
CALLBACK = '/api/baidu/callback'
API = 'https://pan.baidu.com/rest/2.0/xpan/'
OAUTH = 'https://openapi.baidu.com/oauth/2.0/'
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def download_url(value):
p = url.urlsplit(value)
if (p.scheme != 'https' or p.username or p.password or p.port not in (None, 443)
or not any((p.hostname or '').endswith('.'+d) or p.hostname == d
for d in ('baidu.com', 'baidupcs.com'))):
raise ValueError('网盘下载地址未通过校验')
return value
class DownloadRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
download_url(newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)
class Baidu:
def __init__(self, app):
self.a = app
self.guard = threading.RLock()
self.pending = {}
def read(self):
path = self.a.DATA/'baidu-secrets.json'
if not path.exists():
return dict(config={}, connections={})
return json.loads(path.read_text(encoding='utf8'))
def write(self, value):
path = self.a.DATA/'baidu-secrets.json'
temporary = path.with_suffix('.partial')
with temporary.open('w', encoding='utf8') as stream:
json.dump(value, stream, ensure_ascii=False)
stream.flush(); os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
def key(self, user, purpose='media'):
if purpose == 'backup':
if user['role'] != 'admin':
raise self.a.Problem('数据库云备份仅超级管理员可操作', 403)
return 'shared'
if purpose != 'media':
raise self.a.Problem('网盘用途不正确')
self.a.HOUSEHOLDS.scope(user)
return 'shared'
def public_config(self):
with self.guard:
c = self.read()['config']
return dict(configured=bool(c.get('appKey') and c.get('secretKey')),
appKey=c.get('appKey', ''), hasSecret=bool(c.get('secretKey')),
appFolder=c.get('appFolder', '智家'), redirectUri=c.get('redirectUri', ''),
revision=c.get('revision', 0))
def configure(self, data, user):
a = self.a
if user['role'] != 'admin':
raise a.Problem('开发者应用仅超级管理员可配置', 403)
if set(data) != {'appKey', 'secretKey', 'appFolder', 'redirectUri', 'revision'}:
raise a.Problem('应用配置字段不完整')
with self.guard:
saved = self.read(); old = saved['config']
if type(data['revision']) is not int or data['revision'] != old.get('revision', 0):
raise a.Problem('应用配置已变化,请重新读取', 409)
key = a.clean_text(data['appKey'], 150, True)
secret = a.clean_text(data['secretKey'], 150) or old.get('secretKey', '')
folder = a.clean_text(data['appFolder'], 40, True)
callback = a.clean_text(data['redirectUri'], 400, True)
p = url.urlsplit(callback)
if not secret or not re.fullmatch(r'[A-Za-z0-9]+', key+secret):
raise a.Problem('AppKey 或 SecretKey 格式不正确')
if any(c in folder for c in '/\\:*?"<>|') or folder in ('.', '..'):
raise a.Problem('应用文件夹须与百度后台应用名称一致')
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.query or p.fragment or p.path != CALLBACK:
raise a.Problem('回调地址须为 HTTPS 站点地址加 '+CALLBACK)
changed = any(old.get(k) != v for k, v in dict(appKey=key, secretKey=secret, appFolder=folder, redirectUri=callback).items())
if changed and saved['connections']:
raise a.Problem('已有网盘授权,修改应用前须先解除共享网盘连接', 409)
saved['config'] = dict(appKey=key, secretKey=secret, appFolder=folder,
redirectUri=callback, revision=old.get('revision', 0)+1)
self.write(saved)
if changed: self.pending.clear()
a.audit('配置百度网盘应用', user['username'])
return self.public_config()
def status(self, user, purpose='media'):
key = self.key(user, purpose)
with self.guard:
saved = self.read(); c = saved['config']; connection = saved['connections'].get(key, {})
return dict(configured=bool(c.get('appKey') and c.get('secretKey')),
connected=bool(connection.get('refresh_token')), account=connection.get('name', ''),
reconnect=bool(connection.get('invalid')), purpose=purpose,
redirectUri=c.get('redirectUri', ''), root='/apps/'+c.get('appFolder', '智家'),
connectedAt=connection.get('connectedAt', ''))
def begin(self, user, purpose, session_token, host):
if user['role'] != 'admin':
raise self.a.Problem('共享网盘由超级管理员统一连接', 403)
key = self.key(user, purpose)
with self.guard:
c = self.read()['config']
if not c.get('appKey') or not c.get('secretKey'):
raise self.a.Problem('请先由超级管理员配置百度开发者应用')
if url.urlsplit(c['redirectUri']).netloc != host:
raise self.a.Problem('请通过 '+url.urlsplit(c['redirectUri']).netloc+' 登录系统后连接网盘')
now = time.time()
self.pending = {s: v for s, v in self.pending.items() if v['expires'] > now and v['key'] != key}
if len(self.pending) >= 32:
raise self.a.Problem('授权请求较多,请稍后再试', 429)
state = secrets.token_hex(32)
self.pending[state] = dict(key=key, userId=user['id'], familyId=self.a.HOUSEHOLDS.scope(user),
session=hashlib.sha256(session_token.encode()).hexdigest(),
revision=c['revision'], expires=now+600)
address = OAUTH+'authorize?'+url.urlencode(dict(response_type='code', client_id=c['appKey'],
redirect_uri=c['redirectUri'], scope='basic,netdisk', state=state, display='page'))
return dict(url=address), 'baidu_flow='+state+'; Path=/api/baidu/callback; HttpOnly; SameSite=Lax; Secure; Max-Age=600'
def json_request(self, address, data=None, raw=None, content_type=None):
body = url.urlencode(data).encode() if data is not None else raw
headers = {'User-Agent': 'pan.baidu.com'}
if body is not None: headers['Content-Type'] = content_type or 'application/x-www-form-urlencoded'
req = urllib.request.Request(address, body, headers)
try:
with urllib.request.build_opener(NoRedirect()).open(req, timeout=40) as response:
payload = response.read(2*1024*1024+1)
if len(payload) > 2*1024*1024: raise ValueError()
value = json.loads(payload)
except Exception:
raise self.a.Problem('百度网盘暂时未响应,请稍后重试', 502) from None
if not isinstance(value, dict): raise self.a.Problem('网盘返回内容不完整', 502)
if value.get('error') or value.get('errno', value.get('error_code', 0)):
code = str(value.get('error') or value.get('errno') or value.get('error_code'))
code = code if re.fullmatch('[A-Za-z0-9_-]{1,50}', code) else 'unknown'
if code in ('invalid_grant', 'invalid_token', 'expired_token', '110', '111', '-6', '20016', '20017', '31045'):
error = self.a.Problem('网盘授权已失效,请重新连接('+code+')', 409)
error.reauthorize = True
raise error
raise self.a.Problem('网盘操作未完成('+code+'),请检查应用权限、目录及网盘容量', 502)
return value
def token_response(self, c, **fields):
value = self.json_request(OAUTH+'token?'+url.urlencode(dict(client_id=c['appKey'], client_secret=c['secretKey'], **fields)))
if not value.get('access_token') or not value.get('refresh_token') or not isinstance(value.get('expires_in'), int):
raise self.a.Problem('网盘授权返回不完整,请重新连接', 502)
if 'netdisk' not in value.get('scope', '').split():
# Some responses separate scopes with commas instead of spaces.
if 'netdisk' not in value.get('scope', '').split(','):
raise self.a.Problem('本次未授予网盘访问权限,请重新连接', 403)
return dict(access_token=value['access_token'], refresh_token=value['refresh_token'],
expires=time.time()+max(0, value['expires_in']-120))
def callback(self, query, cookie):
state = query.get('state', '')
try: cookies = http.cookies.SimpleCookie(cookie)
except http.cookies.CookieError: cookies = {}
flow = cookies.get('baidu_flow')
if not re.fullmatch('[a-f0-9]{64}', state) or not flow or not hmac.compare_digest(flow.value, state):
raise self.a.Problem('授权校验失败,请从系统重新发起连接', 403)
with self.guard:
pending = self.pending.pop(state, None)
c = self.read()['config']
if not pending or pending['expires'] < time.time() or pending['revision'] != c.get('revision'):
raise self.a.Problem('授权已过期或已使用,请重新连接', 403)
with self.a.LOCK:
session = self.a.DB.execute('SELECT user_id FROM sessions WHERE hash=? AND expires>?', (pending['session'], time.time())).fetchone()
user = self.a.get_object('users', pending['userId'])
if not session or session[0] != pending['userId'] or not user or user['disabled']:
raise self.a.Problem('原登录已失效,请重新登录后授权', 403)
user = self.a.HOUSEHOLDS.context(user, pending['familyId'])
if user['role'] != 'admin': raise self.a.Problem('管理员权限已变化,请重新授权', 403)
key = self.key(user)
if query.get('error') or not query.get('code'):
raise self.a.Problem('本次未完成网盘授权,可回到系统重新连接')
token = self.token_response(c, grant_type='authorization_code', code=query['code'], redirect_uri=c['redirectUri'])
info = self.json_request(API+'nas?'+url.urlencode(dict(method='uinfo', access_token=token['access_token'])))
uid = str(info.get('uk', ''))
if not uid.isdigit(): raise self.a.Problem('无法确认网盘账号,请重试', 502)
saved = self.read(); old = saved['connections'].get(key, {})
# Re-authorizing a different disk would strand existing attachments.
owner = self.a.setting('baidu_owner:'+key)
if owner and owner != uid:
raise self.a.Problem('请连接原网盘账号;更换账号需先迁移现有资料', 409)
saved['connections'][key] = dict(token, uid=uid, name=str(info.get('baidu_name') or info.get('netdisk_name') or '百度网盘')[:80],
connectedAt=dt.datetime.now(dt.timezone.utc).isoformat(), generation=old.get('generation', 0)+1)
self.write(saved); self.a.set_setting('baidu_owner:'+key, uid)
self.a.audit('连接共享百度网盘', user['username'])
def disconnect(self, user, purpose):
if user['role'] != 'admin': raise self.a.Problem('共享网盘由超级管理员管理', 403)
key = self.key(user, purpose)
with self.guard:
saved = self.read(); saved['connections'].pop(key, None); self.write(saved)
self.pending = {s: v for s, v in self.pending.items() if v['key'] != key}
self.a.audit('解除本机网盘连接', user['username'])
return self.status(user, purpose)
def token(self, key):
with self.guard:
saved = self.read(); connection = saved['connections'].get(key)
if not connection or connection.get('invalid'):
raise self.a.Problem('请连接百度网盘后再操作', 409)
if connection['expires'] <= time.time():
try:
connection.update(self.token_response(saved['config'], grant_type='refresh_token', refresh_token=connection['refresh_token']))
except self.a.Problem as error:
if getattr(error, 'reauthorize', False):
connection['invalid'] = True; self.write(saved)
raise
self.write(saved)
return connection['access_token']
def request(self, key, resource, method, data=None, **query):
return self.json_request(API+resource+'?'+url.urlencode(dict(method=method, access_token=self.token(key), **query)), data=data)
def prepare(self, remote, size, hashes):
root = '/apps/'+self.public_config()['appFolder']
if not remote.startswith(root+'/') or '/..' in remote: raise self.a.Problem('网盘路径不正确')
parent = remote.rsplit('/', 1)[0]
for n in range(3, len(parent.split('/'))+1):
path = '/'.join(parent.split('/')[:n])
# rtype=0 keeps an existing folder; -8 is also reported by some API versions.
try: self.request('shared', 'file', 'create', dict(path=path, isdir=1, rtype=0))
except self.a.Problem as error:
if '(-8)' not in str(error): raise
found = self.find_file(remote, size)
if found: return dict(existing=found)
pre = self.request('shared', 'file', 'precreate', dict(path=remote, size=size, isdir=0, rtype=0, block_list=json.dumps(hashes), autoinit=1))
if pre.get('return_type') == 2:
found = self.find_file(remote, size)
if not found: raise self.a.Problem('云端文件尚未确认,请重试', 502)
return dict(existing=found)
if not pre.get('uploadid'): raise self.a.Problem('网盘预上传返回不完整', 502)
return pre
def find_file(self, remote, size):
values = self.request('shared', 'file', 'list', dir=remote.rsplit('/', 1)[0], limit=1000).get('list', [])
found = next((v for v in values if v.get('path') == remote), None)
if found and found.get('size') != size: raise self.a.Problem('云端同名文件大小不符,未覆盖文件', 409)
return found
def send_block(self, remote, upload_id, index, block, expected):
if hashlib.md5(block).hexdigest() != expected: raise self.a.Problem('文件分片校验不一致,请重新选择原文件')
boundary = secrets.token_hex(16)
body = ('--'+boundary+'\r\nContent-Disposition: form-data; name="file"; filename="chunk"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()+block+('\r\n--'+boundary+'--\r\n').encode()
address = 'https://d.pcs.baidu.com/rest/2.0/pcs/superfile2?'+url.urlencode(dict(method='upload', type='tmpfile', path=remote, uploadid=upload_id, partseq=index, access_token=self.token('shared')))
result = self.json_request(address, raw=body, content_type='multipart/form-data; boundary='+boundary)
if result.get('md5') != expected: raise self.a.Problem('网盘分片校验不一致,请重试', 502)
def complete(self, item):
found = self.find_file(item['remotePath'], item['size'])
if found: return found
result = self.request('shared', 'file', 'create', dict(path=item['remotePath'], size=item['size'], isdir=0, rtype=0,
uploadid=item['uploadId'], block_list=json.dumps(item['hashes'])))
if not result.get('fs_id') or result.get('size') != item['size'] or result.get('path') != item['remotePath']:
raise self.a.Problem('上传结果尚未确认,请重试核对云端文件', 502)
return result
def upload(self, key, local, remote, progress=lambda n, total: None):
"""Only database backups use a local file; personal media uses send_block directly."""
size = local.stat().st_size
hashes = []
with local.open('rb') as stream:
while block := stream.read(BLOCK): hashes.append(hashlib.md5(block).hexdigest())
if not hashes: raise self.a.Problem('空文件不能上传')
pre = self.prepare(remote, size, hashes)
if pre.get('existing'): return pre['existing']
needed = pre.get('block_list', list(range(len(hashes))))
if any(type(i) is not int or not 0 <= i < len(hashes) for i in needed):
raise self.a.Problem('网盘分片索引不正确', 502)
with local.open('rb') as stream:
for i in needed:
stream.seek(i*BLOCK)
self.send_block(remote, pre['uploadid'], i, stream.read(BLOCK), hashes[i])
progress(i+1, len(hashes))
return self.complete(dict(remotePath=remote, size=size, uploadId=pre['uploadid'], hashes=hashes))
def open_file(self, key, item, byte_range=''):
result = self.request(key, 'multimedia', 'filemetas', fsids=json.dumps([int(item['fsId'])]), dlink=1)
info = next((v for v in result.get('list', []) if str(v.get('fs_id')) == str(item['fsId'])), None)
if not info or info.get('path') != item['remotePath']:
raise self.a.Problem('网盘文件已移动或不存在', 404)
address = download_url(info.get('dlink', ''))
address += ('&' if '?' in address else '?')+url.urlencode(dict(access_token=self.token(key)))
headers = {'User-Agent': 'pan.baidu.com'}
if byte_range:
if not re.fullmatch(r'bytes=(?:\d+-\d*|-\d+)', byte_range): raise self.a.Problem('播放范围不正确', 416)
headers['Range'] = byte_range
try:
return urllib.request.build_opener(DownloadRedirect()).open(urllib.request.Request(address, headers=headers), timeout=30)
except Exception:
raise self.a.Problem('网盘文件暂时无法读取,请稍后重试', 502) from None
def verify_backup(self, remote, fs_id, size, digest):
"""Read back into a small memory buffer before allowing retention deletion."""
sha = hashlib.sha256(); received = 0
with self.open_file('shared', dict(remotePath=remote, fsId=str(fs_id))) as response:
while chunk := response.read(65536):
received += len(chunk)
if received > size: raise self.a.Problem('云端备份大小不符,未清理旧备份', 502)
sha.update(chunk)
if received != size or sha.hexdigest() != digest:
raise self.a.Problem('云端备份校验不一致,未清理旧备份', 502)
def list_directory(self, directory):
items = []; seen = set()
for start in range(0, 20000, 1000):
result = self.request('shared', 'file', 'list', dir=directory, start=start, limit=1000, order='name')
page = result.get('list')
if not isinstance(page, list): raise self.a.Problem('网盘目录读取不完整,未继续清理', 502)
for item in page:
path = item.get('path', '')
if path in seen: raise self.a.Problem('网盘目录分页重复,未继续清理', 502)
seen.add(path); items.append(item)
if len(page) < 1000: return items
raise self.a.Problem('网盘目录过大,未继续清理', 502)
def delete_backup_file(self, remote):
# Restrict this capability to one generated database file, never a directory or media.
root = '/apps/'+self.public_config()['appFolder']+'/数据库备份/'
if not remote.startswith(root): raise self.a.Problem('备份清理路径不正确')
parts = remote[len(root):].split('/')
if len(parts) not in (2, 3) or not re.fullmatch('[a-f0-9]{16}', parts[0]):
raise self.a.Problem('备份清理路径不正确')
name = parts[-1]
if not re.fullmatch('vision-'+parts[0]+r'-\d{8}T\d{12}Z\.sqlite', name) or len(parts) == 3 and parts[1] != name:
raise self.a.Problem('备份清理文件名不正确')
result = self.request('shared', 'file', 'filemanager', {'async':0, 'filelist':json.dumps([remote])}, opera='delete')
info = result.get('info')
if result.get('taskid') or not isinstance(info, list) or len(info) != 1 or info[0].get('path') != remote or info[0].get('errno') != 0:
raise self.a.Problem('网盘未确认删除结果,请稍后重试', 502)
+8
View File
@@ -178,6 +178,14 @@ class Family:
item[key] = value.strip()
else:
item[key] = a.clean_text(value, maximum, key == 'name')
# Keep the legacy combined field verbatim. New structured locations are optional;
# old clients updating another field must not erase them.
for prefix in ('native', 'birth'):
for field in ('Country', 'Province', 'City', 'District', 'Town', 'Community', 'Address'):
key = prefix+field
value = data.get(key, (old or {}).get(key, ''))
if not isinstance(value, str): raise a.Problem('地域信息须为文字')
item[key] = a.clean_text(value, 200 if field == 'Address' else 80)
for key in ('birthDate', 'deathDate'):
if item[key]:
try:
+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
+17 -1
View File
@@ -3,6 +3,8 @@ import datetime as dt
import json
import re
import secrets
import hashlib
from appearance import png_bytes
class Households:
@@ -110,7 +112,8 @@ class Households:
def label(uid):
account = self.a.get_object('users', uid)
return account.get('name', '') if account else ''
return [dict({k: v for k, v in row.items() if k != 'leadershipHistory' or self.manager(user)}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')),
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]
@@ -154,6 +157,8 @@ class Households:
raise a.Problem('家庭不存在', 404)
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
raise a.Problem('只能管理自己所属的家庭', 403)
if type(data.get('profileRevision', 0)) is not int or data.get('profileRevision', 0) != (old or {}).get('profileRevision', 0):
raise a.Problem('家庭档案已被更新,请重新打开后保存,避免覆盖他人的修改', 409)
kind = data.get('kind', old.get('kind', 'home') if old else 'home')
if kind not in ('home', 'office', 'factory'):
raise a.Problem('请选择家庭、办公场所或工厂')
@@ -161,7 +166,18 @@ class Households:
name=a.clean_text(data.get('name', ''), 80, True),
kind=kind,
note=a.clean_text(data.get('note', ''), 300),
profileRevision=(old or {}).get('profileRevision', 0)+1,
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
+5 -2
View File
@@ -64,6 +64,9 @@ class Accounts:
raise a.Problem('用户名已存在', 409)
role = data.get('role', 'member')
access = data.get('familyAccess', 'none')
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')):
@@ -116,10 +119,10 @@ class Accounts:
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, familyId=family, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control,
personId=person, relatedToId=related, includeSubspaces=descendants,
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', 'familyId', 'siteIds', 'includeSubspaces', 'familyAccess', 'disabled', 'username', 'ptzControl', 'personId')):
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)
+293
View File
@@ -0,0 +1,293 @@
"""Personal journals, household sharing and owner-bound cloud attachments."""
import datetime as dt
import hashlib
import os
import re
import secrets
import threading
import time
CHUNK = 512 * 1024
MAX_FILE = 256 * 1024 * 1024
KINDS = {'日常', '成长', '家庭', '旅行', '工作', '其他'}
TYPES = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp',
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.wav': 'audio/wav', '.ogg': 'audio/ogg'}
class Journal:
def __init__(self, app):
self.a = app
self.guard = threading.RLock()
self.buffers = {}
def initialize(self):
with self.a.LOCK, self.a.DB:
for table in ('journal_entries', 'journal_media', 'journal_comments', 'journal_likes'):
self.a.DB.execute('CREATE TABLE IF NOT EXISTS '+table+' (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
# No binary files are written to disk. Partial 4 MiB blocks vanish on restart.
def owned(self, user, table, key):
item = self.a.HOUSEHOLDS.owns(user, self.a.get_object(table, key))
if item['ownerId'] != user['id']:
raise self.a.Problem('只能修改自己的记录', 403)
return item
def visible(self, user, item):
return bool(item and not item.get('deleted') and item['familyId'] == self.a.HOUSEHOLDS.scope(user)
and (item['ownerId'] == user['id'] or item['visibility'] == 'household'))
def entry(self, user, key):
item = self.a.get_object('journal_entries', key)
if not self.visible(user, item): raise self.a.Problem('记事不存在或未获授权', 404)
return item
def public_media(self, item):
return {k: item.get(k) for k in ('id', 'name', 'mime', 'size', 'status', 'received', 'progress', 'error', 'at', 'trashedAt')}
def media(self, user, key):
item = self.a.HOUSEHOLDS.owns(user, self.a.get_object('journal_media', key))
if item['ownerId'] != user['id'] and not any(self.visible(user, entry) and key in entry['mediaIds'] for entry in self.a.objects('journal_entries')):
raise self.a.Problem('附件不存在或未获授权', 404)
if item['status'] != 'ready': raise self.a.Problem('附件尚未上传完成', 409)
return item
def snapshot(self, user, query):
a = self.a
if query.get('mode', 'feed') not in ('feed', 'mine', 'person'):
raise a.Problem('查询方式不正确')
entries = [e for e in a.objects('journal_entries') if self.visible(user, e)]
mode = query.get('mode', 'feed')
if mode == 'feed': entries = [e for e in entries if e['visibility'] == 'household']
elif mode == 'mine': entries = [e for e in entries if e['ownerId'] == user['id']]
elif mode == 'person': entries = [e for e in entries if e['ownerId'] == query.get('person')]
for field, compare in [('from', lambda x, y: x >= y), ('to', lambda x, y: x <= y)]:
if query.get(field):
self.date(query[field]); entries = [e for e in entries if compare(e['occurredOn'], query[field])]
search = query.get('search', '').strip().casefold()[:120]
if search: entries = [e for e in entries if search in (e['text']+' '+e['category']).casefold()]
if query.get('category'): entries = [e for e in entries if e['category'] == query['category']]
entries.sort(key=lambda e: (e['occurredOn'], e['createdAt'], e['id']), reverse=True)
try: page = max(0, min(100000, int(query.get('page', '0'))))
except ValueError: raise a.Problem('页码不正确')
media = {m['id']: m for m in a.HOUSEHOLDS.objects(user, 'journal_media')}
likes = a.HOUSEHOLDS.objects(user, 'journal_likes')
comments = a.HOUSEHOLDS.objects(user, 'journal_comments')
items = []
for entry in entries[page*20:page*20+20]:
related = sorted([c for c in comments if c['entryId'] == entry['id'] and not c.get('deleted')], key=lambda c: c['createdAt'])
voters = [v for v in likes if v['entryId'] == entry['id'] and v['liked']]
items.append(dict(entry, media=[self.public_media(media[mid]) for mid in entry['mediaIds'] if mid in media],
canEdit=entry['ownerId'] == user['id'], likes=len(voters), liked=any(v['ownerId'] == user['id'] for v in voters),
comments=[dict(c, canDelete=c['ownerId'] == user['id'] or entry['ownerId'] == user['id']) for c in related[-100:]],
commentCount=len(related)))
owned = [m for m in media.values() if m['ownerId'] == user['id']]
uploads = [self.public_media(m) for m in owned if not m.get('trashedAt') and m['status'] != 'removed']
uploads.sort(key=lambda m: m['at'], reverse=True)
trash = [self.public_media(m) for m in owned if m.get('trashedAt') or m['status'] == 'removed']
trash.sort(key=lambda m: m.get('trashedAt') or m['at'], reverse=True)
people = {e['ownerId']: e['authorName'] for e in a.objects('journal_entries') if self.visible(user, e)}
return dict(items=items, total=len(entries), page=page, uploads=uploads[:50], trash=trash[:50], people=people,
connection=a.BAIDU.status(user), chunkSize=CHUNK, maxFile=MAX_FILE)
def date(self, value):
try:
if dt.date.fromisoformat(value).isoformat() != value: raise ValueError()
except (TypeError, ValueError): raise self.a.Problem('请填写有效的公历日期')
return value
def save(self, user, data):
a = self.a
with a.LOCK:
old = self.owned(user, 'journal_entries', data['id']) if data.get('id') else None
if old and old.get('deleted'): raise a.Problem('记录已经删除', 404)
if type(data.get('revision', 0)) is not int or data.get('revision', 0) != (old or {}).get('revision', 0):
raise a.Problem('记事已更新,请重新打开后保存', 409)
client = a.clean_text(data.get('clientId', ''), 32, True)
if not re.fullmatch('[a-f0-9]{32}', client): raise a.Problem('提交编号不正确')
if not old:
prior = next((e for e in a.objects('journal_entries') if e['ownerId'] == user['id'] and e['familyId'] == a.HOUSEHOLDS.scope(user) and e['clientId'] == client), None)
if prior: return {'id': prior['id'], 'duplicate': True}
raw_text = data.get('text', '')
if not isinstance(raw_text, str): raise a.Problem('记事内容须为文字')
text = a.clean_text(raw_text.replace('\r\n', '\n').replace('\n', '\u2028'), 5000).replace('\u2028', '\n')
visibility = data.get('visibility', 'private'); category = data.get('category', '日常')
if visibility not in ('private', 'household') or category not in KINDS:
raise a.Problem('可见范围或分类不正确')
ids = data.get('mediaIds', [])
if not isinstance(ids, list) or len(ids) > 9 or any(not isinstance(i, str) for i in ids) or len(set(ids)) != len(ids):
raise a.Problem('每条记事最多 9 个附件')
for mid in ids:
item = self.owned(user, 'journal_media', mid)
if item['status'] != 'ready': raise a.Problem('请等待附件上传完成后再发布')
if item.get('trashedAt') and mid not in (old or {}).get('mediaIds', []):
raise a.Problem('请先恢复已删除的附件再添加到记事', 409)
if not text and not ids: raise a.Problem('请写下内容或添加附件')
now = dt.datetime.now(dt.timezone.utc).isoformat()
entry = dict(id=old['id'] if old else secrets.token_hex(8), familyId=a.HOUSEHOLDS.scope(user),
ownerId=user['id'], authorName=user['name'], text=text, category=category,
visibility=visibility, occurredOn=self.date(data.get('occurredOn')),
mediaIds=ids, createdAt=old['createdAt'] if old else now, updatedAt=now,
revision=(old or {}).get('revision', 0)+1, clientId=client, deleted=False)
a.save_object('journal_entries', entry)
return {'id': entry['id']}
def action(self, user, data):
a = self.a
with a.LOCK:
entry = self.entry(user, data.get('id'))
action = data.get('action')
if action == 'delete':
self.owned(user, 'journal_entries', entry['id'])
if data.get('revision') != entry['revision']: raise a.Problem('记事已更新,请重新读取', 409)
entry.update(deleted=True, revision=entry['revision']+1); a.save_object('journal_entries', entry)
elif action == 'like':
if not isinstance(data.get('liked'), bool): raise a.Problem('点赞状态不正确')
key = hashlib.sha256((entry['id']+user['id']).encode()).hexdigest()[:32]
a.save_object('journal_likes', dict(id=key, entryId=entry['id'], familyId=entry['familyId'], ownerId=user['id'], liked=data['liked']))
elif action == 'comment':
text = a.clean_text(data.get('text', ''), 500, True)
key = a.clean_text(data.get('clientId', ''), 32, True)
if not re.fullmatch('[a-f0-9]{32}', key): raise a.Problem('评论编号不正确')
old = a.get_object('journal_comments', key)
if old:
if old['ownerId'] != user['id'] or old['entryId'] != entry['id']: raise a.Problem('评论编号已使用', 409)
return {'ok': True}
count = sum(c['entryId'] == entry['id'] and not c.get('deleted') for c in a.objects('journal_comments'))
if count >= 100: raise a.Problem('本条记事已达到 100 条评论上限')
a.save_object('journal_comments', dict(id=key, entryId=entry['id'], familyId=entry['familyId'], ownerId=user['id'], authorName=user['name'], text=text, createdAt=dt.datetime.now(dt.timezone.utc).isoformat()))
elif action == 'delete-comment':
comment = a.get_object('journal_comments', data.get('commentId'))
if not comment or comment['entryId'] != entry['id']: raise a.Problem('评论不存在', 404)
if user['id'] not in (entry['ownerId'], comment['ownerId']): raise a.Problem('只能删除自己的评论或自己记事下的评论', 403)
comment['deleted'] = True; a.save_object('journal_comments', comment)
else: raise a.Problem('记事操作不正确')
return {'ok': True}
def begin_upload(self, user, data):
a = self.a
name = a.clean_text(data.get('name', ''), 120, True)
ext = os.path.splitext(name)[1].lower()
if ext not in TYPES or any(c in name for c in '/\\:*?"<>|') or name.startswith('.'):
raise a.Problem('请选择 JPG、PNG、WebP 照片,MP4、MOV、WebM 视频或常见音频文件')
size = a.integer(data.get('size'), 1, MAX_FILE)
hashes = data.get('hashes')
if not isinstance(hashes, list) or len(hashes) != (size+4194303)//4194304 or any(not isinstance(h, str) or not re.fullmatch('[a-f0-9]{32}', h) for h in hashes):
raise a.Problem('文件分片校验表不正确')
mime = 'audio/webm' if ext == '.webm' and data.get('mime', '').startswith('audio/') else TYPES[ext]
a.BAIDU.token('shared')
with self.guard:
for key, buffer in list(self.buffers.items()):
if buffer['touched'] < time.time()-600: self.buffers.pop(key)
prior = self.owned(user, 'journal_media', data['id']) if data.get('id') else None
if prior:
if prior.get('trashedAt'): raise a.Problem('请先恢复已删除的附件', 409)
if prior['hashes'] != hashes or prior['size'] != size or prior['name'] != name:
raise a.Problem('请选择与原任务相同的文件')
if prior['status'] == 'ready': return self.public_media(prior)
if prior['status'] == 'removed': raise a.Problem('该附件已移除,请新建上传任务')
item = prior
else:
active = [m for m in a.objects('journal_media') if m['status'] == 'receiving' and not m.get('trashedAt') and m['ownerId'] == user['id']]
if len(active) >= 4: raise a.Problem('请先完成或移除现有上传任务,最多同时保留 4 个', 409)
key = secrets.token_hex(8); family = a.HOUSEHOLDS.scope(user)
instance = a.setting('cloud_instance')
if not instance:
instance = secrets.token_hex(8); a.set_setting('cloud_instance', instance)
remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/资料/'+instance+'/'+family+'/'+key+'/'+name
item = dict(id=key, familyId=family, ownerId=user['id'], name=name, mime=mime, size=size,
status='receiving', received=0, progress=0, error='', remotePath=remote, hashes=hashes,
at=dt.datetime.now(dt.timezone.utc).isoformat())
a.save_object('journal_media', item)
try:
pre = a.BAIDU.prepare(item['remotePath'], size, hashes)
if pre.get('existing'):
item.update(status='ready', received=size, progress=100, fsId=str(pre['existing']['fs_id']), error='')
else:
# Precreate reports which cloud blocks are still missing; after browser/server
# restart resend from the first missing block, without any local file cache.
needed = pre.get('block_list', list(range(len(hashes))))
if not isinstance(needed, list) or any(type(n) is not int or not 0 <= n < len(hashes) for n in needed):
raise a.Problem('网盘返回的分片索引不正确', 502)
received = min(needed)*4194304 if needed else size
item.update(uploadId=pre['uploadid'], received=received, progress=round(received/size*95), error='')
self.buffers.pop(item['id'], None)
a.save_object('journal_media', item)
except a.Problem as error:
item['error'] = str(error); a.save_object('journal_media', item); raise
return self.public_media(item)
@staticmethod
def valid_signature(mime, raw):
return bool(mime == 'image/jpeg' and raw.startswith(b'\xff\xd8\xff') or
mime == 'image/png' and raw.startswith(b'\x89PNG\r\n\x1a\n') or
mime == 'image/webp' and raw.startswith(b'RIFF') and raw[8:12] == b'WEBP' or
mime in ('video/mp4', 'video/quicktime', 'audio/mp4') and raw[4:8] == b'ftyp' or
mime in ('video/webm', 'audio/webm') and raw.startswith(b'\x1a\x45\xdf\xa3') or
mime == 'audio/wav' and raw.startswith(b'RIFF') and raw[8:12] == b'WAVE' or
mime == 'audio/ogg' and raw.startswith(b'OggS') or
mime in ('audio/mpeg', 'audio/aac') and (raw.startswith(b'ID3') or len(raw) > 1 and raw[0] == 255 and raw[1] & 224 == 224))
def chunk(self, user, key, offset, content):
if not content or len(content) > CHUNK: raise self.a.Problem('文件分片大小不正确')
# At most two 4 MiB buffers; this lock covers only cloud upload, never the app DB.
with self.guard:
item = self.owned(user, 'journal_media', key)
if item.get('trashedAt'): raise self.a.Problem('附件已删除,请先恢复', 409)
if item['status'] != 'receiving' or not item.get('uploadId'): raise self.a.Problem('请重新选择文件继续上传', 409)
if offset < 0 or offset+len(content) > item['size']: raise self.a.Problem('文件分片位置不正确')
if offset < item['received']: return dict(self.public_media(item), received=item['received'])
for mid, value in list(self.buffers.items()):
if value['touched'] < time.time()-600: self.buffers.pop(mid)
if key not in self.buffers:
if len(self.buffers) >= 2: raise self.a.Problem('上传缓冲正在使用,请稍后重试', 429)
self.buffers[key] = dict(start=item['received'], data=bytearray(), touched=time.time())
buffer = self.buffers[key]; end = buffer['start']+len(buffer['data'])
if offset == end: buffer['data'].extend(content)
elif offset < end and buffer['data'][offset-buffer['start']:offset-buffer['start']+len(content)] == content: pass
else: raise self.a.Problem('分片顺序或内容不正确,请重新选择文件', 409)
buffer['touched'] = time.time(); end = buffer['start']+len(buffer['data'])
if len(buffer['data']) > 4194304: raise self.a.Problem('分片超出缓冲上限')
if buffer['start'] == 0 and not self.valid_signature(item['mime'], buffer['data'][:32]):
self.buffers.pop(key); raise self.a.Problem('文件内容与格式不符,请选择原始媒体文件')
if len(buffer['data']) == 4194304 or end == item['size']:
index = buffer['start']//4194304
self.a.BAIDU.send_block(item['remotePath'], item['uploadId'], index, bytes(buffer['data']), item['hashes'][index])
item.update(received=end, progress=round(end/item['size']*95), error='')
self.a.save_object('journal_media', item); self.buffers.pop(key)
return dict(self.public_media(item), received=end)
def finish_upload(self, user, key):
with self.guard:
item = self.owned(user, 'journal_media', key)
if item.get('trashedAt'): raise self.a.Problem('附件已删除,请先恢复', 409)
if item['status'] == 'ready': return self.public_media(item)
if item['status'] != 'receiving' or item['received'] != item['size']:
raise self.a.Problem('文件尚未完整上传,请重新选择原文件继续', 409)
result = self.a.BAIDU.complete(item)
item.update(status='ready', progress=100, fsId=str(result['fs_id']), error='')
self.a.save_object('journal_media', item)
return self.public_media(item)
def remove_upload(self, user, key):
with self.guard, self.a.LOCK:
item = self.owned(user, 'journal_media', key)
self.buffers.pop(key, None)
# Library deletion must not break existing published references or delete cloud files.
if not item.get('trashedAt'):
item['trashedAt'] = dt.datetime.now(dt.timezone.utc).isoformat()
self.a.save_object('journal_media', item)
return {'ok': True}
def restore_upload(self, user, key):
with self.guard, self.a.LOCK:
item = self.owned(user, 'journal_media', key)
if not item.get('trashedAt') and item['status'] != 'removed': return {'ok': True}
status = ('ready' if item.get('fsId') else 'receiving') if item['status'] == 'removed' else item['status']
if status == 'receiving':
active = sum(m['ownerId'] == user['id'] and m['status'] == 'receiving' and not m.get('trashedAt') for m in self.a.objects('journal_media'))
if active >= 4: raise self.a.Problem('请先完成或删除现有上传任务,最多同时保留 4 个', 409)
item.update(status=status, trashedAt=None)
self.a.save_object('journal_media', item)
return {'ok': True}
+3 -1
View File
@@ -1,5 +1,6 @@
"""Isolated regression tests: python -m unittest -v test_app."""
import hashlib
import atexit
import http.client
import io
import json
@@ -16,6 +17,7 @@ os.environ['VISION_DATA'] = _data.name
os.environ['VISION_RECORDINGS'] = _data.name + '/recordings'
import app
import playback
atexit.register(app.DB.close)
class WebTests(unittest.TestCase):
@@ -105,7 +107,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), 22)
self.assertEqual(len(assets), 27)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
+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='新名称',profileRevision=1))[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)
+124
View File
@@ -0,0 +1,124 @@
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']))
def test_config_read_failure_always_releases_running_guard(self):
backup=self.source_file();backup.running=True
with patch.object(backup,'config',side_effect=RuntimeError('配置读取失败')):
result=backup.run()
self.assertFalse(backup.running);self.assertIn('配置读取失败',result['error'])
def test_baidu_success_is_separate_from_network_mount_and_requires_opt_in(self):
backup=self.source_file()
with patch.object(app.BAIDU,'upload',return_value={'fs_id':123}) as upload, patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}), patch.object(app.BAIDU,'verify_backup'), patch.object(app.BAIDU,'list_directory',return_value=[]):
result=backup.run();upload.assert_not_called();self.assertFalse(result['baiduOk'])
app.set_setting('backup_config',dict(backup.config(),baidu=True))
result=backup.run();self.assertTrue(result['baiduOk']);self.assertFalse(result['networkOk']);self.assertEqual(result['baiduFileId'],'123')
def test_cloud_cleanup_only_runs_after_verified_upload(self):
backup=self.source_file();app.set_setting('backup_config',dict(backup.config(),baidu=True))
with patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}), patch.object(app.BAIDU,'upload',return_value={'fs_id':123}), patch.object(backup,'prune_cloud') as prune:
with patch.object(app.BAIDU,'verify_backup',side_effect=RuntimeError('hash mismatch')):
result=backup.run();self.assertFalse(result['baiduOk']);prune.assert_not_called()
with patch.object(app.BAIDU,'verify_backup'):
result=backup.run();self.assertTrue(result['baiduOk']);prune.assert_called_once()
def test_retention_is_a_calendar_month_including_leap_and_year_boundaries(self):
for value, expected in [('2024-03-31','2024-02-29'),('2026-03-31','2026-02-28'),('2026-01-04','2025-12-04')]:
self.assertEqual(Backups.month_before(dt.datetime.fromisoformat(value)).date().isoformat(),expected)
def test_cloud_prune_limits_to_instance_and_expired_database_files(self):
backup=Backups(app);instance='a'*16;root='/apps/测试/数据库备份/'+instance
def name(stamp):return 'vision-'+instance+'-'+stamp+'.sqlite'
old=name('20260804T120000000000Z');nested=name('20260805T120000000000Z');recent=name('20261004T120000000000Z');boundary=name('20260904T120000000000Z')
rows=[dict(path=root+'/'+n,isdir=0) for n in (old,recent,boundary,'holiday.png','../'+old)]
rows+=[dict(path=root+'/'+nested,isdir=1),dict(path='/apps/其他/'+old,isdir=0)]
def listing(path):return rows if path==root else [dict(path=path+'/'+nested,isdir=0),dict(path=path+'/private.png',isdir=0)]
with patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}),patch.object(app.BAIDU,'list_directory',side_effect=listing),patch.object(app.BAIDU,'delete_backup_file') as delete:
result={};backup.prune_cloud(instance,root+'/'+recent,result,dt.datetime(2026,10,4,12,tzinfo=dt.timezone.utc))
self.assertEqual([c.args[0] for c in delete.call_args_list],[root+'/'+old,root+'/'+nested+'/'+nested]);self.assertEqual(result['cloudDeleted'],2)
def test_cloud_cleanup_failure_keeps_successful_new_backup(self):
backup=self.source_file();app.set_setting('backup_config',dict(backup.config(),baidu=True))
with patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}),patch.object(app.BAIDU,'upload',return_value={'fs_id':123}),patch.object(app.BAIDU,'verify_backup'),patch.object(backup,'prune_cloud',side_effect=RuntimeError('cleanup unavailable')):
result=backup.run()
self.assertTrue(result['baiduOk']);self.assertFalse(result['cloudCleanupOk']);self.assertNotIn('error',result)
+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)
+60
View File
@@ -0,0 +1,60 @@
const test=require('node:test'),assert=require('node:assert/strict'),crypto=require('node:crypto'),fs=require('node:fs');
const hash=require('./web/file-hash.js');
test('upload MD5 matches RFC vectors and a multi-block file',()=>{
for(const text of ['', 'a', 'abc', 'message digest', 'abcdefghijklmnopqrstuvwxyz', '照片与语音', 'x'.repeat(4194304)]){
const bytes=Buffer.from(text);assert.equal(hash.md5(bytes),crypto.createHash('md5').update(bytes).digest('hex'));
}
});
test('browser hashes in 4 MiB slices rather than loading the entire video',async()=>{
const file=new Blob([Buffer.alloc(4194304,1),Buffer.from('tail')]);const values=await hash.blocks(file);
assert.equal(values.length,2);assert.equal(values[1],crypto.createHash('md5').update('tail').digest('hex'));
await assert.rejects(hash.blocks(file,()=>{},()=>false),/取消/);
});
test('journals default to private and encode content before rendering',()=>{
const html=fs.readFileSync('web/index.html','utf8'),js=fs.readFileSync('web/journal.js','utf8');
assert.match(html,/<select name="visibility"><option value="private">/);assert.match(js,/esc\(e.text\)/);assert.match(js,/esc\(c.text\)/);
assert.doesNotMatch(js,/localStorage|indexedDB|caches\.open|access_token|secretKey.*localStorage/);
});
test('leave clears media URLs, live microphone tracks, and pending requests',()=>{
const js=fs.readFileSync('web/journal.js','utf8');assert.match(js,/journalRequest\?\.abort\(\)/);assert.match(js,/releaseJournalMedia/);assert.match(js,/removeAttribute\('src'\)/);assert.match(js,/getTracks\(\).forEach\(t=>t.stop\(\)\)/);
});
test('photos load by default and audio/video preload metadata with controls, never autoplay',()=>{
const vm=require('node:vm'),js=fs.readFileSync('web/journal.js','utf8');
const code=js.slice(js.indexOf('function journalMediaView('),js.indexOf('function bindJournalMedia('));
const c=vm.createContext({esc:s=>String(s).replaceAll('<','&lt;'),journalMediaUrl:id=>'/media/journal?id='+id});vm.runInContext(code,c);
for(const mime of ['image/png','audio/webm','video/mp4']){
const html=c.journalMediaView({id:'a',name:'<test>',mime});assert.match(html,/src="\/media\/journal\?id=a"/);assert.doesNotMatch(html,/autoplay|<test>/);
if(mime.startsWith('image'))assert.match(html,/loading="lazy"/);else assert.match(html,/controls preload="metadata"/);
}
});
test('successful save clears draft attachment ids, stops recorder, and reloads feed',()=>{
const js=fs.readFileSync('web/journal.js','utf8');
const fn=js.slice(js.indexOf('function newJournal'),js.indexOf('async function loadJournal'));
assert.match(fn,/journalDraftMedia.clear\(\)/);assert.match(fn,/journalSelectedMedia.clear\(\)/);assert.match(fn,/stopJournalVoice\(\)/);assert.match(fn,/journal-library'\).open=false/);
assert.match(js,/newJournal\(\);\$\('#journal-mode'\).value='mine';journalPage=0;await loadJournal\(\)/);
assert.match(js,/available.filter\(m=>journalDraftMedia.has\(m.id\)\)/);
});
test('attachment preview uses a closable dialog and deletion has a restore path',()=>{
const js=fs.readFileSync('web/journal.js','utf8'),html=fs.readFileSync('web/index.html','utf8');
assert.match(html,/<dialog id="journal-preview"/);assert.match(js,/addEventListener\('close'/);assert.match(js,/\/api\/journal\/restore-upload/);
assert.match(js,/journal-library-list/);assert.match(js,/if\(other!==media\)other.pause\(\)/);
});
test('structured place formatting and search preserve separate ancestral/birth/legacy locations',()=>{
const vm=require('node:vm'),js=fs.readFileSync('web/family.js','utf8');
const c=vm.createContext({});vm.runInContext(js.slice(js.indexOf('function personLocation'),js.indexOf('function personName')),c);
const person={nativeProvince:'山东省',nativeCity:'烟台市',nativeDistrict:'莱阳市',birthCity:'济南市',birthplace:'旧村名'};
assert.equal(c.personLocation(person,'native'),'山东省 · 烟台市 · 莱阳市');assert.equal(c.personLocation(person,'birth'),'济南市');assert.ok(c.personSearch(person).includes('旧村名'));
});
test('new entry clears hidden edit identifiers even when form.reset preserves them',()=>{
const vm=require('node:vm'),js=fs.readFileSync('web/journal.js','utf8');
const form={elements:{id:{value:'old-entry'},revision:{value:'9'},clientId:{value:'old-request'}},reset(){}};
const context={$:()=>form};vm.createContext(context);
vm.runInContext(js.match(/function clearJournalForm\(\)\{[^\n]+/)[0]+'\nclearJournalForm();',context);
for(const field of Object.values(form.elements))assert.equal(field.value,'');
assert.match(js,/function newJournal\(\)\{clearJournalForm\(\)/);
assert.match(js,/function resetJournal\(\).*clearJournalForm\(\)/);
});
test('initial composer setup happens before asynchronous loading and never clears a typed draft',()=>{
const js=fs.readFileSync('web/journal.js','utf8');const fn=js.slice(js.indexOf('async function loadJournal'),js.indexOf('function renderJournalUploads'));
assert.ok(fn.indexOf('newJournal()')<fn.indexOf('await api('));assert.equal(fn.match(/newJournal\(\)/g).length,1);
});
+235
View File
@@ -0,0 +1,235 @@
import hashlib
import http.client
import io
import json
import secrets
import time
import unittest
from unittest.mock import patch
from test_app import app
import test_households as fixture
from baidu import Baidu, download_url
from journal import Journal, CHUNK
class JournalTests(unittest.TestCase):
stop_server = fixture.HouseholdTests.stop_server
request = fixture.HouseholdTests.request
def setUp(self):
fixture.HouseholdTests.setUp(self)
for name, value in [('BAIDU', Baidu(app)), ('JOURNAL', Journal(app))]:
p = patch.object(app, name, value); p.start(); self.addCleanup(p.stop)
self.actor = self.members[self.aid]
def note(self, **changes):
return dict(text='今日记事\n第二行', occurredOn='2026-10-04', category='日常',
clientId=secrets.token_hex(16), visibility='private', revision=0, mediaIds=[], **changes)
def saved(self, user=None, visibility='private'):
body = self.note(); body['visibility'] = visibility
return app.JOURNAL.save(user or self.actor, body)['id']
def test_private_notes_are_not_readable_by_family_manager_or_superadmin(self):
key = self.saved()
self.assertEqual(len(app.JOURNAL.snapshot(self.actor, {'mode':'mine'})['items']), 1)
for user in (self.owner, self.root, self.other, self.members[self.bid]):
self.assertEqual(app.JOURNAL.snapshot(user, {'mode':'person','person':self.actor['id']})['items'], [])
self.assertEqual(self.request('/api/journal/action', user, dict(id=key,action='like',liked=True))[0], 404)
def test_shared_feed_history_search_and_date_filters_reference_the_same_record(self):
key = self.saved(visibility='household')
for mode in ('feed','person'):
result = app.JOURNAL.snapshot(self.owner, dict(mode=mode,person=self.actor['id'],search='第二行',category='日常',**{'from':'2026-10-01','to':'2026-10-31'}))
self.assertEqual(result['items'][0]['id'], key)
self.assertEqual(app.JOURNAL.snapshot(self.other, {'mode':'feed'})['total'], 0)
self.assertEqual(app.JOURNAL.snapshot(self.owner, {'from':'2026-10-05'})['total'], 0)
def test_account_cannot_forge_other_household_or_edit_anothers_note(self):
key = self.saved(visibility='household')
body = dict(app.get_object('journal_entries', key), text='stolen')
self.assertEqual(self.request('/api/journal', self.owner, body)[0], 403)
self.assertEqual(self.request('/api/journal', self.actor, self.note(), home=self.bid)[0], 403)
def test_creation_retry_and_edit_conflict(self):
body = self.note(); first = app.JOURNAL.save(self.actor, body); second = app.JOURNAL.save(self.actor, body)
self.assertEqual(first['id'], second['id']); self.assertTrue(second['duplicate'])
old = app.get_object('journal_entries', first['id'])
self.assertEqual(self.request('/api/journal', self.actor, dict(old,text='修改'))[0], 200)
self.assertEqual(self.request('/api/journal', self.actor, old)[0], 409)
def test_like_set_is_idempotent_and_privacy_change_removes_all_interactions(self):
key = self.saved(visibility='household'); data = dict(id=key,action='like',liked=True)
for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0], 200)
row = app.JOURNAL.snapshot(self.actor, {'mode':'mine'})['items'][0]; self.assertEqual(row['likes'],1)
app.JOURNAL.save(self.actor, dict(app.get_object('journal_entries', key),visibility='private'))
self.assertEqual(self.request('/api/journal/action', self.owner, dict(data,liked=False))[0],404)
def test_comments_allow_author_or_entry_owner_to_remove_but_no_other_member(self):
key = self.saved(visibility='household'); cid=secrets.token_hex(16)
data=dict(id=key,action='comment',clientId=cid,text='你好')
for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0],200)
self.assertEqual(len(app.objects('journal_comments')),1)
delete=dict(id=key,action='delete-comment',commentId=cid)
self.assertEqual(self.request('/api/journal/action', self.backup, delete)[0],403)
self.assertEqual(self.request('/api/journal/action', self.actor, delete)[0],200)
def test_unready_or_foreign_media_cannot_be_attached_and_unlinked_media_is_private(self):
media=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='ready',size=8,name='x.png',mime='image/png',at='now')
app.save_object('journal_media', media)
body=self.note();body['mediaIds']=[media['id']]
self.assertEqual(self.request('/api/journal', self.owner, body)[0],403)
with self.assertRaises(app.Problem): app.JOURNAL.media(self.owner, media['id'])
body['visibility']='household';key=app.JOURNAL.save(self.actor,body)['id']
self.assertEqual(app.JOURNAL.media(self.owner,media['id'])['id'],media['id'])
app.JOURNAL.action(self.actor,dict(id=key,action='delete',revision=1))
with self.assertRaises(app.Problem): app.JOURNAL.media(self.owner,media['id'])
def test_upload_sends_bounded_blocks_without_writing_any_media_file(self):
raw=b'\x89PNG\r\n\x1a\n'+b'a'*(CHUNK+20)
before=set(app.DATA.rglob('*'))
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[0]}), patch.object(app.BAIDU,'send_block') as send:
task=app.JOURNAL.begin_upload(self.actor,dict(name='x.png',size=len(raw),hashes=[hashlib.md5(raw).hexdigest()]))
first=app.JOURNAL.chunk(self.actor,task['id'],0,raw[:CHUNK]);self.assertEqual(first['received'],CHUNK)
app.JOURNAL.chunk(self.actor,task['id'],0,raw[:CHUNK]) # idempotent partial retry
app.JOURNAL.chunk(self.actor,task['id'],CHUNK,raw[CHUNK:]);send.assert_called_once()
self.assertEqual(send.call_args.args[3],raw)
self.assertEqual(set(app.DATA.rglob('*')), before)
self.assertFalse(app.JOURNAL.buffers)
def test_resume_after_restart_uses_cloud_missing_blocks(self):
data=dict(name='v.mp4',size=4194305,hashes=['a'*32,'b'*32])
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[1]}):
task=app.JOURNAL.begin_upload(self.actor,data)
self.assertEqual(task['received'],4194304)
again=app.JOURNAL.begin_upload(self.actor,dict(data,id=task['id']))
self.assertEqual(again['id'],task['id'])
def test_raw_upload_api_auth_and_foreign_user(self):
raw=b'\x89PNG\r\n\x1a\nabc'
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[0]}):
task=app.JOURNAL.begin_upload(self.actor,dict(name='x.png',size=len(raw),hashes=[hashlib.md5(raw).hexdigest()]))
c=http.client.HTTPConnection(*self.server.server_address)
c.request('POST','/api/journal/chunk?id='+task['id']+'&offset=0',raw,{'Cookie':'vision='+self.tokens[self.owner['id']]})
r=c.getresponse();r.read();c.close();self.assertEqual(r.status,403)
def test_media_and_oauth_metadata_do_not_reveal_tokens_or_remote_paths(self):
self.assertEqual(self.request('/api/baidu/config',self.owner)[0],403)
self.assertEqual(self.request('/api/baidu/connect',self.owner,{})[0],403)
self.assertEqual(self.request('/api/baidu/disconnect',self.owner,{})[0],403)
body=app.JOURNAL.snapshot(self.actor,{'mode':'mine'})
self.assertNotIn('secretKey',json.dumps(body));self.assertNotIn('access_token',json.dumps(body))
def test_media_http_requires_visibility_and_preserves_range_without_cloud_redirect(self):
media=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='ready',
size=10,name='clip.mp4',mime='video/mp4',at='now',fsId='123',remotePath='/private/clip.mp4')
app.save_object('journal_media',media)
key=app.JOURNAL.save(self.actor,dict(self.note(),mediaIds=[media['id']]))['id']
path='/media/journal?id='+media['id']+'&family='+self.aid
def fetch(user):
c=http.client.HTTPConnection(*self.server.server_address)
headers={'Range':'bytes=2-4'}
if user: headers['Cookie']='vision='+self.tokens[user['id']]
c.request('GET',path,headers=headers);r=c.getresponse();result=(r.status,dict(r.getheaders()),r.read());c.close();return result
with patch.object(app.BAIDU,'open_file') as opened:
self.assertEqual(fetch(None)[0],401)
self.assertEqual(fetch(self.owner)[0],404)
opened.assert_not_called()
app.JOURNAL.save(self.actor,dict(app.get_object('journal_entries',key),visibility='household'))
response=io.BytesIO(b'234');response.status=206
response.headers={'Content-Length':'3','Content-Range':'bytes 2-4/10'};opened.return_value=response
status,headers,body=fetch(self.owner)
self.assertEqual((status,body),(206,b'234'));self.assertEqual(headers['Cache-Control'],'no-store')
self.assertEqual(headers['Content-Range'],'bytes 2-4/10');self.assertNotIn('Location',headers)
self.assertEqual(opened.call_args.args[2],'bytes=2-4')
def config(self):
return dict(appKey='fixtureKey',secretKey='fixtureSecret',appFolder='测试',redirectUri='https://example.org/api/baidu/callback',revision=0)
def test_config_revisions_callback_validation_and_secret_not_returned(self):
body=app.BAIDU.configure(self.config(),self.root)
self.assertTrue(body['hasSecret']);self.assertNotIn('secretKey',body)
with self.assertRaises(app.Problem): app.BAIDU.configure(self.config(),self.root)
for address in ('http://example.org/api/baidu/callback','https://example.org/other','https://a:b@example.org/api/baidu/callback'):
with self.assertRaises(app.Problem): app.BAIDU.configure(dict(self.config(),revision=1,redirectUri=address),self.root)
def test_oauth_state_cookie_session_and_replay_protection(self):
app.BAIDU.configure(self.config(),self.root)
result,cookie=app.BAIDU.begin(self.root,'media',self.tokens[self.root['id']],'example.org')
state=cookie.split(';')[0].split('=')[1]
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),'')
token=dict(access_token='test-token',refresh_token='test-refresh',expires=time.time()+1000)
with patch.object(app.BAIDU,'token_response',return_value=token),patch.object(app.BAIDU,'json_request',return_value={'uk':123,'baidu_name':'测试网盘'}):
app.BAIDU.callback(dict(state=state,code='x'),cookie)
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),cookie)
self.assertTrue(app.BAIDU.status(self.actor)['connected'])
self.assertNotIn('test-token',json.dumps(app.BAIDU.status(self.actor)))
def test_logged_out_or_demoted_oauth_cannot_bind_account(self):
app.BAIDU.configure(self.config(),self.root)
_,cookie=app.BAIDU.begin(self.root,'media',self.tokens[self.root['id']],'example.org')
state=cookie.split(';')[0].split('=')[1];app.DB.execute('DELETE FROM sessions');app.DB.commit()
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),cookie)
def test_download_redirect_targets_are_constrained(self):
for address in ('https://d.pcs.baidu.com/file/a','https://x.baidupcs.com/a'):
self.assertEqual(download_url(address),address)
for address in ('http://d.pcs.baidu.com/a','https://evil.test/a','https://baidu.com.evil.test/a','https://user@baidu.com/a','https://127.0.0.1/a'):
with self.assertRaises(ValueError): download_url(address)
def test_removed_attachment_and_invalid_text_never_publish(self):
bad=self.note();bad['text']=[]
self.assertEqual(self.request('/api/journal',self.actor,bad)[0],400)
bad=self.note();bad['occurredOn']='2026-99-99'
self.assertEqual(self.request('/api/journal',self.actor,bad)[0],400)
self.assertEqual(self.request('/api/journal',self.actor,dict(self.note(),visibility='public'))[0],400)
def test_household_profile_revision_preserves_independent_leadership(self):
home=app.get_object('households',self.aid)
data=dict(home,name='第一次修改')
self.assertEqual(self.request('/api/households',self.root,data)[0],200)
self.assertEqual(self.request('/api/households',self.root,data)[0],409)
self.assertEqual(app.get_object('households',self.aid)['name'],'第一次修改')
def test_attachment_trash_restore_and_existing_reference_remain_visible(self):
m=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='ready',size=8,name='x.png',mime='image/png',at='now')
app.save_object('journal_media',m)
key=app.JOURNAL.save(self.actor,dict(self.note(),visibility='household',mediaIds=[m['id']]))['id']
self.assertEqual(self.request('/api/journal/remove-upload',self.actor,{'id':m['id']})[0],200)
snap=app.JOURNAL.snapshot(self.actor,{'mode':'mine'});self.assertEqual(snap['uploads'],[]);self.assertEqual(snap['trash'][0]['id'],m['id'])
self.assertEqual(app.JOURNAL.media(self.owner,m['id'])['id'],m['id'])
self.assertEqual(self.request('/api/journal',self.actor,dict(self.note(),mediaIds=[m['id']]))[0],409)
self.assertEqual(self.request('/api/journal',self.actor,app.get_object('journal_entries',key))[0],200)
for user in (self.owner,self.other):
self.assertIn(self.request('/api/journal/restore-upload',user,{'id':m['id']})[0],(403,404))
self.assertEqual(app.JOURNAL.snapshot(user,{})['trash'],[])
self.assertEqual(self.request('/api/journal/restore-upload',self.actor,{'id':m['id']})[0],200)
self.assertEqual(app.JOURNAL.snapshot(self.actor,{})['trash'],[])
def test_deleted_partial_upload_rejects_chunk_and_finish_and_can_restore(self):
m=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='receiving',size=8,name='x.png',mime='image/png',at='now',received=0)
app.save_object('journal_media',m);app.JOURNAL.remove_upload(self.actor,m['id'])
for action in (lambda:app.JOURNAL.chunk(self.actor,m['id'],0,b'123'),lambda:app.JOURNAL.finish_upload(self.actor,m['id'])):
with self.assertRaises(app.Problem):action()
app.JOURNAL.restore_upload(self.actor,m['id']);self.assertFalse(app.get_object('journal_media',m['id'])['trashedAt'])
def test_backup_readback_hash_check_and_constrained_cloud_delete(self):
data=b'fixture sqlite';stream=io.BytesIO(data)
with patch.object(app.BAIDU,'open_file',return_value=stream):
app.BAIDU.verify_backup('/test','1',len(data),hashlib.sha256(data).hexdigest())
with patch.object(app.BAIDU,'open_file',return_value=io.BytesIO(b'wrong')):
with self.assertRaises(app.Problem):app.BAIDU.verify_backup('/test','1',len(data),'wrong')
root='/apps/测试/数据库备份/'+'a'*16;name='vision-'+'a'*16+'-20260801T120000000000Z.sqlite';valid=root+'/'+name
with patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}),patch.object(app.BAIDU,'request',return_value={'info':[{'path':valid,'errno':0}]}) as request:
app.BAIDU.delete_backup_file(valid)
for path in (root,root+'/../'+name,root+'/other.png',root+'/'+name+'/private.png','/apps/测试/资料/'+name):
with self.assertRaises(app.Problem):app.BAIDU.delete_backup_file(path)
self.assertEqual(request.call_count,1)
def test_person_structured_places_preserve_legacy_and_survive_old_clients(self):
data=dict(name='测试人物',birthplace='旧称原记录',nativeProvince='山东省',nativeCity='烟台市',nativeDistrict='莱阳市',nativeTown='测试镇',nativeCommunity='测试村',birthCountry='中国',birthCity='济南市')
person=app.FAMILY.person(data,None)
self.assertEqual(person['nativeCity'],'烟台市');self.assertEqual(person['birthplace'],'旧称原记录')
saved=app.FAMILY.person({'name':'新姓名','birthplace':'旧称原记录'},person)
self.assertEqual(saved['nativeCommunity'],'测试村');self.assertEqual(saved['birthCity'],'济南市')
with self.assertRaises(app.Problem):app.FAMILY.person(dict(data,nativeCity=[]),person)
+68
View File
@@ -0,0 +1,68 @@
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');
});
test('replacing or resetting an image invalidates older decode jobs',()=>{
const c=vm.createContext({pageInfo:{},document:{documentElement:{dataset:{}}}});
vm.runInContext(source.slice(0,source.indexOf('function applyBranding(')),c);
const first=c.nextImageJob('logo'),second=c.nextImageJob('logo');assert.equal(c.imageJobCurrent('logo',first),false);assert.equal(c.imageJobCurrent('logo',second),true);
c.invalidateBrandImages();assert.equal(c.imageJobCurrent('logo',second),false);
});
+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/);
});
+31 -17
View File
@@ -3,7 +3,7 @@ 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:'办公',office:'办公',factory:'工厂',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], devices:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
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;
let householdScope = '';
@@ -14,19 +14,23 @@ 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);
$$('[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();
$('.discovery').hidden=!isPlatformAdmin()||!!deviceContext;
$('#scan-result').hidden=!isPlatformAdmin();
$('#new-household').hidden=!isPlatformAdmin();
$('#household-context').hidden=!loggedIn;
$('#household-filter').disabled=!isPlatformAdmin();
$('nav [data-page="family"]').hidden=!admin&&(!currentUser||currentUser.familyAccess==='none');
if(typeof renderSteward==='function')renderSteward();
$('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : '';
}
function clearPrivateView(){
if(typeof resetJournal==='function')resetJournal();
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(()=>{});
@@ -36,7 +40,7 @@ function clearPrivateView(){
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:['录像与存储','让记录持续,也让磁盘留有余地。']};
async function api(path, data) {
@@ -50,25 +54,35 @@ async function api(path, data) {
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
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() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;return state.cameras.filter(c=>!range||range.has(c.siteId)); }
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 leaveJournal==='function'&&activePage==='journal'&&page!=='journal')leaveJournal();
if(typeof leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings();
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings','journal'].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','journal'].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','households'].includes(page);
$('.filter-row').hidden=['family','users','households'].includes(page);
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings','journal'].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 || b.dataset.page==='objects'&&['cameras','recorders'].includes(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();
if(page==='journal'&&typeof loadJournal==='function')loadJournal();
}
function render() {
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
@@ -101,7 +115,7 @@ function renderLive() {
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`));
@@ -136,7 +150,7 @@ function renderStorage() {
}
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
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());}
@@ -146,21 +160,21 @@ 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(){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(siteName(s.id))}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
function 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;
+66
View File
@@ -253,3 +253,69 @@ footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
.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}}
/* Personal journals: a quiet paper timeline with bounded media panels. */
.journal-feed{max-width:1080px;margin:0 auto}.journal-compose textarea{resize:vertical;min-height:130px}.journal-compose>label{display:grid;gap:12px}.journal-add-media,.journal-reactions{display:flex;flex-wrap:wrap;gap:12px;margin:20px 0}.journal-add-media>label{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.journal-add-media input{max-width:260px}.journal-filters{display:flex;align-items:end;flex-wrap:wrap;gap:16px}.journal-filters label{flex:1;min-width:145px;display:grid;gap:7px}.journal-entry{border-left:3px solid #a98964;margin-bottom:26px}.journal-entry .section-head small{display:block;color:var(--muted);margin-top:6px}.journal-text{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.95;font-size:17px}.journal-author{font-size:20px!important;font-family:serif}.journal-media{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:14px}.journal-media figure{margin:0;min-width:0}.journal-media img,.journal-media video{width:100%;max-height:450px;object-fit:contain;border-radius:8px;background:#1a2925}.journal-media audio{width:100%;max-width:100%}.journal-media figcaption{font-size:13px;overflow-wrap:anywhere;margin-top:6px}.journal-image-load{width:100%;min-height:150px;background:#e9eee2!important}.journal-upload{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;border-bottom:1px solid #d9d7c8;padding:12px 0}.journal-upload label{flex:1;overflow-wrap:anywhere}.journal-upload small{display:block}.journal-upload input[type=checkbox]{width:auto}.journal-resume input{max-width:220px}.journal-comments{border-top:1px solid #d9d7c8;padding-top:15px}.journal-comments form{display:flex;align-items:end;gap:12px;flex-wrap:wrap}.journal-comments form label{flex:1;min-width:170px}.journal-comment{padding:10px 0;border-bottom:1px dashed #d9d7c8;overflow-wrap:anywhere}.journal-comment small{font-size:11px;color:var(--muted)}.journal-reactions [aria-pressed=true]{color:#ad5842!important}.journal-comments .quiet{padding:4px 8px;font-size:12px}.journal-feed .panel{overflow:hidden}html[data-theme=ink] .journal-image-load{background:#293f37!important}
.journal-upload-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr));gap:18px;margin:20px 0}
.journal-upload-card{border:1px solid var(--line,#d9d7c8);border-radius:12px;padding:16px;background:var(--surface,#fffdf8);min-width:0;display:flex;flex-direction:column;gap:14px}
.journal-upload-card:has(input:checked){border-color:#456f5f;box-shadow:0 0 0 1px #456f5f}
.journal-upload-select{display:flex!important;align-items:flex-start;gap:10px;overflow-wrap:anywhere;line-height:1.5}
.journal-upload-select input[type=checkbox]{width:18px;min-width:18px;height:18px;margin-top:3px;accent-color:#315a4e}
.journal-upload-select small{display:block;font-size:12px;color:var(--muted);margin-top:4px}
.journal-card-actions{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-top:auto}
.journal-card-actions button{padding:8px 13px;font-size:13px}.journal-card-actions [data-remove-upload]{margin-left:auto;color:#954f40}
.journal-card-actions a,.journal-media-caption a{font-size:13px;color:var(--accent,#315a4e)}
.journal-attachment{margin:0;min-width:0}.journal-photo{display:block;position:relative;width:100%;padding:0!important;border:0!important;background:#e6e9df!important;border-radius:9px;overflow:hidden;color:#fff!important}
.journal-photo img{display:block;width:100%;min-height:120px;max-height:450px;object-fit:contain;aspect-ratio:16/10;background:#e6e9df;border-radius:9px}
.journal-photo-hint{position:absolute;right:10px;bottom:10px;padding:4px 9px;background:#213a30c9;border-radius:5px;font-size:12px}
.journal-upload-card .journal-photo img{height:180px;min-height:0;object-fit:cover}
.journal-attachment video{width:100%;max-height:450px;border-radius:8px;background:#1a2925}.journal-upload-card video{height:180px}
.journal-attachment audio{width:100%;max-width:100%}.journal-audio{background:#e9eee5;border:1px solid #d5decd;border-radius:10px;padding:14px}
.journal-audio-label{display:flex;align-items:center;gap:10px;color:#42614f;font-size:13px;letter-spacing:1px;margin:0 0 14px}.journal-audio-label span{font-size:28px}
.journal-media-state{display:block;font-size:12px;color:var(--muted);padding-top:6px;min-height:0}.journal-media-state:empty{display:none}
.journal-media-caption{font-size:12px;color:var(--muted);overflow-wrap:anywhere;margin:8px 0 16px}
.journal-trash{padding:12px 0;border-top:1px solid #d9d7c8}.journal-trash-row{display:flex;align-items:center;gap:15px;justify-content:space-between;padding:10px 0;border-bottom:1px solid #d9d7c8;overflow-wrap:anywhere}
.journal-preview{width:min(960px,94vw);max-width:94vw;padding:24px;max-height:94vh}.journal-preview header,.journal-preview footer{display:flex;align-items:center;justify-content:space-between;gap:18px;position:static}.journal-preview header{margin-bottom:20px}.journal-preview h2{font-size:22px;overflow-wrap:anywhere;margin:0}.journal-preview footer{font-size:13px;margin-top:18px}
.journal-preview .journal-photo img{max-height:65vh;min-height:0;aspect-ratio:auto}.journal-preview .journal-photo{pointer-events:none}.journal-preview .journal-photo-hint{display:none}.journal-preview video{max-height:65vh}
html[data-theme=ink] .journal-upload-card{background:#20352d}html[data-theme=ink] .journal-audio{background:#293f37;border-color:#436152}html[data-theme=ink] .journal-audio-label{color:#d5e2ce}
@media(max-width:600px){.journal-upload-grid{grid-template-columns:1fr}.journal-preview{padding:16px}.journal-preview h2{font-size:17px}.journal-preview header button{flex-shrink:0}.journal-media{grid-template-columns:1fr}}
+2 -2
View File
@@ -8,8 +8,8 @@ function renderDeviceGrouping(){
function deviceCard(d){
const location=ZhaoDevices.path(state.sites,d.siteId).map(s=>s.name).join(' / ')||'未指定区域';
const attr=d.source==='camera'?'edit-asset':d.source==='recorder'?'edit-recorder':'edit-device';
const detail=d.source==='camera'?`<div><dt>镜头 / 通道</dt><dd>${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道</dd></div>`:d.source==='recorder'?`<div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===d.id).length} 个</dd></div>`:'<div><dt>接入状态</dt><dd>仅登记档案,尚未接入控制</dd></div>';
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span><button class="quiet" data-${attr}="${esc(d.id)}">编辑</button></div><h3>${esc(d.name)}</h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${detail}</dl>${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
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'];
+15
View File
@@ -1,5 +1,20 @@
'use strict';
const historyFields={name:'姓名',alias:'别名',gender:'性别',lifeStatus:'在世情况',birthDate:'出生日期',deathDate:'去世日期',birthplace:'籍贯',biography:'生平',note:'备注',archived:'归档',fromId:'关系一方',toId:'关系另一方',kind:'关系类型',lineage:'亲子属性',active:'有效关系',title:'记事标题',eventDate:'发生日期',summary:'摘要',body:'正文',personIds:'关联人物',status:'发布状态',sourceName:'资料来源',sourceUrl:'报道链接'};
historyFields.nativeCountry='籍贯 · 国家/地区';
historyFields.nativeProvince='籍贯 · 省/自治区/直辖市';
historyFields.nativeCity='籍贯 · 地级市/州/盟';
historyFields.nativeDistrict='籍贯 · 区/县/县级市';
historyFields.nativeTown='籍贯 · 乡镇/街道';
historyFields.nativeCommunity='籍贯 · 村/社区';
historyFields.nativeAddress='籍贯 · 详细地址/历史地名';
historyFields.birthCountry='出生地 · 国家/地区';
historyFields.birthProvince='出生地 · 省/自治区/直辖市';
historyFields.birthCity='出生地 · 地级市/州/盟';
historyFields.birthDistrict='出生地 · 区/县/县级市';
historyFields.birthTown='出生地 · 乡镇/街道';
historyFields.birthCommunity='出生地 · 村/社区';
historyFields.birthAddress='出生地 · 详细地址/历史地名';
let historyContext=null,historySelection=null,historyGeneration=0,historyDetailGeneration=0;
function clearFamilyHistory(){historyGeneration++;historyDetailGeneration++;historyContext=null;historySelection=null;for(const id of ['history-list','history-detail','history-error'])$('#'+id).replaceChildren();}
function historyValue(key,value){
+1 -1
View File
@@ -29,7 +29,7 @@ class FamilyMap {
const byId=new Map(data.people.map(p=>[p.id,p]));
this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{
const p=byId.get(b.dataset.mapPerson),self=p.id===data.selfId,selected=p.id===data.focusId;
const matched=data.term&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(data.term);
const matched=data.term&&personSearch(p).includes(data.term);
const relation=p.id===data.reference&&!self?'参照人物':ZhaoKinship.describe(data.people,data.links,data.reference,p.id);
b.classList.toggle('self',self);b.classList.toggle('selected',selected);b.classList.toggle('search-hit',!!matched);b.classList.toggle('archived',!!p.archived);
b.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案');
+7 -5
View File
@@ -6,6 +6,8 @@ const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id));
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamilyHistory();closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
function personLocation(p,prefix){return ['Country','Province','City','District','Town','Community','Address'].map(k=>p[prefix+k]).filter(Boolean).join(' · ');}
function personSearch(p){return [p.name,p.alias,p.birthplace,personLocation(p,'native'),personLocation(p,'birth')].join(' ').toLowerCase();}
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
@@ -30,7 +32,7 @@ async function loadFamily(resetView=false){
function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');}
function renderFamily(){
const reference=$('#family-reference').value,term=$('#family-search').value.trim().toLowerCase();
const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term));
const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&personSearch(p).includes(term));
$('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'+(term?' · 找到 '+people.length+' 位':'');
$('#family-find').hidden=!term;$('#family-find').disabled=!people.length;
$('#new-person').hidden=!family.canEdit;
@@ -40,7 +42,7 @@ function renderFamily(){
$('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p);
if(!p){$('#family-detail').innerHTML='<h3>人物档案</h3><p class="description">点击图中的人物查看生平,或录入第一位人物。</p>';return;}
const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id);
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button><button class="quiet" data-history-kind="person" data-history-id="${p.id}">修改记录</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button><button class="quiet" data-history-kind="link" data-history-id="${e.id}">历史</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
$('#family-detail').innerHTML=`<div class="section-head"><div><span class="eyebrow">人物档案</span><h2>${esc(p.name)}</h2></div>${family.canEdit?`<button data-edit-person="${p.id}" class="quiet">编辑人物</button><button class="quiet" data-history-kind="person" data-history-id="${p.id}">修改记录</button>`:''}</div><p class="description">相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}</p><dl>${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯(祖籍)',personLocation(p,'native')],['出生地',personLocation(p,'birth')],...(p.birthplace?[['原地域记录',p.birthplace]]:[])].map(([k,v])=>`<div><dt>${k}</dt><dd>${esc(v||'待补充')}</dd></div>`).join('')}</dl><h3>生平与记载</h3><p class="family-prose">${esc(p.biography||'尚未记录生平')}</p><p class="description family-prose">${esc(p.note||'')}</p><div class="section-head"><h3>亲属关系</h3>${family.canEdit?'<button id="add-relation" class="quiet">+ 连接亲属</button>':''}</div>${edges.map(e=>`<div class="family-link"><span>${esc(relationText(e))}<small>${esc(({biological:'亲生',adoptive:'收养',step:'继亲',unspecified:''}[e.lineage]||'')+' '+(e.note||''))}</small></span>${family.canEdit?`<button class="quiet" data-edit-relation="${e.id}">编辑</button><button class="quiet" data-history-kind="link" data-history-id="${e.id}">历史</button>`:''}</div>`).join('')||'<p class="description">尚未连接亲属。</p>'}`;
if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id));
$('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'<div class="family-self-heading">◎ 本人 · 当前账号关联人物</div>':''}<button type="button" id="family-show-person" class="quiet family-back-to-map">在图中定位</button>`);
}
@@ -54,7 +56,7 @@ function renderFamilyTree(){
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
}
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});initDateFields();$('#person-dialog').showModal();}
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});$('#person-legacy-place').hidden=!p.birthplace;initDateFields();$('#person-dialog').showModal();}
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();}
for(const [name,path]of [['person','person'],['relation','link']]){
$('#'+name+'-form').addEventListener('submit',async e=>{
@@ -67,7 +69,7 @@ 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>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}</td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
$('#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){
@@ -83,7 +85,7 @@ $('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderF
$('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
$('#family-zoom-in').onclick=()=>familyMap.zoom(1.25);$('#family-zoom-out').onclick=()=>familyMap.zoom(.8);$('#family-fit').onclick=()=>familyMap.fit();
$('#family-locate-self').onclick=()=>selectFamilyPerson(family.selfId,true);
$('#family-find').onclick=()=>{const term=$('#family-search').value.trim().toLowerCase(),matches=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term));if(!matches.length)return;const index=matches.findIndex(p=>p.id===focusPerson);selectFamilyPerson(matches[(index+1)%matches.length].id,true);};
$('#family-find').onclick=()=>{const term=$('#family-search').value.trim().toLowerCase(),matches=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&personSearch(p).includes(term));if(!matches.length)return;const index=matches.findIndex(p=>p.id===focusPerson);selectFamilyPerson(matches[(index+1)%matches.length].id,true);};
let familyBodyOverflow='';
function syncFamilyFullscreen(){
const expanded=document.fullscreenElement===$('#family-workspace')||$('#family-workspace').classList.contains('family-expanded');
+37
View File
@@ -0,0 +1,37 @@
'use strict';
// MD5 is required by Baidu's upload protocol; it is not used for authentication.
const ZhaoFileHash=(()=>{
const shifts=[7,12,17,22,5,9,14,20,4,11,16,23,6,10,15,21];
const constants=Array.from({length:64},(_,i)=>Math.floor(Math.abs(Math.sin(i+1))*4294967296)|0);
function md5(input){
const bytes=new Uint8Array(input),length=bytes.length,padded=new Uint8Array(Math.ceil((length+9)/64)*64);
padded.set(bytes);padded[length]=128;const view=new DataView(padded.buffer);
view.setUint32(padded.length-8,(length*8)>>>0,true);view.setUint32(padded.length-4,Math.floor(length/536870912),true);
let h=[0x67452301,0xefcdab89|0,0x98badcfe|0,0x10325476];
for(let offset=0;offset<padded.length;offset+=64){
let [a,b,c,d]=h;
for(let i=0;i<64;i++){
let f,g;
if(i<16){f=(b&c)|(~b&d);g=i;}
else if(i<32){f=(d&b)|(~d&c);g=(5*i+1)%16;}
else if(i<48){f=b^c^d;g=(3*i+5)%16;}
else{f=c^(b|~d);g=(7*i)%16;}
const shift=shifts[Math.floor(i/16)*4+i%4],sum=(a+f+constants[i]+view.getInt32(offset+g*4,true))|0;
const next=(b+((sum<<shift)|(sum>>>(32-shift))))|0;a=d;d=c;c=b;b=next;
}
h=[(h[0]+a)|0,(h[1]+b)|0,(h[2]+c)|0,(h[3]+d)|0];
}
return h.map(n=>[0,8,16,24].map(s=>((n>>>s)&255).toString(16).padStart(2,'0')).join('')).join('');
}
async function blocks(file,progress=()=>{},valid=()=>true){
const result=[];
for(let offset=0;offset<file.size;offset+=4194304){
if(!valid())throw Object.assign(Error('操作已取消'),{stale:true});
result.push(md5(await file.slice(offset,offset+4194304).arrayBuffer()));progress(Math.min(file.size,offset+4194304),file.size);
await new Promise(resolve=>setTimeout(resolve,0));
}
return result;
}
return {md5,blocks};
})();
if(typeof module!=='undefined')module.exports=ZhaoFileHash;
+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='';}
});
+13 -7
View File
@@ -1,12 +1,14 @@
'use strict';
pageInfo.households=['家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
pageInfo.households=['我的家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
function renderHouseholds(){
const homes=state.households||[];
setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));
$('#household-filter').value=state.familyId||'';
if(activePage!=='households')return;
$('#household-list').innerHTML=homes.map(h=>`<article class="site-card"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span><button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button></div><h3>${esc(h.name)}</h3><p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">${h.id===state.familyId?'管理本归属成员':'进入该归属'}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</article>`).join('');
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;
@@ -20,7 +22,7 @@ function openHousehold(id){
if(!isAdmin())return;
const item=(state.households||[]).find(h=>h.id===id);
if(!item&&!isPlatformAdmin())return;
fillForm($('#household-form'),item||{});$('#household-dialog').showModal();
nextImageJob('cover');fillForm($('#household-form'),{profileRevision:0,...(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{
@@ -41,11 +43,11 @@ $('#leadership-form').addEventListener('submit',async e=>{
try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
});
$('#household-filter').onchange=e=>switchHousehold(e.target.value);
$('#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));f.closest('dialog').close();await refresh();toast('归属档案已保存');}
e.preventDefault();if(imageJobPending('cover')){$('#household-cover-status').textContent='照片仍在读取,请完成后保存。';return;}const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{await api('/api/households',{...formValues(f),profileRevision:Number(f.elements.profileRevision.value||0),...(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;}
});
@@ -53,5 +55,9 @@ document.addEventListener('click',async e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.editHousehold)openHousehold(b.dataset.editHousehold);
if(b.dataset.leadership)await openLeadership(b.dataset.leadership);
if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('users');}
if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('overview');}
});
$('#household-cover-file').onchange=async e=>{const job=nextImageJob('cover'),generation=sessionGeneration;imageJobs.get('cover').pending=true;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!imageJobCurrent('cover',job)||!$('#household-dialog').open)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){if(generation===sessionGeneration&&imageJobCurrent('cover',job))$('#household-cover-status').textContent=e.message;}finally{if(imageJobCurrent('cover',job))imageJobs.get('cover').pending=false;}};
$('#household-cover-reset').onclick=()=>{nextImageJob('cover');coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';};
$('#household-dialog').addEventListener('close',()=>nextImageJob('cover'));
+48 -21
View File
File diff suppressed because one or more lines are too long
+199
View File
@@ -0,0 +1,199 @@
'use strict';
pageInfo.journal=['个人记事','一言一照,都是阅历。私藏或分享,由自己决定。'];
let journalGeneration=0,journalData=null,journalPage=0,journalUpload=false,journalRequest=null,journalRecorder=null,journalTracks=null,journalVoice=[],journalVoiceTimer=null,journalConfigRevision=0;
const journalDraftMedia=new Set(),journalSelectedMedia=new Set();
const journalId=()=>[...crypto.getRandomValues(new Uint8Array(16))].map(b=>b.toString(16).padStart(2,'0')).join('');
const journalToday=()=>{const d=new Date();return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');};
function journalBusy(value){journalUpload=value;$('#journal-save').disabled=value;$('#journal-new').disabled=value;for(const control of $('#journal-filter').elements)control.disabled=value;$('#journal-prev').disabled=value||!journalData?.page;$('#journal-next').disabled=value||!journalData||(journalData.page+1)*20>=journalData.total;$('#journal-record').disabled=value||!journalData?.connection.connected||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;}
function journalMessage(text){$('#journal-status').textContent=text;}
function journalValid(g){return g===journalGeneration&&loggedIn&&activePage==='journal';}
function journalMediaUrl(id,download=false){return '/media/journal?'+new URLSearchParams({id,family:state.familyId,...(download?{download:'1'}:{})});}
function releaseJournalMedia(root){
root.querySelectorAll('audio,video').forEach(v=>{v.pause();v.removeAttribute('src');v.load();});
root.querySelectorAll('img').forEach(img=>img.removeAttribute('src'));
}
function journalMediaView(m){
const name=esc(m.name),id=esc(m.id),src=esc(journalMediaUrl(m.id));
if(m.mime.startsWith('image/'))return `<figure class="journal-attachment"><button type="button" class="journal-photo" data-preview-media="${id}" aria-label="放大预览 ${name}"><img src="${src}" alt="${name}" loading="lazy" decoding="async"><span class="journal-photo-hint">点击放大</span></button><span class="journal-media-state" role="status">照片加载中…</span><button type="button" class="quiet" data-retry-media hidden>重新加载照片</button></figure>`;
const tag=m.mime.startsWith('audio/')?'audio':'video';
return `<figure class="journal-attachment journal-${tag}">${tag==='audio'?'<div class="journal-audio-label"><span aria-hidden="true">♪</span> 语音与音频</div>':''}<${tag} controls preload="metadata" playsinline src="${src}" aria-label="${name}"></${tag}><span class="journal-media-state" role="status">正在加载${tag==='audio'?'音频':'视频'}…</span><button type="button" class="quiet" data-retry-media hidden>重新加载</button></figure>`;
}
function bindJournalMedia(root){
root.querySelectorAll('.journal-attachment').forEach(box=>{
const media=box.querySelector('img,audio,video'),message=box.querySelector('.journal-media-state'),retry=box.querySelector('[data-retry-media]');
const ready=()=>{message.textContent='';retry.hidden=true;box.classList.remove('media-error');};
media.addEventListener(media.tagName==='IMG'?'load':'loadedmetadata',ready);
media.addEventListener('error',()=>{message.textContent=media.tagName==='IMG'?'照片暂未加载,请重试。':'暂时无法播放,请重试或下载原文件。';retry.hidden=false;box.classList.add('media-error');});
if(media.tagName==='IMG'&&media.complete&&media.naturalWidth)ready();
if(media.tagName!=='IMG'){
if(media.readyState>=1)ready();
media.addEventListener('waiting',()=>{message.textContent='正在缓冲…';});
media.addEventListener('playing',ready);media.addEventListener('canplay',ready);
media.addEventListener('play',()=>{$$('#journal-list audio,#journal-list video,#journal-upload-list audio,#journal-upload-list video,#journal-library-list audio,#journal-library-list video,#journal-preview audio,#journal-preview video').forEach(other=>{if(other!==media)other.pause();});});
}
});
}
function previewJournalMedia(id){
const all=[...(journalData?.uploads||[]),...(journalData?.trash||[]),...(journalData?.items||[]).flatMap(e=>e.media)];
const media=all.find(m=>m.id===id&&m.status==='ready');if(!media)return;
const dialog=$('#journal-preview');releaseJournalMedia($('#journal-preview-content'));
$('#journal-preview-title').textContent=media.name;$('#journal-preview-download').href=journalMediaUrl(id,true);
$('#journal-preview-content').innerHTML=journalMediaView(media);bindJournalMedia($('#journal-preview-content'));if(!dialog.open)dialog.showModal();
}
$('#journal-preview-close').onclick=()=>$('#journal-preview').close();
$('#journal-preview').addEventListener('close',()=>{releaseJournalMedia($('#journal-preview-content'));$('#journal-preview-content').replaceChildren();$('#journal-preview-download').removeAttribute('href');});
function stopJournalVoice(discard=true){
if(discard)journalVoice=[];if(journalRecorder&&journalRecorder.state!=='inactive')journalRecorder.stop();
journalTracks?.getTracks().forEach(t=>t.stop());journalTracks=null;clearTimeout(journalVoiceTimer);journalVoiceTimer=null;
$('#journal-record').disabled=false;$('#journal-stop-record').hidden=true;
}
function leaveJournal(){
journalGeneration++;journalRequest?.abort();journalRequest=null;journalBusy(false);stopJournalVoice();
for(const id of ['journal-list','journal-upload-list','journal-library-list','journal-preview-content'])releaseJournalMedia($('#'+id));
$('#journal-preview').close();
$('#journal-list').replaceChildren();$('#journal-files').disabled=false;$('#journal-save').disabled=false;
}
function clearJournalForm(){const f=$('#journal-form');f.reset();for(const key of ['id','revision','clientId'])f.elements[key].value='';}
function resetJournal(){leaveJournal();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library-list').replaceChildren();$('#journal-trash-list').replaceChildren();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';}
function newJournal(){clearJournalForm();stopJournalVoice();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library').open=false;const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();}
async function loadJournal(){
const g=++journalGeneration;if(!loggedIn)return;
if(!$('#journal-form').elements.clientId.value)newJournal();
$('#journal-person-label').hidden=$('#journal-mode').value!=='person';
const query=new URLSearchParams({mode:$('#journal-mode').value,person:$('#journal-person').value,category:$('#journal-category').value,search:$('#journal-search').value,from:$('#journal-from').value,to:$('#journal-to').value,page:String(journalPage)});
$('#journal-query-status').textContent='正在读取记事…';
try{
const result=await api('/api/journal?'+query);if(!journalValid(g))return;journalData=result;
const selected=$('#journal-person').value;$('#journal-person').innerHTML='<option value="">请选择人物</option>'+Object.entries(result.people).map(([id,name])=>`<option value="${esc(id)}">${esc(name)}</option>`).join('');$('#journal-person').value=selected;
$('#journal-query-status').textContent=result.total?`共 ${result.total} 条 · 第 ${result.page+1} 页`:'暂时没有记事。写下今天,也可以补记从前。';
$('#journal-prev').disabled=!result.page;$('#journal-next').disabled=(result.page+1)*20>=result.total;
$('#journal-cloud-note').textContent=result.connection.connected&&!result.connection.reconnect?'附件保存到后台统一连接的百度网盘;可见范围由系统控制。':'文字可直接保存。照片、视频、语音需由超级管理员先连接百度网盘。';
$('#journal-files').disabled=journalUpload||!result.connection.connected||result.connection.reconnect;
$('#journal-record').disabled=$('#journal-files').disabled||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;
renderJournalUploads();renderJournalEntries();
}catch(e){if(!e.stale&&journalValid(g))$('#journal-query-status').textContent=e.message;}
}
function journalUploadCard(m,library=false){
const id=esc(m.id),ready=m.status==='ready';
return `<div class="journal-upload-card" data-upload-card="${id}"><${library?'div':'label'} class="journal-upload-select">${library?'':`<input type="checkbox" value="${id}" ${ready?'':'disabled'} ${journalSelectedMedia.has(m.id)?'checked':''}>`}<span>${esc(m.name)}<small>${Math.ceil(m.size/1024)} KB · ${ready?'已存百度网盘':'等待继续上传'}</small></span></${library?'div':'label'}>${ready?journalMediaView(m):`<p class="description">${esc(m.error||'上传尚未完成,请选择原文件继续。')}</p>`}<div class="journal-card-actions">${ready?`<button type="button" class="quiet" data-preview-media="${id}">预览</button>${library?`<button type="button" class="quiet" data-use-upload="${id}">用于本条</button>`:`<button type="button" class="quiet" data-detach-upload="${id}">从本条移除</button>`}`:`<label class="quiet journal-resume">继续上传<input type="file" data-journal-resume="${id}"></label>`}<button type="button" class="quiet" data-remove-upload="${id}">删除</button></div></div>`;
}
function renderJournalUploads(){
const editing=journalData?.items.find(e=>e.id===$('#journal-form').elements.id.value);
const available=[...(journalData?.uploads||[])];for(const media of editing?.media||[])if(!available.some(m=>m.id===media.id))available.push(media);
releaseJournalMedia($('#journal-upload-list'));releaseJournalMedia($('#journal-library-list'));
$('#journal-upload-list').innerHTML=available.filter(m=>journalDraftMedia.has(m.id)).map(m=>journalUploadCard(m)).join('');
const library=(journalData?.uploads||[]).filter(m=>!journalDraftMedia.has(m.id));
$('#journal-library-count').textContent=String(library.length);
$('#journal-library-list').innerHTML=$('#journal-library').open?(library.map(m=>journalUploadCard(m,true)).join('')||'<p class="description">暂无其他附件。</p>'):'';
const trash=journalData?.trash||[];$('#journal-trash').hidden=!trash.length;
$('#journal-trash-count').textContent=String(trash.length);
$('#journal-trash-list').innerHTML=trash.map(m=>`<div class="journal-trash-row"><span>${esc(m.name)}</span><button type="button" class="quiet" data-restore-upload="${esc(m.id)}">恢复</button></div>`).join('');
bindJournalMedia($('#journal-upload-list'));bindJournalMedia($('#journal-library-list'));
}
$('#journal-library').addEventListener('toggle',renderJournalUploads);
$('#journal-upload-list').addEventListener('change',e=>{if(e.target.type==='checkbox'){if(e.target.checked)journalSelectedMedia.add(e.target.value);else journalSelectedMedia.delete(e.target.value);}});
function renderJournalEntries(){
releaseJournalMedia($('#journal-list'));
$('#journal-list').innerHTML=journalData.items.map(e=>`<article class="panel journal-entry" data-entry="${esc(e.id)}"><div class="section-head"><div><button class="quiet journal-author" data-journal-person="${esc(e.ownerId)}">${esc(e.authorName)}</button><small>${esc(e.occurredOn)} · ${esc(e.category)} · ${e.visibility==='private'?'仅自己可见':'当前家庭/场所'}</small></div>${e.canEdit?`<div><button class="quiet" data-journal-edit="${esc(e.id)}">编辑</button><button class="quiet" data-journal-delete="${esc(e.id)}">删除</button></div>`:''}</div><p class="journal-text">${esc(e.text)}</p><div class="journal-media">${e.media.map(m=>`<div>${journalMediaView(m)}<p class="journal-media-caption">${esc(m.name)} <a href="${esc(journalMediaUrl(m.id,true))}">下载原文件</a></p></div>`).join('')}</div><div class="journal-reactions"><button class="quiet" data-journal-like="${esc(e.id)}" aria-pressed="${e.liked}">${e.liked?'♥ 已赞':'♡ 点赞'} · ${e.likes}</button><button class="quiet" data-journal-person="${esc(e.ownerId)}">查看个人阅历</button></div><details class="journal-comments"><summary>评论 <span data-comment-count>${e.commentCount}</span></summary><div data-comments>${e.comments.map(c=>journalComment(e,c)).join('')}</div><form data-journal-comment="${esc(e.id)}"><label>写评论<input name="text" maxlength="500" required autocomplete="off"></label><button type="submit">发送</button><span class="form-error" role="status"></span></form></details></article>`).join('');
bindJournalMedia($('#journal-list'));
}
function journalComment(entry,c){return `<p class="journal-comment"><strong>${esc(c.authorName)}</strong> ${esc(c.text)} <small>${esc(new Date(c.createdAt).toLocaleString())}</small>${c.canDelete?` <button class="quiet" data-delete-comment="${esc(c.id)}" data-journal-entry="${esc(entry.id)}">删除评论</button>`:''}</p>`;}
$('#journal-form').addEventListener('submit',async event=>{
event.preventDefault();if(journalUpload){journalMessage('请等当前附件上传完成,或取消上传后保存文字。');return;}
const f=event.currentTarget,g=journalGeneration,button=$('#journal-save');button.disabled=true;
try{await api('/api/journal',{...formValues(f),revision:Number(f.elements.revision.value||0),mediaIds:[...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value)});if(!journalValid(g))return;newJournal();$('#journal-mode').value='mine';journalPage=0;await loadJournal();journalMessage('已保存,动态与个人阅历使用同一条记录。');}
catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{if(journalValid(g)||activePage==='journal')button.disabled=false;}
});
$('#journal-new').onclick=newJournal;
$('#journal-filter').onsubmit=e=>{e.preventDefault();journalPage=0;loadJournal();};
$('#journal-mode').onchange=()=>{$('#journal-person-label').hidden=$('#journal-mode').value!=='person';journalPage=0;loadJournal();};
$('#journal-prev').onclick=()=>{journalPage--;loadJournal();};$('#journal-next').onclick=()=>{journalPage++;loadJournal();};
async function uploadJournalFile(file,resume=''){
if(!file||journalUpload)return;if(!journalData?.connection.connected){journalMessage('请先由超级管理员连接百度网盘。');return;}if(file.size>268435456||!file.size){journalMessage('单个文件须为 1 字节至 256 MB。');return;}
journalBusy(true);const g=journalGeneration;$('#journal-save').disabled=true;$('#journal-files').disabled=true;$('#journal-cancel-upload').hidden=false;
try{
const hashes=await ZhaoFileHash.blocks(file,(n,total)=>journalMessage('正在计算文件校验:'+Math.round(n/total*100)+'%'),()=>journalValid(g)&&journalUpload);
if(!journalValid(g)||!journalUpload)return;
let task=await api('/api/journal/upload',{id:resume,name:file.name,size:file.size,mime:file.type,hashes});
if(!journalValid(g)||!journalUpload)return;
for(let offset=task.received;offset<file.size;){
if(!journalValid(g)||!journalUpload)return;
journalRequest=new AbortController();
const response=await fetch('/api/journal/chunk?'+new URLSearchParams({id:task.id,offset:String(offset)}),{method:'POST',headers:{'Content-Type':'application/octet-stream','X-Household-Id':state.familyId},body:file.slice(offset,Math.min(offset+524288,file.size)),signal:journalRequest.signal});
const result=await response.json();if(!response.ok)throw Error(result.error||'上传未完成');
if(!journalValid(g)||!journalUpload)return;
if(result.received<=offset)throw Error('上传进度未前进,请选择原文件继续');offset=result.received;journalMessage('正在上传百度网盘:'+Math.round(offset/file.size*95)+'%');
}
await api('/api/journal/finish',{id:task.id});if(!journalValid(g)||!journalUpload)return;
journalUpload=false;journalDraftMedia.add(task.id);journalSelectedMedia.add(task.id);await loadJournal();journalMessage('附件已保存到百度网盘。填写内容和可见范围后保存记事。');
}catch(e){if(!e.stale&&journalValid(g)){journalMessage(e.name==='AbortError'?'已暂停上传,可选择原文件继续。':e.message);journalUpload=false;const message=$('#journal-status').textContent;await loadJournal();journalMessage(message);}}
finally{journalBusy(false);journalRequest=null;if(activePage==='journal'){$('#journal-save').disabled=false;$('#journal-files').disabled=!journalData?.connection.connected;$('#journal-cancel-upload').hidden=true;}}
}
$('#journal-files').onchange=e=>{uploadJournalFile(e.target.files[0]);e.target.value='';};
$('#journal-cancel-upload').onclick=()=>{journalBusy(false);journalRequest?.abort();journalMessage('已暂停。重新选择原文件可继续上传。');};
$('#journal-record').onclick=async()=>{
if(journalUpload||!journalData?.connection.connected)return;
const g=journalGeneration;try{
const tracks=await navigator.mediaDevices.getUserMedia({audio:true});if(!journalValid(g)){tracks.getTracks().forEach(t=>t.stop());return;}
journalTracks=tracks;journalVoice=[];const type=['audio/webm;codecs=opus','audio/mp4','audio/ogg;codecs=opus'].find(t=>MediaRecorder.isTypeSupported(t));
if(!type){stopJournalVoice();throw Error('当前浏览器不能直接录音,可上传已有音频文件。');}
const recorder=new MediaRecorder(tracks,{mimeType:type});journalRecorder=recorder;
recorder.ondataavailable=e=>{if(journalValid(g)&&e.data.size)journalVoice.push(e.data);};
recorder.onstop=()=>{if(journalValid(g)&&journalVoice.length){const blob=new Blob(journalVoice,{type}),extension=type.includes('mp4')?'m4a':type.includes('ogg')?'ogg':'webm';uploadJournalFile(new File([blob],'语音-'+Date.now()+'.'+extension,{type}));}journalVoice=[];};
recorder.start();$('#journal-record').disabled=true;$('#journal-stop-record').hidden=false;journalMessage('正在录音,最长 5 分钟。点击结束后上传。');journalVoiceTimer=setTimeout(()=>stopJournalVoice(false),300000);
}catch(e){if(journalValid(g))journalMessage(e.name==='NotAllowedError'?'未获得麦克风权限,可上传已有语音文件。':e.message);}
};
$('#journal-stop-record').onclick=()=>stopJournalVoice(false);
document.addEventListener('change',e=>{if(e.target.dataset.journalResume){uploadJournalFile(e.target.files[0],e.target.dataset.journalResume);e.target.value='';}});
document.addEventListener('click',async event=>{
const b=event.target.closest('button');if(!b)return;const g=journalGeneration;
if(b.dataset.journalPerson){$('#journal-mode').value='person';$('#journal-person-label').hidden=false;$('#journal-person').value=b.dataset.journalPerson;journalPage=0;loadJournal();return;}
if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];journalDraftMedia.clear();journalSelectedMedia.clear();e.mediaIds.forEach(id=>{journalDraftMedia.add(id);journalSelectedMedia.add(id);});renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);f.scrollIntoView({behavior:'smooth'});return;}
if(b.dataset.useUpload){journalDraftMedia.add(b.dataset.useUpload);journalSelectedMedia.add(b.dataset.useUpload);renderJournalUploads();return;}
if(b.dataset.detachUpload){journalDraftMedia.delete(b.dataset.detachUpload);journalSelectedMedia.delete(b.dataset.detachUpload);renderJournalUploads();return;}
if(b.dataset.previewMedia){previewJournalMedia(b.dataset.previewMedia);return;}
if(b.hasAttribute('data-retry-media')){
const box=b.closest('.journal-attachment'),media=box.querySelector('img,audio,video');
b.hidden=true;box.classList.remove('media-error');box.querySelector('.journal-media-state').textContent='正在重新加载…';
if(media.tagName==='IMG'){const src=media.src;media.removeAttribute('src');media.src=src;}else media.load();return;
}
const id=b.dataset.journalLike||b.dataset.journalDelete||b.dataset.journalEntry;
if(!id&&!b.dataset.removeUpload&&!b.dataset.restoreUpload)return;b.disabled=true;
try{
if(b.dataset.removeUpload||b.dataset.restoreUpload){
if(journalUpload){journalMessage('请等待当前上传完成,或先暂停上传。');return;}
const restore=!!b.dataset.restoreUpload;
await api(restore?'/api/journal/restore-upload':'/api/journal/remove-upload',{id:b.dataset.restoreUpload||b.dataset.removeUpload});
if(!journalValid(g))return;
const result=await api('/api/journal?mode=mine');if(!journalValid(g))return;
journalData.uploads=result.uploads;journalData.trash=result.trash;if(!restore){journalDraftMedia.delete(b.dataset.removeUpload);journalSelectedMedia.delete(b.dataset.removeUpload);}
// Keep an editor's existing references current without replacing its draft or feed players.
for(const entry of journalData.items)for(const media of entry.media){const current=[...result.uploads,...result.trash].find(m=>m.id===media.id);if(current)Object.assign(media,current);}
renderJournalUploads();journalMessage(restore?'附件已恢复,可重新勾选使用。':'已移到“已删除附件”,可以恢复。已发布的记事和网盘原文件保留。');return;
}
const entry=journalData.items.find(e=>e.id===id);if(!entry)return;
if(b.dataset.journalLike){await api('/api/journal/action',{id,action:'like',liked:!entry.liked});if(!journalValid(g))return;entry.liked=!entry.liked;entry.likes+=entry.liked?1:-1;b.textContent=(entry.liked?'♥ 已赞':'♡ 点赞')+' · '+entry.likes;b.setAttribute('aria-pressed',String(entry.liked));}
if(b.dataset.journalDelete){if(!confirm('删除这条记事?网盘原文件保留。'))return;await api('/api/journal/action',{id,action:'delete',revision:entry.revision});if(journalValid(g))loadJournal();}
if(b.dataset.deleteComment){await api('/api/journal/action',{id,action:'delete-comment',commentId:b.dataset.deleteComment});if(journalValid(g)){b.closest('.journal-comment').remove();entry.commentCount--;document.querySelector(`[data-entry="${id}"] [data-comment-count]`).textContent=entry.commentCount;}}
}catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{b.disabled=false;}
});
document.addEventListener('submit',async event=>{
const f=event.target;if(!f.dataset.journalComment)return;event.preventDefault();const id=f.dataset.journalComment,g=journalGeneration,b=f.querySelector('button');b.disabled=true;const text=f.elements.text.value;
f.dataset.clientId ||= journalId();
try{await api('/api/journal/action',{id,action:'comment',text,clientId:f.dataset.clientId});if(!journalValid(g))return;
const entry=journalData.items.find(e=>e.id===id),comment={id:f.dataset.clientId,text,authorName:currentUser.name,createdAt:new Date().toISOString(),canDelete:true};
if(!entry.comments.some(c=>c.id===comment.id)){entry.comments.push(comment);entry.commentCount++;f.parentElement.querySelector('[data-comments]').insertAdjacentHTML('beforeend',journalComment(entry,comment));f.parentElement.querySelector('[data-comment-count]').textContent=entry.commentCount;}
f.reset();delete f.dataset.clientId;f.querySelector('.form-error').textContent='';
}catch(e){if(!e.stale&&journalValid(g))f.querySelector('.form-error').textContent=e.message;}finally{b.disabled=false;}
});
async function loadBaiduSettings(){
const g=appearanceGeneration;$('#baidu-admin').hidden=!isPlatformAdmin();if(!isPlatformAdmin())return;
try{const [config,status]=await Promise.all([api('/api/baidu/config'),api('/api/baidu/status')]);if(g!==appearanceGeneration||activePage!=='settings')return;
const f=$('#baidu-config');for(const k of ['appKey','appFolder','redirectUri'])f.elements[k].value=config[k];f.elements.secretKey.value='';f.elements.secretKey.placeholder=config.hasSecret?'已保存,留空保持原值':'填写 SecretKey';journalConfigRevision=config.revision;
$('#baidu-status').textContent=status.connected?(status.reconnect?'网盘授权已失效,请重新连接。':'已连接统一网盘:'+status.account):'尚未连接百度网盘';$('#baidu-connect').disabled=!config.configured;$('#baidu-disconnect').disabled=!status.connected;
}catch(e){if(!e.stale&&g===appearanceGeneration)$('#baidu-status').textContent=e.message;}
}
$('#baidu-config').onsubmit=async e=>{e.preventDefault();const g=appearanceGeneration,f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api('/api/baidu/config',{...formValues(f),revision:journalConfigRevision});if(g!==appearanceGeneration)return;f.elements.secretKey.value='';await loadBaiduSettings();$('#baidu-config-status').textContent='应用配置已保存,下一步连接百度网盘。';}catch(e){if(!e.stale&&g===appearanceGeneration)$('#baidu-config-status').textContent=e.message;}finally{b.disabled=false;}};
$('#baidu-connect').onclick=async()=>{try{const result=await api('/api/baidu/connect',{});const address=new URL(result.url);if(address.origin!=='https://openapi.baidu.com')throw Error('授权地址不正确');location.assign(result.url);}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
$('#baidu-disconnect').onclick=async()=>{if(!confirm('解除本机网盘连接后,媒体将暂时无法访问;网盘文件保留。确认解除?'))return;try{await api('/api/baidu/disconnect',{});loadBaiduSettings();}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
document.addEventListener('DOMContentLoaded',()=>{const result=new URLSearchParams(location.search).get('baidu');if(result){history.replaceState(null,'',location.pathname);toast(result==='ok'?'网盘授权已保存,请在设置中心查看连接状态。':'授权未完成,请检查回调地址、原登录状态,并重新连接。');}},{once:true});
+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';
}
+121
View File
@@ -0,0 +1,121 @@
'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;
const imageJobs=new Map();
function nextImageJob(key){const value=(imageJobs.get(key)?.value||0)+1;imageJobs.set(key,{value,pending:false});return value;}
function imageJobCurrent(key,value){return imageJobs.get(key)?.value===value;}
function imageJobPending(key){return !!imageJobs.get(key)?.pending;}
function invalidateBrandImages(){nextImageJob('logo');nextImageJob('icon');nextImageJob('import');}
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(){
invalidateBrandImages();nextImageJob('cover');
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){
invalidateBrandImages();
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;}
if(typeof loadBaiduSettings==='function')loadBaiduSettings();
$('#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,job=nextImageJob(type);nextImageJob('import');imageJobs.get(type).pending=true;try{const image=await readImage(e.target.files[0],max);if(generation!==appearanceGeneration||!imageJobCurrent(type,job))return;if(type==='logo')brandLogo=image;else brandIcon=image;renderBrandImages();$('#brand-status').textContent='图片已准备,保存发布配置后使用。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent(type,job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent(type,job))imageJobs.get(type).pending=false;}};
}
$('#brand-logo-reset').onclick=()=>{nextImageJob('logo');nextImageJob('import');brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{nextImageJob('icon');nextImageJob('import');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();if(imageJobPending('logo')||imageJobPending('icon')||imageJobPending('import')){$('#brand-status').textContent='图片或配置仍在读取,请完成后保存。';return;}const controls=[...e.currentTarget.elements],generation=appearanceGeneration;for(const control of controls)control.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{for(const control of controls)control.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;invalidateBrandImages();const job=nextImageJob('import');imageJobs.get('import').pending=true;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||!imageJobCurrent('import',job))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&&imageJobCurrent('import',job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent('import',job))imageJobs.get('import').pending=false;}};
function fillBackup(value){const c=value.config,f=$('#backup-form');backupRevision=c.revision;f.elements.enabled.checked=c.enabled;if(f.elements.baidu)f.elements.baidu.checked=!!c.baidu;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.baiduOk?(r.cloudCleanupError?'网盘备份已校验;过期副本清理待重试':r.cloudCleanupOk?'网盘备份已校验,过期副本清理完成':'本机备份完成,百度网盘已确认文件'):r.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';}
function renderBackup(value){
$('#backup-run').disabled=value.running;$('#backup-result').textContent=value.running?(value.stage||'正在生成数据库副本…'):backupLabel(value.last)+(value.last?.error?':'+value.last.error:'')+(value.last?.cloudCleanupError?':'+value.last.cloudCleanupError:'');
$('#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.baiduPath?`<small>百度网盘:${esc(r.baiduPath)}</small>`:''}${r.cloudCleanupOk?`<small>保留最近一个自然月,本次删除 ${Number(r.cloudDeleted||0)} 份过期备份</small>`:''}${r.cloudCleanupError?`<small>${esc(r.cloudCleanupError)}</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,baidu:f.elements.baidu?.checked||false,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});}
});