diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e683db4..ef44199 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,3 +111,14 @@ 前端 `heritage.js` 用独立请求代次防止晚返回的数据覆盖其他页面;退出、切换家庭清空资料和表单。更新使用修订号防并发,归档可恢复;目前保留当前版本及最近修改人、时间,不保存该档案的逐次历史快照。所有人、保管人、意向接收人均是档案文本,不是认证标识,不参与权限或所有权计算。 当前架构边界:非超级管理员一账号一归属;财产记录与系统角色交接是独立流程;通用设备登记不代表支持控制;录像机既有历史录像仍需品牌适配。未来多归属成员表与正式交接流程须先完成明确授权模型,再扩展数据结构。 + + +## 发布设置、个人偏好与备份(v0.1.25) + +`appearance.py` 将平台配置保存在 settings.appearance,个人偏好保存在 settings 的 `preferences:` 键;修改账号资料不会覆盖偏好。选项通过有限枚举校验,使用修订号阻止并发覆盖。公开 `/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 依赖挂载自身的超时和可用性;不会挂载网盘、保存网盘登录密码或声称云端同步已确认。服务实例必须单进程部署,跨进程任务互斥尚不支持。 diff --git a/README.md b/README.md index f42ff96..4f227fc 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,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 +232,24 @@ 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/反向代理配置。 diff --git a/VERSION b/VERSION index 5a48b6b..0e7400f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.24 +0.1.25 diff --git a/app.py b/app.py index 3b57c44..e5930c8 100644 --- a/app.py +++ b/app.py @@ -34,6 +34,8 @@ import genealogy import households import inventory import heritage +import appearance +import backups import ptz ROOT = Path(__file__).resolve().parent @@ -79,6 +81,8 @@ FAMILY = genealogy.Family(sys.modules[__name__]) HOUSEHOLDS = households.Households(sys.modules[__name__]) INVENTORY = inventory.Inventory(sys.modules[__name__]) HERITAGE = heritage.Heritage(sys.modules[__name__]) +APPEARANCE = appearance.Appearance(sys.modules[__name__]) +BACKUPS = backups.Backups(sys.modules[__name__]) def setting(key, default=None): @@ -515,6 +519,15 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(data) + def image(self, raw, public=False): + self.send_response(200) + self.send_header('Content-Type', 'image/png') + self.send_header('Content-Length', str(len(raw))) + self.send_header('Cache-Control', 'no-cache' if public else 'no-store') + self.send_header('X-Content-Type-Options', 'nosniff') + self.end_headers() + self.wfile.write(raw) + def token(self): try: c = http.cookies.SimpleCookie(self.headers.get('Cookie', '')) @@ -617,13 +630,13 @@ class Handler(BaseHTTPRequestHandler): try: path = url.urlsplit(self.path).path # 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB. - data = self.body(131072 if path 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') 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', + if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore', '/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets'): self.require_manager() if path == '/api/logout': @@ -631,6 +644,17 @@ class Handler(BaseHTTPRequestHandler): DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),)) DB.commit() self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict') + elif path == '/api/preferences': + self.answer(APPEARANCE.save_preferences(data, self.user)) + elif path == '/api/appearance': + self.require_admin() + self.answer(APPEARANCE.save_config(data, self.user)) + elif path == '/api/backups/config': + self.require_admin() + self.answer(BACKUPS.save(data)) + elif path == '/api/backups/run': + self.require_admin() + self.answer(BACKUPS.start()) elif path == '/api/account/password': result = ACCOUNTS.change_password(data, self.user) self.answer(result, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict') @@ -817,11 +841,19 @@ class Handler(BaseHTTPRequestHandler): parsed = url.urlsplit(self.path) path = parsed.path query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()} + if path == '/api/branding': + self.answer(APPEARANCE.public()) + return + if path in ('/brand-logo.png', '/brand-icon.png'): + value = APPEARANCE.config().get('logoData' if path == '/brand-logo.png' else 'iconData', '') + raw = base64.b64decode(value.split(',', 1)[1]) if value else (ROOT/'web/zhao-icon.png').read_bytes() + self.image(raw, public=True) + return if path == '/api/session': user = self.authenticated() user = HOUSEHOLDS.context(user, self.headers.get('X-Household-Id')) self.answer({'authenticated': bool(user), 'user': ACCOUNTS.public(user), - 'setupRequired': not bool(objects('users'))}) + 'setupRequired': not bool(objects('users')), 'preferences': APPEARANCE.preferences(user) if user else None}) return if path == '/healthz': self.answer({'status': 'running', 'version': (ROOT / 'VERSION').read_text().strip()}) @@ -830,6 +862,20 @@ class Handler(BaseHTTPRequestHandler): self.require_auth() if path == '/api/state': self.answer(ACCOUNTS.state(self.user, status())) + 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 +969,7 @@ 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', '/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', @@ -978,6 +1024,7 @@ if __name__ == '__main__': initialize() threading.Thread(target=storage_watch, daemon=True).start() threading.Thread(target=media_config_watch, daemon=True).start() + threading.Thread(target=BACKUPS.watch, daemon=True).start() bindings = [h.strip() for h in os.environ.get('VISION_BIND', '127.0.0.1').split(',') if h.strip()] port = int(os.environ.get('VISION_PORT', '8790')) servers = [] diff --git a/appearance.py b/appearance.py new file mode 100644 index 0000000..e074eef --- /dev/null +++ b/appearance.py @@ -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) diff --git a/backups.py b/backups.py new file mode 100644 index 0000000..692289b --- /dev/null +++ b/backups.py @@ -0,0 +1,149 @@ +"""Online SQLite snapshots with optional copy to a pre-mounted network directory.""" +import datetime as dt +from contextlib import closing +import hashlib +import os +from pathlib import Path +import re +import secrets +import shutil +import sqlite3 +import threading +import time + + +class Backups: + def __init__(self, app): + self.a = app + self.guard = threading.Lock() + self.running = False + + def config(self): + return self.a.setting('backup_config', dict(enabled=False, time='03:00', keep=14, networkPath='', revision=0)) + + def status(self): + return dict(config=self.config(), running=self.running, + localPath=str(self.a.DATA/'managed-backups'), + last=self.a.setting('backup_last', None), + history=self.a.setting('backup_history', [])[-20:][::-1]) + + 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 any(os.path.ismount(p) for p in (folder, *folder.parents) if p != Path(p.anchor)): + raise self.a.Problem('该目录尚未检测到独立挂载;请先连接网络盘,不能把本机目录当作云端成功') + if folder == (self.a.DATA/'managed-backups').resolve(): + raise self.a.Problem('网络副本与本机备份目录不能相同') + return folder + + def save(self, data): + if set(data) != {'enabled','time','keep','networkPath','revision'}: + raise self.a.Problem('备份设置字段不完整') + with self.guard: + if self.running: + raise self.a.Problem('备份正在执行,请完成后再调整设置', 409) + with self.a.LOCK: + old = self.config() + if type(data['revision']) is not int or data['revision'] != old['revision']: + raise self.a.Problem('设置已变化,请重新读取后保存', 409) + if not isinstance(data['enabled'], bool) or not isinstance(data['time'], str) or not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', data['time']): + raise self.a.Problem('请选择有效的每日备份时间') + self.a.integer(data['keep'], 1, 90) + if not isinstance(data['networkPath'], str): + raise self.a.Problem('备份目录格式不正确') + self.network_folder(data['networkPath']) + self.a.set_setting('backup_config', dict(data, revision=old['revision']+1)) + return self.status() + + def start(self, reason='manual'): + with self.guard: + if self.running: + raise self.a.Problem('已有备份正在执行', 409) + self.running = True + threading.Thread(target=self.run, args=(reason,), daemon=True).start() + return dict(started=True) + + def prune(self, folder, prefix, keep): + owned = [p for p in folder.glob(prefix+'*.sqlite') if p.is_file() and not p.is_symlink()] + for path in sorted(owned, key=lambda p:p.name, reverse=True)[keep:]: + path.unlink() + + def run(self, reason='manual'): + a = self.a + config = self.config() + result = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), reason=reason, + localOk=False, networkOk=False, networkRequested=bool(config['networkPath'])) + temporary = network_temp = None + try: + local = a.DATA/'managed-backups' + local.mkdir(mode=0o700, exist_ok=True) + instance = a.setting('backup_instance') + if not instance: + instance = secrets.token_hex(8); a.set_setting('backup_instance', instance) + prefix = 'vision-'+instance+'-' + name = prefix+dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')+'.sqlite' + target = local/name + temporary = local/(name+'.partial') + # Separate read connection avoids holding the live write lock during IO. + with closing(sqlite3.connect(a.DATA/'vision.db', timeout=10)) as source, closing(sqlite3.connect(temporary)) as dest: + source.backup(dest, pages=256, sleep=0.01) + if dest.execute('PRAGMA quick_check').fetchone()[0] != 'ok': + raise RuntimeError('数据库副本完整性检查失败') + dest.execute('DELETE FROM sessions'); dest.commit() + os.chmod(temporary, 0o600) + with temporary.open('rb') as f: + digest = hashlib.file_digest(f, 'sha256').hexdigest() + os.replace(temporary, target) + result.update(localOk=True, file=name, bytes=target.stat().st_size, sha256=digest) + self.prune(local, prefix, config['keep']) + if config['networkPath']: + network = self.network_folder(config['networkPath']) + network_temp = network/(name+'.partial') + shutil.copyfile(target, network_temp) + os.chmod(network_temp, 0o600) + with network_temp.open('rb') as f: + if hashlib.file_digest(f, 'sha256').hexdigest() != digest: + raise RuntimeError('网络副本校验不一致') + os.replace(network_temp, network/name) + result['networkOk'] = True + self.prune(network, prefix, config['keep']) + except Exception as error: + result['error'] = str(error)[:300] or '备份未完成' + finally: + for temporary_path in (temporary, network_temp): + try: + if temporary_path and temporary_path.exists(): temporary_path.unlink() + except OSError: + pass + with a.LOCK: + a.set_setting('backup_last', result) + history = a.setting('backup_history', []) + a.set_setting('backup_history', (history+[result])[-100:]) + self.running = False + return result + + def tick(self, now=None): + now = now or dt.datetime.now(dt.timezone(dt.timedelta(hours=8))) + config = self.config(); day = now.strftime('%Y-%m-%d') + if not config['enabled'] or now.strftime('%H:%M') < config['time'] or self.running: + return False + if self.a.setting('backup_attempt_day') == day: + return False + self.a.set_setting('backup_attempt_day', day) + self.start('scheduled') + return True + + def watch(self): + while True: + try: self.tick() + except Exception: pass + time.sleep(60) diff --git a/households.py b/households.py index 5d33b13..cc02261 100644 --- a/households.py +++ b/households.py @@ -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] @@ -162,6 +165,16 @@ class Households: kind=kind, note=a.clean_text(data.get('note', ''), 300), createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat()) + for field in ('province','city','district'): + item[field] = a.clean_text(data.get(field, (old or {}).get(field, '')), 60) + if 'coverData' in data: + cover = data['coverData'] + if not isinstance(cover, str): raise a.Problem('封面图片格式不正确') + if cover: + try: png_bytes(cover, 2*1024*1024) + except ValueError as error: raise a.Problem(str(error).replace('Logo', '封面')) + item['coverData'] = cover + item['coverRevision'] = hashlib.sha256(cover.encode()).hexdigest()[:16] a.save_object('households', item) a.audit('保存家庭', item['name']) return item diff --git a/test_app.py b/test_app.py index fdbea6f..b820e62 100644 --- a/test_app.py +++ b/test_app.py @@ -105,7 +105,7 @@ class WebTests(unittest.TestCase): self.assertEqual(headers['Cache-Control'], 'no-store') self.assertNotIn(b'__ASSET_VERSION__', body) assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode()) - self.assertEqual(len(assets), 24) + self.assertEqual(len(assets), 25) version = (app.ROOT / 'VERSION').read_text().strip() for asset in assets: self.assertTrue(asset.endswith('?v=' + version)) diff --git a/test_appearance.py b/test_appearance.py new file mode 100644 index 0000000..d1aaa6d --- /dev/null +++ b/test_appearance.py @@ -0,0 +1,86 @@ +import base64 +import copy +import http.client +import struct +import unittest +import zlib +from test_app import app +import test_households as fixture +from appearance import BRAND, png_bytes + + +def image_data(): + def chunk(kind, data): + return struct.pack('>I',len(data))+kind+data+struct.pack('>I',zlib.crc32(kind+data)) + raw=b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',2,2,8,6,0,0,0))+chunk(b'IDAT',zlib.compress((b'\x00'+b'\x33\x66\x55\xff'*2)*2))+chunk(b'IEND',b'') + return 'data:image/png;base64,'+base64.b64encode(raw).decode() + + +class AppearanceTests(unittest.TestCase): + setUp=fixture.HouseholdTests.setUp + stop_server=fixture.HouseholdTests.stop_server + request=fixture.HouseholdTests.request + + def test_platform_branding_is_admin_only_and_public_values_are_allowlisted(self): + data=copy.deepcopy(BRAND);data['name']='自有品牌';data['logoData']=image_data();data['iconData']=image_data() + self.assertEqual(self.request('/api/appearance',self.owner,data)[0],403) + self.assertEqual(self.request('/api/appearance',self.members[self.aid])[0],403) + self.assertEqual(self.request('/api/appearance',self.root,data)[0],200) + c=http.client.HTTPConnection(*self.server.server_address) + c.request('GET','/api/branding');response=c.getresponse();raw=response.read();c.close() + self.assertEqual(response.status,200) + self.assertNotIn(b'logoData',raw);self.assertNotIn(b'password',raw) + self.assertEqual(app.APPEARANCE.public()['name'],'自有品牌') + self.assertTrue(app.APPEARANCE.public()['iconUrl'].startswith('/brand-icon.png')) + + def test_brand_config_conflict_and_invalid_values(self): + original=copy.deepcopy(BRAND) + self.assertEqual(self.request('/api/appearance',self.root,original)[0],200) + self.assertEqual(self.request('/api/appearance',self.root,original)[0],409) + data=app.APPEARANCE.config();data['defaults']['layout']='arbitrary-css' + self.assertEqual(self.request('/api/appearance',self.root,data)[0],400) + data=app.APPEARANCE.config();data['logoData']='https://example.org/private.png' + self.assertEqual(self.request('/api/appearance',self.root,data)[0],400) + + def test_preferences_are_account_scoped_and_survive_account_edits(self): + one,two=self.members[self.aid],self.members[self.bid] + config=self.request('/api/preferences',one)[1] + values=config['values'];values['theme']='ink' + self.assertEqual(self.request('/api/preferences',one,dict(revision=0,values=values))[0],200) + self.assertEqual(self.request('/api/preferences',two)[1]['effective']['theme'],'paper') + self.assertEqual(self.request('/api/preferences',one,dict(revision=1,values=values,userId=two['id']))[0],400) + app.ACCOUNTS.save(dict(app.get_object('users',one['id']),name='重命名'),self.root) + self.assertEqual(app.APPEARANCE.preferences(one)['effective']['theme'],'ink') + + def test_defaults_inheritance_and_personal_conflicts(self): + user=self.members[self.aid];values=app.APPEARANCE.preferences(user)['values'];values['layout']='top' + app.APPEARANCE.save_preferences(dict(revision=0,values=values),user) + config=copy.deepcopy(BRAND);config['defaults']['theme']='clean' + app.APPEARANCE.save_config(config,self.root) + effective=app.APPEARANCE.preferences(user)['effective'] + self.assertEqual(effective['theme'],'clean');self.assertEqual(effective['layout'],'top') + self.assertEqual(self.request('/api/preferences',user,dict(revision=0,values=values))[0],409) + + def test_png_validation_rejects_svg_corruption_and_trailing_data(self): + good=image_data();self.assertGreater(len(png_bytes(good)),10) + for bad in ['data:image/svg+xml;base64,AAA','data:image/png;base64,!!',good[:-2], + 'data:image/png;base64,'+base64.b64encode(png_bytes(good)+b'extra').decode()]: + with self.assertRaises(ValueError):png_bytes(bad) + + def test_city_and_cover_are_preserved_without_exposing_inline_image_in_state(self): + home=app.get_object('households',self.aid) + self.assertEqual(self.request('/api/households',self.root,dict(home,province='山东省',city='烟台市',district='莱阳市',coverData=image_data()))[0],200) + listed=self.request('/api/households',self.owner)[1]['households'][0] + self.assertEqual(listed['city'],'烟台市');self.assertNotIn('coverData',listed) + self.assertIn('/api/households/cover?',listed['coverUrl']) + saved=self.request('/api/households',self.owner,dict(id=self.aid,name='新名称'))[1] + self.assertEqual(saved['city'],'烟台市');self.assertEqual(saved['coverData'],image_data()) + for user,expected in [(self.members[self.aid],200),(self.members[self.bid],404)]: + c=http.client.HTTPConnection(*self.server.server_address) + c.request('GET',listed['coverUrl'],headers={'Cookie':'vision='+self.tokens[user['id']]}) + r=c.getresponse();r.read();c.close();self.assertEqual(r.status,expected) + + def test_untrusted_fields_do_not_become_executable_or_change_roles(self): + user=self.members[self.aid];data=app.APPEARANCE.preferences(user) + self.assertEqual(self.request('/api/preferences',user,dict(revision=0,values=dict(data['values'],css='url(x)')))[0],400) + self.assertEqual(self.request('/api/appearance',self.root,dict(copy.deepcopy(BRAND),role='admin'))[0],400) diff --git a/test_backups.py b/test_backups.py new file mode 100644 index 0000000..fec100c --- /dev/null +++ b/test_backups.py @@ -0,0 +1,65 @@ +import datetime as dt +from contextlib import closing +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('backups.os.path.ismount',side_effect=lambda p:p==network.resolve()): + 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()) diff --git a/test_personalization.js b/test_personalization.js new file mode 100644 index 0000000..8a8038b --- /dev/null +++ b/test_personalization.js @@ -0,0 +1,56 @@ +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); +}); diff --git a/web/app.js b/web/app.js index 95437d3..c7d8714 100644 --- a/web/app.js +++ b/web/app.js @@ -25,6 +25,7 @@ function applyPermissions(){ $('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : ''; } function clearPrivateView(){ + if(typeof resetAppearance==='function')resetAppearance(); if(typeof clearSnapshot==='function')clearSnapshot(); if(typeof resetDeviceView==='function')resetDeviceView(); if(typeof resetSteward==='function')resetSteward(); @@ -56,16 +57,17 @@ 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 leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings(); + if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings'].includes(page))return; if(['heritage','family','heritage-records'].includes(page)&¤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'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null; + if(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage','settings'].includes(page); if(page==='storage'&&!isPlatformAdmin())return; if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage(); if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();} if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen(); activePage = page; - $('.metrics').hidden=['family','users','households','heritage','heritage-records'].includes(page); + $('.metrics').hidden=['family','users','households','heritage','heritage-records','settings'].includes(page); $('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext; $$('.page').forEach(el => el.hidden = el.id !== 'page-' + page); $$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page))); @@ -77,6 +79,7 @@ function showPage(page, options={}) { if (page === 'family') loadFamily(!options.keepFamilyView); if (page === 'users') loadUsers(); if(page==='heritage-records')loadHeritage(); + if(page==='settings'&&typeof loadSettings==='function')loadSettings(); } function render() { $('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length; @@ -144,7 +147,7 @@ function renderStorage() { } async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`
${new Date(r.at).toLocaleString()}${esc(r.action)} ${esc(r.name)}
`).join('')||'

尚无操作记录

';}catch(e){if(!e.stale)toast(e.message);} } async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}} -async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}} +async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}if(typeof onAppearanceSession==='function')onAppearanceSession(s.preferences);}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}} function openSite(id) {const f=$('#site-form');f.reset();f.elements.parentId.innerHTML=''+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=>``).join('');f.elements.recorderId.innerHTML=''+state.recorders.map(r=>``).join('');f.elements.archiveRecorderId.innerHTML=''+state.recorders.map(r=>``).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();} function formValues(f) {return Object.fromEntries(new FormData(f).entries());} @@ -161,7 +164,7 @@ $('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#s $('#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(){ diff --git a/web/chinese-theme.css b/web/chinese-theme.css index bd14a97..b85a749 100644 --- a/web/chinese-theme.css +++ b/web/chinese-theme.css @@ -261,3 +261,36 @@ 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}} diff --git a/web/households.js b/web/households.js index 512a2cf..2afcb7f 100644 --- a/web/households.js +++ b/web/households.js @@ -6,7 +6,9 @@ function renderHouseholds(){ setOptions($('#household-filter'),homes.map(h=>``).join('')); $('#household-filter').value=state.familyId||''; if(activePage!=='households')return; - $('#household-list').innerHTML=homes.map(h=>`
${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}${isAdmin()?``:''}

