diff --git a/README.md b/README.md index d5b8734..23c90e5 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,25 @@ # 赵府智家 · 视觉系统 +## 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 等元数据,不保存媒体内容或永久公开分享链接。 -- 展示时逐次检查记事访问权限,再获取临时下载地址并同源转发。照片点击后加载,当前页面最多使用 20 MiB 图片内存缓存;视频和音频由浏览器按需缓冲。退出、离开记事页或切换家庭会释放页面媒体资源。不使用服务端文件缓存、Service Worker 离线缓存或浏览器本地存储保存附件。删除记事/移除引用不删除网盘原文件;云端整理在百度网盘进行。 +- 展示时逐次检查记事访问权限,再获取临时下载地址并同源转发。照片和音视频的加载交互已在 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 一致性副本校验后上传,界面分别报告本机、网络目录和百度网盘结果。数据库备份包含各家庭的业务数据和设备连接配置,因此仅超级管理员可操作;不含个人媒体原文件。百度云端旧备份不自动清理,保留份数只作用于本机和挂载目录。 +- 数据库备份可独立勾选「同时上传到百度网盘」,默认关闭。本机 SQLite 一致性副本校验后上传,界面分别报告本机、网络目录和百度网盘结果。数据库备份包含各家庭的业务数据和设备连接配置,因此仅超级管理员可操作;不含个人媒体原文件。云端保留规则已在 v0.1.29 更新,本机保留份数只作用于本机和挂载目录。 - 稳定性:家庭档案使用独立修订号防止相互覆盖;Logo、图标及封面采用最新图片任务校验,取消或重新选择后旧解码结果不会覆盖新选择;备份配置读取失败也会释放执行状态。 升级会新增 `journal_entries`、`journal_media`、`journal_comments` 和 `journal_likes` 表,现有设备、家谱和家庭数据保持原样。当前版本的「个人阅历」以系统账号为人物主体,尚未提供导入网盘已有文件、自动关联祖先人物、分享链接或语音转文字。自定义品牌小图及家庭封面仍沿用原有外观配置,新的记事附件全部使用上述云端流程。 diff --git a/VERSION b/VERSION index baec65a..5ef49d2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.28 +0.1.29 diff --git a/app.py b/app.py index fd68b1d..a6e556a 100644 --- a/app.py +++ b/app.py @@ -654,7 +654,7 @@ class Handler(BaseHTTPRequestHandler): self.require_auth() 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', '/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: @@ -681,6 +681,8 @@ class Handler(BaseHTTPRequestHandler): 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)) diff --git a/backups.py b/backups.py index e71979d..ff03572 100644 --- a/backups.py +++ b/backups.py @@ -1,5 +1,6 @@ """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 @@ -111,6 +112,35 @@ class Backups: 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, @@ -155,9 +185,15 @@ class Backups: self.prune(network, prefix, config['keep']) if config['baidu']: self.stage = '正在上传百度网盘' - remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/数据库备份/'+instance+'/'+name+'/'+name + 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: diff --git a/baidu.py b/baidu.py index d2aa3d3..4292460 100644 --- a/baidu.py +++ b/baidu.py @@ -315,3 +315,42 @@ class Baidu: 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) diff --git a/genealogy.py b/genealogy.py index 45c7478..76fc717 100644 --- a/genealogy.py +++ b/genealogy.py @@ -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: diff --git a/journal.py b/journal.py index 642abe6..79cd373 100644 --- a/journal.py +++ b/journal.py @@ -44,7 +44,7 @@ class Journal: return item def public_media(self, item): - return {k: item.get(k) for k in ('id', 'name', 'mime', 'size', 'status', 'received', 'progress', 'error', 'at')} + 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)) @@ -82,10 +82,13 @@ class Journal: 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))) - uploads = [self.public_media(m) for m in media.values() if m['ownerId'] == user['id'] and m['status'] != 'removed'] + 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], people=people, + 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): @@ -118,6 +121,8 @@ class Journal: 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), @@ -177,13 +182,14 @@ class Journal: 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 m['ownerId'] == user['id']] + 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') @@ -228,6 +234,7 @@ class Journal: # 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']) @@ -254,6 +261,7 @@ class Journal: 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) @@ -265,8 +273,21 @@ class Journal: def remove_upload(self, user, key): with self.guard, self.a.LOCK: item = self.owned(user, 'journal_media', key) - if any(not e.get('deleted') and key in e['mediaIds'] for e in self.a.objects('journal_entries')): - raise self.a.Problem('附件仍被记事引用,请先编辑记事移除附件', 409) self.buffers.pop(key, None) - item.update(status='removed'); self.a.save_object('journal_media', item) + # 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} diff --git a/test_backups.py b/test_backups.py index 0c7c46e..857b45b 100644 --- a/test_backups.py +++ b/test_backups.py @@ -89,7 +89,36 @@ class BackupTests(unittest.TestCase): 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':'测试'}): + 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) diff --git a/test_journal.js b/test_journal.js index 86cb990..5d98af9 100644 --- a/test_journal.js +++ b/test_journal.js @@ -16,7 +16,34 @@ test('journals default to private and encode content before rendering',()=>{ 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,/URL.revokeObjectURL/);assert.match(js,/getTracks\(\).forEach\(t=>t.stop\(\)\)/); + 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:'',mime});assert.match(html,/src="\/media\/journal\?id=a"/);assert.doesNotMatch(html,/autoplay|/); + if(mime.startsWith('image'))assert.match(html,/loading="lazy"/);else assert.match(html,/controls preload="metadata"/); + } +}); +test('successful save clears draft attachment ids, stops recorder, and reloads feed',()=>{ + const js=fs.readFileSync('web/journal.js','utf8'); + const fn=js.slice(js.indexOf('function newJournal'),js.indexOf('async function loadJournal')); + assert.match(fn,/journalDraftMedia.clear\(\)/);assert.match(fn,/journalSelectedMedia.clear\(\)/);assert.match(fn,/stopJournalVoice\(\)/);assert.match(fn,/journal-library'\).open=false/); + assert.match(js,/newJournal\(\);\$\('#journal-mode'\).value='mine';journalPage=0;await loadJournal\(\)/); + assert.match(js,/available.filter\(m=>journalDraftMedia.has\(m.id\)\)/); +}); +test('attachment preview uses a closable dialog and deletion has a restore path',()=>{ + const js=fs.readFileSync('web/journal.js','utf8'),html=fs.readFileSync('web/index.html','utf8'); + assert.match(html,/{ + 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'); diff --git a/test_journal.py b/test_journal.py index 93b2297..d28d5d0 100644 --- a/test_journal.py +++ b/test_journal.py @@ -190,3 +190,46 @@ class JournalTests(unittest.TestCase): 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) diff --git a/web/chinese-theme.css b/web/chinese-theme.css index 97fe487..7b375f4 100644 --- a/web/chinese-theme.css +++ b/web/chinese-theme.css @@ -296,3 +296,26 @@ html[data-theme=ink] body{background-image:radial-gradient(ellipse at 90% 0,#263 @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}} diff --git a/web/family-history.js b/web/family-history.js index 96f510a..33eeef3 100644 --- a/web/family-history.js +++ b/web/family-history.js @@ -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){ diff --git a/web/family-map.js b/web/family-map.js index 413fa20..91c0cb2 100644 --- a/web/family-map.js +++ b/web/family-map.js @@ -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+',查看人物档案'); diff --git a/web/family.js b/web/family.js index fec2ebf..9a49da7 100644 --- a/web/family.js +++ b/web/family.js @@ -6,6 +6,8 @@ const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id)); const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事']; $('#relationship-choices').innerHTML=relationships.map(r=>``).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?'':'')+family.people.map(p=>``).join('');} function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}} @@ -30,7 +32,7 @@ async function loadFamily(resetView=false){ function relationText(e){return personName(e.fromId)+(e.kind==='parent'?' → 子女:':' ↔ 配偶:')+personName(e.toId)+(e.active?'':'(已停用)');} function renderFamily(){ const reference=$('#family-reference').value,term=$('#family-search').value.trim().toLowerCase(); - const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(term)); + const people=family.people.filter(p=>($('#family-archived').checked||!p.archived)&&personSearch(p).includes(term)); $('#family-count').textContent=family.people.filter(p=>!p.archived).length+' 位人物 · '+family.links.filter(e=>e.active).length+' 条关系'+(term?' · 找到 '+people.length+' 位':''); $('#family-find').hidden=!term;$('#family-find').disabled=!people.length; $('#new-person').hidden=!family.canEdit; @@ -40,7 +42,7 @@ function renderFamily(){ $('#family-detail').classList.toggle('is-self',p?.id===family.selfId&&!!p); if(!p){$('#family-detail').innerHTML='

