Compare commits

..

2 Commits

57 changed files with 4444 additions and 169 deletions
+3
View File
@@ -13,3 +13,6 @@ mediamtx
mediamtx.exe
*.tar.gz
/frontend/node_modules/
/frontend/.vite/
+6
View File
@@ -1,5 +1,11 @@
# 赵府智家系统架构
## 前端(v0.1.35)
Vue 3 单文件组件负责应用外壳、家庭卡片、设备目录和网络图,Vite 打包,Bootstrap 5 提供布局与控件样式。`frontend/src/composables/bridge.js` 接收已鉴权的状态快照;`web/vue-bridge.js` 封装现有业务动作。视频、家谱、记事等旧业务控制器暂按依赖顺序加载,只管理各自的兼容容器,不能覆盖 Vue 管理的节点。
生产请求 `/` 返回预构建的 `web/ui/index.html`,仅允许读取哈希命名的 JS/CSS;源码与构建产物一起提交,生产主机不运行构建工具。网络状态接口只检查当前管理员授权范围内的已登记设备,30 秒缓存,区别节点在线、端口可达与子网路由可见。
## 部署与职责
系统运行于自己的 Ubuntu 小电脑,提供局域网和 Tailscale 网页入口。第一阶段是视觉模块。管理程序、SQLite 和 MediaMTX 在同一主机运行,使用独立服务用户;网页只访问管理程序,设备凭据与媒体管理接口留在服务端。
+6
View File
@@ -1,5 +1,11 @@
# 赵府智家 · 视觉系统
## v0.1.35 · Vue、Bootstrap 与设备网络状态
前端采用 Vue 3 + Vite + Bootstrap 5,家庭卡片、设备目录与网络拓扑组件化,提供桌面及手机布局。GL.iNet 等路由器显示服务器探测状态、Tailscale 节点与子网路由状态,并提供管理后台跳转。权限仍以家庭/场所隔离。
详见 [本次发布](RELEASE-v0.1.35.md) 与 [前端构建和模块边界](frontend/README.md)。复杂视频、家谱、记事控制器暂通过兼容层保留;生产主机直接使用已提交的构建产物,无需安装 Node。
## v0.1.32 · 真实网盘验收与保存反馈
- 修复家庭档案已保存、后续列表刷新失败时提示不可见的问题;立即提示保存结果,刷新失败单独提示,无需重复提交。
+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 及物理线路仍需补充。图上的接入方式是档案属性,不会开放端口或自动授予网络权限,也不表示设备已经通过在线探测。录像机历史回放仍需设备协议适配。
公开源码不包含设备凭据、用户资料、业务数据库或网盘授权信息。
+18
View File
@@ -0,0 +1,18 @@
# v0.1.35 — Vue 与 Bootstrap 界面、路由器网络状态
- 新增 Vue 3 + Vite + Bootstrap 5 前端,统一侧栏、页首、卡片、按钮、表单、弹窗及手机排版。使用本地构建资源,不依赖外部 CDN,生产主机不需要 Node。
- 家庭卡片、分页、布局排序、整体拓扑、设备卡片与场所网络拓扑迁为 Vue 组件;复杂记事、家谱和视频控制器暂保留在明确的兼容边界中,避免重建正在播放的视频。
- 路由器、网关及服务器卡片自动检查登记地址的服务端口;设备详情也提供网络检查。区分服务器到设备的实际可达性、Tailscale 节点在线状态和所需子网路由是否可见。
- 增加“打开管理后台”入口,分别显示设备地址和 Tailscale 地址,在新标签页打开。未检测到 Web 服务时保留标有“待连通”的默认 HTTP 入口;当前浏览器仍须具备到设备的网络路径。
- 网络接口仅允许本家庭/场所管理员及超级管理员访问已授权设备;禁止通过请求传入任意目标。检查缓存 30 秒,限制并发,切换场所或账号时丢弃旧结果。不会保存路由器密码或修改其配置。
- 修复显式点击摄像头“设备详情”时被自动跳转到实时画面的问题。
## 验证
167 项 Python、131 项 JavaScript 回归测试。Edge 实际操作覆盖登录、三列卡片布局保存、家庭下钻、设备新增及编辑回填、所有人/负责人选择、上级关联、自动拓扑更新、个人记事保存清空与预览、退出登录及普通成员范围隔离。检查 390 像素手机布局与折叠菜单。
## 范围
网络状态来自服务器端 TCP 检查及 Tailscale 本机视图,不代表完整物理布线发现,也不等于互联网出口健康检查。设备未连通时仍会保留档案。未迁移的业务控制器保留在兼容层;这次不是所有旧业务 JavaScript 的完全重写。
公开仓库不含用户资料、业务数据库、网盘授权或设备凭据。
+1 -1
View File
@@ -1 +1 @@
0.1.33
0.1.35
+13 -5
View File
@@ -34,6 +34,7 @@ import genealogy
import geography
import households
import inventory
import network_status
import heritage
import appearance
import cloud_images
@@ -84,6 +85,7 @@ ACCOUNTS = identity.Accounts(sys.modules[__name__])
FAMILY = genealogy.Family(sys.modules[__name__])
HOUSEHOLDS = households.Households(sys.modules[__name__])
INVENTORY = inventory.Inventory(sys.modules[__name__])
NETWORK = network_status.NetworkStatus(sys.modules[__name__])
HERITAGE = heritage.Heritage(sys.modules[__name__])
APPEARANCE = appearance.Appearance(sys.modules[__name__])
BACKUPS = backups.Backups(sys.modules[__name__])
@@ -263,7 +265,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('镜头数不能小于已经建立的通道数')
@@ -295,7 +297,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'])
@@ -657,7 +659,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()
@@ -668,6 +670,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':
@@ -920,6 +924,8 @@ class Handler(BaseHTTPRequestHandler):
self.require_auth()
if path == '/api/state':
self.answer(ACCOUNTS.state(self.user, status()))
elif path == '/api/devices/network':
self.require_manager(); self.answer(NETWORK.read(self.user, query))
elif path == '/api/baidu/config':
self.require_admin(); self.answer(BAIDU.public_config())
elif path == '/api/baidu/status':
@@ -1052,7 +1058,7 @@ class Handler(BaseHTTPRequestHandler):
else:
raise Problem('接口不存在', 404)
else:
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
files = {'/': 'ui/index.html', '/vue-bridge.js':'vue-bridge.js', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js', '/households.js': 'households.js',
'/device-model.js': 'device-model.js', '/devices.js': 'devices.js',
'/steward.js': 'steward.js', '/heritage.js': 'heritage.js', '/personalization.js': 'personalization.js',
@@ -1067,7 +1073,9 @@ class Handler(BaseHTTPRequestHandler):
'/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js',
'/zhao-icon.png': 'zhao-icon.png',
'/vendor/hls.min.js': 'vendor/hls.min.js'}
if path not in files:
if re.fullmatch(r'/ui/assets/[A-Za-z0-9_-]+\.(?:js|css)', path):
files[path] = path[1:]
if path not in files or not (ROOT / 'web' / files[path]).is_file():
raise Problem('页面不存在', 404)
file = ROOT / 'web' / files[path]
raw = file.read_bytes()
+32
View File
@@ -0,0 +1,32 @@
# 赵府 Vue 前端
Vue 3 + Vite + Bootstrap 5。Python 服务提供 API 和本地静态资源;生产主机无需安装 Node。
## 构建
在仓库根目录运行:
```sh
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
```
提交 `frontend/` 源码、锁文件及 `web/ui/` 构建产物后发布。根路径 `/` 返回 `web/ui/index.html`,资源名含内容哈希。Bootstrap、Vue 和字体均无需外部 CDN。域控制器版本随 `compat/load.js` 发布号更新。
## 模块边界
- `App.vue`、`AppSidebar`、`AppHeader`:应用外壳与导航。
- `HomeExplorer`:账号独立的卡片布局、分页、排序及场所总拓扑。
- `DeviceCollection`、`NetworkMap`:同一设备目录的卡片和拓扑视图。
- `NetworkStatus`:授权设备的实时网络检查与后台入口,清理定时器并丢弃过期响应。
- `Workspace`、`RuntimeOverlays`:现有业务页面与表单模板。
- `composables/bridge.js`、`web/vue-bridge.js`:Vue 与现有业务控制器的状态边界。
- `compat/scripts.json`:旧业务控制器依赖顺序,按顺序加载。视频播放器、家谱、记事等复杂交互暂时保留原控制器;尚未全部迁为 Vue 状态管理。
Vue 管理家庭卡片、设备列表、网络拓扑的 DOM;旧控制器只发布数据,不再覆盖这些容器。表单及播放器的兼容容器由原控制器管理,Vue 不重建其内部节点,避免轮询时重建视频。`web/index.html` 已移除,模板以 Vue 组件为准。
## 网络状态
`GET /api/devices/network?source=device|camera|recorder&id=...` 只允许当前家庭管理员与超级管理员检查本归属下的已登记设备。端口探测与 Tailscale 节点状态分别显示,30 秒缓存、并发限制;用户输入不能指定任意检查目标。只连接 TCP 端口,不修改设备、不发送登录密码。
“服务可达”表示从服务器能连接常用端口,并不证明设备品牌或互联网出口正常。管理后台优先使用探测到的 HTTP/HTTPS 端口,未检测到时提供标有“待连通”的默认 HTTP 入口,方便在设备同一局域网的浏览器打开。Tailscale 路由状态来自本服务器看到的路由,不等于完整物理布线发现。
+46
View File
@@ -0,0 +1,46 @@
vue
The MIT License (MIT)
Copyright (c) 2018-present, Yuxi (Evan) You
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS 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. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
bootstrap
The MIT License (MIT)
Copyright (c) 2011-2025 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS 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. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title></head><body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>
+18
View File
@@ -0,0 +1,18 @@
{
"name": "zhao-steward-ui",
"private": true,
"version": "0.1.35",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 127.0.0.1"
},
"dependencies": {
"bootstrap": "5.3.8",
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0"
}
}
+862
View File
@@ -0,0 +1,862 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
bootstrap:
specifier: 5.3.8
version: 5.3.8(@popperjs/core@2.11.8)
vue:
specifier: ^3.5.0
version: 3.5.43
devDependencies:
'@vitejs/plugin-vue':
specifier: ^6.0.0
version: 6.0.9(vite@7.3.6)(vue@3.5.43)
vite:
specifier: ^7.0.0
version: 7.3.6
packages:
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.9':
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rollup/rollup-android-arm-eabi@4.64.0':
resolution: {integrity: sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.64.0':
resolution: {integrity: sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.64.0':
resolution: {integrity: sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.64.0':
resolution: {integrity: sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.64.0':
resolution: {integrity: sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.64.0':
resolution: {integrity: sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.64.0':
resolution: {integrity: sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.64.0':
resolution: {integrity: sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.64.0':
resolution: {integrity: sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.64.0':
resolution: {integrity: sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.64.0':
resolution: {integrity: sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.64.0':
resolution: {integrity: sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.64.0':
resolution: {integrity: sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.64.0':
resolution: {integrity: sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.64.0':
resolution: {integrity: sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.64.0':
resolution: {integrity: sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.64.0':
resolution: {integrity: sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.64.0':
resolution: {integrity: sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.64.0':
resolution: {integrity: sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.64.0':
resolution: {integrity: sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.64.0':
resolution: {integrity: sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.64.0':
resolution: {integrity: sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.64.0':
resolution: {integrity: sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.64.0':
resolution: {integrity: sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.64.0':
resolution: {integrity: sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@vitejs/plugin-vue@6.0.9':
resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@vue/compiler-core@3.5.43':
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
'@vue/compiler-dom@3.5.43':
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
'@vue/compiler-sfc@3.5.43':
resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
'@vue/compiler-ssr@3.5.43':
resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
'@vue/reactivity@3.5.43':
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
'@vue/runtime-core@3.5.43':
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
'@vue/runtime-dom@3.5.43':
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
'@vue/server-renderer@3.5.43':
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
'@vue/shared@3.5.43':
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
bootstrap@5.3.8:
resolution: {integrity: sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==}
peerDependencies:
'@popperjs/core': ^2.11.8
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.64.0:
resolution: {integrity: sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
vite@7.3.6:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vue@3.5.43:
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.9':
dependencies:
'@babel/types': 7.29.8
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@esbuild/aix-ppc64@0.28.2':
optional: true
'@esbuild/android-arm64@0.28.2':
optional: true
'@esbuild/android-arm@0.28.2':
optional: true
'@esbuild/android-x64@0.28.2':
optional: true
'@esbuild/darwin-arm64@0.28.2':
optional: true
'@esbuild/darwin-x64@0.28.2':
optional: true
'@esbuild/freebsd-arm64@0.28.2':
optional: true
'@esbuild/freebsd-x64@0.28.2':
optional: true
'@esbuild/linux-arm64@0.28.2':
optional: true
'@esbuild/linux-arm@0.28.2':
optional: true
'@esbuild/linux-ia32@0.28.2':
optional: true
'@esbuild/linux-loong64@0.28.2':
optional: true
'@esbuild/linux-mips64el@0.28.2':
optional: true
'@esbuild/linux-ppc64@0.28.2':
optional: true
'@esbuild/linux-riscv64@0.28.2':
optional: true
'@esbuild/linux-s390x@0.28.2':
optional: true
'@esbuild/linux-x64@0.28.2':
optional: true
'@esbuild/netbsd-arm64@0.28.2':
optional: true
'@esbuild/netbsd-x64@0.28.2':
optional: true
'@esbuild/openbsd-arm64@0.28.2':
optional: true
'@esbuild/openbsd-x64@0.28.2':
optional: true
'@esbuild/openharmony-arm64@0.28.2':
optional: true
'@esbuild/sunos-x64@0.28.2':
optional: true
'@esbuild/win32-arm64@0.28.2':
optional: true
'@esbuild/win32-ia32@0.28.2':
optional: true
'@esbuild/win32-x64@0.28.2':
optional: true
'@jridgewell/sourcemap-codec@1.6.0': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
'@popperjs/core@2.11.8': {}
'@rolldown/pluginutils@1.0.1': {}
'@rollup/rollup-android-arm-eabi@4.64.0':
optional: true
'@rollup/rollup-android-arm64@4.64.0':
optional: true
'@rollup/rollup-darwin-arm64@4.64.0':
optional: true
'@rollup/rollup-darwin-x64@4.64.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.64.0':
optional: true
'@rollup/rollup-freebsd-x64@4.64.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.64.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.64.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.64.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.64.0':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.64.0':
optional: true
'@rollup/rollup-linux-loong64-musl@4.64.0':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.64.0':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.64.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.64.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.64.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.64.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.64.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.64.0':
optional: true
'@rollup/rollup-openbsd-x64@4.64.0':
optional: true
'@rollup/rollup-openharmony-arm64@4.64.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.64.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.64.0':
optional: true
'@rollup/rollup-win32-x64-gnu@4.64.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.64.0':
optional: true
'@types/estree@1.0.9': {}
'@vitejs/plugin-vue@6.0.9(vite@7.3.6)(vue@3.5.43)':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 7.3.6
vue: 3.5.43
'@vue/compiler-core@3.5.43':
dependencies:
'@babel/parser': 7.29.9
'@vue/shared': 3.5.43
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.2
'@vue/compiler-dom@3.5.43':
dependencies:
'@vue/compiler-core': 3.5.43
'@vue/shared': 3.5.43
'@vue/compiler-sfc@3.5.43':
dependencies:
'@babel/parser': 7.29.9
'@vue/compiler-core': 3.5.43
'@vue/compiler-dom': 3.5.43
'@vue/compiler-ssr': 3.5.43
'@vue/shared': 3.5.43
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.28
source-map-js: 1.2.2
'@vue/compiler-ssr@3.5.43':
dependencies:
'@vue/compiler-dom': 3.5.43
'@vue/shared': 3.5.43
'@vue/reactivity@3.5.43':
dependencies:
'@vue/shared': 3.5.43
'@vue/runtime-core@3.5.43':
dependencies:
'@vue/reactivity': 3.5.43
'@vue/shared': 3.5.43
'@vue/runtime-dom@3.5.43':
dependencies:
'@vue/reactivity': 3.5.43
'@vue/runtime-core': 3.5.43
'@vue/shared': 3.5.43
csstype: 3.2.3
'@vue/server-renderer@3.5.43':
dependencies:
'@vue/compiler-ssr': 3.5.43
'@vue/runtime-dom': 3.5.43
'@vue/shared': 3.5.43
'@vue/shared@3.5.43': {}
bootstrap@5.3.8(@popperjs/core@2.11.8):
dependencies:
'@popperjs/core': 2.11.8
csstype@3.2.3: {}
entities@7.0.1: {}
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
fsevents@2.3.3:
optional: true
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
nanoid@3.3.19: {}
picocolors@1.1.1: {}
picomatch@4.0.7: {}
postcss@8.5.28:
dependencies:
nanoid: 3.3.19
picocolors: 1.1.1
source-map-js: 1.2.2
rollup@4.64.0:
dependencies:
'@types/estree': 1.0.9
optionalDependencies:
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
'@rollup/rollup-android-arm-eabi': 4.64.0
'@rollup/rollup-android-arm64': 4.64.0
'@rollup/rollup-darwin-arm64': 4.64.0
'@rollup/rollup-darwin-x64': 4.64.0
'@rollup/rollup-freebsd-arm64': 4.64.0
'@rollup/rollup-freebsd-x64': 4.64.0
'@rollup/rollup-linux-arm-gnueabihf': 4.64.0
'@rollup/rollup-linux-arm-musleabihf': 4.64.0
'@rollup/rollup-linux-arm64-gnu': 4.64.0
'@rollup/rollup-linux-arm64-musl': 4.64.0
'@rollup/rollup-linux-loong64-gnu': 4.64.0
'@rollup/rollup-linux-loong64-musl': 4.64.0
'@rollup/rollup-linux-ppc64-gnu': 4.64.0
'@rollup/rollup-linux-ppc64-musl': 4.64.0
'@rollup/rollup-linux-riscv64-gnu': 4.64.0
'@rollup/rollup-linux-riscv64-musl': 4.64.0
'@rollup/rollup-linux-s390x-gnu': 4.64.0
'@rollup/rollup-linux-x64-gnu': 4.64.0
'@rollup/rollup-linux-x64-musl': 4.64.0
'@rollup/rollup-openbsd-x64': 4.64.0
'@rollup/rollup-openharmony-arm64': 4.64.0
'@rollup/rollup-win32-arm64-msvc': 4.64.0
'@rollup/rollup-win32-ia32-msvc': 4.64.0
'@rollup/rollup-win32-x64-gnu': 4.64.0
'@rollup/rollup-win32-x64-msvc': 4.64.0
fsevents: 2.3.3
source-map-js@1.2.2: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
vite@7.3.6:
dependencies:
esbuild: 0.28.2
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
postcss: 8.5.28
rollup: 4.64.0
tinyglobby: 0.2.17
optionalDependencies:
fsevents: 2.3.3
vue@3.5.43:
dependencies:
'@vue/compiler-dom': 3.5.43
'@vue/compiler-sfc': 3.5.43
'@vue/runtime-dom': 3.5.43
'@vue/server-renderer': 3.5.43
'@vue/shared': 3.5.43
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+11
View File
@@ -0,0 +1,11 @@
<script setup>
import {onMounted,ref} from 'vue'
import AppSidebar from './components/AppSidebar.vue'
import AppHeader from './components/AppHeader.vue'
import Workspace from './components/Workspace.vue'
import RuntimeOverlays from './components/RuntimeOverlays.vue'
import {loadControllers} from './compat/load.js'
const failed=ref(false)
onMounted(async()=>{try{await loadControllers()}catch(error){console.error('Application startup failed',error);failed.value=true}})
</script>
<template><AppSidebar/><main><AppHeader/><div v-if="failed" class="alert alert-danger" role="alert">页面组件加载失败,请检查网络后重新打开页面。</div><Workspace/></main><RuntimeOverlays/></template>
+20
View File
@@ -0,0 +1,20 @@
import scripts from './scripts.json'
// Existing domain controllers keep their event contracts while pages migrate to Vue.
// This observer styles only controls; it never owns their state or recreates players.
export function enhance(root){
if(!(root instanceof Element))return
const elements=[root,...root.querySelectorAll('button,input,select,textarea,table')]
for(const el of elements){
if(el.matches('button')&&!el.closest('.family-map-viewport')&&!el.classList.contains('btn'))el.classList.add('btn',el.matches('.quiet,.secondary')?'btn-outline-secondary':'btn-primary')
if(el.matches('select'))el.classList.add('form-select')
if(el.matches('textarea,input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=hidden])'))el.classList.add('form-control')
if(el.matches('input[type=checkbox],input[type=radio]'))el.classList.add('form-check-input')
if(el.matches('table'))el.classList.add('table','align-middle')
}
}
export async function loadControllers(){
enhance(document.getElementById('app'))
new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true})
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.35';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
await window.ZhaoVue.boot()
}
+28
View File
@@ -0,0 +1,28 @@
[
"/vendor/hls.min.js",
"/live-player.js",
"/kinship.js",
"/family-graph.js",
"/calendar.js",
"/device-model.js",
"/data/geography-data.js",
"/geography.js",
"/app.js",
"/monitor.js",
"/date-fields.js",
"/family-map.js",
"/family.js",
"/family-events.js",
"/family-history.js",
"/camera-settings.js",
"/recording-ranges.js",
"/recording-ui.js",
"/snapshot.js",
"/households.js",
"/devices.js",
"/steward.js",
"/heritage.js",
"/personalization.js",
"/file-hash.js",
"/journal.js"
]
+3
View File
@@ -0,0 +1,3 @@
<template>
<header><div><div class="eyebrow" id="brand-motto">赵氏家宅 · 起居有序</div><h1 id="page-title">我的家庭与场所</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup>
defineProps({ name: { type: String, default: "grid" } });
const paths = {
home: "m3 10 9-7 9 7v10H3Z M9 20v-7h6v7",
journal: "M5 3h14v18H5Z M8 7h8 M8 11h8 M8 15h5",
grid: "M3 3h7v7H3Z M14 3h7v7h-7Z M3 14h7v7H3Z M14 14h7v7h-7Z",
devices: "M3 4h18v12H3Z M8 20h8 M12 16v4",
tree: "M9 2h6v6H9Z M2 16h6v6H2Z M16 16h6v6h-6Z M12 8v4 M5 16v-4h14v4",
users:
"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2 M22 21v-2a4 4 0 0 0-3-3.8 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8 M17 3a4 4 0 0 1 0 8",
settings:
"M9 3h6l1 4 4 1v8l-4 1-1 4H9l-1-4-4-1V8l4-1Z M15 12a3 3 0 1 0-6 0 3 3 0 0 0 6 0",
database:
"M3 6c0-5 18-5 18 0s-18 5-18 0V18c0 5 18 5 18 0V6 M3 12c0 5 18 5 18 0",
network: "M9 2h6v6H9Z M2 16h6v6H2Z M16 16h6v6h-6Z M12 8v4 M5 16v-4h14v4",
arrow: "M5 12h14 M14 7l5 5-5 5",
router: "M3 13h18v8H3Z M6 13V5 M18 13V5 M7 17h.01 M11 17h.01 M15 17h3",
camera: "M3 6h12v12H3Z M15 10l6-4v12l-6-4",
};
</script>
<template>
<svg
class="app-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.65"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="paths[name] || paths.grid" />
</svg>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup>
import AppIcon from './AppIcon.vue'
</script>
<template>
<aside class="sidebar">
<a class="brand" href="/"><img id="brand-image" class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span><span id="brand-name">赵府</span><small id="brand-subtitle">智能数字管家</small></span></a>
<button id="mobile-nav-toggle" class="quiet mobile-nav-toggle" aria-expanded="false" aria-controls="main-navigation" type="button">☰ 菜单</button>
<div class="nav-label">家宅 · 起居</div>
<nav class="nav flex-column" id="main-navigation" aria-label="主导航">
<button data-page="households" class="active"><AppIcon name="home" /> <span>我的家庭与场所</span></button>
<div id="scope-navigation" hidden><div id="scope-nav-name" class="nav-label"></div>
<button data-page="journal"><AppIcon name="journal" /> <span>记事与阅历</span></button>
<button data-page="overview"><AppIcon name="grid" /> <span>场所总览</span></button>
<button data-page="objects"><AppIcon name="devices" /> <span>区域与设备</span></button>
<button data-page="heritage"><AppIcon name="tree" /> <span>家庭传承</span></button>
<button data-page="users" data-admin><AppIcon name="users" /> <span>成员与权限</span></button>
</div>
<button data-page="settings"><AppIcon name="settings" /> <span>设置中心</span></button>
<button data-page="storage"><AppIcon name="database" /> <span>平台存储与审计</span></button>
</nav>
<div class="side-note"><span class="dot"></span> 独立部署 · 数据自有<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
</aside>
</template>
@@ -0,0 +1,153 @@
<script setup>
import {
deviceView,
snapshot,
bridge,
accessLabel,
} from "../composables/bridge.js";
import AppIcon from "./AppIcon.vue";
import NetworkStatus from "./NetworkStatus.vue";
const channels = (d) =>
(snapshot.value.state.cameras || []).filter((c) =>
d.source === "camera"
? c.assetId === d.id
: c.recorderId === d.id || c.archiveRecorderId === d.id,
);
const editAttrs = (d) => ({
["data-" +
(d.source === "camera"
? "edit-asset"
: d.source === "recorder"
? "edit-recorder"
: "edit-device")]: d.id,
});
const monitored = (d) =>
/路由|网关|服务器/.test(d.deviceType) || /gl\.inet/i.test(d.brand || "");
</script>
<template>
<div id="asset-list">
<section
v-for="group in deviceView.groups"
:key="group.key"
class="device-group"
>
<h3 class="group-title">
{{ group.labels.join(" / ") || "全部设备" }}
<span class="count-pill">{{ group.items.length }}</span>
</h3>
<div class="device-card-grid">
<article
v-for="d in group.items"
:key="d.source + d.id"
class="card device-card"
>
<div class="card-body">
<div class="device-card-heading">
<div class="device-symbol">
<AppIcon
:name="
d.source === 'camera'
? 'camera'
: monitored(d)
? 'router'
: 'devices'
"
/>
</div>
<div class="flex-grow-1">
<span class="small text-secondary">{{ d.deviceType }}</span>
<h3>
<button
class="device-title"
@click="bridge().enterDevice(d.source, d.id)"
>
{{ d.name }}
</button>
</h3>
</div>
<button
v-if="snapshot.admin"
class="btn btn-sm btn-outline-secondary"
v-bind="editAttrs(d)"
>
编辑
</button>
</div>
<p class="device-location">
{{ bridge().location?.(d.siteId) || "区域待填写"
}}<template v-if="d.point"> · {{ d.point }}</template>
</p>
<dl class="compact-details">
<div>
<dt>品牌型号</dt>
<dd>
{{
[d.brand, d.model].filter(Boolean).join(" / ") || "未填写"
}}
</dd>
</div>
<div>
<dt>设备地址</dt>
<dd class="font-monospace">
{{ d.networkHosts.join(" / ") || "未填写" }}
</dd>
</div>
<div>
<dt>网络</dt>
<dd>{{ accessLabel(d.networkAccess) }}</dd>
</div>
<div>
<dt>所有人 / 负责人</dt>
<dd>
{{ d.ownerName || "待指定" }} /
{{ d.managerName || "待指定" }}
</dd>
</div>
<div v-if="d.source !== 'device'">
<dt>镜头通道</dt>
<dd>{{ channels(d).length }} 个已配置</dd>
</div>
</dl>
<div v-if="d.tags?.length" class="device-tags">
<span v-for="tag in d.tags" :key="tag">{{ tag }}</span>
</div>
<NetworkStatus
v-if="monitored(d)"
:device="d"
:active="
snapshot.page === 'objects' && deviceView.mode === 'cards'
"
/>
</div>
<div class="card-footer device-card-actions">
<button
class="btn btn-sm btn-outline-secondary"
@click="bridge().enterDevice(d.source, d.id, 'device')"
>
设备详情</button
><template v-if="d.source !== 'device'"
><button
v-if="snapshot.admin"
class="btn btn-sm btn-outline-secondary"
@click="bridge().enterDevice(d.source, d.id, 'cameras')"
>
配置镜头</button
><button
v-if="channels(d).length"
class="btn btn-sm btn-primary"
@click="bridge().enterDevice(d.source, d.id, 'live')"
>
实时画面
</button></template
>
</div>
</article>
</div>
</section>
<div v-if="!deviceView.devices.length" class="empty">
<AppIcon name="devices" />
<h3>当前范围没有设备</h3>
<p>调整筛选条件,或添加摄像机、路由器、灯等设备。</p>
</div>
</div>
</template>
@@ -0,0 +1,5 @@
<script setup>
import {snapshot} from '../composables/bridge.js'
import NetworkStatus from './NetworkStatus.vue'
</script>
<template><section v-if="snapshot.admin&&snapshot.device&&snapshot.page==='device'" class="panel"><h3>网络状态与管理入口</h3><NetworkStatus :device="snapshot.device"/><p class="description mb-0">只检查已登记的设备地址。常用端口未响应不一定表示断电;Tailscale 节点在线也不代表下级网段可达。</p></section></template>
+406
View File
@@ -0,0 +1,406 @@
<script setup>
import { computed, ref, watch } from "vue";
import { snapshot, bridge, accessLabel } from "../composables/bridge.js";
import AppIcon from "./AppIcon.vue";
const draft = ref({
revision: 0,
columns: 2,
rows: 2,
groupCity: false,
order: [],
}),
dirty = ref(false),
saving = ref(false),
page = ref(0),
arrange = ref(false),
mode = ref("cards"),
drag = ref(""),
message = ref("");
let owner = "";
watch(
snapshot,
(value) => {
const identity = value.user?.id + ":" + value.session;
if (owner !== identity) {
owner = identity;
dirty.value = false;
saving.value = false;
page.value = 0;
arrange.value = false;
mode.value = "cards";
drag.value = "";
message.value = "";
}
if (!dirty.value && !saving.value)
draft.value = {
revision: 0,
columns: 2,
rows: 2,
groupCity: false,
order: [],
...value.state.householdLayout,
};
},
{ immediate: true },
);
const homes = computed(() => {
const all = snapshot.value.state.households || [],
byId = new Map(all.map((h) => [h.id, h]));
return [...new Set([...draft.value.order, ...all.map((h) => h.id)])]
.filter((id) => byId.has(id))
.map((id) => byId.get(id));
});
const capacity = computed(() => draft.value.columns * draft.value.rows);
const pages = computed(() =>
Math.max(1, Math.ceil(homes.value.length / capacity.value)),
);
watch(pages, (n) => (page.value = Math.min(page.value, n - 1)));
const groups = computed(() => {
const visible = homes.value.slice(
page.value * capacity.value,
(page.value + 1) * capacity.value,
);
if (!draft.value.groupCity) return [{ name: "", items: visible }];
const map = new Map();
for (const h of visible) {
const key = [
h.geography?.country || h.country,
h.province,
h.city || "地区待填写",
]
.filter(Boolean)
.join(" / ");
if (!map.has(key)) map.set(key, []);
map.get(key).push(h);
}
return [...map].map(([name, items]) => ({ name, items }));
});
const kind = (h) =>
({ home: "家庭", office: "办公场所", factory: "工厂" })[h.kind || "home"] ||
"场所";
const address = (h) =>
bridge().address?.(h) ||
[h.province, h.city, h.district].filter(Boolean).join(" · ") ||
"地区待填写";
function changed() {
dirty.value = true;
page.value = 0;
if (draft.value.groupCity) arrange.value = false;
message.value = "布局已调整,保存后下次登录会沿用。";
}
function move(id, step, target) {
if (saving.value) return;
const order = homes.value.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);
draft.value.order = order;
dirty.value = true;
page.value = Math.floor(to / capacity.value);
}
function reset() {
dirty.value = false;
draft.value = { ...snapshot.value.state.householdLayout };
page.value = 0;
message.value = "";
}
async function save() {
const identity = owner;
saving.value = true;
try {
const value = await bridge().saveLayout({ ...draft.value });
if (identity !== owner) return;
draft.value = value;
dirty.value = false;
message.value = "布局与卡片顺序已保存";
} catch (e) {
if (!e.stale && identity === owner) message.value = e.message;
} finally {
if (identity === owner) saving.value = false;
}
}
async function enter(h, network = false) {
await bridge().enterHome(h.id, network);
}
</script>
<template>
<section id="page-households" class="page">
<div class="section-head">
<div>
<h2>
我的空间 <span class="count-pill">{{ homes.length }}</span>
</h2>
<p class="description mb-0">
家庭、办公与工厂,各自管理成员、区域和设备。
</p>
</div>
<button
id="new-household"
class="btn btn-primary"
:hidden="!snapshot.platformAdmin"
>
+ 新建家庭或场所
</button>
</div>
<div class="home-toolbar">
<div class="btn-group" role="group" aria-label="场所显示方式">
<button
id="home-card-view"
class="btn btn-outline-secondary"
:class="{ active: mode === 'cards' }"
:aria-pressed="mode === 'cards'"
@click="mode = 'cards'"
>
<AppIcon name="grid" /> 卡片</button
><button
id="home-topology-view"
class="btn btn-outline-secondary"
:class="{ active: mode === 'topology' }"
:aria-pressed="mode === 'topology'"
@click="mode = 'topology'"
>
<AppIcon name="network" /> 整体拓扑
</button>
</div>
<div v-show="mode === 'cards'" class="home-layout-controls">
<label
>每页
<select
id="home-columns"
v-model.number="draft.columns"
class="form-select"
:disabled="saving"
@change="changed"
>
<option v-for="n in 4" :key="n" :value="n">{{ n }} 列</option>
</select></label
><span>×</span
><select
id="home-rows"
v-model.number="draft.rows"
aria-label="每页行数"
class="form-select"
:disabled="saving"
@change="changed"
>
<option v-for="n in 4" :key="n" :value="n">{{ n }} 行</option></select
><label class="inline-check"
><input
id="home-group-city"
v-model="draft.groupCity"
type="checkbox"
class="form-check-input"
:disabled="saving"
@change="changed"
/>按城市分组</label
><label class="inline-check"
><input
id="home-arrange"
v-model="arrange"
type="checkbox"
class="form-check-input"
:disabled="draft.groupCity || saving"
/>调整顺序</label
><button
id="home-layout-save"
class="btn btn-primary"
:disabled="!dirty || saving"
@click="save"
>
{{ saving ? "保存中…" : "保存布局" }}</button
><button
id="home-layout-reset"
class="btn btn-outline-secondary"
:disabled="!dirty || saving"
@click="reset"
>
撤销
</button>
</div>
</div>
<p id="home-layout-status" role="status" class="small text-secondary">
{{ message || "布局仅影响你的账号;手机端自动适配为单列。" }}
</p>
<div
id="household-list"
v-show="mode === 'cards'"
:style="{ '--home-columns': draft.columns }"
>
<section
v-for="group in groups"
:key="group.name"
class="home-city-group"
>
<h3 v-if="group.name" class="group-title">{{ group.name }}</h3>
<div class="home-card-grid">
<article
v-for="home in group.items"
:key="home.id"
class="card home-card"
:draggable="arrange && !saving"
@dragstart="
drag = home.id;
$event.dataTransfer.setData('text/plain', home.id);
"
@dragend="drag = ''"
@dragover="arrange && drag && $event.preventDefault()"
@drop.prevent="
arrange && drag && move(drag, 0, home.id);
drag = '';
"
>
<div class="home-cover">
<img
v-if="home.coverUrl"
:src="home.coverUrl"
:alt="home.name + '封面'"
loading="lazy"
/>
<div class="cover-shade"></div>
<div class="home-cover-content">
<div class="site-heading">
<span class="badge"
>{{ kind(home)
}}<template v-if="home.id === snapshot.state.familyId">
· 当前</template
></span
><button
v-if="snapshot.admin"
class="btn btn-sm cover-edit"
:data-edit-household="home.id"
>
编辑
</button>
</div>
<div>
<h3>{{ home.name }}</h3>
<p class="home-location">{{ address(home) }}</p>
</div>
</div>
</div>
<div class="card-body">
<div v-if="arrange" class="home-order-actions">
<span>拖动排序</span
><button
class="btn btn-sm btn-outline-secondary"
:disabled="homes[0]?.id === home.id || saving"
:aria-label="home.name + '前移'"
@click="move(home.id, -1)"
>
←</button
><button
class="btn btn-sm btn-outline-secondary"
:disabled="homes.at(-1)?.id === home.id || saving"
:aria-label="home.name + '后移'"
@click="move(home.id, 1)"
>
→
</button>
</div>
<p class="home-summary">
{{ home.note || "成员、空间与设备,独立管理。" }}
</p>
<div class="home-numbers">
<span
><b>{{ home.deviceCount || 0 }}</b
>设备</span
><span
><b>{{ home.sites || 0 }}</b
>区域</span
><span
><b>{{ home.members || 0 }}</b
>成员</span
>
</div>
<dl class="compact-details">
<div>
<dt>
{{
home.kind === "office" || home.kind === "factory"
? "主要负责人"
: "一家之主"
}}
</dt>
<dd>{{ home.ownerName || "待指定" }}</dd>
</div>
<div>
<dt>备用负责人</dt>
<dd>{{ home.successorName || "待指定" }}</dd>
</div>
</dl>
<div class="home-card-actions">
<button class="btn btn-primary" @click="enter(home)">
进入空间 <AppIcon name="arrow" /></button
><button
v-if="home.canAssignOwner"
class="btn btn-link"
:data-leadership="home.id"
>
负责人安排
</button>
</div>
</div>
</article>
</div>
</section>
<div v-if="!homes.length" class="empty">
登录后查看已授权的家庭与场所。
</div>
</div>
<div
id="home-global-topology"
v-show="mode === 'topology'"
class="network-overview"
>
<div class="network-platform">
<AppIcon name="network" />
<div>
<h3>{{ snapshot.brandName || "赵府" }} · 数字管家</h3>
<p>{{ homes.length }} 个已授权空间</p>
</div>
</div>
<p class="description">
平台与场所的管理关系;进入场所查看内部设备及网络。
</p>
<div class="platform-place-grid">
<button
v-for="h in homes"
:key="h.id"
class="card platform-place-node"
@click="enter(h, true)"
>
<span class="badge">{{ kind(h) }}</span>
<h3>{{ h.name }}</h3>
<p>{{ address(h) }}</p>
<span>{{ h.deviceCount || 0 }} 台设备</span
><small>{{
(h.networkAccesses || []).map(accessLabel).join(" · ") ||
"网络待登记"
}}</small
><span class="network-open">查看网络拓扑 →</span>
</button>
</div>
</div>
<div v-show="mode === 'cards'" class="home-pagination">
<span id="home-page-info"
>第 {{ page + 1 }} / {{ pages }} 页 · 每页最多 {{ capacity }} 张</span
><button
id="home-prev"
class="btn btn-outline-secondary"
:disabled="!page"
@click="page--"
>
上一页</button
><button
id="home-next"
class="btn btn-outline-secondary"
:disabled="page >= pages - 1"
@click="page++"
>
下一页
</button>
</div>
</section>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup>
import { computed } from "vue";
import {
deviceView,
snapshot,
bridge,
accessLabel,
} from "../composables/bridge.js";
import AppIcon from "./AppIcon.vue";
import NetworkStatus from "./NetworkStatus.vue";
const home = computed(() =>
(snapshot.value.state.households || []).find(
(h) => h.id === snapshot.value.state.familyId,
),
);
</script>
<template>
<div id="device-topology" hidden>
<section class="panel network-map">
<div class="network-platform">
<AppIcon name="network" />
<div>
<h3>{{ home?.name || "当前场所" }}</h3>
<p>{{ deviceView.devices.length }} 台设备 · 随设备档案自动更新</p>
</div>
</div>
<p class="description">
连线来自已确认的上级设备关系。上级未填写时单独列出;不代表已自动发现所有交换机或布线。
</p>
<div class="topology-nodes">
<article
v-for="row in deviceView.rows"
:key="row.device.id"
class="network-node card"
:class="{ nested: row.depth > 0 }"
:style="{ '--depth': Math.min(row.depth, 5) }"
>
<div class="card-body">
<div class="device-card-heading">
<AppIcon
:name="row.device.source === 'camera' ? 'camera' : 'router'"
/>
<div class="flex-grow-1">
<span class="small text-secondary"
>{{ row.device.deviceType }} ·
{{ accessLabel(row.device.networkAccess) }}</span
>
<h3>
<button
class="device-title"
@click="
bridge().enterDevice(row.device.source, row.device.id)
"
>
{{ row.device.name }}
</button>
</h3>
</div>
<button
class="btn btn-sm btn-outline-secondary"
@click="
bridge().enterDevice(
row.device.source,
row.device.id,
'device',
)
"
>
详情 →
</button>
</div>
<p class="device-location">
{{ bridge().location?.(row.device.siteId) || "未指定区域" }}
<template v-if="row.device.point">
· {{ row.device.point }}</template
>
</p>
<div class="network-facts">
<span
>IP
<b>{{
row.device.networkHosts.join(" / ") || "待填写"
}}</b></span
><span v-if="row.device.tailscaleHost"
>Tailscale <b>{{ row.device.tailscaleHost }}</b></span
><span v-if="row.device.networkCidr"
>网段 <b>{{ row.device.networkCidr }}</b></span
><span
>上级 <b>{{ row.parentName || "待确认 / 直接接入" }}</b></span
><span
>连接
<b>{{
{ wired: "网线", wifi: "Wi-Fi" }[row.device.connectionType] ||
"待确认"
}}</b></span
><span v-if="row.via.length"
>取流经录像机 <b>{{ row.via.join("、") }}</b></span
><span v-if="row.archiveVia.length"
>录像归档 <b>{{ row.archiveVia.join("、") }}</b></span
>
</div>
<NetworkStatus
v-if="/路由|网关|服务器/.test(row.device.deviceType)"
:device="row.device"
:active="
snapshot.page === 'objects' && deviceView.mode === 'topology'
"
/>
</div>
</article>
<p v-if="!deviceView.rows.length" class="empty">
当前范围尚未登记设备。
</p>
</div>
</section>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup>
import {computed,ref,watch,onUnmounted} from 'vue'
import {snapshot,bridge} from '../composables/bridge.js'
const props=defineProps({device:{type:Object,required:true},active:{type:Boolean,default:true}})
const result=ref(null),busy=ref(false),error=ref('');let generation=0,timer
const key=computed(()=>[props.device.id,props.device.source,props.device.managementHost,props.device.tailscaleHost,props.device.networkCidr,snapshot.value.state.familyId,snapshot.value.session,snapshot.value.admin,props.active].join('|'))
async function check(){const current=generation;if(busy.value||!snapshot.value.admin||!props.active)return;busy.value=true;error.value='';try{const value=await bridge().api('/api/devices/network?source='+encodeURIComponent(props.device.source)+'&id='+encodeURIComponent(props.device.id));if(current===generation)result.value=value}catch(e){if(current===generation&&!e.stale)error.value=e.message}finally{if(current===generation){busy.value=false;timer=setTimeout(check,35000)}}}
watch(key,()=>{generation++;clearTimeout(timer);busy.value=false;result.value=null;error.value='';if(snapshot.value.admin&&props.active)check()},{immediate:true})
onUnmounted(()=>{generation++;clearTimeout(timer)})
const summary=computed(()=>!result.value?'等待检查':result.value.targets.some(t=>t.reachable)?'网络可达':'未连通')
function retry(){clearTimeout(timer);check()}
</script>
<template><div v-if="snapshot.admin" class="network-status-block"><div class="network-status-title"><span><i class="status-dot" :class="{online:result?.targets.some(t=>t.reachable)}"></i>{{busy?'正在检查…':summary}}</span><button class="btn btn-sm btn-link" :disabled="busy" @click="retry">检查网络</button></div><p v-if="error" class="text-danger small" role="status">{{error}}</p><template v-if="result"><div v-for="target in result.targets" :key="target.kind" class="network-target"><span>{{target.kind==='tailscale'?'Tailscale':'设备地址'}}</span><code>{{target.host}}</code><span :class="target.reachable?'text-success':'text-secondary'">{{target.reachable?'服务可达':'未响应'}}</span></div><p v-if="!result.targets.length" class="small text-secondary">请编辑设备档案,填写 IP 或域名。</p><p v-if="result.tailscale?.available" class="small text-secondary">Tailscale 节点{{result.tailscale.online?'在线':'离线'}}<template v-if="result.tailscale.subnet"> · {{result.tailscale.subnetAvailable?'已发布':'未检测到'}} {{result.tailscale.subnet}} 路由</template></p><p v-else-if="result.tailscale" class="small text-secondary">{{result.tailscale.note}}</p><div class="management-links"><a v-for="link in result.links" :key="link.kind" :href="link.url" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary">打开管理后台 · {{link.kind==='tailscale'?'Tailscale':'局域网'}} ↗<span v-if="!link.verified">(待连通)</span></a></div><small class="network-check-time">检查于 {{new Date(result.checkedAt*1000).toLocaleTimeString('zh-CN')}} · 从服务器探测,打开后台需当前浏览器也能到达。</small></template></div></template>
@@ -0,0 +1,25 @@
<template>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
<dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"></option><option value="手机"></option><option value="冰箱"></option><option value="空调"></option><option value="传感器"></option><option value="电脑"></option><option value="路由器"></option><option value="交换机"></option><option value="无线接入点"></option><option value="其他"></option></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="heritage-dialog"><form id="heritage-form"><div class="dialog-heading"><h2 id="heritage-form-title">传承资料</h2><button type="button" class="quiet" data-close="heritage-dialog">关闭</button></div><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="kind"><div class="form-grid"><label class="full">档案名称 *<input name="name" required maxlength="120"></label><label>相关日期<input name="date" type="date"></label><label>讲述人 / 整理人<input name="contributor" maxlength="80"></label><label class="full">相关内容与来历<textarea name="content" rows="6" maxlength="8000"></textarea></label><label class="full">资料 / 报道链接<input name="sourceUrl" type="url" maxlength="1500" placeholder="https://"></label><label class="full">关联已有设备<select name="deviceReference"><option value="">不关联设备</option></select></label><label data-heritage-asset>所有人 / 共有说明<input name="ownerName" maxlength="80"></label><label data-heritage-asset>保管人<input name="custodianName" maxlength="80"></label><label data-heritage-asset>意向接收人<input name="successorName" maxlength="80"></label><label class="full" data-heritage-asset>交接意愿与说明<textarea name="handoverNote" rows="3" maxlength="2000"></textarea></label><label class="check full"><input type="checkbox" name="archived">归档(保留资料,可恢复)</label></div><p class="description">这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存档案</button></div></form></dialog>
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><fieldset class="full geo-picker" data-geography="" data-geo-title="空间所在地"></fieldset><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>ONVIF 服务端口<input name="onvifPort" type="number" value="80" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="1" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<select name="ownerRef" aria-label="所有人"></select><input name="ownerName" aria-label="手动填写所有人" maxlength="80" placeholder="姓名或单位" hidden></label><label>负责人<select name="managerRef" aria-label="负责人"></select><input name="managerName" aria-label="手动填写负责人" maxlength="80" placeholder="管理或维护人员" hidden></label><fieldset class="full device-network"><legend>网络与连接</legend><div class="form-grid"><label>设备 IP/域名<input name="managementHost" maxlength="253" placeholder="例如 192.168.8.160"></label><label>服务端接入方式<select name="networkAccess"><option value="unknown">待确认</option><option value="lan">局域网直连</option><option value="tailscale">Tailscale 跨网</option><option value="public">公网接入</option></select></label><label>Tailscale 地址(可选)<input name="tailscaleHost" maxlength="253" placeholder="100.x.x.x 或设备域名"></label><label>网关提供的网段(可选)<input name="networkCidr" maxlength="64" placeholder="例如 192.168.8.0/24"></label><label>上级设备<select name="uplinkId"></select></label><label>连接方式<select name="connectionType"><option value="unknown">待确认</option><option value="wired">网线连接</option><option value="wifi">Wi-Fi 连接</option></select></label></div><p class="description">保存后自动更新拓扑。网络信息用于设备档案;摄像机实际取流地址在“配置镜头”内设置。选择人员不改变账号访问权限。</p></fieldset><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><input name="profileRevision" type="hidden" value="0"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><fieldset class="full geo-picker" data-geography="" data-geo-title="所在地"></fieldset><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-cancel" class="quiet">取消本次图片选择</button><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description" role="status" aria-live="polite">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>文化与财产档案权限<select name="heritageAccess"><option value="none">不可访问</option><option value="read">查看资料</option><option value="edit">整理与维护</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<datalist id="relationship-choices"></datalist>
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full" id="person-legacy-place">原籍贯/出生地记录<input name="birthplace" maxlength="160"><small>历史填写内容保留,可在下方分别补充。</small></label><fieldset class="full person-location"><legend>籍贯(祖籍)</legend><div class="form-grid"><fieldset class="full geo-picker" data-geography="native" data-geo-title="籍贯(祖籍)"></fieldset><label>乡镇/街道<input name="nativeTown" maxlength="80" placeholder="" aria-label="籍贯(祖籍) · 乡镇/街道"></label><label>村/社区<input name="nativeCommunity" maxlength="80" placeholder="" aria-label="籍贯(祖籍) · 村/社区"></label><label class="full">详细地址/历史地名<input name="nativeAddress" maxlength="200" placeholder="楼栋、门牌或旧称,可按已知信息填写" aria-label="籍贯(祖籍) · 详细地址/历史地名"></label></div></fieldset><fieldset class="full person-location"><legend>出生地</legend><div class="form-grid"><fieldset class="full geo-picker" data-geography="birth" data-geo-title="出生地"></fieldset><label>乡镇/街道<input name="birthTown" maxlength="80" placeholder="" aria-label="出生地 · 乡镇/街道"></label><label>村/社区<input name="birthCommunity" maxlength="80" placeholder="" aria-label="出生地 · 村/社区"></label><label class="full">详细地址/历史地名<input name="birthAddress" maxlength="200" placeholder="楼栋、门牌或旧称,可按已知信息填写" aria-label="出生地 · 详细地址/历史地名"></label></div></fieldset><p class="description full">籍贯与出生地可不同;选择中国、世界或自定义地域,不详的层级可留空。</p><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">家事 · 家声</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
<dialog id="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog>
<dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog>
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调与预置位:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
<div id="toast" role="status" hidden></div>
<dialog id="snapshot-dialog" aria-labelledby="snapshot-title"><div class="dialog-heading"><h2 id="snapshot-title">画面截图</h2><button data-close="snapshot-dialog" class="quiet">关闭</button></div><img id="snapshot-image" alt="当前视频画面截图"><p id="snapshot-info" class="description"></p><a id="snapshot-save" class="button" download>下载 PNG 图片</a></dialog>
<dialog id="journal-preview" class="journal-preview" aria-labelledby="journal-preview-title"><header><h2 id="journal-preview-title">附件预览</h2><button id="journal-preview-close" type="button" class="quiet">关闭预览</button></header><div id="journal-preview-content"></div><footer><span>按 Esc 关闭预览</span><a id="journal-preview-download">下载原文件</a></footer></dialog>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup>
import HomeExplorer from './HomeExplorer.vue'
import DeviceCollection from './DeviceCollection.vue'
import NetworkMap from './NetworkMap.vue'
import DeviceNetworkPanel from './DeviceNetworkPanel.vue'
</script>
<template>
<div id="household-context" class="household-context" hidden><label>当前归属 <select id="household-filter" aria-label="当前归属"></select></label><span id="household-context-note"></span><button id="scope-back" class="quiet">返回场所</button></div>
<div id="device-context" class="device-context" hidden><p id="device-breadcrumb"></p><div role="group" aria-label="设备功能"><button class="quiet" data-device-tab="device">设备档案</button><button class="quiet" data-device-tab="live">实时画面</button><button class="quiet" data-device-tab="playback">录像回放</button><button class="quiet" data-device-tab="cameras">镜头与设置</button></div></div><div id="banner" class="banner" hidden></div>
<section class="metrics"><div><span>设备</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">可选择任意区域,包含其下级区域</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
<section id="page-settings" class="page" hidden>
<div class="section-head"><h2>把这里布置成你习惯的样子</h2><button id="settings-reload" class="quiet">重新读取设置</button></div><p id="settings-error" role="status"></p><div class="settings-sections" role="group" aria-label="设置分类"><button class="quiet" data-settings-section="preferences" aria-pressed="true">我的偏好</button><button class="quiet" data-settings-section="baidu" data-platform-setting hidden>网盘连接</button><button class="quiet" data-settings-section="brand" data-platform-setting hidden>品牌与外观</button><button class="quiet" data-settings-section="backup" data-platform-setting hidden>数据库备份</button></div>
<form id="preferences-form" class="panel"><h3>我的偏好</h3><p class="description">只影响当前账号,保存后换电脑或手机登录也会沿用。切换选项可以预览,离开未保存的预览会撤销。</p><div id="personal-choices" class="form-grid"></div><p id="preferences-status" role="status"></p><div class="device-actions"><button type="submit">保存我的偏好</button><button type="button" id="personal-follow" class="quiet">全部跟随平台</button><button type="button" id="personal-cancel" class="quiet">撤销预览</button></div></form>
<section id="baidu-admin" class="panel" hidden><div class="section-head"><h3>百度网盘 · 统一存储</h3><span class="badge">仅超级管理员配置</span></div><p class="description">连接一个网盘账号,统一保存封面、Logo、图标及各用户上传的照片、视频和语音。系统按用户、家庭和记事可见范围授权;网盘账号持有人本身可以管理网盘中的文件。服务器仅转发分片,不保存媒体原文件。</p><details id="baidu-storage-guide"><summary>文件存在哪里?</summary><ul><li>家庭与场所:按家庭或场所保存封面。</li><li>平台外观:保存 Logo 和图标。</li><li>个人记事:按家庭、用户分组,再分照片、视频、语音。</li><li>数据库备份:单独存放;新备份上传并校验成功后,清理超过一个自然月的旧云备份。</li></ul><p class="description">文字、权限和文件引用保存在本机数据库;个人媒体原文件保存在百度网盘。封面和品牌图片仅做有限的内存缓存。更换封面、移除附件或删除记事不会删除网盘原文件。</p></details><form id="baidu-config"><div class="form-grid"><label>AppKey<input name="appKey" autocomplete="off" maxlength="150" required></label><label>SecretKey<input name="secretKey" type="password" autocomplete="new-password" maxlength="150"></label><label>百度后台应用名称<input name="appFolder" value="智家" required maxlength="40"></label><label>OAuth 回调地址<input name="redirectUri" type="url" placeholder="https://你的域名/api/baidu/callback" required maxlength="400"></label></div><p class="description">回调地址须与百度后台完全一致。SecretKey 留空保持已保存值,SignKey 不需要填写。当前开发者应用仅限个人场景,适用范围以百度后台为准。</p><p id="baidu-config-status" role="status"></p><button type="submit">保存网盘应用配置</button></form><hr><p id="baidu-status" role="status"></p><div class="device-actions"><button id="baidu-connect">连接/重新授权百度网盘</button><button id="baidu-disconnect" class="quiet">解除本机连接</button></div><p class="description">解除连接不会删除云端文件。临时媒体地址由后台按需获取,令牌不发送给浏览器。网盘授权凭据单独保存在服务器受限目录,不包含在数据库备份或公开源码中。</p></section>
<div id="settings-private" hidden>
<form id="brand-form" class="panel"><h3>发布与品牌配置</h3><p class="description">仅超级管理员可修改。名称、Logo、浏览器图标及登录文案会公开显示;适用于整套系统。已有个人偏好优先于平台默认。</p>
<div class="form-grid"><label>系统名称<input name="name" required maxlength="40"></label><label>系统副标题<input name="subtitle" required maxlength="60"></label><label class="full">页首题词<input name="motto" maxlength="100"></label><label>登录标题<input name="loginTitle" required maxlength="40"></label><label>登录说明<input name="loginNote" maxlength="160"></label><label class="full">页脚说明<input name="footer" maxlength="120"></label></div>
<div class="brand-image-grid"><div><img id="brand-logo-preview" alt="Logo 预览" src="/zhao-icon.png"><label>更换系统 Logo<input id="brand-logo-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-logo-reset" class="quiet">使用默认 Logo</button></div><div><img id="brand-icon-preview" alt="浏览器图标预览" src="/zhao-icon.png"><label>更换浏览器图标<input id="brand-icon-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-icon-reset" class="quiet">使用默认图标</button></div></div><p class="description">支持 PNG、JPEG、WebP,原图最多 5 MB;上传时自动缩小,图片随系统数据库保存。</p>
<h3>新账号与跟随平台账号的默认外观</h3><div id="brand-defaults" class="form-grid"></div><p id="brand-status" role="status"></p><div class="device-actions"><button type="submit">保存发布配置</button><button type="button" id="brand-export" class="quiet">导出发布配置</button><label class="config-import">导入配置到表单<input id="brand-import" type="file" accept="application/json,.json"></label></div><p class="description">导入后先核对再保存。导出的配置只含品牌与默认外观,不含账号、设备凭据、家庭资料和网络备份目录。</p>
</form>
<section id="backup-panel" class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接配置和网盘文件引用,因此仅超级管理员可操作。网盘中的照片、视频、语音及录像文件不包含在新数据库副本中;旧版备份可能仍含历史内嵌图片。恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label class="check full"><input name="baidu" type="checkbox">同时上传到已连接的百度网盘</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>本机/挂载目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。使用百度接口备份只需勾选上方选项;此路径用于其他已挂载的网络存储,不能填网页地址或分享链接。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本机和挂载目录中本系统生成的旧备份,百度网盘保留最近一个自然月;新备份上传并校验成功后才清理本应用的过期副本。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
</div>
</section>
<section id="page-journal" class="page" hidden>
<div class="scope-intro"><span class="eyebrow">岁月有迹 · 日常成章</span><h2>把生活,写成自己的来时路</h2><p>与家人分享近况,也为自己留下一份阅历。</p></div>
<form id="journal-form" class="panel journal-compose"><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="clientId"><label>此刻想记下什么?<textarea name="text" rows="4" maxlength="5000" placeholder="今天的心情、一段往事,或一个值得记住的时刻…"></textarea></label><div class="form-grid"><label>记事日期<input name="occurredOn" type="date" required></label><label>阅历分类<select name="category"><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>谁可以看到<select name="visibility"><option value="private">仅自己可见</option><option value="household">当前家庭/场所成员</option></select></label></div><div class="journal-add-media"><label class="quiet">+ 照片、视频或语音<input id="journal-files" disabled type="file" accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm,audio/mpeg,audio/mp4,audio/aac,audio/wav,audio/ogg,audio/webm,.m4a"></label><button id="journal-record" disabled type="button" class="quiet">● 直接录音</button><button id="journal-stop-record" type="button" hidden>结束录音并上传</button><button id="journal-cancel-upload" type="button" class="quiet" hidden>暂停上传</button></div><p id="journal-cloud-note" class="description"></p><p class="description">每条最多 9 个附件,单个文件最多 256 MB;一次上传一个文件。录音须通过 HTTPS 并允许麦克风,最长 5 分钟。上传完成后默认选中。保存后清空本次输入;再次使用可展开“我的附件库”。</p><div id="journal-upload-list" class="journal-upload-grid"></div><details id="journal-library" class="journal-library"><summary>我的附件库(<span id="journal-library-count">0</span>)</summary><p class="description">已上传的附件可预览、删除或再次用于记事。</p><div id="journal-library-list" class="journal-upload-grid"></div></details><details id="journal-trash" class="journal-trash" hidden><summary>已删除附件(<span id="journal-trash-count">0</span>)</summary><p class="description">删除仅整理附件库,已发布的记事与网盘原文件保留。恢复后可再次使用。</p><div id="journal-trash-list"></div></details><p id="journal-status" role="status" aria-live="polite"></p><div class="device-actions"><button id="journal-save" type="submit">保存记事</button><button id="journal-new" type="button" class="quiet">另写一条</button></div></form>
<form id="journal-filter" class="panel journal-filters"><label>查看方式<select id="journal-mode"><option value="feed">家庭/场所动态</option><option value="mine">我的个人阅历</option><option value="person">某个人的阅历</option></select></label><label id="journal-person-label" hidden>人物<select id="journal-person"></select></label><label>分类<select id="journal-category"><option value="">全部分类</option><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>关键词<input id="journal-search" type="search" maxlength="120" placeholder="在文字中查找"></label><label>开始日期<input id="journal-from" type="date"></label><label>结束日期<input id="journal-to" type="date"></label><button type="submit">查询阅历</button><button id="journal-filter-clear" type="button" class="quiet">清除筛选</button></form><p class="description">个人阅历保留同一条记事的文字、附件和互动;查看他人时只展示其分享给当前家庭/场所的内容。删除记事或移出附件列表不会删除网盘原文件。</p><div class="section-head"><p id="journal-query-status" role="status"></p><div><button id="journal-prev" class="quiet">上一页</button><button id="journal-next" class="quiet">下一页</button></div></div><div id="journal-list" class="journal-feed"></div>
</section>
<section id="page-overview" class="page" hidden><div id="scope-overview"></div></section><section id="page-device" class="page" hidden><DeviceNetworkPanel /><div id="device-detail"></div></section><section id="page-heritage" class="page" hidden><div id="heritage-home"></div></section>
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label></div></div><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
<div class="panel playback-controls"><label>镜头 / 通道<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像(待接入)</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载原始片段</a></div></div>
<div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div>
</section>
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button class="quiet" data-page="sites" data-admin>管理区域</button><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="例如:卧室 灯;也可查 IP、品牌、型号、人员"></label><button id="device-clear" type="button" class="quiet" hidden>清除设备筛选</button></div><div class="device-actions view-presets" role="group" aria-label="设备分组方式"><button type="button" class="quiet" data-group-preset="space,type">按区域查看</button><button type="button" class="quiet" data-group-preset="type,space">按类型查看</button><button type="button" class="quiet" data-group-preset="manager,type,space">按负责人查看</button></div><details class="group-custom"><summary>自定义分组顺序</summary><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div></details><p id="device-count" class="description"></p></div><div class="device-actions" role="group" aria-label="设备展示方式"><button id="devices-card-view" class="quiet" aria-pressed="true">设备卡片</button><button id="devices-topology-view" class="quiet" aria-pressed="false">网络拓扑</button></div><DeviceCollection /><NetworkMap /></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-heritage-records" class="page" hidden><div class="section-head"><h2 id="heritage-records-title">文化与家风</h2><div><button class="quiet" data-page="heritage">返回家庭传承</button><button id="heritage-new">+ 新建档案</button></div></div><div class="panel heritage-controls"><label>查找档案<input id="heritage-search" type="search" placeholder="名称、内容、相关人员"></label><label class="check"><input id="heritage-archived" type="checkbox">包含归档资料</label><button id="heritage-reload" class="quiet">重新载入</button></div><p id="heritage-error" class="form-error" role="status"></p><div id="heritage-list" class="site-grid"></div><aside id="heritage-detail" class="panel" hidden></aside></section>
<section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><button id="family-find" class="quiet" hidden>定位搜索结果</button><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
<div id="family-error" class="form-error" role="status"></div>
<div id="family-workspace" class="family-workspace">
<section id="family-canvas-panel" class="family-canvas-panel" aria-label="全家关系图">
<div class="family-map-heading"><div><span class="eyebrow">枝叶相连 · 世代相承</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
<div id="family-map-controls" class="family-map-controls"><div class="family-zoom-controls"><button id="family-zoom-out" class="quiet" aria-label="缩小家谱">−</button><output id="family-zoom-value" aria-live="polite">100%</output><button id="family-zoom-in" class="quiet" aria-label="放大家谱">+</button><button id="family-fit" class="quiet">查看全谱</button></div><button id="family-locate-self" class="quiet">◎ 定位本人</button><button id="family-fullscreen" class="quiet">全屏家谱</button></div>
<div id="family-tree" class="family-map-viewport" tabindex="0" role="region" aria-label="家谱关系图,可拖动空白处或使用滚动条浏览"></div>
<div id="family-people" class="family-people family-register" hidden></div>
<section id="family-events" hidden aria-label="家族重大记事">
<div class="family-event-intro"><div><h3>把值得记住的事,留给后来的人</h3><p class="description">按事情发生的日期排列,可关联多位亲人,并附上详细记载或报道链接。</p></div><button id="family-new-event">+ 写一则记事</button></div>
<div class="family-event-filters"><label>关联人物<select id="family-event-person"><option value="">全家</option></select></label><label>年份<select id="family-event-year"><option value="">所有年份</option></select></label><label>排列<select id="family-event-order"><option value="desc">从近到远</option><option value="asc">从远到近</option></select></label><label>搜索记事<input id="family-event-search" type="search" placeholder="标题、摘要或正文"></label><label class="check"><input id="family-event-relatives" type="checkbox">包括此人的父母、配偶和子女</label><label id="family-event-state-label">状态<select id="family-event-state"><option value="published">已发布</option><option value="draft">草稿箱</option><option value="archived">已归档</option><option value="all">全部</option></select></label><button id="family-event-clear" class="quiet">清除筛选</button></div>
<p id="family-event-count" class="description" role="status"></p><div id="family-timeline" class="family-timeline"></div>
</section>
<div id="family-map-footer" class="family-map-footer"><div class="family-map-legend"><span><i class="legend-self"></i>本人</span><span><i class="legend-selected"></i>正在查看</span><span><i class="legend-parent"></i>父母 → 子女</span><span><i class="legend-spouse"></i>配偶</span><span><i class="legend-adoptive"></i>收养 / 继亲</span></div><p id="family-map-note" class="description" role="status"></p><span class="description">拖动空白处移动 · 点击人物查看档案 · 关系称谓按参照人物计算</span></div>
</section>
<aside id="family-detail" class="panel family-inspector" aria-label="人物档案"></aside>
</div>
</section>
<HomeExplorer />
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">这里只管理当前家庭或场所的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。</p><div id="user-list"></div></section>
<footer><span id="brand-footer">赵府 · 智能数字管家</span><span id="version">独立部署 / 数据自有</span></footer>
</template>
+6
View File
@@ -0,0 +1,6 @@
import {shallowRef} from 'vue'
export const snapshot=shallowRef({state:{households:[],sites:[],cameras:[]},admin:false,loggedIn:false,page:'households',session:0,device:null})
export const deviceView=shallowRef({devices:[],groups:[],rows:[]})
window.ZhaoVue={enabled:true,accept(value){snapshot.value=value},setDevices(value){deviceView.value=value},clear(){deviceView.value={devices:[],groups:[],rows:[]}}}
export const bridge=()=>window.ZhaoVue
export const accessLabel=value=>({lan:'局域网',tailscale:'Tailscale',public:'公网',unknown:'接入待确认'}[value]||'接入待确认')
+9
View File
@@ -0,0 +1,9 @@
import {createApp} from 'vue'
import '../../web/style.css'
import '../../web/family-map.css'
import '../../web/chinese-theme.css'
import 'bootstrap/dist/css/bootstrap.min.css'
import './theme.css'
import App from './App.vue'
import './composables/bridge.js'
createApp(App).mount('#app')
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({plugins:[vue({template:{transformAssetUrls:false}})],base:'/ui/',build:{outDir:'../web/ui',emptyOutDir:true,assetsDir:'assets'}})
+30
View File
@@ -113,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')),
**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
+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
+104
View File
@@ -0,0 +1,104 @@
"""Read-only, scoped network checks for registered equipment. Never logs credentials."""
import ipaddress
import json
import re
import socket
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
def safe_ip(value):
ip = ipaddress.ip_address(value)
return not (ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified or ip.is_reserved)
def probe(host):
"""Connect only to resolved, validated addresses, preventing DNS rebinding."""
try:
addresses = list(dict.fromkeys(x[4][0] for x in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)))[:3]
if not addresses or not all(safe_ip(ip) for ip in addresses):
return dict(host=host, reachable=False, ports=[], error='地址不能用于设备网络探测')
except (OSError, ValueError):
return dict(host=host, reachable=False, ports=[], error='地址解析失败')
def connect(port):
for ip in addresses:
try:
with socket.create_connection((ip, port), timeout=.55): return port
except OSError: pass
return None
with ThreadPoolExecutor(max_workers=5) as pool:
ports = [port for port in pool.map(connect, (80, 443, 22, 554, 8790)) if port]
return dict(host=host, reachable=bool(ports), ports=ports,
error='' if ports else '常用服务端口未响应,设备可能离线或未开放这些服务')
def tail_peer(host, cidr):
if not host: return None
try:
result = subprocess.run(['tailscale', 'status', '--json'], capture_output=True, text=True, timeout=2, check=True)
status = json.loads(result.stdout)
peer = next((p for p in (status.get('Peer') or {}).values()
if host in p.get('TailscaleIPs', []) or host.lower().rstrip('.') == p.get('DNSName', '').lower().rstrip('.')), None)
if not peer: return dict(available=False, note='本机 Tailscale 未找到此节点')
routes = list(dict.fromkeys((peer.get('PrimaryRoutes') or [])+(peer.get('AllowedIPs') or [])))
# Online is control-plane presence; it does not prove the subnet is reachable.
return dict(available=True, online=bool(peer.get('Online')), active=bool(peer.get('Active')),
subnet=cidr, subnetAvailable=(cidr in routes) if cidr else None)
except (OSError, subprocess.SubprocessError, ValueError, TypeError):
return dict(available=False, note='本机暂时无法读取 Tailscale 状态')
def management_links(results):
links=[]
for item in results:
host=item['host']; authority='['+host+']' if ':' in host else host
try:
if not safe_ip(host):continue
except ValueError:
if not re.fullmatch(r'[A-Za-z0-9.-]{1,253}',host):continue
ports=item.get('ports',[])
port=next((p for p in (443,80,8790) if p in ports),80)
scheme='https' if port==443 else 'http'
links.append(dict(kind=item['kind'],url=scheme+'://'+authority+(':'+str(port) if port==8790 else '')+'/',port=port,verified=port in ports))
return links
class NetworkStatus:
def __init__(self, app):
self.a=app; self.cache={}; self.lock=threading.Lock(); self.gates={}
def read(self, user, query):
a=self.a
if not a.HOUSEHOLDS.manager(user): raise a.Problem('此操作需要家庭管理员权限',403)
table={'device':'devices','camera':'assets','recorder':'recorders'}.get(query.get('source'))
if not table: raise a.Problem('设备类型不正确')
item=next((d for d in a.HOUSEHOLDS.objects(user,table) if d['id']==query.get('id')),None)
if not item: raise a.Problem('设备不存在或未获授权',404)
hosts=[]
direct=item.get('host') if table=='recorders' else item.get('managementHost')
if table=='assets':
direct=next((c.get('host') for c in a.HOUSEHOLDS.objects(user,'cameras') if c.get('assetId')==item['id'] and c.get('sourceKind')!='recorder' and c.get('host')),direct)
for kind,host in [('local',direct),('tailscale',item.get('tailscaleHost'))]:
if host and host not in [h[1] for h in hosts]: hosts.append((kind,host))
key=(a.HOUSEHOLDS.family_of(item),table,item['id'],tuple(hosts),item.get('networkCidr',''))
with self.lock:
if key in self.cache and time.monotonic()-self.cache[key][0]<30:return {**self.cache[key][1],'cached':True}
# Cap concurrency and collapse repeated requests to the same device.
if key in self.gates:raise a.Problem('正在检查该设备,请稍后再试',429)
if len(self.gates)>=4:raise a.Problem('网络检查繁忙,请稍后再试',429)
self.gates[key]=True
try:
with ThreadPoolExecutor(max_workers=3) as pool:
futures=[(kind,pool.submit(probe,host)) for kind,host in hosts]
tail=pool.submit(tail_peer,item.get('tailscaleHost',''),item.get('networkCidr',''))
targets=[dict(f.result(),kind=kind) for kind,f in futures]
result=dict(id=item['id'],source=query['source'],checkedAt=time.time(),targets=targets,tailscale=tail.result(),links=management_links(targets),cached=False)
with self.lock:
self.cache={k:v for k,v in self.cache.items() if time.monotonic()-v[0]<30}
if len(self.cache)>=256:self.cache.pop(next(iter(self.cache)))
self.cache[key]=(time.monotonic(),result)
return result
finally:
with self.lock:self.gates.pop(key,None)
+2
View File
@@ -0,0 +1,2 @@
const fs=require('node:fs'),path=require('node:path');
module.exports=()=>['AppSidebar','AppHeader','Workspace','RuntimeOverlays','HomeExplorer','DeviceCollection','NetworkMap'].map(name=>fs.readFileSync(path.join(__dirname,'../frontend/src/components',name+'.vue'),'utf8')).join('\n');
+6 -5
View File
@@ -97,7 +97,7 @@ class WebTests(unittest.TestCase):
def test_live_quality_has_only_main_and_sub(self):
code, _, body = self.request('/', False)
self.assertEqual(code, 200)
select = re.search(r'<select id="quality">(.*?)</select>', body.decode()).group(1)
select = re.search(r'<select id="quality">(.*?)</select>', (app.ROOT/'frontend/src/components/Workspace.vue').read_text(encoding='utf8')).group(1)
self.assertEqual(re.findall(r'<option value="(.*?)">(.*?)</option>', select),
[('hd', '主码流'), ('compat', '子码流')])
@@ -106,12 +106,13 @@ class WebTests(unittest.TestCase):
self.assertEqual(code, 200)
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), 29)
version = (app.ROOT / 'VERSION').read_text().strip()
assets = re.findall(r'(?:src|href)="(/ui/assets/[^"?]+\.(?:js|css))"', body.decode())
self.assertEqual(len(assets), 2)
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
self.assertRegex(asset,r'/ui/assets/index-[A-Za-z0-9_-]+\.(js|css)$')
self.assertEqual(self.request(asset, False)[0], 200)
for path in ['/ui/assets/../../vision.db','/ui/assets/missing.js','/ui/index.html']:
self.assertEqual(self.request(path,False)[0],404)
def test_origin_rejected(self):
self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403)
+1 -1
View File
@@ -62,7 +62,7 @@ test('mobile navigation toggles an accessible control without changing scope',()
c.setMobileMenu(false);assert.equal(opened,false);assert.equal(button['aria-expanded'],'false');
});
test('mobile forms use readable inputs, touch targets and bounded dialogs',()=>{
const css=source('chinese-theme.css'),html=source('index.html');
const css=source('chinese-theme.css'),html=require('./test-support/ui-source')();
assert.match(css,/font-size:16px!important;min-height:44px/);
assert.match(css,/max-height:90dvh/);assert.match(css,/sidebar:not\(\.mobile-open\)>nav/);
assert.match(html,/aria-controls="main-navigation"/);
+20
View File
@@ -28,3 +28,23 @@ test('camera recorder and general device records appear in one catalog without m
assert.deepEqual(m.catalog(state).map(d=>d.deviceType),['摄像机','录像机','冰箱']);assert.equal(JSON.stringify(state),before);
assert.equal(m.groups(devices,[],sites).length,1);
});
test('same-name people with different references form separate responsibility groups',()=>{
const rows=[{id:'a',managerName:'张伟',managerRef:'user:one'},{id:'b',managerName:'张伟',managerRef:'user:two'}];
assert.equal(m.groups(rows,['manager'],[]).length,2);
});
test('network topology preserves explicit uplinks and remains usable with missing or cyclic parents',()=>{
const data=[{id:'r'},{id:'c',uplinkId:'r'},{id:'p',uplinkId:'hidden'}];
const tree=m.topology(data);assert.deepEqual(tree.children.get('r').map(x=>x.id),['c']);assert.equal(tree.parents.get('p'),'');
assert.equal(m.topology([{id:'a',uplinkId:'b'},{id:'b',uplinkId:'a'}]).children.get('').length,2);
});
test('network summary distinguishes recorder stream source from physical camera address',()=>{
const n=m.network({id:'a',managementHost:'192.168.8.160',networkAccess:'tailscale'},{cameras:[{assetId:'a',host:'192.168.3.135',sourceKind:'recorder',recorderId:'r',archiveSource:'recorder',archiveRecorderId:'r'}]});
assert.deepEqual(n.hosts,['192.168.8.160']);assert.deepEqual(n.via,['r']);assert.deepEqual(n.archiveVia,['r']);assert.equal(n.access,'tailscale');
});
test('configured camera address overrides an old inventory address and is searchable',()=>{
const rows=m.catalog({assets:[{id:'a',managementHost:'192.168.1.20'}],cameras:[{assetId:'a',host:'192.168.1.30',sourceKind:'direct'}]});
assert.deepEqual(rows[0].networkHosts,['192.168.1.30']);assert.equal(m.matches(rows[0],'192.168.1.30',[]),true);
});
+1 -1
View File
@@ -68,7 +68,7 @@ test('a stale post-save refresh does not show a new-session warning',async()=>{
});
test('storage guide names cloud categories and avoids old all-local claims',()=>{
const html=fs.readFileSync('web/index.html','utf8');
const html=require('./test-support/ui-source')();
assert.ok(html.includes('id="baidu-storage-guide"'));
for(const label of ['家庭与场所:','平台外观:','个人记事:','数据库备份:'])assert.ok(html.includes(label));
assert.ok(!html.includes('自有部署 · 本地存储'));assert.ok(!app.includes('数据存于本机'));
+88
View File
@@ -0,0 +1,88 @@
"""Responsibility references and personal household layout regressions."""
import unittest
from test_app import app
import test_households as fixture
class InventoryTests(unittest.TestCase):
setUp = fixture.HouseholdTests.setUp
stop_server = fixture.HouseholdTests.stop_server
request = fixture.HouseholdTests.request
def test_candidates_are_scoped_minimal_and_support_current_superadmin(self):
options = self.request('/api/state')[1]['responsibilityOptions']
refs = {p['ref'] for p in options}
self.assertIn('user:'+self.members[self.aid]['id'], refs)
self.assertNotIn('user:'+self.members[self.bid]['id'], refs)
self.assertTrue(all(set(p)=={'ref','name','label','kind'} for p in options))
root_options = app.INVENTORY.candidates(self.roots[self.bid])
self.assertIn('user:'+self.root['id'], {p['ref'] for p in root_options})
self.assertNotIn('responsibilityOptions', self.request('/api/state', self.members[self.aid])[1])
def test_reference_names_are_authoritative_and_cannot_cross_households(self):
member = self.members[self.aid]
data = dict(self.assets[self.aid],ownerRef='user:'+member['id'],ownerName='spoofed')
code, saved = self.request('/api/assets',data=data)
self.assertEqual(code,200);self.assertEqual(saved['ownerName'],member['name'])
data['managerRef']='user:'+self.members[self.bid]['id']
self.assertEqual(self.request('/api/assets',data=data)[0],400)
app.ACCOUNTS.save(dict(app.get_object('users',member['id']),name='更新名字'),self.roots[self.aid])
shown=self.request('/api/state')[1]['assets'][0]
self.assertEqual(shown['ownerName'],'更新名字');self.assertEqual(shown['ownerRef'],saved['ownerRef'])
self.assertEqual(self.request('/api/assets',data=dict(saved,ownerRef='',ownerName='共有单位'))[1]['ownerName'],'共有单位')
def test_archived_or_disabled_links_are_preserved_but_not_newly_assigned(self):
member=self.members[self.aid]
saved=app.INVENTORY.save(dict(name='测试灯',deviceType='灯',managerRef='user:'+member['id']),self.owner)
app.ACCOUNTS.save(dict(app.get_object('users',member['id']),disabled=True),self.roots[self.aid])
self.assertEqual(app.INVENTORY.save(saved,self.owner)['managerRef'],saved['managerRef'])
with self.assertRaises(app.Problem):app.INVENTORY.save(dict(name='新灯',deviceType='灯',managerRef=saved['managerRef']),self.owner)
def test_people_and_all_device_types_share_references_without_granting_access(self):
person=dict(id='e'*16,name='家庭成员',familyId=self.aid,archived=False)
app.save_object('people',person)
ref='person:'+person['id'];self.assertIn(ref,{p['ref'] for p in app.INVENTORY.candidates(self.owner)})
before=app.objects('users')
rows=[app.save_asset(dict(self.assets[self.aid],managerRef=ref),self.owner),
app.save_recorder(dict(name='录像机',siteId=self.sites[self.aid]['id'],host='192.168.9.50',managerRef=ref),self.owner),
app.INVENTORY.save(dict(name='灯',deviceType='灯',managerRef=ref),self.owner)]
self.assertTrue(all(r['managerName']=='家庭成员' for r in rows));self.assertEqual(before,app.objects('users'))
def test_layout_persists_per_account_and_rejects_conflicts_and_cross_scope(self):
data=dict(revision=0,columns=3,rows=2,groupCity=False,order=[self.bid,self.aid])
code,saved=self.request('/api/households/layout',self.root,data)
self.assertEqual(code,200);self.assertEqual(saved['order'],data['order'])
self.assertEqual(self.request('/api/state',self.root)[1]['householdLayout']['columns'],3)
self.assertEqual(app.HOUSEHOLDS.layout(self.owner)['columns'],2)
self.assertEqual(self.request('/api/households/layout',self.root,data)[0],409)
self.assertEqual(self.request('/api/households/layout',self.owner,data)[0],400)
for bad in [dict(data,revision=1,columns=0),dict(data,revision=1,rows=True),dict(data,revision=1,order=[self.aid,self.aid]),dict(data,revision=1,userId=self.owner['id'])]:
self.assertEqual(self.request('/api/households/layout',self.root,bad)[0],400)
member=self.members[self.aid]
self.assertEqual(self.request('/api/households/layout',member,dict(data,order=[self.aid]))[0],200)
def test_topology_references_and_network_fields_validate_without_opening_access(self):
gateway=app.INVENTORY.save(dict(name='网关',deviceType='路由器',managementHost='192.168.8.1',tailscaleHost='gateway.example.ts.net',networkCidr='192.168.8.0/24',networkAccess='tailscale'),self.owner)
camera=app.save_asset(dict(self.assets[self.aid],uplinkId=gateway['id'],connectionType='wired'),self.owner)
with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,uplinkId=camera['id']),self.owner)
with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,uplinkId=gateway['id']),self.owner)
with self.assertRaises(app.Problem):app.save_asset(dict(camera,uplinkId=self.assets[self.bid]['id']),self.owner)
for bad in [dict(networkCidr='192.168.8.160/24'),dict(managementHost='http://user:secret@example.com'),dict(networkAccess='all'),dict(connectionType='anything')]:
with self.assertRaises(app.Problem):app.INVENTORY.save(dict(gateway,**bad),self.owner)
self.assertEqual(self.request('/api/state',self.other)[1]['devices'],[])
self.assertEqual(app.INVENTORY.save(dict(gateway,networkAccess='public',managementHost='example.org'),self.owner)['managementHost'],'example.org')
def test_global_topology_summaries_count_only_authorized_devices(self):
app.INVENTORY.save(dict(name='未授权区域的设备',deviceType='路由器',networkAccess='tailscale'),self.owner)
own=self.request('/api/state',self.members[self.aid])[1]['households']
self.assertEqual(len(own),1)
self.assertEqual(own[0]['deviceCount'],1)
self.assertNotIn('tailscale',own[0]['networkAccesses'])
managed=self.request('/api/state',self.owner)[1]['households'][0]
self.assertEqual(managed['deviceCount'],2)
self.assertIn('tailscale',managed['networkAccesses'])
overall=self.request('/api/state',self.root)[1]['households']
self.assertEqual({h['id']:h['deviceCount'] for h in overall},{self.aid:2,self.bid:1})
if __name__=='__main__':unittest.main()
+2 -2
View File
@@ -11,7 +11,7 @@ test('browser hashes in 4 MiB slices rather than loading the entire video',async
await assert.rejects(hash.blocks(file,()=>{},()=>false),/取消/);
});
test('journals default to private and encode content before rendering',()=>{
const html=fs.readFileSync('web/index.html','utf8'),js=fs.readFileSync('web/journal.js','utf8');
const html=require('./test-support/ui-source')(),js=fs.readFileSync('web/journal.js','utf8');
assert.match(html,/<select name="visibility"><option value="private">/);assert.match(js,/esc\(e.text\)/);assert.match(js,/esc\(c.text\)/);
assert.doesNotMatch(js,/localStorage|indexedDB|caches\.open|access_token|secretKey.*localStorage/);
});
@@ -35,7 +35,7 @@ test('successful save clears draft attachment ids, stops recorder, and reloads f
assert.match(js,/available.filter\(m=>journalDraftMedia.has\(m.id\)\)/);
});
test('attachment preview uses a closable dialog and deletion has a restore path',()=>{
const js=fs.readFileSync('web/journal.js','utf8'),html=fs.readFileSync('web/index.html','utf8');
const js=fs.readFileSync('web/journal.js','utf8'),html=require('./test-support/ui-source')();
assert.match(html,/<dialog id="journal-preview"/);assert.match(js,/addEventListener\('close'/);assert.match(js,/\/api\/journal\/restore-upload/);
assert.match(js,/journal-library-list/);assert.match(js,/if\(other!==media\)other.pause\(\)/);
});
+66
View File
@@ -0,0 +1,66 @@
"""Network status must be scoped, bounded and honest about reachability."""
import json
import unittest
from unittest.mock import patch,Mock
import network_status as ns
from test_app import app
import test_households as fixture
class NetworkTests(unittest.TestCase):
setUp=fixture.HouseholdTests.setUp
stop_server=fixture.HouseholdTests.stop_server
request=fixture.HouseholdTests.request
def test_scoped_checks_cannot_probe_an_arbitrary_host(self):
device=app.INVENTORY.save(dict(name='网关',deviceType='路由器',managementHost='192.168.8.1'),self.owner)
endpoint='/api/devices/network?source=device&id='+device['id']
with patch.object(ns,'probe',return_value=dict(host='192.168.8.1',reachable=True,ports=[80])) as probe:
result=self.request(endpoint)[1]
self.assertTrue(result['targets'][0]['reachable'])
self.assertEqual(result['links'][0]['url'],'http://192.168.8.1/')
self.assertEqual(self.request(endpoint,self.other)[0],404)
self.assertEqual(self.request(endpoint,self.members[self.aid])[0],403)
self.assertEqual(self.request('/api/devices/network?source=device&host=example.com')[0],404)
again=self.request(endpoint)[1];self.assertTrue(again['cached']);self.assertEqual(probe.call_count,1)
def test_record_changes_invalidate_cache(self):
device=app.INVENTORY.save(dict(name='网关',deviceType='路由器',managementHost='192.168.8.1'),self.owner)
with patch.object(ns,'probe',side_effect=lambda host:dict(host=host,ports=[],reachable=False)) as probe:
query=dict(source='device',id=device['id'])
app.NETWORK.read(self.owner,query)
app.INVENTORY.save(dict(device,managementHost='192.168.8.2'),self.owner)
result=app.NETWORK.read(self.owner,query)
self.assertEqual(probe.call_count,2)
self.assertFalse(result['targets'][0]['reachable'])
self.assertFalse(result['links'][0]['verified'])
self.assertEqual(result['links'][0]['url'],'http://192.168.8.2/')
class NetworkUnitTests(unittest.TestCase):
def test_rejects_local_metadata_and_unsafe_links(self):
for host in ['127.0.0.1','169.254.169.254','::1','0.0.0.0','224.0.0.1']:
self.assertFalse(ns.safe_ip(host))
self.assertTrue(ns.safe_ip('192.168.8.1'));self.assertTrue(ns.safe_ip('100.64.1.2'))
for host in ['127.0.0.1','javascript:alert(1)','user:secret@example.com','example.com/path']:
self.assertEqual(ns.management_links([dict(host=host,kind='local',ports=[80])]),[])
with patch.object(ns.socket,'getaddrinfo',return_value=[(2,1,6,'',('127.0.0.1',0))]),patch.object(ns.socket,'create_connection') as connect:
self.assertFalse(ns.probe('malicious.example')['reachable']);connect.assert_not_called()
def test_peer_online_does_not_prove_subnet_route(self):
status={'Peer':{'abc':{'DNSName':'gateway.example.ts.net.','TailscaleIPs':['100.64.1.2'],'Online':True,'Active':False,'AllowedIPs':['100.64.1.2/32'],'PrimaryRoutes':None}}}
with patch.object(ns.subprocess,'run',return_value=Mock(stdout=json.dumps(status))):
peer=ns.tail_peer('100.64.1.2','192.168.8.0/24')
self.assertTrue(peer['online']);self.assertFalse(peer['subnetAvailable']);self.assertNotIn('Peer',peer)
status['Peer']['abc']['PrimaryRoutes']=['192.168.8.0/24']
with patch.object(ns.subprocess,'run',return_value=Mock(stdout=json.dumps(status))):
self.assertTrue(ns.tail_peer('gateway.example.ts.net','192.168.8.0/24')['subnetAvailable'])
with patch.object(ns.subprocess,'run',side_effect=FileNotFoundError):self.assertFalse(ns.tail_peer('100.64.1.2','')['available'])
def test_uses_detected_web_port_and_ipv6_brackets(self):
links=ns.management_links([dict(host='192.168.3.151',kind='local',ports=[8790]),dict(host='fd7a:115c:a1e0::1',kind='tailscale',ports=[443])])
self.assertEqual(links[0]['url'],'http://192.168.3.151:8790/')
self.assertEqual(links[1]['url'],'https://[fd7a:115c:a1e0::1]/')
if __name__=='__main__':unittest.main()
+9 -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}]},
ZhaoGeography:require('./web/geography.js'),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/);
+1 -1
View File
@@ -1,5 +1,5 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
const code=fs.readFileSync('web/steward.js','utf8'),html=fs.readFileSync('web/index.html','utf8'),app=fs.readFileSync('web/app.js','utf8');
const code=fs.readFileSync('web/steward.js','utf8'),html=require('./test-support/ui-source')(),app=fs.readFileSync('web/app.js','utf8');
const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)+a.length));
test('navigation starts at places and contains no global camera functions',()=>{
const nav=html.match(/<nav[^>]*aria-label="主导航">([\s\S]*?)<\/nav>/)[1];
+6 -7
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const css = fs.readFileSync(path.join(__dirname, 'web/chinese-theme.css'), 'utf8');
const html = fs.readFileSync(path.join(__dirname, 'web/index.html'), 'utf8');
const html = require('./test-support/ui-source')();
const root = css.match(/:root\s*\{([^}]+)\}/)[1];
const token = name => root.match(new RegExp('--' + name + ':\\s*(#[0-9a-f]{6})'))[1];
function luminance(hex) {
@@ -15,12 +15,11 @@ function contrast(a, b) {
const x = luminance(a), y = luminance(b);
return (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
}
test('paper theme is loaded last, versioned and uses only local fonts', () => {
const styles = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map(x => x[1]);
assert.equal(styles.at(-1), '/chinese-theme.css?v=__ASSET_VERSION__');
assert.doesNotMatch(css, /@import|https?:|@font-face/i);
assert.match(html, /name="theme-color" content="#213d34"/);
assert.match(html, /class="courtyard-landscape"[^>]+aria-hidden="true"/);
test('Vue theme builds locally after Bootstrap without remote fonts', () => {
const main=fs.readFileSync('frontend/src/main.js','utf8');
assert.ok(main.indexOf('bootstrap/dist/css/bootstrap.min.css')<main.indexOf('./theme.css'));
assert.doesNotMatch(fs.readFileSync('frontend/src/theme.css','utf8'), /@import|https?:|@font-face/i);
assert.match(fs.readFileSync('frontend/index.html','utf8'), /name="viewport"/);
});
test('primary text, muted text and actions have at least 4.5:1 contrast', () => {
for (const [fg, bg] of [
+29
View File
@@ -0,0 +1,29 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs');
const read=path=>fs.readFileSync(path,'utf8');
test('Vue production entry uses local hashed assets and precompiled templates',()=>{
const html=read('web/ui/index.html');
assert.match(html,/type="module"[^>]+src="\/ui\/assets\/index-/);
assert.match(html,/rel="stylesheet"[^>]+href="\/ui\/assets\/index-/);
assert.doesNotMatch(html,/https?:\/\/|unsafe-eval/);
const config=read('frontend/package.json');assert.match(config,/"vue"/);assert.match(config,/"bootstrap"/);
});
test('Vue compatibility boundary preserves unique static DOM identities',()=>{
const source=require('./test-support/ui-source')();
const ids=[...source.matchAll(/\sid="([\w-]+)"/g)].map(m=>m[1]);
assert.equal(ids.length,new Set(ids).size);
for(const id of ['auth-form','device-form','household-list','asset-list','device-topology','device-detail','live-grid','journal-form'])assert.ok(ids.includes(id),id);
const app=read('web/app.js');assert.match(app,/window\.ZhaoVue\?\.clear\(\)/);assert.match(app,/window\.ZhaoVue\?\.publish\?\.\(\)/);
const load=read('frontend/src/compat/load.js');assert.match(load,/await new Promise/);assert.match(load,/await window\.ZhaoVue.boot/);
});
test('network widget rejects stale responses and stops timers on unmount',()=>{
const component=read('frontend/src/components/NetworkStatus.vue');
assert.match(component,/current===generation/);assert.match(component,/onUnmounted/);assert.match(component,/clearTimeout\(timer\)/);
assert.match(component,/rel="noopener noreferrer"/);assert.match(component,/snapshot.admin/);
const routes=read('app.py');assert.match(routes,/self.require_manager\(\); self.answer\(NETWORK.read/);
});
test('explicit device detail does not force camera live view',()=>{
const vm=require('node:vm'),source=read('web/steward.js');let shown='';
const context=vm.createContext({ZhaoDevices:{catalog:()=>[{id:'a',source:'camera'}]},state:{cameras:[{}]},toast(){},stopPlayers(){},resetPlayback(){},deviceCameras:()=>[{}],$:()=>({value:''}),showPage:value=>shown=value});
vm.runInContext(source.slice(source.indexOf('function enterDevice('),source.indexOf('function renderSteward(')),context);
context.enterDevice('camera','a','device');assert.equal(shown,'device');context.enterDevice('camera','a');assert.equal(shown,'live');
});
+6 -3
View File
@@ -36,7 +36,8 @@ 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();
window.ZhaoVue?.clear();
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']){if(!window.ZhaoVue||!['asset-list','household-list','device-topology','home-global-topology'].includes(id))$('#'+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());
@@ -71,7 +72,7 @@ function showPage(page, options={}) {
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
const pageChanged=activePage!==page;activePage = page;
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings','journal'].includes(page);
$('.metrics').hidden=['family','users','households','heritage','heritage-records','settings','journal','device'].includes(page);
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
$$('.page').forEach(el => el.hidden = el.id !== 'page-' + page);
$$('nav button').forEach(b => b.classList.toggle('active', b.dataset.page === page || b.dataset.page==='objects'&&['sites','device','cameras','recorders','live','playback'].includes(page)||b.dataset.page==='heritage'&&['family','heritage-records'].includes(page)));
@@ -111,6 +112,7 @@ function render() {
if(typeof renderHouseholds==='function')renderHouseholds();
applyPermissions();
if(activePage==='live'&&typeof wallSync==='function')wallSync();
window.ZhaoVue?.publish?.();
}
function empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
function renderLive() {
@@ -178,7 +180,8 @@ $('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#s
$('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}};
window.addEventListener('beforeunload',stopPlayers);
setInterval(()=>{$('#clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
setInterval(refresh,12000);document.addEventListener('DOMContentLoaded',async()=>{showPage('households');if(typeof loadBranding==='function')await loadBranding();await auth();},{once:true});
async function bootApplication(){showPage('households');if(typeof loadBranding==='function')await loadBranding();await auth();}
setInterval(refresh,12000);if(!window.ZhaoVue)document.addEventListener('DOMContentLoaded',bootApplication,{once:true});
function renderAssets(){renderDevices();}
function renderRecorders(){
+12
View File
@@ -344,3 +344,15 @@ html[data-theme=ink] .journal-upload-card{background:#20352d}html[data-theme=ink
/* 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}
+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,5 +1,14 @@
'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('');
@@ -16,7 +25,7 @@ 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>${videoDeviceActions(d)}${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'];
@@ -29,12 +38,41 @@ 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'&&!window.ZhaoVue)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(' → ')||'全部平铺'} 查看`;
if(window.ZhaoVue){
const tree=ZhaoDevices.topology(devices),catalog=ZhaoDevices.catalog(state),rows=[],stack=(tree.children.get('')||[]).slice().reverse().map(device=>({device,depth:0}));
const name=id=>catalog.find(d=>d.id===id)?.name||'关联设备待确认';
while(stack.length){const row=stack.pop(),info=ZhaoDevices.network(row.device,state);rows.push({...row,parentName:row.device.uplinkId?name(row.device.uplinkId):'',via:info.via.map(name),archiveVia:info.archiveVia.map(name)});stack.push(...(tree.children.get(row.device.id)||[]).slice().reverse().map(device=>({device,depth:row.depth+1})));}
window.ZhaoVue.setDevices({devices,groups:ZhaoDevices.groups(devices,deviceGroups,state.sites),rows,mode:devicePresentation});return;
}
$('#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();
@@ -45,6 +83,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();}};
+42 -4
View File
@@ -2,14 +2,52 @@
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||[];
if(typeof window!=='undefined'&&window.ZhaoVue){setOptions($('#household-filter'),(state.households||[]).map(h=>`<option value="${esc(h.id)}">${esc(scopeKind(h)+' · '+h.name)}</option>`).join(''));$('#household-filter').value=state.familyId||'';return;}
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(ZhaoGeography.describe(h)||'地区待填写')}</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.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=[...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='';};}
}
if(typeof window==='undefined'||!window.ZhaoVue){
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;
-119
View File
@@ -1,119 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府 · 智能数字管家</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/device-model.js?v=__ASSET_VERSION__"></script><script defer src="/data/geography-data.js?v=__ASSET_VERSION__"></script><script defer src="/geography.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
<script defer src="/recording-ranges.js?v=__ASSET_VERSION__"></script><script defer src="/recording-ui.js?v=__ASSET_VERSION__"></script><script defer src="/snapshot.js?v=__ASSET_VERSION__"></script><script defer src="/households.js?v=__ASSET_VERSION__"></script><script defer src="/devices.js?v=__ASSET_VERSION__"></script><script defer src="/steward.js?v=__ASSET_VERSION__"></script><script defer src="/heritage.js?v=__ASSET_VERSION__"></script><script defer src="/personalization.js?v=__ASSET_VERSION__"></script>
<script defer src="/file-hash.js?v=__ASSET_VERSION__"></script><script defer src="/journal.js?v=__ASSET_VERSION__"></script>
</head>
<body>
<aside class="sidebar">
<a class="brand" href="/"><img id="brand-image" class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span><span id="brand-name">赵府</span><small id="brand-subtitle">智能数字管家</small></span></a>
<button id="mobile-nav-toggle" class="quiet mobile-nav-toggle" aria-expanded="false" aria-controls="main-navigation" type="button">☰ 菜单</button>
<div class="nav-label">家宅 · 起居</div>
<nav id="main-navigation" aria-label="主导航">
<button data-page="households" class="active">⌂ <span>我的家庭与场所</span></button>
<div id="scope-navigation" hidden><div id="scope-nav-name" class="nav-label"></div>
<button data-page="journal">✎ <span>记事与阅历</span></button>
<button data-page="overview">◫ <span>场所总览</span></button>
<button data-page="objects">▣ <span>区域与设备</span></button>
<button data-page="heritage">♧ <span>家庭传承</span></button>
<button data-page="users" data-admin>♙ <span>成员与权限</span></button>
</div>
<button data-page="settings">⚙ <span>设置中心</span></button>
<button data-page="storage">▤ <span>平台存储与审计</span></button>
</nav>
<div class="side-note"><span class="dot"></span> 独立部署 · 数据自有<p>按空间组织画面<br>让每一段记录有处可寻</p></div>
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
</aside>
<main>
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow" id="brand-motto">赵氏家宅 · 起居有序</div><h1 id="page-title">我的家庭与场所</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
<div id="household-context" class="household-context" hidden><label>当前归属 <select id="household-filter" aria-label="当前归属"></select></label><span id="household-context-note"></span><button id="scope-back" class="quiet">返回场所</button></div>
<div id="device-context" class="device-context" hidden><p id="device-breadcrumb"></p><div role="group" aria-label="设备功能"><button class="quiet" data-device-tab="device">设备档案</button><button class="quiet" data-device-tab="live">实时画面</button><button class="quiet" data-device-tab="playback">录像回放</button><button class="quiet" data-device-tab="cameras">镜头与设置</button></div></div><div id="banner" class="banner" hidden></div>
<section class="metrics"><div><span>设备</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">可选择任意区域,包含其下级区域</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
<section id="page-settings" class="page" hidden>
<div class="section-head"><h2>把这里布置成你习惯的样子</h2><button id="settings-reload" class="quiet">重新读取设置</button></div><p id="settings-error" role="status"></p><div class="settings-sections" role="group" aria-label="设置分类"><button class="quiet" data-settings-section="preferences" aria-pressed="true">我的偏好</button><button class="quiet" data-settings-section="baidu" data-platform-setting hidden>网盘连接</button><button class="quiet" data-settings-section="brand" data-platform-setting hidden>品牌与外观</button><button class="quiet" data-settings-section="backup" data-platform-setting hidden>数据库备份</button></div>
<form id="preferences-form" class="panel"><h3>我的偏好</h3><p class="description">只影响当前账号,保存后换电脑或手机登录也会沿用。切换选项可以预览,离开未保存的预览会撤销。</p><div id="personal-choices" class="form-grid"></div><p id="preferences-status" role="status"></p><div class="device-actions"><button type="submit">保存我的偏好</button><button type="button" id="personal-follow" class="quiet">全部跟随平台</button><button type="button" id="personal-cancel" class="quiet">撤销预览</button></div></form>
<section id="baidu-admin" class="panel" hidden><div class="section-head"><h3>百度网盘 · 统一存储</h3><span class="badge">仅超级管理员配置</span></div><p class="description">连接一个网盘账号,统一保存封面、Logo、图标及各用户上传的照片、视频和语音。系统按用户、家庭和记事可见范围授权;网盘账号持有人本身可以管理网盘中的文件。服务器仅转发分片,不保存媒体原文件。</p><details id="baidu-storage-guide"><summary>文件存在哪里?</summary><ul><li>家庭与场所:按家庭或场所保存封面。</li><li>平台外观:保存 Logo 和图标。</li><li>个人记事:按家庭、用户分组,再分照片、视频、语音。</li><li>数据库备份:单独存放;新备份上传并校验成功后,清理超过一个自然月的旧云备份。</li></ul><p class="description">文字、权限和文件引用保存在本机数据库;个人媒体原文件保存在百度网盘。封面和品牌图片仅做有限的内存缓存。更换封面、移除附件或删除记事不会删除网盘原文件。</p></details><form id="baidu-config"><div class="form-grid"><label>AppKey<input name="appKey" autocomplete="off" maxlength="150" required></label><label>SecretKey<input name="secretKey" type="password" autocomplete="new-password" maxlength="150"></label><label>百度后台应用名称<input name="appFolder" value="智家" required maxlength="40"></label><label>OAuth 回调地址<input name="redirectUri" type="url" placeholder="https://你的域名/api/baidu/callback" required maxlength="400"></label></div><p class="description">回调地址须与百度后台完全一致。SecretKey 留空保持已保存值,SignKey 不需要填写。当前开发者应用仅限个人场景,适用范围以百度后台为准。</p><p id="baidu-config-status" role="status"></p><button type="submit">保存网盘应用配置</button></form><hr><p id="baidu-status" role="status"></p><div class="device-actions"><button id="baidu-connect">连接/重新授权百度网盘</button><button id="baidu-disconnect" class="quiet">解除本机连接</button></div><p class="description">解除连接不会删除云端文件。临时媒体地址由后台按需获取,令牌不发送给浏览器。网盘授权凭据单独保存在服务器受限目录,不包含在数据库备份或公开源码中。</p></section>
<div id="settings-private" hidden>
<form id="brand-form" class="panel"><h3>发布与品牌配置</h3><p class="description">仅超级管理员可修改。名称、Logo、浏览器图标及登录文案会公开显示;适用于整套系统。已有个人偏好优先于平台默认。</p>
<div class="form-grid"><label>系统名称<input name="name" required maxlength="40"></label><label>系统副标题<input name="subtitle" required maxlength="60"></label><label class="full">页首题词<input name="motto" maxlength="100"></label><label>登录标题<input name="loginTitle" required maxlength="40"></label><label>登录说明<input name="loginNote" maxlength="160"></label><label class="full">页脚说明<input name="footer" maxlength="120"></label></div>
<div class="brand-image-grid"><div><img id="brand-logo-preview" alt="Logo 预览" src="/zhao-icon.png"><label>更换系统 Logo<input id="brand-logo-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-logo-reset" class="quiet">使用默认 Logo</button></div><div><img id="brand-icon-preview" alt="浏览器图标预览" src="/zhao-icon.png"><label>更换浏览器图标<input id="brand-icon-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="brand-icon-reset" class="quiet">使用默认图标</button></div></div><p class="description">支持 PNG、JPEG、WebP,原图最多 5 MB;上传时自动缩小,图片随系统数据库保存。</p>
<h3>新账号与跟随平台账号的默认外观</h3><div id="brand-defaults" class="form-grid"></div><p id="brand-status" role="status"></p><div class="device-actions"><button type="submit">保存发布配置</button><button type="button" id="brand-export" class="quiet">导出发布配置</button><label class="config-import">导入配置到表单<input id="brand-import" type="file" accept="application/json,.json"></label></div><p class="description">导入后先核对再保存。导出的配置只含品牌与默认外观,不含账号、设备凭据、家庭资料和网络备份目录。</p>
</form>
<section id="backup-panel" class="panel"><h3>数据库备份</h3><p class="description">整台服务的数据库包含所有家庭、账号、设备连接配置和网盘文件引用,因此仅超级管理员可操作。网盘中的照片、视频、语音及录像文件不包含在新数据库副本中;旧版备份可能仍含历史内嵌图片。恢复后所有账号需要重新登录。</p><form id="backup-form"><div class="form-grid"><label class="check full"><input name="enabled" type="checkbox">启用每日定时备份</label><label class="check full"><input name="baidu" type="checkbox">同时上传到已连接的百度网盘</label><label>每日时间(北京时间)<input name="time" type="time" required value="03:00"></label><label>本机/挂载目录保留份数<input name="keep" type="number" min="1" max="90" required value="14"></label><label class="full">网络备份目录(可留空)<input name="networkPath" maxlength="500" placeholder="/var/lib/zhaovision/network-backup"></label></div><p class="description">填写本服务器已挂载的网络盘目录。使用百度接口备份只需勾选上方选项;此路径用于其他已挂载的网络存储,不能填网页地址或分享链接。写入网络目录成功不等于已确认百度云端同步完成。</p><p class="description">默认部署允许写入 /var/lib/zhaovision 下的目录。挂载和目录权限须由主机管理员配置;本页不会保存网盘登录密码。保留份数只清理本机和挂载目录中本系统生成的旧备份,百度网盘保留最近一个自然月;新备份上传并校验成功后才清理本应用的过期副本。</p><p id="backup-local-path" class="description"></p><p id="backup-save-status" role="status"></p><button type="submit">保存备份设置</button></form><div class="section-head"><h3>备份记录</h3><button id="backup-run">立即备份数据库</button></div><p id="backup-result" role="status"></p><div id="backup-history"></div></section>
</div>
</section>
<section id="page-journal" class="page" hidden>
<div class="scope-intro"><span class="eyebrow">岁月有迹 · 日常成章</span><h2>把生活,写成自己的来时路</h2><p>与家人分享近况,也为自己留下一份阅历。</p></div>
<form id="journal-form" class="panel journal-compose"><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="clientId"><label>此刻想记下什么?<textarea name="text" rows="4" maxlength="5000" placeholder="今天的心情、一段往事,或一个值得记住的时刻…"></textarea></label><div class="form-grid"><label>记事日期<input name="occurredOn" type="date" required></label><label>阅历分类<select name="category"><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>谁可以看到<select name="visibility"><option value="private">仅自己可见</option><option value="household">当前家庭/场所成员</option></select></label></div><div class="journal-add-media"><label class="quiet">+ 照片、视频或语音<input id="journal-files" disabled type="file" accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm,audio/mpeg,audio/mp4,audio/aac,audio/wav,audio/ogg,audio/webm,.m4a"></label><button id="journal-record" disabled type="button" class="quiet">● 直接录音</button><button id="journal-stop-record" type="button" hidden>结束录音并上传</button><button id="journal-cancel-upload" type="button" class="quiet" hidden>暂停上传</button></div><p id="journal-cloud-note" class="description"></p><p class="description">每条最多 9 个附件,单个文件最多 256 MB;一次上传一个文件。录音须通过 HTTPS 并允许麦克风,最长 5 分钟。上传完成后默认选中。保存后清空本次输入;再次使用可展开“我的附件库”。</p><div id="journal-upload-list" class="journal-upload-grid"></div><details id="journal-library" class="journal-library"><summary>我的附件库(<span id="journal-library-count">0</span>)</summary><p class="description">已上传的附件可预览、删除或再次用于记事。</p><div id="journal-library-list" class="journal-upload-grid"></div></details><details id="journal-trash" class="journal-trash" hidden><summary>已删除附件(<span id="journal-trash-count">0</span>)</summary><p class="description">删除仅整理附件库,已发布的记事与网盘原文件保留。恢复后可再次使用。</p><div id="journal-trash-list"></div></details><p id="journal-status" role="status" aria-live="polite"></p><div class="device-actions"><button id="journal-save" type="submit">保存记事</button><button id="journal-new" type="button" class="quiet">另写一条</button></div></form>
<form id="journal-filter" class="panel journal-filters"><label>查看方式<select id="journal-mode"><option value="feed">家庭/场所动态</option><option value="mine">我的个人阅历</option><option value="person">某个人的阅历</option></select></label><label id="journal-person-label" hidden>人物<select id="journal-person"></select></label><label>分类<select id="journal-category"><option value="">全部分类</option><option>日常</option><option>成长</option><option>家庭</option><option>旅行</option><option>工作</option><option>其他</option></select></label><label>关键词<input id="journal-search" type="search" maxlength="120" placeholder="在文字中查找"></label><label>开始日期<input id="journal-from" type="date"></label><label>结束日期<input id="journal-to" type="date"></label><button type="submit">查询阅历</button><button id="journal-filter-clear" type="button" class="quiet">清除筛选</button></form><p class="description">个人阅历保留同一条记事的文字、附件和互动;查看他人时只展示其分享给当前家庭/场所的内容。删除记事或移出附件列表不会删除网盘原文件。</p><div class="section-head"><p id="journal-query-status" role="status"></p><div><button id="journal-prev" class="quiet">上一页</button><button id="journal-next" class="quiet">下一页</button></div></div><div id="journal-list" class="journal-feed"></div>
</section>
<section id="page-overview" class="page" hidden><div id="scope-overview"></div></section><section id="page-device" class="page" hidden><div id="device-detail"></div></section><section id="page-heritage" class="page" hidden><div id="heritage-home"></div></section>
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label></div></div><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
<div class="panel playback-controls"><label>镜头 / 通道<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像(待接入)</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载原始片段</a></div></div>
<div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div>
</section>
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
<section id="page-objects" class="page" hidden><div class="section-head"><h2>一物一档 <small>设备 · 归属</small></h2><div class="device-actions"><button class="quiet" data-page="sites" data-admin>管理区域</button><button data-action="new-asset">+ 摄像机</button><button data-action="new-recorder">+ 录像机</button><button id="new-device" data-admin>+ 其他设备</button></div></div><p class="description">同一设备可同时记录类型、区域、所有人、负责人和标签。分组顺序可自由组合,例如“灯 → 卧室”或“负责人 → 类型 → 区域”。人员归属不自动授予访问权限。</p><div class="panel device-view-controls"><div class="device-search-row"><label>设备类型<select id="device-type"><option value="">所有类型</option></select></label><label>搜索设备或人员<input id="device-search" type="search" placeholder="例如:卧室 灯;也可查品牌、型号、人员"></label><button id="device-clear" type="button" class="quiet" hidden>清除设备筛选</button></div><div class="device-actions view-presets" role="group" aria-label="设备分组方式"><button type="button" class="quiet" data-group-preset="space,type">按区域查看</button><button type="button" class="quiet" data-group-preset="type,space">按类型查看</button><button type="button" class="quiet" data-group-preset="manager,type,space">按负责人查看</button></div><details class="group-custom"><summary>自定义分组顺序</summary><p class="description">依次分组(前面的维度优先,区域按你定义的完整层级展示)</p><div id="device-group-order" class="device-group-order"></div><div class="device-actions"><button id="device-group-add" class="quiet">+ 增加分组维度</button><button data-admin id="device-view-save" class="secondary">保存为本归属的默认视图</button></div></details><p id="device-count" class="description"></p></div><div id="asset-list"></div></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>区域与空间 <small>自定层级</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">在当前家庭或场所下自行划分区域,不限定层级数量。例如“一楼 → 卧室”或“厂区 → 车间 → 产线”。名称和区域类别均由你定义,地址信息按需填写。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<section id="page-heritage-records" class="page" hidden><div class="section-head"><h2 id="heritage-records-title">文化与家风</h2><div><button class="quiet" data-page="heritage">返回家庭传承</button><button id="heritage-new">+ 新建档案</button></div></div><div class="panel heritage-controls"><label>查找档案<input id="heritage-search" type="search" placeholder="名称、内容、相关人员"></label><label class="check"><input id="heritage-archived" type="checkbox">包含归档资料</label><button id="heritage-reload" class="quiet">重新载入</button></div><p id="heritage-error" class="form-error" role="status"></p><div id="heritage-list" class="site-grid"></div><aside id="heritage-detail" class="panel" hidden></aside></section>
<section id="page-family" class="page" hidden>
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><button id="family-find" class="quiet" hidden>定位搜索结果</button><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
<div id="family-error" class="form-error" role="status"></div>
<div id="family-workspace" class="family-workspace">
<section id="family-canvas-panel" class="family-canvas-panel" aria-label="全家关系图">
<div class="family-map-heading"><div><span class="eyebrow">枝叶相连 · 世代相承</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
<div id="family-map-controls" class="family-map-controls"><div class="family-zoom-controls"><button id="family-zoom-out" class="quiet" aria-label="缩小家谱">−</button><output id="family-zoom-value" aria-live="polite">100%</output><button id="family-zoom-in" class="quiet" aria-label="放大家谱">+</button><button id="family-fit" class="quiet">查看全谱</button></div><button id="family-locate-self" class="quiet">◎ 定位本人</button><button id="family-fullscreen" class="quiet">全屏家谱</button></div>
<div id="family-tree" class="family-map-viewport" tabindex="0" role="region" aria-label="家谱关系图,可拖动空白处或使用滚动条浏览"></div>
<div id="family-people" class="family-people family-register" hidden></div>
<section id="family-events" hidden aria-label="家族重大记事">
<div class="family-event-intro"><div><h3>把值得记住的事,留给后来的人</h3><p class="description">按事情发生的日期排列,可关联多位亲人,并附上详细记载或报道链接。</p></div><button id="family-new-event">+ 写一则记事</button></div>
<div class="family-event-filters"><label>关联人物<select id="family-event-person"><option value="">全家</option></select></label><label>年份<select id="family-event-year"><option value="">所有年份</option></select></label><label>排列<select id="family-event-order"><option value="desc">从近到远</option><option value="asc">从远到近</option></select></label><label>搜索记事<input id="family-event-search" type="search" placeholder="标题、摘要或正文"></label><label class="check"><input id="family-event-relatives" type="checkbox">包括此人的父母、配偶和子女</label><label id="family-event-state-label">状态<select id="family-event-state"><option value="published">已发布</option><option value="draft">草稿箱</option><option value="archived">已归档</option><option value="all">全部</option></select></label><button id="family-event-clear" class="quiet">清除筛选</button></div>
<p id="family-event-count" class="description" role="status"></p><div id="family-timeline" class="family-timeline"></div>
</section>
<div id="family-map-footer" class="family-map-footer"><div class="family-map-legend"><span><i class="legend-self"></i>本人</span><span><i class="legend-selected"></i>正在查看</span><span><i class="legend-parent"></i>父母 → 子女</span><span><i class="legend-spouse"></i>配偶</span><span><i class="legend-adoptive"></i>收养 / 继亲</span></div><p id="family-map-note" class="description" role="status"></p><span class="description">拖动空白处移动 · 点击人物查看档案 · 关系称谓按参照人物计算</span></div>
</section>
<aside id="family-detail" class="panel family-inspector" aria-label="人物档案"></aside>
</div>
</section>
<section id="page-households" class="page" hidden><div class="section-head"><h2>各有归属 <small>家庭 · 办公 · 工厂</small></h2><button id="new-household">+ 新建家庭或场所</button></div><p class="description">每个家庭、办公场所或工厂独立管理自己的成员、区域和设备。内部区域与分组顺序由各自定制;管理员只管理自己的归属,超级管理员可管理全部。</p><div id="household-list" class="site-grid"></div></section>
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">这里只管理当前家庭或场所的账号。新账号默认没有摄像头或家谱访问权限;可按需授权。亲属称呼与管理权限分别设置,无需为每位家谱人物创建账号。</p><div id="user-list"></div></section>
<footer><span id="brand-footer">赵府 · 智能数字管家</span><span id="version">独立部署 / 数据自有</span></footer>
</main>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
<dialog id="device-dialog"><form id="device-form"><div class="dialog-heading"><h2>设备档案</h2><button type="button" class="quiet" data-close="device-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>设备名称 *<input name="name" required maxlength="120" placeholder="例如:卧室台灯、爸爸的手机"></label><label>设备类型 *<input name="deviceType" list="device-types" required maxlength="120" placeholder="可选择或自定义"></label><datalist id="device-types"><option value="灯"><option value="手机"><option value="冰箱"><option value="空调"><option value="传感器"><option value="电脑"><option value="其他"></datalist><label class="full">所在区域<select name="siteId"></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">手机、灯、冰箱等当前支持设备档案、分类和责任归属。自动控制需另行接入对应协议,登记不代表已连接设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="heritage-dialog"><form id="heritage-form"><div class="dialog-heading"><h2 id="heritage-form-title">传承资料</h2><button type="button" class="quiet" data-close="heritage-dialog">关闭</button></div><input type="hidden" name="id"><input type="hidden" name="revision" value="0"><input type="hidden" name="kind"><div class="form-grid"><label class="full">档案名称 *<input name="name" required maxlength="120"></label><label>相关日期<input name="date" type="date"></label><label>讲述人 / 整理人<input name="contributor" maxlength="80"></label><label class="full">相关内容与来历<textarea name="content" rows="6" maxlength="8000"></textarea></label><label class="full">资料 / 报道链接<input name="sourceUrl" type="url" maxlength="1500" placeholder="https://"></label><label class="full">关联已有设备<select name="deviceReference"><option value="">不关联设备</option></select></label><label data-heritage-asset>所有人 / 共有说明<input name="ownerName" maxlength="80"></label><label data-heritage-asset>保管人<input name="custodianName" maxlength="80"></label><label data-heritage-asset>意向接收人<input name="successorName" maxlength="80"></label><label class="full" data-heritage-asset>交接意愿与说明<textarea name="handoverNote" rows="3" maxlength="2000"></textarea></label><label class="check full"><input type="checkbox" name="archived">归档(保留资料,可恢复)</label></div><p class="description">这里只保存资料和意愿;不会据此转移财产或授予任何账号权限。链接内容保存在原位置,本系统保存链接和文字记录。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存档案</button></div></form></dialog>
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:一楼、主卧、三号车间"></label><label>上级区域<select name="parentId"></select></label><label>区域类别<input name="category" maxlength="40" placeholder="自定,例如:楼层、卧室、车间、产线"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">办公场所</option><option value="factory">工厂</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><fieldset class="full geo-picker" data-geography="" data-geo-title="空间所在地"></fieldset><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>ONVIF 服务端口<input name="onvifPort" type="number" value="80" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。端口请按设备填写:通常为 80,部分 TP-Link 设备为 2020。服务器需要能够访问该设备所在网络。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label><label>所有人<input name="ownerName" maxlength="80" placeholder="这是谁的设备,可填姓名或单位"></label><label>负责人<input name="managerName" maxlength="80" placeholder="谁负责管理或维护"></label><label class="full">自定义标签<input name="tags" maxlength="500" placeholder="用顿号或逗号分隔,例如:照明、常用、公共"></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
<dialog id="leadership-dialog"><form id="leadership-form"><div class="dialog-heading"><h2>负责人安排与交接</h2><button type="button" class="quiet" data-close="leadership-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><p class="description">先建立至少两位家庭 / 场所管理员,再指定主要和备用负责人。家庭的主要负责人即一家之主;备用负责人已具有本归属管理权限,意外发生也能继续管理。正式交接由当前主要负责人或超级管理员办理。</p><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label class="full">安排 / 交接说明 *<textarea name="reason" required maxlength="300" rows="3" placeholder="说明负责人安排或本次交接原因"></textarea></label></div><p class="description">交接后原负责人仍保留本归属的管理员权限。系统不会依据未登录或家谱去世日期自动转移权限;交接记录可追溯。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">确认保存负责人安排</button></div></form></dialog>
<dialog id="household-dialog"><form id="household-form"><div class="dialog-heading"><h2>家庭 / 场所档案</h2><button type="button" class="quiet" data-close="household-dialog">关闭</button></div><input name="id" type="hidden"><input name="profileRevision" type="hidden" value="0"><div class="form-grid"><label class="full">归属名称 *<input name="name" maxlength="80" required placeholder="例如:赵府、济南办公室、莱阳工厂"></label><label class="full">归属类型<select name="kind"><option value="home">家庭</option><option value="office">办公场所</option><option value="factory">工厂</option></select></label><fieldset class="full geo-picker" data-geography="" data-geo-title="所在地"></fieldset><div class="full cover-editor"><img id="household-cover-preview" alt="场所封面预览" hidden><label>卡片封面照片<input id="household-cover-file" type="file" accept="image/png,image/jpeg,image/webp"></label><button type="button" id="household-cover-cancel" class="quiet">取消本次图片选择</button><button type="button" id="household-cover-reset" class="quiet">使用默认封面</button><p id="household-cover-status" class="description" role="status" aria-live="polite">照片仅向有权进入该家庭或场所的账号开放。</p></div><label class="full">备注<textarea name="note" maxlength="300" rows="3"></textarea></label></div><p class="description">创建后进入该归属,建立管理员,再自行划分区域和登记设备。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存家庭</button></div></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-heading"><h2>账号档案</h2><button type="button" class="quiet" data-close="user-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>用户名 *<input name="username" required minlength="3" maxlength="32" autocomplete="off"></label><label>显示名称 *<input name="name" required maxlength="80"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="编辑时留空保留"></label><label>账号角色<select name="role"><option value="member">家庭 / 场所成员</option><option value="family_admin">家庭 / 场所管理员</option><option value="admin">超级管理员</option></select></label><label>家谱权限<select name="familyAccess"><option value="none">不可访问</option><option value="read">查看家谱</option><option value="edit">录入和维护家谱</option></select></label><label>文化与财产档案权限<select name="heritageAccess"><option value="none">不可访问</option><option value="read">查看资料</option><option value="edit">整理与维护</option></select></label><label>关联家谱人物<select name="personId"></select></label><label>这个账号是<select name="relatedToId"></select></label><label>的什么关系<input name="relationship" list="relationship-choices" maxlength="40" placeholder="可选择或自行填写"></label><fieldset class="full"><legend>允许查看摄像头的空间</legend><div id="user-sites"></div><p class="description">普通成员只访问本归属内已授权的区域。默认仅授权所选区域;勾选“包含下级区域”后同时包含其所有后代。管理员可管理本归属全部区域。</p></fieldset><label class="check full"><input name="includeSubspaces" type="checkbox">授权包含所选区域的所有下级区域(含以后新增)</label><label class="check"><input name="ptzControl" type="checkbox">允许控制已授权空间的摄像头云台</label><label class="check"><input name="disabled" type="checkbox">停用账号</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存账号</button></div></form></dialog>
<datalist id="relationship-choices"></datalist>
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full" id="person-legacy-place">原籍贯/出生地记录<input name="birthplace" maxlength="160"><small>历史填写内容保留,可在下方分别补充。</small></label><fieldset class="full person-location"><legend>籍贯(祖籍)</legend><div class="form-grid"><fieldset class="full geo-picker" data-geography="native" data-geo-title="籍贯(祖籍)"></fieldset><label>乡镇/街道<input name="nativeTown" maxlength="80" placeholder="" aria-label="籍贯(祖籍) · 乡镇/街道"></label><label>村/社区<input name="nativeCommunity" maxlength="80" placeholder="" aria-label="籍贯(祖籍) · 村/社区"></label><label class="full">详细地址/历史地名<input name="nativeAddress" maxlength="200" placeholder="楼栋、门牌或旧称,可按已知信息填写" aria-label="籍贯(祖籍) · 详细地址/历史地名"></label></div></fieldset><fieldset class="full person-location"><legend>出生地</legend><div class="form-grid"><fieldset class="full geo-picker" data-geography="birth" data-geo-title="出生地"></fieldset><label>乡镇/街道<input name="birthTown" maxlength="80" placeholder="" aria-label="出生地 · 乡镇/街道"></label><label>村/社区<input name="birthCommunity" maxlength="80" placeholder="" aria-label="出生地 · 村/社区"></label><label class="full">详细地址/历史地名<input name="birthAddress" maxlength="200" placeholder="楼栋、门牌或旧称,可按已知信息填写" aria-label="出生地 · 详细地址/历史地名"></label></div></fieldset><p class="description full">籍贯与出生地可不同;选择中国、世界或自定义地域,不详的层级可留空。</p><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">家事 · 家声</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
<dialog id="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog>
<dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog>
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调与预置位:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
<div id="toast" role="status" hidden></div>
<dialog id="snapshot-dialog" aria-labelledby="snapshot-title"><div class="dialog-heading"><h2 id="snapshot-title">画面截图</h2><button data-close="snapshot-dialog" class="quiet">关闭</button></div><img id="snapshot-image" alt="当前视频画面截图"><p id="snapshot-info" class="description"></p><a id="snapshot-save" class="button" download>下载 PNG 图片</a></dialog>
<dialog id="journal-preview" class="journal-preview" aria-labelledby="journal-preview-title"><header><h2 id="journal-preview-title">附件预览</h2><button id="journal-preview-close" type="button" class="quiet">关闭预览</button></header><div id="journal-preview-content"></div><footer><span>按 Esc 关闭预览</span><a id="journal-preview-download">下载原文件</a></footer></dialog>
</body>
</html>
+3 -3
View File
@@ -15,12 +15,12 @@ function deviceCameras(cameras,context=deviceContext){
if(context.source==='recorder')return cameras.filter(c=>c.recorderId===context.id||c.archiveRecorderId===context.id);
return [];
}
function enterDevice(source,id,tab='device'){
function enterDevice(source,id,tab='auto'){
const device=ZhaoDevices.catalog(state).find(d=>d.source===source&&d.id===id);
if(!device){toast('该设备不存在或未获授权');return;}
stopPlayers();resetPlayback();if(typeof closePtz==='function')closePtz();
const returnSite=$('#site-filter').value;$('#site-filter').value='';deviceContext={source,id,returnSite};scopeEntered=true;
showPage(tab==='device'&&source==='camera'&&deviceCameras(state.cameras).length?'live':tab);
showPage(tab==='auto'?(source==='camera'&&deviceCameras(state.cameras).length?'live':'device'):tab);
}
function renderSteward(){
const home=currentHome(),device=selectedDevice(),within=loggedIn&&scopeEntered&&!!home;
@@ -53,7 +53,7 @@ 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>${videoDeviceActions(device)}${device.source==='device'?'<p class="description">当前已登记档案;自动控制需按实际设备协议接入。</p>':''}${device.source==='recorder'?'<p class="description">可查看已关联镜头。录像机硬盘的历史检索与回放仍待设备适配。</p>':''}</section>`;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-YWYfmYO_.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-DxbM73EL.css">
</head><body><div id="app"></div></body></html>
+11
View File
@@ -0,0 +1,11 @@
'use strict';
Object.assign(window.ZhaoVue,{
boot:bootApplication,
publish(){this.accept({state,user:currentUser,admin:isAdmin(),platformAdmin:isPlatformAdmin(),loggedIn,page:activePage,session:sessionGeneration,device:selectedDevice(),brandName:branding.name});},
api,notify:toast,address:ZhaoGeography.describe,
async saveLayout(value){const saved=await api('/api/households/layout',value);state.householdLayout=saved;this.publish();return saved;},
async enterHome(id,network=false){try{if(id!==state.familyId&&!await switchHousehold(id))return;if(network){devicePresentation='topology';showPage('objects');}else showPage('overview');}catch(e){if(!e.stale)toast(e.message);}},
enterDevice,
location(id){return siteName(id);},
network(device){return ZhaoDevices.network(device,state);}
});