Compare commits

..

5 Commits

45 changed files with 1416 additions and 139 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
data/
/data/
recordings/
runtime/
*.db*
+34
View File
@@ -1,5 +1,39 @@
# 赵府智家 · 视觉系统
## v0.1.32 · 真实网盘验收与保存反馈
- 修复家庭档案已保存、后续列表刷新失败时提示不可见的问题;立即提示保存结果,刷新失败单独提示,无需重复提交。
- 网盘设置中展示封面、平台外观、个人记事、数据库备份四类存储说明;修正“全部本地存储”的旧文案。
- 异常国家编码类型统一返回字段校验错误,不再触发服务器异常,失败保留原档案。
- 真实百度网盘验收:在应用内“验收资料”目录上传 100867 字节合成图片,清空进程缓存后读回校验一致;重复上传返回相同文件 ID。正式家庭的已上传封面也通过独立回读及浏览器刷新、重新打开验证。验收不修改正式家庭档案。
- 手机宽度检查覆盖已上传封面与编辑对话框。自动文件选择器仍受 Edge 扩展文件 URL 权限限制,不将接口上传或用户自行上传等同为自动选文件验收。
验证:150 项 Python、120 项 JavaScript 回归通过。真实网盘和正式封面回读校验通过;360px 网盘说明、390px 封面及对话框无横向溢出。上线前备份数据库,发布源码后部署。
## v0.1.31 · 地域分级选择与网盘图片
- 家庭/场所、空间和家谱籍贯/出生地共用地域控件:中国省市区联动、世界国家/地区选择、自定义历史地名或其他天体。更换上级清空下级;服务端以编码校验父子关系。旧地名无法匹配时原样保留为自定义,权限仍按家庭/场所划分。
- 离线内置中国 2023-06-30 区划快照,来源 [Administrative-divisions-of-China](https://github.com/modood/Administrative-divisions-of-China),该项目不再更新。港澳台下级及新设区划可手填或自定义;世界下级行政区不声称完整收录。国家/地区名称来源 [Unicode CLDR](https://github.com/unicode-org/cldr-json),许可证随数据打包于 web/data/GEOGRAPHY-LICENSES.txt。
- 封面支持 30 MiB 内、8000 万像素内的 PNG/JPEG/WebP 原图,由浏览器缩小至最长 960px、至多 600 KiB 后上传。读取失败阻止保存;服务端确认网盘上传并回读校验后才保存引用。保存失败保持原档案,再次打开按持久化引用加载。
- 所有新选择的封面、Logo、图标存百度网盘。数据库只保存 provider/fsId/remotePath/size/sha256;短时图片缓存仅在内存,最多 16 MiB、10 分钟。文字、人员与家庭权限仍由本系统管理;图片地址经鉴权代理,不暴露网盘凭据。默认内置标识仍是程序资源。
- 网盘分类:`家庭与场所/<实例>/<家庭>/封面`、`平台外观/<实例>/platform/Logo或图标`、`个人记事/<实例>/<家庭>/<用户>/照片或视频或语音`。数据库备份保留原独立目录与一个自然月云端保留策略。旧记事附件链接继续有效,不移动或删除云端原文件。
- 支持显式迁移旧封面和品牌图;每张上传回读校验成功后才移除数据库中的内嵌图片。已产生的历史数据库备份按原保留策略自然过期。外观导出保留文字与版式,已上传网盘图片不嵌入导出文件。
验证:148 项 Python、116 项 JavaScript 回归通过。Edge 实测中国联动清空、世界地址保存与回填、自定义火星路径、390px 对话框无横向溢出、接口上传后刷新重开封面。自动文件选择受 Edge 扩展“允许访问文件 URL”权限限制;大图读取/压缩与失败阻止保存由单元测试覆盖,图片上传回读由接口测试覆盖,不将这些测试混同为真实文件选择器上传成功。
## v0.1.30 · 日常操作流程与手机网页
- 家庭或办公、工厂是权限归属;内部区域可分多级。设备提供按区域、类型、负责人三个快捷视图,并保留自定义组合。搜索支持“卧室 灯”等多词组合,涵盖区域、品牌、人员与标签。
- 总览补充记事入口;区域管理收进设备页;设备返回列表,家谱与家族记事返回家庭传承。区分个人日常记事与家族重大记事,办公场所不显示家族传承。
- 修复编辑后新建残留隐藏记录编号的问题。新建区域可选择新增下级,设备和录像机继承当前选中的区域。
- 记事保存后清空本次输入、附件和旧筛选,重新加载并定位保存结果。补记往事也可定位到对应分页;空页自动回到有效页。保存中锁定表单,失败保留草稿;另写、退出或切换家庭前提示未保存输入。
- 设置中心分为我的偏好、网盘连接、品牌与外观、数据库备份,后三项仅超级管理员可配置。录像机原有历史录像明确标注待接入,避免查询后才发现不可用。
- 手机使用可展开的顶部菜单,选择页面后自动收起。表单单列、输入字号 16px、按钮至少 44px;修复设备弹窗、媒体预览和筛选操作在窄屏的布局。
验证:138 项 Python 回归、109 项 JavaScript 回归。通过 Edge Computer Use 验证管理员和普通成员、设备查找与编辑后新建、记事保存清空与定位、照片预览和语音播放、草稿取消清空、设置分区、办公场所切换。响应式检查覆盖 360、390、430、768px;手机浏览器引擎、软键盘和真机录音仍需 Android/iOS 实机复核。流程测试使用独立的本地合成数据,未向正式家庭写入测试记事或设备。
范围:灯、冰箱等仍是设备档案,自动控制需接入实际协议;录像机原有历史录像接口仍待接入。浏览器预览测试不能替代长时间监控稳定性或设备全部厂商功能的验证。
## v0.1.29 · 记事预览、完整地域与云备份保留
- 记事照片默认显示,点击放大;语音、视频自动加载播放信息,提供播放、暂停、进度和音量控制,不自动出声。页面原生播放器按需缓冲,图片延迟解码;媒体仍保存在百度网盘,由同源鉴权接口转发,服务器不保存个人媒体文件。
+11
View File
@@ -0,0 +1,11 @@
# v0.1.33 — 摄像机接入与设备入口整理
- 摄像机、录像机卡片增加“配置镜头”入口;镜头配置始终保留当前设备及家庭归属。
- 监控墙统一用于观看、分屏、回放、截图与云台操作,移除重复的新建设备和接入设置按钮;设备配置集中在“区域与设备”。
- 镜头通道新增可保存的 ONVIF 服务端口,支持实际使用 2020 端口的 TP-Link 摄像机;RTSP 端口独立配置。
- 区分摄像机认证失败与服务器网络连接失败,兼容通过 HTTP 400 返回认证 SOAP Fault 的设备。
- 空通道页面说明“摄像头对象”和“镜头通道”的关系:主、子码流属于同一个镜头。
验证:155 项 Python 测试、123 项 JavaScript 测试全部通过,并完成浏览器接入表单和入口回归检查。
网络连通与设备认证是接入前提;摄像机凭据和家庭配置保存在部署主机的数据目录,不包含在公开源码中。
+18
View File
@@ -0,0 +1,18 @@
# v0.1.34 — 人员归属、卡片布局与网络拓扑
- 摄像机、录像机及普通设备统一提供所有人、负责人选择器,可选择本家庭/场所的账号成员或家谱人物,也可手动填写人员或单位。人员更名会同步显示,职责登记不授予额外访问权限。
- 家庭/场所照片卡片支持 1–4 列 × 1–4 行、分页、拖动或按钮排序,以及可选的城市分组。布局按账号保存,并检测并发修改;手机自动采用单列。
- “我的家庭与场所”新增“赵府整体拓扑”,仅展示当前账号获授权的家庭、公司及工厂;点击场所进入其内部网络。
- “区域与设备”新增网络拓扑视图,与设备卡片共用同一套档案。新增、编辑和周期状态更新自动反映到图中,可按区域及下级区域、设备类型、名称、人员或 IP 筛选。
- 设备档案增加管理 IP/域名、局域网/Tailscale/公网接入方式、上级设备、有线/Wi-Fi 连接方式、Tailscale 地址及网段。服务器校验地址、网段、跨家庭引用及上级循环。
- 图中显示设备名称、位置、网络地址和接入方式,分别标注录像机取流与录像归档。已配置摄像机的地址从真实镜头通道读取,避免出现两个互相矛盾的地址。
## 验证
162 项 Python 测试和 127 项 JavaScript 测试通过。使用独立测试数据,在 Edge 实际执行了三类设备录入、人员选择与重新打开、区域层级、负责人分类、IP 搜索、卡片排序保存、总拓扑下钻及 390 像素手机布局检查。
## 范围说明
拓扑自动生成于已登记设备和已确认的连接关系;未登记的交换机端口、AP 及物理线路仍需补充。图上的接入方式是档案属性,不会开放端口或自动授予网络权限,也不表示设备已经通过在线探测。录像机历史回放仍需设备协议适配。
公开源码不包含设备凭据、用户资料、业务数据库或网盘授权信息。
+1 -1
View File
@@ -1 +1 @@
0.1.29
0.1.34
+21 -8
View File
@@ -31,10 +31,12 @@ import onvif
import playback
import identity
import genealogy
import geography
import households
import inventory
import heritage
import appearance
import cloud_images
import backups
import baidu
import journal
@@ -87,6 +89,7 @@ APPEARANCE = appearance.Appearance(sys.modules[__name__])
BACKUPS = backups.Backups(sys.modules[__name__])
BAIDU = baidu.Baidu(sys.modules[__name__])
JOURNAL = journal.Journal(sys.modules[__name__])
CLOUD_IMAGES = cloud_images.CloudImages(sys.modules[__name__])
def setting(key, default=None):
@@ -190,6 +193,7 @@ def save_site(body, actor=None):
required=k == 'name') for k in SITE_FIELDS}
item.update(id=key, kind=body.get('kind', 'home'), familyId=family, parentId=parent,
category=clean_text(body.get('category', ''), 40))
geography.apply(item, body, get_object('sites', key), clean_text, Problem)
if item['kind'] not in ('home', 'company', 'office', 'factory', 'other'):
raise Problem('请选择有效区域用途')
save_object('sites', item)
@@ -226,6 +230,7 @@ def save_camera(body, actor=None):
item['archiveRecorderId'] = archive_recorder
item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')),
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535), familyId=family)
item['onvifPort'] = integer(body.get('onvifPort', old.get('onvifPort', 80)), 1, 65535)
HOUSEHOLDS.endpoint(actor, item['host'])
for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]:
path = clean_text(body.get(k, default), 500, required=True)
@@ -258,7 +263,7 @@ def save_asset(body, actor=None):
item = {k: clean_text(body.get(k, ''), 120, required=k == 'name')
for k in ('name', 'brand', 'model', 'serial', 'point', 'note')}
item.update(id=key, siteId=site, lensCount=integer(body.get('lensCount', 2), 1, 16), familyId=family)
item.update(INVENTORY.metadata(body, get_object('assets', key)))
item.update(INVENTORY.metadata(body, get_object('assets', key), actor))
attached = [c for c in objects('cameras') if c.get('assetId') == key]
if len(attached) > item['lensCount']:
raise Problem('镜头数不能小于已经建立的通道数')
@@ -290,7 +295,7 @@ def save_recorder(body, actor=None):
if not isinstance(password, str) or len(password) > 256 or any(ord(c) < 32 for c in password):
raise Problem('密码格式不正确')
item['password'] = password or old.get('password', '')
item.update(INVENTORY.metadata(body, old))
item.update(INVENTORY.metadata(body, old, actor))
save_object('recorders', item)
write_media_config()
audit('保存录像机', item['name'])
@@ -652,7 +657,7 @@ class Handler(BaseHTTPRequestHandler):
self.login(data, path == '/api/setup')
return
self.require_auth()
if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/family/restore',
if path not in ('/api/logout', '/api/account/password', '/api/preferences', '/api/households/layout', '/api/family/restore',
'/api/family/person', '/api/family/link', '/api/family/event', '/api/heritage', '/api/ptz', '/api/ptz/presets',
'/api/journal', '/api/journal/action', '/api/journal/upload', '/api/journal/finish', '/api/journal/remove-upload', '/api/journal/restore-upload'):
self.require_manager()
@@ -663,6 +668,8 @@ 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/households/layout':
self.answer(HOUSEHOLDS.save_layout(data, self.user))
elif path == '/api/baidu/config':
self.require_admin(); self.answer(BAIDU.configure(data, self.user))
elif path == '/api/baidu/connect':
@@ -764,6 +771,10 @@ class Handler(BaseHTTPRequestHandler):
raise Problem('设备密码格式不正确')
try:
result = onvif.inspect(host, port, username, password)
except PermissionError:
raise Problem('摄像机认证失败,请填写设备本地账号和密码;平台接入密码可能不同', 422)
except (urllib.error.URLError, TimeoutError, OSError):
raise Problem('服务器无法连接摄像机,请检查设备地址、ONVIF 端口以及服务器到该局域网的路由', 502)
except Exception:
raise Problem('ONVIF 读取未成功,请检查账号、设备时间、服务端口及 ONVIF 是否启用', 502)
self.answer(result)
@@ -893,8 +904,9 @@ class Handler(BaseHTTPRequestHandler):
self.answer(APPEARANCE.public())
return
if path in ('/brand-logo.png', '/brand-icon.png'):
value = APPEARANCE.config().get('logoData' if path == '/brand-logo.png' else 'iconData', '')
raw = base64.b64decode(value.split(',', 1)[1]) if value else (ROOT/'web/zhao-icon.png').read_bytes()
kind='logo' if path == '/brand-logo.png' else 'icon'
config=APPEARANCE.config();value=config.get(kind+'Data','')
raw = CLOUD_IMAGES.get(config[kind+'Ref']) if config.get(kind+'Ref') else base64.b64decode(value.split(',',1)[1]) if value else (ROOT/'web/zhao-icon.png').read_bytes()
self.image(raw, public=True)
return
if path == '/api/session':
@@ -942,7 +954,7 @@ class Handler(BaseHTTPRequestHandler):
self.answer(APPEARANCE.preferences(self.user))
elif path == '/api/appearance':
self.require_admin()
self.answer(APPEARANCE.config())
self.answer(APPEARANCE.editable())
elif path == '/api/backups':
self.require_admin()
self.answer(BACKUPS.status())
@@ -950,8 +962,8 @@ class Handler(BaseHTTPRequestHandler):
home = get_object('households', query.get('id'))
if not home or self.user['role'] != 'admin' and home['id'] != HOUSEHOLDS.scope(self.user):
raise Problem('封面不存在或未获授权', 404)
if not home.get('coverData'): raise Problem('尚未上传封面', 404)
self.image(base64.b64decode(home['coverData'].split(',', 1)[1]))
if not home.get('coverRef') and not home.get('coverData'): raise Problem('尚未上传封面', 404)
self.image(CLOUD_IMAGES.get(home['coverRef']) if home.get('coverRef') else base64.b64decode(home['coverData'].split(',', 1)[1]))
elif path == '/api/users':
self.require_manager()
self.answer({'users': [ACCOUNTS.public(u) for u in HOUSEHOLDS.objects(self.user, 'users')
@@ -1047,6 +1059,7 @@ class Handler(BaseHTTPRequestHandler):
'/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',
'/geography.js': 'geography.js', '/data/geography-data.js': 'data/geography-data.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',
+18 -4
View File
@@ -89,8 +89,12 @@ class Appearance:
value = self.config()
return {**{k: value[k] for k in TEXT_LIMITS}, 'defaults': value['defaults'],
'revision': value['revision'],
'logoUrl': '/brand-logo.png?v='+str(value['revision']) if value.get('logoData') else '/zhao-icon.png',
'iconUrl': '/brand-icon.png?v='+str(value['revision']) if value.get('iconData') else '/zhao-icon.png'}
'logoUrl': '/brand-logo.png?v='+str(value['revision']) if value.get('logoData') or value.get('logoRef') else '/zhao-icon.png',
'iconUrl': '/brand-icon.png?v='+str(value['revision']) if value.get('iconData') or value.get('iconRef') else '/zhao-icon.png'}
def editable(self):
value=self.config();public=self.public()
return dict({k:v for k,v in value.items() if k not in ('logoData','iconData','logoRef','iconRef')},logoUrl=public['logoUrl'],iconUrl=public['iconUrl'])
def validate_values(self, values, inherit=False):
if not isinstance(values, dict) or set(values) != set(CHOICES):
@@ -130,10 +134,20 @@ class Appearance:
if icon:
try: png_bytes(icon)
except ValueError as error: raise a.Problem(str(error))
result = dict(fields, defaults=defaults, logoData=logo, iconData=icon, revision=old['revision']+1)
result=dict(fields,defaults=defaults,revision=old['revision']+1)
for field in ('logo','icon'):
for suffix in ('Data','Ref'):
if field+suffix in old:result[field+suffix]=old[field+suffix]
for field,kind,value in [('logo','Logo',logo),('icon','图标',icon)]:
if field+'Data' not in data:continue
ref=a.CLOUD_IMAGES.put(value,'platform',kind,256*1024) if value else None
result.pop(field+'Data',None);result.pop(field+'Ref',None)
if ref:result[field+'Ref']=ref
with a.LOCK:
self.check_revision(data,self.config())
a.set_setting('appearance', result)
a.audit('更新发布与品牌配置', actor['username'])
return result
return self.editable()
def preferences(self, actor):
saved = self.a.setting('preferences:'+actor['id'], {'revision': 0, 'values': dict.fromkeys(CHOICES, 'default')})
+94
View File
@@ -0,0 +1,94 @@
"""Baidu-hosted UI images. DB stores references; only a bounded RAM cache holds bytes."""
import base64
import hashlib
import re
import secrets
import threading
import time
from collections import OrderedDict
from appearance import png_bytes
class CloudImages:
def __init__(self, app):
self.a = app
self.guard = threading.RLock()
self.cache = OrderedDict()
self.limit = 16*1024*1024
def remember(self, ref, raw):
with self.guard:
now=time.monotonic()
for k,(until,_) in list(self.cache.items()):
if until<now:self.cache.pop(k)
self.cache[ref['sha256']] = (now+600,raw)
self.cache.move_to_end(ref['sha256'])
while sum(len(v[1]) for v in self.cache.values())>self.limit:self.cache.popitem(last=False)
def get(self, ref):
with self.guard:
found=self.cache.get(ref['sha256'])
if found and found[0]>time.monotonic():return found[1]
with self.a.BAIDU.open_file('shared',ref) as response:
raw=response.read(ref['size']+1)
if len(raw)!=ref['size'] or hashlib.sha256(raw).hexdigest()!=ref['sha256']:
raise self.a.Problem('网盘图片校验未通过,请重新上传',502)
self.remember(ref,raw)
return raw
def put(self, value, scope, kind, maximum=2*1024*1024):
if kind not in ('封面','Logo','图标') or not re.fullmatch(r'[a-f0-9]{16}|platform',scope):
raise self.a.Problem('图片归属不正确')
try:raw=png_bytes(value,maximum)
except ValueError as error:raise self.a.Problem(str(error).replace('Logo',kind))
a=self.a
a.BAIDU.token('shared')
with a.LOCK:
instance=a.setting('cloud_instance')
if not instance:instance=secrets.token_hex(8);a.set_setting('cloud_instance',instance)
digest=hashlib.sha256(raw).hexdigest()
category='平台外观' if scope=='platform' else '家庭与场所'
remote='/apps/'+a.BAIDU.public_config()['appFolder']+'/'+category+'/'+instance+'/'+scope+'/'+kind+'/'+digest+'.png'
md5=hashlib.md5(raw).hexdigest();hashes=[md5]
pre=a.BAIDU.prepare(remote,len(raw),hashes)
result=pre.get('existing')
if not result:
needed=pre.get('block_list',[0])
if not isinstance(needed,list) or any(type(i) is not int or i!=0 for i in needed):raise a.Problem('网盘图片分片信息不正确',502)
if 0 in needed:a.BAIDU.send_block(remote,pre['uploadid'],0,raw,md5)
result=a.BAIDU.complete(dict(remotePath=remote,size=len(raw),uploadId=pre['uploadid'],hashes=hashes))
if not result.get('fs_id') or result.get('path')!=remote or result.get('size')!=len(raw):
raise a.Problem('网盘尚未确认图片,请重试',502)
ref=dict(provider='baidu',fsId=str(result['fs_id']),remotePath=remote,size=len(raw),sha256=digest,mime='image/png')
# Verify by reading the remote file before committing the reference, including retries.
with a.BAIDU.open_file('shared',ref) as response:
check=response.read(len(raw)+1)
if check!=raw:raise a.Problem('网盘图片回读校验失败,原有封面未改变',502)
self.remember(ref,raw)
return ref
def migrate_legacy(self):
"""Explicit admin maintenance; replace each reference only after cloud verification."""
a=self.a;result=[]
for home in a.objects('households'):
value=home.get('coverData')
if not value:continue
ref=self.put(value,home['id'],'封面')
with a.LOCK:
current=a.get_object('households',home['id'])
if current.get('coverData')!=value:continue
current.pop('coverData',None);current['coverRef']=ref
current['profileRevision']=current.get('profileRevision',0)+1
current['coverRevision']=ref['sha256'][:16];a.save_object('households',current)
result.append('household:'+home['id'])
for kind,field in [('Logo','logo'),('图标','icon')]:
old=a.APPEARANCE.config();value=old.get(field+'Data')
if not value:continue
ref=self.put(value,'platform',kind,256*1024)
with a.LOCK:
current=a.APPEARANCE.config()
if current.get(field+'Data')!=value:continue
current.pop(field+'Data',None);current[field+'Ref']=ref
current['revision']+=1;a.set_setting('appearance',current)
result.append('brand:'+field)
return result
+3
View File
@@ -1,4 +1,5 @@
"""A private family register, independent from login accounts and camera roles."""
import geography
import datetime as dt
import json
import secrets
@@ -186,6 +187,8 @@ class Family:
value = data.get(key, (old or {}).get(key, ''))
if not isinstance(value, str): raise a.Problem('地域信息须为文字')
item[key] = a.clean_text(value, 200 if field == 'Address' else 80)
for prefix in ('native', 'birth'):
geography.apply(item, data, old, a.clean_text, a.Problem, prefix)
for key in ('birthDate', 'deathDate'):
if item[key]:
try:
+66
View File
@@ -0,0 +1,66 @@
"""Versioned offline location paths. Administrative geography is not an ACL boundary."""
import json
from pathlib import Path
DATA = json.loads((Path(__file__).parent/'web/data/geography.json').read_text(encoding='utf8'))
def normalize(value, clean):
if not isinstance(value, dict):
raise ValueError('地域信息格式不正确')
mode = value.get('mode')
if mode not in ('china', 'world', 'custom'):
raise ValueError('请选择中国、世界或自定义地域')
result = dict(version=1, mode=mode, dataset=DATA['version'], body='地球', countryCode='', country='',
provinceCode='', cityCode='', districtCode='', province='', city='', district='', path='')
if mode == 'china':
result.update(countryCode='CN', country='中国')
children = DATA['china']
manual = False
for field in ('province', 'city', 'district'):
code = value.get(field+'Code', '')
if not isinstance(code, str): raise ValueError('地域编码格式不正确')
if manual:
result[field] = clean(value.get(field, ''), 100)
continue
if not code:
if any(value.get(k+'Code') for k in ('city','district') if ('province','city','district').index(k)>('province','city','district').index(field)):
raise ValueError('请先选择上级地区')
continue
item = next((x for x in children if x['code']==code), None)
if not item: raise ValueError('地区不属于所选上级,请重新选择')
result[field+'Code'], result[field] = item['code'], item['name']
manual = item.get('manual', False)
children = item.get('children', [])
elif mode == 'world':
code = value.get('countryCode', '')
if not isinstance(code, str) or code not in DATA['countries']: raise ValueError('请选择国家或地区')
if code == 'CN': raise ValueError('中国地区请使用中国分级选择')
result.update(countryCode=code, country=DATA['countries'][code])
for field in ('province','city','district'):
result[field] = clean(value.get(field, ''), 100)
else:
result['body'] = clean(value.get('body', '地球'), 100, True)
for field in ('country','province','city','district'):
result[field] = clean(value.get(field, ''), 100)
result['path'] = clean(value.get('path', ''), 1000)
return result
def apply(item, data, old, clean, problem, prefix=''):
key = prefix+'Geography' if prefix else 'geography'
if key not in data:
if key in (old or {}):
previous = old[key]
names = {f:prefix+f.title() if prefix else f for f in ('country','province','city','district')}
if any(k in data and data[k] != old.get(k,'') for k in names.values()):
# An older client can edit text but cannot keep now-inconsistent codes.
item[key] = normalize(dict(mode='custom',body=previous.get('body','地球'),
path=previous.get('path',''),**{f:data.get(k,old.get(k,'')) for f,k in names.items()}),clean)
else: item[key] = previous
return
try: result = normalize(data[key], clean)
except ValueError as error: raise problem(str(error))
item[key] = result
for field in ('country','province','city','district'):
item[prefix+field.title() if prefix else field] = result[field]
+49 -5
View File
@@ -5,6 +5,7 @@ import re
import secrets
import hashlib
from appearance import png_bytes
import geography
class Households:
@@ -112,12 +113,42 @@ class Households:
def label(uid):
account = self.a.get_object('users', uid)
return account.get('name', '') if account else ''
inventory = [x for table in ('devices','assets','recorders') for x in self.a.objects(table)]
def network_summary(home):
devices = [x for x in inventory if self.family_of(x) == home['id'] and
(self.manager(user) or x.get('siteId') in self.site_ids(user))]
return dict(deviceCount=len(devices), networkAccesses=sorted({x.get('networkAccess','unknown') for x in devices}))
return [dict({k: v for k, v in row.items() if k != 'coverData' and (k != 'leadershipHistory' or self.manager(user))}, ownerName=label(row.get('ownerId')), successorName=label(row.get('successorId')),
coverUrl=('/api/households/cover?id='+row['id']+'&v='+row.get('coverRevision','0')) if row.get('coverData') else '',
**network_summary(row),
coverUrl=('/api/households/cover?id='+row['id']+'&v='+row.get('coverRevision','0')) if row.get('coverData') or row.get('coverRef') else '',
canAssignOwner=user['role'] == 'admin' or row.get('ownerId') == user['id'],
members=sum(self.family_of(u) == row['id'] for u in self.a.objects('users')),
sites=sum(self.family_of(s) == row['id'] for s in self.a.objects('sites'))) for row in rows]
def layout(self, user):
saved = self.a.setting('householdLayout:'+user['id'], {})
allowed = {h['id'] for h in self.list(user)}
return dict(revision=saved.get('revision', 0), columns=saved.get('columns', 2), rows=saved.get('rows', 2),
groupCity=saved.get('groupCity', False), order=[x for x in saved.get('order', []) if x in allowed])
def save_layout(self, data, user):
a = self.a
with a.LOCK:
old = self.layout(user)
if set(data) != {'revision','columns','rows','groupCity','order'}:
raise a.Problem('卡片布局参数不正确')
if type(data['revision']) is not int or data['revision'] != old['revision']:
raise a.Problem('布局已被更新,请刷新后重新排列', 409)
for key in ('columns','rows'):
if type(data[key]) is not int or not 1 <= data[key] <= 4:
raise a.Problem('行数和列数须为 1 到 4')
order = data['order']
allowed = {h['id'] for h in self.list(user)}
if type(data['groupCity']) is not bool or not isinstance(order, list) or len(order) > 1000 or any(not isinstance(x,str) or x not in allowed for x in order) or len(order) != len(set(order)):
raise a.Problem('请选择有权访问的家庭/场所,排列不能重复')
a.set_setting('householdLayout:'+user['id'], dict(data, revision=old['revision']+1))
return self.layout(user)
def leadership(self, data, user):
"""Explicit handover; a backup already has management access during an emergency."""
a = self.a
@@ -170,17 +201,30 @@ class Households:
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)
geography.apply(item, data, old, a.clean_text, a.Problem)
if 'coverData' in data:
cover = data['coverData']
if not isinstance(cover, str): raise a.Problem('封面图片格式不正确')
if cover:
try: png_bytes(cover, 2*1024*1024)
except ValueError as error: raise a.Problem(str(error).replace('Logo', '封面'))
item['coverData'] = cover
item['coverRevision'] = hashlib.sha256(cover.encode()).hexdigest()[:16]
a.save_object('households', item)
# Network I/O must not hold the database lock or stall unrelated requests.
if 'coverData' in data:
ref=a.CLOUD_IMAGES.put(data['coverData'],item['id'],'封面') if data['coverData'] else None
item.pop('coverData',None);item.pop('coverRef',None)
if ref:item['coverRef']=ref
item['coverRevision']=ref['sha256'][:16] if ref else 'default'
with a.LOCK:
current=a.get_object('households',item['id'])
if (current or {}).get('profileRevision',0)!=(old or {}).get('profileRevision',0):
raise a.Problem('上传期间档案已变化,请重新打开后保存',409)
# Preserve unrelated management/grouping updates made during the upload.
committed=dict(current or {},**{k:v for k,v in item.items() if k not in ('leadershipHistory','ownerId','successorId','leadershipRevision','deviceGrouping')})
if 'coverData' in data:committed.pop('coverData',None);committed.pop('coverRef',None)
if item.get('coverRef'):committed['coverRef']=item['coverRef']
a.save_object('households', committed)
a.audit('保存家庭', item['name'])
return item
return committed
def revision_key(self, user):
family = self.scope(user)
+4
View File
@@ -177,12 +177,16 @@ class Accounts:
family = self.a.HOUSEHOLDS.scope(user)
state['familyId'] = family
state['households'] = self.a.HOUSEHOLDS.list(user)
state['householdLayout'] = self.a.HOUSEHOLDS.layout(user)
for key in ('sites', 'assets', 'cameras', 'recorders', 'devices'):
state[key] = [x for x in state.get(key, []) if self.a.HOUSEHOLDS.family_of(x) == family]
for key in ('assets', 'recorders', 'devices'):
state[key] = [self.a.INVENTORY.display(x) for x in state[key]]
if user['role'] != 'admin':
state['scan'] = {}
state['storage'] = {}
if self.a.HOUSEHOLDS.manager(user):
state['responsibilityOptions'] = self.a.INVENTORY.candidates(user)
return state
permitted = self.a.HOUSEHOLDS.site_ids(user)
state['sites'] = [s for s in state['sites'] if s['id'] in permitted]
+66 -2
View File
@@ -1,12 +1,76 @@
"""Common device responsibility fields and non-video device records."""
import ipaddress
import re
class Inventory:
def __init__(self, app):
self.a = app
def metadata(self, data, old=None):
def candidates(self, user):
if not self.a.HOUSEHOLDS.manager(user):
return []
a = self.a
users = a.HOUSEHOLDS.objects(user, 'users')
if user['role'] == 'admin' and not any(u['id'] == user['id'] for u in users):
users.append(user)
result = [dict(ref='user:'+u['id'], name=u['name'], label=u['name']+' / '+u['username'], kind='账号成员')
for u in users if not u.get('disabled')]
linked = {u.get('personId') for u in users if not u.get('disabled')}
result += [dict(ref='person:'+p['id'], name=p['name'], label=p['name']+' · '+p['id'][-4:], kind='家谱人物')
for p in a.HOUSEHOLDS.objects(user, 'people') if not p.get('archived') and p['id'] not in linked]
return result
def display(self, item):
result = dict(item)
for prefix in ('owner', 'manager'):
ref = item.get(prefix+'Ref', '')
kind, _, key = ref.partition(':')
target = self.a.get_object({'user':'users','person':'people'}.get(kind, 'users'), key) if key else None
if target and (self.a.HOUSEHOLDS.family_of(target) == self.a.HOUSEHOLDS.family_of(item) or
kind == 'user' and target.get('role') == 'admin'):
result[prefix+'Name'] = target['name']
return result
def metadata(self, data, old=None, user=None):
old = old or {}
result = {k: self.a.clean_text(data.get(k, old.get(k, '')), 80)
for k in ('ownerName', 'managerName')}
candidates = {p['ref']:p for p in self.candidates(user)}
for prefix in ('owner', 'manager'):
field = prefix+'Ref'
ref = self.a.clean_text(data.get(field, old.get(field, '')), 64)
if ref:
if ref in candidates:
result[prefix+'Name'] = candidates[ref]['name']
elif ref == old.get(field):
result[prefix+'Name'] = self.display(old).get(prefix+'Name', '')
else:
raise self.a.Problem('请选择当前家庭/场所的人员,或选择手动填写')
result[field] = ref
for field in ('managementHost','tailscaleHost'):
value = self.a.clean_text(data.get(field,old.get(field,'')),253)
if value:
try: ipaddress.ip_address(value)
except ValueError:
if re.fullmatch(r'[0-9.]+',value) or not all(re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?',part) for part in value.split('.')):
raise self.a.Problem('设备地址请填写 IP 或域名,不含账号、路径及端口')
result[field]=value
for field,choices in [('networkAccess',('unknown','lan','tailscale','public')),('connectionType',('unknown','wired','wifi'))]:
result[field]=data.get(field,old.get(field,'unknown'))
if result[field] not in choices:raise self.a.Problem('网络接入类型不正确')
cidr=self.a.clean_text(data.get('networkCidr',old.get('networkCidr','')),64)
if cidr:
try:cidr=str(ipaddress.ip_network(cidr,strict=True))
except ValueError:raise self.a.Problem('网段请使用 CIDR 格式,例如 192.168.8.0/24')
result['networkCidr']=cidr
uplink=self.a.clean_text(data.get('uplinkId',old.get('uplinkId','')),32)
if uplink:
records={x['id']:x for table in ('assets','recorders','devices') for x in self.a.HOUSEHOLDS.objects(user,table)}
if uplink not in records:raise self.a.Problem('上级设备不属于当前家庭/场所')
seen={data.get('id')};node=uplink
while node:
if node in seen:raise self.a.Problem('上级设备不能指向自己或形成循环')
seen.add(node);node=records.get(node,{}).get('uplinkId')
result['uplinkId']=uplink
tags = data.get('tags', old.get('tags', []))
if not isinstance(tags, list) or len(tags) > 12:
raise self.a.Problem('自定义标签最多 12 个')
@@ -24,7 +88,7 @@ class Inventory:
if item['deviceType'] in ('摄像头', '摄像机', '录像机'):
raise a.Problem('视频设备请使用摄像机或录像机档案,以保留取流与通道配置')
item.update(id=key, familyId=family, siteId=a.clean_text(data.get('siteId', ''), 32),
**self.metadata(data, old))
**self.metadata(data, old, user))
a.save_object('devices', item)
a.audit('保存设备档案', item['name'])
return item
+6 -1
View File
@@ -71,6 +71,10 @@ class Journal:
entries.sort(key=lambda e: (e['occurredOn'], e['createdAt'], e['id']), reverse=True)
try: page = max(0, min(100000, int(query.get('page', '0'))))
except ValueError: raise a.Problem('页码不正确')
if query.get('focus'):
index = next((i for i, entry in enumerate(entries) if entry['id'] == query['focus']), None)
if index is not None: page = index // 20
page = min(page, max(0, (len(entries)-1)//20))
media = {m['id']: m for m in a.HOUSEHOLDS.objects(user, 'journal_media')}
likes = a.HOUSEHOLDS.objects(user, 'journal_likes')
comments = a.HOUSEHOLDS.objects(user, 'journal_comments')
@@ -195,7 +199,8 @@ class Journal:
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
category = '照片' if mime.startswith('image/') else '视频' if mime.startswith('video/') else '语音'
remote = '/apps/'+a.BAIDU.public_config()['appFolder']+'/个人记事/'+instance+'/'+family+'/'+user['id']+'/'+category+'/'+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())
+22 -3
View File
@@ -20,6 +20,16 @@ class NoRedirect(urllib.request.HTTPRedirectHandler):
return None
def check_fault(root):
fault = root.find('.//{http://www.w3.org/2003/05/soap-envelope}Fault')
if fault is None:
return
codes = [el.text or '' for el in fault.iter() if el.tag.rsplit('}', 1)[-1] in ('Value', 'faultcode')]
if any(code.rsplit(':', 1)[-1] in ('NotAuthorized', 'FailedAuthentication') for code in codes):
raise PermissionError('ONVIF authentication failed')
raise ValueError('ONVIF returned a fault; check device service and request support')
def request(host, port, endpoint, namespace, operation, content, username, password):
created = dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
nonce = secrets.token_bytes(16)
@@ -44,10 +54,19 @@ def request(host, port, endpoint, namespace, operation, content, username, passw
with opener.open(req, timeout=5) as response:
root = ET.fromstring(response.read(1024 * 1024))
except urllib.error.HTTPError as exc:
exc.close()
try:
if exc.code in (401, 403):
raise PermissionError('ONVIF authentication failed') from None
try:
fault = ET.fromstring(exc.read(1024 * 1024))
except ET.ParseError:
fault = None
if fault is not None:
check_fault(fault)
finally:
exc.close()
raise
if root.find('.//{http://www.w3.org/2003/05/soap-envelope}Fault') is not None:
raise ValueError('ONVIF returned a fault; check device credentials and permissions')
check_fault(root)
return root
+23 -1
View File
@@ -107,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), 27)
self.assertEqual(len(assets), 29)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
@@ -547,6 +547,28 @@ class WebTests(unittest.TestCase):
self.assertEqual(current['username'], 'updated')
self.assertTrue(current['passwordConfigured']);self.assertNotIn('password', current)
def test_onvif_port_persists_and_is_validated_separately_from_rtsp(self):
site = app.save_site({'name': 'ONVIF port test'})
asset = app.save_asset(dict(name='TP-Link fixture', siteId=site['id'], lensCount=1))
with patch.object(app, 'write_media_config'):
camera = app.save_camera(dict(name='Single source', assetId=asset['id'], host='192.168.8.160', port=554, onvifPort=2020))
self.assertEqual(camera['onvifPort'], 2020)
self.assertEqual(camera['port'], 554)
legacy_edit = dict(camera)
legacy_edit.pop('onvifPort')
self.assertEqual(app.save_camera(legacy_edit)['onvifPort'], 2020)
for invalid in (0, 65536, 'wrong'):
with self.assertRaises(app.Problem):
app.save_camera(dict(camera, onvifPort=invalid))
def test_onvif_auth_and_network_failures_have_different_errors(self):
data = dict(host='192.168.8.160', port=2020, username='admin', password='fixture-only')
for fault, status, text in [(PermissionError(), 422, '摄像机认证失败'), (TimeoutError(), 502, '服务器无法连接')]:
with patch.object(app.onvif, 'inspect', side_effect=fault):
code, _, body = self.request('/api/onvif', data=data)
self.assertEqual(code, status)
self.assertIn(text, json.loads(body)['error'])
def test_camera_settings_admin_only_and_errors_are_explicit(self):
user = app.ACCOUNTS.save(dict(username='settings-member', password='settings-pass', siteIds=['test-site'] if app.get_object('sites','test-site') else []), self.admin)
token = '3'*64
+5 -2
View File
@@ -17,7 +17,10 @@ def image_data():
class AppearanceTests(unittest.TestCase):
setUp=fixture.HouseholdTests.setUp
def setUp(self):
fixture.HouseholdTests.setUp(self)
from test_cloud_images import install_cloud_fixture
install_cloud_fixture(self,app)
stop_server=fixture.HouseholdTests.stop_server
request=fixture.HouseholdTests.request
@@ -74,7 +77,7 @@ class AppearanceTests(unittest.TestCase):
self.assertEqual(listed['city'],'烟台市');self.assertNotIn('coverData',listed)
self.assertIn('/api/households/cover?',listed['coverUrl'])
saved=self.request('/api/households',self.owner,dict(id=self.aid,name='新名称',profileRevision=1))[1]
self.assertEqual(saved['city'],'烟台市');self.assertEqual(saved['coverData'],image_data())
self.assertEqual(saved['city'],'烟台市');self.assertNotIn('coverData',saved);self.assertTrue(saved['coverRef']['fsId'])
for user,expected in [(self.members[self.aid],200),(self.members[self.bid],404)]:
c=http.client.HTTPConnection(*self.server.server_address)
c.request('GET',listed['coverUrl'],headers={'Cookie':'vision='+self.tokens[user['id']]})
+76
View File
@@ -0,0 +1,76 @@
import io
import hashlib
import unittest
from unittest.mock import patch
from test_app import app
import test_households as fixture
from cloud_images import CloudImages
def install_cloud_fixture(test, app):
"""Exercise the real image store over an isolated in-memory Baidu transport."""
files={};blocks={}
def prepare(remote,size,hashes):
if remote in files:return {'existing':dict(fs_id=files[remote][0],path=remote,size=len(files[remote][1]))}
return {'uploadid':'mock-upload','block_list':[0]}
def block(remote,upload,index,raw,md5):
assert hashlib.md5(raw).hexdigest()==md5;blocks[remote]=raw
def complete(item):
remote=item['remotePath'];files[remote]=(len(files)+1,blocks[remote])
return dict(fs_id=files[remote][0],path=remote,size=len(files[remote][1]))
def open_file(key,item,byte_range=''):
return io.BytesIO(files[item['remotePath']][1])
for obj,name,value in [(app,'CLOUD_IMAGES',CloudImages(app)),(app.BAIDU,'token',lambda *a:'fixture'),
(app.BAIDU,'public_config',lambda:dict(appFolder='测试应用')),(app.BAIDU,'prepare',prepare),
(app.BAIDU,'send_block',block),(app.BAIDU,'complete',complete),(app.BAIDU,'open_file',open_file)]:
p=patch.object(obj,name,value);p.start();test.addCleanup(p.stop)
return files
class CloudImageTests(unittest.TestCase):
stop_server=fixture.HouseholdTests.stop_server
request=fixture.HouseholdTests.request
def setUp(self):
fixture.HouseholdTests.setUp(self)
self.files=install_cloud_fixture(self,app)
def test_cover_is_uploaded_checked_and_db_contains_only_reference(self):
from test_appearance import image_data
result=app.HOUSEHOLDS.save(dict(id=self.aid,name='云封面',coverData=image_data()),self.root)
self.assertNotIn('coverData',result)
self.assertIn('/家庭与场所/',result['coverRef']['remotePath'])
ref=result['coverRef'];app.CLOUD_IMAGES.cache.clear()
self.assertEqual(hashlib.sha256(app.CLOUD_IMAGES.get(ref)).hexdigest(),ref['sha256'])
self.assertEqual(len(self.files),1)
app.HOUSEHOLDS.save(dict(id=self.aid,name='重复上传',profileRevision=1,coverData=image_data()),self.root)
self.assertEqual(len(self.files),1)
def test_failed_upload_does_not_save_profile_or_replace_existing_cover(self):
from test_appearance import image_data
before=app.get_object('households',self.aid)
with patch.object(app.BAIDU,'prepare',side_effect=app.Problem('网盘断线',502)):
with self.assertRaises(app.Problem):app.HOUSEHOLDS.save(dict(id=self.aid,name='不应保存',coverData=image_data()),self.root)
self.assertEqual(app.get_object('households',self.aid),before)
def test_conflict_during_upload_preserves_newer_profile(self):
from test_appearance import image_data
put=app.CLOUD_IMAGES.put
def concurrent(*args):
ref=put(*args);home=app.get_object('households',self.aid)
home.update(name='他人刚修改',profileRevision=1);app.save_object('households',home);return ref
with patch.object(app.CLOUD_IMAGES,'put',side_effect=concurrent):
with self.assertRaises(app.Problem):app.HOUSEHOLDS.save(dict(id=self.aid,name='旧编辑',coverData=image_data()),self.root)
self.assertEqual(app.get_object('households',self.aid)['name'],'他人刚修改')
def test_legacy_migration_is_verified_and_idempotent(self):
from test_appearance import image_data
home=app.get_object('households',self.aid);home['coverData']=image_data();app.save_object('households',home)
self.assertEqual(len(app.CLOUD_IMAGES.migrate_legacy()),1)
self.assertNotIn('coverData',app.get_object('households',self.aid))
self.assertEqual(app.CLOUD_IMAGES.migrate_legacy(),[])
def test_corrupt_remote_fails_before_commit_and_no_disk_cache(self):
from test_appearance import image_data
with patch.object(app.BAIDU,'open_file',return_value=io.BytesIO(b'corrupt')):
with self.assertRaises(app.Problem):app.CLOUD_IMAGES.put(image_data(),self.aid,'封面')
self.assertEqual(list(app.CLOUD_IMAGES.cache),[])
+69
View File
@@ -0,0 +1,69 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
const source=name=>fs.readFileSync('web/'+name,'utf8');
function part(file,start,end){const s=source(file);return s.slice(s.indexOf(start),s.indexOf(end,s.indexOf(start)+start.length));}
const devices=require('./web/device-model.js');
const areas=[{id:'floor',name:'一楼'},{id:'room',name:'卧室',parentId:'floor'}];
const lamp={name:'台灯',deviceType:'灯',siteId:'room',brand:'小米',managerName:'爸爸',tags:['阅读']};
test('device search combines area, type, brand, responsible person and tags',()=>{
for(const q of ['卧室 灯','一楼 小米','爸爸 阅读',' 卧室 台灯 '])assert.ok(devices.matches(lamp,q,areas),q);
assert.ok(!devices.matches(lamp,'厨房 灯',areas));assert.ok(devices.matches(lamp,'',areas));
});
test('grouping views retain devices and their location',()=>{
const before=JSON.stringify(lamp);
for(const dimensions of [['space'],['type','space'],['manager','type'],['owner','tag']])assert.equal(devices.groups([lamp],dimensions,areas).flatMap(g=>g.items).length,1);
assert.equal(JSON.stringify(lamp),before);
});
test('editor reset clears old identities and revisions after editing',()=>{
const fields=[{name:'id',value:'old'},{name:'familyRevision',value:'9'},{name:'revision',value:'3'}];
let resets=0;const c=vm.createContext({});vm.runInContext(part('app.js','function resetEditorForm(','function formValues('),c);
c.resetEditorForm({reset(){resets++;},querySelectorAll(){return fields;}});
assert.equal(resets,1);assert.deepEqual(fields.map(f=>f.value),['','0','0']);
});
test('back buttons follow place, device and family hierarchy',()=>{
const c=vm.createContext({});vm.runInContext(part('steward.js','function scopeReturn(','function selectedDevice('),c);
for(const [page,device,target] of [['overview',null,'households'],['live',{},'objects'],['sites',null,'objects'],['family',null,'heritage'],['heritage-records',null,'heritage'],['journal',null,'overview']])assert.equal(c.scopeReturn(page,device).page,target);
});
function settingsContext(admin){
const nodes={};const c=vm.createContext({settingsSection:'preferences',isPlatformAdmin:()=>admin,$:id=>nodes[id]??={},$$:()=>[]});
vm.runInContext(part('personalization.js','function showSettingsSection(','document.addEventListener'),c);return {c,nodes};
}
test('settings show one section at a time',()=>{
const {c,nodes}=settingsContext(true);
for(const section of ['preferences','baidu','brand','backup']){c.showSettingsSection(section);assert.equal(Object.values(nodes).filter(n=>!n.hidden).length,1);assert.equal(c.settingsSection,section);}
});
test('members cannot select platform settings',()=>{
const {c,nodes}=settingsContext(false);for(const section of ['baidu','brand','backup','unknown']){c.showSettingsSection(section);assert.equal(c.settingsSection,'preferences');assert.equal(nodes['#backup-panel'].hidden,true);}
});
function journalContext(){
const fields=Object.fromEntries(['text','occurredOn','category','visibility'].map(k=>[k,{value:k==='visibility'?'private':''}]));
const nodes={'#journal-form':{elements:fields}};
const c=vm.createContext({journalSelectedMedia:new Set(),journalBaseline:'',journalUpload:false,journalTracks:null,journalSaving:false,journalPage:3,confirm:()=>false,toast:()=>{},$:id=>nodes[id]??={value:''}});
vm.runInContext(part('journal.js','function journalDraftSignature(','const journalId='),c);return {c,fields,nodes};
}
test('saved entry filter reset clears searches and dates but retains privacy',()=>{
const {c,fields,nodes}=journalContext();for(const id of ['journal-search','journal-category','journal-from','journal-to','journal-person'])c.$('#'+id).value='old';
c.resetJournalFilters('mine');assert.equal(c.journalPage,0);assert.equal(nodes['#journal-search'].value,'');assert.equal(nodes['#journal-person-label'].hidden,true);assert.equal(fields.visibility.value,'private');
});
test('dirty drafts and pending saves are protected',()=>{
const {c,fields}=journalContext();c.journalBaseline=c.journalDraftSignature();assert.equal(c.journalHasDraft(),false);
fields.text.value='未保存';assert.equal(c.journalHasDraft(),true);assert.equal(c.confirmJournalDiscard(),false);
c.confirm=()=>true;assert.equal(c.confirmJournalDiscard(),true);c.journalSaving=true;assert.equal(c.confirmJournalDiscard(),false);
});
test('empty feed explains private visibility and filtered emptiness',()=>{
const {c}=journalContext();c.$('#journal-mode').value='feed';assert.match(c.journalEmptyMessage(),/仅自己可见/);
c.$('#journal-search').value='missing';assert.match(c.journalEmptyMessage(),/清除筛选/);
});
test('mobile navigation toggles an accessible control without changing scope',()=>{
let opened=false;const button={setAttribute(k,v){this[k]=v;},textContent:''};
const c=vm.createContext({$:id=>id==='.sidebar'?{classList:{toggle(k,v){opened=v;}}}:button});
vm.runInContext(part('app.js','function setMobileMenu(',"$('#mobile-nav-toggle').onclick"),c);
c.setMobileMenu(true);assert.equal(opened,true);assert.equal(button['aria-expanded'],'true');
c.setMobileMenu(false);assert.equal(opened,false);assert.equal(button['aria-expanded'],'false');
});
test('mobile forms use readable inputs, touch targets and bounded dialogs',()=>{
const css=source('chinese-theme.css'),html=source('index.html');
assert.match(css,/font-size:16px!important;min-height:44px/);
assert.match(css,/max-height:90dvh/);assert.match(css,/sidebar:not\(\.mobile-open\)>nav/);
assert.match(html,/aria-controls="main-navigation"/);
});
+20
View File
@@ -28,3 +28,23 @@ test('camera recorder and general device records appear in one catalog without m
assert.deepEqual(m.catalog(state).map(d=>d.deviceType),['摄像机','录像机','冰箱']);assert.equal(JSON.stringify(state),before);
assert.equal(m.groups(devices,[],sites).length,1);
});
test('same-name people with different references form separate responsibility groups',()=>{
const rows=[{id:'a',managerName:'张伟',managerRef:'user:one'},{id:'b',managerName:'张伟',managerRef:'user:two'}];
assert.equal(m.groups(rows,['manager'],[]).length,2);
});
test('network topology preserves explicit uplinks and remains usable with missing or cyclic parents',()=>{
const data=[{id:'r'},{id:'c',uplinkId:'r'},{id:'p',uplinkId:'hidden'}];
const tree=m.topology(data);assert.deepEqual(tree.children.get('r').map(x=>x.id),['c']);assert.equal(tree.parents.get('p'),'');
assert.equal(m.topology([{id:'a',uplinkId:'b'},{id:'b',uplinkId:'a'}]).children.get('').length,2);
});
test('network summary distinguishes recorder stream source from physical camera address',()=>{
const n=m.network({id:'a',managementHost:'192.168.8.160',networkAccess:'tailscale'},{cameras:[{assetId:'a',host:'192.168.3.135',sourceKind:'recorder',recorderId:'r',archiveSource:'recorder',archiveRecorderId:'r'}]});
assert.deepEqual(n.hosts,['192.168.8.160']);assert.deepEqual(n.via,['r']);assert.deepEqual(n.archiveVia,['r']);assert.equal(n.access,'tailscale');
});
test('configured camera address overrides an old inventory address and is searchable',()=>{
const rows=m.catalog({assets:[{id:'a',managementHost:'192.168.1.20'}],cameras:[{assetId:'a',host:'192.168.1.30',sourceKind:'direct'}]});
assert.deepEqual(rows[0].networkHosts,['192.168.1.30']);assert.equal(m.matches(rows[0],'192.168.1.30',[]),true);
});
+26
View File
@@ -0,0 +1,26 @@
const test=require('node:test'),assert=require('node:assert/strict');
const geo=require('./web/geography.js');
test('legacy known China path upgrades losslessly',()=>{
const v=geo.infer({province:'山东省',city:'烟台市',district:'莱阳市'});
assert.equal(v.mode,'china');assert.equal(v.districtCode,'370682');
assert.match(geo.label(v),/山东省 · 烟台市 · 莱阳市/);
});
test('unknown historic names are preserved in custom mode',()=>{
const v=geo.infer({nativeCountry:'中国',nativeProvince:'山东',nativeCity:'登州府'},'native');
assert.equal(v.mode,'custom');assert.equal(v.city,'登州府');
});
test('changing an ancestor clears every lower selection',()=>{
let v=geo.infer({province:'山东省',city:'烟台市',district:'莱阳市'});
v=geo.change(v,'provinceCode','11');assert.equal(v.province,'北京市');
assert.equal(v.city,'');assert.equal(v.cityCode,'');assert.equal(v.districtCode,'');
v=geo.change(v,'mode','world');assert.equal(v.provinceCode,'');assert.equal(v.countryCode,'');
v=geo.change(v,'countryCode','US');assert.equal(v.country,'美国');
v=geo.change({...v,city:'Boston'},'countryCode','JP');assert.equal(v.city,'');
});
test('self-defined celestial names and deep paths display without pretending to be administrative codes',()=>{
const v=geo.infer({geography:{mode:'custom',body:'火星',path:'基地 / 居住区 / 一栋 / 二层'}});
assert.equal(geo.label(v),'火星 · 基地 / 居住区 / 一栋 / 二层');
});
test('world selection can return to China cascade',()=>{
assert.equal(geo.change({mode:'world'},'countryCode','CN').mode,'china');
});
+69
View File
@@ -0,0 +1,69 @@
import unittest
import geography
from test_app import app
import test_appearance as appearance_fixture
class GeographyUnitTests(unittest.TestCase):
def norm(self, value): return geography.normalize(value, app.clean_text)
def test_codes_resolve_canonical_names_and_reject_wrong_parent(self):
v=dict(mode='china',provinceCode='37',cityCode='3706',districtCode='370682',city='假名字')
result=self.norm(v)
self.assertEqual((result['province'],result['city'],result['district']),('山东省','烟台市','莱阳市'))
with self.assertRaises(ValueError):self.norm(dict(v,provinceCode='11'))
with self.assertRaises(ValueError):self.norm(dict(v,cityCode=''))
with self.assertRaises(ValueError):self.norm(dict(v,districtCode='999999'))
def test_partial_unknown_and_manual_paths(self):
self.assertEqual(self.norm(dict(mode='china'))['province'],'')
self.assertEqual(self.norm(dict(mode='china',provinceCode='81',city='香港岛',district='中西区'))['district'],'中西区')
result=self.norm(dict(mode='world',countryCode='US',province='California',city='Los Angeles'))
self.assertEqual(result['country'],'美国')
with self.assertRaises(ValueError):self.norm(dict(mode='world',countryCode='ZZ'))
with self.assertRaises(ValueError):self.norm(dict(mode='world',countryCode='CN'))
result=self.norm(dict(mode='custom',body='火星',path='基地 / 一区 / 一栋'))
self.assertEqual(result['body'],'火星');self.assertEqual(result['countryCode'],'')
def test_invalid_country_types_have_a_validation_error(self):
for code in ([], {}, None, 42, True):
with self.subTest(code=code),self.assertRaises(ValueError):
self.norm(dict(mode='world',countryCode=code))
def test_legacy_edits_drop_stale_codes_and_other_updates_preserve(self):
old=dict(geography=self.norm(dict(mode='china',provinceCode='37',cityCode='3706')),province='山东省',city='烟台市')
item={};geography.apply(item,{},old,app.clean_text,app.Problem)
self.assertEqual(item['geography']['cityCode'],'3706')
geography.apply(item,dict(city='登州府'),old,app.clean_text,app.Problem)
self.assertEqual(item['geography']['mode'],'custom');self.assertEqual(item['geography']['city'],'登州府')
self.assertEqual(item['geography']['cityCode'],'')
class GeographyIntegrationTests(unittest.TestCase):
setUp=appearance_fixture.AppearanceTests.setUp
stop_server=appearance_fixture.AppearanceTests.stop_server
request=appearance_fixture.AppearanceTests.request
def test_bad_country_input_returns_400_and_preserves_profile(self):
old=app.get_object('households',self.aid)
status,result=self.request('/api/households',self.root,dict(id=self.aid,name='不应写入',geography=dict(mode='world',countryCode=[])))
self.assertEqual(status,400);self.assertIn('国家',result['error'])
self.assertEqual(app.get_object('households',self.aid),old)
def test_household_cover_and_path_roundtrip(self):
v=dict(mode='china',provinceCode='37',cityCode='3706',districtCode='370682')
status,saved=self.request('/api/households',self.root,dict(id=self.aid,name='封面与地域',geography=v,coverData=appearance_fixture.image_data()))
self.assertEqual(status,200);self.assertEqual(saved['district'],'莱阳市')
listed=self.request('/api/households',self.root)[1]['households']
home=next(x for x in listed if x['id']==self.aid)
self.assertEqual(home['geography']['districtCode'],'370682');self.assertTrue(home['coverUrl'])
self.assertNotIn('coverData',home)
code,_=self.request('/api/households',self.owner,dict(id=self.aid,name='错误地区',profileRevision=1,geography=dict(v,cityCode='1101')))
self.assertEqual(code,400)
self.assertEqual(app.get_object('households',self.aid)['name'],'封面与地域')
def test_person_and_site_preserve_world_and_celestial_locations(self):
person=app.FAMILY.person(dict(name='人物',birthGeography=dict(mode='world',countryCode='JP',province='东京都'),nativeGeography=dict(mode='custom',body='地球',path='登州府 / 某村')),None)
self.assertEqual(person['birthCountry'],'日本');self.assertEqual(person['nativeGeography']['path'],'登州府 / 某村')
site=app.save_site(dict(name='未来基地',geography=dict(mode='custom',body='火星',path='基地 / 居住区 / 一区')),self.root)
self.assertEqual(site['geography']['body'],'火星')
+43
View File
@@ -30,3 +30,46 @@ test('owner is visibly distinct from family and super administrators',()=>{
assert.equal(c.label({id:'backup',role:'family_admin'}),'家庭管理员');
assert.equal(c.label({id:'root',role:'admin'}),'超级管理员');
});
function saveHarness({saveError,refreshError}={}){
const events=[],error={textContent:''},elements=[{disabled:false},{disabled:true}];
elements.id={value:'home'};elements.profileRevision={value:0};
const form={elements,querySelector:()=>error,closest:()=>({close:()=>events.push('closed')})};
let submit;
const state={households:[{id:'home',name:'原档案'}]};
const c=vm.createContext({coverSaving:false,coverDraft:'data:image/png;base64,test',sessionGeneration:1,
$:()=>({addEventListener:(_type,fn)=>{submit=fn;}}),imageJobPending:()=>false,imageJobs:new Map(),
formValues:()=>({id:'home',name:'已保存'}),state,
api:async()=>{if(saveError)throw saveError;return {id:'home',name:'已保存',profileRevision:1,coverRevision:'new',coverRef:{fsId:'1'}};},
renderHouseholds:()=>events.push('rendered'),toast:t=>events.push(t),
refresh:async()=>{events.push('refresh');if(refreshError)throw refreshError;}});
vm.runInContext(part(homes,"$('#household-form').addEventListener('submit'",'document.addEventListener'),c);
return {run:()=>submit({preventDefault(){},currentTarget:form}),events,error,elements,state,c};
}
test('saved cover stays committed and success is visible before a failed refresh',async()=>{
const h=saveHarness({refreshError:Error('network')});await h.run();
assert.equal(h.state.households[0].name,'已保存');assert.equal(h.elements.profileRevision.value,1);
assert.match(h.state.households[0].coverUrl,/v=new/);
assert.ok(h.events.indexOf('封面已存百度网盘,归属档案已保存')<h.events.indexOf('refresh'));
assert.match(h.events.at(-1),/无需重复保存/);assert.ok(h.events.includes('closed'));
assert.deepEqual(h.elements.map(e=>e.disabled),[false,true]);assert.equal(h.c.coverSaving,false);
});
test('failed cover save keeps the draft open and does not claim success',async()=>{
const h=saveHarness({saveError:Error('网盘暂时不可用')});await h.run();
assert.equal(h.state.households[0].name,'原档案');assert.equal(h.error.textContent,'网盘暂时不可用');
assert.deepEqual(h.events,[]);assert.equal(h.c.coverDraft,'data:image/png;base64,test');
});
test('a stale post-save refresh does not show a new-session warning',async()=>{
const h=saveHarness({refreshError:Object.assign(Error('stale'),{stale:true})});await h.run();
assert.equal(h.events.at(-1),'refresh');assert.equal(h.state.households[0].name,'已保存');
});
test('storage guide names cloud categories and avoids old all-local claims',()=>{
const html=fs.readFileSync('web/index.html','utf8');
assert.ok(html.includes('id="baidu-storage-guide"'));
for(const label of ['家庭与场所:','平台外观:','个人记事:','数据库备份:'])assert.ok(html.includes(label));
assert.ok(!html.includes('自有部署 · 本地存储'));assert.ok(!app.includes('数据存于本机'));
});
+88
View File
@@ -0,0 +1,88 @@
"""Responsibility references and personal household layout regressions."""
import unittest
from test_app import app
import test_households as fixture
class InventoryTests(unittest.TestCase):
setUp = fixture.HouseholdTests.setUp
stop_server = fixture.HouseholdTests.stop_server
request = fixture.HouseholdTests.request
def test_candidates_are_scoped_minimal_and_support_current_superadmin(self):
options = self.request('/api/state')[1]['responsibilityOptions']
refs = {p['ref'] for p in options}
self.assertIn('user:'+self.members[self.aid]['id'], refs)
self.assertNotIn('user:'+self.members[self.bid]['id'], refs)
self.assertTrue(all(set(p)=={'ref','name','label','kind'} for p in options))
root_options = app.INVENTORY.candidates(self.roots[self.bid])
self.assertIn('user:'+self.root['id'], {p['ref'] for p in root_options})
self.assertNotIn('responsibilityOptions', self.request('/api/state', self.members[self.aid])[1])
def test_reference_names_are_authoritative_and_cannot_cross_households(self):
member = self.members[self.aid]
data = dict(self.assets[self.aid],ownerRef='user:'+member['id'],ownerName='spoofed')
code, saved = self.request('/api/assets',data=data)
self.assertEqual(code,200);self.assertEqual(saved['ownerName'],member['name'])
data['managerRef']='user:'+self.members[self.bid]['id']
self.assertEqual(self.request('/api/assets',data=data)[0],400)
app.ACCOUNTS.save(dict(app.get_object('users',member['id']),name='更新名字'),self.roots[self.aid])
shown=self.request('/api/state')[1]['assets'][0]
self.assertEqual(shown['ownerName'],'更新名字');self.assertEqual(shown['ownerRef'],saved['ownerRef'])
self.assertEqual(self.request('/api/assets',data=dict(saved,ownerRef='',ownerName='共有单位'))[1]['ownerName'],'共有单位')
def test_archived_or_disabled_links_are_preserved_but_not_newly_assigned(self):
member=self.members[self.aid]
saved=app.INVENTORY.save(dict(name='测试灯',deviceType='灯',managerRef='user:'+member['id']),self.owner)
app.ACCOUNTS.save(dict(app.get_object('users',member['id']),disabled=True),self.roots[self.aid])
self.assertEqual(app.INVENTORY.save(saved,self.owner)['managerRef'],saved['managerRef'])
with self.assertRaises(app.Problem):app.INVENTORY.save(dict(name='新灯',deviceType='灯',managerRef=saved['managerRef']),self.owner)
def test_people_and_all_device_types_share_references_without_granting_access(self):
person=dict(id='e'*16,name='家庭成员',familyId=self.aid,archived=False)
app.save_object('people',person)
ref='person:'+person['id'];self.assertIn(ref,{p['ref'] for p in app.INVENTORY.candidates(self.owner)})
before=app.objects('users')
rows=[app.save_asset(dict(self.assets[self.aid],managerRef=ref),self.owner),
app.save_recorder(dict(name='录像机',siteId=self.sites[self.aid]['id'],host='192.168.9.50',managerRef=ref),self.owner),
app.INVENTORY.save(dict(name='灯',deviceType='灯',managerRef=ref),self.owner)]
self.assertTrue(all(r['managerName']=='家庭成员' for r in rows));self.assertEqual(before,app.objects('users'))
def test_layout_persists_per_account_and_rejects_conflicts_and_cross_scope(self):
data=dict(revision=0,columns=3,rows=2,groupCity=False,order=[self.bid,self.aid])
code,saved=self.request('/api/households/layout',self.root,data)
self.assertEqual(code,200);self.assertEqual(saved['order'],data['order'])
self.assertEqual(self.request('/api/state',self.root)[1]['householdLayout']['columns'],3)
self.assertEqual(app.HOUSEHOLDS.layout(self.owner)['columns'],2)
self.assertEqual(self.request('/api/households/layout',self.root,data)[0],409)
self.assertEqual(self.request('/api/households/layout',self.owner,data)[0],400)
for bad in [dict(data,revision=1,columns=0),dict(data,revision=1,rows=True),dict(data,revision=1,order=[self.aid,self.aid]),dict(data,revision=1,userId=self.owner['id'])]:
self.assertEqual(self.request('/api/households/layout',self.root,bad)[0],400)
member=self.members[self.aid]
self.assertEqual(self.request('/api/households/layout',member,dict(data,order=[self.aid]))[0],200)
def test_topology_references_and_network_fields_validate_without_opening_access(self):
gateway=app.INVENTORY.save(dict(name='网关',deviceType='路由器',managementHost='192.168.8.1',tailscaleHost='gateway.example.ts.net',networkCidr='192.168.8.0/24',networkAccess='tailscale'),self.owner)
camera=app.save_asset(dict(self.assets[self.aid],uplinkId=gateway['id'],connectionType='wired'),self.owner)
with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,uplinkId=camera['id']),self.owner)
with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,uplinkId=gateway['id']),self.owner)
with self.assertRaises(app.Problem):app.save_asset(dict(camera,uplinkId=self.assets[self.bid]['id']),self.owner)
for bad in [dict(networkCidr='192.168.8.160/24'),dict(managementHost='http://user:secret@example.com'),dict(networkAccess='all'),dict(connectionType='anything')]:
with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,**bad),self.owner)
self.assertEqual(self.request('/api/state',self.other)[1]['devices'],[])
self.assertEqual(app.INVENTORY.save(dict(gateway,networkAccess='public',managementHost='example.org'),self.owner)['managementHost'],'example.org')
def test_global_topology_summaries_count_only_authorized_devices(self):
app.INVENTORY.save(dict(name='未授权区域的设备',deviceType='路由器',networkAccess='tailscale'),self.owner)
own=self.request('/api/state',self.members[self.aid])[1]['households']
self.assertEqual(len(own),1)
self.assertEqual(own[0]['deviceCount'],1)
self.assertNotIn('tailscale',own[0]['networkAccesses'])
managed=self.request('/api/state',self.owner)[1]['households'][0]
self.assertEqual(managed['deviceCount'],2)
self.assertIn('tailscale',managed['networkAccesses'])
overall=self.request('/api/state',self.root)[1]['households']
self.assertEqual({h['id']:h['deviceCount'] for h in overall},{self.aid:2,self.bid:1})
if __name__=='__main__':unittest.main()
+2 -2
View File
@@ -31,7 +31,7 @@ test('successful save clears draft attachment ids, stops recorder, and reloads f
const js=fs.readFileSync('web/journal.js','utf8');
const fn=js.slice(js.indexOf('function newJournal'),js.indexOf('async function loadJournal'));
assert.match(fn,/journalDraftMedia.clear\(\)/);assert.match(fn,/journalSelectedMedia.clear\(\)/);assert.match(fn,/stopJournalVoice\(\)/);assert.match(fn,/journal-library'\).open=false/);
assert.match(js,/newJournal\(\);\$\('#journal-mode'\).value='mine';journalPage=0;await loadJournal\(\)/);
assert.match(js,/newJournal\(\);resetJournalFilters\('mine'\);await loadJournal\(saved.id\)/);
assert.match(js,/available.filter\(m=>journalDraftMedia.has\(m.id\)\)/);
});
test('attachment preview uses a closable dialog and deletion has a restore path',()=>{
@@ -41,7 +41,7 @@ test('attachment preview uses a closable dialog and deletion has a restore path'
});
test('structured place formatting and search preserve separate ancestral/birth/legacy locations',()=>{
const vm=require('node:vm'),js=fs.readFileSync('web/family.js','utf8');
const c=vm.createContext({});vm.runInContext(js.slice(js.indexOf('function personLocation'),js.indexOf('function personName')),c);
const c=vm.createContext({ZhaoGeography:require('./web/geography.js')});vm.runInContext(js.slice(js.indexOf('function personLocation'),js.indexOf('function personName')),c);
const person={nativeProvince:'山东省',nativeCity:'烟台市',nativeDistrict:'莱阳市',birthCity:'济南市',birthplace:'旧村名'};
assert.equal(c.personLocation(person,'native'),'山东省 · 烟台市 · 莱阳市');assert.equal(c.personLocation(person,'birth'),'济南市');assert.ok(c.personSearch(person).includes('旧村名'));
});
+19
View File
@@ -58,6 +58,25 @@ class JournalTests(unittest.TestCase):
self.assertEqual(self.request('/api/journal', self.actor, dict(old,text='修改'))[0], 200)
self.assertEqual(self.request('/api/journal', self.actor, old)[0], 409)
def test_saved_backdated_entry_focus_and_page_clamp(self):
for _ in range(21): self.saved()
body = self.note(); body['occurredOn'] = '2000-01-01'
key = app.JOURNAL.save(self.actor, body)['id']
focused = app.JOURNAL.snapshot(self.actor, dict(mode='mine', focus=key))
self.assertEqual(focused['page'], 1)
self.assertIn(key, [e['id'] for e in focused['items']])
self.assertEqual(app.JOURNAL.snapshot(self.actor, dict(mode='mine', page='99'))['page'], 1)
empty = app.JOURNAL.snapshot(self.actor, dict(mode='mine', page='99', search='不存在的关键词'))
self.assertEqual(empty['page'], 0)
self.assertEqual(empty['items'], [])
def test_focus_does_not_reveal_private_or_foreign_entries(self):
private = self.saved()
for user in (self.owner, self.root, self.other):
result = app.JOURNAL.snapshot(user, dict(mode='feed', focus=private))
self.assertEqual(result['total'], 0)
self.assertEqual(result['items'], [])
def test_like_set_is_idempotent_and_privacy_change_removes_all_interactions(self):
key = self.saved(visibility='household'); data = dict(id=key,action='like',liked=True)
for _ in range(2): self.assertEqual(self.request('/api/journal/action', self.owner, data)[0], 200)
+33
View File
@@ -0,0 +1,33 @@
"""Read-only ONVIF transport regression tests using synthetic responses."""
import io
import unittest
import urllib.error
from unittest.mock import MagicMock, patch
import onvif
class OnvifTests(unittest.TestCase):
def test_tplink_http_400_auth_fault_is_not_a_connection_error(self):
payload = b'<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><s:Fault><s:Code><s:Value>s:Sender</s:Value><s:Subcode><s:Value>ter:NotAuthorized</s:Value></s:Subcode></s:Code></s:Fault></s:Body></s:Envelope>'
body = io.BytesIO(payload)
error = urllib.error.HTTPError('http://192.168.8.160:2020/onvif/device_service', 400, 'Bad Request', {}, body)
opener = MagicMock()
opener.open.side_effect = error
with patch.object(onvif.urllib.request, 'build_opener', return_value=opener):
with self.assertRaises(PermissionError):
onvif.request('192.168.8.160', 2020, '/onvif/device_service', onvif.DEVICE, 'GetDeviceInformation', '', 'admin', 'fixture-only')
self.assertTrue(body.closed)
def test_plain_auth_error_is_closed(self):
body = io.BytesIO(b'Unauthorized')
opener = MagicMock()
opener.open.side_effect = urllib.error.HTTPError('http://camera', 401, 'Unauthorized', {}, body)
with patch.object(onvif.urllib.request, 'build_opener', return_value=opener):
with self.assertRaises(PermissionError):
onvif.request('192.168.8.160', 2020, '/onvif/device_service', onvif.DEVICE, 'GetDeviceInformation', '', 'admin', 'fixture-only')
self.assertTrue(body.closed)
def test_unsupported_operation_remains_a_fault(self):
root = onvif.ET.fromstring('<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><s:Fault><s:Code><s:Value>ter:ActionNotSupported</s:Value></s:Code></s:Fault></s:Body></s:Envelope>')
with self.assertRaises(ValueError):
onvif.check_fault(root)
+28 -6
View File
@@ -34,13 +34,16 @@ test('backup messages distinguish local-only, network copy, and failure',()=>{
assert.match(c.backupLabel({localOk:true,networkOk:false}),/本机/);assert.doesNotMatch(c.backupLabel({localOk:true}),/网络|云端/);
assert.match(c.backupLabel({error:'offline',localOk:true}),/未完成/);assert.match(c.backupLabel({networkOk:true}),/网络目录/);
});
test('place cards group by city without exposing an inline photo payload',()=>{
const out={innerHTML:''},filter={};const c=vm.createContext({state:{familyId:'a',households:[{id:'a',name:'Home',province:'Province',city:'City A',members:1,sites:1,coverUrl:'/api/households/cover?id=a',coverData:'PRIVATE-PHOTO'},{id:'b',name:'Office',city:'City B',kind:'office',members:1,sites:0}]},
activePage:'households',setOptions:()=>{},$:s=>s==='#household-list'?out:filter,isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'});
vm.runInContext(part(homes,'function renderHouseholds(','async function switchHousehold('),c);c.renderHouseholds();
assert.match(out.innerHTML,/City A/);assert.match(out.innerHTML,/City B/);assert.match(out.innerHTML,/\/api\/households\/cover\?id=a/);
assert.doesNotMatch(out.innerHTML,/PRIVATE-PHOTO|data-edit-household/);
test('place cards support saved layout, city grouping and private photo references',()=>{
const nodes=new Map(),node=s=>{if(!nodes.has(s))nodes.set(s,{innerHTML:'',style:{setProperty(){}},setAttribute(){},addEventListener(){}});return nodes.get(s);};
const c=vm.createContext({state:{familyId:'a',householdLayout:{revision:0,columns:2,rows:2,groupCity:true,order:['b','a']},households:[{id:'a',name:'Home',province:'Province',city:'City A',members:1,sites:1,coverUrl:'/api/households/cover?id=a',coverData:'PRIVATE-PHOTO'},{id:'b',name:'Office',city:'City B',kind:'office',members:1,sites:0}]},
currentUser:{id:'viewer'},pageInfo:{},ZhaoGeography:require('./web/geography.js'),activePage:'households',setOptions:()=>{},$:node,$$:()=>[],isAdmin:()=>false,esc:s=>String(s),scopeKind:h=>h.kind||'home'});
vm.runInContext(homes.slice(0,homes.indexOf('async function switchHousehold(')),c);c.renderHouseholds();
const out=node('#household-list');assert.match(out.innerHTML,/City A/);assert.match(out.innerHTML,/City B/);assert.match(out.innerHTML,/\/api\/households\/cover\?id=a/);
assert.doesNotMatch(out.innerHTML,/PRIVATE-PHOTO|data-edit-household/);assert.ok(out.innerHTML.indexOf('Office')<out.innerHTML.indexOf('Home'));
assert.equal(node('#home-arrange').disabled,true);
});
test('publication text is rendered as text and the export omits private state',()=>{
const apply=part(source,'function applyBranding(','async function loadBranding(');assert.doesNotMatch(apply,/innerHTML/);
const payload=part(source,'function brandPayload()',"\n$('#brand-form').addEventListener");assert.doesNotMatch(payload,/password|currentUser|backupRevision|networkPath/);
@@ -66,3 +69,22 @@ test('replacing or resetting an image invalidates older decode jobs',()=>{
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);
});
test('cover accepts a large original but compresses below proxy request limits',async()=>{
let size=0;const canvas={getContext:()=>({drawImage(){}}),toDataURL:()=> 'data:image/png;base64,'+'A'.repeat(++size===1?1000000:500000)};
const c=vm.createContext({createImageBitmap:async()=>({width:8000,height:6000,close(){}}),document:{createElement:()=>canvas}});
vm.runInContext(part(source,'async function readImage(','for(const [id,type,max]'),c);
const result=await c.readImage({type:'image/jpeg',size:15*1024*1024},960);
assert.ok((result.length-22)*.75<600*1024);assert.ok(canvas.width<=960);
await assert.rejects(c.readImage({type:'image/jpeg',size:31*1024*1024},960),/30 MB/);
await assert.rejects(c.readImage({type:'image/jpeg',size:15*1024*1024},256),/5 MB/);
});
test('failed and still-processing covers block profile save instead of reporting success',async()=>{
let submit,calls=0;const error={textContent:''},f={querySelector:()=>error,addEventListener:(_,fn)=>submit=fn};
const jobs=new Map([['cover',{error:true}]]);
const c=vm.createContext({coverSaving:false,$:()=>f,imageJobs:jobs,imageJobPending:()=>false,api:async()=>{calls++;},formValues:()=>{throw Error('must not serialize failed image');}});
vm.runInContext(part(homes,"$('#household-form').addEventListener('submit'",'document.addEventListener'),c);
await submit({preventDefault(){},currentTarget:f});assert.equal(calls,0);assert.match(error.textContent,/封面未准备成功/);
c.imageJobPending=()=>true;await submit({preventDefault(){},currentTarget:f});assert.match(error.textContent,/仍在处理/);
});
+27 -1
View File
@@ -2,7 +2,7 @@ const test=require('node:test'),assert=require('node:assert/strict'),fs=require(
const code=fs.readFileSync('web/steward.js','utf8'),html=fs.readFileSync('web/index.html','utf8'),app=fs.readFileSync('web/app.js','utf8');
const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length));
test('navigation starts at places and contains no global camera functions',()=>{
const nav=html.match(/<nav aria-label="主导航">([\s\S]*?)<\/nav>/)[1];
const nav=html.match(/<nav[^>]*aria-label="主导航">([\s\S]*?)<\/nav>/)[1];
assert.doesNotMatch(nav,/data-page="(?:live|playback)"/);
assert.match(nav,/scope-navigation/);assert.match(app,/activePage = 'households'/);
assert.match(html,/aria-label="设备功能"/);
@@ -49,3 +49,29 @@ test('culture and property entry requires its separate permission',()=>{
assert.match(output.innerHTML,/data-family-section/);assert.doesNotMatch(output.innerHTML,/data-heritage-kind/);
c.currentUser.heritageAccess='read';c.renderHeritageHome();assert.match(output.innerHTML,/data-heritage-kind/);
});
test('video device cards expose a single configuration entry with correct scope',()=>{
const devices=fs.readFileSync('web/devices.js','utf8');
const c=vm.createContext({state:{cameras:[]},isAdmin:()=>true,esc:s=>String(s)});
vm.runInContext(part(devices,'function videoDeviceActions(','function deviceCard('),c);
const camera={source:'camera',id:'a'};
assert.match(c.videoDeviceActions(camera),/data-view-device="a".*data-device-source="camera".*data-open-device-tab="cameras"/);
assert.doesNotMatch(c.videoDeviceActions(camera),/查看画面/);
c.state.cameras=[{assetId:'a'}];assert.match(c.videoDeviceActions(camera),/查看画面/);
c.isAdmin=()=>false;assert.doesNotMatch(c.videoDeviceActions(camera),/配置镜头/);
assert.equal(c.videoDeviceActions({source:'device',id:'lamp'}),'');
});
test('monitor wall offers viewing without duplicate device management',()=>{
const live=part(app,'function renderLive()','function updateLiveStatus(');
assert.doesNotMatch(live,/data-edit-camera|data-camera-settings|new-camera/);
assert.match(live,/data-view-device/);
const section=part(html,'<section id="page-live"','<section id="page-playback"');
assert.doesNotMatch(section,/data-action="new-camera"/);
});
test('camera form submits the configured ONVIF port',()=>{
assert.match(html,/name="onvifPort" type="number" value="80" min="1" max="65535"/);
assert.match(app,/onvifPort:Number\(d.onvifPort\)/);
assert.match(app,/port:Number\(f.elements.onvifPort.value\)/);
});
+30 -19
View File
@@ -36,7 +36,7 @@ function clearPrivateView(){
if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});
currentUser=null;householdScope='';stopPlayers();resetPlayback();
state={cameras:[],assets:[],recorders:[],devices:[],sites:[],storage:{},scan:{}};
for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list','household-list'])$('#'+id).replaceChildren();
for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list','household-list','device-topology','home-global-topology'])$('#'+id).replaceChildren();
if(typeof clearFamilyState==='function')clearFamilyState();
$$('dialog[open]').filter(d=>d.id!=='auth-dialog').forEach(d=>d.close());
$$('dialog form').forEach(f=>f.reset());
@@ -53,30 +53,33 @@ async function api(path, data) {
}
function toast(text) { clearTimeout(toastTimer); $('#toast').textContent = text; $('#toast').hidden = false; toastTimer = setTimeout(() => $('#toast').hidden = true, 4000); }
function siteName(id) { return ZhaoDevices.path(state.sites,id).map(s=>s.name).join(' / ') || '未关联空间'; }
function address(s) { return [s.province,s.city,s.district,s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; }
function address(s) { return [ZhaoGeography.describe(s),s.street,s.community,s.building,s.unit,s.floor,s.door].filter(Boolean).join(' · ') || '地址待完善'; }
function filtered() { const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const cameras=state.cameras.filter(c=>!range||range.has(c.siteId));return typeof deviceCameras==='function'?deviceCameras(cameras):cameras; }
function setOptions(select, html) { const old = select.value; select.innerHTML = html; if ([...select.options].some(o => o.value === old)) select.value = old; }
function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; }
function showPage(page, options={}) {
if(typeof journalSaving!=='undefined'&&journalSaving&&page!==activePage){toast('记事正在保存,请稍候。');return;}
setMobileMenu(false);
if(typeof leaveJournal==='function'&&activePage==='journal'&&page!=='journal')leaveJournal();
if(typeof leaveSettings==='function'&&activePage==='settings'&&page!=='settings')leaveSettings();
if(!isAdmin()&&!['households','overview','objects','device','live','playback','family','heritage','heritage-records','settings','journal'].includes(page))return;
if(['heritage','family','heritage-records'].includes(page)&&currentHome()?.kind&&currentHome().kind!=='home')return;
if(['objects','overview','sites','users','family','heritage','heritage-records','households','storage','settings','journal'].includes(page)&&typeof deviceContext!=='undefined')deviceContext=null;
if(typeof scopeEntered!=='undefined')scopeEntered=!['households','storage','settings'].includes(page);
if(typeof scopeEntered!=='undefined'){if(page==='households')scopeEntered=false;else if(!['storage','settings'].includes(page))scopeEntered=true;}
if(page==='storage'&&!isPlatformAdmin())return;
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
activePage = page;
const pageChanged=activePage!==page;activePage = page;
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings','journal'].includes(page);
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['sites','device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
$('#page-title').textContent = pageInfo[page][0]; $('#page-note').textContent = pageInfo[page][1];
if (page !== 'playback') resetPlayback();
render();
if(page==='playback')$('#archive-source').value=state.cameras.find(c=>c.id===$('#play-camera').value)?.archiveSource||'local';
if(pageChanged)window.scrollTo({top:0,left:0,behavior:'instant'});
if(page==='playback'){$('#archive-source').value=state.cameras.find(c=>c.id===$('#play-camera').value)?.archiveSource||'local';playbackSourceHint();}
if (page === 'storage') loadAudit();
if (page === 'family') loadFamily(!options.keepFamilyView);
if (page === 'users') loadUsers();
@@ -91,7 +94,7 @@ function render() {
$('#metric-used').textContent = state.storage.usedGB == null ? '—' : state.storage.usedGB + ' GB';
$('#service-status').textContent = state.mediaOnline ? '视频服务已连接' : '视频服务未连接';
$('#service-status').classList.toggle('off', !state.mediaOnline);
$('#version').textContent = state.version ? 'v' + state.version + ' / 数据存于本机' : '独立部署 / 数据自有';
$('#version').textContent = state.version ? 'v' + state.version + ' / 独立部署 · 数据自有' : '独立部署 / 数据自有';
const warning = state.storage.paused ? state.storage.reason + ',已暂停录像。' : '';
$('#banner').textContent = warning; $('#banner').hidden = !warning;
setOptions($('#site-filter'), '<option value="">全部空间</option>' + state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join(''));
@@ -115,7 +118,7 @@ function renderLive() {
const key = (currentUser?.role||'')+':'+(state.familyId||'')+':'+ZhaoLivePlayer.key(cameras, quality);
if (key === liveKey) { updateLiveStatus(cameras); return; }
stopPlayers(); liveKey = key;
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button><button class="quiet" data-view-device="${c.assetId}" data-device-source="camera">摄像头详情</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-camera="${c.id}">截图</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="wall-reorder"><span draggable="false" data-wall-drag="${c.id}" title="拖动排列">⠿ 拖动</span><button class="quiet" data-wall-before="${c.id}" aria-label="${esc(c.name)}前移">前移</button><button class="quiet" data-wall-after="${c.id}" aria-label="${esc(c.name)}后移">后移</button></div><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-view-device="${c.assetId}" data-device-source="camera">摄像头详情</button><button class="quiet" data-play-camera="${c.id}">查看录像</button><button class="quiet" data-snapshot-camera="${c.id}">截图</button><button class="quiet" data-ptz-camera="${c.id}" hidden>云台控制</button></div></article>`).join('') : (isAdmin()?empty('当前没有已配置画面','请在区域与设备中选择摄像机并配置镜头,然后回到这里集中观看。','<button data-page="objects">前往区域与设备</button>'):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。'));
for (const c of cameras.filter(c=>c.enabled)) {
const video = $(`video[data-camera="${c.id}"]`);
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
@@ -137,11 +140,11 @@ function renderCameras() {
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
const cameras=filtered();
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚无摄像头','可以先扫描当前局域网,或手动填写设备地址。');
$('#camera-list').innerHTML=cameras.length?`<div class="table-wrap"><table class="device-table"><thead><tr><th>设备 / 位置</th><th>所属空间</th><th>地址</th><th>码流状态</th><th>录像</th><th></th></tr></thead><tbody>${cameras.map(c=>`<tr><td>${esc(c.name)}<small>${esc(c.point||'位置待完善')}</small></td><td>${esc(siteName(c.siteId))}</td><td>${esc(c.host)}:${c.port}</td><td><span class="badge ${c.ready?'':'off'}">${c.ready?'已接通':c.enabled?'未接通':'停用'}</span></td><td>${c.recordingActive?'正在录像':c.record?'已配置,等待写入':'未开启'}</td><td><button class="quiet" data-edit-camera="${c.id}">配置</button><button class="quiet" data-camera-settings="${c.id}">摄像机设置</button></td></tr>`).join('')}</tbody></table></div>`:empty('尚未配置镜头通道','摄像头档案已建立后,还需填写设备地址、账号与码流路径。每个镜头只建一个通道,主、子码流在通道内配置。','<button data-action="new-camera">+ 配置第一个镜头</button>');
}
function renderSites() {
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;const sites=state.sites.filter(s=>!range||range.has(s.id));
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
$('#site-list').innerHTML=sites.length?sites.map(s=>`<article class="site-card"><div class="site-heading"><span class="badge">${esc(s.category||kind(s.kind))}</span><button class="quiet" data-edit-site="${s.id}">编辑档案</button></div><h3>${esc(s.name)}</h3><button class="quiet" data-child-site="${esc(s.id)}">+ 新增下级区域</button><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([ZhaoGeography.describe(s),s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
}
function renderStorage() {
const s=state.storage;
@@ -151,17 +154,25 @@ function renderStorage() {
async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-list').innerHTML=rows.slice(0,8).map(r=>`<div class="audit-item"><small>${new Date(r.at).toLocaleString()}</small>${esc(r.action)} ${esc(r.name)}</div>`).join('')||'<p class="description">尚无操作记录</p>';}catch(e){if(!e.stale)toast(e.message);} }
async function refresh() {if(!loggedIn)return;const userId=currentUser?.id,generation=++refreshGeneration;try{const next=await api('/api/state');if(!loggedIn||currentUser?.id!==userId||generation!==refreshGeneration)return;if(next.user.id!==userId||next.user.personId!==currentUser.personId){clearPrivateView();loggedIn=true;}state=next;currentUser=state.user;render();}catch(e){if(e.stale||generation!==refreshGeneration||!loggedIn)return;$('#banner').textContent=e.message;$('#banner').hidden=false;}}
async function auth() {const generation=++authGeneration;try{const s=await api('/api/session');if(generation!==authGeneration)return;if(currentUser&&(!s.authenticated||currentUser.id!==s.user?.id||currentUser.personId!==s.user?.personId))clearPrivateView();loggedIn=s.authenticated;currentUser=s.user;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-form').elements.username.readOnly=setup;if(setup)$('#auth-form').elements.username.value='admin';$('#auth-title').textContent=setup?'建立你的家庭空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置管理员密码。':'输入用户名和密码,进入家庭空间。';applyPermissions();if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}if(typeof onAppearanceSession==='function')onAppearanceSession(s.preferences);}catch(e){if(!e.stale&&generation===authGeneration)toast('无法连接管理服务:'+e.message);}}
function openSite(id) {const f=$('#site-form');f.reset();f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
function openSite(id,parentId='') {const f=$('#site-form');resetEditorForm(f);f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(!item){f.elements.parentId.value=parentId||$('#site-filter').value||'';f.elements.kind.value=currentHome()?.kind==='office'?'company':currentHome()?.kind||'home';}if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;ZhaoGeography.fill(f,item||{});$('#site-dialog').showModal();}
function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');resetEditorForm(f);f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();}
function setMobileMenu(open){$('.sidebar').classList.toggle('mobile-open',open);$('#mobile-nav-toggle').setAttribute('aria-expanded',String(open));$('#mobile-nav-toggle').textContent=open?'收起菜单':'☰ 菜单';}
$('#mobile-nav-toggle').onclick=()=>setMobileMenu($('#mobile-nav-toggle').getAttribute('aria-expanded')!=='true');
document.addEventListener('keydown',event=>{if(event.key==='Escape'&&$('#mobile-nav-toggle').getAttribute('aria-expanded')==='true'){setMobileMenu(false);$('#mobile-nav-toggle').focus();}});
function resetEditorForm(form){
form.reset();
// Hidden input values become reset defaults in browsers; always clear record identity explicitly.
for(const field of form.querySelectorAll('input[type="hidden"]'))field.value=field.name.toLowerCase().includes('revision')?'0':'';
}
function formValues(f) {return {...Object.fromEntries(new FormData(f).entries()),...(typeof ZhaoGeography!=='undefined'?ZhaoGeography.collect(f):{})};}
function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});}
handleForm('site-form','/api/sites');
handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),enabled:f.elements.enabled.checked,record:f.elements.record.checked}));
handleForm('camera-form','/api/cameras',(d,f)=>({...d,port:Number(d.port),onvifPort:Number(d.onvifPort),enabled:f.elements.enabled.checked,record:f.elements.record.checked}));
handleForm('storage-form','/api/storage',d=>Object.fromEntries(Object.entries(d).map(([k,v])=>[k,Number(v)])),loadAudit);
$('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api(setup?'/api/setup':'/api/login',formValues(f));f.reset();f.querySelector('.form-error').textContent='';await auth();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault());
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera'){openCamera();if(deviceContext?.source==='camera'){$('#camera-form').elements.assetId.value=deviceContext.id;syncEndpoint();}}if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){const c=state.cameras.find(c=>c.id===b.dataset.playCamera);if(c)enterDevice('camera',c.assetId,'playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;clearPrivateView();await auth();};
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera'){openCamera();if(deviceContext?.source==='camera'){const f=$('#camera-form');f.elements.assetId.value=deviceContext.id;f.elements.sourceKind.value='direct';f.elements.recorderId.value='';f.elements.archiveSource.value='local';syncEndpoint();}else if(deviceContext?.source==='recorder'){$('#camera-form').elements.recorderId.value=deviceContext.id;syncEndpoint();}}if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.childSite)openSite('',b.dataset.childSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){const c=state.cameras.find(c=>c.id===b.dataset.playCamera);if(c)enterDevice('camera',c.assetId,'playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
$('#logout').onclick=async()=>{if(typeof confirmJournalDiscard==='function'&&!confirmJournalDiscard())return;await api('/api/logout',{});loggedIn=false;clearPrivateView();await auth();};
$('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();render();};
$('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}};
$('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}};
@@ -174,13 +185,13 @@ function renderRecorders(){
const recorders=state.recorders.filter(r=>!$('#site-filter').value||r.siteId===$('#site-filter').value);
$('#recorder-list').innerHTML=recorders.length?recorders.map(r=>`<article class="site-card"><div class="site-heading"><span class="badge">录像机</span><button class="quiet" data-edit-recorder="${r.id}">配置</button></div><h3>${esc(r.name)}</h3><dl><div><dt>所属空间</dt><dd>${esc(siteName(r.siteId))}</dd></div><div><dt>品牌型号</dt><dd>${esc([r.brand,r.model].filter(Boolean).join(' / ')||'待确认')}</dd></div><div><dt>设备地址</dt><dd>${esc(r.host)}:${r.port}</dd></div><div><dt>凭据</dt><dd>${r.passwordConfigured?'已配置':'尚未填写'}</dd></div><div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===r.id).length} 个</dd></div><div><dt>原有录像回放</dt><dd>待型号适配</dd></div></dl></article>`).join(''):empty('连接现有录像机','填写录像机账号后,在各镜头通道选择这台录像机。','<button data-action="new-recorder">+ 添加录像机</button>');
}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(!a&&$('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
function openAsset(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#asset-form');resetEditorForm(f);f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const a=state.assets.find(a=>a.id===id);if(!a&&$('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;if(a)for(const [k,v]of Object.entries(a))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,a);$('#asset-dialog').showModal();}
function openRecorder(id){if(!state.sites.length){toast('请先建立空间档案');openSite();return;}const f=$('#recorder-form');resetEditorForm(f);f.querySelector('.form-error').textContent='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(siteName(s.id))}</option>`).join('');const r=state.recorders.find(a=>a.id===id);if(!r&&$('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;if(r)for(const [k,v]of Object.entries(r))if(f.elements[k])f.elements[k].value=v;responsibilityFields(f,r);$('#recorder-dialog').showModal();}
function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;}
$('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint;
handleForm('asset-form','/api/assets',d=>({...deviceValues(d),lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...deviceValues(d),port:Number(d.port)}));
$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}};
$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:Number(f.elements.onvifPort.value),username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}};
function validateNewPassword(values){
if(values.newPassword!==values.confirmPassword)throw Error('两次输入的新密码不一致');
+37
View File
@@ -319,3 +319,40 @@ html[data-theme=ink] body{background-image:radial-gradient(ellipse at 90% 0,#263
.journal-preview .journal-photo img{max-height:65vh;min-height:0;aspect-ratio:auto}.journal-preview .journal-photo{pointer-events:none}.journal-preview .journal-photo-hint{display:none}.journal-preview video{max-height:65vh}
html[data-theme=ink] .journal-upload-card{background:#20352d}html[data-theme=ink] .journal-audio{background:#293f37;border-color:#436152}html[data-theme=ink] .journal-audio-label{color:#d5e2ce}
@media(max-width:600px){.journal-upload-grid{grid-template-columns:1fr}.journal-preview{padding:16px}.journal-preview h2{font-size:17px}.journal-preview header button{flex-shrink:0}.journal-media{grid-template-columns:1fr}}
/* Compact mobile navigation and touch-friendly forms. */
.mobile-nav-toggle{display:none}
@media(max-width:760px){
.sidebar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:14px 16px;height:auto;min-height:0}
.sidebar .brand{min-width:0;padding:0;gap:10px}.sidebar .brand>span{min-width:0}.sidebar #brand-name{font-size:23px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar .brand-icon{width:38px;height:44px}.sidebar #brand-subtitle{font-size:11px;letter-spacing:1px}
.sidebar .mobile-nav-toggle{display:block;margin:0;min-height:44px;color:#f7f3e5;border-color:#8faaa0;padding:9px 14px}
.sidebar:not(.mobile-open)>nav,.sidebar:not(.mobile-open)>#current-user,.sidebar:not(.mobile-open)>#account-password,.sidebar:not(.mobile-open)>#logout{display:none}
.sidebar>nav,.sidebar>#current-user{grid-column:1/-1}.sidebar.mobile-open>nav{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:0;padding:8px 0 0}.sidebar #scope-navigation{display:contents}.sidebar #scope-navigation[hidden]{display:none}.sidebar nav button{width:100%;text-align:left;min-height:44px;white-space:normal;margin:0;font-size:14px}
.sidebar.mobile-open>#current-user{margin:0;padding:12px 0 0}.sidebar.mobile-open>#account-password,.sidebar.mobile-open>#logout{display:block;min-height:44px;margin:0}
main{padding:22px 16px}main>header{margin-bottom:22px}.header-status #clock{display:none}
input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{font-size:16px!important;min-height:44px}button,.button{min-height:44px;touch-action:manipulation}
.form-grid{grid-template-columns:1fr}.form-grid>*{min-width:0}.form-grid .wide{grid-column:1/-1}
.section-head{flex-wrap:wrap;gap:12px}.section-head>div{max-width:100%}.section-head button{white-space:normal}
.household-context{gap:10px;padding:12px}.household-context label{width:100%;display:flex;flex-wrap:wrap}.household-context select{flex:1;min-width:0}.household-context #household-context-note{display:none}
.filter-row{gap:10px;flex-wrap:wrap}.filter-row label{width:100%;display:grid;grid-template-columns:1fr}.filter-row select{width:100%}
.settings-sections,.view-presets{gap:8px}.settings-sections button,.view-presets button{flex:1 1 100px;white-space:normal}
.device-actions,.device-search-row{gap:10px}.device-search-row{display:grid;grid-template-columns:1fr}
dialog{width:calc(100% - 24px);max-width:calc(100% - 24px);max-height:90vh;max-height:90dvh;padding:18px;overscroll-behavior:contain}dialog header,dialog .dialog-heading{gap:12px;align-items:flex-start}dialog h2{font-size:22px}dialog footer{flex-wrap:wrap}
.journal-compose,.journal-entry{padding:18px}.journal-filters{gap:12px}.journal-filters label{flex-basis:100%;min-width:0}.journal-add-media input{max-width:100%;width:100%}.journal-add-media>label{width:100%}.journal-preview{padding:16px}.journal-preview .journal-photo img,.journal-preview video{max-height:60vh;max-height:60dvh}.journal-upload-card{padding:12px}.journal-media audio{min-width:0}.journal-comments form label{min-width:0;flex-basis:100%}
.device-context [role=group]{display:flex;flex-wrap:wrap;gap:8px}.device-context button{flex:1 1 42%;white-space:normal}.metrics{margin-bottom:20px}
}
@media(max-width:760px){.sidebar .brand{grid-column:1;max-width:100%;width:auto}.sidebar>.mobile-nav-toggle{grid-column:2;grid-row:1;width:auto;max-width:110px}.sidebar .brand-icon{flex-shrink:0}.sidebar .brand>span{overflow:hidden}.sidebar #brand-name{font-size:21px}}
/* Shared geography controls and cover feedback, including phone dialogs. */
.geo-picker{min-width:0;border:1px solid var(--line,#d8d2c3);border-radius:10px;padding:14px;margin:0}.geo-picker legend{padding:0 8px}.geo-picker .geo-path{display:block;overflow-wrap:anywhere;margin-top:10px;font-size:.9rem}.geo-picker .form-grid{min-width:0}.geo-picker input,.geo-picker select{width:100%;min-width:0}#household-cover-status[data-error="true"]{color:#ad3c28;font-weight:600}#household-cover-preview{max-width:100%;max-height:240px;object-fit:contain}
/* Personal household card wall */
.home-layout-controls{padding:16px 20px}.home-layout-controls .device-actions{align-items:end;flex-wrap:wrap}.home-layout-controls label{margin:0;min-width:90px}.home-layout-controls label.check{min-width:0;align-self:center}.home-card-grid{display:grid;grid-template-columns:repeat(var(--home-columns,2),minmax(0,1fr));gap:20px;align-items:start}.home-card-grid .home-card{min-width:0}.home-card-grid .home-card-body{padding:18px}.home-card-grid .home-cover-content{min-height:190px;padding:18px}.home-card-grid .home-cover{min-height:190px}.home-card-grid .home-card-body dl{margin:12px 0}.home-card-grid .home-card-body .description{font-size:13px}.home-card-grid .household-actions{display:flex;flex-wrap:wrap;gap:8px}.home-card-grid .household-actions button{font-size:13px;padding:10px 12px}.home-order-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:14px}.home-order-actions span{font-size:12px;color:var(--muted)}.home-order-actions button{font-size:12px;padding:8px}.home-card[draggable=true]{cursor:grab;outline:1px dashed var(--accent);outline-offset:3px}.home-pagination{display:flex;gap:18px;justify-content:center;align-items:center;margin:24px 0;flex-wrap:wrap}.home-pagination span{font-size:13px;color:var(--muted)}
@media(max-width:1100px){.home-card-grid{grid-template-columns:repeat(min(var(--home-columns,2),2),minmax(0,1fr))}}
@media(max-width:650px){.home-card-grid{grid-template-columns:minmax(0,1fr)}.home-layout-controls{padding:12px}.home-layout-controls .device-actions{gap:10px}.home-layout-controls button{padding:10px 12px}.home-pagination{gap:10px}.home-card-grid .home-cover-content{padding:16px}}
.device-network{min-width:0;border:1px solid var(--line);padding:16px}.device-network legend{padding:0 8px}.topology-tree,.topology-tree ul{list-style:none;padding:0;margin:20px 0}.topology-tree ul{margin-left:24px;padding-left:24px;border-left:2px solid var(--accent,#31584a)}.topology-tree li{position:relative;margin:18px 0;min-width:0}.topology-tree ul>li:before{content:"";position:absolute;left:-24px;top:28px;width:24px;border-top:2px solid var(--accent,#31584a)}.topology-node{border:1px solid var(--line);border-radius:10px;padding:20px;background:var(--paper);max-width:800px;overflow-wrap:anywhere}.topology-node h3{margin:12px 0}.topology-node dl{display:grid;gap:8px}.topology-node dl>div{display:grid;grid-template-columns:100px minmax(0,1fr);gap:10px}.topology-node dd{margin:0}.topology-node dt,.topology-node p{font-size:13px;color:var(--muted)}.topology-node .device-title{text-align:left}
@media(max-width:650px){.topology-tree ul{margin-left:8px;padding-left:12px}.topology-tree ul>li:before{left:-12px;width:12px}.topology-node{padding:14px}.topology-node dl>div{grid-template-columns:72px minmax(0,1fr)}.device-network .form-grid{grid-template-columns:1fr}}
.platform-topology-root{border:1px solid var(--line);border-top:4px solid var(--accent,#31584a);background:var(--paper);padding:22px;text-align:center;margin:24px auto;max-width:420px;border-radius:10px}.platform-place-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:24px;border-top:2px solid var(--line);padding-top:24px}.platform-place-node{position:relative;text-align:left;white-space:normal;min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--paper);color:var(--text);padding:22px}.platform-place-node:before{position:absolute;content:"";height:25px;top:-25px;left:50%;border-left:2px solid var(--line)}.platform-place-node h3{margin:16px 0}.platform-place-node p,.platform-place-node small{color:var(--muted)}.platform-place-node>span:last-child{display:block;margin-top:18px}.platform-place-node:hover{border-color:var(--accent,#31584a);background:var(--surface,#f1f2e9)}
.topology-tree{display:flex;gap:24px;align-items:start;flex-wrap:wrap}.topology-tree>li{flex:1 1 400px}.topology-tree ul{display:flex;gap:16px;flex-wrap:wrap}.topology-tree ul>li{flex:1 1 260px;max-width:560px}.topology-node{max-width:560px;padding:16px}.device-actions #devices-card-view[aria-pressed=true],.device-actions #devices-topology-view[aria-pressed=true],.device-actions #home-card-view[aria-pressed=true],.device-actions #home-topology-view[aria-pressed=true]{background:var(--accent,#31584a);color:#fff}.home-card-grid[hidden],.home-layout-controls[hidden],.home-pagination[hidden]{display:none!important}
+62
View File
@@ -0,0 +1,62 @@
China data: https://raw.githubusercontent.com/modood/Administrative-divisions-of-China/master/dist/pca-code.json
Snapshot: 2023-06-30; project no longer updates. HK/MO/TW root entries added; subdivisions entered manually.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
Country/region display names: Unicode CLDR, retrieved 2026-10-04
https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-localenames-full/main/zh/territories.json
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 2015-2024 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
SPDX-License-Identifier: Unicode-3.0
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+23 -3
View File
@@ -12,7 +12,12 @@
function catalog(state){return [
...(state.assets||[]).map(x=>({...x,source:'camera',deviceType:'摄像机'})),
...(state.recorders||[]).map(x=>({...x,source:'recorder',deviceType:'录像机'})),
...(state.devices||[]).map(x=>({...x,source:'device'}))];}
...(state.devices||[]).map(x=>({...x,source:'device'}))].map(x=>({...x,networkHosts:network(x,state).hosts}));}
function matches(device,query,sites){
const words=String(query||'').trim().toLowerCase().split(/\s+/).filter(Boolean);
const text=[device.name,device.deviceType,device.brand,device.model,device.serial,device.ownerName,device.managerName,device.managementHost,device.host,device.tailscaleHost,device.networkCidr,...(device.networkHosts||[]),...(device.tags||[]),...path(sites,device.siteId).map(s=>s.name)].join(' ').toLowerCase();
return words.every(word=>text.includes(word));
}
function groups(devices,dimensions,sites){
const result=new Map();
for(const device of devices){
@@ -23,7 +28,8 @@
const parts=path(sites,device.siteId);options=[parts.length?parts.map(s=>({key:'space:'+s.id,label:s.name})):[{key:'space:none',label:'未指定区域'}]];
}else{
const values=dimension==='tag'?(device.tags?.length?device.tags:['未标记']):[dimension==='type'?device.deviceType:dimension==='manager'?(device.managerName||'负责人待指定'):(device.ownerName||'所有人待指定')];
options=[...new Set(values)].map(value=>[{key:dimension+':'+value,label:value}]);
const ref=dimension==='manager'?device.managerRef:dimension==='owner'?device.ownerRef:'';
options=[...new Set(values)].map(value=>[{key:dimension+':'+(ref||value),label:value}]);
}
branches=branches.flatMap(branch=>options.map(option=>branch.concat(option)));
}
@@ -31,5 +37,19 @@
}
return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN'));
}
return {path,descendants,catalog,groups};
function network(device,state){
const channels=(state.cameras||[]).filter(c=>c.assetId===device.id);
const direct=channels.filter(c=>c.sourceKind!=='recorder'&&c.host).map(c=>c.host);
const hosts=[...new Set((device.source==='recorder'?[device.host]:direct.length?direct:[device.managementHost,device.host]).filter(Boolean))];
const via=[...new Set(channels.filter(c=>c.sourceKind==='recorder'&&c.recorderId).map(c=>c.recorderId))];
const archiveVia=[...new Set(channels.filter(c=>c.archiveSource==='recorder'&&c.archiveRecorderId).map(c=>c.archiveRecorderId))];
return {hosts,via,archiveVia,access:device.networkAccess||'unknown',channels};
}
function topology(devices){
const map=new Map(devices.map(d=>[d.id,d])),parents=new Map();
for(const d of devices){let parent=d.uplinkId||'',cursor=parent,seen=new Set([d.id]);while(cursor&&map.has(cursor)){if(seen.has(cursor)){parent='';break;}seen.add(cursor);cursor=map.get(cursor).uplinkId;}parents.set(d.id,map.has(parent)?parent:'');}
const children=new Map();for(const d of devices){const parent=parents.get(d.id);if(!children.has(parent))children.set(parent,[]);children.get(parent).push(d);}
return {parents,children};
}
return {path,descendants,catalog,groups,matches,network,topology};
});
+51 -7
View File
@@ -1,40 +1,83 @@
'use strict';
let deviceGroups=['type','space'],deviceGroupScope='',deviceViewSignature='';
let devicePresentation='cards';
const networkLabels={unknown:'接入线路待确认',lan:'局域网直连',tailscale:'Tailscale 跨网',public:'公网接入'};
function networkInfo(d){const n=ZhaoDevices.network(d,state);return `<div><dt>IP/域名</dt><dd>${n.hosts.length?n.hosts.map(esc).join('<br>'):'待填写'}</dd></div><div><dt>网络接入</dt><dd>${esc(networkLabels[n.access]||networkLabels.unknown)}${d.tailscaleHost?'<br>'+esc(d.tailscaleHost):''}</dd></div>`;}
function renderTopology(devices){
const tree=ZhaoDevices.topology(devices),all=ZhaoDevices.catalog(state),label=id=>all.find(d=>d.id===id)?.name||'关联设备待核对';
const nodes=new Map(),queue=[...(tree.children.get('' )||[])];for(let i=0;i<queue.length;i++)queue.push(...(tree.children.get(queue[i].id)||[]));const node=d=>{const info=ZhaoDevices.network(d,state),children=tree.children.get(d.id)||[];return `<li><article class="topology-node"><span class="badge">${esc(d.deviceType)}</span><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} ›</button></h3><p>${esc(siteName(d.siteId))}${d.point?' · '+esc(d.point):''}</p><dl>${networkInfo(d)}${d.networkCidr?`<div><dt>所提供网段</dt><dd>${esc(d.networkCidr)}</dd></div>`:''}<div><dt>物理连接</dt><dd>${d.uplinkId?esc(label(d.uplinkId))+' · ':''}${esc({wired:'网线连接',wifi:'Wi-Fi 连接'}[d.connectionType]||(d.uplinkId?'连接方式待确认':'上级及连接方式待确认'))}</dd></div></dl>${info.via.length?`<p>取流经录像机:${info.via.map(label).map(esc).join('、')}</p>`:''}${info.archiveVia.length?`<p>录像归档:${info.archiveVia.map(label).map(esc).join('、')}</p>`:''}${info.channels.length?`<p>镜头码流:${info.channels.filter(c=>c.ready).length} / ${info.channels.length} 已接通</p>`:'<p class="description">档案已登记,在线状态待核实</p>'}</article>${children.length?`<ul>${children.map(c=>nodes.get(c.id)).join('')}</ul>`:''}</li>`;};
for(const d of queue.reverse())nodes.set(d.id,node(d));
$('#device-topology').innerHTML=`<div class="panel"><h3>${esc(currentHome()?.name||'当前场所')} · 网络拓扑</h3><p class="description">随设备新增、编辑和区域筛选自动更新。连线表示档案中已确认的上级关系;录像机取流与录像归档另行标注。未登记的交换机端口、AP 及网络设备仍需补充。</p><ul class="topology-tree">${(tree.children.get('')||[]).map(d=>nodes.get(d.id)).join('')||'<li>当前范围没有已登记设备</li>'}</ul></div>`;
}
const deviceDimensions={type:'设备类型',space:'区域空间',manager:'负责人',owner:'所有人',tag:'自定义标签'};
function renderDeviceGrouping(){
$('#device-group-order').innerHTML=deviceGroups.map((g,i)=>`<div class="group-step"><span>${i+1}</span><select data-group-index="${i}" aria-label="第 ${i+1} 分组">${Object.entries(deviceDimensions).map(([key,label])=>`<option value="${key}" ${key===g?'selected':''} ${deviceGroups.includes(key)&&key!==g?'disabled':''}>${label}</option>`).join('')}</select><button class="quiet" data-group-up="${i}" aria-label="第 ${i+1} 分组前移" ${i===0?'disabled':''}>↑</button><button class="quiet" data-group-remove="${i}" aria-label="移除第 ${i+1} 分组">×</button></div>`).join('');
$$('[data-group-preset]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.groupPreset===deviceGroups.join(','))));
$('#device-group-add').disabled=deviceGroups.length>=Object.keys(deviceDimensions).length;
}
function videoDeviceActions(d){
if(!['camera','recorder'].includes(d.source))return '';
const channels=state.cameras.filter(c=>d.source==='camera'?c.assetId===d.id:c.recorderId===d.id||c.archiveRecorderId===d.id);
const target=`data-view-device="${esc(d.id)}" data-device-source="${d.source}"`;
return `<div class="camera-actions">${isAdmin()?`<button class="secondary" ${target} data-open-device-tab="cameras">配置镜头</button>`:''}${channels.length?`<button class="quiet" ${target} data-open-device-tab="live">查看画面</button><button class="quiet" ${target} data-open-device-tab="playback">录像回放</button>`:''}</div>${!channels.length?'<p class="description">尚未接入画面,请先配置镜头通道。</p>':''}`;
}
function deviceCard(d){
const location=ZhaoDevices.path(state.sites,d.siteId).map(s=>s.name).join(' / ')||'未指定区域';
const attr=d.source==='camera'?'edit-asset':d.source==='recorder'?'edit-recorder':'edit-device';
const detail=d.source==='camera'?`<div><dt>镜头 / 通道</dt><dd>${d.lensCount} 个镜头 · ${state.cameras.filter(c=>c.assetId===d.id).length} 个已配置通道</dd></div>`:d.source==='recorder'?`<div><dt>关联镜头</dt><dd>${state.cameras.filter(c=>c.recorderId===d.id||c.archiveRecorderId===d.id).length} 个</dd></div>`:'<div><dt>接入状态</dt><dd>仅登记档案,尚未接入控制</dd></div>';
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span>${isAdmin()?`<button class="quiet" data-${attr}="${esc(d.id)}">编辑</button>`:''}</div><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} <span aria-hidden="true">›</span></button></h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${detail}</dl>${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
return `<article class="site-card"><div class="site-heading"><span class="badge">${esc(d.deviceType)}</span>${isAdmin()?`<button class="quiet" data-${attr}="${esc(d.id)}">编辑</button>`:''}</div><h3><button class="device-title" data-view-device="${esc(d.id)}" data-device-source="${d.source}">${esc(d.name)} <span aria-hidden="true">›</span></button></h3><dl><div><dt>所在区域</dt><dd>${esc(location)}</dd></div><div><dt>所有人</dt><dd>${esc(d.ownerName||'待指定')}</dd></div><div><dt>负责人</dt><dd>${esc(d.managerName||'待指定')}</dd></div><div><dt>品牌型号</dt><dd>${esc([d.brand,d.model].filter(Boolean).join(' / ')||'待填写')}</dd></div>${networkInfo(d)}${detail}</dl>${videoDeviceActions(d)}${d.tags?.length?`<p class="device-tags">${d.tags.map(t=>`<span>${esc(t)}</span>`).join('')}</p>`:''}</article>`;
}
function renderDevices(){
const home=(state.households||[]).find(h=>h.id===state.familyId),saved=home?.deviceGrouping||['type','space'];
const signature=JSON.stringify(saved);
if(deviceGroupScope!==state.familyId||deviceViewSignature!==signature){deviceGroupScope=state.familyId;deviceViewSignature=signature;deviceGroups=[...saved];renderDeviceGrouping();}
$('#device-clear').hidden=!($('#site-filter').value||$('#device-type').value||$('#device-search').value);
const search=$('#device-search').value.trim().toLowerCase();
const all=ZhaoDevices.catalog(state),types=[...new Set(all.map(d=>d.deviceType))].sort();
setOptions($('#device-type'),'<option value="">所有类型</option>'+types.map(type=>`<option value="${esc(type)}">${esc(type)}</option>`).join(''));
const category=$('#device-type').value;
const range=$('#site-filter').value?ZhaoDevices.descendants(state.sites,$('#site-filter').value):null;
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&(!search||[d.name,d.deviceType,d.ownerName,d.managerName,...(d.tags||[])].join(' ').toLowerCase().includes(search)));
$('#device-count').textContent=`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&ZhaoDevices.matches(d,search,state.sites));
$('#asset-list').hidden=devicePresentation!=='cards';$('#device-topology').hidden=devicePresentation!=='topology';
$('#devices-card-view').setAttribute('aria-pressed',String(devicePresentation==='cards'));$('#devices-topology-view').setAttribute('aria-pressed',String(devicePresentation==='topology'));
if(devicePresentation==='topology')renderTopology(devices);
$('.view-presets').hidden=devicePresentation==='topology';$('.group-custom').hidden=devicePresentation==='topology';
$('#device-count').textContent=devicePresentation==='topology'?`${devices.length} 台设备 · 网络连接视图`:`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
$('#asset-list').innerHTML=devices.length?ZhaoDevices.groups(devices,deviceGroups,state.sites).map(group=>`<section class="device-group"><h3>${group.labels.map(esc).join(' <span aria-hidden="true">›</span> ')||'全部设备'} <small>${group.items.length} 台</small></h3><div class="site-grid">${group.items.map(deviceCard).join('')}</div></section>`).join(''):empty('当前条件下没有设备','可以清除筛选,或添加摄像机、录像机、灯、手机等设备档案。');
}
function resetDeviceView(){deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='<option value="">所有类型</option>';$('#device-group-order').replaceChildren();}
function responsibilityFields(form,item){for(const key of ['ownerName','managerName'])form.elements[key].value=item?.[key]||'';form.elements.tags.value=(item?.tags||[]).join('、');}
function deviceValues(data){return {...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};}
function resetDeviceView(){devicePresentation='cards';deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='<option value="">所有类型</option>';$('#device-group-order').replaceChildren();}
function responsibilityFields(form,item){
for(const field of ['managementHost','tailscaleHost','networkCidr','networkAccess','connectionType'])form.elements[field].value=item?.[field]||(['networkAccess','connectionType'].includes(field)?'unknown':'');
const uplink=form.elements.uplinkId;uplink.innerHTML='<option value="">上级设备待确认/直接接入</option>'+ZhaoDevices.catalog(state).filter(d=>d.id!==item?.id).map(d=>`<option value="${esc(d.id)}">${esc(d.name+' · '+d.deviceType)}</option>`).join('');uplink.value=item?.uplinkId||'';
form.elements.managementHost.closest('label').hidden=form.id==='recorder-form';
const direct=form.id==='asset-form'&&state.cameras.some(c=>c.assetId===item?.id&&c.sourceKind!=='recorder');
form.elements.managementHost.readOnly=direct;
form.elements.managementHost.title=direct?'此地址来自镜头通道,请在“配置镜头”内修改':'';
if(direct)form.elements.managementHost.value=ZhaoDevices.network(item,state).hosts[0]||'';
const candidates=state.responsibilityOptions||[];
for(const prefix of ['owner','manager']){
const input=form.elements[prefix+'Name'],select=form.elements[prefix+'Ref'];
input.value=item?.[prefix+'Name']||'';
const ref=item?.[prefix+'Ref']||'',missing=ref&&!candidates.some(p=>p.ref===ref);
select.innerHTML='<option value="">暂未指定</option>'+['账号成员','家谱人物'].map(kind=>`<optgroup label="${kind}">${candidates.filter(p=>p.kind===kind).map(p=>`<option value="${esc(p.ref)}">${esc(p.label)}</option>`).join('')}</optgroup>`).join('')+(missing?`<option value="${esc(ref)}">${esc(input.value)}(原关联人员,保留记录)</option>`:'')+'<option value="custom">手动填写人员或单位</option>';
select.value=ref||(input.value?'custom':'');
input.hidden=select.value!=='custom';input.required=select.value==='custom';
select.onchange=()=>{input.hidden=select.value!=='custom';input.required=select.value==='custom';if(select.value==='custom'){input.value='';input.focus();}else input.value=candidates.find(p=>p.ref===select.value)?.name||(select.value===ref?item?.[prefix+'Name']||'':'');};
}
form.elements.tags.value=(item?.tags||[]).join('、');
}
function deviceValues(data){const result={...data,tags:(data.tags||'').split(/[,,、\n]+/).map(t=>t.trim()).filter(Boolean)};for(const prefix of ['owner','manager']){if(result[prefix+'Ref']==='custom')result[prefix+'Ref']='';else if(!result[prefix+'Ref'])result[prefix+'Name']='';}return result;}
function openDevice(id){
const item=(state.devices||[]).find(d=>d.id===id)||{},f=$('#device-form');
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);responsibilityFields(f,item);$('#device-dialog').showModal();
f.elements.siteId.innerHTML='<option value="">未指定区域</option>'+spaceOptions();fillForm(f,item);if(!item.id)f.elements.siteId.value=$('#site-filter').value||'';responsibilityFields(f,item);$('#device-dialog').showModal();
}
function spaceOptions(exclude=''){
const omitted=exclude?ZhaoDevices.descendants(state.sites,exclude):new Set();
return state.sites.filter(s=>!omitted.has(s.id)).map(s=>`<option value="${esc(s.id)}">${esc(siteName(s.id))}</option>`).join('');
}
function clearDeviceFilters(){$('#site-filter').value='';$('#device-type').value='';$('#device-search').value='';renderDevices();}
$('#device-clear').onclick=clearDeviceFilters;
$('#devices-card-view').onclick=()=>{devicePresentation='cards';renderDevices();};$('#devices-topology-view').onclick=()=>{devicePresentation='topology';renderDevices();};
$('#device-search').oninput=renderDevices;$('#device-type').onchange=renderDevices;
$('#device-group-order').addEventListener('change',e=>{if(e.target.dataset.groupIndex!==undefined){deviceGroups[Number(e.target.dataset.groupIndex)]=e.target.value;renderDeviceGrouping();renderDevices();}});
$('#device-group-add').onclick=()=>{const next=Object.keys(deviceDimensions).find(k=>!deviceGroups.includes(k));if(next){deviceGroups.push(next);renderDeviceGrouping();renderDevices();}};
@@ -43,6 +86,7 @@ $('#new-device').onclick=()=>openDevice();
handleForm('device-form','/api/devices',deviceValues);
document.addEventListener('click',e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.groupPreset){deviceGroups=b.dataset.groupPreset.split(',');renderDeviceGrouping();renderDevices();}
if(b.dataset.editDevice)openDevice(b.dataset.editDevice);
if(b.dataset.groupRemove!==undefined){deviceGroups.splice(Number(b.dataset.groupRemove),1);renderDeviceGrouping();renderDevices();}
if(b.dataset.groupUp!==undefined){const i=Number(b.dataset.groupUp);if(i>0){[deviceGroups[i-1],deviceGroups[i]]=[deviceGroups[i],deviceGroups[i-1]];renderDeviceGrouping();renderDevices();}}
+3 -3
View File
@@ -6,11 +6,11 @@ const familyMap=new FamilyMap($('#family-tree'),id=>selectFamilyPerson(id));
const relationships=['本人','配偶','哥哥','弟弟','姐姐','妹妹','爸爸','妈妈','爷爷','奶奶','姥爷','姥姥','伯伯','叔叔','姑姑','舅舅','姨妈','儿子','女儿','孙子','孙女','外孙','外孙女','曾孙','曾孙女','玄孙','玄孙女','祖先','后裔','亲友','同事'];
$('#relationship-choices').innerHTML=relationships.map(r=>`<option value="${esc(r)}"></option>`).join('');
function clearFamilyState(){if(typeof clearFamilyHistory==='function')clearFamilyHistory();closeFamilyFullscreen();familyGeneration++;family={people:[],links:[],events:[],revision:0,canEdit:false};users=[];focusPerson='';familyMap.reset();if(typeof resetFamilyEvents==='function')resetFamilyEvents(true);$('#family-map-note').textContent='';$('#family-count').textContent='';$('#family-reference').replaceChildren();delete $('#family-reference').dataset.initialized;$('#family-search').value='';$('#family-archived').checked=false;$('#family-find').hidden=true;}
function personLocation(p,prefix){return ['Country','Province','City','District','Town','Community','Address'].map(k=>p[prefix+k]).filter(Boolean).join(' · ');}
function personLocation(p,prefix){return [ZhaoGeography.describe(p,prefix),...['Town','Community','Address'].map(k=>p[prefix+k])].filter(Boolean).join(' · ');}
function personSearch(p){return [p.name,p.alias,p.birthplace,personLocation(p,'native'),personLocation(p,'birth')].join(' ').toLowerCase();}
function personName(id){return family.people.find(p=>p.id===id)?.name||'未关联';}
function peopleOptions(blank=true){return (blank?'<option value="">尚未关联</option>':'')+family.people.map(p=>`<option value="${p.id}">${esc(p.name+(p.archived?'(已归档)':''))}</option>`).join('');}
function fillForm(form,data){form.reset();form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
function fillForm(form,data){resetEditorForm(form);form.querySelector('.form-error').textContent='';for(const [k,v]of Object.entries(data)){const e=form.elements[k];if(!e)continue;if(e.type==='checkbox')e.checked=!!v;else e.value=v??'';}}
function familyFormError(form,error){
if(error.stale)return;
const box=form.querySelector('.form-error');box.textContent=error.message;
@@ -56,7 +56,7 @@ function renderFamilyTree(){
familyMap.render({...family,focusId:focusPerson,reference:$('#family-reference').value,term:$('#family-search').value.trim().toLowerCase(),showArchived:$('#family-archived').checked});
}
function selectFamilyPerson(id,locate=false){focusPerson=id;if(locate){familyView='tree';if(family.people.find(p=>p.id===id)?.archived)$('#family-archived').checked=true;}renderFamily();if(locate)familyMap.locate(id);if(window.innerWidth<=1000&&!document.fullscreenElement)$('#family-detail').scrollIntoView({behavior:'smooth',block:'start'});}
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});$('#person-legacy-place').hidden=!p.birthplace;initDateFields();$('#person-dialog').showModal();}
function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});ZhaoGeography.fill($('#person-form'),p);$('#person-legacy-place').hidden=!p.birthplace;initDateFields();$('#person-dialog').showModal();}
function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();}
for(const [name,path]of [['person','person'],['relation','link']]){
$('#'+name+'-form').addEventListener('submit',async e=>{
+76
View File
@@ -0,0 +1,76 @@
'use strict';
const ZhaoGeography=(()=>{
const data=typeof ZhaoGeographyData!=='undefined'?ZhaoGeographyData:require('./data/geography-data.js');
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fields=['province','city','district'];
const key=p=>p?p+'Geography':'geography';
const legacy=(item,p,f)=>item[p?p+f[0].toUpperCase()+f.slice(1):f]||'';
function infer(item={},prefix=''){
if(item[key(prefix)])return {...item[key(prefix)]};
const v={mode:'china',body:'地球',countryCode:'CN',country:'中国',path:''};
let children=data.china,manual=false,valid=true;
for(const f of fields){
v[f]=legacy(item,prefix,f);const selected=children.find(x=>x.name===v[f]);v[f+'Code']=selected?.code||'';
if(v[f]&&!selected&&!manual)valid=false;
manual=manual||!!selected?.manual;children=selected?.children||[];
}
const country=legacy(item,prefix,'country');
if(country&&country!=='中国'&&country!=='中华人民共和国'){
const match=Object.entries(data.countries).find(([,name])=>name===country);
v.mode=match?'world':'custom';v.country=country;v.countryCode=match?.[0]||'';
}else if(!valid){v.mode='custom';v.country=country||'中国';}
return v;
}
function change(v,field,value){
const next={...v,[field]:value};
if(field==='provinceCode'){next.cityCode='';next.city='';next.districtCode='';next.district='';}
if(field==='cityCode'){next.districtCode='';next.district='';}
if(field==='countryCode'){next.province='';next.city='';next.district='';if(value==='CN')next.mode='china';}
if(field==='mode'){
Object.assign(next,{body:'地球',countryCode:value==='china'?'CN':'',country:value==='china'?'中国':'',provinceCode:'',cityCode:'',districtCode:'',province:'',city:'',district:'',path:''});
}
if(next.mode==='china'){
let children=data.china,manual=false;
for(const f of fields){if(manual)continue;const selected=children.find(x=>x.code===next[f+'Code']);next[f]=selected?.name||'';children=selected?.children||[];manual=!!selected?.manual;}
next.country='中国';next.body='地球';next.countryCode='CN';
}else if(next.mode==='world')next.country=data.countries[next.countryCode]||'';
return next;
}
function label(v){return [v.body&&v.body!=='地球'?v.body:'',v.country,v.province,v.city,v.district,v.path].filter(Boolean).filter((x,i,a)=>!i||x!==a[i-1]).join(' · ');}
function describe(item,prefix=''){
if(item[key(prefix)])return label(item[key(prefix)]);
return ['country',...fields].map(f=>legacy(item,prefix,f)).filter(Boolean).join(' · ');
}
function fill(form,item={}){
for(const root of form.querySelectorAll('[data-geography]')){
const prefix=root.dataset.geography||'';let value=infer(item,prefix);
const title=root.dataset.geoTitle||'地域';
function draw(){
const select=(field,text,options,disabled=false)=>`<label>${text}<select data-geo-field="${field}" aria-label="${esc(title+' · '+text)}" ${disabled?'disabled':''}>${options.map(([id,name])=>`<option value="${esc(id)}" ${value[field]===id?'selected':''}>${esc(name)}</option>`).join('')}</select></label>`;
const input=(field,text,placeholder='')=>`<label>${text}<input data-geo-field="${field}" value="${esc(value[field]||'')}" maxlength="${field==='path'?1000:100}" placeholder="${esc(placeholder)}" aria-label="${esc(title+' · '+text)}"></label>`;
let html=select('mode','地域范围',[['china','中国'],['world','世界 · 国家/地区'],['custom','自定义 · 历史地名/其他天体']]);
if(value.mode==='china'){
const province=data.china.find(x=>x.code===value.provinceCode),city=province?.children.find(x=>x.code===value.cityCode);
html+=select('provinceCode','省/自治区/直辖市',[['','请选择(可留空)'],...data.china.map(x=>[x.code,x.name])]);
if(province?.manual)html+=input('city','城市/地区')+input('district','区县/区域');
else html+=select('cityCode','地级市/州/盟',[['','请选择(可留空)'],...(province?.children||[]).map(x=>[x.code,x.name])],!province)+select('districtCode','区/县/县级市',[['','请选择(可留空)'],...(city?.children||[]).map(x=>[x.code,x.name])],!city);
html+='<p class="description full">选择上级后再选下级;变更上级会清空下级。内置区划为 2023 年数据,港澳台下级、新设区划或旧地名可手动填写或使用自定义。</p>';
}else if(value.mode==='world'){
html+=select('countryCode','国家/地区',[['','请选择'],...Object.entries(data.countries).sort((a,b)=>a[1].localeCompare(b[1],'zh-CN'))]);
html+=input('province','州/省/一级行政区')+input('city','城市/二级行政区')+input('district','区县/地方行政区');
html+='<p class="description full">国家/地区使用选项;各国层级不同,下级按当地名称填写,不适用可留空。</p>';
}else{
html+=input('body','天体/世界','例如:地球、月球、火星')+input('country','国家/地区(可空)')+input('province','省州/大区(可空)')+input('city','城市/区域(可空)')+input('district','区县/分区(可空)')+input('path','补充层级路径','例如:基地 / 居住区 / 一号楼');
html+='<p class="description full">自定义内容按原文保存,适合历史地名或扩展地域;不做行政区划校验。</p>';
}
root.innerHTML=`<legend>${esc(title)}</legend><div class="form-grid">${html}</div><output class="geo-path" aria-live="polite">${esc(label(value)||'地域待选择')}</output>`;
}
root.onchange=e=>{const field=e.target.dataset.geoField;if(!field)return;value=change(value,field,e.target.value);if(e.target.tagName==='SELECT'){draw();root.querySelector(`[data-geo-field="${field}"]`)?.focus();}else root.querySelector('output').textContent=label(value);};
root.oninput=e=>{const field=e.target.dataset.geoField;if(field&&e.target.tagName==='INPUT'){value[field]=e.target.value;root.querySelector('output').textContent=label(value);}};
root.geoValue=()=>({...value});draw();
}
}
function collect(form){return Object.fromEntries([...form.querySelectorAll('[data-geography]')].map(root=>[key(root.dataset.geography||''),root.geoValue()]));}
return {infer,change,label,describe,fill,collect};
})();
if(typeof module!=='undefined')module.exports=ZhaoGeography;
+85 -16
View File
@@ -1,32 +1,71 @@
'use strict';
pageInfo.households=['我的家庭与场所','家庭、办公室或工厂,分别组织,各自授权。'];
const scopeKind=h=>({home:'家庭',office:'办公场所',factory:'工厂'}[h.kind||'home']||'家庭');
let coverSaving=false;
let homeLayoutDraft=null,homeLayoutOwner='',homeLayoutDirty=false,homeLayoutSaving=false,homePage=0,homeArrange=false,homeDragId='';
let homePresentation='cards';
function homeOrder(homes,order){const byId=new Map(homes.map(h=>[h.id,h]));return [...new Set([...order,...homes.map(h=>h.id)])].filter(id=>byId.has(id)).map(id=>byId.get(id));}
function homeMove(id,step,target=''){
if(homeLayoutSaving)return;
const order=homeOrder(state.households||[],homeLayoutDraft.order).map(h=>h.id),from=order.indexOf(id),to=target?order.indexOf(target):from+step;
if(from<0||to<0||to>=order.length||from===to)return;
order.splice(from,1);order.splice(to,0,id);homeLayoutDraft.order=order;homeLayoutDirty=true;homePage=Math.floor(to/(homeLayoutDraft.columns*homeLayoutDraft.rows));renderHouseholds();
}
function homeArrangeActions(id,homes){const index=homes.findIndex(h=>h.id===id);return homeArrange?`<div class="home-order-actions"><span>拖动卡片或移动顺序</span><button class="quiet" data-home-move="${esc(id)}" data-step="-1" aria-label="${esc(homes[index].name)}前移" ${index===0?'disabled':''}>← 前移</button><button class="quiet" data-home-move="${esc(id)}" data-step="1" aria-label="${esc(homes[index].name)}后移" ${index===homes.length-1?'disabled':''}>后移 →</button></div>`:'';}
function renderHouseholds(){
const homes=state.households||[];
const saved=state.householdLayout||{revision:0,columns:2,rows:2,groupCity:false,order:[]};
if(homeLayoutOwner!==currentUser?.id){homeLayoutOwner=currentUser?.id||'';homeLayoutDirty=false;homeLayoutDraft=null;homePage=0;homeArrange=false;}
if(!homeLayoutDraft||!homeLayoutDirty&&!homeLayoutSaving&&homeLayoutDraft.revision!==saved.revision)homeLayoutDraft={...saved,order:[...saved.order]};
const homes=homeOrder(state.households||[],homeLayoutDraft.order);
setOptions($('#household-filter'),homes.map(h=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));
$('#household-filter').value=state.familyId||'';
if(activePage!=='households')return;
const cards=h=>`<article class="site-card home-card"><div class="home-cover ${h.coverUrl?'has-photo':''}">${h.coverUrl?`<img src="${esc(h.coverUrl)}" alt="${esc(h.name)}封面照片" loading="lazy">`:'<div class="home-cover-placeholder" aria-hidden="true">山川有归处 · 灯火有人家</div>'}<div class="home-cover-content"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span>${isAdmin()?`<button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button>`:''}</div><h3>${esc(h.name)}</h3><p class="home-location">${esc([h.province,h.city,h.district].filter(Boolean).join(' · ')||'地区待填写')}</p></div></div><div class="home-card-body"><p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">进入${esc(h.name)}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</div></article>`;
const groups=new Map();for(const h of homes){const key=[h.province,h.city||'未填写地级市'].filter(Boolean).join(' / ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(h);}
$('#household-list').innerHTML=[...groups].sort(([a],[b])=>a.localeCompare(b,'zh-CN')).map(([city,rows])=>`<section class="home-city-group"><h3>${esc(city)} <small>${rows.length} 个家庭或场所</small></h3><div class="site-grid">${rows.map(cards).join('')}</div></section>`).join('');
$('#home-columns').value=homeLayoutDraft.columns;$('#home-rows').value=homeLayoutDraft.rows;$('#home-group-city').checked=homeLayoutDraft.groupCity;
if(homeLayoutDraft.groupCity)homeArrange=false;
$('#home-arrange').checked=homeArrange;$('#home-arrange').disabled=homeLayoutDraft.groupCity||homeLayoutSaving;
$('#home-layout-save').disabled=!homeLayoutDirty||homeLayoutSaving;$('#home-layout-reset').disabled=!homeLayoutDirty||homeLayoutSaving;
for(const id of ['home-columns','home-rows','home-group-city'])$('#'+id).disabled=homeLayoutSaving;
$('#home-layout-status').textContent=homeLayoutSaving?'正在保存布局…':homeLayoutDirty?'布局已调整,点击“保存我的布局”后下次打开会沿用。':homeLayoutDraft.groupCity?'按城市分组展示;取消分组后可自由排列卡片。':'可设置每页列数和行数;手机端自动收为单列。布局仅影响当前账号。';
const capacity=homeLayoutDraft.columns*homeLayoutDraft.rows,pages=Math.max(1,Math.ceil(homes.length/capacity));homePage=Math.min(homePage,pages-1);
const visible=homes.slice(homePage*capacity,(homePage+1)*capacity);
$('#home-prev').disabled=homePage===0;$('#home-next').disabled=homePage>=pages-1;$('#home-page-info').textContent=`第 ${homePage+1} / ${pages} 页 · 共 ${homes.length} 个 · 每页最多 ${capacity} 张`;
$('#household-list').style.setProperty('--home-columns',homeLayoutDraft.columns);
const cards=h=>`<article class="site-card home-card" data-home-id="${esc(h.id)}"><div class="home-cover ${h.coverUrl?'has-photo':''}">${h.coverUrl?`<img src="${esc(h.coverUrl)}" alt="${esc(h.name)}封面照片" loading="lazy">`:'<div class="home-cover-placeholder" aria-hidden="true">山川有归处 · 灯火有人家</div>'}<div class="home-cover-content"><div class="site-heading"><span class="badge">${h.id===state.familyId?'当前归属 · ':''}${scopeKind(h)}</span>${isAdmin()?`<button class="quiet" data-edit-household="${esc(h.id)}">编辑档案</button>`:''}</div><h3>${esc(h.name)}</h3><p class="home-location">${esc(ZhaoGeography.describe(h)||'地区待填写')}</p></div></div><div class="home-card-body">${homeArrangeActions(h.id,homes)}<p class="description">${esc(h.note||'区域与设备归属独立管理')}</p><dl><div><dt>${!h.kind||h.kind==='home'?'一家之主':'主要负责人'}</dt><dd>${esc(h.ownerName||'待指定')}</dd></div><div><dt>备用负责人</dt><dd>${esc(h.successorName||'待指定')}</dd></div><div><dt>账号 / 空间</dt><dd>${h.members} 个 / ${h.sites} 处</dd></div></dl>${!h.successorId?'<p class="description">尚未指定备用负责人,建议提前安排可信人员接管。</p>':''}<div class="household-actions"><button class="secondary" data-enter-household="${esc(h.id)}">进入${esc(h.name)}</button>${h.canAssignOwner?`<button class="quiet" data-leadership="${esc(h.id)}">负责人安排与交接</button>`:''}</div>${h.leadershipHistory?.length?`<details><summary>交接记录</summary>${h.leadershipHistory.slice().reverse().map(r=>`<p class="description">${esc(new Date(r.at).toLocaleString())} · ${esc(r.actor)}<br>${esc(r.reason)}</p>`).join('')}</details>`:''}</div></article>`;
const groups=new Map();for(const h of visible){const key=[h.geography?.body&&h.geography.body!=='地球'?h.geography.body:'',h.geography?.country||h.country||'',h.province,h.city||'未填写城市/区域'].filter(Boolean).join(' / ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(h);}
$('#household-list').innerHTML=homeLayoutDraft.groupCity?[...groups].map(([city,rows])=>`<section class="home-city-group"><h3>${esc(city)}</h3><div class="home-card-grid">${rows.map(cards).join('')}</div></section>`).join(''):`<div class="home-card-grid">${visible.map(cards).join('')}</div>`;
$('#household-list').hidden=homePresentation!=='cards';$('#home-global-topology').hidden=homePresentation!=='topology';$('.home-layout-controls').hidden=homePresentation!=='cards';$('.home-pagination').hidden=homePresentation!=='cards';
$('#home-card-view').setAttribute('aria-pressed',String(homePresentation==='cards'));$('#home-topology-view').setAttribute('aria-pressed',String(homePresentation==='topology'));
if(homePresentation==='topology')$('#home-global-topology').innerHTML=`<div class="platform-topology-root"><h3>${esc(typeof branding==='object'?branding.name:'赵府')} · 数字管家</h3><p>${homes.length} 个已授权家庭/场所 · 点击进入内部拓扑</p></div><p class="description">此图表示平台与场所的管理关系;网络标签来自设备档案,连线不代表物理专线或实时在线状态。</p><div class="platform-place-grid">${homes.map(h=>`<button class="platform-place-node" data-home-network="${esc(h.id)}"><span class="badge">${esc(scopeKind(h))}</span><h3>${esc(h.name)}</h3><p>${esc(ZhaoGeography.describe(h)||'地址待填写')}</p><p>${h.deviceCount||0} 台已授权设备</p><small>${(h.networkAccesses||[]).map(x=>esc(networkLabels[x]||'接入线路待确认')).join(' · ')||'尚未登记网络设备'}</small><span>查看场所网络拓扑 →</span></button>`).join('')}</div>`;
for(const card of $$('#household-list .home-card')){card.draggable=homeArrange&&!homeLayoutSaving;card.ondragstart=e=>{if(e.target.closest('button,a,input,select')){e.preventDefault();return;}homeDragId=card.dataset.homeId;e.dataTransfer.setData('text/plain',homeDragId);};card.ondragover=e=>{if(homeArrange&&homeDragId)e.preventDefault();};card.ondrop=e=>{e.preventDefault();if(homeArrange&&homeDragId)homeMove(homeDragId,0,card.dataset.homeId);homeDragId='';};card.ondragend=()=>{homeDragId='';};}
}
for(const [id,key] of [['home-columns','columns'],['home-rows','rows'],['home-group-city','groupCity']])$('#'+id).onchange=e=>{homeLayoutDraft[key]=key==='groupCity'?e.target.checked:Number(e.target.value);homePage=0;homeLayoutDirty=true;renderHouseholds();};
$('#home-arrange').onchange=e=>{homeArrange=e.target.checked;renderHouseholds();};
$('#home-prev').onclick=()=>{homePage=Math.max(0,homePage-1);renderHouseholds();};$('#home-next').onclick=()=>{homePage++;renderHouseholds();};
$('#home-card-view').onclick=()=>{homePresentation='cards';renderHouseholds();};$('#home-topology-view').onclick=()=>{homePresentation='topology';renderHouseholds();};
$('#home-global-topology').addEventListener('click',async e=>{const b=e.target.closest('[data-home-network]');if(!b)return;if(b.dataset.homeNetwork!==state.familyId&&!await switchHousehold(b.dataset.homeNetwork))return;devicePresentation='topology';showPage('objects');});
$('#home-layout-reset').onclick=()=>{homeLayoutDirty=false;homeLayoutDraft=null;homePage=0;renderHouseholds();};
$('#household-list').addEventListener('click',e=>{const button=e.target.closest('[data-home-move]');if(button)homeMove(button.dataset.homeMove,Number(button.dataset.step));});
$('#home-layout-save').onclick=async()=>{if(homeLayoutSaving||!homeLayoutDirty)return;const generation=sessionGeneration;homeLayoutSaving=true;renderHouseholds();try{const saved=await api('/api/households/layout',homeLayoutDraft);if(generation!==sessionGeneration)return;state.householdLayout=saved;homeLayoutDraft={...saved,order:[...saved.order]};homeLayoutDirty=false;toast('卡片布局与顺序已保存');}catch(e){if(!e.stale)toast(e.message);}finally{homeLayoutSaving=false;if(generation===sessionGeneration)renderHouseholds();}};
async function switchHousehold(id){
if(!isPlatformAdmin()||!id||id===state.familyId)return;
if(id===state.familyId)return true;
if(!isPlatformAdmin()||!id)return false;
if(typeof confirmJournalDiscard==='function'&&!confirmJournalDiscard())return false;
const user=currentUser;
// Invalidate outstanding API responses, video players and dialogs before changing scope.
clearPrivateView();
householdScope=id;currentUser=user;loggedIn=true;
await auth();
await auth();return loggedIn;
}
function openHousehold(id){
if(coverSaving){toast('档案仍在保存,请稍候。');return;}
if(!isAdmin())return;
const item=(state.households||[]).find(h=>h.id===id);
if(!item&&!isPlatformAdmin())return;
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();
nextImageJob('cover');$('#household-cover-status').dataset.error='false';fillForm($('#household-form'),{profileRevision:0,...(item||{})});coverDraft=undefined;ZhaoGeography.fill($('#household-form'),item||{});$('#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='封面存入百度网盘,原图可选 30 MB 以内;浏览器会先压缩。仅向获授权账号开放。';$('#household-dialog').showModal();
}
async function openLeadership(id){
try{
if(id!==state.familyId)await switchHousehold(id);
if(id!==state.familyId&&!await switchHousehold(id))return;
const home=(state.households||[]).find(h=>h.id===id);
if(!home?.canAssignOwner)return;
const result=await api('/api/users');
@@ -43,21 +82,51 @@ $('#leadership-form').addEventListener('submit',async e=>{
try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
});
$('#household-filter').onchange=async e=>{await switchHousehold(e.target.value);if(loggedIn)showPage('overview');};
$('#household-filter').onchange=async e=>{const switched=await switchHousehold(e.target.value);if(switched&&loggedIn)showPage('overview');else e.target.value=state.familyId;};
$('#new-household').onclick=()=>openHousehold();
$('#household-form').addEventListener('submit',async e=>{
e.preventDefault();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;}
e.preventDefault();const f=e.currentTarget;if(coverSaving)return;
if(imageJobPending('cover')){f.querySelector('.form-error').textContent='照片仍在处理,请完成后保存。';return;}
if(imageJobs.get('cover')?.error){f.querySelector('.form-error').textContent='封面未准备成功:请重新选择图片,或点击“取消本次图片选择”。';return;}
const payload={...formValues(f),profileRevision:Number(f.elements.profileRevision.value||0),...(coverDraft!==undefined?{coverData:coverDraft}:{})};
const controls=[...f.elements],disabled=controls.map(x=>x.disabled),generation=sessionGeneration;
coverSaving=true;controls.forEach(x=>x.disabled=true);f.querySelector('.form-error').textContent=payload.coverData?'正在上传封面到百度网盘并校验,请稍候…':'正在保存档案…';
try{
const saved=await api('/api/households',payload);if(generation!==sessionGeneration)return;
if(payload.coverData&&!saved.coverRef?.fsId)throw Error('服务器未确认封面保存,请重试。');
// Adopt the returned revision even if a later state refresh fails.
f.elements.id.value=saved.id;f.elements.profileRevision.value=saved.profileRevision;
const view={...saved,coverUrl:saved.coverData||saved.coverRef?'/api/households/cover?id='+encodeURIComponent(saved.id)+'&v='+saved.coverRevision:''};
delete view.coverData;delete view.coverRef;const previous=(state.households||[]).find(x=>x.id===saved.id);
if(previous)Object.assign(previous,view);else (state.households||=[]).push(view);
renderHouseholds();f.closest('dialog').close();toast(payload.coverData?'封面已存百度网盘,归属档案已保存':payload.coverData===''?'归属档案已保存,已使用默认封面':'归属档案已保存');
try{await refresh();}catch(error){if(!error.stale&&generation===sessionGeneration)toast('档案已保存,列表刷新暂未完成,请稍后刷新页面。无需重复保存。');}
}catch(error){if(!error.stale&&generation===sessionGeneration)f.querySelector('.form-error').textContent=error.message;}
finally{coverSaving=false;controls.forEach((x,i)=>x.disabled=disabled[i]);}
});
document.addEventListener('click',async e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.editHousehold)openHousehold(b.dataset.editHousehold);
if(b.dataset.leadership)await openLeadership(b.dataset.leadership);
if(b.dataset.enterHousehold){await switchHousehold(b.dataset.enterHousehold);if(loggedIn)showPage('overview');}
if(b.dataset.enterHousehold){const switched=await switchHousehold(b.dataset.enterHousehold);if(switched&&loggedIn)showPage('overview');}
});
$('#household-cover-file').onchange=async e=>{const job=nextImageJob('cover'),generation=sessionGeneration;imageJobs.get('cover').pending=true;try{const image=await readImage(e.target.files[0],960);if(generation!==sessionGeneration||!imageJobCurrent('cover',job)||!$('#household-dialog').open)return;coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;$('#household-cover-status').textContent='照片已准备,保存档案后更新卡片。';}catch(e){if(generation===sessionGeneration&&imageJobCurrent('cover',job))$('#household-cover-status').textContent=e.message;}finally{if(imageJobCurrent('cover',job))imageJobs.get('cover').pending=false;}};
$('#household-cover-reset').onclick=()=>{nextImageJob('cover');coverDraft='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='保存后使用默认封面。';};
$('#household-cover-file').onchange=async e=>{
const file=e.target.files[0];if(!file)return;
const job=nextImageJob('cover'),generation=sessionGeneration,status=$('#household-cover-status');
imageJobs.get('cover').pending=true;status.dataset.error='false';status.textContent='正在压缩照片,请稍候…';$('#household-form .form-error').textContent='';
try{const image=await readImage(file,960);if(generation!==sessionGeneration||!imageJobCurrent('cover',job)||!$('#household-dialog').open)return;
coverDraft=image;$('#household-cover-preview').src=image;$('#household-cover-preview').hidden=false;status.textContent='封面预览已就绪(约 '+Math.ceil((image.length-22)*.75/1024)+' KB),保存后上传百度网盘并更新卡片。';
}catch(error){if(generation===sessionGeneration&&imageJobCurrent('cover',job)){imageJobs.get('cover').error=true;status.dataset.error='true';status.textContent=error.message+'。本次图片未保存,请重新选择或取消本次选择。';}}
finally{if(imageJobCurrent('cover',job))imageJobs.get('cover').pending=false;}
};
$('#household-cover-cancel').onclick=()=>{
nextImageJob('cover');coverDraft=undefined;$('#household-cover-file').value='';
const item=(state.households||[]).find(x=>x.id===$('#household-form').elements.id.value),img=$('#household-cover-preview');
img.hidden=!item?.coverUrl;if(item?.coverUrl)img.src=item.coverUrl;else img.removeAttribute('src');
$('#household-cover-status').dataset.error='false';$('#household-cover-status').textContent='已取消本次图片选择,保留原有封面。';$('#household-form .form-error').textContent='';
};
$('#household-cover-reset').onclick=()=>{nextImageJob('cover');coverDraft='';$('#household-cover-file').value='';$('#household-cover-preview').hidden=true;$('#household-cover-preview').removeAttribute('src');$('#household-cover-status').dataset.error='false';$('#household-cover-status').textContent='保存后使用默认封面。';$('#household-form .form-error').textContent='';};
$('#household-dialog').addEventListener('close',()=>nextImageJob('cover'));
$('#household-dialog').addEventListener('cancel',event=>{if(coverSaving)event.preventDefault();});
$('#household-cover-preview').addEventListener('error',()=>{$('#household-cover-status').textContent='封面暂时无法从网盘读取,请稍后重新打开;原有记录仍保留。';});
+20 -20
View File
File diff suppressed because one or more lines are too long
+41 -15
View File
@@ -2,6 +2,21 @@
pageInfo.journal=['个人记事','一言一照,都是阅历。私藏或分享,由自己决定。'];
let journalGeneration=0,journalData=null,journalPage=0,journalUpload=false,journalRequest=null,journalRecorder=null,journalTracks=null,journalVoice=[],journalVoiceTimer=null,journalConfigRevision=0;
const journalDraftMedia=new Set(),journalSelectedMedia=new Set();
let journalBaseline='',journalSaving=false;
function journalDraftSignature(){const f=$('#journal-form');return JSON.stringify([f.elements.text.value,f.elements.occurredOn.value,f.elements.category.value,f.elements.visibility.value,[...journalSelectedMedia].sort()]);}
function journalHasDraft(){return journalUpload||!!journalTracks||!!journalBaseline&&journalDraftSignature()!==journalBaseline;}
function confirmJournalDiscard(){if(journalSaving){toast('记事正在保存,请稍候。');return false;}return !journalHasDraft()||confirm('当前记事尚未保存,确定放弃本次输入吗?已上传的附件仍保留在附件库中。');}
function resetJournalFilters(mode=$('#journal-mode').value){
for(const id of ['journal-category','journal-search','journal-from','journal-to'])$('#'+id).value='';
$('#journal-mode').value=mode;if(mode!=='person')$('#journal-person').value='';
$('#journal-person-label').hidden=mode!=='person';journalPage=0;
}
function journalEmptyMessage(){
if(['journal-category','journal-search','journal-from','journal-to'].some(id=>$('#'+id).value))return '没有符合筛选条件的记事,可以点击“清除筛选”再查看。';
if($('#journal-mode').value==='feed')return '还没有分享给当前家庭/场所的动态。仅自己可见的记事,请切换到“我的个人阅历”。';
if($('#journal-mode').value==='person')return $('#journal-person').value?'此人暂无分享给当前家庭/场所的记事。':'请先选择要查看的人物。';
return '还没有个人记事,写下今天,也可以补记从前。';
}
const journalId=()=>[...crypto.getRandomValues(new Uint8Array(16))].map(b=>b.toString(16).padStart(2,'0')).join('');
const journalToday=()=>{const d=new Date();return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');};
function journalBusy(value){journalUpload=value;$('#journal-save').disabled=value;$('#journal-new').disabled=value;for(const control of $('#journal-filter').elements)control.disabled=value;$('#journal-prev').disabled=value||!journalData?.page;$('#journal-next').disabled=value||!journalData||(journalData.page+1)*20>=journalData.total;$('#journal-record').disabled=value||!journalData?.connection.connected||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;}
@@ -54,23 +69,24 @@ function leaveJournal(){
$('#journal-list').replaceChildren();$('#journal-files').disabled=false;$('#journal-save').disabled=false;
}
function clearJournalForm(){const f=$('#journal-form');f.reset();for(const key of ['id','revision','clientId'])f.elements[key].value='';}
function resetJournal(){leaveJournal();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library-list').replaceChildren();$('#journal-trash-list').replaceChildren();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';}
function newJournal(){clearJournalForm();stopJournalVoice();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library').open=false;const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();}
async function loadJournal(){
const g=++journalGeneration;if(!loggedIn)return;
function resetJournal(){leaveJournal();journalBaseline='';journalSaving=false;journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library-list').replaceChildren();$('#journal-trash-list').replaceChildren();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';}
function newJournal(){clearJournalForm();stopJournalVoice();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library').open=false;const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();journalBaseline=journalDraftSignature();}
async function loadJournal(focus=''){
if(journalSaving)return;const g=++journalGeneration;if(!loggedIn)return;
if(!$('#journal-form').elements.clientId.value)newJournal();
$('#journal-person-label').hidden=$('#journal-mode').value!=='person';
const query=new URLSearchParams({mode:$('#journal-mode').value,person:$('#journal-person').value,category:$('#journal-category').value,search:$('#journal-search').value,from:$('#journal-from').value,to:$('#journal-to').value,page:String(journalPage)});
const query=new URLSearchParams({mode:$('#journal-mode').value,person:$('#journal-person').value,category:$('#journal-category').value,search:$('#journal-search').value,from:$('#journal-from').value,to:$('#journal-to').value,page:String(journalPage),focus});
$('#journal-query-status').textContent='正在读取记事…';
try{
const result=await api('/api/journal?'+query);if(!journalValid(g))return;journalData=result;
const result=await api('/api/journal?'+query);if(!journalValid(g))return;journalData=result;journalPage=result.page;
const selected=$('#journal-person').value;$('#journal-person').innerHTML='<option value="">请选择人物</option>'+Object.entries(result.people).map(([id,name])=>`<option value="${esc(id)}">${esc(name)}</option>`).join('');$('#journal-person').value=selected;
$('#journal-query-status').textContent=result.total?`共 ${result.total} 条 · 第 ${result.page+1} 页`:'暂时没有记事。写下今天,也可以补记从前。';
$('#journal-query-status').textContent=result.total?`共 ${result.total} 条 · 第 ${result.page+1} 页`:journalEmptyMessage();
$('#journal-prev').disabled=!result.page;$('#journal-next').disabled=(result.page+1)*20>=result.total;
$('#journal-cloud-note').textContent=result.connection.connected&&!result.connection.reconnect?'附件保存到后台统一连接的百度网盘;可见范围由系统控制。':'文字可直接保存。照片、视频、语音需由超级管理员先连接百度网盘。';
$('#journal-files').disabled=journalUpload||!result.connection.connected||result.connection.reconnect;
$('#journal-record').disabled=$('#journal-files').disabled||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;
renderJournalUploads();renderJournalEntries();
if(focus){const card=$$('#journal-list [data-entry]').find(el=>el.dataset.entry===focus);if(card){card.classList.add('just-saved');card.scrollIntoView({behavior:'smooth',block:'center'});}}
}catch(e){if(!e.stale&&journalValid(g))$('#journal-query-status').textContent=e.message;}
}
function journalUploadCard(m,library=false){
@@ -99,17 +115,23 @@ function renderJournalEntries(){
}
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;}
event.preventDefault();if(journalSaving)return;if(journalUpload){journalMessage('请等当前附件上传完成,或取消上传后保存文字。');return;}
const f=event.currentTarget,g=journalGeneration,session=sessionGeneration;
const payload={...formValues(f),revision:Number(f.elements.revision.value||0),mediaIds:[...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value)};
const controls=[...f.elements,...$('#journal-filter').elements],disabled=controls.map(el=>el.disabled);
const unlock=()=>controls.forEach((el,i)=>el.disabled=disabled[i]);journalSaving=true;controls.forEach(el=>el.disabled=true);
try{const saved=await api('/api/journal',payload);if(!journalValid(g))return;journalSaving=false;unlock();newJournal();resetJournalFilters('mine');await loadJournal(saved.id);journalMessage('已保存,输入已清空;下方已重新加载个人阅历。');toast('记事已保存,正在展示保存结果');}
catch(e){if(!e.stale&&journalValid(g))journalMessage(e.message);}finally{journalSaving=false;if(session===sessionGeneration)unlock();}
});
$('#journal-new').onclick=newJournal;
$('#journal-filter').onsubmit=e=>{e.preventDefault();journalPage=0;loadJournal();};
$('#journal-new').onclick=()=>{if(!journalSaving&&confirmJournalDiscard())newJournal();};
$('#journal-form').addEventListener('input',()=>{if(!journalUpload&&!journalSaving)journalMessage(journalHasDraft()?'当前输入尚未保存。':'默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');});
$('#journal-filter-clear').onclick=()=>{resetJournalFilters();loadJournal();};
$('#journal-filter').onsubmit=e=>{e.preventDefault();if($('#journal-from').value&&$('#journal-to').value&&$('#journal-from').value>$('#journal-to').value){$('#journal-query-status').textContent='开始日期不能晚于结束日期。';return;}journalPage=0;loadJournal();};
$('#journal-mode').onchange=()=>{$('#journal-person-label').hidden=$('#journal-mode').value!=='person';journalPage=0;loadJournal();};
for(const id of ['journal-person','journal-category'])$('#'+id).onchange=()=>{journalPage=0;loadJournal();};
$('#journal-prev').onclick=()=>{journalPage--;loadJournal();};$('#journal-next').onclick=()=>{journalPage++;loadJournal();};
async function uploadJournalFile(file,resume=''){
if(!file||journalUpload)return;if(!journalData?.connection.connected){journalMessage('请先由超级管理员连接百度网盘。');return;}if(file.size>268435456||!file.size){journalMessage('单个文件须为 1 字节至 256 MB。');return;}
if(!file||journalUpload||journalSaving)return;if(!journalData?.connection.connected){journalMessage('请先由超级管理员连接百度网盘。');return;}if(file.size>268435456||!file.size){journalMessage('单个文件须为 1 字节至 256 MB。');return;}
journalBusy(true);const g=journalGeneration;$('#journal-save').disabled=true;$('#journal-files').disabled=true;$('#journal-cancel-upload').hidden=false;
try{
const hashes=await ZhaoFileHash.blocks(file,(n,total)=>journalMessage('正在计算文件校验:'+Math.round(n/total*100)+'%'),()=>journalValid(g)&&journalUpload);
@@ -148,7 +170,8 @@ document.addEventListener('change',e=>{if(e.target.dataset.journalResume){upload
document.addEventListener('click',async event=>{
const b=event.target.closest('button');if(!b)return;const g=journalGeneration;
if(b.dataset.journalPerson){$('#journal-mode').value='person';$('#journal-person-label').hidden=false;$('#journal-person').value=b.dataset.journalPerson;journalPage=0;loadJournal();return;}
if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];journalDraftMedia.clear();journalSelectedMedia.clear();e.mediaIds.forEach(id=>{journalDraftMedia.add(id);journalSelectedMedia.add(id);});renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);f.scrollIntoView({behavior:'smooth'});return;}
if(b.dataset.journalEdit){if(journalSaving||!confirmJournalDiscard())return;const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];journalDraftMedia.clear();journalSelectedMedia.clear();e.mediaIds.forEach(id=>{journalDraftMedia.add(id);journalSelectedMedia.add(id);});renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);journalBaseline=journalDraftSignature();f.scrollIntoView({behavior:'smooth'});return;}
if(journalSaving&&(b.dataset.useUpload||b.dataset.detachUpload||b.dataset.removeUpload||b.dataset.restoreUpload))return;
if(b.dataset.useUpload){journalDraftMedia.add(b.dataset.useUpload);journalSelectedMedia.add(b.dataset.useUpload);renderJournalUploads();return;}
if(b.dataset.detachUpload){journalDraftMedia.delete(b.dataset.detachUpload);journalSelectedMedia.delete(b.dataset.detachUpload);renderJournalUploads();return;}
if(b.dataset.previewMedia){previewJournalMedia(b.dataset.previewMedia);return;}
@@ -188,6 +211,7 @@ document.addEventListener('submit',async event=>{
});
async function loadBaiduSettings(){
const g=appearanceGeneration;$('#baidu-admin').hidden=!isPlatformAdmin();if(!isPlatformAdmin())return;
showSettingsSection();
try{const [config,status]=await Promise.all([api('/api/baidu/config'),api('/api/baidu/status')]);if(g!==appearanceGeneration||activePage!=='settings')return;
const f=$('#baidu-config');for(const k of ['appKey','appFolder','redirectUri'])f.elements[k].value=config[k];f.elements.secretKey.value='';f.elements.secretKey.placeholder=config.hasSecret?'已保存,留空保持原值':'填写 SecretKey';journalConfigRevision=config.revision;
$('#baidu-status').textContent=status.connected?(status.reconnect?'网盘授权已失效,请重新连接。':'已连接统一网盘:'+status.account):'尚未连接百度网盘';$('#baidu-connect').disabled=!config.configured;$('#baidu-disconnect').disabled=!status.connected;
@@ -196,4 +220,6 @@ async function loadBaiduSettings(){
$('#baidu-config').onsubmit=async e=>{e.preventDefault();const g=appearanceGeneration,f=e.currentTarget,b=f.querySelector('button');b.disabled=true;try{await api('/api/baidu/config',{...formValues(f),revision:journalConfigRevision});if(g!==appearanceGeneration)return;f.elements.secretKey.value='';await loadBaiduSettings();$('#baidu-config-status').textContent='应用配置已保存,下一步连接百度网盘。';}catch(e){if(!e.stale&&g===appearanceGeneration)$('#baidu-config-status').textContent=e.message;}finally{b.disabled=false;}};
$('#baidu-connect').onclick=async()=>{try{const result=await api('/api/baidu/connect',{});const address=new URL(result.url);if(address.origin!=='https://openapi.baidu.com')throw Error('授权地址不正确');location.assign(result.url);}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
$('#baidu-disconnect').onclick=async()=>{if(!confirm('解除本机网盘连接后,媒体将暂时无法访问;网盘文件保留。确认解除?'))return;try{await api('/api/baidu/disconnect',{});loadBaiduSettings();}catch(e){if(!e.stale)$('#baidu-status').textContent=e.message;}};
window.addEventListener('beforeunload',event=>{if(loggedIn&&journalHasDraft()){event.preventDefault();event.returnValue='';}});
document.addEventListener('DOMContentLoaded',()=>{const result=new URLSearchParams(location.search).get('baidu');if(result){history.replaceState(null,'',location.pathname);toast(result==='ok'?'网盘授权已保存,请在设置中心查看连接状态。':'授权未完成,请检查回调地址、原登录状态,并重新连接。');}},{once:true});
+18 -9
View File
@@ -5,6 +5,7 @@ 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;
let settingsSection='preferences';
const imageJobs=new Map();
function nextImageJob(key){const value=(imageJobs.get(key)?.value||0)+1;imageJobs.set(key,{value,pending:false});return value;}
function imageJobCurrent(key,value){return imageJobs.get(key)?.value===value;}
@@ -51,16 +52,23 @@ function appearanceFields(container,values,inherit){
}
function fillBrandForm(value){
invalidateBrandImages();
brandDraft=value;brandLogo=value.logoData||'';brandIcon=value.iconData||'';
brandDraft=value;brandLogo=value.logoData;brandIcon=value.iconData;
const form=$('#brand-form');form.reset();
for(const k of ['name','subtitle','motto','loginTitle','loginNote','footer'])form.elements[k].value=value[k];
appearanceFields($('#brand-defaults'),value.defaults,false);renderBrandImages();
}
function renderBrandImages(){
$('#brand-logo-preview').src=brandLogo||'/zhao-icon.png';$('#brand-icon-preview').src=brandIcon||'/zhao-icon.png';
$('#brand-logo-preview').src=brandLogo===undefined?(brandDraft?.logoUrl||'/zhao-icon.png'):(brandLogo||'/zhao-icon.png');$('#brand-icon-preview').src=brandIcon===undefined?(brandDraft?.iconUrl||'/zhao-icon.png'):(brandIcon||'/zhao-icon.png');
}
function showSettingsSection(section=settingsSection){
settingsSection=['preferences','baidu','brand','backup'].includes(section)&&(section==='preferences'||isPlatformAdmin())?section:'preferences';
for(const [key,id] of Object.entries({preferences:'preferences-form',baidu:'baidu-admin',brand:'brand-form',backup:'backup-panel'}))$('#'+id).hidden=key!==settingsSection;
$$('[data-settings-section]').forEach(b=>{b.hidden=b.hasAttribute('data-platform-setting')&&!isPlatformAdmin();b.setAttribute('aria-pressed',String(b.dataset.settingsSection===settingsSection));});
}
document.addEventListener('click',event=>{const b=event.target.closest('[data-settings-section]');if(b)showSettingsSection(b.dataset.settingsSection);});
async function loadSettings(){
const generation=++appearanceGeneration;$('#settings-error').textContent='正在读取…';$('#settings-private').hidden=true;
showSettingsSection();
try{
const [preferences,config,backup]=await Promise.all([api('/api/preferences'),isPlatformAdmin()?api('/api/appearance'):null,isPlatformAdmin()?api('/api/backups'):null]);
if(generation!==appearanceGeneration||activePage!=='settings')return;
@@ -84,10 +92,11 @@ $('#personal-follow').onclick=()=>{appearanceFields($('#personal-choices'),Objec
$('#personal-cancel').onclick=()=>{appearanceFields($('#personal-choices'),personalValues,true);applyAppearance();$('#preferences-status').textContent='已恢复已保存的偏好。';};
$('#settings-reload').onclick=loadSettings;
async function readImage(file,maxSide=512){
if(!file||!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>5*1024*1024)throw Error('请选择 5 MB 以内的 PNG、JPEG 或 WebP 图片');
const maxBytes=maxSide>512?30*1024*1024:5*1024*1024;
if(!file||!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>maxBytes)throw Error('请选择 '+(maxBytes/1024/1024)+' MB 以内的 PNG、JPEG 或 WebP 图片');
const bitmap=await createImageBitmap(file);
try{if(bitmap.width*bitmap.height>40000000)throw Error('图片尺寸过大,请先缩小');
let scale=Math.min(1,maxSide/Math.max(bitmap.width,bitmap.height));const canvas=document.createElement('canvas'),limit=maxSide>256?2*1024*1024:256*1024;
try{if(bitmap.width*bitmap.height>(maxSide>512?80000000:40000000))throw Error('图片尺寸过大,请先缩小');
let scale=Math.min(1,maxSide/Math.max(bitmap.width,bitmap.height));const canvas=document.createElement('canvas'),limit=maxSide>512?600*1024:maxSide>256?2*1024*1024:256*1024;
for(let attempt=0;attempt<5;attempt++){
canvas.width=Math.max(1,Math.round(bitmap.width*scale));canvas.height=Math.max(1,Math.round(bitmap.height*scale));
canvas.getContext('2d').drawImage(bitmap,0,0,canvas.width,canvas.height);const result=canvas.toDataURL('image/png');
@@ -97,17 +106,17 @@ 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,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;}};
$('#'+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)){imageJobs.get(type).error=true;$('#brand-status').textContent=e.message;}}finally{if(imageJobCurrent(type,job))imageJobs.get(type).pending=false;}};
}
$('#brand-logo-reset').onclick=()=>{nextImageJob('logo');nextImageJob('import');brandLogo='';renderBrandImages();};$('#brand-icon-reset').onclick=()=>{nextImageJob('icon');nextImageJob('import');brandIcon='';renderBrandImages();};
function brandPayload(){const form=$('#brand-form');return {...Object.fromEntries(['name','subtitle','motto','loginTitle','loginNote','footer'].map(k=>[k,form.elements[k].value])),defaults:readAppearanceFields($('#brand-defaults')),logoData:brandLogo,iconData:brandIcon,revision:brandDraft?.revision};}
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')),...(brandLogo!==undefined?{logoData:brandLogo}:{}),...(brandIcon!==undefined?{iconData:brandIcon}:{}),revision:brandDraft?.revision};}
$('#brand-form').addEventListener('submit',async e=>{
e.preventDefault();if(imageJobPending('logo')||imageJobPending('icon')||imageJobPending('import')){$('#brand-status').textContent='图片或配置仍在读取,请完成后保存。';return;}const controls=[...e.currentTarget.elements],generation=appearanceGeneration;for(const control of controls)control.disabled=true;
e.preventDefault();if(['logo','icon','import'].some(k=>imageJobs.get(k)?.error)){$('#brand-status').textContent='图片或配置读取失败,请重新选择,或恢复默认图片后再保存。';return;}if(imageJobPending('logo')||imageJobPending('icon')||imageJobPending('import')){$('#brand-status').textContent='图片或配置仍在读取,请完成后保存。';return;}const controls=[...e.currentTarget.elements],generation=appearanceGeneration;for(const control of controls)control.disabled=true;
try{const result=await api('/api/appearance',brandPayload());if(generation!==appearanceGeneration)return;fillBrandForm(result);await loadBranding();if(generation!==appearanceGeneration)return;$('#brand-status').textContent='发布配置已保存。已有个人偏好继续保留。';}
catch(e){if(!e.stale&&generation===appearanceGeneration)$('#brand-status').textContent=e.message;}finally{for(const control of controls)control.disabled=false;}
});
$('#brand-export').onclick=()=>{if(!brandDraft)return;const {revision,...value}=brandPayload(),blob=new Blob([JSON.stringify({format:'digital-steward-appearance',version:1,config:value},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='digital-steward-appearance.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
$('#brand-import').onchange=async e=>{const generation=appearanceGeneration;invalidateBrandImages();const job=nextImageJob('import');imageJobs.get('import').pending=true;try{const file=e.target.files[0];if(!file||file.size>1024*1024)throw Error('配置文件须小于 1 MB');const value=JSON.parse(await file.text());if(generation!==appearanceGeneration||!imageJobCurrent('import',job))return;if(value.format!=='digital-steward-appearance'||value.version!==1||!value.config||!brandDraft)throw Error('发布配置格式不正确');const config=value.config;if(Object.keys(config).sort().join('|')!==['name','subtitle','motto','loginTitle','loginNote','footer','defaults','logoData','iconData'].sort().join('|'))throw Error('配置字段不匹配');for(const k of Object.keys(appearanceChoices))if(!appearanceChoices[k][config.defaults?.[k]])throw Error('默认外观选项不正确');for(const k of ['logoData','iconData'])if(config[k]&&!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(config[k]))throw Error('图片格式不正确');fillBrandForm({...config,revision:brandDraft.revision});$('#brand-status').textContent='已读入表单,请核对名称、图片与默认外观后保存。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent('import',job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent('import',job))imageJobs.get('import').pending=false;}};
$('#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).some(k=>!['name','subtitle','motto','loginTitle','loginNote','footer','defaults','logoData','iconData'].includes(k))||['name','subtitle','motto','loginTitle','loginNote','footer','defaults'].some(k=>!(k in config)))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({...brandDraft,...config,revision:brandDraft.revision});$('#brand-status').textContent='已读入表单,请核对名称、图片与默认外观后保存。';}catch(e){if(generation===appearanceGeneration&&imageJobCurrent('import',job))$('#brand-status').textContent=e.message;}finally{if(imageJobCurrent('import',job))imageJobs.get('import').pending=false;}};
function fillBackup(value){const c=value.config,f=$('#backup-form');backupRevision=c.revision;f.elements.enabled.checked=c.enabled;if(f.elements.baidu)f.elements.baidu.checked=!!c.baidu;for(const k of ['time','keep','networkPath'])f.elements[k].value=c[k];renderBackup(value);}
function backupLabel(r){return !r?'尚未备份':r.error?(r.localOk?'本机成功;后续备份步骤未完成':'备份失败'):r.baiduOk?(r.cloudCleanupError?'网盘备份已校验;过期副本清理待重试':r.cloudCleanupOk?'网盘备份已校验,过期副本清理完成':'本机备份完成,百度网盘已确认文件'):r.networkOk?'本机和网络目录副本均已校验':'本机备份已校验';}
function renderBackup(value){
+3 -1
View File
@@ -1,5 +1,7 @@
'use strict';
let playbackRange=null;
function playbackSourceHint(){if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
function resetPlayback(){
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
@@ -49,7 +51,7 @@ function makeClip(){
}catch(e){$('#clip-status').textContent=e.message;}
}
$('#query-recordings').onclick=queryRecordings;
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=resetPlayback;$('#play-date').onchange=resetPlayback;
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=()=>{resetPlayback();playbackSourceHint();};$('#play-date').onchange=resetPlayback;
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
$('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
+15 -9
View File
@@ -2,6 +2,12 @@
let deviceContext=null,scopeEntered=false;
Object.assign(pageInfo,{overview:['场所总览','人、区域与设备,在这里各有归属。'],device:['设备档案','查看这台设备的位置、责任人与功能。'],heritage:['家庭传承','把人物、记忆、文化与物品的来历留存下来。']});
function currentHome(){return (state.households||[]).find(h=>h.id===state.familyId);}
function scopeReturn(page,device){
if(page==='overview')return {page:'households',label:'所有家庭与场所'};
if(device||page==='sites')return {page:'objects',label:'返回区域与设备'};
if(['family','heritage-records'].includes(page))return {page:'heritage',label:'返回家庭传承'};
return {page:'overview',label:'返回场所总览'};
}
function selectedDevice(){return deviceContext?ZhaoDevices.catalog(state).find(d=>d.id===deviceContext.id&&d.source===deviceContext.source):null;}
function deviceCameras(cameras,context=deviceContext){
if(!context)return cameras;
@@ -13,7 +19,7 @@ function enterDevice(source,id,tab='device'){
const device=ZhaoDevices.catalog(state).find(d=>d.source===source&&d.id===id);
if(!device){toast('该设备不存在或未获授权');return;}
stopPlayers();resetPlayback();if(typeof closePtz==='function')closePtz();
$('#site-filter').value='';deviceContext={source,id};scopeEntered=true;
const returnSite=$('#site-filter').value;$('#site-filter').value='';deviceContext={source,id,returnSite};scopeEntered=true;
showPage(tab==='device'&&source==='camera'&&deviceCameras(state.cameras).length?'live':tab);
}
function renderSteward(){
@@ -21,12 +27,12 @@ function renderSteward(){
if(deviceContext&&!device&&loggedIn){deviceContext=null;stopPlayers();resetPlayback();showPage('objects');return;}
$('#scope-navigation').hidden=!within;
$('#scope-nav-name').textContent=home?.name||'';
$('#household-context').hidden=!within;
$('#household-context').hidden=!within||['settings','storage'].includes(activePage);
$('#household-context-note').textContent=home?scopeKind(home)+' · '+home.name:'';
$('#device-context').hidden=!device||!['device','live','playback','cameras'].includes(activePage);
$('#wall-context').hidden=activePage!=='live'||!!device;
$('#device-breadcrumb').textContent=device?[home?.name,siteName(device.siteId),device.name].filter(Boolean).join(' / '):'';
$('#scope-back').textContent='返回 '+(home?.name||'场所');
$('#scope-back').textContent=scopeReturn(activePage,device).label;
const hasVideo=device&&['camera','recorder'].includes(device.source);
$$('[data-device-tab]').forEach(b=>{b.hidden=b.dataset.deviceTab!=='device'&&(!hasVideo||b.dataset.deviceTab==='cameras'&&!isAdmin());b.classList.toggle('active',b.dataset.deviceTab===activePage);});
if(device&&['device','live','playback','cameras'].includes(activePage)){
@@ -43,24 +49,24 @@ function renderSteward(){
function renderOverview(){
const home=currentHome();if(!home){$('#scope-overview').replaceChildren();return;}
const devices=ZhaoDevices.catalog(state),homeKind=!home.kind||home.kind==='home';
$('#scope-overview').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(scopeKind(home))} · 日常有序</span><h2>${esc(home.name)}</h2><p>${esc(home.note||'从每一处空间,照看每一件事物。')}</p></div><div class="steward-grid"><button class="steward-tile" data-page="objects"><strong>区域与设备</strong><span>${devices.length} 台设备 · ${state.sites.length} 处区域</span><small>按位置、类型或负责人查找,再进入具体设备</small></button><button class="steward-tile" data-open-wall><strong>本场所监控墙</strong><span>${state.cameras.length} 个已授权镜头</span><small>集中观看、排列或全屏;回放可进入对应摄像头</small></button>${homeKind&&(isAdmin()||currentUser?.familyAccess!=='none'||['read','edit'].includes(currentUser?.heritageAccess))?'<button class="steward-tile" data-page="heritage"><strong>家庭传承</strong><span>人物 · 记事 · 文化 · 物品</span><small>从家谱追溯记忆,也保存资料与物品来历</small></button>':''}${isAdmin()?'<button class="steward-tile" data-page="users"><strong>成员与权限</strong><span>访问范围 · 管理职责</span><small>亲属关系、责任归属与系统权限分别设置</small></button>':''}</div>`;
$('#scope-overview').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(scopeKind(home))} · 日常有序</span><h2>${esc(home.name)}</h2><p>${esc(home.note||'从每一处空间,照看每一件事物。')}</p></div><div class="steward-grid"><button class="steward-tile" data-page="journal"><strong>记事与阅历</strong><span>写日常 · 看动态 · 查阅历</span><small>文字、照片和录音;每条记事自行选择谁可以看</small></button><button class="steward-tile" data-page="objects"><strong>区域与设备</strong><span>${devices.length} 台设备 · ${state.sites.length} 处区域</span><small>按位置、类型或负责人查找,再进入具体设备</small></button><button class="steward-tile" data-open-wall><strong>本场所监控墙</strong><span>${state.cameras.length} 个已授权镜头</span><small>集中观看、排列或全屏;回放可进入对应摄像头</small></button>${homeKind&&(isAdmin()||currentUser?.familyAccess!=='none'||['read','edit'].includes(currentUser?.heritageAccess))?'<button class="steward-tile" data-page="heritage"><strong>家庭传承</strong><span>人物 · 记事 · 文化 · 物品</span><small>从家谱追溯记忆,也保存资料与物品来历</small></button>':''}${isAdmin()?'<button class="steward-tile" data-page="users"><strong>成员与权限</strong><span>访问范围 · 管理职责</span><small>亲属关系、责任归属与系统权限分别设置</small></button>':''}</div>`;
}
function renderDeviceDetail(device){
if(!device){$('#device-detail').innerHTML=empty('设备暂不可用','返回设备目录重新选择');return;}
const fields=[['类型',device.deviceType],['所属场所',currentHome()?.name],['所在区域',siteName(device.siteId)],['所有人',device.ownerName||'待指定'],['负责人',device.managerName||'待指定'],['品牌 / 型号',[device.brand,device.model].filter(Boolean).join(' / ')||'待填写'],['序列号',device.serial||'待填写']];
const fields=[['类型',device.deviceType],['所属场所',currentHome()?.name],['所在区域',siteName(device.siteId)],['所有人',device.ownerName||'待指定'],['负责人',device.managerName||'待指定'],['品牌 / 型号',[device.brand,device.model].filter(Boolean).join(' / ')||'待填写'],['序列号',device.serial||'待填写'],['IP/域名',ZhaoDevices.network(device,state).hosts.join(' / ')||'待填写'],['网络接入',networkLabels[device.networkAccess||'unknown']],['Tailscale 地址',device.tailscaleHost||'未填写']];
const attr=device.source==='camera'?'edit-asset':device.source==='recorder'?'edit-recorder':'edit-device';
$('#device-detail').innerHTML=`<section class="panel"><div class="section-head"><h2>一物一档</h2>${isAdmin()?`<button class="quiet" data-${attr}="${esc(device.id)}">编辑设备档案</button>`:''}</div><dl>${fields.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl><p class="description">${esc(device.note||'')}</p>${device.source==='device'?'<p class="description">当前已登记档案;自动控制需按实际设备协议接入。</p>':''}${device.source==='recorder'?'<p class="description">可查看已关联镜头。录像机硬盘的历史检索与回放仍待设备适配。</p>':''}</section>`;
$('#device-detail').innerHTML=`<section class="panel"><div class="section-head"><h2>一物一档</h2>${isAdmin()?`<button class="quiet" data-${attr}="${esc(device.id)}">编辑设备档案</button>`:''}</div><dl>${fields.map(([k,v])=>`<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl><p class="description">${esc(device.note||'')}</p>${videoDeviceActions(device)}${device.source==='device'?'<p class="description">当前已登记档案;自动控制需按实际设备协议接入。</p>':''}${device.source==='recorder'?'<p class="description">可查看已关联镜头。录像机硬盘的历史检索与回放仍待设备适配。</p>':''}</section>`;
}
function renderHeritageHome(){
const canFamily=isAdmin()||currentUser?.familyAccess!=='none',canHeritage=isAdmin()||['read','edit'].includes(currentUser?.heritageAccess);
$('#heritage-home').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(currentHome()?.name||'')} · 薪火相传</span><h2>留住来处,也照看未来</h2><p>人物、记事、文化资料与家庭物品分别保存,关联同一个家庭。</p></div><div class="steward-grid">${canFamily?'<button class="steward-tile" data-family-section="tree"><strong>人物与家谱</strong><span>亲属关系 · 人物档案</span><small>从祖辈到后辈,查看全家关系</small></button><button class="steward-tile" data-family-section="timeline"><strong>家庭记事</strong><span>时间线 · 相关人物</span><small>追溯重要时刻与详细记载</small></button>':''}${canHeritage?'<button class="steward-tile" data-heritage-kind="culture"><strong>文化与家风</strong><span>家训 · 故事 · 手艺 · 资料</span><small>记录来源、整理人和相关内容</small></button><button class="steward-tile" data-heritage-kind="asset"><strong>家庭物品与财产档案</strong><span>来历 · 保管 · 交接意愿</span><small>档案记录不自动改变所有权或系统权限</small></button>':'<p class="description">文化与财产档案须由家庭管理员另行授权。</p>'}</div>`;
$('#heritage-home').innerHTML=`<div class="scope-intro"><span class="eyebrow">${esc(currentHome()?.name||'')} · 薪火相传</span><h2>留住来处,也照看未来</h2><p>这里整理家谱、家族重大记事、文化和物品来历。个人近况与日常分享,请进入“记事与阅历”。</p></div><div class="steward-grid">${canFamily?'<button class="steward-tile" data-family-section="tree"><strong>人物与家谱</strong><span>亲属关系 · 人物档案</span><small>从祖辈到后辈,查看全家关系</small></button><button class="steward-tile" data-family-section="timeline"><strong>家族重大记事</strong><span>时间线 · 相关人物</span><small>追溯重要时刻与详细记载</small></button>':''}${canHeritage?'<button class="steward-tile" data-heritage-kind="culture"><strong>文化与家风</strong><span>家训 · 故事 · 手艺 · 资料</span><small>记录来源、整理人和相关内容</small></button><button class="steward-tile" data-heritage-kind="asset"><strong>家庭物品与财产档案</strong><span>来历 · 保管 · 交接意愿</span><small>档案记录不自动改变所有权或系统权限</small></button>':'<p class="description">文化与财产档案须由家庭管理员另行授权。</p>'}</div>`;
}
function resetSteward(){deviceContext=null;scopeEntered=false;for(const id of ['scope-overview','device-detail','heritage-home'])$('#'+id).replaceChildren();}
document.addEventListener('click',e=>{
const b=e.target.closest('button');if(!b)return;
if(b.dataset.viewDevice)enterDevice(b.dataset.deviceSource,b.dataset.viewDevice);
if(b.dataset.viewDevice)enterDevice(b.dataset.deviceSource,b.dataset.viewDevice,b.dataset.openDeviceTab||'device');
if(b.dataset.deviceTab)showPage(b.dataset.deviceTab);
if(b.hasAttribute('data-open-wall')){deviceContext=null;showPage('live');}
if(b.id==='scope-back')showPage('overview');
if(b.id==='scope-back'){const target=scopeReturn(activePage,selectedDevice()).page;if(target==='objects'&&deviceContext)$('#site-filter').value=deviceContext.returnSite||'';showPage(target);}
if(b.dataset.familySection){familyView=b.dataset.familySection;showPage('family',{keepFamilyView:true});}
});
+6
View File
@@ -26,3 +26,9 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
/* v0.1.21: presets and recording tools */
.ptz-panel{width:620px;display:grid;grid-template-columns:1fr auto;max-height:80dvh;overflow:auto;align-items:start}.ptz-panel>div:first-child{grid-column:1}.ptz-panel .ptz-pad{grid-column:2;grid-row:1/3}#ptz-presets{grid-column:1/-1;min-width:0;border-top:1px solid var(--line);padding-top:12px}#ptz-presets .section-head{margin:0;gap:10px}#ptz-presets p{max-width:none;line-height:1.6}#preset-count{color:var(--muted);font-size:12px}.preset-row{display:flex;align-items:center;gap:5px;padding:9px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}.preset-row strong{font-size:13px;flex:1;overflow-wrap:anywhere}.preset-row button{padding:5px 8px;font-size:12px}#preset-form{display:flex;align-items:end;gap:10px;margin-top:12px}#preset-form label{flex:1;margin:0}#preset-name{width:100%}#preset-confirmation{padding:12px;background:#553b31;border-radius:8px;margin-top:10px}#ptz-close{justify-self:start}.playback-tools{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:12px 0}.playback-tools .check{margin:0}.clip-editor{border-top:1px solid var(--line);padding-top:12px}.clip-editor summary{cursor:pointer;margin-bottom:14px}#snapshot-dialog{width:min(1000px,92vw)}#snapshot-image{width:100%;max-height:65vh;object-fit:contain;background:#000;border-radius:8px}#play-position{font-size:13px;color:var(--muted)}
@media(max-width:600px){.ptz-panel{right:12px;bottom:12px;padding:12px;gap:12px}.ptz-panel .ptz-pad{grid-column:2}#preset-form{flex-wrap:wrap}#preset-form label{flex-basis:100%}}
/* Frequent tasks stay visible; advanced grouping and platform settings are opened on demand. */
.settings-sections,.view-presets{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 22px}
.settings-sections button[aria-pressed="true"],.view-presets button[aria-pressed="true"]{background:var(--accent,#30594e);color:var(--paper,#fff);border-color:var(--accent,#30594e)}
.group-custom{margin:16px 0}.group-custom summary{cursor:pointer;padding:10px 0}
.device-search-row{align-items:end}.device-search-row>button{align-self:end}
.journal-entry.just-saved{outline:2px solid var(--accent,#30594e);outline-offset:4px;scroll-margin-block:24px}