人物档案

点击图中的人物查看生平,或录入第一位人物。

';return;} const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id); - $('#family-detail').innerHTML=`
人物档案

${esc(p.name)}

${family.canEdit?``:''}

相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}

${[['别名',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])=>`
${k}
${esc(v||'待补充')}
`).join('')}

生平与记载

${esc(p.biography||'尚未记录生平')}

${esc(p.note||'')}

亲属关系

${family.canEdit?'':''}
${edges.map(e=>``).join('')||'

尚未连接亲属。

'}`; + $('#family-detail').innerHTML=`
人物档案

${esc(p.name)}

${family.canEdit?``:''}

相对于 ${esc(personName(reference))}:${esc(p.id===reference&&p.id!==family.selfId?'参照人物':ZhaoKinship.describe(family.people,family.links,reference,p.id))}

${[['别名',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])=>`
${k}
${esc(v||'待补充')}
`).join('')}

生平与记载

${esc(p.biography||'尚未记录生平')}

${esc(p.note||'')}

亲属关系

${family.canEdit?'':''}
${edges.map(e=>``).join('')||'

尚未连接亲属。

'}`; if(typeof personEventSummary==='function')$('#family-detail').insertAdjacentHTML('beforeend',personEventSummary(p.id)); $('#family-detail').insertAdjacentHTML('afterbegin',`${p.id===family.selfId?'
◎ 本人 · 当前账号关联人物
':''}`); } @@ -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'); diff --git a/web/index.html b/web/index.html index 726ae20..6a68827 100644 --- a/web/index.html +++ b/web/index.html @@ -45,12 +45,12 @@
Logo 预览
浏览器图标预览

