feat: add private journals and centralized Baidu media storage for v0.1.28

This commit is contained in:
Codex
2026-10-04 19:22:31 +08:00
parent 61968adf94
commit 67709832c5
21 changed files with 1187 additions and 35 deletions
+2
View File
@@ -5,6 +5,8 @@ runtime/
*.log
*.key
*.env
baidu-secrets.json
baidu-secrets.partial
__pycache__/
.venv/
mediamtx
+16
View File
@@ -1,5 +1,21 @@
# 赵府智家 · 视觉系统
## v0.1.28 · 个人记事与百度网盘
- 场所内新增「记事与阅历」。文字、日期、分类、作者、可见范围、点赞和评论存于本机数据库,同一条记事可从动态或个人阅历查询。支持关键词、人物、分类及日期筛选,每页 20 条。默认仅自己可见,选择「当前家庭/场所」才会共享;其他家庭不可查看,超级管理员的普通记事 API 也不越过他人的私密范围。
- 支持照片、视频、音频附件和浏览器直接录音。每条最多 9 个附件,单文件上限 256 MiB,录音最多 5 分钟;录音需要 HTTPS 与用户的麦克风授权。只上传原始媒体,不进行服务端转码或语音识别;实际播放还取决于浏览器编码支持。
- **一个后台百度网盘账号统一存放媒体**,应用账号与网盘账号分别管理。用户权限通过本平台的家庭归属和记事可见范围检查。该网盘账号持有人可以直接管理网盘中的全部文件。开发者应用的个人场景限制以百度后台为准。
- 个人记事的媒体不写入服务器磁盘。浏览器逐个计算 4 MiB 分片 MD5,以 512 KiB 请求发送;服务器最多保留两个 4 MiB 中转缓冲,转发至百度预上传/分片上传/创建文件接口。中断后选择同一原文件,重新读取云端缺失分片继续。数据库仅保存附件归属、名称、大小、校验值、网盘路径和文件 ID 等元数据,不保存媒体内容或永久公开分享链接。
- 展示时逐次检查记事访问权限,再获取临时下载地址并同源转发。照片点击后加载,当前页面最多使用 20 MiB 图片内存缓存;视频和音频由浏览器按需缓冲。退出、离开记事页或切换家庭会释放页面媒体资源。不使用服务端文件缓存、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 一致性副本校验后上传,界面分别报告本机、网络目录和百度网盘结果。数据库备份包含各家庭的业务数据和设备连接配置,因此仅超级管理员可操作;不含个人媒体原文件。百度云端旧备份不自动清理,保留份数只作用于本机和挂载目录。
- 稳定性:家庭档案使用独立修订号防止相互覆盖;Logo、图标及封面采用最新图片任务校验,取消或重新选择后旧解码结果不会覆盖新选择;备份配置读取失败也会释放执行状态。
升级会新增 `journal_entries`、`journal_media`、`journal_comments` 和 `journal_likes` 表,现有设备、家谱和家庭数据保持原样。当前版本的「个人阅历」以系统账号为人物主体,尚未提供导入网盘已有文件、自动关联祖先人物、分享链接或语音转文字。自定义品牌小图及家庭封面仍沿用原有外观配置,新的记事附件全部使用上述云端流程。
回归验证增加 `python3 -m unittest -q test_journal` 与 `node --test test_journal.js`,应和原有完整测试集一起执行。接口参考:[百度 OAuth](https://pan.baidu.com/union/doc/al0rwqzzl)、[上传](https://pan.baidu.com/union/doc/3ksg0s9ye)、[下载](https://pan.baidu.com/union/doc/pkuo3snyp)及[百度网盘官方 Go SDK](https://github.com/baidu-netdisk/baidu-drive-sdk-go)。真实上传、播放和云端备份仍须在合法网盘授权后验收;模拟接口测试不能代替真实平台验收。
运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。
## 界面风格
+1 -1
View File
@@ -1 +1 @@
0.1.27
0.1.28
+78 -2
View File
@@ -36,6 +36,8 @@ import inventory
import heritage
import appearance
import backups
import baidu
import journal
import ptz
ROOT = Path(__file__).resolve().parent
@@ -83,6 +85,8 @@ INVENTORY = inventory.Inventory(sys.modules[__name__])
HERITAGE = heritage.Heritage(sys.modules[__name__])
APPEARANCE = appearance.Appearance(sys.modules[__name__])
BACKUPS = backups.Backups(sys.modules[__name__])
BAIDU = baidu.Baidu(sys.modules[__name__])
JOURNAL = journal.Journal(sys.modules[__name__])
def setting(key, default=None):
@@ -629,15 +633,28 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
try:
path = url.urlsplit(self.path).path
if path == '/api/journal/chunk':
self.validate_origin(); self.require_auth()
query = dict(url.parse_qsl(url.urlsplit(self.path).query))
try:
size = int(self.headers.get('Content-Length', '0')); offset = int(query.get('offset', '-1'))
except ValueError: raise Problem('上传分片参数不正确')
if not 0 < size <= journal.CHUNK: raise Problem('上传分片大小不正确')
self.connection.settimeout(45)
raw = self.rfile.read(size)
if len(raw) != size: raise Problem('分片未完整接收,请重试')
self.answer(JOURNAL.chunk(self.user, query.get('id'), offset, raw))
return
# 12,000 Chinese characters plus JSON escapes and linked people exceed 16 KiB.
data = self.body(3*1024*1024 if path in ('/api/appearance','/api/households') else 131072 if path in ('/api/family/event','/api/heritage') else 16384)
data = self.body(3*1024*1024 if path in ('/api/appearance','/api/households') else 131072 if path in ('/api/family/event','/api/heritage','/api/journal') else 16384)
self.validate_origin()
if path in ('/api/login', '/api/setup'):
self.login(data, path == '/api/setup')
return
self.require_auth()
if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets'):
'/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'):
self.require_manager()
if path == '/api/logout':
with LOCK:
@@ -646,6 +663,24 @@ class Handler(BaseHTTPRequestHandler):
self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict')
elif path == '/api/preferences':
self.answer(APPEARANCE.save_preferences(data, self.user))
elif path == '/api/baidu/config':
self.require_admin(); self.answer(BAIDU.configure(data, self.user))
elif path == '/api/baidu/connect':
self.require_admin()
result, cookie = BAIDU.begin(self.user, 'media', self.token(), self.headers.get('Host'))
self.answer(result, cookie=cookie)
elif path == '/api/baidu/disconnect':
self.require_admin(); self.answer(BAIDU.disconnect(self.user, 'media'))
elif path == '/api/journal':
self.answer(JOURNAL.save(self.user, data))
elif path == '/api/journal/action':
self.answer(JOURNAL.action(self.user, data))
elif path == '/api/journal/upload':
self.answer(JOURNAL.begin_upload(self.user, data))
elif path == '/api/journal/finish':
self.answer(JOURNAL.finish_upload(self.user, data.get('id')))
elif path == '/api/journal/remove-upload':
self.answer(JOURNAL.remove_upload(self.user, data.get('id')))
elif path == '/api/appearance':
self.require_admin()
self.answer(APPEARANCE.save_config(data, self.user))
@@ -841,6 +876,17 @@ class Handler(BaseHTTPRequestHandler):
parsed = url.urlsplit(self.path)
path = parsed.path
query = {k: v[0] for k, v in url.parse_qs(parsed.query).items()}
if path == baidu.CALLBACK:
try:
BAIDU.callback(query, self.headers.get('Cookie', '')); result = 'ok'
except Problem: result = 'failed'
self.send_response(303)
self.send_header('Location', '/?baidu='+result)
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('Set-Cookie', 'baidu_flow=; Path=/api/baidu/callback; HttpOnly; SameSite=Lax; Secure; Max-Age=0')
self.send_header('Content-Length', '0'); self.end_headers()
return
if path == '/api/branding':
self.answer(APPEARANCE.public())
return
@@ -862,6 +908,34 @@ class Handler(BaseHTTPRequestHandler):
self.require_auth()
if path == '/api/state':
self.answer(ACCOUNTS.state(self.user, status()))
elif path == '/api/baidu/config':
self.require_admin(); self.answer(BAIDU.public_config())
elif path == '/api/baidu/status':
self.answer(BAIDU.status(self.user))
elif path == '/api/journal':
self.answer(JOURNAL.snapshot(self.user, query))
elif path == '/media/journal':
self.user = HOUSEHOLDS.context(self.user, query.get('family'))
item = JOURNAL.media(self.user, query.get('id'))
with BAIDU.open_file('shared', item, self.headers.get('Range', '')) as response:
if response.status not in (200, 206): raise Problem('网盘媒体暂时不可用', 502)
self.send_response(response.status)
self.send_header('Content-Type', item['mime'])
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('Accept-Ranges', 'bytes')
self.send_header('Content-Disposition', ('attachment' if query.get('download') == '1' else 'inline')+"; filename*=UTF-8''"+url.quote(item['name']))
for header in ('Content-Length', 'Content-Range'):
if response.headers.get(header): self.send_header(header, response.headers[header])
self.end_headers()
remaining = item['size']
try:
while remaining > 0:
chunk = response.read(min(65536, remaining))
if not chunk: break
self.wfile.write(chunk); remaining -= len(chunk)
except (OSError, http.client.HTTPException): self.close_connection = True
elif path == '/api/preferences':
self.answer(APPEARANCE.preferences(self.user))
elif path == '/api/appearance':
@@ -970,6 +1044,7 @@ class Handler(BaseHTTPRequestHandler):
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js', '/personalization.js': 'personalization.js',
'/journal.js': 'journal.js', '/file-hash.js': 'file-hash.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
'/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js',
@@ -1009,6 +1084,7 @@ def initialize():
ACCOUNTS.initialize()
HOUSEHOLDS.initialize()
HERITAGE.initialize()
JOURNAL.initialize()
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
if not setting('mediaSecret'):
+29 -11
View File
@@ -17,12 +17,13 @@ class Backups:
self.a = app
self.guard = threading.Lock()
self.running = False
self.stage = ''
def config(self):
return self.a.setting('backup_config', dict(enabled=False, time='03:00', keep=14, networkPath='', revision=0))
return {'baidu': False, **self.a.setting('backup_config', dict(enabled=False, time='03:00', keep=14, networkPath='', revision=0))}
def status(self):
return dict(config=self.config(), running=self.running,
return dict(config=self.config(), running=self.running, stage=self.stage,
localPath=str(self.a.DATA/'managed-backups'),
last=self.a.setting('backup_last', None),
history=self.a.setting('backup_history', [])[-20:][::-1])
@@ -73,11 +74,14 @@ class Backups:
return folder
def save(self, data):
if set(data) != {'enabled','time','keep','networkPath','revision'}:
data = {'baidu': False, **data}
if set(data) != {'enabled','time','keep','networkPath','revision','baidu'}:
raise self.a.Problem('备份设置字段不完整')
with self.guard:
if self.running:
raise self.a.Problem('备份正在执行,请完成后再调整设置', 409)
if not isinstance(data['baidu'], bool): raise self.a.Problem('网盘备份设置不正确')
if data['baidu']: self.a.BAIDU.token('shared')
with self.a.LOCK:
old = self.config()
if type(data['revision']) is not int or data['revision'] != old['revision']:
@@ -96,7 +100,10 @@ class Backups:
if self.running:
raise self.a.Problem('已有备份正在执行', 409)
self.running = True
threading.Thread(target=self.run, args=(reason,), daemon=True).start()
try: threading.Thread(target=self.run, args=(reason,), daemon=True).start()
except Exception:
with self.guard: self.running = False
raise
return dict(started=True)
def prune(self, folder, prefix, keep):
@@ -106,11 +113,13 @@ class Backups:
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']))
localOk=False, networkOk=False, baiduOk=False, networkRequested=False, baiduRequested=False)
temporary = network_temp = None
try:
config = self.config()
result.update(networkRequested=bool(config['networkPath']), baiduRequested=config['baidu'])
self.stage = '正在生成本机数据库副本'
local = a.DATA/'managed-backups'
local.mkdir(mode=0o700, exist_ok=True)
instance = a.setting('backup_instance')
@@ -133,6 +142,7 @@ class Backups:
result.update(localOk=True, file=name, bytes=target.stat().st_size, sha256=digest)
self.prune(local, prefix, config['keep'])
if config['networkPath']:
self.stage = '正在复制到网络目录'
network = self.network_folder(config['networkPath'])
network_temp = network/(name+'.partial')
shutil.copyfile(target, network_temp)
@@ -143,6 +153,11 @@ class Backups:
os.replace(network_temp, network/name)
result['networkOk'] = True
self.prune(network, prefix, config['keep'])
if config['baidu']:
self.stage = '正在上传百度网盘'
remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/数据库备份/'+instance+'/'+name+'/'+name
uploaded = a.BAIDU.upload('shared', target, remote)
result.update(baiduOk=True, baiduPath=remote, baiduFileId=str(uploaded['fs_id']))
except Exception as error:
result['error'] = str(error)[:300] or '备份未完成'
finally:
@@ -151,11 +166,14 @@ class Backups:
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
try:
with a.LOCK:
a.set_setting('backup_last', result)
history = a.setting('backup_history', [])
a.set_setting('backup_history', (history+[result])[-100:])
finally:
with self.guard:
self.running = False; self.stage = ''
return result
def tick(self, now=None):
+317
View File
@@ -0,0 +1,317 @@
"""Baidu OAuth and bounded uploads. Secrets stay in a private file, outside DB backups."""
import datetime as dt
import hashlib
import hmac
import http.cookies
import json
import os
import re
import secrets
import threading
import time
import urllib.error
import urllib.parse as url
import urllib.request
BLOCK = 4 * 1024 * 1024
CALLBACK = '/api/baidu/callback'
API = 'https://pan.baidu.com/rest/2.0/xpan/'
OAUTH = 'https://openapi.baidu.com/oauth/2.0/'
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def download_url(value):
p = url.urlsplit(value)
if (p.scheme != 'https' or p.username or p.password or p.port not in (None, 443)
or not any((p.hostname or '').endswith('.'+d) or p.hostname == d
for d in ('baidu.com', 'baidupcs.com'))):
raise ValueError('网盘下载地址未通过校验')
return value
class DownloadRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
download_url(newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)
class Baidu:
def __init__(self, app):
self.a = app
self.guard = threading.RLock()
self.pending = {}
def read(self):
path = self.a.DATA/'baidu-secrets.json'
if not path.exists():
return dict(config={}, connections={})
return json.loads(path.read_text(encoding='utf8'))
def write(self, value):
path = self.a.DATA/'baidu-secrets.json'
temporary = path.with_suffix('.partial')
with temporary.open('w', encoding='utf8') as stream:
json.dump(value, stream, ensure_ascii=False)
stream.flush(); os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
def key(self, user, purpose='media'):
if purpose == 'backup':
if user['role'] != 'admin':
raise self.a.Problem('数据库云备份仅超级管理员可操作', 403)
return 'shared'
if purpose != 'media':
raise self.a.Problem('网盘用途不正确')
self.a.HOUSEHOLDS.scope(user)
return 'shared'
def public_config(self):
with self.guard:
c = self.read()['config']
return dict(configured=bool(c.get('appKey') and c.get('secretKey')),
appKey=c.get('appKey', ''), hasSecret=bool(c.get('secretKey')),
appFolder=c.get('appFolder', '智家'), redirectUri=c.get('redirectUri', ''),
revision=c.get('revision', 0))
def configure(self, data, user):
a = self.a
if user['role'] != 'admin':
raise a.Problem('开发者应用仅超级管理员可配置', 403)
if set(data) != {'appKey', 'secretKey', 'appFolder', 'redirectUri', 'revision'}:
raise a.Problem('应用配置字段不完整')
with self.guard:
saved = self.read(); old = saved['config']
if type(data['revision']) is not int or data['revision'] != old.get('revision', 0):
raise a.Problem('应用配置已变化,请重新读取', 409)
key = a.clean_text(data['appKey'], 150, True)
secret = a.clean_text(data['secretKey'], 150) or old.get('secretKey', '')
folder = a.clean_text(data['appFolder'], 40, True)
callback = a.clean_text(data['redirectUri'], 400, True)
p = url.urlsplit(callback)
if not secret or not re.fullmatch(r'[A-Za-z0-9]+', key+secret):
raise a.Problem('AppKey 或 SecretKey 格式不正确')
if any(c in folder for c in '/\\:*?"<>|') or folder in ('.', '..'):
raise a.Problem('应用文件夹须与百度后台应用名称一致')
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.query or p.fragment or p.path != CALLBACK:
raise a.Problem('回调地址须为 HTTPS 站点地址加 '+CALLBACK)
changed = any(old.get(k) != v for k, v in dict(appKey=key, secretKey=secret, appFolder=folder, redirectUri=callback).items())
if changed and saved['connections']:
raise a.Problem('已有网盘授权,修改应用前须先解除共享网盘连接', 409)
saved['config'] = dict(appKey=key, secretKey=secret, appFolder=folder,
redirectUri=callback, revision=old.get('revision', 0)+1)
self.write(saved)
if changed: self.pending.clear()
a.audit('配置百度网盘应用', user['username'])
return self.public_config()
def status(self, user, purpose='media'):
key = self.key(user, purpose)
with self.guard:
saved = self.read(); c = saved['config']; connection = saved['connections'].get(key, {})
return dict(configured=bool(c.get('appKey') and c.get('secretKey')),
connected=bool(connection.get('refresh_token')), account=connection.get('name', ''),
reconnect=bool(connection.get('invalid')), purpose=purpose,
redirectUri=c.get('redirectUri', ''), root='/apps/'+c.get('appFolder', '智家'),
connectedAt=connection.get('connectedAt', ''))
def begin(self, user, purpose, session_token, host):
if user['role'] != 'admin':
raise self.a.Problem('共享网盘由超级管理员统一连接', 403)
key = self.key(user, purpose)
with self.guard:
c = self.read()['config']
if not c.get('appKey') or not c.get('secretKey'):
raise self.a.Problem('请先由超级管理员配置百度开发者应用')
if url.urlsplit(c['redirectUri']).netloc != host:
raise self.a.Problem('请通过 '+url.urlsplit(c['redirectUri']).netloc+' 登录系统后连接网盘')
now = time.time()
self.pending = {s: v for s, v in self.pending.items() if v['expires'] > now and v['key'] != key}
if len(self.pending) >= 32:
raise self.a.Problem('授权请求较多,请稍后再试', 429)
state = secrets.token_hex(32)
self.pending[state] = dict(key=key, userId=user['id'], familyId=self.a.HOUSEHOLDS.scope(user),
session=hashlib.sha256(session_token.encode()).hexdigest(),
revision=c['revision'], expires=now+600)
address = OAUTH+'authorize?'+url.urlencode(dict(response_type='code', client_id=c['appKey'],
redirect_uri=c['redirectUri'], scope='basic,netdisk', state=state, display='page'))
return dict(url=address), 'baidu_flow='+state+'; Path=/api/baidu/callback; HttpOnly; SameSite=Lax; Secure; Max-Age=600'
def json_request(self, address, data=None, raw=None, content_type=None):
body = url.urlencode(data).encode() if data is not None else raw
headers = {'User-Agent': 'pan.baidu.com'}
if body is not None: headers['Content-Type'] = content_type or 'application/x-www-form-urlencoded'
req = urllib.request.Request(address, body, headers)
try:
with urllib.request.build_opener(NoRedirect()).open(req, timeout=40) as response:
payload = response.read(2*1024*1024+1)
if len(payload) > 2*1024*1024: raise ValueError()
value = json.loads(payload)
except Exception:
raise self.a.Problem('百度网盘暂时未响应,请稍后重试', 502) from None
if not isinstance(value, dict): raise self.a.Problem('网盘返回内容不完整', 502)
if value.get('error') or value.get('errno', value.get('error_code', 0)):
code = str(value.get('error') or value.get('errno') or value.get('error_code'))
code = code if re.fullmatch('[A-Za-z0-9_-]{1,50}', code) else 'unknown'
if code in ('invalid_grant', 'invalid_token', 'expired_token', '110', '111', '-6', '20016', '20017', '31045'):
error = self.a.Problem('网盘授权已失效,请重新连接('+code+')', 409)
error.reauthorize = True
raise error
raise self.a.Problem('网盘操作未完成('+code+'),请检查应用权限、目录及网盘容量', 502)
return value
def token_response(self, c, **fields):
value = self.json_request(OAUTH+'token?'+url.urlencode(dict(client_id=c['appKey'], client_secret=c['secretKey'], **fields)))
if not value.get('access_token') or not value.get('refresh_token') or not isinstance(value.get('expires_in'), int):
raise self.a.Problem('网盘授权返回不完整,请重新连接', 502)
if 'netdisk' not in value.get('scope', '').split():
# Some responses separate scopes with commas instead of spaces.
if 'netdisk' not in value.get('scope', '').split(','):
raise self.a.Problem('本次未授予网盘访问权限,请重新连接', 403)
return dict(access_token=value['access_token'], refresh_token=value['refresh_token'],
expires=time.time()+max(0, value['expires_in']-120))
def callback(self, query, cookie):
state = query.get('state', '')
try: cookies = http.cookies.SimpleCookie(cookie)
except http.cookies.CookieError: cookies = {}
flow = cookies.get('baidu_flow')
if not re.fullmatch('[a-f0-9]{64}', state) or not flow or not hmac.compare_digest(flow.value, state):
raise self.a.Problem('授权校验失败,请从系统重新发起连接', 403)
with self.guard:
pending = self.pending.pop(state, None)
c = self.read()['config']
if not pending or pending['expires'] < time.time() or pending['revision'] != c.get('revision'):
raise self.a.Problem('授权已过期或已使用,请重新连接', 403)
with self.a.LOCK:
session = self.a.DB.execute('SELECT user_id FROM sessions WHERE hash=? AND expires>?', (pending['session'], time.time())).fetchone()
user = self.a.get_object('users', pending['userId'])
if not session or session[0] != pending['userId'] or not user or user['disabled']:
raise self.a.Problem('原登录已失效,请重新登录后授权', 403)
user = self.a.HOUSEHOLDS.context(user, pending['familyId'])
if user['role'] != 'admin': raise self.a.Problem('管理员权限已变化,请重新授权', 403)
key = self.key(user)
if query.get('error') or not query.get('code'):
raise self.a.Problem('本次未完成网盘授权,可回到系统重新连接')
token = self.token_response(c, grant_type='authorization_code', code=query['code'], redirect_uri=c['redirectUri'])
info = self.json_request(API+'nas?'+url.urlencode(dict(method='uinfo', access_token=token['access_token'])))
uid = str(info.get('uk', ''))
if not uid.isdigit(): raise self.a.Problem('无法确认网盘账号,请重试', 502)
saved = self.read(); old = saved['connections'].get(key, {})
# Re-authorizing a different disk would strand existing attachments.
owner = self.a.setting('baidu_owner:'+key)
if owner and owner != uid:
raise self.a.Problem('请连接原网盘账号;更换账号需先迁移现有资料', 409)
saved['connections'][key] = dict(token, uid=uid, name=str(info.get('baidu_name') or info.get('netdisk_name') or '百度网盘')[:80],
connectedAt=dt.datetime.now(dt.timezone.utc).isoformat(), generation=old.get('generation', 0)+1)
self.write(saved); self.a.set_setting('baidu_owner:'+key, uid)
self.a.audit('连接共享百度网盘', user['username'])
def disconnect(self, user, purpose):
if user['role'] != 'admin': raise self.a.Problem('共享网盘由超级管理员管理', 403)
key = self.key(user, purpose)
with self.guard:
saved = self.read(); saved['connections'].pop(key, None); self.write(saved)
self.pending = {s: v for s, v in self.pending.items() if v['key'] != key}
self.a.audit('解除本机网盘连接', user['username'])
return self.status(user, purpose)
def token(self, key):
with self.guard:
saved = self.read(); connection = saved['connections'].get(key)
if not connection or connection.get('invalid'):
raise self.a.Problem('请连接百度网盘后再操作', 409)
if connection['expires'] <= time.time():
try:
connection.update(self.token_response(saved['config'], grant_type='refresh_token', refresh_token=connection['refresh_token']))
except self.a.Problem as error:
if getattr(error, 'reauthorize', False):
connection['invalid'] = True; self.write(saved)
raise
self.write(saved)
return connection['access_token']
def request(self, key, resource, method, data=None, **query):
return self.json_request(API+resource+'?'+url.urlencode(dict(method=method, access_token=self.token(key), **query)), data=data)
def prepare(self, remote, size, hashes):
root = '/apps/'+self.public_config()['appFolder']
if not remote.startswith(root+'/') or '/..' in remote: raise self.a.Problem('网盘路径不正确')
parent = remote.rsplit('/', 1)[0]
for n in range(3, len(parent.split('/'))+1):
path = '/'.join(parent.split('/')[:n])
# rtype=0 keeps an existing folder; -8 is also reported by some API versions.
try: self.request('shared', 'file', 'create', dict(path=path, isdir=1, rtype=0))
except self.a.Problem as error:
if '(-8)' not in str(error): raise
found = self.find_file(remote, size)
if found: return dict(existing=found)
pre = self.request('shared', 'file', 'precreate', dict(path=remote, size=size, isdir=0, rtype=0, block_list=json.dumps(hashes), autoinit=1))
if pre.get('return_type') == 2:
found = self.find_file(remote, size)
if not found: raise self.a.Problem('云端文件尚未确认,请重试', 502)
return dict(existing=found)
if not pre.get('uploadid'): raise self.a.Problem('网盘预上传返回不完整', 502)
return pre
def find_file(self, remote, size):
values = self.request('shared', 'file', 'list', dir=remote.rsplit('/', 1)[0], limit=1000).get('list', [])
found = next((v for v in values if v.get('path') == remote), None)
if found and found.get('size') != size: raise self.a.Problem('云端同名文件大小不符,未覆盖文件', 409)
return found
def send_block(self, remote, upload_id, index, block, expected):
if hashlib.md5(block).hexdigest() != expected: raise self.a.Problem('文件分片校验不一致,请重新选择原文件')
boundary = secrets.token_hex(16)
body = ('--'+boundary+'\r\nContent-Disposition: form-data; name="file"; filename="chunk"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()+block+('\r\n--'+boundary+'--\r\n').encode()
address = 'https://d.pcs.baidu.com/rest/2.0/pcs/superfile2?'+url.urlencode(dict(method='upload', type='tmpfile', path=remote, uploadid=upload_id, partseq=index, access_token=self.token('shared')))
result = self.json_request(address, raw=body, content_type='multipart/form-data; boundary='+boundary)
if result.get('md5') != expected: raise self.a.Problem('网盘分片校验不一致,请重试', 502)
def complete(self, item):
found = self.find_file(item['remotePath'], item['size'])
if found: return found
result = self.request('shared', 'file', 'create', dict(path=item['remotePath'], size=item['size'], isdir=0, rtype=0,
uploadid=item['uploadId'], block_list=json.dumps(item['hashes'])))
if not result.get('fs_id') or result.get('size') != item['size'] or result.get('path') != item['remotePath']:
raise self.a.Problem('上传结果尚未确认,请重试核对云端文件', 502)
return result
def upload(self, key, local, remote, progress=lambda n, total: None):
"""Only database backups use a local file; personal media uses send_block directly."""
size = local.stat().st_size
hashes = []
with local.open('rb') as stream:
while block := stream.read(BLOCK): hashes.append(hashlib.md5(block).hexdigest())
if not hashes: raise self.a.Problem('空文件不能上传')
pre = self.prepare(remote, size, hashes)
if pre.get('existing'): return pre['existing']
needed = pre.get('block_list', list(range(len(hashes))))
if any(type(i) is not int or not 0 <= i < len(hashes) for i in needed):
raise self.a.Problem('网盘分片索引不正确', 502)
with local.open('rb') as stream:
for i in needed:
stream.seek(i*BLOCK)
self.send_block(remote, pre['uploadid'], i, stream.read(BLOCK), hashes[i])
progress(i+1, len(hashes))
return self.complete(dict(remotePath=remote, size=size, uploadId=pre['uploadid'], hashes=hashes))
def open_file(self, key, item, byte_range=''):
result = self.request(key, 'multimedia', 'filemetas', fsids=json.dumps([int(item['fsId'])]), dlink=1)
info = next((v for v in result.get('list', []) if str(v.get('fs_id')) == str(item['fsId'])), None)
if not info or info.get('path') != item['remotePath']:
raise self.a.Problem('网盘文件已移动或不存在', 404)
address = download_url(info.get('dlink', ''))
address += ('&' if '?' in address else '?')+url.urlencode(dict(access_token=self.token(key)))
headers = {'User-Agent': 'pan.baidu.com'}
if byte_range:
if not re.fullmatch(r'bytes=(?:\d+-\d*|-\d+)', byte_range): raise self.a.Problem('播放范围不正确', 416)
headers['Range'] = byte_range
try:
return urllib.request.build_opener(DownloadRedirect()).open(urllib.request.Request(address, headers=headers), timeout=30)
except Exception:
raise self.a.Problem('网盘文件暂时无法读取,请稍后重试', 502) from None
+3
View File
@@ -157,6 +157,8 @@ class Households:
raise a.Problem('家庭不存在', 404)
if user['role'] != 'admin' and (not old or old['id'] != self.scope(user)):
raise a.Problem('只能管理自己所属的家庭', 403)
if type(data.get('profileRevision', 0)) is not int or data.get('profileRevision', 0) != (old or {}).get('profileRevision', 0):
raise a.Problem('家庭档案已被更新,请重新打开后保存,避免覆盖他人的修改', 409)
kind = data.get('kind', old.get('kind', 'home') if old else 'home')
if kind not in ('home', 'office', 'factory'):
raise a.Problem('请选择家庭、办公场所或工厂')
@@ -164,6 +166,7 @@ class Households:
name=a.clean_text(data.get('name', ''), 80, True),
kind=kind,
note=a.clean_text(data.get('note', ''), 300),
profileRevision=(old or {}).get('profileRevision', 0)+1,
createdAt=old['createdAt'] if old else dt.datetime.now(dt.timezone.utc).isoformat())
for field in ('province','city','district'):
item[field] = a.clean_text(data.get(field, (old or {}).get(field, '')), 60)
+272
View File
@@ -0,0 +1,272 @@
"""Personal journals, household sharing and owner-bound cloud attachments."""
import datetime as dt
import hashlib
import os
import re
import secrets
import threading
import time
CHUNK = 512 * 1024
MAX_FILE = 256 * 1024 * 1024
KINDS = {'日常', '成长', '家庭', '旅行', '工作', '其他'}
TYPES = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp',
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.wav': 'audio/wav', '.ogg': 'audio/ogg'}
class Journal:
def __init__(self, app):
self.a = app
self.guard = threading.RLock()
self.buffers = {}
def initialize(self):
with self.a.LOCK, self.a.DB:
for table in ('journal_entries', 'journal_media', 'journal_comments', 'journal_likes'):
self.a.DB.execute('CREATE TABLE IF NOT EXISTS '+table+' (id TEXT PRIMARY KEY, body TEXT NOT NULL)')
# No binary files are written to disk. Partial 4 MiB blocks vanish on restart.
def owned(self, user, table, key):
item = self.a.HOUSEHOLDS.owns(user, self.a.get_object(table, key))
if item['ownerId'] != user['id']:
raise self.a.Problem('只能修改自己的记录', 403)
return item
def visible(self, user, item):
return bool(item and not item.get('deleted') and item['familyId'] == self.a.HOUSEHOLDS.scope(user)
and (item['ownerId'] == user['id'] or item['visibility'] == 'household'))
def entry(self, user, key):
item = self.a.get_object('journal_entries', key)
if not self.visible(user, item): raise self.a.Problem('记事不存在或未获授权', 404)
return item
def public_media(self, item):
return {k: item.get(k) for k in ('id', 'name', 'mime', 'size', 'status', 'received', 'progress', 'error', 'at')}
def media(self, user, key):
item = self.a.HOUSEHOLDS.owns(user, self.a.get_object('journal_media', key))
if item['ownerId'] != user['id'] and not any(self.visible(user, entry) and key in entry['mediaIds'] for entry in self.a.objects('journal_entries')):
raise self.a.Problem('附件不存在或未获授权', 404)
if item['status'] != 'ready': raise self.a.Problem('附件尚未上传完成', 409)
return item
def snapshot(self, user, query):
a = self.a
if query.get('mode', 'feed') not in ('feed', 'mine', 'person'):
raise a.Problem('查询方式不正确')
entries = [e for e in a.objects('journal_entries') if self.visible(user, e)]
mode = query.get('mode', 'feed')
if mode == 'feed': entries = [e for e in entries if e['visibility'] == 'household']
elif mode == 'mine': entries = [e for e in entries if e['ownerId'] == user['id']]
elif mode == 'person': entries = [e for e in entries if e['ownerId'] == query.get('person')]
for field, compare in [('from', lambda x, y: x >= y), ('to', lambda x, y: x <= y)]:
if query.get(field):
self.date(query[field]); entries = [e for e in entries if compare(e['occurredOn'], query[field])]
search = query.get('search', '').strip().casefold()[:120]
if search: entries = [e for e in entries if search in (e['text']+' '+e['category']).casefold()]
if query.get('category'): entries = [e for e in entries if e['category'] == query['category']]
entries.sort(key=lambda e: (e['occurredOn'], e['createdAt'], e['id']), reverse=True)
try: page = max(0, min(100000, int(query.get('page', '0'))))
except ValueError: raise a.Problem('页码不正确')
media = {m['id']: m for m in a.HOUSEHOLDS.objects(user, 'journal_media')}
likes = a.HOUSEHOLDS.objects(user, 'journal_likes')
comments = a.HOUSEHOLDS.objects(user, 'journal_comments')
items = []
for entry in entries[page*20:page*20+20]:
related = sorted([c for c in comments if c['entryId'] == entry['id'] and not c.get('deleted')], key=lambda c: c['createdAt'])
voters = [v for v in likes if v['entryId'] == entry['id'] and v['liked']]
items.append(dict(entry, media=[self.public_media(media[mid]) for mid in entry['mediaIds'] if mid in media],
canEdit=entry['ownerId'] == user['id'], likes=len(voters), liked=any(v['ownerId'] == user['id'] for v in voters),
comments=[dict(c, canDelete=c['ownerId'] == user['id'] or entry['ownerId'] == user['id']) for c in related[-100:]],
commentCount=len(related)))
uploads = [self.public_media(m) for m in media.values() if m['ownerId'] == user['id'] and m['status'] != 'removed']
uploads.sort(key=lambda m: 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,
connection=a.BAIDU.status(user), chunkSize=CHUNK, maxFile=MAX_FILE)
def date(self, value):
try:
if dt.date.fromisoformat(value).isoformat() != value: raise ValueError()
except (TypeError, ValueError): raise self.a.Problem('请填写有效的公历日期')
return value
def save(self, user, data):
a = self.a
with a.LOCK:
old = self.owned(user, 'journal_entries', data['id']) if data.get('id') else None
if old and old.get('deleted'): raise a.Problem('记录已经删除', 404)
if type(data.get('revision', 0)) is not int or data.get('revision', 0) != (old or {}).get('revision', 0):
raise a.Problem('记事已更新,请重新打开后保存', 409)
client = a.clean_text(data.get('clientId', ''), 32, True)
if not re.fullmatch('[a-f0-9]{32}', client): raise a.Problem('提交编号不正确')
if not old:
prior = next((e for e in a.objects('journal_entries') if e['ownerId'] == user['id'] and e['familyId'] == a.HOUSEHOLDS.scope(user) and e['clientId'] == client), None)
if prior: return {'id': prior['id'], 'duplicate': True}
raw_text = data.get('text', '')
if not isinstance(raw_text, str): raise a.Problem('记事内容须为文字')
text = a.clean_text(raw_text.replace('\r\n', '\n').replace('\n', '\u2028'), 5000).replace('\u2028', '\n')
visibility = data.get('visibility', 'private'); category = data.get('category', '日常')
if visibility not in ('private', 'household') or category not in KINDS:
raise a.Problem('可见范围或分类不正确')
ids = data.get('mediaIds', [])
if not isinstance(ids, list) or len(ids) > 9 or any(not isinstance(i, str) for i in ids) or len(set(ids)) != len(ids):
raise a.Problem('每条记事最多 9 个附件')
for mid in ids:
item = self.owned(user, 'journal_media', mid)
if item['status'] != 'ready': raise a.Problem('请等待附件上传完成后再发布')
if not text and not ids: raise a.Problem('请写下内容或添加附件')
now = dt.datetime.now(dt.timezone.utc).isoformat()
entry = dict(id=old['id'] if old else secrets.token_hex(8), familyId=a.HOUSEHOLDS.scope(user),
ownerId=user['id'], authorName=user['name'], text=text, category=category,
visibility=visibility, occurredOn=self.date(data.get('occurredOn')),
mediaIds=ids, createdAt=old['createdAt'] if old else now, updatedAt=now,
revision=(old or {}).get('revision', 0)+1, clientId=client, deleted=False)
a.save_object('journal_entries', entry)
return {'id': entry['id']}
def action(self, user, data):
a = self.a
with a.LOCK:
entry = self.entry(user, data.get('id'))
action = data.get('action')
if action == 'delete':
self.owned(user, 'journal_entries', entry['id'])
if data.get('revision') != entry['revision']: raise a.Problem('记事已更新,请重新读取', 409)
entry.update(deleted=True, revision=entry['revision']+1); a.save_object('journal_entries', entry)
elif action == 'like':
if not isinstance(data.get('liked'), bool): raise a.Problem('点赞状态不正确')
key = hashlib.sha256((entry['id']+user['id']).encode()).hexdigest()[:32]
a.save_object('journal_likes', dict(id=key, entryId=entry['id'], familyId=entry['familyId'], ownerId=user['id'], liked=data['liked']))
elif action == 'comment':
text = a.clean_text(data.get('text', ''), 500, True)
key = a.clean_text(data.get('clientId', ''), 32, True)
if not re.fullmatch('[a-f0-9]{32}', key): raise a.Problem('评论编号不正确')
old = a.get_object('journal_comments', key)
if old:
if old['ownerId'] != user['id'] or old['entryId'] != entry['id']: raise a.Problem('评论编号已使用', 409)
return {'ok': True}
count = sum(c['entryId'] == entry['id'] and not c.get('deleted') for c in a.objects('journal_comments'))
if count >= 100: raise a.Problem('本条记事已达到 100 条评论上限')
a.save_object('journal_comments', dict(id=key, entryId=entry['id'], familyId=entry['familyId'], ownerId=user['id'], authorName=user['name'], text=text, createdAt=dt.datetime.now(dt.timezone.utc).isoformat()))
elif action == 'delete-comment':
comment = a.get_object('journal_comments', data.get('commentId'))
if not comment or comment['entryId'] != entry['id']: raise a.Problem('评论不存在', 404)
if user['id'] not in (entry['ownerId'], comment['ownerId']): raise a.Problem('只能删除自己的评论或自己记事下的评论', 403)
comment['deleted'] = True; a.save_object('journal_comments', comment)
else: raise a.Problem('记事操作不正确')
return {'ok': True}
def begin_upload(self, user, data):
a = self.a
name = a.clean_text(data.get('name', ''), 120, True)
ext = os.path.splitext(name)[1].lower()
if ext not in TYPES or any(c in name for c in '/\\:*?"<>|') or name.startswith('.'):
raise a.Problem('请选择 JPG、PNG、WebP 照片,MP4、MOV、WebM 视频或常见音频文件')
size = a.integer(data.get('size'), 1, MAX_FILE)
hashes = data.get('hashes')
if not isinstance(hashes, list) or len(hashes) != (size+4194303)//4194304 or any(not isinstance(h, str) or not re.fullmatch('[a-f0-9]{32}', h) for h in hashes):
raise a.Problem('文件分片校验表不正确')
mime = 'audio/webm' if ext == '.webm' and data.get('mime', '').startswith('audio/') else TYPES[ext]
a.BAIDU.token('shared')
with self.guard:
for key, buffer in list(self.buffers.items()):
if buffer['touched'] < time.time()-600: self.buffers.pop(key)
prior = self.owned(user, 'journal_media', data['id']) if data.get('id') else None
if prior:
if prior['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']]
if len(active) >= 4: raise a.Problem('请先完成或移除现有上传任务,最多同时保留 4 个', 409)
key = secrets.token_hex(8); family = a.HOUSEHOLDS.scope(user)
instance = a.setting('cloud_instance')
if not instance:
instance = secrets.token_hex(8); a.set_setting('cloud_instance', instance)
remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/资料/'+instance+'/'+family+'/'+key+'/'+name
item = dict(id=key, familyId=family, ownerId=user['id'], name=name, mime=mime, size=size,
status='receiving', received=0, progress=0, error='', remotePath=remote, hashes=hashes,
at=dt.datetime.now(dt.timezone.utc).isoformat())
a.save_object('journal_media', item)
try:
pre = a.BAIDU.prepare(item['remotePath'], size, hashes)
if pre.get('existing'):
item.update(status='ready', received=size, progress=100, fsId=str(pre['existing']['fs_id']), error='')
else:
# Precreate reports which cloud blocks are still missing; after browser/server
# restart resend from the first missing block, without any local file cache.
needed = pre.get('block_list', list(range(len(hashes))))
if not isinstance(needed, list) or any(type(n) is not int or not 0 <= n < len(hashes) for n in needed):
raise a.Problem('网盘返回的分片索引不正确', 502)
received = min(needed)*4194304 if needed else size
item.update(uploadId=pre['uploadid'], received=received, progress=round(received/size*95), error='')
self.buffers.pop(item['id'], None)
a.save_object('journal_media', item)
except a.Problem as error:
item['error'] = str(error); a.save_object('journal_media', item); raise
return self.public_media(item)
@staticmethod
def valid_signature(mime, raw):
return bool(mime == 'image/jpeg' and raw.startswith(b'\xff\xd8\xff') or
mime == 'image/png' and raw.startswith(b'\x89PNG\r\n\x1a\n') or
mime == 'image/webp' and raw.startswith(b'RIFF') and raw[8:12] == b'WEBP' or
mime in ('video/mp4', 'video/quicktime', 'audio/mp4') and raw[4:8] == b'ftyp' or
mime in ('video/webm', 'audio/webm') and raw.startswith(b'\x1a\x45\xdf\xa3') or
mime == 'audio/wav' and raw.startswith(b'RIFF') and raw[8:12] == b'WAVE' or
mime == 'audio/ogg' and raw.startswith(b'OggS') or
mime in ('audio/mpeg', 'audio/aac') and (raw.startswith(b'ID3') or len(raw) > 1 and raw[0] == 255 and raw[1] & 224 == 224))
def chunk(self, user, key, offset, content):
if not content or len(content) > CHUNK: raise self.a.Problem('文件分片大小不正确')
# At most two 4 MiB buffers; this lock covers only cloud upload, never the app DB.
with self.guard:
item = self.owned(user, 'journal_media', key)
if item['status'] != 'receiving' or not item.get('uploadId'): raise self.a.Problem('请重新选择文件继续上传', 409)
if offset < 0 or offset+len(content) > item['size']: raise self.a.Problem('文件分片位置不正确')
if offset < item['received']: return dict(self.public_media(item), received=item['received'])
for mid, value in list(self.buffers.items()):
if value['touched'] < time.time()-600: self.buffers.pop(mid)
if key not in self.buffers:
if len(self.buffers) >= 2: raise self.a.Problem('上传缓冲正在使用,请稍后重试', 429)
self.buffers[key] = dict(start=item['received'], data=bytearray(), touched=time.time())
buffer = self.buffers[key]; end = buffer['start']+len(buffer['data'])
if offset == end: buffer['data'].extend(content)
elif offset < end and buffer['data'][offset-buffer['start']:offset-buffer['start']+len(content)] == content: pass
else: raise self.a.Problem('分片顺序或内容不正确,请重新选择文件', 409)
buffer['touched'] = time.time(); end = buffer['start']+len(buffer['data'])
if len(buffer['data']) > 4194304: raise self.a.Problem('分片超出缓冲上限')
if buffer['start'] == 0 and not self.valid_signature(item['mime'], buffer['data'][:32]):
self.buffers.pop(key); raise self.a.Problem('文件内容与格式不符,请选择原始媒体文件')
if len(buffer['data']) == 4194304 or end == item['size']:
index = buffer['start']//4194304
self.a.BAIDU.send_block(item['remotePath'], item['uploadId'], index, bytes(buffer['data']), item['hashes'][index])
item.update(received=end, progress=round(end/item['size']*95), error='')
self.a.save_object('journal_media', item); self.buffers.pop(key)
return dict(self.public_media(item), received=end)
def finish_upload(self, user, key):
with self.guard:
item = self.owned(user, 'journal_media', key)
if item['status'] == 'ready': return self.public_media(item)
if item['status'] != 'receiving' or item['received'] != item['size']:
raise self.a.Problem('文件尚未完整上传,请重新选择原文件继续', 409)
result = self.a.BAIDU.complete(item)
item.update(status='ready', progress=100, fsId=str(result['fs_id']), error='')
self.a.save_object('journal_media', item)
return self.public_media(item)
def remove_upload(self, user, key):
with self.guard, self.a.LOCK:
item = self.owned(user, 'journal_media', key)
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)
return {'ok': True}
+3 -1
View File
@@ -1,5 +1,6 @@
"""Isolated regression tests: python -m unittest -v test_app."""
import hashlib
import atexit
import http.client
import io
import json
@@ -16,6 +17,7 @@ os.environ['VISION_DATA'] = _data.name
os.environ['VISION_RECORDINGS'] = _data.name + '/recordings'
import app
import playback
atexit.register(app.DB.close)
class WebTests(unittest.TestCase):
@@ -105,7 +107,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
self.assertEqual(len(assets), 25)
self.assertEqual(len(assets), 27)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
+1 -1
View File
@@ -73,7 +73,7 @@ class AppearanceTests(unittest.TestCase):
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]
saved=self.request('/api/households',self.owner,dict(id=self.aid,name='新名称',profileRevision=1))[1]
self.assertEqual(saved['city'],'烟台市');self.assertEqual(saved['coverData'],image_data())
for user,expected in [(self.members[self.aid],200),(self.members[self.bid],404)]:
c=http.client.HTTPConnection(*self.server.server_address)
+13
View File
@@ -80,3 +80,16 @@ class BackupTests(unittest.TestCase):
'2 1 0:2 / /mnt/cloud\\040drive rw - '+filesystem+' remote rw']
self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud drive/backups'),records))
self.assertFalse(Backups.linux_network_mount(PurePosixPath('/tmp'),['invalid data']))
def test_config_read_failure_always_releases_running_guard(self):
backup=self.source_file();backup.running=True
with patch.object(backup,'config',side_effect=RuntimeError('配置读取失败')):
result=backup.run()
self.assertFalse(backup.running);self.assertIn('配置读取失败',result['error'])
def test_baidu_success_is_separate_from_network_mount_and_requires_opt_in(self):
backup=self.source_file()
with patch.object(app.BAIDU,'upload',return_value={'fs_id':123}) as upload, patch.object(app.BAIDU,'public_config',return_value={'appFolder':'测试'}):
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')
+33
View File
@@ -0,0 +1,33 @@
const test=require('node:test'),assert=require('node:assert/strict'),crypto=require('node:crypto'),fs=require('node:fs');
const hash=require('./web/file-hash.js');
test('upload MD5 matches RFC vectors and a multi-block file',()=>{
for(const text of ['', 'a', 'abc', 'message digest', 'abcdefghijklmnopqrstuvwxyz', '照片与语音', 'x'.repeat(4194304)]){
const bytes=Buffer.from(text);assert.equal(hash.md5(bytes),crypto.createHash('md5').update(bytes).digest('hex'));
}
});
test('browser hashes in 4 MiB slices rather than loading the entire video',async()=>{
const file=new Blob([Buffer.alloc(4194304,1),Buffer.from('tail')]);const values=await hash.blocks(file);
assert.equal(values.length,2);assert.equal(values[1],crypto.createHash('md5').update('tail').digest('hex'));
await assert.rejects(hash.blocks(file,()=>{},()=>false),/取消/);
});
test('journals default to private and encode content before rendering',()=>{
const html=fs.readFileSync('web/index.html','utf8'),js=fs.readFileSync('web/journal.js','utf8');
assert.match(html,/<select name="visibility"><option value="private">/);assert.match(js,/esc\(e.text\)/);assert.match(js,/esc\(c.text\)/);
assert.doesNotMatch(js,/localStorage|indexedDB|caches\.open|access_token|secretKey.*localStorage/);
});
test('leave clears media URLs, live microphone tracks, and pending requests',()=>{
const js=fs.readFileSync('web/journal.js','utf8');assert.match(js,/journalRequest\?\.abort\(\)/);assert.match(js,/URL.revokeObjectURL/);assert.match(js,/getTracks\(\).forEach\(t=>t.stop\(\)\)/);
});
test('new entry clears hidden edit identifiers even when form.reset preserves them',()=>{
const vm=require('node:vm'),js=fs.readFileSync('web/journal.js','utf8');
const form={elements:{id:{value:'old-entry'},revision:{value:'9'},clientId:{value:'old-request'}},reset(){}};
const context={$:()=>form};vm.createContext(context);
vm.runInContext(js.match(/function clearJournalForm\(\)\{[^\n]+/)[0]+'\nclearJournalForm();',context);
for(const field of Object.values(form.elements))assert.equal(field.value,'');
assert.match(js,/function newJournal\(\)\{clearJournalForm\(\)/);
assert.match(js,/function resetJournal\(\).*clearJournalForm\(\)/);
});
test('initial composer setup happens before asynchronous loading and never clears a typed draft',()=>{
const js=fs.readFileSync('web/journal.js','utf8');const fn=js.slice(js.indexOf('async function loadJournal'),js.indexOf('function renderJournalUploads'));
assert.ok(fn.indexOf('newJournal()')<fn.indexOf('await api('));assert.equal(fn.match(/newJournal\(\)/g).length,1);
});
+192
View File
@@ -0,0 +1,192 @@
import hashlib
import http.client
import io
import json
import secrets
import time
import unittest
from unittest.mock import patch
from test_app import app
import test_households as fixture
from baidu import Baidu, download_url
from journal import Journal, CHUNK
class JournalTests(unittest.TestCase):
stop_server = fixture.HouseholdTests.stop_server
request = fixture.HouseholdTests.request
def setUp(self):
fixture.HouseholdTests.setUp(self)
for name, value in [('BAIDU', Baidu(app)), ('JOURNAL', Journal(app))]:
p = patch.object(app, name, value); p.start(); self.addCleanup(p.stop)
self.actor = self.members[self.aid]
def note(self, **changes):
return dict(text='今日记事\n第二行', occurredOn='2026-10-04', category='日常',
clientId=secrets.token_hex(16), visibility='private', revision=0, mediaIds=[], **changes)
def saved(self, user=None, visibility='private'):
body = self.note(); body['visibility'] = visibility
return app.JOURNAL.save(user or self.actor, body)['id']
def test_private_notes_are_not_readable_by_family_manager_or_superadmin(self):
key = self.saved()
self.assertEqual(len(app.JOURNAL.snapshot(self.actor, {'mode':'mine'})['items']), 1)
for user in (self.owner, self.root, self.other, self.members[self.bid]):
self.assertEqual(app.JOURNAL.snapshot(user, {'mode':'person','person':self.actor['id']})['items'], [])
self.assertEqual(self.request('/api/journal/action', user, dict(id=key,action='like',liked=True))[0], 404)
def test_shared_feed_history_search_and_date_filters_reference_the_same_record(self):
key = self.saved(visibility='household')
for mode in ('feed','person'):
result = app.JOURNAL.snapshot(self.owner, dict(mode=mode,person=self.actor['id'],search='第二行',category='日常',**{'from':'2026-10-01','to':'2026-10-31'}))
self.assertEqual(result['items'][0]['id'], key)
self.assertEqual(app.JOURNAL.snapshot(self.other, {'mode':'feed'})['total'], 0)
self.assertEqual(app.JOURNAL.snapshot(self.owner, {'from':'2026-10-05'})['total'], 0)
def test_account_cannot_forge_other_household_or_edit_anothers_note(self):
key = self.saved(visibility='household')
body = dict(app.get_object('journal_entries', key), text='stolen')
self.assertEqual(self.request('/api/journal', self.owner, body)[0], 403)
self.assertEqual(self.request('/api/journal', self.actor, self.note(), home=self.bid)[0], 403)
def test_creation_retry_and_edit_conflict(self):
body = self.note(); first = app.JOURNAL.save(self.actor, body); second = app.JOURNAL.save(self.actor, body)
self.assertEqual(first['id'], second['id']); self.assertTrue(second['duplicate'])
old = app.get_object('journal_entries', first['id'])
self.assertEqual(self.request('/api/journal', self.actor, dict(old,text='修改'))[0], 200)
self.assertEqual(self.request('/api/journal', self.actor, old)[0], 409)
def test_like_set_is_idempotent_and_privacy_change_removes_all_interactions(self):
key = self.saved(visibility='household'); data = dict(id=key,action='like',liked=True)
for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0], 200)
row = app.JOURNAL.snapshot(self.actor, {'mode':'mine'})['items'][0]; self.assertEqual(row['likes'],1)
app.JOURNAL.save(self.actor, dict(app.get_object('journal_entries', key),visibility='private'))
self.assertEqual(self.request('/api/journal/action', self.owner, dict(data,liked=False))[0],404)
def test_comments_allow_author_or_entry_owner_to_remove_but_no_other_member(self):
key = self.saved(visibility='household'); cid=secrets.token_hex(16)
data=dict(id=key,action='comment',clientId=cid,text='你好')
for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0],200)
self.assertEqual(len(app.objects('journal_comments')),1)
delete=dict(id=key,action='delete-comment',commentId=cid)
self.assertEqual(self.request('/api/journal/action', self.backup, delete)[0],403)
self.assertEqual(self.request('/api/journal/action', self.actor, delete)[0],200)
def test_unready_or_foreign_media_cannot_be_attached_and_unlinked_media_is_private(self):
media=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='ready',size=8,name='x.png',mime='image/png',at='now')
app.save_object('journal_media', media)
body=self.note();body['mediaIds']=[media['id']]
self.assertEqual(self.request('/api/journal', self.owner, body)[0],403)
with self.assertRaises(app.Problem): app.JOURNAL.media(self.owner, media['id'])
body['visibility']='household';key=app.JOURNAL.save(self.actor,body)['id']
self.assertEqual(app.JOURNAL.media(self.owner,media['id'])['id'],media['id'])
app.JOURNAL.action(self.actor,dict(id=key,action='delete',revision=1))
with self.assertRaises(app.Problem): app.JOURNAL.media(self.owner,media['id'])
def test_upload_sends_bounded_blocks_without_writing_any_media_file(self):
raw=b'\x89PNG\r\n\x1a\n'+b'a'*(CHUNK+20)
before=set(app.DATA.rglob('*'))
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[0]}), patch.object(app.BAIDU,'send_block') as send:
task=app.JOURNAL.begin_upload(self.actor,dict(name='x.png',size=len(raw),hashes=[hashlib.md5(raw).hexdigest()]))
first=app.JOURNAL.chunk(self.actor,task['id'],0,raw[:CHUNK]);self.assertEqual(first['received'],CHUNK)
app.JOURNAL.chunk(self.actor,task['id'],0,raw[:CHUNK]) # idempotent partial retry
app.JOURNAL.chunk(self.actor,task['id'],CHUNK,raw[CHUNK:]);send.assert_called_once()
self.assertEqual(send.call_args.args[3],raw)
self.assertEqual(set(app.DATA.rglob('*')), before)
self.assertFalse(app.JOURNAL.buffers)
def test_resume_after_restart_uses_cloud_missing_blocks(self):
data=dict(name='v.mp4',size=4194305,hashes=['a'*32,'b'*32])
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[1]}):
task=app.JOURNAL.begin_upload(self.actor,data)
self.assertEqual(task['received'],4194304)
again=app.JOURNAL.begin_upload(self.actor,dict(data,id=task['id']))
self.assertEqual(again['id'],task['id'])
def test_raw_upload_api_auth_and_foreign_user(self):
raw=b'\x89PNG\r\n\x1a\nabc'
with patch.object(app.BAIDU,'token',return_value='fake'), patch.object(app.BAIDU,'prepare',return_value={'uploadid':'u','block_list':[0]}):
task=app.JOURNAL.begin_upload(self.actor,dict(name='x.png',size=len(raw),hashes=[hashlib.md5(raw).hexdigest()]))
c=http.client.HTTPConnection(*self.server.server_address)
c.request('POST','/api/journal/chunk?id='+task['id']+'&offset=0',raw,{'Cookie':'vision='+self.tokens[self.owner['id']]})
r=c.getresponse();r.read();c.close();self.assertEqual(r.status,403)
def test_media_and_oauth_metadata_do_not_reveal_tokens_or_remote_paths(self):
self.assertEqual(self.request('/api/baidu/config',self.owner)[0],403)
self.assertEqual(self.request('/api/baidu/connect',self.owner,{})[0],403)
self.assertEqual(self.request('/api/baidu/disconnect',self.owner,{})[0],403)
body=app.JOURNAL.snapshot(self.actor,{'mode':'mine'})
self.assertNotIn('secretKey',json.dumps(body));self.assertNotIn('access_token',json.dumps(body))
def test_media_http_requires_visibility_and_preserves_range_without_cloud_redirect(self):
media=dict(id=secrets.token_hex(8),familyId=self.aid,ownerId=self.actor['id'],status='ready',
size=10,name='clip.mp4',mime='video/mp4',at='now',fsId='123',remotePath='/private/clip.mp4')
app.save_object('journal_media',media)
key=app.JOURNAL.save(self.actor,dict(self.note(),mediaIds=[media['id']]))['id']
path='/media/journal?id='+media['id']+'&family='+self.aid
def fetch(user):
c=http.client.HTTPConnection(*self.server.server_address)
headers={'Range':'bytes=2-4'}
if user: headers['Cookie']='vision='+self.tokens[user['id']]
c.request('GET',path,headers=headers);r=c.getresponse();result=(r.status,dict(r.getheaders()),r.read());c.close();return result
with patch.object(app.BAIDU,'open_file') as opened:
self.assertEqual(fetch(None)[0],401)
self.assertEqual(fetch(self.owner)[0],404)
opened.assert_not_called()
app.JOURNAL.save(self.actor,dict(app.get_object('journal_entries',key),visibility='household'))
response=io.BytesIO(b'234');response.status=206
response.headers={'Content-Length':'3','Content-Range':'bytes 2-4/10'};opened.return_value=response
status,headers,body=fetch(self.owner)
self.assertEqual((status,body),(206,b'234'));self.assertEqual(headers['Cache-Control'],'no-store')
self.assertEqual(headers['Content-Range'],'bytes 2-4/10');self.assertNotIn('Location',headers)
self.assertEqual(opened.call_args.args[2],'bytes=2-4')
def config(self):
return dict(appKey='fixtureKey',secretKey='fixtureSecret',appFolder='测试',redirectUri='https://example.org/api/baidu/callback',revision=0)
def test_config_revisions_callback_validation_and_secret_not_returned(self):
body=app.BAIDU.configure(self.config(),self.root)
self.assertTrue(body['hasSecret']);self.assertNotIn('secretKey',body)
with self.assertRaises(app.Problem): app.BAIDU.configure(self.config(),self.root)
for address in ('http://example.org/api/baidu/callback','https://example.org/other','https://a:b@example.org/api/baidu/callback'):
with self.assertRaises(app.Problem): app.BAIDU.configure(dict(self.config(),revision=1,redirectUri=address),self.root)
def test_oauth_state_cookie_session_and_replay_protection(self):
app.BAIDU.configure(self.config(),self.root)
result,cookie=app.BAIDU.begin(self.root,'media',self.tokens[self.root['id']],'example.org')
state=cookie.split(';')[0].split('=')[1]
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),'')
token=dict(access_token='test-token',refresh_token='test-refresh',expires=time.time()+1000)
with patch.object(app.BAIDU,'token_response',return_value=token),patch.object(app.BAIDU,'json_request',return_value={'uk':123,'baidu_name':'测试网盘'}):
app.BAIDU.callback(dict(state=state,code='x'),cookie)
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),cookie)
self.assertTrue(app.BAIDU.status(self.actor)['connected'])
self.assertNotIn('test-token',json.dumps(app.BAIDU.status(self.actor)))
def test_logged_out_or_demoted_oauth_cannot_bind_account(self):
app.BAIDU.configure(self.config(),self.root)
_,cookie=app.BAIDU.begin(self.root,'media',self.tokens[self.root['id']],'example.org')
state=cookie.split(';')[0].split('=')[1];app.DB.execute('DELETE FROM sessions');app.DB.commit()
with self.assertRaises(app.Problem): app.BAIDU.callback(dict(state=state,code='x'),cookie)
def test_download_redirect_targets_are_constrained(self):
for address in ('https://d.pcs.baidu.com/file/a','https://x.baidupcs.com/a'):
self.assertEqual(download_url(address),address)
for address in ('http://d.pcs.baidu.com/a','https://evil.test/a','https://baidu.com.evil.test/a','https://user@baidu.com/a','https://127.0.0.1/a'):
with self.assertRaises(ValueError): download_url(address)
def test_removed_attachment_and_invalid_text_never_publish(self):
bad=self.note();bad['text']=[]
self.assertEqual(self.request('/api/journal',self.actor,bad)[0],400)
bad=self.note();bad['occurredOn']='2026-99-99'
self.assertEqual(self.request('/api/journal',self.actor,bad)[0],400)
self.assertEqual(self.request('/api/journal',self.actor,dict(self.note(),visibility='public'))[0],400)
def test_household_profile_revision_preserves_independent_leadership(self):
home=app.get_object('households',self.aid)
data=dict(home,name='第一次修改')
self.assertEqual(self.request('/api/households',self.root,data)[0],200)
self.assertEqual(self.request('/api/households',self.root,data)[0],409)
self.assertEqual(app.get_object('households',self.aid)['name'],'第一次修改')
+6
View File
@@ -60,3 +60,9 @@ test('settings opened before login resolves reloads once after the account is kn
c.onAppearanceSession({values:{},defaults:{},revision:0});c.onAppearanceSession({values:{},defaults:{},revision:0});
assert.equal(loads,1);assert.equal(c.appearanceOwner,'root');
});
test('replacing or resetting an image invalidates older decode jobs',()=>{
const c=vm.createContext({pageInfo:{},document:{documentElement:{dataset:{}}}});
vm.runInContext(source.slice(0,source.indexOf('function applyBranding(')),c);
const first=c.nextImageJob('logo'),second=c.nextImageJob('logo');assert.equal(c.imageJobCurrent('logo',first),false);assert.equal(c.imageJobCurrent('logo',second),true);
c.invalidateBrandImages();assert.equal(c.imageJobCurrent('logo',second),false);
});
+6 -3
View File
@@ -25,6 +25,7 @@ function applyPermissions(){
$('#current-user').textContent=currentUser ? currentUser.name+' · '+roleName(currentUser) : '';
}
function clearPrivateView(){
if(typeof resetJournal==='function')resetJournal();
if(typeof resetAppearance==='function')resetAppearance();
if(typeof clearSnapshot==='function')clearSnapshot();
if(typeof resetDeviceView==='function')resetDeviceView();
@@ -57,17 +58,18 @@ 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(typeof leaveJournal==='function'&&activePage==='journal'&&page!=='journal')leaveJournal();
if(typeof leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings();
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings'].includes(page))return;
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings','journal'].includes(page))return;
if(['heritage','family','heritage-records'].includes(page)&&currentHome()?.kind&&currentHome().kind!=='home')return;
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings','journal'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
if(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage','settings'].includes(page);
if(page==='storage'&&!isPlatformAdmin())return;
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
activePage = page;
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings'].includes(page);
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings','journal'].includes(page);
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
@@ -80,6 +82,7 @@ function showPage(page, options={}) {
if (page === 'users') loadUsers();
if(page==='heritage-records')loadHeritage();
if(page==='settings'&&typeof loadSettings==='function')loadSettings();
if(page==='journal'&&typeof loadJournal==='function')loadJournal();
}
function render() {
$('#metric-all').textContent = state.assets.length+state.recorders.length+(state.devices||[]).length;
+2
View File
@@ -294,3 +294,5 @@ html[data-theme=ink] .home-cover .quiet{background:#172a25df;color:#fff}
html[data-theme=ink] body{background-image:radial-gradient(ellipse at 90% 0,#263c3340,transparent 65%)}
@media(min-width:761px){html[data-layout=top] .sidebar{position:relative;width:100%;padding:16px 30px;display:flex;align-items:center;flex-wrap:wrap;gap:12px 22px;min-height:0;height:auto;border-right:0;border-bottom:1px solid var(--line)}html[data-layout=top] .sidebar .brand{padding:0;max-width:250px}html[data-layout=top] .sidebar>.nav-label,html[data-layout=top] .side-note{display:none}html[data-layout=top] .sidebar nav{display:flex;flex:1;flex-wrap:wrap;min-width:250px;gap:6px}html[data-layout=top] #scope-navigation{display:flex;flex-wrap:wrap;width:100%;order:1;margin:0;padding:0;border:0}html[data-layout=top] #scope-navigation .nav-label{display:none}html[data-layout=top] .sidebar #current-user{margin:0;padding:0;border:0}html[data-layout=top] .sidebar>.quiet{margin:0}html[data-layout=top] main{margin-left:0;margin-right:auto;margin-left:auto}html[data-layout=top] .sidebar nav button{width:auto;white-space:nowrap}}
@media(max-width:760px){.brand-image-grid{grid-template-columns:1fr}.home-cover-content{padding:20px}.home-card-body{padding:20px}.home-cover-placeholder{font-size:15px}.sidebar #logout{display:block}#page-settings .form-grid{grid-template-columns:1fr}html[data-density=compact] main{padding:18px 14px}#household-form .form-grid{grid-template-columns:1fr}.home-city-group>h3 small{display:block;margin:8px 0}}
/* Personal journals: a quiet paper timeline with bounded media panels. */
.journal-feed{max-width:1080px;margin:0 auto}.journal-compose textarea{resize:vertical;min-height:130px}.journal-compose>label{display:grid;gap:12px}.journal-add-media,.journal-reactions{display:flex;flex-wrap:wrap;gap:12px;margin:20px 0}.journal-add-media>label{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.journal-add-media input{max-width:260px}.journal-filters{display:flex;align-items:end;flex-wrap:wrap;gap:16px}.journal-filters label{flex:1;min-width:145px;display:grid;gap:7px}.journal-entry{border-left:3px solid #a98964;margin-bottom:26px}.journal-entry .section-head small{display:block;color:var(--muted);margin-top:6px}.journal-text{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.95;font-size:17px}.journal-author{font-size:20px!important;font-family:serif}.journal-media{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:14px}.journal-media figure{margin:0;min-width:0}.journal-media img,.journal-media video{width:100%;max-height:450px;object-fit:contain;border-radius:8px;background:#1a2925}.journal-media audio{width:100%;max-width:100%}.journal-media figcaption{font-size:13px;overflow-wrap:anywhere;margin-top:6px}.journal-image-load{width:100%;min-height:150px;background:#e9eee2!important}.journal-upload{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;border-bottom:1px solid #d9d7c8;padding:12px 0}.journal-upload label{flex:1;overflow-wrap:anywhere}.journal-upload small{display:block}.journal-upload input[type=checkbox]{width:auto}.journal-resume input{max-width:220px}.journal-comments{border-top:1px solid #d9d7c8;padding-top:15px}.journal-comments form{display:flex;align-items:end;gap:12px;flex-wrap:wrap}.journal-comments form label{flex:1;min-width:170px}.journal-comment{padding:10px 0;border-bottom:1px dashed #d9d7c8;overflow-wrap:anywhere}.journal-comment small{font-size:11px;color:var(--muted)}.journal-reactions [aria-pressed=true]{color:#ad5842!important}.journal-comments .quiet{padding:4px 8px;font-size:12px}.journal-feed .panel{overflow:hidden}html[data-theme=ink] .journal-image-load{background:#293f37!important}
+37
View File
@@ -0,0 +1,37 @@
'use strict';
// MD5 is required by Baidu's upload protocol; it is not used for authentication.
const ZhaoFileHash=(()=>{
const shifts=[7,12,17,22,5,9,14,20,4,11,16,23,6,10,15,21];
const constants=Array.from({length:64},(_,i)=>Math.floor(Math.abs(Math.sin(i+1))*4294967296)|0);
function md5(input){
const bytes=new Uint8Array(input),length=bytes.length,padded=new Uint8Array(Math.ceil((length+9)/64)*64);
padded.set(bytes);padded[length]=128;const view=new DataView(padded.buffer);
view.setUint32(padded.length-8,(length*8)>>>0,true);view.setUint32(padded.length-4,Math.floor(length/536870912),true);
let h=[0x67452301,0xefcdab89|0,0x98badcfe|0,0x10325476];
for(let offset=0;offset<padded.length;offset+=64){
let [a,b,c,d]=h;
for(let i=0;i<64;i++){
let f,g;
if(i<16){f=(b&c)|(~b&d);g=i;}
else if(i<32){f=(d&b)|(~d&c);g=(5*i+1)%16;}
else if(i<48){f=b^c^d;g=(3*i+5)%16;}
else{f=c^(b|~d);g=(7*i)%16;}
const shift=shifts[Math.floor(i/16)*4+i%4],sum=(a+f+constants[i]+view.getInt32(offset+g*4,true))|0;
const next=(b+((sum<<shift)|(sum>>>(32-shift))))|0;a=d;d=c;c=b;b=next;
}
h=[(h[0]+a)|0,(h[1]+b)|0,(h[2]+c)|0,(h[3]+d)|0];
}
return h.map(n=>[0,8,16,24].map(s=>((n>>>s)&255).toString(16).padStart(2,'0')).join('')).join('');
}
async function blocks(file,progress=()=>{},valid=()=>true){
const result=[];
for(let offset=0;offset<file.size;offset+=4194304){
if(!valid())throw Object.assign(Error('操作已取消'),{stale:true});
result.push(md5(await file.slice(offset,offset+4194304).arrayBuffer()));progress(Math.min(file.size,offset+4194304),file.size);
await new Promise(resolve=>setTimeout(resolve,0));
}
return result;
}
return {md5,blocks};
})();
if(typeof module!=='undefined')module.exports=ZhaoFileHash;
+6 -5
View File
@@ -22,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||{});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();
nextImageJob('cover');fillForm($('#household-form'),{profileRevision:0,...(item||{})});coverDraft=undefined;$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='照片仅向有权进入该家庭或场所的账号开放。';$('#household-dialog').showModal();
}
async function openLeadership(id){
try{
@@ -46,8 +46,8 @@ $('#leadership-form').addEventListener('submit',async e=>{
$('#household-filter').onchange=async e=>{await switchHousehold(e.target.value);if(loggedIn)showPage('overview');};
$('#new-household').onclick=()=>openHousehold();
$('#household-form').addEventListener('submit',async e=>{
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{await api('/api/households',{...formValues(f),...(coverDraft!==undefined?{coverData:coverDraft}:{})});f.closest('dialog').close();await refresh();toast('归属档案已保存');}
e.preventDefault();if(imageJobPending('cover')){$('#household-cover-status').textContent='照片仍在读取,请完成后保存。';return;}const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
try{await api('/api/households',{...formValues(f),profileRevision:Number(f.elements.profileRevision.value||0),...(coverDraft!==undefined?{coverData:coverDraft}:{})});f.closest('dialog').close();await refresh();toast('归属档案已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}
finally{b.disabled=false;}
});
@@ -58,5 +58,6 @@ document.addEventListener('click',async e=>{
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='保存后使用默认封面。';};
$('#household-cover-file').onchange=async e=>{const job=nextImageJob('cover'),generation=sessionGeneration;imageJobs.get('cover').pending=true;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!imageJobCurrent('cover',job)||!$('#household-dialog').open)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){if(generation===sessionGeneration&&imageJobCurrent('cover',job))$('#household-cover-status').textContent=e.message;}finally{if(imageJobCurrent('cover',job))imageJobs.get('cover').pending=false;}};
$('#household-cover-reset').onclick=()=>{nextImageJob('cover');coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';};
$('#household-dialog').addEventListener('close',()=>nextImageJob('cover'));
+11 -2
View File
@@ -7,6 +7,7 @@
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script><script defer src="/steward.js?v=__ASSET_VERSION__"></script><script defer src="/heritage.js?v=__ASSET_VERSION__"></script><script defer src="/personalization.js?v=__ASSET_VERSION__"></script>
<script defer src="/file-hash.js?v=__ASSET_VERSION__"></script><script defer src="/journal.js?v=__ASSET_VERSION__"></script>
</head>
<body>
<aside class="sidebar">
@@ -15,6 +16,7 @@
<nav aria-label="主导航">
<button data-page="households" class="active">⌂ <span>我的家庭与场所</span></button>
<div id="scope-navigation" hidden><div id="scope-nav-name" class="nav-label"></div>
<button data-page="journal">✎ <span>记事与阅历</span></button>
<button data-page="overview">◫ <span>场所总览</span></button>
<button data-page="objects">▣ <span>区域与设备</span></button>
<button data-page="heritage">♧ <span>家庭传承</span></button>
@@ -36,15 +38,22 @@
<section id="page-settings" class="page" hidden>
<div class="section-head"><h2>把这里布置成你习惯的样子</h2><button id="settings-reload" class="quiet">重新读取设置</button></div><p id="settings-error" role="status"></p>
<form id="preferences-form" class="panel"><h3>我的后台</h3><p class="description">只影响当前账号,保存后换电脑或手机登录也会沿用。切换选项可以预览,离开未保存的预览会撤销。</p><div id="personal-choices" class="form-grid"></div><p id="preferences-status" role="status"></p><div class="device-actions"><button type="submit">保存我的偏好</button><button type="button" id="personal-follow" class="quiet">全部跟随平台</button><button type="button" id="personal-cancel" class="quiet">撤销预览</button></div></form>
<section id="baidu-admin" class="panel" hidden><div class="section-head"><h3>百度网盘 · 统一存储</h3><span class="badge">仅超级管理员配置</span></div><p class="description">连接一个网盘账号,保存各用户上传的照片、视频和语音。系统按用户、家庭和记事可见范围授权;网盘账号持有人本身可以管理网盘中的文件。服务器仅转发分片,不保存媒体原文件。</p><form id="baidu-config"><div class="form-grid"><label>AppKey<input name="appKey" autocomplete="off" maxlength="150" required></label><label>SecretKey<input name="secretKey" type="password" autocomplete="new-password" maxlength="150"></label><label>百度后台应用名称<input name="appFolder" value="智家" required maxlength="40"></label><label>OAuth 回调地址<input name="redirectUri" type="url" placeholder="https://你的域名/api/baidu/callback" required maxlength="400"></label></div><p class="description">回调地址须与百度后台完全一致。SecretKey 留空保持已保存值,SignKey 不需要填写。当前开发者应用仅限个人场景,适用范围以百度后台为准。</p><p id="baidu-config-status" role="status"></p><button type="submit">保存网盘应用配置</button></form><hr><p id="baidu-status" role="status"></p><div class="device-actions"><button id="baidu-connect">连接/重新授权百度网盘</button><button id="baidu-disconnect" class="quiet">解除本机连接</button></div><p class="description">解除连接不会删除云端文件。临时媒体地址由后台按需获取,令牌不发送给浏览器。网盘授权凭据单独保存在服务器受限目录,不包含在数据库备份或公开源码中。</p></section>
<div id="settings-private" hidden>
<form id="brand-form" class="panel"><h3>发布与品牌配置</h3><p class="description">仅超级管理员可修改。名称、Logo、浏览器图标及登录文案会公开显示;适用于整套系统。已有个人偏好优先于平台默认。</p>
<div class="form-grid"><label>系统名称<input name="name" required maxlength="40"></label><label>系统副标题<input name="subtitle" required maxlength="60"></label><label class="full">页首题词<input name="motto" maxlength="100"></label><label>登录标题<input name="loginTitle" required maxlength="40"></label><label>登录说明<input name="loginNote" maxlength="160"></label><label class="full">页脚说明<input name="footer" maxlength="120"></label></div>
<div class="brand-image-grid"><div><img id="brand-logo-preview" alt="Logo 预览" src="/zhao-icon.png"><label>更换系统 Logo<input id="brand-logo-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-logo-reset" class="quiet">使用默认 Logo</button></div><div><img id="brand-icon-preview" alt="浏览器图标预览" src="/zhao-icon.png"><label>更换浏览器图标<input id="brand-icon-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-icon-reset" class="quiet">使用默认图标</button></div></div><p class="description">支持 PNG、JPEG、WebP,原图最多 5 MB;上传时自动缩小,图片随系统数据库保存。</p>
<h3>新账号与跟随平台账号的默认外观</h3><div id="brand-defaults" class="form-grid"></div><p id="brand-status" role="status"></p><div class="device-actions"><button type="submit">保存发布配置</button><button type="button" id="brand-export" class="quiet">导出发布配置</button><label class="config-import">导入配置到表单<input id="brand-import" type="file" accept="application/json,.json"></label></div><p class="description">导入后先核对再保存。导出的配置只含品牌与默认外观,不含账号、设备凭据、家庭资料和网络备份目录。</p>
</form>
<section class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>每个目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。百度网盘需先有挂载或同步服务;网页地址、分享链接不能直接使用。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本系统生成的旧备份。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
<section class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接凭据和图片,因此仅超级管理员可操作。录像文件不包含在数据库副本中;恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label class="check full"><input name="baidu" type="checkbox">同时上传到已连接的百度网盘</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>每个目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。使用百度接口备份只需勾选上方选项;此路径用于其他已挂载的网络存储,不能填网页地址或分享链接。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本机和挂载目录中本系统生成的旧备份,百度云端副本需自行整理。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
</div>
</section>
<section id="page-journal" class="page" hidden>
<div class="scope-intro"><span class="eyebrow">岁月有迹 · 日常成章</span><h2>把生活,写成自己的来时路</h2><p>与家人分享近况,也为自己留下一份阅历。</p></div>
<form id="journal-form" class="panel journal-compose"><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="clientId"><label>此刻想记下什么?<textarea name="text" rows="4" maxlength="5000" placeholder="今天的心情、一段往事,或一个值得记住的时刻…"></textarea></label><div class="form-grid"><label>记事日期<input name="occurredOn" type="date" required></label><label>阅历分类<select name="category"><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>谁可以看到<select name="visibility"><option value="private">仅自己可见</option><option value="household">当前家庭/场所成员</option></select></label></div><div class="journal-add-media"><label class="quiet">+ 照片、视频或语音<input id="journal-files" disabled type="file" accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm,audio/mpeg,audio/mp4,audio/aac,audio/wav,audio/ogg,audio/webm,.m4a"></label><button id="journal-record" disabled type="button" class="quiet">● 直接录音</button><button id="journal-stop-record" type="button" hidden>结束录音并上传</button><button id="journal-cancel-upload" type="button" class="quiet" hidden>暂停上传</button></div><p id="journal-cloud-note" class="description"></p><p class="description">每条最多 9 个附件,单个文件最多 256 MB;一次上传一个文件。录音须通过 HTTPS 并允许麦克风,最长 5 分钟。上传完成后勾选附件再保存;已有附件可重复引用。</p><div id="journal-upload-list"></div><p id="journal-status" role="status" aria-live="polite"></p><div class="device-actions"><button id="journal-save" type="submit">保存记事</button><button id="journal-new" type="button" class="quiet">另写一条</button></div></form>
<form id="journal-filter" class="panel journal-filters"><label>查看方式<select id="journal-mode"><option value="feed">家庭/场所动态</option><option value="mine">我的个人阅历</option><option value="person">某个人的阅历</option></select></label><label id="journal-person-label" hidden>人物<select id="journal-person"></select></label><label>分类<select id="journal-category"><option value="">全部分类</option><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>关键词<input id="journal-search" type="search" maxlength="120" placeholder="在文字中查找"></label><label>开始日期<input id="journal-from" type="date"></label><label>结束日期<input id="journal-to" type="date"></label><button type="submit">查询阅历</button></form><p class="description">个人阅历保留同一条记事的文字、附件和互动;查看他人时只展示其分享给当前家庭/场所的内容。删除记事或移出附件列表不会删除网盘原文件。</p><div class="section-head"><p id="journal-query-status" role="status"></p><div><button id="journal-prev" class="quiet">上一页</button><button id="journal-next" class="quiet">下一页</button></div></div><div id="journal-list" class="journal-feed"></div>
</section>
<section id="page-overview" class="page" hidden><div id="scope-overview"></div></section><section id="page-device" class="page" hidden><div id="device-detail"></div></section><section id="page-heritage" class="page" hidden><div id="heritage-home"></div></section>
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-playback" class="page" hidden>
@@ -93,7 +102,7 @@
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><label>省份<input name="province" maxlength="60" placeholder="例如:山东省"></label><label>地级市<input name="city" maxlength="60" placeholder="例如:烟台市"></label><label>区 / 县 / 县级市<input name="district" maxlength="60" placeholder="例如:莱阳市"></label><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><input name="profileRevision" type="hidden" value="0"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><label>省份<input name="province" maxlength="60" placeholder="例如:山东省"></label><label>地级市<input name="city" maxlength="60" placeholder="例如:烟台市"></label><label>区 / 县 / 县级市<input name="district" maxlength="60" placeholder="例如:莱阳市"></label><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>文化与财产档案权限<select name="heritageAccess"><option value="none">不可访问</option><option value="read">查看资料</option><option value="edit">整理与维护</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<datalist id="relationship-choices"></datalist>
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
+142
View File
@@ -0,0 +1,142 @@
'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 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 stopJournalVoice(discard=true){
if(discard)journalVoice=[];if(journalRecorder&&journalRecorder.state!=='inactive')journalRecorder.stop();
journalTracks?.getTracks().forEach(t=>t.stop());journalTracks=null;clearTimeout(journalVoiceTimer);journalVoiceTimer=null;
$('#journal-record').disabled=false;$('#journal-stop-record').hidden=true;
}
function leaveJournal(){
journalGeneration++;journalRequest?.abort();journalRequest=null;journalBusy(false);stopJournalVoice();
$$('#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;
$('#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();}
async function loadJournal(){
const g=++journalGeneration;if(!loggedIn)return;
if(!$('#journal-form').elements.clientId.value)newJournal();
$('#journal-person-label').hidden=$('#journal-mode').value!=='person';
const query=new URLSearchParams({mode:$('#journal-mode').value,person:$('#journal-person').value,category:$('#journal-category').value,search:$('#journal-search').value,from:$('#journal-from').value,to:$('#journal-to').value,page:String(journalPage)});
$('#journal-query-status').textContent='正在读取记事…';
try{
const result=await api('/api/journal?'+query);if(!journalValid(g))return;journalData=result;
const selected=$('#journal-person').value;$('#journal-person').innerHTML='<option value="">请选择人物</option>'+Object.entries(result.people).map(([id,name])=>`<option value="${esc(id)}">${esc(name)}</option>`).join('');$('#journal-person').value=selected;
$('#journal-query-status').textContent=result.total?`共 ${result.total} 条 · 第 ${result.page+1} 页`:'暂时没有记事。写下今天,也可以补记从前。';
$('#journal-prev').disabled=!result.page;$('#journal-next').disabled=(result.page+1)*20>=result.total;
$('#journal-cloud-note').textContent=result.connection.connected&&!result.connection.reconnect?'附件保存到后台统一连接的百度网盘;可见范围由系统控制。':'文字可直接保存。照片、视频、语音需由超级管理员先连接百度网盘。';
$('#journal-files').disabled=journalUpload||!result.connection.connected||result.connection.reconnect;
$('#journal-record').disabled=$('#journal-files').disabled||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;
renderJournalUploads();renderJournalEntries();
}catch(e){if(!e.stale&&journalValid(g))$('#journal-query-status').textContent=e.message;}
}
function 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=>`<div class="journal-upload"><label><input type="checkbox" value="${esc(m.id)}" ${m.status==='ready'?'':'disabled'} ${selected.has(m.id)?'checked':''}>${esc(m.name)} <small>${Math.ceil(m.size/1024)} KB · ${m.status==='ready'?'已存百度网盘':m.status==='receiving'?'等待继续上传':'已失效'}</small></label>${m.status==='receiving'?`<label class="quiet journal-resume">选择原文件继续<input type="file" data-journal-resume="${esc(m.id)}"></label>`:''}<button type="button" class="quiet" data-remove-upload="${esc(m.id)}">移出列表</button></div>`).join('');
}
function renderJournalEntries(){
for(const value of journalImages.values())URL.revokeObjectURL(value.url);journalImages.clear();journalImageBytes=0;
$('#journal-list').innerHTML=journalData.items.map(e=>`<article class="panel journal-entry" data-entry="${esc(e.id)}"><div class="section-head"><div><button class="quiet journal-author" data-journal-person="${esc(e.ownerId)}">${esc(e.authorName)}</button><small>${esc(e.occurredOn)} · ${esc(e.category)} · ${e.visibility==='private'?'仅自己可见':'当前家庭/场所'}</small></div>${e.canEdit?`<div><button class="quiet" data-journal-edit="${esc(e.id)}">编辑</button><button class="quiet" data-journal-delete="${esc(e.id)}">删除</button></div>`:''}</div><p class="journal-text">${esc(e.text)}</p><div class="journal-media">${e.media.map(m=>m.mime.startsWith('image/')?`<figure><button class="journal-image-load quiet" data-load-photo="${esc(m.id)}" data-size="${m.size}">查看照片 · ${esc(m.name)}</button><img hidden alt="${esc(m.name)}" loading="lazy"></figure>`:`<figure><${m.mime.startsWith('audio/')?'audio':'video'} controls preload="none" playsinline src="${esc(journalMediaUrl(m.id))}"></${m.mime.startsWith('audio/')?'audio':'video'}><figcaption>${esc(m.name)} <a href="${esc(journalMediaUrl(m.id,true))}">下载原文件</a></figcaption><small>点击播放后缓冲;编码不兼容时可下载原文件。</small></figure>`).join('')}</div><div class="journal-reactions"><button class="quiet" data-journal-like="${esc(e.id)}" aria-pressed="${e.liked}">${e.liked?'♥ 已赞':'♡ 点赞'} · ${e.likes}</button><button class="quiet" data-journal-person="${esc(e.ownerId)}">查看个人阅历</button></div><details class="journal-comments"><summary>评论 <span data-comment-count>${e.commentCount}</span></summary><div data-comments>${e.comments.map(c=>journalComment(e,c)).join('')}</div><form data-journal-comment="${esc(e.id)}"><label>写评论<input name="text" maxlength="500" required autocomplete="off"></label><button type="submit">发送</button><span class="form-error" role="status"></span></form></details></article>`).join('');
}
function journalComment(entry,c){return `<p class="journal-comment"><strong>${esc(c.authorName)}</strong> ${esc(c.text)} <small>${esc(new Date(c.createdAt).toLocaleString())}</small>${c.canDelete?` <button class="quiet" data-delete-comment="${esc(c.id)}" data-journal-entry="${esc(entry.id)}">删除评论</button>`:''}</p>`;}
$('#journal-form').addEventListener('submit',async event=>{
event.preventDefault();if(journalUpload){journalMessage('请等当前附件上传完成,或取消上传后保存文字。');return;}
const f=event.currentTarget,g=journalGeneration,button=$('#journal-save');button.disabled=true;
try{await api('/api/journal',{...formValues(f),revision:Number(f.elements.revision.value||0),mediaIds:[...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value)});if(!journalValid(g))return;newJournal();$('#journal-mode').value='mine';journalPage=0;await loadJournal();journalMessage('已保存,动态与个人阅历使用同一条记录。');}
catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{if(journalValid(g)||activePage==='journal')button.disabled=false;}
});
$('#journal-new').onclick=newJournal;
$('#journal-filter').onsubmit=e=>{e.preventDefault();journalPage=0;loadJournal();};
$('#journal-mode').onchange=()=>{$('#journal-person-label').hidden=$('#journal-mode').value!=='person';journalPage=0;loadJournal();};
$('#journal-prev').onclick=()=>{journalPage--;loadJournal();};$('#journal-next').onclick=()=>{journalPage++;loadJournal();};
async function uploadJournalFile(file,resume=''){
if(!file||journalUpload)return;if(!journalData?.connection.connected){journalMessage('请先由超级管理员连接百度网盘。');return;}if(file.size>268435456||!file.size){journalMessage('单个文件须为 1 字节至 256 MB。');return;}
journalBusy(true);const g=journalGeneration;$('#journal-save').disabled=true;$('#journal-files').disabled=true;$('#journal-cancel-upload').hidden=false;
try{
const hashes=await ZhaoFileHash.blocks(file,(n,total)=>journalMessage('正在计算文件校验:'+Math.round(n/total*100)+'%'),()=>journalValid(g)&&journalUpload);
if(!journalValid(g)||!journalUpload)return;
let task=await api('/api/journal/upload',{id:resume,name:file.name,size:file.size,mime:file.type,hashes});
if(!journalValid(g)||!journalUpload)return;
for(let offset=task.received;offset<file.size;){
if(!journalValid(g)||!journalUpload)return;
journalRequest=new AbortController();
const response=await fetch('/api/journal/chunk?'+new URLSearchParams({id:task.id,offset:String(offset)}),{method:'POST',headers:{'Content-Type':'application/octet-stream','X-Household-Id':state.familyId},body:file.slice(offset,Math.min(offset+524288,file.size)),signal:journalRequest.signal});
const result=await response.json();if(!response.ok)throw Error(result.error||'上传未完成');
if(!journalValid(g)||!journalUpload)return;
if(result.received<=offset)throw Error('上传进度未前进,请选择原文件继续');offset=result.received;journalMessage('正在上传百度网盘:'+Math.round(offset/file.size*95)+'%');
}
await api('/api/journal/finish',{id:task.id});if(!journalValid(g)||!journalUpload)return;
journalUpload=false;await loadJournal();const input=$('#journal-upload-list').querySelector(`input[value="${task.id}"]`);if(input)input.checked=true;journalMessage('附件已保存到百度网盘。填写内容和可见范围后保存记事。');
}catch(e){if(!e.stale&&journalValid(g)){journalMessage(e.name==='AbortError'?'已暂停上传,可选择原文件继续。':e.message);journalUpload=false;const message=$('#journal-status').textContent;await loadJournal();journalMessage(message);}}
finally{journalBusy(false);journalRequest=null;if(activePage==='journal'){$('#journal-save').disabled=false;$('#journal-files').disabled=!journalData?.connection.connected;$('#journal-cancel-upload').hidden=true;}}
}
$('#journal-files').onchange=e=>{uploadJournalFile(e.target.files[0]);e.target.value='';};
$('#journal-cancel-upload').onclick=()=>{journalBusy(false);journalRequest?.abort();journalMessage('已暂停。重新选择原文件可继续上传。');};
$('#journal-record').onclick=async()=>{
if(journalUpload||!journalData?.connection.connected)return;
const g=journalGeneration;try{
const tracks=await navigator.mediaDevices.getUserMedia({audio:true});if(!journalValid(g)){tracks.getTracks().forEach(t=>t.stop());return;}
journalTracks=tracks;journalVoice=[];const type=['audio/webm;codecs=opus','audio/mp4','audio/ogg;codecs=opus'].find(t=>MediaRecorder.isTypeSupported(t));
if(!type){stopJournalVoice();throw Error('当前浏览器不能直接录音,可上传已有音频文件。');}
const recorder=new MediaRecorder(tracks,{mimeType:type});journalRecorder=recorder;
recorder.ondataavailable=e=>{if(journalValid(g)&&e.data.size)journalVoice.push(e.data);};
recorder.onstop=()=>{if(journalValid(g)&&journalVoice.length){const blob=new Blob(journalVoice,{type}),extension=type.includes('mp4')?'m4a':type.includes('ogg')?'ogg':'webm';uploadJournalFile(new File([blob],'语音-'+Date.now()+'.'+extension,{type}));}journalVoice=[];};
recorder.start();$('#journal-record').disabled=true;$('#journal-stop-record').hidden=false;journalMessage('正在录音,最长 5 分钟。点击结束后上传。');journalVoiceTimer=setTimeout(()=>stopJournalVoice(false),300000);
}catch(e){if(journalValid(g))journalMessage(e.name==='NotAllowedError'?'未获得麦克风权限,可上传已有语音文件。':e.message);}
};
$('#journal-stop-record').onclick=()=>stopJournalVoice(false);
document.addEventListener('change',e=>{if(e.target.dataset.journalResume){uploadJournalFile(e.target.files[0],e.target.dataset.journalResume);e.target.value='';}});
document.addEventListener('click',async event=>{
const b=event.target.closest('button');if(!b)return;const g=journalGeneration;
if(b.dataset.journalPerson){$('#journal-mode').value='person';$('#journal-person-label').hidden=false;$('#journal-person').value=b.dataset.journalPerson;journalPage=0;loadJournal();return;}
if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];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;
}
const id=b.dataset.journalLike||b.dataset.journalDelete||b.dataset.journalEntry;
if(!id&&!b.dataset.removeUpload)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;}
const entry=journalData.items.find(e=>e.id===id);if(!entry)return;
if(b.dataset.journalLike){await api('/api/journal/action',{id,action:'like',liked:!entry.liked});if(!journalValid(g))return;entry.liked=!entry.liked;entry.likes+=entry.liked?1:-1;b.textContent=(entry.liked?'♥ 已赞':'♡ 点赞')+' · '+entry.likes;b.setAttribute('aria-pressed',String(entry.liked));}
if(b.dataset.journalDelete){if(!confirm('删除这条记事?网盘原文件保留。'))return;await api('/api/journal/action',{id,action:'delete',revision:entry.revision});if(journalValid(g))loadJournal();}
if(b.dataset.deleteComment){await api('/api/journal/action',{id,action:'delete-comment',commentId:b.dataset.deleteComment});if(journalValid(g)){b.closest('.journal-comment').remove();entry.commentCount--;document.querySelector(`[data-entry="${id}"] [data-comment-count]`).textContent=entry.commentCount;}}
}catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{b.disabled=false;}
});
document.addEventListener('submit',async event=>{
const f=event.target;if(!f.dataset.journalComment)return;event.preventDefault();const id=f.dataset.journalComment,g=journalGeneration,b=f.querySelector('button');b.disabled=true;const text=f.elements.text.value;
f.dataset.clientId ||= journalId();
try{await api('/api/journal/action',{id,action:'comment',text,clientId:f.dataset.clientId});if(!journalValid(g))return;
const entry=journalData.items.find(e=>e.id===id),comment={id:f.dataset.clientId,text,authorName:currentUser.name,createdAt:new Date().toISOString(),canDelete:true};
if(!entry.comments.some(c=>c.id===comment.id)){entry.comments.push(comment);entry.commentCount++;f.parentElement.querySelector('[data-comments]').insertAdjacentHTML('beforeend',journalComment(entry,comment));f.parentElement.querySelector('[data-comment-count]').textContent=entry.commentCount;}
f.reset();delete f.dataset.clientId;f.querySelector('.form-error').textContent='';
}catch(e){if(!e.stale&&journalValid(g))f.querySelector('.form-error').textContent=e.message;}finally{b.disabled=false;}
});
async function loadBaiduSettings(){
const g=appearanceGeneration;$('#baidu-admin').hidden=!isPlatformAdmin();if(!isPlatformAdmin())return;
try{const [config,status]=await Promise.all([api('/api/baidu/config'),api('/api/baidu/status')]);if(g!==appearanceGeneration||activePage!=='settings')return;
const f=$('#baidu-config');for(const k of ['appKey','appFolder','redirectUri'])f.elements[k].value=config[k];f.elements.secretKey.value='';f.elements.secretKey.placeholder=config.hasSecret?'已保存,留空保持原值':'填写 SecretKey';journalConfigRevision=config.revision;
$('#baidu-status').textContent=status.connected?(status.reconnect?'网盘授权已失效,请重新连接。':'已连接统一网盘:'+status.account):'尚未连接百度网盘';$('#baidu-connect').disabled=!config.configured;$('#baidu-disconnect').disabled=!status.connected;
}catch(e){if(!e.stale&&g===appearanceGeneration)$('#baidu-status').textContent=e.message;}
}
$('#baidu-config').onsubmit=async e=>{e.preventDefault();const g=appearanceGeneration,f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api('/api/baidu/config',{...formValues(f),revision:journalConfigRevision});if(g!==appearanceGeneration)return;f.elements.secretKey.value='';await loadBaiduSettings();$('#baidu-config-status').textContent='应用配置已保存,下一步连接百度网盘。';}catch(e){if(!e.stale&&g===appearanceGeneration)$('#baidu-config-status').textContent=e.message;}finally{b.disabled=false;}};
$('#baidu-connect').onclick=async()=>{try{const result=await api('/api/baidu/connect',{});const address=new URL(result.url);if(address.origin!=='https://openapi.baidu.com')throw Error('授权地址不正确');location.assign(result.url);}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
$('#baidu-disconnect').onclick=async()=>{if(!confirm('解除本机网盘连接后,媒体将暂时无法访问;网盘文件保留。确认解除?'))return;try{await api('/api/baidu/disconnect',{});loadBaiduSettings();}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
document.addEventListener('DOMContentLoaded',()=>{const result=new URLSearchParams(location.search).get('baidu');if(result){history.replaceState(null,'',location.pathname);toast(result==='ok'?'网盘授权已保存,请在设置中心查看连接状态。':'授权未完成,请检查回调地址、原登录状态,并重新连接。');}},{once:true});
+17 -9
View File
@@ -5,6 +5,11 @@ const initialDefaults={theme:'paper',layout:'sidebar',density:'comfortable',back
let branding={name:'赵府',subtitle:'智能数字管家',motto:'赵氏家宅 · 起居有序',loginTitle:'欢迎回来',loginNote:'输入用户名和密码,进入家庭空间。',footer:'赵府 · 智能数字管家',logoUrl:'/zhao-icon.png',iconUrl:'/zhao-icon.png',defaults:initialDefaults};
let personalValues=Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default'])),personalRevision=0,appearanceOwner='',appearanceGeneration=0,settingsTimer=null;
let brandDraft=null,brandLogo='',brandIcon='',backupRevision=0,coverDraft;
const imageJobs=new Map();
function nextImageJob(key){const value=(imageJobs.get(key)?.value||0)+1;imageJobs.set(key,{value,pending:false});return value;}
function imageJobCurrent(key,value){return imageJobs.get(key)?.value===value;}
function imageJobPending(key){return !!imageJobs.get(key)?.pending;}
function invalidateBrandImages(){nextImageJob('logo');nextImageJob('icon');nextImageJob('import');}
pageInfo.settings=['设置中心','发布配置统一管理,每个账号保留自己的使用习惯。'];
function effectiveAppearance(values=personalValues,defaults=branding.defaults){return Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,appearanceChoices[k][values[k]]?values[k]:appearanceChoices[k][defaults?.[k]]?defaults[k]:initialDefaults[k]]));}
function applyAppearance(values=personalValues,defaults=branding.defaults){
@@ -34,6 +39,7 @@ function onAppearanceSession(preferences){
}
}
function resetAppearance(){
invalidateBrandImages();nextImageJob('cover');
appearanceGeneration++;clearTimeout(settingsTimer);settingsTimer=null;appearanceOwner='';brandDraft=null;brandLogo='';brandIcon='';coverDraft=undefined;
personalValues=Object.fromEntries(Object.keys(appearanceChoices).map(k=>[k,'default']));personalRevision=0;applyAppearance();
for(const selector of ['#brand-form','#preferences-form','#backup-form'])$(selector).reset();
@@ -44,6 +50,7 @@ function appearanceFields(container,values,inherit){
for(const k of Object.keys(appearanceChoices))container.querySelector(`[name="${k}"]`).value=values[k];
}
function fillBrandForm(value){
invalidateBrandImages();
brandDraft=value;brandLogo=value.logoData||'';brandIcon=value.iconData||'';
const form=$('#brand-form');form.reset();
for(const k of ['name','subtitle','motto','loginTitle','loginNote','footer'])form.elements[k].value=value[k];
@@ -60,6 +67,7 @@ async function loadSettings(){
personalValues=preferences.values;personalRevision=preferences.revision;branding.defaults=preferences.defaults;
appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();
if(config){fillBrandForm(config);fillBackup(backup);$('#settings-private').hidden=false;}
if(typeof loadBaiduSettings==='function')loadBaiduSettings();
$('#settings-error').textContent='';
}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#settings-error').textContent=e.message;}
}
@@ -89,25 +97,25 @@ async function readImage(file,maxSide=512){
}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;}};
$('#'+id).onchange=async e=>{const generation=appearanceGeneration,job=nextImageJob(type);nextImageJob('import');imageJobs.get(type).pending=true;try{const image=await readImage(e.target.files[0],max);if(generation!==appearanceGeneration||!imageJobCurrent(type,job))return;if(type==='logo')brandLogo=image;else brandIcon=image;renderBrandImages();$('#brand-status').textContent='图片已准备,保存发布配置后使用。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent(type,job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent(type,job))imageJobs.get(type).pending=false;}};
}
$('#brand-logo-reset').onclick=()=>{brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{brandIcon='';renderBrandImages();};
$('#brand-logo-reset').onclick=()=>{nextImageJob('logo');nextImageJob('import');brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{nextImageJob('icon');nextImageJob('import');brandIcon='';renderBrandImages();};
function brandPayload(){const form=$('#brand-form');return {...Object.fromEntries(['name','subtitle','motto','loginTitle','loginNote','footer'].map(k=>[k,form.elements[k].value])),defaults:readAppearanceFields($('#brand-defaults')),logoData:brandLogo,iconData:brandIcon,revision:brandDraft?.revision};}
$('#brand-form').addEventListener('submit',async e=>{
e.preventDefault();const b=e.currentTarget.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;
e.preventDefault();if(imageJobPending('logo')||imageJobPending('icon')||imageJobPending('import')){$('#brand-status').textContent='图片或配置仍在读取,请完成后保存。';return;}const controls=[...e.currentTarget.elements],generation=appearanceGeneration;for(const control of controls)control.disabled=true;
try{const result=await api('/api/appearance',brandPayload());if(generation!==appearanceGeneration)return;fillBrandForm(result);await loadBranding();if(generation!==appearanceGeneration)return;$('#brand-status').textContent='发布配置已保存。已有个人偏好继续保留。';}
catch(e){if(!e.stale&&generation===appearanceGeneration)$('#brand-status').textContent=e.message;}finally{b.disabled=false;}
catch(e){if(!e.stale&&generation===appearanceGeneration)$('#brand-status').textContent=e.message;}finally{for(const control of controls)control.disabled=false;}
});
$('#brand-export').onclick=()=>{if(!brandDraft)return;const {revision,...value}=brandPayload(),blob=new Blob([JSON.stringify({format:'digital-steward-appearance',version:1,config:value},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='digital-steward-appearance.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
$('#brand-import').onchange=async e=>{const generation=appearanceGeneration;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?'本机和网络目录副本均已校验':'本机备份已校验';}
$('#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 renderBackup(value){
$('#backup-run').disabled=value.running;$('#backup-result').textContent=value.running?'正在生成数据库副本…':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:'');
$('#backup-local-path').textContent='本机目录:'+value.localPath;
$('#backup-history').innerHTML=value.history.map(r=>`<article class="backup-row"><strong>${esc(backupLabel(r))}</strong><span>${esc(new Date(r.at).toLocaleString('zh-CN'))}</span><small>${esc(r.file||'')} ${r.bytes?Math.round(r.bytes/1024)+' KB':''}</small>${r.sha256?`<details><summary>完整性校验值</summary><code>${esc(r.sha256)}</code></details>`:''}</article>`).join('');
clearTimeout(settingsTimer);if(value.running&&activePage==='settings')settingsTimer=setTimeout(pollBackup,2000);
}
async function pollBackup(){const generation=appearanceGeneration;try{const data=await api('/api/backups');if(generation===appearanceGeneration&&activePage==='settings')renderBackup(data);}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-result').textContent=e.message;}}
$('#backup-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;try{const data=await api('/api/backups/config',{revision:backupRevision,enabled:f.elements.enabled.checked,time:f.elements.time.value,keep:Number(f.elements.keep.value),networkPath:f.elements.networkPath.value.trim()});if(generation!==appearanceGeneration)return;fillBackup(data);$('#backup-save-status').textContent='备份设置已保存。';}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-save-status').textContent=e.message;}finally{b.disabled=false;}});
$('#backup-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]'),generation=appearanceGeneration;b.disabled=true;try{const data=await api('/api/backups/config',{revision:backupRevision,enabled:f.elements.enabled.checked,baidu:f.elements.baidu?.checked||false,time:f.elements.time.value,keep:Number(f.elements.keep.value),networkPath:f.elements.networkPath.value.trim()});if(generation!==appearanceGeneration)return;fillBackup(data);$('#backup-save-status').textContent='备份设置已保存。';}catch(e){if(!e.stale&&generation===appearanceGeneration)$('#backup-save-status').textContent=e.message;}finally{b.disabled=false;}});
$('#backup-run').onclick=async()=>{const generation=appearanceGeneration;$('#backup-run').disabled=true;try{await api('/api/backups/run',{});if(generation===appearanceGeneration)pollBackup();}catch(e){if(!e.stale&&generation===appearanceGeneration){$('#backup-result').textContent=e.message;$('#backup-run').disabled=false;}}};