Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04d56d5aa2 | |||
| cf895110ee |
@@ -13,3 +13,6 @@ mediamtx
|
||||
mediamtx.exe
|
||||
*.tar.gz
|
||||
|
||||
|
||||
/frontend/node_modules/
|
||||
/frontend/.vite/
|
||||
|
||||
@@ -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 在同一主机运行,使用独立服务用户;网页只访问管理程序,设备凭据与媒体管理接口留在服务端。
|
||||
|
||||
@@ -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 · 真实网盘验收与保存反馈
|
||||
|
||||
- 修复家庭档案已保存、后续列表刷新失败时提示不可见的问题;立即提示保存结果,刷新失败单独提示,无需重复提交。
|
||||
|
||||
@@ -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 及物理线路仍需补充。图上的接入方式是档案属性,不会开放端口或自动授予网络权限,也不表示设备已经通过在线探测。录像机历史回放仍需设备协议适配。
|
||||
|
||||
公开源码不包含设备凭据、用户资料、业务数据库或网盘授权信息。
|
||||
@@ -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 的完全重写。
|
||||
|
||||
公开仓库不含用户资料、业务数据库、网盘授权或设备凭据。
|
||||
@@ -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()
|
||||
|
||||
@@ -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 路由状态来自本服务器看到的路由,不等于完整物理布线发现。
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+862
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]||'接入待确认')
|
||||
@@ -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
@@ -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'}})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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"/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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('数据存于本机'));
|
||||
|
||||
@@ -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
@@ -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\(\)/);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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 [
|
||||
|
||||
@@ -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
@@ -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(){
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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);}
|
||||
});
|
||||
Reference in New Issue
Block a user