支持 PNG、JPEG、WebP,原图最多 5 MB;上传时自动缩小,图片随系统数据库保存。

新账号与跟随平台账号的默认外观

导入后先核对再保存。导出的配置只含品牌与默认外观,不含账号、设备凭据、家庭资料和网络备份目录。

-

数据库备份

整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。

填写本服务器已挂载的网络盘目录。使用百度接口备份只需勾选上方选项;此路径用于其他已挂载的网络存储,不能填网页地址或分享链接。写入网络目录成功不等于已确认百度云端同步完成。

默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本机和挂载目录中本系统生成的旧备份,百度云端副本需自行整理。

备份记录

+

数据库备份

整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。

填写本服务器已挂载的网络盘目录。使用百度接口备份只需勾选上方选项;此路径用于其他已挂载的网络存储,不能填网页地址或分享链接。写入网络目录成功不等于已确认百度云端同步完成。

默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本机和挂载目录中本系统生成的旧备份,百度网盘保留最近一个自然月;新备份上传并校验成功后才清理本应用的过期副本。

备份记录

@@ -105,7 +105,7 @@

家庭 / 场所档案

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

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

账号档案

允许查看摄像头的空间

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

-

家谱人物

出生日期日期不详可留空

+

家谱人物

出生日期日期不详可留空
籍贯(祖籍)
出生地

籍贯与出生地可不同;不详的层级可留空,海外地址和历史地名也可直接填写。

连接人物关系

每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。

写一则家族记事

事情发生的日期 *
关联人物(可多选)

未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。

正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。

家事 · 家声
@@ -114,5 +114,6 @@

摄像机参数

图像与日夜模式

作用于当前物理镜头,主、子码流共用这些图像参数。

码流与清晰度

作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。

其他萤石功能的接入情况
  • 云台方向微调与预置位:在实时画面的“云台”中使用,按设备能力启用。
  • 实时声音与全屏:通过画面播放器控制。
  • 双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。
  • 录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。

功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。

画面截图

当前视频画面截图

下载 PNG 图片
+

附件预览

