Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a77fcc6671 | |||
| 693dd2a10a | |||
| 67709832c5 | |||
| 61968adf94 | |||
| 631c411602 | |||
| 83e8981603 |
@@ -5,6 +5,8 @@ runtime/
|
||||
*.log
|
||||
*.key
|
||||
*.env
|
||||
baidu-secrets.json
|
||||
baidu-secrets.partial
|
||||
__pycache__/
|
||||
.venv/
|
||||
mediamtx
|
||||
|
||||
@@ -111,3 +111,14 @@
|
||||
前端 `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 依赖挂载自身的超时和可用性;不会挂载网盘、保存网盘登录密码或声称云端同步已确认。服务实例必须单进程部署,跨进程任务互斥尚不支持。
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# 赵府智家 · 视觉系统
|
||||
|
||||
## v0.1.30 · 日常操作流程与手机网页
|
||||
|
||||
- 家庭或办公、工厂是权限归属;内部区域可分多级。设备提供按区域、类型、负责人三个快捷视图,并保留自定义组合。搜索支持“卧室 灯”等多词组合,涵盖区域、品牌、人员与标签。
|
||||
- 总览补充记事入口;区域管理收进设备页;设备返回列表,家谱与家族记事返回家庭传承。区分个人日常记事与家族重大记事,办公场所不显示家族传承。
|
||||
- 修复编辑后新建残留隐藏记录编号的问题。新建区域可选择新增下级,设备和录像机继承当前选中的区域。
|
||||
- 记事保存后清空本次输入、附件和旧筛选,重新加载并定位保存结果。补记往事也可定位到对应分页;空页自动回到有效页。保存中锁定表单,失败保留草稿;另写、退出或切换家庭前提示未保存输入。
|
||||
- 设置中心分为我的偏好、网盘连接、品牌与外观、数据库备份,后三项仅超级管理员可配置。录像机原有历史录像明确标注待接入,避免查询后才发现不可用。
|
||||
- 手机使用可展开的顶部菜单,选择页面后自动收起。表单单列、输入字号 16px、按钮至少 44px;修复设备弹窗、媒体预览和筛选操作在窄屏的布局。
|
||||
|
||||
验证:138 项 Python 回归、109 项 JavaScript 回归。通过 Edge Computer Use 验证管理员和普通成员、设备查找与编辑后新建、记事保存清空与定位、照片预览和语音播放、草稿取消清空、设置分区、办公场所切换。响应式检查覆盖 360、390、430、768px;手机浏览器引擎、软键盘和真机录音仍需 Android/iOS 实机复核。流程测试使用独立的本地合成数据,未向正式家庭写入测试记事或设备。
|
||||
|
||||
范围:灯、冰箱等仍是设备档案,自动控制需接入实际协议;录像机原有历史录像接口仍待接入。浏览器预览测试不能替代长时间监控稳定性或设备全部厂商功能的验证。
|
||||
|
||||
## 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 +93,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 使用管理员与个人账号分权。
|
||||
|
||||
@@ -232,3 +271,28 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有
|
||||
交接说明只是记录意愿,不自动改变财产所有权、设备责任人、账号角色或家主安排。实际系统权限交接继续走「我的家庭与场所 → 负责人安排与交接」。当前非超级管理员账号仍只属于一个家庭或场所,多归属成员身份尚未实现。
|
||||
|
||||
升级幂等创建 `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 共享目录。网络类型校验不代替云服务同步状态确认。
|
||||
|
||||
@@ -34,6 +34,10 @@ import genealogy
|
||||
import households
|
||||
import inventory
|
||||
import heritage
|
||||
import appearance
|
||||
import backups
|
||||
import baidu
|
||||
import journal
|
||||
import ptz
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
@@ -79,6 +83,10 @@ 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):
|
||||
@@ -515,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', ''))
|
||||
@@ -616,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 in ('/api/family/event','/api/heritage') 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/heritage', '/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')
|
||||
@@ -817,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()})
|
||||
@@ -830,6 +910,48 @@ 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')
|
||||
@@ -923,7 +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',
|
||||
'/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',
|
||||
@@ -963,6 +1086,7 @@ def 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'):
|
||||
@@ -978,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
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
+17
-1
@@ -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
|
||||
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
"""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('页码不正确')
|
||||
if query.get('focus'):
|
||||
index = next((i for i, entry in enumerate(entries) if entry['id'] == query['focus']), None)
|
||||
if index is not None: page = index // 20
|
||||
page = min(page, max(0, (len(entries)-1)//20))
|
||||
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
@@ -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), 24)
|
||||
self.assertEqual(len(assets), 27)
|
||||
version = (app.ROOT / 'VERSION').read_text().strip()
|
||||
for asset in assets:
|
||||
self.assertTrue(asset.endswith('?v=' + version))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -0,0 +1,69 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
|
||||
const source=name=>fs.readFileSync('web/'+name,'utf8');
|
||||
function part(file,start,end){const s=source(file);return s.slice(s.indexOf(start),s.indexOf(end,s.indexOf(start)+start.length));}
|
||||
const devices=require('./web/device-model.js');
|
||||
const areas=[{id:'floor',name:'一楼'},{id:'room',name:'卧室',parentId:'floor'}];
|
||||
const lamp={name:'台灯',deviceType:'灯',siteId:'room',brand:'小米',managerName:'爸爸',tags:['阅读']};
|
||||
test('device search combines area, type, brand, responsible person and tags',()=>{
|
||||
for(const q of ['卧室 灯','一楼 小米','爸爸 阅读',' 卧室 台灯 '])assert.ok(devices.matches(lamp,q,areas),q);
|
||||
assert.ok(!devices.matches(lamp,'厨房 灯',areas));assert.ok(devices.matches(lamp,'',areas));
|
||||
});
|
||||
test('grouping views retain devices and their location',()=>{
|
||||
const before=JSON.stringify(lamp);
|
||||
for(const dimensions of [['space'],['type','space'],['manager','type'],['owner','tag']])assert.equal(devices.groups([lamp],dimensions,areas).flatMap(g=>g.items).length,1);
|
||||
assert.equal(JSON.stringify(lamp),before);
|
||||
});
|
||||
test('editor reset clears old identities and revisions after editing',()=>{
|
||||
const fields=[{name:'id',value:'old'},{name:'familyRevision',value:'9'},{name:'revision',value:'3'}];
|
||||
let resets=0;const c=vm.createContext({});vm.runInContext(part('app.js','function resetEditorForm(','function formValues('),c);
|
||||
c.resetEditorForm({reset(){resets++;},querySelectorAll(){return fields;}});
|
||||
assert.equal(resets,1);assert.deepEqual(fields.map(f=>f.value),['','0','0']);
|
||||
});
|
||||
test('back buttons follow place, device and family hierarchy',()=>{
|
||||
const c=vm.createContext({});vm.runInContext(part('steward.js','function scopeReturn(','function selectedDevice('),c);
|
||||
for(const [page,device,target] of [['overview',null,'households'],['live',{},'objects'],['sites',null,'objects'],['family',null,'heritage'],['heritage-records',null,'heritage'],['journal',null,'overview']])assert.equal(c.scopeReturn(page,device).page,target);
|
||||
});
|
||||
function settingsContext(admin){
|
||||
const nodes={};const c=vm.createContext({settingsSection:'preferences',isPlatformAdmin:()=>admin,$:id=>nodes[id]??={},$$:()=>[]});
|
||||
vm.runInContext(part('personalization.js','function showSettingsSection(','document.addEventListener'),c);return {c,nodes};
|
||||
}
|
||||
test('settings show one section at a time',()=>{
|
||||
const {c,nodes}=settingsContext(true);
|
||||
for(const section of ['preferences','baidu','brand','backup']){c.showSettingsSection(section);assert.equal(Object.values(nodes).filter(n=>!n.hidden).length,1);assert.equal(c.settingsSection,section);}
|
||||
});
|
||||
test('members cannot select platform settings',()=>{
|
||||
const {c,nodes}=settingsContext(false);for(const section of ['baidu','brand','backup','unknown']){c.showSettingsSection(section);assert.equal(c.settingsSection,'preferences');assert.equal(nodes['#backup-panel'].hidden,true);}
|
||||
});
|
||||
function journalContext(){
|
||||
const fields=Object.fromEntries(['text','occurredOn','category','visibility'].map(k=>[k,{value:k==='visibility'?'private':''}]));
|
||||
const nodes={'#journal-form':{elements:fields}};
|
||||
const c=vm.createContext({journalSelectedMedia:new Set(),journalBaseline:'',journalUpload:false,journalTracks:null,journalSaving:false,journalPage:3,confirm:()=>false,toast:()=>{},$:id=>nodes[id]??={value:''}});
|
||||
vm.runInContext(part('journal.js','function journalDraftSignature(','const journalId='),c);return {c,fields,nodes};
|
||||
}
|
||||
test('saved entry filter reset clears searches and dates but retains privacy',()=>{
|
||||
const {c,fields,nodes}=journalContext();for(const id of ['journal-search','journal-category','journal-from','journal-to','journal-person'])c.$('#'+id).value='old';
|
||||
c.resetJournalFilters('mine');assert.equal(c.journalPage,0);assert.equal(nodes['#journal-search'].value,'');assert.equal(nodes['#journal-person-label'].hidden,true);assert.equal(fields.visibility.value,'private');
|
||||
});
|
||||
test('dirty drafts and pending saves are protected',()=>{
|
||||
const {c,fields}=journalContext();c.journalBaseline=c.journalDraftSignature();assert.equal(c.journalHasDraft(),false);
|
||||
fields.text.value='未保存';assert.equal(c.journalHasDraft(),true);assert.equal(c.confirmJournalDiscard(),false);
|
||||
c.confirm=()=>true;assert.equal(c.confirmJournalDiscard(),true);c.journalSaving=true;assert.equal(c.confirmJournalDiscard(),false);
|
||||
});
|
||||
test('empty feed explains private visibility and filtered emptiness',()=>{
|
||||
const {c}=journalContext();c.$('#journal-mode').value='feed';assert.match(c.journalEmptyMessage(),/仅自己可见/);
|
||||
c.$('#journal-search').value='missing';assert.match(c.journalEmptyMessage(),/清除筛选/);
|
||||
});
|
||||
|
||||
test('mobile navigation toggles an accessible control without changing scope',()=>{
|
||||
let opened=false;const button={setAttribute(k,v){this[k]=v;},textContent:''};
|
||||
const c=vm.createContext({$:id=>id==='.sidebar'?{classList:{toggle(k,v){opened=v;}}}:button});
|
||||
vm.runInContext(part('app.js','function setMobileMenu(',"$('#mobile-nav-toggle').onclick"),c);
|
||||
c.setMobileMenu(true);assert.equal(opened,true);assert.equal(button['aria-expanded'],'true');
|
||||
c.setMobileMenu(false);assert.equal(opened,false);assert.equal(button['aria-expanded'],'false');
|
||||
});
|
||||
test('mobile forms use readable inputs, touch targets and bounded dialogs',()=>{
|
||||
const css=source('chinese-theme.css'),html=source('index.html');
|
||||
assert.match(css,/font-size:16px!important;min-height:44px/);
|
||||
assert.match(css,/max-height:90dvh/);assert.match(css,/sidebar:not\(\.mobile-open\)>nav/);
|
||||
assert.match(html,/aria-controls="main-navigation"/);
|
||||
});
|
||||
@@ -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('<','<'),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\(\);resetJournalFilters\('mine'\);await loadJournal\(saved.id\)/);
|
||||
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);
|
||||
});
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
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_saved_backdated_entry_focus_and_page_clamp(self):
|
||||
for _ in range(21): self.saved()
|
||||
body = self.note(); body['occurredOn'] = '2000-01-01'
|
||||
key = app.JOURNAL.save(self.actor, body)['id']
|
||||
focused = app.JOURNAL.snapshot(self.actor, dict(mode='mine', focus=key))
|
||||
self.assertEqual(focused['page'], 1)
|
||||
self.assertIn(key, [e['id'] for e in focused['items']])
|
||||
self.assertEqual(app.JOURNAL.snapshot(self.actor, dict(mode='mine', page='99'))['page'], 1)
|
||||
empty = app.JOURNAL.snapshot(self.actor, dict(mode='mine', page='99', search='不存在的关键词'))
|
||||
self.assertEqual(empty['page'], 0)
|
||||
self.assertEqual(empty['items'], [])
|
||||
|
||||
def test_focus_does_not_reveal_private_or_foreign_entries(self):
|
||||
private = self.saved()
|
||||
for user in (self.owner, self.root, self.other):
|
||||
result = app.JOURNAL.snapshot(user, dict(mode='feed', focus=private))
|
||||
self.assertEqual(result['total'], 0)
|
||||
self.assertEqual(result['items'], [])
|
||||
|
||||
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)
|
||||
@@ -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);
|
||||
});
|
||||
+1
-1
@@ -2,7 +2,7 @@ const test=require('node:test'),assert=require('node:assert/strict'),fs=require(
|
||||
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];
|
||||
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="设备功能"/);
|
||||
|
||||
+34
-17
@@ -25,6 +25,8 @@ function applyPermissions(){
|
||||
$('#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();
|
||||
@@ -56,27 +58,34 @@ function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendant
|
||||
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, options={}) {
|
||||
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records'].includes(page))return;
|
||||
if(typeof journalSaving!=='undefined'&&journalSaving&&page!==activePage){toast('记事正在保存,请稍候。');return;}
|
||||
setMobileMenu(false);
|
||||
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)&¤tHome()?.kind&¤tHome().kind!=='home')return;
|
||||
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
|
||||
if(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage'].includes(page);
|
||||
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings','journal'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
|
||||
if(typeof scopeEntered!=='undefined'){if(page==='households')scopeEntered=false;else if(!['storage','settings'].includes(page))scopeEntered=true;}
|
||||
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','heritage','heritage-records'].includes(page);
|
||||
const pageChanged=activePage!==page;activePage = 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'&&['device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
|
||||
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['sites','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(pageChanged)window.scrollTo({top:0,left:0,behavior:'instant'});
|
||||
if(page==='playback'){$('#archive-source').value=state.cameras.find(c=>c.id===$('#play-camera').value)?.archiveSource||'local';playbackSourceHint();}
|
||||
if (page === 'storage') loadAudit();
|
||||
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;
|
||||
@@ -109,7 +118,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-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('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
|
||||
$('#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`));
|
||||
@@ -135,7 +144,7 @@ function renderCameras() {
|
||||
}
|
||||
function renderSites() {
|
||||
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id));
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
|
||||
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><button class="quiet" data-child-site="${esc(s.id)}">+ 新增下级区域</button><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
|
||||
}
|
||||
function renderStorage() {
|
||||
const s=state.storage;
|
||||
@@ -144,9 +153,17 @@ function renderStorage() {
|
||||
}
|
||||
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
|
||||
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
|
||||
async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
|
||||
function openSite(id) {const f=$('#site-form');f.reset();f.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();}
|
||||
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,parentId='') {const f=$('#site-form');resetEditorForm(f);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){f.elements.parentId.value=parentId||$('#site-filter').value||'';f.elements.kind.value=currentHome()?.kind==='office'?'company':currentHome()?.kind||'home';}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');resetEditorForm(f);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 setMobileMenu(open){$('.sidebar').classList.toggle('mobile-open',open);$('#mobile-nav-toggle').setAttribute('aria-expanded',String(open));$('#mobile-nav-toggle').textContent=open?'收起菜单':'☰ 菜单';}
|
||||
$('#mobile-nav-toggle').onclick=()=>setMobileMenu($('#mobile-nav-toggle').getAttribute('aria-expanded')!=='true');
|
||||
document.addEventListener('keydown',event=>{if(event.key==='Escape'&&$('#mobile-nav-toggle').getAttribute('aria-expanded')==='true'){setMobileMenu(false);$('#mobile-nav-toggle').focus();}});
|
||||
function resetEditorForm(form){
|
||||
form.reset();
|
||||
// Hidden input values become reset defaults in browsers; always clear record identity explicitly.
|
||||
for(const field of form.querySelectorAll('input[type="hidden"]'))field.value=field.name.toLowerCase().includes('revision')?'0':'';
|
||||
}
|
||||
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
|
||||
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
|
||||
handleForm('site-form','/api/sites');
|
||||
@@ -154,22 +171,22 @@ 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(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();};
|
||||
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.childSite)openSite('',b.dataset.childSite);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()=>{if(typeof confirmJournalDiscard==='function'&&!confirmJournalDiscard())return;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',()=>{showPage('households');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&&$('#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 openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');resetEditorForm(f);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');resetEditorForm(f);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&&$('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
|
||||
function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;}
|
||||
$('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint;
|
||||
handleForm('asset-form','/api/assets',d=>({...deviceValues(d),lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...deviceValues(d),port:Number(d.port)}));
|
||||
|
||||
@@ -261,3 +261,83 @@ footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
|
||||
.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}}
|
||||
/* Compact mobile navigation and touch-friendly forms. */
|
||||
.mobile-nav-toggle{display:none}
|
||||
@media(max-width:760px){
|
||||
.sidebar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:14px 16px;height:auto;min-height:0}
|
||||
.sidebar .brand{min-width:0;padding:0;gap:10px}.sidebar .brand>span{min-width:0}.sidebar #brand-name{font-size:23px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar .brand-icon{width:38px;height:44px}.sidebar #brand-subtitle{font-size:11px;letter-spacing:1px}
|
||||
.sidebar .mobile-nav-toggle{display:block;margin:0;min-height:44px;color:#f7f3e5;border-color:#8faaa0;padding:9px 14px}
|
||||
.sidebar:not(.mobile-open)>nav,.sidebar:not(.mobile-open)>#current-user,.sidebar:not(.mobile-open)>#account-password,.sidebar:not(.mobile-open)>#logout{display:none}
|
||||
.sidebar>nav,.sidebar>#current-user{grid-column:1/-1}.sidebar.mobile-open>nav{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:0;padding:8px 0 0}.sidebar #scope-navigation{display:contents}.sidebar #scope-navigation[hidden]{display:none}.sidebar nav button{width:100%;text-align:left;min-height:44px;white-space:normal;margin:0;font-size:14px}
|
||||
.sidebar.mobile-open>#current-user{margin:0;padding:12px 0 0}.sidebar.mobile-open>#account-password,.sidebar.mobile-open>#logout{display:block;min-height:44px;margin:0}
|
||||
main{padding:22px 16px}main>header{margin-bottom:22px}.header-status #clock{display:none}
|
||||
input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{font-size:16px!important;min-height:44px}button,.button{min-height:44px;touch-action:manipulation}
|
||||
.form-grid{grid-template-columns:1fr}.form-grid>*{min-width:0}.form-grid .wide{grid-column:1/-1}
|
||||
.section-head{flex-wrap:wrap;gap:12px}.section-head>div{max-width:100%}.section-head button{white-space:normal}
|
||||
.household-context{gap:10px;padding:12px}.household-context label{width:100%;display:flex;flex-wrap:wrap}.household-context select{flex:1;min-width:0}.household-context #household-context-note{display:none}
|
||||
.filter-row{gap:10px;flex-wrap:wrap}.filter-row label{width:100%;display:grid;grid-template-columns:1fr}.filter-row select{width:100%}
|
||||
.settings-sections,.view-presets{gap:8px}.settings-sections button,.view-presets button{flex:1 1 100px;white-space:normal}
|
||||
.device-actions,.device-search-row{gap:10px}.device-search-row{display:grid;grid-template-columns:1fr}
|
||||
dialog{width:calc(100% - 24px);max-width:calc(100% - 24px);max-height:90vh;max-height:90dvh;padding:18px;overscroll-behavior:contain}dialog header,dialog .dialog-heading{gap:12px;align-items:flex-start}dialog h2{font-size:22px}dialog footer{flex-wrap:wrap}
|
||||
.journal-compose,.journal-entry{padding:18px}.journal-filters{gap:12px}.journal-filters label{flex-basis:100%;min-width:0}.journal-add-media input{max-width:100%;width:100%}.journal-add-media>label{width:100%}.journal-preview{padding:16px}.journal-preview .journal-photo img,.journal-preview video{max-height:60vh;max-height:60dvh}.journal-upload-card{padding:12px}.journal-media audio{min-width:0}.journal-comments form label{min-width:0;flex-basis:100%}
|
||||
.device-context [role=group]{display:flex;flex-wrap:wrap;gap:8px}.device-context button{flex:1 1 42%;white-space:normal}.metrics{margin-bottom:20px}
|
||||
}
|
||||
@media(max-width:760px){.sidebar .brand{grid-column:1;max-width:100%;width:auto}.sidebar>.mobile-nav-toggle{grid-column:2;grid-row:1;width:auto;max-width:110px}.sidebar .brand-icon{flex-shrink:0}.sidebar .brand>span{overflow:hidden}.sidebar #brand-name{font-size:21px}}
|
||||
|
||||
+6
-1
@@ -13,6 +13,11 @@
|
||||
...(state.assets||[]).map(x=>({...x,source:'camera',deviceType:'摄像机'})),
|
||||
...(state.recorders||[]).map(x=>({...x,source:'recorder',deviceType:'录像机'})),
|
||||
...(state.devices||[]).map(x=>({...x,source:'device'}))];}
|
||||
function matches(device,query,sites){
|
||||
const words=String(query||'').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const text=[device.name,device.deviceType,device.brand,device.model,device.serial,device.ownerName,device.managerName,...(device.tags||[]),...path(sites,device.siteId).map(s=>s.name)].join(' ').toLowerCase();
|
||||
return words.every(word=>text.includes(word));
|
||||
}
|
||||
function groups(devices,dimensions,sites){
|
||||
const result=new Map();
|
||||
for(const device of devices){
|
||||
@@ -31,5 +36,5 @@
|
||||
}
|
||||
return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN'));
|
||||
}
|
||||
return {path,descendants,catalog,groups};
|
||||
return {path,descendants,catalog,groups,matches};
|
||||
});
|
||||
|
||||
+7
-2
@@ -3,6 +3,7 @@ let deviceGroups=['type','space'],deviceGroupScope='',deviceViewSignature='';
|
||||
const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'};
|
||||
function renderDeviceGrouping(){
|
||||
$('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`<div class="group-step"><span>${i+1}</span><select data-group-index="${i}" aria-label="第 ${i+1} 分组">${Object.entries(deviceDimensions).map(([key,label])=>`<option value="${key}" ${key===g?'selected':''} ${deviceGroups.includes(key)&&key!==g?'disabled':''}>${label}</option>`).join('')}</select><button class="quiet" data-group-up="${i}" aria-label="第 ${i+1} 分组前移" ${i===0?'disabled':''}>↑</button><button class="quiet" data-group-remove="${i}" aria-label="移除第 ${i+1} 分组">×</button></div>`).join('');
|
||||
$$('[data-group-preset]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.groupPreset===deviceGroups.join(','))));
|
||||
$('#device-group-add').disabled=deviceGroups.length>=Object.keys(deviceDimensions).length;
|
||||
}
|
||||
function deviceCard(d){
|
||||
@@ -15,12 +16,13 @@ function renderDevices(){
|
||||
const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
|
||||
const signature=JSON.stringify(saved);
|
||||
if(deviceGroupScope!==state.familyId||deviceViewSignature!==signature){deviceGroupScope=state.familyId;deviceViewSignature=signature;deviceGroups=[...saved];renderDeviceGrouping();}
|
||||
$('#device-clear').hidden=!($('#site-filter').value||$('#device-type').value||$('#device-search').value);
|
||||
const search=$('#device-search').value.trim().toLowerCase();
|
||||
const all=ZhaoDevices.catalog(state),types=[...new Set(all.map(d=>d.deviceType))].sort();
|
||||
setOptions($('#device-type'),'<option value="">所有类型</option>'+types.map(type=>`<option value="${esc(type)}">${esc(type)}</option>`).join(''));
|
||||
const category=$('#device-type').value;
|
||||
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;
|
||||
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&(!search||[d.name,d.deviceType,d.ownerName,d.managerName,...(d.tags||[])].join(' ').toLowerCase().includes(search)));
|
||||
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&ZhaoDevices.matches(d,search,state.sites));
|
||||
$('#device-count').textContent=`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
|
||||
$('#asset-list').innerHTML=devices.length?ZhaoDevices.groups(devices,deviceGroups,state.sites).map(group=>`<section class="device-group"><h3>${group.labels.map(esc).join(' <span aria-hidden="true">›</span> ')||'全部设备'} <small>${group.items.length} 台</small></h3><div class="site-grid">${group.items.map(deviceCard).join('')}</div></section>`).join(''):empty('当前条件下没有设备','可以清除筛选,或添加摄像机、录像机、灯、手机等设备档案。');
|
||||
}
|
||||
@@ -29,12 +31,14 @@ function responsibilityFields(form,item){for(const key of ['ownerName','managerN
|
||||
function deviceValues(data){return {...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};}
|
||||
function openDevice(id){
|
||||
const item=(state.devices||[]).find(d=>d.id===id)||{},f=$('#device-form');
|
||||
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);responsibilityFields(f,item);$('#device-dialog').showModal();
|
||||
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);if(!item.id)f.elements.siteId.value=$('#site-filter').value||'';responsibilityFields(f,item);$('#device-dialog').showModal();
|
||||
}
|
||||
function spaceOptions(exclude=''){
|
||||
const omitted=exclude?ZhaoDevices.descendants(state.sites,exclude):new Set();
|
||||
return state.sites.filter(s=>!omitted.has(s.id)).map(s=>`<option value="${esc(s.id)}">${esc(siteName(s.id))}</option>`).join('');
|
||||
}
|
||||
function clearDeviceFilters(){$('#site-filter').value='';$('#device-type').value='';$('#device-search').value='';renderDevices();}
|
||||
$('#device-clear').onclick=clearDeviceFilters;
|
||||
$('#device-search').oninput=renderDevices;$('#device-type').onchange=renderDevices;
|
||||
$('#device-group-order').addEventListener('change',e=>{if(e.target.dataset.groupIndex!==undefined){deviceGroups[Number(e.target.dataset.groupIndex)]=e.target.value;renderDeviceGrouping();renderDevices();}});
|
||||
$('#device-group-add').onclick=()=>{const next=Object.keys(deviceDimensions).find(k=>!deviceGroups.includes(k));if(next){deviceGroups.push(next);renderDeviceGrouping();renderDevices();}};
|
||||
@@ -43,6 +47,7 @@ $('#new-device').onclick=()=>openDevice();
|
||||
handleForm('device-form','/api/devices',deviceValues);
|
||||
document.addEventListener('click',e=>{
|
||||
const b=e.target.closest('button');if(!b)return;
|
||||
if(b.dataset.groupPreset){deviceGroups=b.dataset.groupPreset.split(',');renderDeviceGrouping();renderDevices();}
|
||||
if(b.dataset.editDevice)openDevice(b.dataset.editDevice);
|
||||
if(b.dataset.groupRemove!==undefined){deviceGroups.splice(Number(b.dataset.groupRemove),1);renderDeviceGrouping();renderDevices();}
|
||||
if(b.dataset.groupUp!==undefined){const i=Number(b.dataset.groupUp);if(i>0){[deviceGroups[i-1],deviceGroups[i]]=[deviceGroups[i],deviceGroups[i-1]];renderDeviceGrouping();renderDevices();}}
|
||||
|
||||
@@ -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
@@ -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
@@ -6,9 +6,11 @@ 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??'';}}
|
||||
function fillForm(form,data){resetEditorForm(form);form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
|
||||
function familyFormError(form,error){
|
||||
if(error.stale)return;
|
||||
const box=form.querySelector('.form-error');box.textContent=error.message;
|
||||
@@ -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=>{
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
+17
-9
@@ -6,25 +6,29 @@ function renderHouseholds(){
|
||||
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>${isAdmin()?`<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)}">进入${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>`:''}</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;
|
||||
if(id===state.familyId)return true;
|
||||
if(!isPlatformAdmin()||!id)return false;
|
||||
if(typeof confirmJournalDiscard==='function'&&!confirmJournalDiscard())return false;
|
||||
const user=currentUser;
|
||||
// Invalidate outstanding API responses, video players and dialogs before changing scope.
|
||||
clearPrivateView();
|
||||
householdScope=id;currentUser=user;loggedIn=true;
|
||||
await auth();
|
||||
await auth();return loggedIn;
|
||||
}
|
||||
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{
|
||||
if(id!==state.familyId)await switchHousehold(id);
|
||||
if(id!==state.familyId&&!await switchHousehold(id))return;
|
||||
const home=(state.households||[]).find(h=>h.id===id);
|
||||
if(!home?.canAssignOwner)return;
|
||||
const result=await api('/api/users');
|
||||
@@ -41,11 +45,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=async e=>{await switchHousehold(e.target.value);if(loggedIn)showPage('overview');};
|
||||
$('#household-filter').onchange=async e=>{const switched=await switchHousehold(e.target.value);if(switched&&loggedIn)showPage('overview');else e.target.value=state.familyId;};
|
||||
$('#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 +57,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('overview');}
|
||||
if(b.dataset.enterHousehold){const switched=await switchHousehold(b.dataset.enterHousehold);if(switched&&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'));
|
||||
|
||||
+33
-10
File diff suppressed because one or more lines are too long
+225
@@ -0,0 +1,225 @@
|
||||
'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();
|
||||
let journalBaseline='',journalSaving=false;
|
||||
function journalDraftSignature(){const f=$('#journal-form');return JSON.stringify([f.elements.text.value,f.elements.occurredOn.value,f.elements.category.value,f.elements.visibility.value,[...journalSelectedMedia].sort()]);}
|
||||
function journalHasDraft(){return journalUpload||!!journalTracks||!!journalBaseline&&journalDraftSignature()!==journalBaseline;}
|
||||
function confirmJournalDiscard(){if(journalSaving){toast('记事正在保存,请稍候。');return false;}return !journalHasDraft()||confirm('当前记事尚未保存,确定放弃本次输入吗?已上传的附件仍保留在附件库中。');}
|
||||
function resetJournalFilters(mode=$('#journal-mode').value){
|
||||
for(const id of ['journal-category','journal-search','journal-from','journal-to'])$('#'+id).value='';
|
||||
$('#journal-mode').value=mode;if(mode!=='person')$('#journal-person').value='';
|
||||
$('#journal-person-label').hidden=mode!=='person';journalPage=0;
|
||||
}
|
||||
function journalEmptyMessage(){
|
||||
if(['journal-category','journal-search','journal-from','journal-to'].some(id=>$('#'+id).value))return '没有符合筛选条件的记事,可以点击“清除筛选”再查看。';
|
||||
if($('#journal-mode').value==='feed')return '还没有分享给当前家庭/场所的动态。仅自己可见的记事,请切换到“我的个人阅历”。';
|
||||
if($('#journal-mode').value==='person')return $('#journal-person').value?'此人暂无分享给当前家庭/场所的记事。':'请先选择要查看的人物。';
|
||||
return '还没有个人记事,写下今天,也可以补记从前。';
|
||||
}
|
||||
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();journalBaseline='';journalSaving=false;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();journalBaseline=journalDraftSignature();}
|
||||
async function loadJournal(focus=''){
|
||||
if(journalSaving)return;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),focus});
|
||||
$('#journal-query-status').textContent='正在读取记事…';
|
||||
try{
|
||||
const result=await api('/api/journal?'+query);if(!journalValid(g))return;journalData=result;journalPage=result.page;
|
||||
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} 页`:journalEmptyMessage();
|
||||
$('#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();
|
||||
if(focus){const card=$$('#journal-list [data-entry]').find(el=>el.dataset.entry===focus);if(card){card.classList.add('just-saved');card.scrollIntoView({behavior:'smooth',block:'center'});}}
|
||||
}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(journalSaving)return;if(journalUpload){journalMessage('请等当前附件上传完成,或取消上传后保存文字。');return;}
|
||||
const f=event.currentTarget,g=journalGeneration,session=sessionGeneration;
|
||||
const payload={...formValues(f),revision:Number(f.elements.revision.value||0),mediaIds:[...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value)};
|
||||
const controls=[...f.elements,...$('#journal-filter').elements],disabled=controls.map(el=>el.disabled);
|
||||
const unlock=()=>controls.forEach((el,i)=>el.disabled=disabled[i]);journalSaving=true;controls.forEach(el=>el.disabled=true);
|
||||
try{const saved=await api('/api/journal',payload);if(!journalValid(g))return;journalSaving=false;unlock();newJournal();resetJournalFilters('mine');await loadJournal(saved.id);journalMessage('已保存,输入已清空;下方已重新加载个人阅历。');toast('记事已保存,正在展示保存结果');}
|
||||
catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{journalSaving=false;if(session===sessionGeneration)unlock();}
|
||||
});
|
||||
$('#journal-new').onclick=()=>{if(!journalSaving&&confirmJournalDiscard())newJournal();};
|
||||
$('#journal-form').addEventListener('input',()=>{if(!journalUpload&&!journalSaving)journalMessage(journalHasDraft()?'当前输入尚未保存。':'默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');});
|
||||
$('#journal-filter-clear').onclick=()=>{resetJournalFilters();loadJournal();};
|
||||
$('#journal-filter').onsubmit=e=>{e.preventDefault();if($('#journal-from').value&&$('#journal-to').value&&$('#journal-from').value>$('#journal-to').value){$('#journal-query-status').textContent='开始日期不能晚于结束日期。';return;}journalPage=0;loadJournal();};
|
||||
$('#journal-mode').onchange=()=>{$('#journal-person-label').hidden=$('#journal-mode').value!=='person';journalPage=0;loadJournal();};
|
||||
for(const id of ['journal-person','journal-category'])$('#'+id).onchange=()=>{journalPage=0;loadJournal();};
|
||||
$('#journal-prev').onclick=()=>{journalPage--;loadJournal();};$('#journal-next').onclick=()=>{journalPage++;loadJournal();};
|
||||
async function uploadJournalFile(file,resume=''){
|
||||
if(!file||journalUpload||journalSaving)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){if(journalSaving||!confirmJournalDiscard())return;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);journalBaseline=journalDraftSignature();f.scrollIntoView({behavior:'smooth'});return;}
|
||||
if(journalSaving&&(b.dataset.useUpload||b.dataset.detachUpload||b.dataset.removeUpload||b.dataset.restoreUpload))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;
|
||||
showSettingsSection();
|
||||
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;}};
|
||||
window.addEventListener('beforeunload',event=>{if(loggedIn&&journalHasDraft()){event.preventDefault();event.returnValue='';}});
|
||||
|
||||
document.addEventListener('DOMContentLoaded',()=>{const result=new URLSearchParams(location.search).get('baidu');if(result){history.replaceState(null,'',location.pathname);toast(result==='ok'?'网盘授权已保存,请在设置中心查看连接状态。':'授权未完成,请检查回调地址、原登录状态,并重新连接。');}},{once:true});
|
||||
@@ -0,0 +1,129 @@
|
||||
'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;
|
||||
let settingsSection='preferences';
|
||||
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&¤tHome().kind!=='home'||!isAdmin()&¤tUser.familyAccess==='none'&&!['read','edit'].includes(currentUser.heritageAccess)))page='overview';
|
||||
if(page!=='households'&¤tHome())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';
|
||||
}
|
||||
function showSettingsSection(section=settingsSection){
|
||||
settingsSection=['preferences','baidu','brand','backup'].includes(section)&&(section==='preferences'||isPlatformAdmin())?section:'preferences';
|
||||
for(const [key,id] of Object.entries({preferences:'preferences-form',baidu:'baidu-admin',brand:'brand-form',backup:'backup-panel'}))$('#'+id).hidden=key!==settingsSection;
|
||||
$$('[data-settings-section]').forEach(b=>{b.hidden=b.hasAttribute('data-platform-setting')&&!isPlatformAdmin();b.setAttribute('aria-pressed',String(b.dataset.settingsSection===settingsSection));});
|
||||
}
|
||||
document.addEventListener('click',event=>{const b=event.target.closest('[data-settings-section]');if(b)showSettingsSection(b.dataset.settingsSection);});
|
||||
async function loadSettings(){
|
||||
const generation=++appearanceGeneration;$('#settings-error').textContent='正在读取…';$('#settings-private').hidden=true;
|
||||
showSettingsSection();
|
||||
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;}}};
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
'use strict';
|
||||
let playbackRange=null;
|
||||
function playbackSourceHint(){if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
|
||||
|
||||
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
|
||||
function resetPlayback(){
|
||||
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
|
||||
@@ -49,7 +51,7 @@ function makeClip(){
|
||||
}catch(e){$('#clip-status').textContent=e.message;}
|
||||
}
|
||||
$('#query-recordings').onclick=queryRecordings;
|
||||
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=resetPlayback;$('#play-date').onchange=resetPlayback;
|
||||
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=()=>{resetPlayback();playbackSourceHint();};$('#play-date').onchange=resetPlayback;
|
||||
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
|
||||
$('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
|
||||
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
|
||||
|
||||
+12
-6
@@ -2,6 +2,12 @@
|
||||
let deviceContext=null,scopeEntered=false;
|
||||
Object.assign(pageInfo,{overview:['场所总览','人、区域与设备,在这里各有归属。'],device:['设备档案','查看这台设备的位置、责任人与功能。'],heritage:['家庭传承','把人物、记忆、文化与物品的来历留存下来。']});
|
||||
function currentHome(){return (state.households||[]).find(h=>h.id===state.familyId);}
|
||||
function scopeReturn(page,device){
|
||||
if(page==='overview')return {page:'households',label:'所有家庭与场所'};
|
||||
if(device||page==='sites')return {page:'objects',label:'返回区域与设备'};
|
||||
if(['family','heritage-records'].includes(page))return {page:'heritage',label:'返回家庭传承'};
|
||||
return {page:'overview',label:'返回场所总览'};
|
||||
}
|
||||
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;
|
||||
@@ -13,7 +19,7 @@ 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;
|
||||
const returnSite=$('#site-filter').value;$('#site-filter').value='';deviceContext={source,id,returnSite};scopeEntered=true;
|
||||
showPage(tab==='device'&&source==='camera'&&deviceCameras(state.cameras).length?'live':tab);
|
||||
}
|
||||
function renderSteward(){
|
||||
@@ -21,12 +27,12 @@ function renderSteward(){
|
||||
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').hidden=!within||['settings','storage'].includes(activePage);
|
||||
$('#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||'场所');
|
||||
$('#scope-back').textContent=scopeReturn(activePage,device).label;
|
||||
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)){
|
||||
@@ -43,7 +49,7 @@ function renderSteward(){
|
||||
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>`;
|
||||
$('#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="journal"><strong>记事与阅历</strong><span>写日常 · 看动态 · 查阅历</span><small>文字、照片和录音;每条记事自行选择谁可以看</small></button><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;}
|
||||
@@ -53,7 +59,7 @@ function renderDeviceDetail(device){
|
||||
}
|
||||
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>`;
|
||||
$('#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=>{
|
||||
@@ -61,6 +67,6 @@ document.addEventListener('click',e=>{
|
||||
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.id==='scope-back'){const target=scopeReturn(activePage,selectedDevice()).page;if(target==='objects'&&deviceContext)$('#site-filter').value=deviceContext.returnSite||'';showPage(target);}
|
||||
if(b.dataset.familySection){familyView=b.dataset.familySection;showPage('family',{keepFamilyView:true});}
|
||||
});
|
||||
|
||||
@@ -26,3 +26,9 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
|
||||
/* v0.1.21: presets and recording tools */
|
||||
.ptz-panel{width:620px;display:grid;grid-template-columns:1fr auto;max-height:80dvh;overflow:auto;align-items:start}.ptz-panel>div:first-child{grid-column:1}.ptz-panel .ptz-pad{grid-column:2;grid-row:1/3}#ptz-presets{grid-column:1/-1;min-width:0;border-top:1px solid var(--line);padding-top:12px}#ptz-presets .section-head{margin:0;gap:10px}#ptz-presets p{max-width:none;line-height:1.6}#preset-count{color:var(--muted);font-size:12px}.preset-row{display:flex;align-items:center;gap:5px;padding:9px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}.preset-row strong{font-size:13px;flex:1;overflow-wrap:anywhere}.preset-row button{padding:5px 8px;font-size:12px}#preset-form{display:flex;align-items:end;gap:10px;margin-top:12px}#preset-form label{flex:1;margin:0}#preset-name{width:100%}#preset-confirmation{padding:12px;background:#553b31;border-radius:8px;margin-top:10px}#ptz-close{justify-self:start}.playback-tools{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:12px 0}.playback-tools .check{margin:0}.clip-editor{border-top:1px solid var(--line);padding-top:12px}.clip-editor summary{cursor:pointer;margin-bottom:14px}#snapshot-dialog{width:min(1000px,92vw)}#snapshot-image{width:100%;max-height:65vh;object-fit:contain;background:#000;border-radius:8px}#play-position{font-size:13px;color:var(--muted)}
|
||||
@media(max-width:600px){.ptz-panel{right:12px;bottom:12px;padding:12px;gap:12px}.ptz-panel .ptz-pad{grid-column:2}#preset-form{flex-wrap:wrap}#preset-form label{flex-basis:100%}}
|
||||
/* Frequent tasks stay visible; advanced grouping and platform settings are opened on demand. */
|
||||
.settings-sections,.view-presets{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 22px}
|
||||
.settings-sections button[aria-pressed="true"],.view-presets button[aria-pressed="true"]{background:var(--accent,#30594e);color:var(--paper,#fff);border-color:var(--accent,#30594e)}
|
||||
.group-custom{margin:16px 0}.group-custom summary{cursor:pointer;padding:10px 0}
|
||||
.device-search-row{align-items:end}.device-search-row>button{align-self:end}
|
||||
.journal-entry.just-saved{outline:2px solid var(--accent,#30594e);outline-offset:4px;scroll-margin-block:24px}
|
||||
|
||||
Reference in New Issue
Block a user