${esc(h.name)}

${esc(h.note||'区域与设备归属独立管理')}

${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}
${esc(h.ownerName||'待指定')}
备用负责人
${esc(h.successorName||'待指定')}
账号 / 空间
${h.members} 个 / ${h.sites} 处
${!h.successorId?'

尚未指定备用负责人,建议提前安排可信人员接管。

':''}
${h.canAssignOwner?``:''}
${h.leadershipHistory?.length?`
交接记录${h.leadershipHistory.slice().reverse().map(r=>`

${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}
${esc(r.reason)}

`).join('')}
`:''}
`).join(''); + const cards=h=>`
${h.coverUrl?`${esc(h.name)}封面照片`:''}
${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}${isAdmin()?``:''}

${esc(h.name)}

${esc([h.province,h.city,h.district].filter(Boolean).join(' · ')||'地区待填写')}

${esc(h.note||'区域与设备归属独立管理')}

${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}
${esc(h.ownerName||'待指定')}
备用负责人
${esc(h.successorName||'待指定')}
账号 / 空间
${h.members} 个 / ${h.sites} 处
${!h.successorId?'

尚未指定备用负责人,建议提前安排可信人员接管。

':''}
${h.canAssignOwner?``:''}
${h.leadershipHistory?.length?`
交接记录${h.leadershipHistory.slice().reverse().map(r=>`

${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}
${esc(r.reason)}

`).join('')}
`:''}
`; + 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])=>`

${esc(city)} ${rows.length} 个家庭或场所

${rows.map(cards).join('')}
`).join(''); } async function switchHousehold(id){ if(!isPlatformAdmin()||!id||id===state.familyId)return; @@ -20,7 +22,7 @@ function openHousehold(id){ if(!isAdmin())return; const item=(state.households||[]).find(h=>h.id===id); if(!item&&!isPlatformAdmin())return; - fillForm($('#household-form'),item||{});$('#household-dialog').showModal(); + fillForm($('#household-form'),item||{});coverDraft=undefined;$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='照片仅向有权进入该家庭或场所的账号开放。';$('#household-dialog').showModal(); } async function openLeadership(id){ try{ @@ -45,7 +47,7 @@ $('#household-filter').onchange=async e=>{await switchHousehold(e.target.value); $('#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('归属档案已保存');} + try{await api('/api/households',{...formValues(f),...(coverDraft!==undefined?{coverData:coverDraft}:{})});f.closest('dialog').close();await refresh();toast('归属档案已保存');} catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;} finally{b.disabled=false;} }); @@ -55,3 +57,6 @@ document.addEventListener('click',async e=>{ if(b.dataset.leadership)await openLeadership(b.dataset.leadership); if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('overview');} }); + +$('#household-cover-file').onchange=async e=>{const householdId=$('#household-form').elements.id.value,generation=sessionGeneration;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!$('#household-dialog').open||$('#household-form').elements.id.value!==householdId)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){$('#household-cover-status').textContent=e.message;}}; +$('#household-cover-reset').onclick=()=>{coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';}; diff --git a/web/index.html b/web/index.html index 8e0e88f..2fd8f06 100644 --- a/web/index.html +++ b/web/index.html @@ -6,11 +6,11 @@ - +
-
赵氏家宅 · 起居有序

我的家庭与场所

从一个空间,看到每一个位置。

等待连接
+
赵氏家宅 · 起居有序

我的家庭与场所

从一个空间,看到每一个位置。

等待连接
设备—
已接通码流—
录像占用—
空间档案—
可选择任意区域,包含其下级区域
+

本场所监控墙

这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。

主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。

-
赵府 · 智能数字管家独立部署 / 数据自有
+
赵府 · 智能数字管家独立部署 / 数据自有
灯火可亲 · 归家有时

欢迎回来

使用自己的账号,进入家庭空间。

设备档案

手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。

@@ -80,7 +93,7 @@

摄像头对象

录像机配置

驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。

负责人安排与交接

先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。

交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。

-

家庭 / 场所档案

创建后进入该归属,建立管理员,再自行划分区域和登记设备。

+

家庭 / 场所档案

照片仅向有权进入该家庭或场所的账号开放。

创建后进入该归属,建立管理员,再自行划分区域和登记设备。

账号档案

允许查看摄像头的空间

普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。

家谱人物

出生日期日期不详可留空

diff --git a/web/personalization.js b/web/personalization.js new file mode 100644 index 0000000..b1ef79a --- /dev/null +++ b/web/personalization.js @@ -0,0 +1,112 @@ +'use strict'; +const appearanceChoices={theme:{paper:'庭院书卷',clean:'清爽明亮',ink:'静夜墨色'},layout:{sidebar:'左侧目录',top:'顶部目录'},density:{comfortable:'舒展',compact:'紧凑'},backdrop:{mountains:'山水背景',plain:'纯色背景'},font:{serif:'宋体标题',sans:'简洁标题'},startPage:{households:'我的家庭与场所',overview:'场所总览',objects:'区域与设备',heritage:'家庭传承'}}; +const appearanceLabels={theme:'配色主题',layout:'后台版式',density:'内容疏密',backdrop:'页面背景',font:'标题字体',startPage:'登录后首页'}; +const initialDefaults={theme:'paper',layout:'sidebar',density:'comfortable',backdrop:'mountains',font:'serif',startPage:'households'}; +let branding={name:'赵府',subtitle:'智能数字管家',motto:'赵氏家宅 · 起居有序',loginTitle:'欢迎回来',loginNote:'输入用户名和密码,进入家庭空间。',footer:'赵府 · 智能数字管家',logoUrl:'/zhao-icon.png',iconUrl:'/zhao-icon.png',defaults:initialDefaults}; +let personalValues=Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default'])),personalRevision=0,appearanceOwner='',appearanceGeneration=0,settingsTimer=null; +let brandDraft=null,brandLogo='',brandIcon='',backupRevision=0,coverDraft; +pageInfo.settings=['设置中心','发布配置统一管理,每个账号保留自己的使用习惯。']; +function effectiveAppearance(values=personalValues,defaults=branding.defaults){return Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,appearanceChoices[k][values[k]]?values[k]:appearanceChoices[k][defaults?.[k]]?defaults[k]:initialDefaults[k]]));} +function applyAppearance(values=personalValues,defaults=branding.defaults){ + const effective=effectiveAppearance(values,defaults); + for(const key of ['theme','layout','density','backdrop','font'])document.documentElement.dataset[key]=effective[key]; + return effective; +} +function applyBranding(){ + document.title=branding.name+' · '+branding.subtitle; + $('#brand-name').textContent=branding.name;$('#brand-subtitle').textContent=branding.subtitle; + $('#brand-motto').textContent=branding.motto;$('#brand-footer').textContent=branding.footer; + $('#brand-image').src=branding.logoUrl;$('#brand-image').alt=branding.name+'标识'; + $('link[rel="icon"]').href=branding.iconUrl;$('link[rel="apple-touch-icon"]').href=branding.iconUrl; + if(!setup){$('#auth-title').textContent=branding.loginTitle;$('#auth-note').textContent=branding.loginNote;} + applyAppearance(); +} +async function loadBranding(){try{branding=await api('/api/branding');applyBranding();}catch(e){if(!e.stale)applyBranding();}} +function onAppearanceSession(preferences){ + applyBranding();if(!currentUser||!preferences)return; + const first=appearanceOwner!==currentUser.id;appearanceOwner=currentUser.id; + personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;applyAppearance(); + if(first&&activePage==='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(){ + 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])=>``).join(''); + for(const k of Object.keys(appearanceChoices))container.querySelector(`[name="${k}"]`).value=values[k]; +} +function fillBrandForm(value){ + brandDraft=value;brandLogo=value.logoData||'';brandIcon=value.iconData||''; + const form=$('#brand-form');form.reset(); + for(const k of ['name','subtitle','motto','loginTitle','loginNote','footer'])form.elements[k].value=value[k]; + appearanceFields($('#brand-defaults'),value.defaults,false);renderBrandImages(); +} +function renderBrandImages(){ + $('#brand-logo-preview').src=brandLogo||'/zhao-icon.png';$('#brand-icon-preview').src=brandIcon||'/zhao-icon.png'; +} +async function loadSettings(){ + const generation=++appearanceGeneration;$('#settings-error').textContent='正在读取…';$('#settings-private').hidden=true; + try{ + const [preferences,config,backup]=await Promise.all([api('/api/preferences'),isPlatformAdmin()?api('/api/appearance'):null,isPlatformAdmin()?api('/api/backups'):null]); + if(generation!==appearanceGeneration||activePage!=='settings')return; + personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults; + appearanceFields($('#personal-choices'),personalValues,true);applyAppearance(); + if(config){fillBrandForm(config);fillBackup(backup);$('#settings-private').hidden=false;} + $('#settings-error').textContent=''; + }catch(e){if(!e.stale&&generation===appearanceGeneration)$('#settings-error').textContent=e.message;} +} +function readAppearanceFields(container){return Object.fromEntries([...container.querySelectorAll('select')].map(el=>[el.name,el.value]));} +function leaveSettings(){appearanceGeneration++;clearTimeout(settingsTimer);settingsTimer=null;applyAppearance();} +$('#preferences-form').addEventListener('submit',async e=>{ + e.preventDefault();const generation=appearanceGeneration,b=e.currentTarget.querySelector('[type=submit]');b.disabled=true; + try{const saved=await api('/api/preferences',{revision:personalRevision,values:readAppearanceFields($('#personal-choices'))});if(generation!==appearanceGeneration)return; + personalValues=saved.values;personalRevision=saved.revision;applyAppearance();$('#preferences-status').textContent='已保存到当前账号,重新登录及换设备后仍生效。'; + }catch(e){if(!e.stale&&generation===appearanceGeneration)$('#preferences-status').textContent=e.message;}finally{b.disabled=false;} +}); +$('#personal-choices').onchange=()=>applyAppearance(readAppearanceFields($('#personal-choices'))); +$('#personal-follow').onclick=()=>{appearanceFields($('#personal-choices'),Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default'])),true);applyAppearance({});$('#preferences-status').textContent='已预览平台默认,点击保存后生效。';}; +$('#personal-cancel').onclick=()=>{appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();$('#preferences-status').textContent='已恢复已保存的偏好。';}; +$('#settings-reload').onclick=loadSettings; +async function readImage(file,maxSide=512){ + if(!file||!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>5*1024*1024)throw Error('请选择 5 MB 以内的 PNG、JPEG 或 WebP 图片'); + const bitmap=await createImageBitmap(file); + try{if(bitmap.width*bitmap.height>40000000)throw Error('图片尺寸过大,请先缩小'); + let scale=Math.min(1,maxSide/Math.max(bitmap.width,bitmap.height));const canvas=document.createElement('canvas'),limit=maxSide>256?2*1024*1024:256*1024; + for(let attempt=0;attempt<5;attempt++){ + canvas.width=Math.max(1,Math.round(bitmap.width*scale));canvas.height=Math.max(1,Math.round(bitmap.height*scale)); + canvas.getContext('2d').drawImage(bitmap,0,0,canvas.width,canvas.height);const result=canvas.toDataURL('image/png'); + if((result.length-22)*.75<=limit)return result;scale*=.8; + } + throw Error('图片转换后仍过大,请选择较小的图片'); + }finally{bitmap.close();} +} +for(const [id,type,max] of [['brand-logo-file','logo',256],['brand-icon-file','icon',96]]){ + $('#'+id).onchange=async e=>{const generation=appearanceGeneration;try{const image=await readImage(e.target.files[0],max);if(generation!==appearanceGeneration)return;if(type==='logo')brandLogo=image;else brandIcon=image;renderBrandImages();$('#brand-status').textContent='图片已准备,保存发布配置后使用。';}catch(e){if(generation===appearanceGeneration)$('#brand-status').textContent=e.message;}}; +} +$('#brand-logo-reset').onclick=()=>{brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{brandIcon='';renderBrandImages();}; +function brandPayload(){const form=$('#brand-form');return {...Object.fromEntries(['name','subtitle','motto','loginTitle','loginNote','footer'].map(k=>[k,form.elements[k].value])),defaults:readAppearanceFields($('#brand-defaults')),logoData:brandLogo,iconData:brandIcon,revision:brandDraft?.revision};} +$('#brand-form').addEventListener('submit',async e=>{ + e.preventDefault();const b=e.currentTarget.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true; + try{const result=await api('/api/appearance',brandPayload());if(generation!==appearanceGeneration)return;fillBrandForm(result);await loadBranding();if(generation!==appearanceGeneration)return;$('#brand-status').textContent='发布配置已保存。已有个人偏好继续保留。';} + catch(e){if(!e.stale&&generation===appearanceGeneration)$('#brand-status').textContent=e.message;}finally{b.disabled=false;} +}); +$('#brand-export').onclick=()=>{if(!brandDraft)return;const {revision,...value}=brandPayload(),blob=new Blob([JSON.stringify({format:'digital-steward-appearance',version:1,config:value},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='digital-steward-appearance.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}; +$('#brand-import').onchange=async e=>{const generation=appearanceGeneration;try{const file=e.target.files[0];if(!file||file.size>1024*1024)throw Error('配置文件须小于 1 MB');const value=JSON.parse(await file.text());if(generation!==appearanceGeneration)return;if(value.format!=='digital-steward-appearance'||value.version!==1||!value.config||!brandDraft)throw Error('发布配置格式不正确');const config=value.config;if(Object.keys(config).sort().join('|')!==['name','subtitle','motto','loginTitle','loginNote','footer','defaults','logoData','iconData'].sort().join('|'))throw Error('配置字段不匹配');for(const k of Object.keys(appearanceChoices))if(!appearanceChoices[k][config.defaults?.[k]])throw Error('默认外观选项不正确');for(const k of ['logoData','iconData'])if(config[k]&&!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(config[k]))throw Error('图片格式不正确');fillBrandForm({...config,revision:brandDraft.revision});$('#brand-status').textContent='已读入表单,请核对名称、图片与默认外观后保存。';}catch(e){if(generation===appearanceGeneration)$('#brand-status').textContent=e.message;}}; +function fillBackup(value){const c=value.config,f=$('#backup-form');backupRevision=c.revision;f.elements.enabled.checked=c.enabled;for(const k of ['time','keep','networkPath'])f.elements[k].value=c[k];renderBackup(value);} +function backupLabel(r){return !r?'尚未备份':r.error?(r.localOk?'本机成功;网络复制或清理未完成':'备份失败'):r.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';} +function renderBackup(value){ + $('#backup-run').disabled=value.running;$('#backup-result').textContent=value.running?'正在生成数据库副本…':backupLabel(value.last)+(value.last?.error?':'+value.last.error:''); + $('#backup-local-path').textContent='本机目录:'+value.localPath; + $('#backup-history').innerHTML=value.history.map(r=>`
${esc(backupLabel(r))}${esc(new Date(r.at).toLocaleString('zh-CN'))}${esc(r.file||'')} ${r.bytes?Math.round(r.bytes/1024)+' KB':''}${r.sha256?`
完整性校验值${esc(r.sha256)}
`:''}
`).join(''); + clearTimeout(settingsTimer);if(value.running&&activePage==='settings')settingsTimer=setTimeout(pollBackup,2000); +} +async function pollBackup(){const generation=appearanceGeneration;try{const data=await api('/api/backups');if(generation===appearanceGeneration&&activePage==='settings')renderBackup(data);}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-result').textContent=e.message;}} +$('#backup-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;try{const data=await api('/api/backups/config',{revision:backupRevision,enabled:f.elements.enabled.checked,time:f.elements.time.value,keep:Number(f.elements.keep.value),networkPath:f.elements.networkPath.value.trim()});if(generation!==appearanceGeneration)return;fillBackup(data);$('#backup-save-status').textContent='备份设置已保存。';}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-save-status').textContent=e.message;}finally{b.disabled=false;}}); +$('#backup-run').onclick=async()=>{const generation=appearanceGeneration;$('#backup-run').disabled=true;try{await api('/api/backups/run',{});if(generation===appearanceGeneration)pollBackup();}catch(e){if(!e.stale&&generation===appearanceGeneration){$('#backup-result').textContent=e.message;$('#backup-run').disabled=false;}}};