Compare commits

..

4 Commits

40 changed files with 1173 additions and 94 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
data/
/data/
recordings/
runtime/
*.db*
+21
View File
@@ -1,5 +1,26 @@
# 赵府智家 · 视觉系统
## 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 · 日常操作流程与手机网页
- 家庭或办公、工厂是权限归属;内部区域可分多级。设备提供按区域、类型、负责人三个快捷视图,并保留自定义组合。搜索支持“卧室 灯”等多词组合,涵盖区域、品牌、人员与标签。
+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.30
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
+2 -1
View File
@@ -199,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),[])
+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()
+1 -1
View File
@@ -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('旧村名'));
});
+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,/仍在处理/);
});
+26
View File
@@ -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\)/);
});
+11 -11
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,7 +53,7 @@ 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 = ''; }
@@ -94,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(''));
@@ -118,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`));
@@ -140,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><button class="quiet" data-child-site="${esc(s.id)}">+ 新增下级区域</button><p class="description">${esc(siteName(s.id))}</p><dl><div><dt>所属</dt><dd>${esc(s.owner||'待填写')}</dd></div><div><dt>地区</dt><dd>${esc([s.province,s.city,s.district,s.street].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>小区 / 园区</dt><dd>${esc(s.community||'待填写')}</dd></div><div><dt>楼栋门牌</dt><dd>${esc([s.building,s.unit,s.floor,s.door].filter(Boolean).join(' / ')||'待填写')}</dd></div><div><dt>本级设备</dt><dd>${ZhaoDevices.catalog(state).filter(a=>a.siteId===s.id).length} 台 / ${state.cameras.filter(c=>c.siteId===s.id).length} 个镜头</dd></div><div><dt>详细地址</dt><dd>${esc(s.address||'待填写')}</dd></div></dl></article>`).join(''):empty('建立第一个空间','给区域起个名字,按需选择上级区域;类型和地址可稍后填写。','<button data-action="new-site">+ 新建空间</button>');
$('#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;
@@ -154,7 +154,7 @@ 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,parentId='') {const f=$('#site-form');resetEditorForm(f);f.elements.parentId.innerHTML='<option value="">顶层区域</option>'+spaceOptions(id);f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(!item){f.elements.parentId.value=parentId||$('#site-filter').value||'';f.elements.kind.value=currentHome()?.kind==='office'?'company':currentHome()?.kind||'home';}if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();}
function 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');
@@ -164,14 +164,14 @@ function resetEditorForm(form){
// Hidden input values become reset defaults in browsers; always clear record identity explicitly.
for(const field of form.querySelectorAll('input[type="hidden"]'))field.value=field.name.toLowerCase().includes('revision')?'0':'';
}
function formValues(f) {return Object.fromEntries(new FormData(f).entries());}
function 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.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));});
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);}};
@@ -191,7 +191,7 @@ function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKi
$('#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('两次输入的新密码不一致');
+15
View File
@@ -341,3 +341,18 @@ html[data-theme=ink] .journal-upload-card{background:#20352d}html[data-theme=ink
.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
+19 -4
View File
@@ -12,10 +12,10 @@
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.tags||[]),...path(sites,device.siteId).map(s=>s.name)].join(' ').toLowerCase();
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){
@@ -28,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)));
}
@@ -36,5 +37,19 @@
}
return [...result.values()].sort((a,b)=>a.labels.join('/').localeCompare(b.labels.join('/'),'zh-CN'));
}
return {path,descendants,catalog,groups,matches};
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};
});
+44 -5
View File
@@ -1,16 +1,31 @@
'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'];
@@ -23,12 +38,35 @@ function renderDevices(){
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)&&ZhaoDevices.matches(d,search,state.sites));
$('#device-count').textContent=`${devices.length} 台设备 · 按 ${deviceGroups.map(g=>deviceDimensions[g]).join(' → ')||'全部平铺'} 查看`;
$('#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);if(!item.id)f.elements.siteId.value=$('#site-filter').value||'';responsibilityFields(f,item);$('#device-dialog').showModal();
@@ -39,6 +77,7 @@ function spaceOptions(exclude=''){
}
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();}};
+2 -2
View File
@@ -6,7 +6,7 @@ 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('');}
@@ -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;
+78 -11
View File
@@ -1,15 +1,51 @@
'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(id===state.familyId)return true;
if(!isPlatformAdmin()||!id)return false;
@@ -21,10 +57,11 @@ async function switchHousehold(id){
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{
@@ -48,10 +85,24 @@ $('#leadership-form').addEventListener('submit',async e=>{
$('#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;
@@ -60,6 +111,22 @@ document.addEventListener('click',async e=>{
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='封面暂时无法从网盘读取,请稍后重新打开;原有记录仍保留。';});
+14 -14
View File
File diff suppressed because one or more lines are too long
+10 -9
View File
@@ -52,13 +52,13 @@ 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';
@@ -92,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');
@@ -105,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 -3
View File
@@ -53,9 +53,9 @@ function renderOverview(){
}
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);
@@ -64,7 +64,7 @@ function renderHeritageHome(){
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'){const target=scopeReturn(activePage,selectedDevice()).page;if(target==='objects'&&deviceContext)$('#site-filter').value=deviceContext.returnSite||'';showPage(target);}