Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91915b9d90 | |||
| 436596e786 | |||
| 18d6613bb4 | |||
| 04d56d5aa2 |
@@ -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.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 的完全重写。
|
||||
|
||||
公开仓库不含用户资料、业务数据库、网盘授权或设备凭据。
|
||||
@@ -0,0 +1,20 @@
|
||||
# v0.1.36 — 负责人选择与手机监控体验
|
||||
|
||||
- 负责人安排可以选择本家庭已启用的普通成员或管理员。选择普通成员时,需要明确勾选授予本家庭管理权限;主要与备用负责人不能是同一人。
|
||||
- 没有可选成员时显示原因,提供“添加本家庭管理员”和“管理本家庭成员”入口。新增管理员后返回负责人安排,保留未提交内容。
|
||||
- 负责人授权、会话失效与交接记录原子保存。保留家庭范围隔离、操作者权限检查和版本冲突检查,不自动指定真实用户。
|
||||
- 手机监控页优先显示画面,收起说明,压缩顶部信息。自动布局在手机上使用单列,明确选择的多画面布局继续保留。
|
||||
- 全屏支持网页全屏回退,单镜头占满画面。退出全屏或离开监控页后恢复页面滚动,继续复用播放器。
|
||||
- 调整手机端网络状态、管理后台链接和负责人表单,使长地址换行、按钮可触控、弹窗可以正常滚动。
|
||||
|
||||
## 验证
|
||||
|
||||
- 169 项 Python、135 项 JavaScript 回归测试通过,Vite 生产构建通过。
|
||||
- Edge 隔离测试覆盖普通成员选择及显式授权、保存后回填、空家庭提示及新增管理员入口,检查 390 像素手机弹窗。
|
||||
- 部署前检查实际跨站点摄像机:主码流同一播放器连续播放超过六分钟;现场路由重启后子网配置保留,子码流无需刷新自动恢复。
|
||||
|
||||
## 网络配置说明
|
||||
|
||||
GL.iNet 自带 Tailscale 管理程序会根据路由器的持久配置重新生成启动参数。仅临时设置子网路由可能被服务重启覆盖;应在路由器的 Tailscale 设置中启用所需 LAN 访问,再确认控制台已批准对应子网。参考 [GL.iNet 官方说明](https://docs.gl-inet.com/router/en/4/interface_guide/tailscale/)。本次已在授权设备上修复该配置,应用不会自动更改其他路由器。
|
||||
|
||||
公开源码不包含用户数据库、家庭资料或设备凭据。
|
||||
@@ -0,0 +1,20 @@
|
||||
# v0.1.37 — Vue 与 Bootstrap 界面升级
|
||||
|
||||
## 外观与体验
|
||||
|
||||
- 基于现有 Vue 3、Bootstrap 5 架构升级:深色导航、墨绿主色、浅色内容区,统一卡片、按钮、表单、弹窗与信息层次。
|
||||
- 新增 Vue 场所概览组件,显示已授权场所的设备总量、区域数量与场所类型环形图。统计直接取自服务端已授权记录。
|
||||
- 家庭照片卡片增加清晰的封面文字遮罩与统计分区,保留个人排序、行列、分页和城市分组。
|
||||
- 网络拓扑新增可缩放、可点击的 Vue SVG 连接图;支持鼠标和键盘进入设备档案。只绘制已登记且通过现有层级校验的连接,不推测未确认的布线。
|
||||
- 手机端使用单列卡片、可收起的布局设置和触控表单;弹窗保留可滚动内容与可见保存按钮。
|
||||
- 保留个人配色、顶部或侧边导航、内容疏密及标题字体偏好;修正深色主题的按钮对比度。
|
||||
- 全屏监控使用独立的深色工具栏与名称底色,避免浅色后台样式影响名称可读性。
|
||||
- 图表在浏览器中绘制,不增加外部图表服务或新的后端数据采集。继续复用现有监控播放器。
|
||||
|
||||
## 验证
|
||||
|
||||
- Vite 生产构建通过;169 项 Python、138 项 JavaScript 回归测试通过。
|
||||
- Edge Computer Use 在隔离数据下验证家庭分页、布局保存、档案弹窗、网络图缩放、键盘跳转及手机菜单。
|
||||
- 390 与 320 像素视口检查无页面横向溢出,手机表单输入为 16px;检查浅色和深色主题。
|
||||
|
||||
本版未迁移业务数据库。公开源码不含用户照片、数据库或设备凭据。
|
||||
@@ -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__])
|
||||
@@ -922,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':
|
||||
@@ -1054,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',
|
||||
@@ -1069,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.37",
|
||||
"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,12 @@
|
||||
<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'
|
||||
import {snapshot} from './composables/bridge.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 :data-page="snapshot.page"><AppHeader/><div v-if="failed" class="alert alert-danger" role="alert">页面组件加载失败,请检查网络后重新打开页面。</div><Workspace/></main><RuntimeOverlays/></template>
|
||||
Vendored
+234
@@ -0,0 +1,234 @@
|
||||
/* Bootstrap presentation layer. Keep player geometry and account preferences intact. */
|
||||
:root {
|
||||
--ui-bg: #f3f6fa;
|
||||
--ui-surface: #fff;
|
||||
--ui-line: #e3e9ef;
|
||||
--ui-muted: #5e6f7e;
|
||||
--ui-sidebar: #172e35;
|
||||
--ui-accent: #217964;
|
||||
--ink: #253b46;
|
||||
--muted: #5e6f7e;
|
||||
--paper: var(--ui-surface);
|
||||
--line: var(--ui-line);
|
||||
--green: var(--ui-accent);
|
||||
--bs-primary: #217964;
|
||||
--bs-primary-rgb: 33, 121, 100;
|
||||
--bs-body-color: var(--ink);
|
||||
--bs-body-bg: var(--ui-bg);
|
||||
--bs-border-color: var(--ui-line);
|
||||
--ui-shadow: 0 3px 16px #223d5010;
|
||||
--ui-soft: #f5f8fa;
|
||||
}
|
||||
html[data-theme="paper"] { --ui-bg: #f5f6f4; --ui-soft: #f5f7f3; }
|
||||
html[data-theme="clean"] { --ui-bg: #f1f5f9; --ui-soft: #f4f7fb; }
|
||||
html[data-theme="ink"] {
|
||||
--ui-bg: #111e26; --ui-surface: #1a2b35; --ui-line: #30444e;
|
||||
--ui-soft: #21353f; --ink: #e0ecef; --ui-muted: #a8bdc5;
|
||||
--muted: #a8bdc5; --ui-shadow: 0 3px 16px #0002;
|
||||
--paper: #1a2b35; --line: #30444e;
|
||||
}
|
||||
html, body { min-width: 0; }
|
||||
body { -webkit-font-smoothing: antialiased; }
|
||||
#app > main { max-width: 2000px; padding: 30px 38px 24px !important; }
|
||||
main > header { margin-bottom: 26px !important; padding-bottom: 24px !important; }
|
||||
#page-title { font-size: clamp(24px, 2.3vw, 32px) !important; letter-spacing: -.7px !important; }
|
||||
html[data-font="serif"] #page-title { font-weight: 600 !important; }
|
||||
#brand-motto { color: var(--ui-accent) !important; letter-spacing: 2.4px; }
|
||||
#page-note { font-size: 13px !important; }
|
||||
.header-status { gap: 9px; }
|
||||
#clock { font-variant-numeric: tabular-nums; font-size: 11px; }
|
||||
#service-status { padding: 6px 10px; border-radius: 20px !important; }
|
||||
.sidebar { background: linear-gradient(165deg, #1d3940, #152b32 70%) !important; }
|
||||
.brand { padding-bottom: 26px !important; }
|
||||
.brand-icon { width: 46px !important; height: 46px !important; box-shadow: 0 3px 12px #071d2860; }
|
||||
.sidebar nav button { border-radius: 10px !important; min-height: 46px; transition: background .15s, color .15s; }
|
||||
.sidebar nav button.active { background: #e1f2eb !important; color: #155b48 !important; box-shadow: 0 2px 7px #0002 !important; }
|
||||
.sidebar nav button.active .app-icon { opacity: 1; }
|
||||
.sidebar #current-user { padding: 14px 9px 2px; }
|
||||
.section-head { align-items: center; margin-bottom: 18px; }
|
||||
.section-head h2 { font-size: 21px !important; }
|
||||
.count-pill { background: #e7f0ec; color: #286752; border: 0; border-radius: 7px; padding: 3px 9px; font-size: 12px; }
|
||||
.btn { border-radius: 9px !important; min-height: 38px; padding: 8px 14px; transition: background .15s, border-color .15s, box-shadow .15s; }
|
||||
.btn-primary { --bs-btn-bg: var(--ui-accent); --bs-btn-border-color: var(--ui-accent); box-shadow: 0 3px 8px #21796416; }
|
||||
.btn-primary:hover { box-shadow: 0 4px 12px #21796428; }
|
||||
.btn-outline-secondary { --bs-btn-color: var(--ink); --bs-btn-border-color: var(--ui-line); background: var(--ui-surface); }
|
||||
.btn-link { color: var(--ui-accent); }
|
||||
html:is([data-theme="clean"],[data-theme="ink"]) :is(main,dialog) .btn-primary:not(.steward-tile):not(.device-title):not(.platform-place-node) { background: var(--ui-accent) !important; color: #fff !important; border-color: var(--ui-accent) !important; }
|
||||
html:is([data-theme="clean"],[data-theme="ink"]) :is(main,dialog) .btn-outline-secondary { background: var(--ui-surface) !important; color: var(--ink) !important; }
|
||||
html:is([data-theme="clean"],[data-theme="ink"]) :is(main,dialog) .btn-outline-secondary:is(.active,[aria-pressed="true"]) { background: var(--ui-soft) !important; border-color: #629987 !important; }
|
||||
html:is([data-theme="clean"],[data-theme="ink"]) .btn-link { background: transparent !important; color: var(--ink) !important; }
|
||||
html[data-theme="ink"] #brand-motto { color: #a7d7c4 !important; }
|
||||
.btn:focus-visible, button:focus-visible, a:focus-visible { outline: 3px solid #529cce; outline-offset: 3px; }
|
||||
.form-control, .form-select { border-radius: 9px !important; border-color: var(--ui-line) !important; min-height: 40px; background-color: var(--ui-surface); color: var(--ink); }
|
||||
.form-control:focus, .form-select:focus { border-color: #51a18b !important; box-shadow: 0 0 0 3px #21856819 !important; }
|
||||
.card, .panel { border: 1px solid var(--ui-line) !important; border-radius: 14px !important; background: var(--ui-surface); box-shadow: var(--ui-shadow) !important; }
|
||||
.card { overflow: hidden; }
|
||||
.card-body { padding: 24px; }
|
||||
dialog { border-radius: 16px !important; box-shadow: 0 24px 100px #10293040 !important; }
|
||||
dialog::backdrop { background: #132a3670; backdrop-filter: blur(4px); }
|
||||
dialog h2 { font-size: 22px !important; }
|
||||
.household-context, .device-context { border: 1px solid var(--ui-line) !important; background: var(--ui-surface) !important; border-radius: 12px; box-shadow: var(--ui-shadow); }
|
||||
.home-insights { display: grid; grid-template-columns: 1.4fr .7fr 1fr; gap: 18px; margin: 22px 0 26px; }
|
||||
.home-insights > article { border-radius: 15px; padding: 24px; min-width: 0; }
|
||||
.insight-welcome { position: relative; overflow: hidden; color: #fff; background: linear-gradient(115deg,#20534d,#297562); box-shadow: 0 6px 22px #21594c1c; }
|
||||
.insight-welcome::after { content: ''; position: absolute; right: -28px; bottom: -64px; width: 200px; height: 200px; border: 32px solid #fff07; border-radius: 50%; pointer-events: none; }
|
||||
.insight-kicker { color: #cae4d9; font-size: 10px; letter-spacing: 2px; }
|
||||
.insight-welcome h2 { color: #fff !important; font-size: 23px !important; margin: 14px 0 8px !important; }
|
||||
.insight-welcome p { color: #d0e7df; font-size: 12px; margin-bottom: 22px; }
|
||||
.insight-scope { display: flex; gap: 7px; align-items: center; color: #d7ebe4; font-size: 10px; }
|
||||
.insight-scope .app-icon { width: 15px; height: 15px; flex-basis: 15px; }
|
||||
.insight-number, .insight-distribution { background: var(--ui-surface); border: 1px solid var(--ui-line); box-shadow: var(--ui-shadow); }
|
||||
.insight-number { position: relative; display: flex; flex-direction: column; justify-content: center; }
|
||||
.insight-icon { position: absolute; right: 20px; top: 22px; width: 36px; height: 36px; display: grid; place-items: center; background: #e8f1fb; color: #527ec0; border-radius: 11px; }
|
||||
.insight-label { color: var(--ui-muted); font-size: 12px; padding-right: 35px; }
|
||||
.insight-number > strong { font-size: 40px; line-height: 1.3; font-weight: 650; margin: 10px 0; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
.insight-number strong small { font-size: 12px; font-weight: 400; margin-left: 10px; color: var(--ui-muted); }
|
||||
.insight-foot { color: var(--ui-muted); font-size: 11px; }
|
||||
.insight-distribution { display: flex; align-items: center; justify-content: center; gap: 20px; }
|
||||
.insight-donut { width: 126px; flex: 0 0 126px; position: relative; }
|
||||
.insight-donut svg { width: 100%; display: block; }
|
||||
.donut-track { stroke: var(--ui-line); }
|
||||
.insight-donut > div { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.insight-donut strong { font-size: 28px; line-height: 1.3; }
|
||||
.insight-donut small { font-size: 10px; color: var(--ui-muted); }
|
||||
.insight-legend { flex: 1; min-width: 60px; max-width: 145px; }
|
||||
.insight-legend h3 { font-size: 12px !important; margin-bottom: 13px; white-space: nowrap; }
|
||||
.insight-legend > span { display: flex; align-items: center; gap: 7px; font-size: 11px; margin-top: 7px; color: var(--ui-muted); }
|
||||
.insight-legend i { width: 7px; height: 7px; border-radius: 2px; flex-shrink: 0; }
|
||||
.insight-legend b { margin-left: auto; color: var(--ink); }
|
||||
.home-toolbar { padding: 13px 16px; border: 1px solid var(--ui-line); border-radius: 12px; background: var(--ui-surface); gap: 14px; }
|
||||
.home-layout-toggle { display: none; }
|
||||
#home-layout-status { margin: 10px 3px 18px; font-size: 11px; }
|
||||
.home-card-grid { gap: 24px; }
|
||||
.home-card { transition: box-shadow .18s, border-color .18s; }
|
||||
.home-card:hover { box-shadow: 0 8px 26px #223d5019 !important; border-color: #b4cac0 !important; }
|
||||
.home-cover { height: 220px; background: linear-gradient(120deg,#35595b,#193a40); }
|
||||
.home-cover .cover-shade { background: linear-gradient(180deg,#142b3830 0%,#10282b05 30%,#0a232bcc 100%); }
|
||||
.home-cover-content { padding: 22px 25px; }
|
||||
.home-cover-content h3 { font-size: 24px !important; font-weight: 600 !important; letter-spacing: .5px !important; }
|
||||
.home-cover-content .badge { border: 1px solid #ffffff38; background: #f6fffaed !important; color: #27594a !important; border-radius: 6px !important; font-size: 11px; }
|
||||
.home-location { font-size: 11px; color: #ebf3f0; }
|
||||
.cover-edit { background: #142a3560 !important; border: 1px solid #ffffff80 !important; color: #fff !important; backdrop-filter: blur(4px); }
|
||||
.home-summary { color: var(--ui-muted); font-size: 12px; }
|
||||
.home-numbers { background: var(--ui-soft); border: 0; border-radius: 10px; padding: 14px 0; margin: 16px 0 20px; }
|
||||
.home-numbers span { border-right: 1px solid var(--ui-line); color: var(--ui-muted); font-size: 11px; }
|
||||
.home-numbers span:last-child { border: 0; }
|
||||
.home-numbers b { font-size: 24px; color: var(--ink); }
|
||||
.home-card-actions { border-top: 1px solid var(--ui-line); padding-top: 18px; margin-top: 18px; }
|
||||
.compact-details dt { color: var(--ui-muted); }
|
||||
.compact-details dd { color: var(--ink); }
|
||||
.home-pagination { margin-top: 24px; font-size: 11px; }
|
||||
.metrics { gap: 16px; }
|
||||
.metrics > div { border: 1px solid var(--ui-line); border-radius: 12px; background: var(--ui-surface); box-shadow: var(--ui-shadow); border-top: 3px solid #438979; }
|
||||
.metrics > div:nth-child(2) { border-top-color: #658cd1; }
|
||||
.metrics > div:nth-child(3) { border-top-color: #b69c70; }
|
||||
.metrics > div:nth-child(4) { border-top-color: #8391b5; }
|
||||
.metrics strong { font-variant-numeric: tabular-nums; color: var(--ink); }
|
||||
.device-card-heading .device-symbol { background: #e9f3ef; color: #287e65; border-radius: 12px; width: 44px; height: 44px; }
|
||||
.device-card .card-footer { border-top: 1px solid var(--ui-line); background: var(--ui-soft); padding: 14px 22px; }
|
||||
.device-card-grid { gap: 20px; }
|
||||
.device-view-controls { padding: 20px 24px; }
|
||||
.device-search-row { border-bottom: 1px solid var(--ui-line); padding-bottom: 18px; margin-bottom: 15px; }
|
||||
.group-title { margin: 24px 0 14px; }
|
||||
.device-location { color: var(--ui-muted); }
|
||||
.network-map { padding: 24px; }
|
||||
.network-platform { background: var(--ui-soft); border: 1px solid var(--ui-line); border-radius: 12px; padding: 20px; }
|
||||
.network-platform > .app-icon { width: 30px; height: 30px; flex-basis: 30px; color: var(--ui-accent); }
|
||||
.network-diagram { margin: 20px 0 28px; border: 1px solid var(--ui-line); border-radius: 12px; overflow: hidden; }
|
||||
.diagram-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 18px; background: var(--ui-surface); }
|
||||
.diagram-toolbar > div { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.diagram-toolbar strong { font-size: 13px; }
|
||||
.diagram-dot { width: 7px; height: 7px; border-radius: 50%; background: #4d8dbe; }
|
||||
.diagram-toolbar label { display: flex; align-items: center; gap: 8px; font-size: 11px; margin: 0; }
|
||||
.diagram-toolbar select { width: 86px; }
|
||||
.diagram-scroll { max-height: 480px; overflow: auto; background-color: var(--ui-soft); background-image: radial-gradient(var(--ui-line) 1px, transparent 1px); background-size: 18px 18px; border-block: 1px solid var(--ui-line); }
|
||||
.network-svg { display: block; }
|
||||
.network-edge { stroke: #82a79c; stroke-width: 2; }
|
||||
.diagram-node { cursor: pointer; outline: none; }
|
||||
.diagram-node-bg { fill: var(--ui-surface); stroke: var(--ui-line); stroke-width: 1.5; }
|
||||
.diagram-node:hover .diagram-node-bg, .diagram-node:focus .diagram-node-bg { stroke: #328d78; stroke-width: 2.5; }
|
||||
.diagram-node-type { fill: var(--ui-muted); font-size: 10px; }
|
||||
.diagram-node-title { fill: var(--ink); font-size: 14px; font-weight: 600; }
|
||||
.diagram-node-ip { fill: var(--ui-muted); font-size: 11px; font-family: ui-monospace, Consolas, monospace; }
|
||||
.diagram-note { margin: 0; padding: 11px 18px; font-size: 11px; color: var(--ui-muted); }
|
||||
.network-details-heading { margin: 20px 0 14px; }
|
||||
.platform-place-node { position: relative; text-align: left; padding: 24px; }
|
||||
.platform-place-node::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; background: #438979; }
|
||||
.platform-place-node:hover { border-color: #8db4a7 !important; background: var(--ui-soft); }
|
||||
.network-open { color: var(--ui-accent); font-weight: 600; }
|
||||
.scope-intro { border-bottom: 1px solid var(--ui-line); padding-bottom: 20px; }
|
||||
.steward-tile { border-radius: 14px !important; border: 1px solid var(--ui-line) !important; border-top: 3px solid #438979 !important; box-shadow: var(--ui-shadow) !important; padding: 28px !important; background: var(--ui-surface) !important; }
|
||||
.steward-tile:nth-child(2n) { border-top-color: #6c8fbc !important; }
|
||||
.steward-tile:nth-child(3n) { border-top-color: #b29a72 !important; }
|
||||
.steward-tile strong { font-size: 19px !important; }
|
||||
.steward-tile:hover { border-color: #9dbab0 !important; box-shadow: 0 8px 22px #223d5016 !important; }
|
||||
.journal-card { border-radius: 14px; box-shadow: var(--ui-shadow); }
|
||||
#page-live:is(:fullscreen,.wall-expanded) .camera-meta { background: #172e35 !important; color: #e8f3ef !important; }
|
||||
#page-live:is(:fullscreen,.wall-expanded) .camera-meta strong { color: #e8f3ef !important; }
|
||||
#page-live:is(:fullscreen,.wall-expanded) .wall-toolbar { background: #172e35 !important; border-color: #45616a !important; }
|
||||
#page-live:is(:fullscreen,.wall-expanded) .form-select { background-color: #213b44 !important; color: #e8f3ef !important; border-color: #64818b !important; }
|
||||
main > footer { color: var(--ui-muted); font-size: 10px; }
|
||||
html[data-density="compact"] .home-insights > article { padding: 18px; }
|
||||
html[data-density="compact"] .home-cover { height: 165px; }
|
||||
html[data-density="compact"] .card-body { padding: 18px; }
|
||||
@media (min-width: 1100px) { html[data-layout="top"] #app > main { padding-top: 28px !important; } }
|
||||
@media (max-width: 1250px) {
|
||||
.home-insights { grid-template-columns: 1.2fr .75fr 1fr; gap: 12px; }
|
||||
.home-insights > article { padding: 19px; }
|
||||
.insight-distribution { gap: 10px; }
|
||||
.insight-donut { width: 102px; flex-basis: 102px; }
|
||||
.insight-welcome h2 { font-size: 20px !important; }
|
||||
}
|
||||
@media (max-width: 1000px) and (min-width: 761px) {
|
||||
#app > main { padding: 26px 24px !important; }
|
||||
.home-insights { grid-template-columns: 1fr 1fr; }
|
||||
.insight-welcome { grid-column: 1/-1; }
|
||||
.insight-welcome p { margin-bottom: 12px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
#app > main { padding: 20px 16px 20px !important; }
|
||||
.sidebar { background: #17353b !important; }
|
||||
main > header { margin-bottom: 20px !important; padding-bottom: 18px !important; }
|
||||
#page-title { font-size: 25px !important; }
|
||||
#page-note { font-size: 12px !important; }
|
||||
.home-insights { grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0 20px; }
|
||||
.home-insights > article { padding: 18px; }
|
||||
.insight-welcome { grid-column: 1/-1; }
|
||||
.insight-welcome h2 { margin: 7px 0 0 !important; font-size: 20px !important; }
|
||||
.insight-welcome p, .insight-scope { display: none; }
|
||||
.insight-number > strong { font-size: 34px; }
|
||||
.insight-icon { width: 29px; height: 29px; top: 14px; right: 13px; }
|
||||
.insight-distribution { flex-direction: column; gap: 5px; }
|
||||
.insight-donut { width: 94px; flex-basis: auto; }
|
||||
.insight-legend { width: 100%; display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
|
||||
.insight-legend h3 { display: none; }
|
||||
.insight-legend > span { margin: 0; font-size: 10px; gap: 4px; }
|
||||
.insight-number .insight-label { padding-right: 24px; font-size: 11px; }
|
||||
.insight-donut strong { font-size: 25px; }
|
||||
.insight-donut small { font-size: 9px; }
|
||||
.home-toolbar { padding: 12px; }
|
||||
.home-layout-toggle { display: inline-flex; margin-left: auto; }
|
||||
.home-toolbar .home-layout-controls { display: none; }
|
||||
.home-toolbar.home-options-open .home-layout-controls { display: flex; flex-basis: 100%; border-top: 1px solid var(--ui-line); padding-top: 12px; }
|
||||
.home-cover { height: 200px; }
|
||||
.home-cover-content { padding: 20px; }
|
||||
.home-cover-content h3 { font-size: 23px !important; }
|
||||
.card-body { padding: 20px; }
|
||||
.home-numbers { padding: 12px 0; }
|
||||
.home-card-grid { gap: 18px; }
|
||||
.home-card-actions { gap: 8px; flex-wrap: wrap; }
|
||||
.btn { min-height: 44px; }
|
||||
.form-control, .form-select { min-height: 44px; font-size: 16px; }
|
||||
.device-view-controls { padding: 16px; }
|
||||
.network-map { padding: 14px; }
|
||||
.network-platform { padding: 16px; }
|
||||
.diagram-toolbar { padding: 12px; align-items: flex-start; }
|
||||
.diagram-toolbar > div { gap: 6px; }
|
||||
.diagram-toolbar > div .small { flex-basis: 100%; font-size: 10px; }
|
||||
.diagram-toolbar label { flex-direction: column; align-items: flex-start; }
|
||||
.diagram-toolbar select { width: 82px; }
|
||||
.diagram-scroll { max-height: 360px; }
|
||||
.steward-tile { padding: 22px !important; }
|
||||
main[data-page="live"] > header { margin-bottom: 10px !important; padding-bottom: 12px !important; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } }
|
||||
@@ -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.37';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,36 @@
|
||||
<script setup>
|
||||
defineProps({ name: { type: String, default: "grid" } });
|
||||
const paths = {
|
||||
shield: "M12 2 3 6v6c0 5 9 10 9 10s9-5 9-10V6Z M8 12l3 3 5-6",
|
||||
location: "M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0 M15 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0",
|
||||
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,411 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { snapshot, bridge, accessLabel } from "../composables/bridge.js";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
import HomeInsights from "./HomeInsights.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"),
|
||||
layoutOptions = ref(false),
|
||||
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";
|
||||
layoutOptions.value = false;
|
||||
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>
|
||||
<HomeInsights />
|
||||
<div class="home-toolbar" :class="{ 'home-options-open': layoutOptions }">
|
||||
<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>
|
||||
<button v-show="mode === 'cards'" class="btn btn-outline-secondary home-layout-toggle" :aria-expanded="layoutOptions" aria-controls="home-layout-options" @click="layoutOptions = !layoutOptions">{{ layoutOptions ? '收起设置' : '布局设置' }}</button>
|
||||
<div id="home-layout-options" 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,33 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { snapshot } from '../composables/bridge.js';
|
||||
import { summarizeHomes } from '../composables/insights.mjs';
|
||||
import AppIcon from './AppIcon.vue';
|
||||
const summary = computed(() => summarizeHomes(snapshot.value.state.households));
|
||||
</script>
|
||||
<template>
|
||||
<section class="home-insights" aria-label="已授权场所概览">
|
||||
<article class="insight-welcome">
|
||||
<span class="insight-kicker">一处相连 · 各有归属</span>
|
||||
<h2>家与日常,尽在掌握</h2>
|
||||
<p>从一个空间出发,照看设备,也珍藏生活。</p>
|
||||
<div class="insight-scope"><AppIcon name="shield" /> 仅汇总你有权访问的家庭与场所</div>
|
||||
</article>
|
||||
<article class="insight-number">
|
||||
<span class="insight-icon"><AppIcon name="devices" /></span>
|
||||
<span class="insight-label">已登记设备</span>
|
||||
<strong>{{ summary.devices }}<small>台</small></strong>
|
||||
<span class="insight-foot">分布在 {{ summary.areas }} 处区域</span>
|
||||
</article>
|
||||
<article class="insight-distribution">
|
||||
<div class="insight-donut">
|
||||
<svg viewBox="0 0 120 120" role="img" :aria-label="'场所分布:' + (summary.kinds.map(k => k.label + k.count + '个').join(',') || '暂无场所')">
|
||||
<circle class="donut-track" cx="60" cy="60" r="48" fill="none" stroke-width="10" />
|
||||
<circle v-for="kind in summary.kinds" :key="kind.key" cx="60" cy="60" r="48" fill="none" stroke-width="10" pathLength="100" :stroke="kind.color" :stroke-dasharray="`${kind.share} ${100 - kind.share}`" :stroke-dashoffset="-kind.offset" transform="rotate(-90 60 60)" />
|
||||
</svg>
|
||||
<div aria-hidden="true"><strong>{{ summary.total }}</strong><small>家庭与场所</small></div>
|
||||
</div>
|
||||
<div class="insight-legend"><h3>空间分布</h3><span v-for="kind in summary.kinds" :key="kind.key"><i :style="{ background: kind.color }"></i>{{ kind.label }}<b>{{ kind.count }}</b></span><span v-if="!summary.total">等待添加空间</span></div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { deviceView, snapshot, bridge, accessLabel } from '../composables/bridge.js';
|
||||
import { layoutNetwork } from '../composables/insights.mjs';
|
||||
const zoom = ref(1);
|
||||
const graph = computed(() => layoutNetwork(deviceView.value.rows));
|
||||
watch(() => snapshot.value.session + ':' + snapshot.value.state.familyId, () => { zoom.value = 1; });
|
||||
const open = node => bridge().enterDevice(node.device.source, node.device.id, 'device');
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="graph.nodes.length" class="network-diagram">
|
||||
<div class="diagram-toolbar">
|
||||
<div><span class="diagram-dot"></span><strong>设备连接图</strong><span class="small text-secondary">{{ graph.nodes.length }} 台设备 · {{ graph.edges.length }} 条已登记连接</span></div>
|
||||
<label>缩放<select v-model.number="zoom" class="form-select form-select-sm" aria-label="网络图缩放"><option :value="0.75">75%</option><option :value="1">100%</option><option :value="1.25">125%</option></select></label>
|
||||
</div>
|
||||
<div class="diagram-scroll" tabindex="0" role="region" aria-label="设备连接图,可滚动查看;点击设备打开档案">
|
||||
<svg :width="graph.width * zoom" :height="graph.height * zoom" :viewBox="`0 0 ${graph.width} ${graph.height}`" class="network-svg" role="group" aria-label="已登记的网络连接">
|
||||
<path v-for="edge in graph.edges" :key="edge.id" :d="edge.path" class="network-edge" fill="none" />
|
||||
<g v-for="node in graph.nodes" :key="node.device.id" :transform="`translate(${node.x},${node.y})`" class="diagram-node" role="button" tabindex="0" :aria-label="node.device.name + ',打开设备档案'" @click="open(node)" @keydown.enter.prevent="open(node)" @keydown.space.prevent="open(node)">
|
||||
<title>{{ node.device.name }} · {{ node.device.deviceType }} · {{ (node.device.networkHosts || []).join(' / ') || 'IP 待填写' }}</title>
|
||||
<rect width="244" height="88" rx="12" class="diagram-node-bg" />
|
||||
<rect x="12" y="16" width="4" height="54" rx="2" :fill="node.device.source === 'camera' ? '#5c83cb' : '#258b76'" />
|
||||
<text x="28" y="26" class="diagram-node-type">{{ node.device.deviceType }} · {{ accessLabel(node.device.networkAccess) }}</text>
|
||||
<text x="28" y="48" class="diagram-node-title">{{ node.device.name.length > 14 ? node.device.name.slice(0, 14) + '…' : node.device.name }}</text>
|
||||
<text x="28" y="70" class="diagram-node-ip">{{ (node.device.networkHosts || [])[0] || 'IP 待填写' }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="diagram-note">点击节点查看名称、位置与网络详情。未登记上级的设备独立显示。</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<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";
|
||||
import NetworkDiagram from "./NetworkDiagram.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>
|
||||
<NetworkDiagram />
|
||||
<h3 class="network-details-heading">设备与连接详情</h3>
|
||||
<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><p id="leadership-scope" class="fw-semibold"></p><div id="leadership-empty" class="alert alert-info" role="status" hidden>本家庭 / 场所还没有可选成员。请先添加本家庭管理员,保存账号后会自动返回这里。其他家庭的账号不会混入此列表。</div><div class="leadership-member-actions d-flex flex-wrap gap-2 mb-3"><button type="button" id="leadership-new" class="secondary">+ 添加本家庭管理员</button><button type="button" id="leadership-members" class="quiet">管理本家庭成员</button></div><div class="form-grid"><label>主要负责人 *<select name="ownerId" required></select></label><label>备用负责人<select name="successorId"></select></label><label id="leadership-grant" class="check full alert alert-warning" hidden><input type="checkbox" name="grantManagement"><span id="leadership-grant-note"></span></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><details class="live-help"><summary>画面与清晰度说明</summary><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p></details><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,36 @@
|
||||
// Display-only summaries of the records already authorized by the server.
|
||||
export function summarizeHomes(homes = []) {
|
||||
const kinds = [
|
||||
{ key: 'home', label: '家庭', color: '#258b76', count: 0 },
|
||||
{ key: 'office', label: '办公', color: '#5c83cb', count: 0 },
|
||||
{ key: 'factory', label: '工厂', color: '#c69a52', count: 0 },
|
||||
{ key: 'other', label: '其他', color: '#8997a9', count: 0 },
|
||||
];
|
||||
let devices = 0, areas = 0;
|
||||
for (const home of homes) {
|
||||
(kinds.find(k => k.key === (home.kind || 'home')) || kinds[3]).count++;
|
||||
devices += Math.max(0, Number(home.deviceCount) || 0);
|
||||
areas += Math.max(0, Number(home.sites) || 0);
|
||||
}
|
||||
let offset = 0;
|
||||
return { total: homes.length, devices, areas, kinds: kinds.filter(k => k.count).map(k => {
|
||||
const value = { ...k, share: k.count / homes.length * 100, offset };
|
||||
offset += value.share;
|
||||
return value;
|
||||
}) };
|
||||
}
|
||||
|
||||
export function layoutNetwork(rows = []) {
|
||||
const nodes = rows.map((row, index) => ({
|
||||
...row, x: 28 + Math.max(0, row.depth) * 300, y: 26 + index * 112,
|
||||
}));
|
||||
const byId = new Map(nodes.map(n => [n.device.id, n]));
|
||||
const edges = nodes.flatMap(node => {
|
||||
const parent = byId.get(node.device.uplinkId);
|
||||
// Only draw an edge when the validated hierarchy confirms the parent.
|
||||
if (!parent || parent.depth + 1 !== node.depth) return [];
|
||||
const x = parent.x + 244, y = parent.y + 40, endY = node.y + 40;
|
||||
return [{ id: node.device.id, path: `M${x},${y} H${x + 24} V${endY} H${node.x}` }];
|
||||
});
|
||||
return { nodes, edges, width: Math.max(640, ...nodes.map(n => n.x + 272)), height: Math.max(172, nodes.length * 112 + 26) };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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 './bootstrap-polish.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'}})
|
||||
+18
-4
@@ -165,16 +165,30 @@ class Households:
|
||||
reason = a.clean_text(data.get('reason', ''), 300, True)
|
||||
if owner_id == successor_id:
|
||||
raise a.Problem('备用负责人必须是另一位家庭管理员')
|
||||
grant = data.get('grantManagement', False)
|
||||
if type(grant) is not bool:
|
||||
raise a.Problem('管理员授权确认格式不正确')
|
||||
promotions = []
|
||||
for uid in filter(None, (owner_id, successor_id)):
|
||||
account = self.owns(user, a.get_object('users', uid))
|
||||
if account['role'] != 'family_admin' or account['disabled']:
|
||||
raise a.Problem('负责人须为本家庭已启用的家庭管理员')
|
||||
if account['disabled'] or account['role'] not in ('family_admin', 'member'):
|
||||
raise a.Problem('请选择本家庭已启用的成员或家庭管理员')
|
||||
if account['role'] == 'member':
|
||||
if not grant:
|
||||
raise a.Problem('所选成员还不是家庭管理员,请确认授予本家庭管理权限')
|
||||
promotions.append(dict(account, role='family_admin'))
|
||||
record = dict(at=dt.datetime.now(dt.timezone.utc).isoformat(), actor=user['username'], reason=reason,
|
||||
previousOwnerId=home.get('ownerId', ''), ownerId=owner_id, successorId=successor_id)
|
||||
previousOwnerId=home.get('ownerId', ''), ownerId=owner_id, successorId=successor_id,
|
||||
promotedUserIds=[u['id'] for u in promotions])
|
||||
home.update(ownerId=owner_id, successorId=successor_id,
|
||||
leadershipRevision=home.get('leadershipRevision', 0) + 1,
|
||||
leadershipHistory=(home.get('leadershipHistory', []) + [record])[-50:])
|
||||
a.save_object('households', home)
|
||||
# Validate both choices first, then commit role grants and succession together.
|
||||
with a.DB:
|
||||
for account in promotions:
|
||||
a.DB.execute('UPDATE users SET body=? WHERE id=?', (json.dumps(account), account['id']))
|
||||
a.DB.execute('DELETE FROM sessions WHERE user_id=?', (account['id'],))
|
||||
a.DB.execute('UPDATE households SET body=? WHERE id=?', (json.dumps(home), home['id']))
|
||||
a.audit('家庭负责人交接', user['username'] + ' / ' + home['name'])
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
@@ -207,6 +207,28 @@ class HouseholdTests(unittest.TestCase):
|
||||
self.assertEqual(saved,user)
|
||||
self.assertEqual([tuple(r) for r in before],[tuple(r) for r in app.DB.execute('SELECT * FROM sessions ORDER BY hash')])
|
||||
|
||||
def test_leadership_member_requires_explicit_grant_and_revokes_old_session(self):
|
||||
member=self.members[self.aid]
|
||||
self.assertEqual(self.leadership(ownerId=member['id'],successorId='')[0],400)
|
||||
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
|
||||
self.assertEqual(self.leadership(ownerId=member['id'],successorId='',grantManagement='true')[0],400)
|
||||
self.assertEqual(self.leadership(ownerId=member['id'],successorId='',grantManagement=True)[0],200)
|
||||
self.assertEqual(app.get_object('users',member['id'])['role'],'family_admin')
|
||||
home=app.get_object('households',self.aid)
|
||||
self.assertEqual(home['ownerId'],member['id'])
|
||||
self.assertEqual(home['leadershipHistory'][-1]['promotedUserIds'],[member['id']])
|
||||
self.assertEqual(app.DB.execute('SELECT count(*) FROM sessions WHERE user_id=?',(member['id'],)).fetchone()[0],0)
|
||||
|
||||
def test_leadership_grant_does_not_bypass_scope_or_partial_validation(self):
|
||||
member=self.members[self.aid]
|
||||
self.assertEqual(self.leadership(ownerId=member['id'],successorId=self.other['id'],grantManagement=True)[0],404)
|
||||
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
|
||||
self.assertEqual(app.get_object('households',self.aid).get('leadershipRevision',0),0)
|
||||
disabled=dict(self.backup,disabled=True);app.save_object('users',disabled)
|
||||
self.assertEqual(self.leadership(ownerId=member['id'],grantManagement=True)[0],400)
|
||||
self.assertEqual(app.get_object('users',member['id'])['role'],'member')
|
||||
self.assertEqual(self.leadership(user=member,ownerId=member['id'],successorId='',grantManagement=True)[0],403)
|
||||
|
||||
def test_office_factory_and_custom_grouping_are_independent(self):
|
||||
for kind in ('office','factory'):
|
||||
code, scope=self.request('/api/households',self.root,dict(name=kind,kind=kind))
|
||||
|
||||
@@ -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('数据存于本机'));
|
||||
|
||||
+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,33 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
|
||||
const source=fs.readFileSync('web/households.js','utf8');
|
||||
test('leadership choices include enabled local members and exclude unrelated or global accounts',()=>{
|
||||
const context=vm.createContext({});
|
||||
vm.runInContext(source.slice(source.indexOf('function leadershipCandidates('),source.indexOf('function syncLeadershipSelection(')),context);
|
||||
const rows=[{id:'a',familyId:'home',role:'member'},{id:'b',familyId:'home',role:'family_admin'},{id:'c',familyId:'home',role:'admin'},{id:'d',familyId:'else',role:'family_admin'},{id:'e',familyId:'home',role:'member',disabled:true}];
|
||||
assert.equal(JSON.stringify(context.leadershipCandidates(rows,'home').map(x=>x.id)),JSON.stringify(['a','b']));
|
||||
});
|
||||
test('empty leadership choices have a member creation path and explicit grant control',()=>{
|
||||
const markup=fs.readFileSync('frontend/src/components/RuntimeOverlays.vue','utf8');
|
||||
for(const id of ['leadership-empty','leadership-scope','leadership-new','leadership-members','leadership-grant'])assert.ok(markup.includes(`id="${id}"`));
|
||||
assert.match(source,/grantManagement=f\.elements\.grantManagement\.checked/);
|
||||
assert.match(source,/disabled=!leadershipUsers.length/);
|
||||
assert.match(source,/grantManagement\.checked=false/);
|
||||
const family=fs.readFileSync('web/family.js','utf8');assert.match(family,/await openLeadership\(returnTo\)/);assert.match(family,/leadershipReturnDraft/);
|
||||
});
|
||||
function fullscreenFixture(native){
|
||||
const classes=new Set(),stage={classList:{contains:k=>classes.has(k),add:k=>classes.add(k),remove:k=>classes.delete(k)}};
|
||||
if(native)stage.requestFullscreen=async()=>{document.fullscreenElement=stage};
|
||||
const button={setAttribute(k,v){this[k]=v}},document={fullscreenElement:null,body:{style:{overflow:'auto'},classList:{toggle(){}}},addEventListener(){},exitFullscreen:async()=>{document.fullscreenElement=null}};
|
||||
const context=vm.createContext({document,$:id=>id==='#page-live'?stage:button});
|
||||
const monitor=fs.readFileSync('web/monitor.js','utf8');
|
||||
vm.runInContext(monitor.slice(monitor.indexOf("let wallBodyOverflow="),monitor.indexOf('setInterval(')),context);
|
||||
return {context,document,stage,button,classes};
|
||||
}
|
||||
test('phone fullscreen falls back to viewport and restores scroll on exit',async()=>{
|
||||
const f=fullscreenFixture(false);await f.button.onclick();assert.ok(f.classes.has('wall-expanded'));assert.equal(f.document.body.style.overflow,'hidden');assert.equal(f.button['aria-pressed'],'true');
|
||||
f.context.closeWallFullscreen();assert.equal(f.classes.size,0);assert.equal(f.document.body.style.overflow,'auto');assert.equal(f.button['aria-pressed'],'false');
|
||||
});
|
||||
test('native fullscreen remains supported and exits without rebuilding video',async()=>{
|
||||
const f=fullscreenFixture(true);await f.button.onclick();assert.equal(f.document.fullscreenElement,f.stage);assert.equal(f.classes.size,0);await f.button.onclick();assert.equal(f.document.fullscreenElement,null);
|
||||
const app=fs.readFileSync('web/app.js','utf8');assert.match(app,/page!=='live'.*closeWallFullscreen\(\)/);
|
||||
});
|
||||
@@ -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()
|
||||
+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];
|
||||
|
||||
+22
-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,27 @@ 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('Bootstrap polish keeps readable light and dark foregrounds and avoids remote assets', () => {
|
||||
const polish=fs.readFileSync('frontend/src/bootstrap-polish.css','utf8');
|
||||
const light=polish.match(/:root\s*\{([^}]+)\}/)[1];
|
||||
const value=name=>light.match(new RegExp('--'+name+':\\s*(#[0-9a-f]{6})'))[1];
|
||||
for(const bg of ['#ffffff','#f3f6fa','#f5f6f4']) {
|
||||
assert.ok(contrast(value('ink'),bg)>=4.5);
|
||||
assert.ok(contrast(value('ui-muted'),bg)>=4.5);
|
||||
}
|
||||
for(const bg of [value('ui-accent'),'#397359'])assert.ok(contrast('#ffffff',bg)>=4.5);
|
||||
assert.ok(contrast('#a8bdc5','#1a2b35')>=4.5);
|
||||
assert.doesNotMatch(polish,/@import|https?:|@font-face/);
|
||||
assert.match(polish,/prefers-reduced-motion/);
|
||||
assert.match(polish,/home-options-open/);
|
||||
assert.match(polish,/#page-live:is\(:fullscreen,\.wall-expanded\) \.camera-meta \{[^}]+background: #172e35 !important; color: #e8f3ef !important/);
|
||||
});
|
||||
test('primary text, muted text and actions have at least 4.5:1 contrast', () => {
|
||||
for (const [fg, bg] of [
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const test=require('node:test'),assert=require('node:assert/strict');
|
||||
const model=()=>import('./frontend/src/composables/insights.mjs');
|
||||
test('home summary uses only supplied authorized records and handles empty values',async()=>{
|
||||
const {summarizeHomes}=await model();
|
||||
assert.deepEqual(summarizeHomes(),{total:0,devices:0,areas:0,kinds:[]});
|
||||
const value=summarizeHomes([{kind:'home',deviceCount:4,sites:1},{kind:'office',deviceCount:2,sites:3},{kind:'home',deviceCount:-1}]);
|
||||
assert.equal(value.devices,6);assert.equal(value.areas,4);assert.equal(value.total,3);
|
||||
assert.equal(value.kinds.reduce((n,k)=>n+k.count,0),3);
|
||||
assert.ok(Math.abs(value.kinds.reduce((n,k)=>n+k.share,0)-100)<0.001);
|
||||
assert.equal(value.kinds[1].offset,value.kinds[0].share);
|
||||
});
|
||||
test('network diagram draws only known parent connections',async()=>{
|
||||
const {layoutNetwork}=await model();
|
||||
const rows=[{device:{id:'r'},depth:0},{device:{id:'c',uplinkId:'r'},depth:1},{device:{id:'x',uplinkId:'missing'},depth:0},{device:{id:'cycle',uplinkId:'cycle'},depth:0}];
|
||||
const graph=layoutNetwork(rows);
|
||||
assert.equal(graph.nodes.length,4);assert.deepEqual(graph.edges.map(e=>e.id),['c']);
|
||||
assert.ok(graph.nodes.every(n=>n.x>=0&&n.y>=0&&n.x+244<=graph.width&&n.y+88<=graph.height));
|
||||
assert.deepEqual(layoutNetwork().edges,[]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
+7
-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','device-topology','home-global-topology'])$('#'+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());
|
||||
@@ -70,8 +71,9 @@ function showPage(page, options={}) {
|
||||
if(page!=='heritage-records'&&typeof resetHeritage==='function')resetHeritage();
|
||||
if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();}
|
||||
if(page!=='family'&&typeof closeFamilyFullscreen==='function')closeFamilyFullscreen();
|
||||
if(page!=='live'&&typeof closeWallFullscreen==='function')closeWallFullscreen();
|
||||
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 +113,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 +181,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(){
|
||||
|
||||
+7
-1
@@ -40,9 +40,15 @@ function renderDevices(){
|
||||
const devices=all.filter(d=>(!range||range.has(d.siteId))&&(!category||d.deviceType===category)&&ZhaoDevices.matches(d,search,state.sites));
|
||||
$('#asset-list').hidden=devicePresentation!=='cards';$('#device-topology').hidden=devicePresentation!=='topology';
|
||||
$('#devices-card-view').setAttribute('aria-pressed',String(devicePresentation==='cards'));$('#devices-topology-view').setAttribute('aria-pressed',String(devicePresentation==='topology'));
|
||||
if(devicePresentation==='topology')renderTopology(devices);
|
||||
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(){devicePresentation='cards';deviceGroupScope='';deviceViewSignature='';deviceGroups=['type','space'];$('#device-search').value='';$('#device-type').innerHTML='<option value="">所有类型</option>';$('#device-group-order').replaceChildren();}
|
||||
|
||||
+3
-2
@@ -70,7 +70,8 @@ async function loadUsers(){
|
||||
if(!isAdmin())return;const id=currentUser.id;
|
||||
try{const [u,f]=await Promise.all([api('/api/users'),api('/api/family')]);if(!loggedIn||currentUser?.id!==id)return;users=u.users;family=f;
|
||||
$('#user-list').innerHTML=`<div class="table-wrap"><table class="device-table"><thead><tr><th>账号</th><th>角色 / 状态</th><th>人物关系</th><th>家谱 / 传承资料</th><th>摄像头空间</th><th></th></tr></thead><tbody>${users.map(u=>`<tr><td>${esc(u.name)}<small>${esc(u.username)}</small></td><td>${roleName(u)}${u.disabled?' · 已停用':''}</td><td>${u.relationship==='本人'&&u.relatedToId===u.id?'本人':u.relationship?esc(u.name+' 是 '+(users.find(x=>x.id===u.relatedToId)?.name||'(参照待指定)')+' 的 '+u.relationship):'未设置'}<small>家谱:${esc(personName(u.personId))}</small></td><td>${u.role!=='member'?'管理本归属':({none:'不可访问',read:'查看',edit:'录入和维护'}[u.familyAccess])}<small>文化与财产:${u.role!=='member'?'管理':({none:'不可访问',read:'查看',edit:'维护'}[u.heritageAccess||'none'])}</small></td><td>${u.role!=='member'?'本归属全部区域':esc((u.siteIds.map(siteName).join('、')||'未授权')+(u.includeSubspaces?'(含下级区域)':''))}</td><td><button class="quiet" data-edit-user="${u.id}">编辑账号</button></td></tr>`).join('')}</tbody></table></div>`;
|
||||
}catch(e){toast(e.message);}
|
||||
return true;
|
||||
}catch(e){if(!e.stale)toast(e.message);return false;}
|
||||
}
|
||||
function openUser(id){
|
||||
if(!isAdmin())return;const f=$('#user-form'),u=users.find(u=>u.id===id)||{};
|
||||
@@ -79,7 +80,7 @@ function openUser(id){
|
||||
f.elements.role.querySelector('[value=admin]').hidden=!isPlatformAdmin();
|
||||
$('#user-sites').innerHTML=state.sites.map(s=>`<label class="check"><input type="checkbox" name="siteIds" value="${s.id}" ${(u.siteIds||[]).includes(s.id)?'checked':''}>${esc(siteName(s.id))}</label>`).join('');$('#user-dialog').showModal();
|
||||
}
|
||||
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;d.includeSubspaces=f.elements.includeSubspaces.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
|
||||
$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;d.includeSubspaces=f.elements.includeSubspaces.checked;const saved=await api('/api/users',d),returnTo=leadershipReturnHome;leadershipReturnHome='';f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();if(returnTo&&loggedIn&&state.familyId===returnTo){await openLeadership(returnTo);const lf=$('#leadership-form');if(leadershipReturnDraft){for(const key of ['ownerId','successorId','reason'])lf.elements[key].value=leadershipReturnDraft[key]||'';leadershipReturnDraft=null;}if(!lf.elements.ownerId.value)lf.elements.ownerId.value=saved.id;else if(!lf.elements.successorId.value&&lf.elements.ownerId.value!==saved.id)lf.elements.successorId.value=saved.id;syncLeadershipSelection();}toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});
|
||||
$('#new-person').onclick=()=>openPerson();$('#new-user').onclick=()=>openUser();$('#family-reload').onclick=loadFamily;
|
||||
$('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderFamily;$('#family-archived').onchange=renderFamily;
|
||||
$('#family-view-tree').onclick=()=>{familyView='tree';renderFamily();familyMap.fit();};$('#family-view-list').onclick=()=>{familyView='list';renderFamily();};
|
||||
|
||||
+25
-3
@@ -13,6 +13,7 @@ function homeMove(id,step,target=''){
|
||||
}
|
||||
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(){
|
||||
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]};
|
||||
@@ -38,6 +39,7 @@ function renderHouseholds(){
|
||||
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();};
|
||||
@@ -46,6 +48,7 @@ $('#home-global-topology').addEventListener('click',async e=>{const b=e.target.c
|
||||
$('#home-layout-reset').onclick=()=>{homeLayoutDirty=false;homeLayoutDraft=null;homePage=0;renderHouseholds();};
|
||||
$('#household-list').addEventListener('click',e=>{const button=e.target.closest('[data-home-move]');if(button)homeMove(button.dataset.homeMove,Number(button.dataset.step));});
|
||||
$('#home-layout-save').onclick=async()=>{if(homeLayoutSaving||!homeLayoutDirty)return;const generation=sessionGeneration;homeLayoutSaving=true;renderHouseholds();try{const saved=await api('/api/households/layout',homeLayoutDraft);if(generation!==sessionGeneration)return;state.householdLayout=saved;homeLayoutDraft={...saved,order:[...saved.order]};homeLayoutDirty=false;toast('卡片布局与顺序已保存');}catch(e){if(!e.stale)toast(e.message);}finally{homeLayoutSaving=false;if(generation===sessionGeneration)renderHouseholds();}};
|
||||
}
|
||||
async function switchHousehold(id){
|
||||
if(id===state.familyId)return true;
|
||||
if(!isPlatformAdmin()||!id)return false;
|
||||
@@ -63,25 +66,44 @@ function openHousehold(id){
|
||||
if(!item&&!isPlatformAdmin())return;
|
||||
nextImageJob('cover');$('#household-cover-status').dataset.error='false';fillForm($('#household-form'),{profileRevision:0,...(item||{})});coverDraft=undefined;ZhaoGeography.fill($('#household-form'),item||{});$('#household-cover-preview').hidden=!item?.coverUrl;if(item?.coverUrl)$('#household-cover-preview').src=item.coverUrl;else $('#household-cover-preview').removeAttribute('src');$('#household-cover-status').textContent='封面存入百度网盘,原图可选 30 MB 以内;浏览器会先压缩。仅向获授权账号开放。';$('#household-dialog').showModal();
|
||||
}
|
||||
let leadershipUsers=[],leadershipReturnHome='',leadershipReturnDraft=null;
|
||||
function leadershipCandidates(rows,familyId){return rows.filter(u=>u.familyId===familyId&&!u.disabled&&['family_admin','member'].includes(u.role));}
|
||||
function syncLeadershipSelection(){
|
||||
const f=$('#leadership-form'),selected=[f.elements.ownerId.value,f.elements.successorId.value];
|
||||
for(const option of f.elements.successorId.options)option.disabled=!!option.value&&option.value===selected[0];
|
||||
if(selected[0]&&selected[0]===selected[1])f.elements.successorId.value='';
|
||||
const grants=leadershipUsers.filter(u=>[f.elements.ownerId.value,f.elements.successorId.value].includes(u.id)&&u.role==='member');
|
||||
$('#leadership-grant').hidden=!grants.length;f.elements.grantManagement.required=!!grants.length;
|
||||
$('#leadership-grant-note').textContent=grants.length?'确认将 '+grants.map(u=>u.name).join('、')+' 设为本家庭 / 场所管理员,可管理其全部成员、区域和设备。':'';
|
||||
f.querySelector('[type=submit]').disabled=!leadershipUsers.length;
|
||||
}
|
||||
async function openLeadership(id){
|
||||
try{
|
||||
if(id!==state.familyId&&!await switchHousehold(id))return;
|
||||
const home=(state.households||[]).find(h=>h.id===id);
|
||||
if(!home?.canAssignOwner)return;
|
||||
const result=await api('/api/users');
|
||||
const candidates=result.users.filter(u=>u.role==='family_admin'&&!u.disabled);
|
||||
const f=$('#leadership-form'),options=candidates.map(u=>`<option value="${esc(u.id)}">${esc(u.name+' / '+u.username)}</option>`).join('');
|
||||
leadershipUsers=leadershipCandidates(result.users,id);
|
||||
const f=$('#leadership-form'),options=leadershipUsers.map(u=>`<option value="${esc(u.id)}">${esc(u.name+' / '+u.username+' · '+(u.role==='family_admin'?'管理员':'成员(需授权)'))}</option>`).join('');
|
||||
f.elements.ownerId.innerHTML='<option value="">请选择主要负责人</option>'+options;
|
||||
f.elements.successorId.innerHTML='<option value="">暂未指定</option>'+options;
|
||||
fillForm(f,{id:home.id,revision:home.leadershipRevision||0,ownerId:home.ownerId||'',successorId:home.successorId||''});
|
||||
$('#leadership-scope').textContent='当前家庭 / 场所:'+home.name;
|
||||
$('#leadership-empty').hidden=!!leadershipUsers.length;
|
||||
f.elements.ownerId.disabled=!leadershipUsers.length;f.elements.successorId.disabled=!leadershipUsers.length;
|
||||
f.elements.grantManagement.checked=false;syncLeadershipSelection();
|
||||
$('#leadership-dialog').showModal();
|
||||
}catch(error){if(!error.stale)toast(error.message);}
|
||||
}
|
||||
$('#leadership-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;
|
||||
try{const d=formValues(f);d.revision=Number(d.revision);await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
|
||||
try{const d=formValues(f);d.revision=Number(d.revision);d.grantManagement=f.elements.grantManagement.checked;await api('/api/households/leadership',d);f.closest('dialog').close();await refresh();showPage('households');toast('负责人已更新,交接记录已保存');}
|
||||
catch(error){if(!error.stale)f.querySelector('.form-error').textContent=error.message;}finally{b.disabled=false;}
|
||||
});
|
||||
for(const name of ['ownerId','successorId'])$('#leadership-form').elements[name].addEventListener('change',()=>{$('#leadership-form').elements.grantManagement.checked=false;syncLeadershipSelection();});
|
||||
$('#leadership-members').onclick=()=>{$('#leadership-dialog').close();showPage('users');};
|
||||
$('#leadership-new').onclick=async()=>{const id=state.familyId;leadershipReturnDraft=formValues($('#leadership-form'));$('#leadership-dialog').close();if(!await loadUsers()||state.familyId!==id||!loggedIn)return;openUser();$('#user-form').elements.role.value='family_admin';leadershipReturnHome=id;};
|
||||
$('#user-dialog').addEventListener('close',()=>{leadershipReturnHome='';});
|
||||
$('#household-filter').onchange=async e=>{const switched=await switchHousehold(e.target.value);if(switched&&loggedIn)showPage('overview');else e.target.value=state.familyId;};
|
||||
$('#new-household').onclick=()=>openHousehold();
|
||||
$('#household-form').addEventListener('submit',async e=>{
|
||||
|
||||
-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="例如:卧室 灯;也可查 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><div id="asset-list"></div><section id="device-topology" hidden aria-label="当前范围网络拓扑"></section></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 class="device-actions" role="group" aria-label="家庭场所展示方式"><button id="home-card-view" class="quiet" aria-pressed="true">场所卡片</button><button id="home-topology-view" class="quiet" aria-pressed="false">赵府整体拓扑</button></div><section id="home-global-topology" hidden aria-label="平台与家庭场所拓扑"></section><div class="panel home-layout-controls"><div class="device-actions"><label>每页列数<select id="home-columns"><option value="1">1 列</option><option value="2">2 列</option><option value="3">3 列</option><option value="4">4 列</option></select></label><span aria-hidden="true">×</span><label>每页行数<select id="home-rows"><option value="1">1 行</option><option value="2">2 行</option><option value="3">3 行</option><option value="4">4 行</option></select></label><label class="check"><input id="home-group-city" type="checkbox">按城市分组</label><label class="check"><input id="home-arrange" type="checkbox">调整卡片顺序</label><button id="home-layout-save" class="secondary">保存我的布局</button><button id="home-layout-reset" class="quiet">撤销调整</button></div><p id="home-layout-status" class="description" role="status">布局保存到当前账号,手机端自动适配。</p></div><div id="household-list"></div><div class="home-pagination"><button id="home-prev" class="quiet">上一页</button><span id="home-page-info" aria-live="polite"></span><button id="home-next" class="quiet">下一页</button></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="路由器"><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>所有人<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>
|
||||
</body>
|
||||
</html>
|
||||
+21
-7
@@ -16,6 +16,7 @@ function wallSync(){
|
||||
const capacity=Number(layout)||6,pages=Math.max(1,Math.ceil(ordered.length/capacity));
|
||||
wallPage=Math.min(wallPage,pages-1);const visible=new Set(ordered.slice(wallPage*capacity,(wallPage+1)*capacity));
|
||||
$('#live-grid').dataset.layout=layout;
|
||||
$('#live-grid').dataset.visibleCount=String(visible.size);
|
||||
for(const card of $$('#live-grid .camera-card')){
|
||||
const id=card.querySelector('video').dataset.camera;
|
||||
card.style.order=ordered.indexOf(id);card.hidden=!visible.has(id);
|
||||
@@ -34,16 +35,29 @@ function wallMove(id,target){
|
||||
$('#wall-layout').onchange=()=>{wallPage=0;wallSave();wallSync();};
|
||||
$('#wall-arrange').onchange=wallSync;
|
||||
$('#wall-prev').onclick=()=>{wallPage--;wallSync();};$('#wall-next').onclick=()=>{wallPage++;wallSync();};
|
||||
let wallBodyOverflow='';
|
||||
function syncWallFullscreen(){
|
||||
const active=document.fullscreenElement===$('#page-live')||$('#page-live').classList.contains('wall-expanded');
|
||||
$('#wall-fullscreen').textContent=active?'退出全屏':'全屏监控';
|
||||
$('#wall-fullscreen').setAttribute('aria-pressed',String(active));
|
||||
document.body.classList.toggle('wall-fullscreen',active);
|
||||
}
|
||||
function closeWallFullscreen(){
|
||||
const stage=$('#page-live');
|
||||
if(stage.classList.contains('wall-expanded')){stage.classList.remove('wall-expanded');document.body.style.overflow=wallBodyOverflow;}
|
||||
if(document.fullscreenElement===stage)document.exitFullscreen().catch(()=>{});
|
||||
syncWallFullscreen();
|
||||
}
|
||||
$('#wall-fullscreen').onclick=async()=>{
|
||||
try{if(document.fullscreenElement)await document.exitFullscreen();else await $('#page-live').requestFullscreen();}
|
||||
catch{toast('浏览器未允许全屏,可按 F11 进入浏览器全屏');}
|
||||
const stage=$('#page-live');
|
||||
if(document.fullscreenElement===stage||stage.classList.contains('wall-expanded')){closeWallFullscreen();return;}
|
||||
try{await stage.requestFullscreen();}
|
||||
catch{wallBodyOverflow=document.body.style.overflow;stage.classList.add('wall-expanded');document.body.style.overflow='hidden';}
|
||||
syncWallFullscreen();
|
||||
};
|
||||
document.addEventListener('fullscreenchange',()=>{
|
||||
$('#wall-fullscreen').textContent=document.fullscreenElement?'退出全屏 · Esc':'全屏监控';
|
||||
document.body.classList.toggle('wall-fullscreen',document.fullscreenElement===$('#page-live'));
|
||||
});
|
||||
document.addEventListener('fullscreenchange',syncWallFullscreen);
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key==='Escape'&&document.fullscreenElement===$('#page-live'))document.exitFullscreen().catch(()=>{});
|
||||
if(e.key==='Escape')closeWallFullscreen();
|
||||
});
|
||||
setInterval(()=>{$('#wall-clock').textContent=new Date().toLocaleString('zh-CN',{hour12:false});},1000);
|
||||
let wallPointer=null;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+3
-3
@@ -8,8 +8,8 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
|
||||
@media(max-width:760px){.sidebar #logout{display:block;align-self:flex-end}.sidebar #current-user{position:absolute;right:130px;top:15px;font-size:10px}.form-grid{grid-template-columns:1fr}.family-tree ul{padding-left:12px}}
|
||||
|
||||
#family-detail dl{display:grid;grid-template-columns:1fr 1fr;gap:14px 28px;margin:20px 0 26px}#family-detail dl>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-bottom:1px solid var(--line);padding-bottom:10px}#family-detail dt{font-size:12px;color:var(--muted)}#family-detail dd{margin:0;font-size:13px;overflow-wrap:anywhere}@media(max-width:1000px){#family-detail dl{grid-template-columns:1fr}}
|
||||
.brand-icon{width:44px;height:44px;object-fit:cover;border-radius:12px;flex-shrink:0}.wall-toolbar{display:flex;align-items:center;gap:15px;flex-wrap:wrap;margin:16px 0}.wall-toolbar select{padding:8px}.wall-paging{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12px}#wall-clock{margin-left:auto;color:var(--muted);font-size:12px}.live-grid[data-layout="1"]{grid-template-columns:1fr}.live-grid[data-layout="2"],.live-grid[data-layout="4"]{grid-template-columns:repeat(2,minmax(0,1fr))}.live-grid[data-layout="6"],.live-grid[data-layout="9"]{grid-template-columns:repeat(3,minmax(0,1fr))}.live-grid[data-layout="16"]{grid-template-columns:repeat(4,minmax(0,1fr))}.wall-reorder{display:none;padding:8px 12px;align-items:center;gap:8px}.arranging .wall-reorder{display:flex}.wall-reorder span{cursor:grab;margin-right:auto;font-size:12px;color:var(--muted)}.wall-reorder button{padding:4px 9px;font-size:11px}#page-live:fullscreen{background:#070f12;padding:12px;width:100vw;height:100dvh;display:flex;flex-direction:column;overflow:hidden}#page-live:fullscreen>.section-head,#page-live:fullscreen>#quality-note{display:none}#page-live:fullscreen .wall-toolbar{margin:0 0 10px;flex-shrink:0}#page-live:fullscreen .live-grid{flex:1;min-height:0;gap:8px;grid-template-rows:repeat(2,minmax(0,1fr))}#page-live:fullscreen .live-grid[data-layout="1"],#page-live:fullscreen .live-grid[data-layout="2"]{grid-template-rows:1fr}#page-live:fullscreen .live-grid[data-layout="9"]{grid-template-rows:repeat(3,minmax(0,1fr))}#page-live:fullscreen .live-grid[data-layout="16"]{grid-template-rows:repeat(4,minmax(0,1fr))}#page-live:fullscreen .camera-card{display:flex;flex-direction:column;min-height:0;position:relative}#page-live:fullscreen .video-panel{flex:1;min-height:0;aspect-ratio:auto}#page-live:fullscreen .camera-meta{padding:7px 10px;align-items:center}#page-live:fullscreen .camera-meta p,#page-live:fullscreen .camera-meta .badge{display:none}#page-live:fullscreen .camera-meta strong{font-size:12px}#page-live:fullscreen .camera-actions{position:absolute;bottom:34px;right:6px;padding:4px;background:#10191ddd;border-radius:6px;opacity:0}#page-live:fullscreen .camera-card:hover .camera-actions,#page-live:fullscreen .camera-card:focus-within .camera-actions{opacity:1}#page-live:fullscreen .camera-actions [data-edit-camera],#page-live:fullscreen .camera-actions [data-play-camera]{display:none}.ptz-panel{background:#1a3038;border:1px solid #41656c;border-radius:12px;padding:16px;display:flex;align-items:center;gap:22px;position:fixed;bottom:22px;right:22px;z-index:4;max-width:calc(100vw - 32px);box-shadow:0 12px 50px #0008}.ptz-panel p{font-size:12px;color:var(--muted);max-width:320px;margin:8px 0 0}.ptz-pad{display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,40px);gap:4px}.ptz-pad button{padding:5px;font-size:17px}.ptz-pad [data-ptz-action="up"]{grid-column:2}.ptz-pad [data-ptz-action="left"]{grid-column:1;grid-row:2}.ptz-pad [data-ptz-action="stop"]{grid-column:2;grid-row:2;background:#f0bda6;font-size:12px}.ptz-pad [data-ptz-action="right"]{grid-column:3;grid-row:2}.ptz-pad [data-ptz-action="down"]{grid-column:2;grid-row:3}.family-date{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0;min-width:0}.family-date legend{font-size:13px;color:#a5bec1}.family-date label{margin-bottom:10px}.family-date output{display:block;font-size:12px;line-height:1.7;color:var(--accent)}@media(max-width:760px){.wall-toolbar{gap:9px}#wall-clock{display:none}.camera-meta strong{font-size:12px}.camera-actions{flex-wrap:wrap;padding:0 8px 8px}.camera-meta{padding:9px}.ptz-panel{flex-wrap:wrap;gap:12px}.ptz-panel>div:first-child{flex:1;min-width:160px}.family-date{grid-column:1/-1}#page-live:fullscreen .camera-actions{opacity:1}.live-grid[data-layout="16"] .camera-meta strong{font-size:9px}}
|
||||
#page-live:fullscreen .live-grid[data-layout="auto"]{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.brand-icon{width:44px;height:44px;object-fit:cover;border-radius:12px;flex-shrink:0}.wall-toolbar{display:flex;align-items:center;gap:15px;flex-wrap:wrap;margin:16px 0}.wall-toolbar select{padding:8px}.wall-paging{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12px}#wall-clock{margin-left:auto;color:var(--muted);font-size:12px}.live-grid[data-layout="1"]{grid-template-columns:1fr}.live-grid[data-layout="2"],.live-grid[data-layout="4"]{grid-template-columns:repeat(2,minmax(0,1fr))}.live-grid[data-layout="6"],.live-grid[data-layout="9"]{grid-template-columns:repeat(3,minmax(0,1fr))}.live-grid[data-layout="16"]{grid-template-columns:repeat(4,minmax(0,1fr))}.wall-reorder{display:none;padding:8px 12px;align-items:center;gap:8px}.arranging .wall-reorder{display:flex}.wall-reorder span{cursor:grab;margin-right:auto;font-size:12px;color:var(--muted)}.wall-reorder button{padding:4px 9px;font-size:11px}#page-live:is(:fullscreen,.wall-expanded){background:#070f12;padding:12px;width:100vw;height:100dvh;display:flex;flex-direction:column;overflow:hidden}#page-live:is(:fullscreen,.wall-expanded)>.section-head,#page-live:is(:fullscreen,.wall-expanded)>#quality-note{display:none}#page-live:is(:fullscreen,.wall-expanded) .wall-toolbar{margin:0 0 10px;flex-shrink:0}#page-live:is(:fullscreen,.wall-expanded) .live-grid{flex:1;min-height:0;gap:8px;grid-template-rows:repeat(2,minmax(0,1fr))}#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="1"],#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="2"]{grid-template-rows:1fr}#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="9"]{grid-template-rows:repeat(3,minmax(0,1fr))}#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="16"]{grid-template-rows:repeat(4,minmax(0,1fr))}#page-live:is(:fullscreen,.wall-expanded) .camera-card{display:flex;flex-direction:column;min-height:0;position:relative}#page-live:is(:fullscreen,.wall-expanded) .video-panel{flex:1;min-height:0;aspect-ratio:auto}#page-live:is(:fullscreen,.wall-expanded) .camera-meta{padding:7px 10px;align-items:center}#page-live:is(:fullscreen,.wall-expanded) .camera-meta p,#page-live:is(:fullscreen,.wall-expanded) .camera-meta .badge{display:none}#page-live:is(:fullscreen,.wall-expanded) .camera-meta strong{font-size:12px}#page-live:is(:fullscreen,.wall-expanded) .camera-actions{position:absolute;bottom:34px;right:6px;padding:4px;background:#10191ddd;border-radius:6px;opacity:0}#page-live:is(:fullscreen,.wall-expanded) .camera-card:hover .camera-actions,#page-live:is(:fullscreen,.wall-expanded) .camera-card:focus-within .camera-actions{opacity:1}#page-live:is(:fullscreen,.wall-expanded) .camera-actions [data-edit-camera],#page-live:is(:fullscreen,.wall-expanded) .camera-actions [data-play-camera]{display:none}.ptz-panel{background:#1a3038;border:1px solid #41656c;border-radius:12px;padding:16px;display:flex;align-items:center;gap:22px;position:fixed;bottom:22px;right:22px;z-index:4;max-width:calc(100vw - 32px);box-shadow:0 12px 50px #0008}.ptz-panel p{font-size:12px;color:var(--muted);max-width:320px;margin:8px 0 0}.ptz-pad{display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,40px);gap:4px}.ptz-pad button{padding:5px;font-size:17px}.ptz-pad [data-ptz-action="up"]{grid-column:2}.ptz-pad [data-ptz-action="left"]{grid-column:1;grid-row:2}.ptz-pad [data-ptz-action="stop"]{grid-column:2;grid-row:2;background:#f0bda6;font-size:12px}.ptz-pad [data-ptz-action="right"]{grid-column:3;grid-row:2}.ptz-pad [data-ptz-action="down"]{grid-column:2;grid-row:3}.family-date{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0;min-width:0}.family-date legend{font-size:13px;color:#a5bec1}.family-date label{margin-bottom:10px}.family-date output{display:block;font-size:12px;line-height:1.7;color:var(--accent)}@media(max-width:760px){.wall-toolbar{gap:9px}#wall-clock{display:none}.camera-meta strong{font-size:12px}.camera-actions{flex-wrap:wrap;padding:0 8px 8px}.camera-meta{padding:9px}.ptz-panel{flex-wrap:wrap;gap:12px}.ptz-panel>div:first-child{flex:1;min-width:160px}.family-date{grid-column:1/-1}#page-live:is(:fullscreen,.wall-expanded) .camera-actions{opacity:1}.live-grid[data-layout="16"] .camera-meta strong{font-size:9px}}
|
||||
#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="auto"]{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.wall-reorder [data-wall-drag]{touch-action:none;user-select:none;padding:5px 0}.camera-card.wall-drop-target{outline:2px solid var(--accent);outline-offset:-3px}
|
||||
|
||||
#camera-settings-dialog{width:min(1060px,94vw)}
|
||||
@@ -19,7 +19,7 @@ textarea{font:inherit;color:#e3f0ef;background:#111e24;border:1px solid #3a4e55;
|
||||
#camera-image-fields,#camera-video-fields{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
#camera-settings-status:empty{display:none}
|
||||
#camera-settings-dialog details{margin-top:20px;color:var(--muted);font-size:13px;line-height:1.8}
|
||||
#page-live:fullscreen .camera-actions [data-camera-settings]{display:none}
|
||||
#page-live:is(:fullscreen,.wall-expanded) .camera-actions [data-camera-settings]{display:none}
|
||||
@media(max-width:700px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:420px){#camera-settings-info dl,#camera-image-fields,#camera-video-fields{grid-template-columns:1fr}}
|
||||
|
||||
|
||||
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-DuGUtT0D.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-XTjkr4dE.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