diff --git a/web/journal.js b/web/journal.js index df94515..c50c03e 100644 --- a/web/journal.js +++ b/web/journal.js @@ -1,13 +1,47 @@ '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 journalImages=new Map();let journalImageBytes=0; +const journalDraftMedia=new Set(),journalSelectedMedia=new Set(); const journalId=()=>[...crypto.getRandomValues(new Uint8Array(16))].map(b=>b.toString(16).padStart(2,'0')).join(''); const journalToday=()=>{const d=new Date();return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');}; function journalBusy(value){journalUpload=value;$('#journal-save').disabled=value;$('#journal-new').disabled=value;for(const control of $('#journal-filter').elements)control.disabled=value;$('#journal-prev').disabled=value||!journalData?.page;$('#journal-next').disabled=value||!journalData||(journalData.page+1)*20>=journalData.total;$('#journal-record').disabled=value||!journalData?.connection.connected||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;} function journalMessage(text){$('#journal-status').textContent=text;} function journalValid(g){return g===journalGeneration&&loggedIn&&activePage==='journal';} function journalMediaUrl(id,download=false){return '/media/journal?'+new URLSearchParams({id,family:state.familyId,...(download?{download:'1'}:{})});} +function releaseJournalMedia(root){ + root.querySelectorAll('audio,video').forEach(v=>{v.pause();v.removeAttribute('src');v.load();}); + root.querySelectorAll('img').forEach(img=>img.removeAttribute('src')); +} +function journalMediaView(m){ + const name=esc(m.name),id=esc(m.id),src=esc(journalMediaUrl(m.id)); + if(m.mime.startsWith('image/'))return `
照片加载中…
`; + const tag=m.mime.startsWith('audio/')?'audio':'video'; + return `
${tag==='audio'?'
语音与音频
':''}<${tag} controls preload="metadata" playsinline src="${src}" aria-label="${name}">正在加载${tag==='audio'?'音频':'视频'}…
`; +} +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; @@ -15,13 +49,13 @@ function stopJournalVoice(discard=true){ } function leaveJournal(){ journalGeneration++;journalRequest?.abort();journalRequest=null;journalBusy(false);stopJournalVoice(); - $$('#journal-list video,#journal-list audio').forEach(v=>{v.pause();v.removeAttribute('src');v.load();}); - for(const image of journalImages.values())URL.revokeObjectURL(image.url);journalImages.clear();journalImageBytes=0; + 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();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();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();} +function resetJournal(){leaveJournal();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library-list').replaceChildren();$('#journal-trash-list').replaceChildren();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';} +function newJournal(){clearJournalForm();stopJournalVoice();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library').open=false;const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();} async function loadJournal(){ const g=++journalGeneration;if(!loggedIn)return; if(!$('#journal-form').elements.clientId.value)newJournal(); @@ -39,15 +73,29 @@ async function loadJournal(){ renderJournalUploads();renderJournalEntries(); }catch(e){if(!e.stale&&journalValid(g))$('#journal-query-status').textContent=e.message;} } +function journalUploadCard(m,library=false){ + const id=esc(m.id),ready=m.status==='ready'; + return `
<${library?'div':'label'} class="journal-upload-select">${library?'':``}${esc(m.name)}${Math.ceil(m.size/1024)} KB · ${ready?'已存百度网盘':'等待继续上传'}${ready?journalMediaView(m):`

${esc(m.error||'上传尚未完成,请选择原文件继续。')}

`}
${ready?`${library?``:``}`:``}
`; +} function renderJournalUploads(){ - const selected=new Set([...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value)); 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); - $('#journal-upload-list').innerHTML=available.map(m=>`
${m.status==='receiving'?``:''}
`).join(''); + 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('')||'

暂无其他附件。

'):''; + const trash=journalData?.trash||[];$('#journal-trash').hidden=!trash.length; + $('#journal-trash-count').textContent=String(trash.length); + $('#journal-trash-list').innerHTML=trash.map(m=>`
${esc(m.name)}
`).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(){ - for(const value of journalImages.values())URL.revokeObjectURL(value.url);journalImages.clear();journalImageBytes=0; - $('#journal-list').innerHTML=journalData.items.map(e=>`
${esc(e.occurredOn)} · ${esc(e.category)} · ${e.visibility==='private'?'仅自己可见':'当前家庭/场所'}
${e.canEdit?`
`:''}

${esc(e.text)}

${e.media.map(m=>m.mime.startsWith('image/')?`
`:`
<${m.mime.startsWith('audio/')?'audio':'video'} controls preload="none" playsinline src="${esc(journalMediaUrl(m.id))}">
${esc(m.name)} 下载原文件
点击播放后缓冲;编码不兼容时可下载原文件。
`).join('')}
评论 ${e.commentCount}
${e.comments.map(c=>journalComment(e,c)).join('')}
`).join(''); + releaseJournalMedia($('#journal-list')); + $('#journal-list').innerHTML=journalData.items.map(e=>`
${esc(e.occurredOn)} · ${esc(e.category)} · ${e.visibility==='private'?'仅自己可见':'当前家庭/场所'}
${e.canEdit?`
`:''}

${esc(e.text)}

${e.media.map(m=>`
${journalMediaView(m)}

${esc(m.name)} 下载原文件

`).join('')}
评论 ${e.commentCount}
${e.comments.map(c=>journalComment(e,c)).join('')}
`).join(''); + bindJournalMedia($('#journal-list')); } function journalComment(entry,c){return `

${esc(c.authorName)} ${esc(c.text)} ${esc(new Date(c.createdAt).toLocaleString())}${c.canDelete?` `:''}

`;} $('#journal-form').addEventListener('submit',async event=>{ @@ -77,7 +125,7 @@ async function uploadJournalFile(file,resume=''){ 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;await loadJournal();const input=$('#journal-upload-list').querySelector(`input[value="${task.id}"]`);if(input)input.checked=true;journalMessage('附件已保存到百度网盘。填写内容和可见范围后保存记事。'); + 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;}} } @@ -100,20 +148,29 @@ document.addEventListener('change',e=>{if(e.target.dataset.journalResume){upload document.addEventListener('click',async event=>{ const b=event.target.closest('button');if(!b)return;const g=journalGeneration; if(b.dataset.journalPerson){$('#journal-mode').value='person';$('#journal-person-label').hidden=false;$('#journal-person').value=b.dataset.journalPerson;journalPage=0;loadJournal();return;} - if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);f.scrollIntoView({behavior:'smooth'});return;} - if(b.dataset.loadPhoto){ - b.disabled=true;try{ - const id=b.dataset.loadPhoto;let cached=journalImages.get(id); - if(!cached){if(Number(b.dataset.size)>20*1024*1024||journalImageBytes+Number(b.dataset.size)>20*1024*1024)throw Error('照片较大,请下载原图查看。'); - journalImageBytes+=Number(b.dataset.size);b.textContent='照片缓冲中…';const response=await fetch(journalMediaUrl(id));if(!response.ok)throw Error('照片读取失败,请稍后重试');const blob=await response.blob();if(!journalValid(g))return; - cached={url:URL.createObjectURL(blob),size:blob.size};journalImages.set(id,cached);} - const img=b.parentElement.querySelector('img');img.src=cached.url;img.hidden=false;b.hidden=true; - }catch(e){if(journalValid(g)){if(b.textContent==='照片缓冲中…')journalImageBytes=Math.max(0,journalImageBytes-Number(b.dataset.size));b.textContent=e.message;b.disabled=false;const link=document.createElement('a');link.href=journalMediaUrl(b.dataset.loadPhoto,true);link.textContent='下载照片';b.after(link);}}return; + if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];journalDraftMedia.clear();journalSelectedMedia.clear();e.mediaIds.forEach(id=>{journalDraftMedia.add(id);journalSelectedMedia.add(id);});renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);f.scrollIntoView({behavior:'smooth'});return;} + if(b.dataset.useUpload){journalDraftMedia.add(b.dataset.useUpload);journalSelectedMedia.add(b.dataset.useUpload);renderJournalUploads();return;} + if(b.dataset.detachUpload){journalDraftMedia.delete(b.dataset.detachUpload);journalSelectedMedia.delete(b.dataset.detachUpload);renderJournalUploads();return;} + if(b.dataset.previewMedia){previewJournalMedia(b.dataset.previewMedia);return;} + if(b.hasAttribute('data-retry-media')){ + const box=b.closest('.journal-attachment'),media=box.querySelector('img,audio,video'); + b.hidden=true;box.classList.remove('media-error');box.querySelector('.journal-media-state').textContent='正在重新加载…'; + if(media.tagName==='IMG'){const src=media.src;media.removeAttribute('src');media.src=src;}else media.load();return; } const id=b.dataset.journalLike||b.dataset.journalDelete||b.dataset.journalEntry; - if(!id&&!b.dataset.removeUpload)return;b.disabled=true; + if(!id&&!b.dataset.removeUpload&&!b.dataset.restoreUpload)return;b.disabled=true; try{ - if(b.dataset.removeUpload){await api('/api/journal/remove-upload',{id:b.dataset.removeUpload});if(journalValid(g))await loadJournal();return;} + 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();} diff --git a/web/personalization.js b/web/personalization.js index 941690b..e40337c 100644 --- a/web/personalization.js +++ b/web/personalization.js @@ -109,11 +109,11 @@ $('#brand-form').addEventListener('submit',async e=>{ $('#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.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';} +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:''); + $('#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=>`
${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(''); + $('#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.baiduPath?`百度网盘:${esc(r.baiduPath)}`:''}${r.cloudCleanupOk?`保留最近一个自然月,本次删除 ${Number(r.cloudDeleted||0)} 份过期备份`:''}${r.cloudCleanupError?`${esc(r.cloudCleanupError)}`:''}${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;}}