Compare commits

..

5 Commits

42 changed files with 782 additions and 46 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点
**录像机原有历史录像回放尚未实现。** 需要取得具体型号及合法设备账号,接入其录像检索和回放接口;实时 RTSP 地址不能代替录像检索接口。驱动选项目前仅表示路径模板与接入类型,不能视为某个品牌全系列已经兼容。实际通道号需要由设备确认,不能根据三台双镜头直接猜成通道 1–6。
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
本机录像默认关闭,用户逐通道开启。默认最长保留 30 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。保留天数是时间上限,不保证在容量限制内存满该天数。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
HLS 会有数秒延迟。网页清晰度仅提供“主码流”和“子码流”两个选项,默认主码流。主码流预览使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。子码流预览使用实际子码流,由 FFmpeg 按需生成 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。网页不提供原始编码直出的预览选项。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
+20
View File
@@ -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/)。本次已在授权设备上修复该配置,应用不会自动更改其他路由器。
公开源码不包含用户数据库、家庭资料或设备凭据。
+20
View File
@@ -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;检查浅色和深色主题。
本版未迁移业务数据库。公开源码不含用户照片、数据库或设备凭据。
+13
View File
@@ -0,0 +1,13 @@
# v0.1.38 — 录像留存与回放修复
- 新安装默认最长保留 30 天,保留现有 40 GB 容量上限、8 GB 磁盘预留;既有安装的自定义策略不会因升级被覆盖。
- 容量不足时暂停写入,空间恢复后继续;过期录像由 MediaMTX 自动清理。保留天数是上限,实际时长取决于码率、路数与可用容量。
- 回放页显示实际写入、未开启、设备离线、存储保护等状态;管理员可直接进入当前镜头的录像设置。后台状态刷新不重建播放器。
- 修正首次录像前目录不存在被误报为服务故障;仅匹配当前镜头的缺失目录,权限、损坏文件与真实服务错误仍报错。
- 修正微秒时间转换为网页毫秒时精度丢失,避免查询起点落在片段之前导致 404。查询边界向录像范围内收敛,不填补无录像空档。
- 回放响应关闭反向代理缓冲,并减少 FFmpeg 输入探测等待;保留双路转换并发限制与退出清理。
- “持续录像”开关明确说明存储于服务器并受保留策略管理。
验证:173 项 Python 与 140 项 JavaScript 回归测试;Vue / Bootstrap 生产构建。包含时间边界、空录像目录、权限错误、容量暂停、状态显示及代理缓冲回归。
升级不重建业务数据库,不批量启用摄像头录制。已关闭录制期间的历史画面无法补录。
+7
View File
@@ -0,0 +1,7 @@
# v0.1.39 — 手机录像筛选布局
在 v0.1.38 录像修复基础上调整小屏幕回放表单:镜头、录像来源、日期和查询按钮纵向排列,控件保持完整宽度,避免三个选择框挤在一行。
本版仅调整前端;保留 v0.1.38 的 30 天默认留存、容量保护、录像状态与时间精度修复。设备数据及录像配置不迁移。生产更新仅重启网页服务,持续录像的 MediaMTX 进程保留。
验证:141 项 JavaScript 回归测试与 Vue / Bootstrap 生产构建通过;后端沿用已通过 173 项回归的 v0.1.38 实现。
+1 -1
View File
@@ -1 +1 @@
0.1.35
0.1.39
+34 -8
View File
@@ -73,6 +73,7 @@ SCAN = {'running': False, 'items': [], 'error': '', 'network': '', 'at': None}
ATTEMPTS = {}
API_PORT, HLS_PORT, PLAYBACK_PORT = 19997, 18888, 19996
MEDIA_AUTH = None
DEFAULT_STORAGE = {'retentionDays': 30, 'maxGB': 40, 'reserveGB': 8}
class Problem(Exception):
@@ -329,7 +330,7 @@ def source(c, quality):
def write_media_config():
with LOCK:
paths = {}
policy = setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
policy = setting('storage', DEFAULT_STORAGE)
for c in objects('cameras'):
for quality in ('main', 'sub'):
paths[stream_name(c, quality)] = {
@@ -381,16 +382,24 @@ def media_json(path, playback=False):
return json.load(r)
except urllib.error.HTTPError as exc:
with exc:
if playback and path.startswith('/list?') and exc.code == 404:
if playback and path.startswith('/list?') and exc.code in (400, 404):
try:
detail = json.loads(exc.read(4096))
if detail.get('error') == 'no recording segments found':
message = detail.get('error')
if exc.code == 404 and message == 'no recording segments found':
return []
# MediaMTX 1.21 returns 400 before this camera's first recording.
# Match only the requested camera directory, never permissions,
# another path, a corrupt file, or an unavailable service.
name = url.parse_qs(url.urlsplit(path).query).get('path', [''])[0]
if (exc.code == 400 and re.fullmatch(r'cam_[a-f0-9]{16}_main', name)
and message == 'lstat ' + str(RECORDINGS / name) + ': no such file or directory'):
return []
except (ValueError, AttributeError):
pass
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
raise Problem('录像服务暂不可用,请稍后重试或联系管理员检查服务', 503)
except Exception:
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
raise Problem('录像服务暂不可用,请稍后重试或联系管理员检查服务', 503)
def media_config_watch():
@@ -417,6 +426,22 @@ def media_config_watch():
time.sleep(5)
def recording_state(camera, ready, media_online):
if not camera.get('record'):
return 'off'
if not camera.get('enabled'):
return 'disabled'
if STORAGE['paused']:
return 'paused'
if not media_online:
return 'unavailable'
if not ready:
return 'offline'
if time.time() - RECENT_RECORDS.get(stream_name(camera), 0) < 45:
return 'recording'
return 'waiting'
def status():
try:
data = media_json('/v3/paths/list?itemsPerPage=200')
@@ -432,10 +457,10 @@ def status():
if endpoint:
c = dict(c, host=endpoint['host'], port=endpoint['port'])
main = ready.get(stream_name(c), {})
recording = recording_state(c, main.get('ready', False), media)
cameras.append({**public_camera(c), 'ready': main.get('ready', False), 'tracks': main.get('tracks', []),
'recordingRequested': c['enabled'] and c['record'] and not STORAGE['paused'],
'recordingActive': bool(c['enabled'] and c['record'] and not STORAGE['paused'] and main.get('ready', False)
and time.time() - RECENT_RECORDS.get(stream_name(c), 0) < 45)})
'recordingState': recording, 'recordingActive': recording == 'recording'})
with LOCK:
storage = dict(STORAGE)
scan = dict(SCAN)
@@ -867,6 +892,7 @@ class Handler(BaseHTTPRequestHandler):
self.connection.settimeout(15)
self.send_response(200)
self.send_header('Content-Type', 'video/mp4')
self.send_header('X-Accel-Buffering', 'no')
self.send_header('Cache-Control', 'no-store')
self.send_header('Accept-Ranges', 'none')
self.send_header('X-Content-Type-Options', 'nosniff')
@@ -1107,7 +1133,7 @@ def initialize():
HERITAGE.initialize()
JOURNAL.initialize()
if not setting('storage'):
set_setting('storage', {'retentionDays': 7, 'maxGB': 40, 'reserveGB': 8})
set_setting('storage', DEFAULT_STORAGE)
if not setting('mediaSecret'):
set_setting('mediaSecret', secrets.token_hex(32))
MEDIA_AUTH = 'Basic ' + base64.b64encode(('vision:' + setting('mediaSecret')).encode()).decode()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "zhao-steward-ui",
"private": true,
"version": "0.1.35",
"version": "0.1.39",
"type": "module",
"scripts": {
"build": "vite build",
+2 -1
View File
@@ -5,7 +5,8 @@ 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><AppHeader/><div v-if="failed" class="alert alert-danger" role="alert">页面组件加载失败,请检查网络后重新打开页面。</div><Workspace/></main><RuntimeOverlays/></template>
<template><AppSidebar/><main :data-page="snapshot.page"><AppHeader/><div v-if="failed" class="alert alert-danger" role="alert">页面组件加载失败,请检查网络后重新打开页面。</div><Workspace/></main><RuntimeOverlays/></template>
+239
View File
@@ -0,0 +1,239 @@
/* 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; }
#page-playback .playback-controls { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
#page-playback .playback-controls label { width: 100%; min-width: 0; }
#page-playback .playback-controls input, #page-playback .playback-controls select { width: 100%; }
#page-playback .playback-controls button { width: 100%; }
#play-info { overflow-wrap: anywhere; }
.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; } }
+1 -1
View File
@@ -15,6 +15,6 @@ export function enhance(root){
export async function loadControllers(){
enhance(document.getElementById('app'))
new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true})
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.35';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.39';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
await window.ZhaoVue.boot()
}
+2
View File
@@ -1,6 +1,8 @@
<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",
+7 -2
View File
@@ -2,6 +2,7 @@
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,
@@ -14,6 +15,7 @@ const draft = ref({
page = ref(0),
arrange = ref(false),
mode = ref("cards"),
layoutOptions = ref(false),
drag = ref(""),
message = ref("");
let owner = "";
@@ -28,6 +30,7 @@ watch(
page.value = 0;
arrange.value = false;
mode.value = "cards";
layoutOptions.value = false;
drag.value = "";
message.value = "";
}
@@ -144,7 +147,8 @@ async function enter(h, network = false) {
+ 新建家庭或场所
</button>
</div>
<div class="home-toolbar">
<HomeInsights />
<div class="home-toolbar" :class="{ 'home-options-open': layoutOptions }">
<div class="btn-group" role="group" aria-label="场所显示方式">
<button
id="home-card-view"
@@ -164,7 +168,8 @@ async function enter(h, network = false) {
<AppIcon name="network" /> 整体拓扑
</button>
</div>
<div v-show="mode === 'cards'" class="home-layout-controls">
<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
+33
View File
@@ -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>
+3
View File
@@ -8,6 +8,7 @@ import {
} 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,
@@ -27,6 +28,8 @@ const home = computed(() =>
<p class="description">
连线来自已确认的上级设备关系。上级未填写时单独列出;不代表已自动发现所有交换机或布线。
</p>
<NetworkDiagram />
<h3 class="network-details-heading">设备与连接详情</h3>
<div class="topology-nodes">
<article
v-for="row in deviceView.rows"
+2 -2
View File
@@ -4,10 +4,10 @@
<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="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="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>
+3 -2
View File
@@ -30,10 +30,11 @@ import DeviceNetworkPanel from './DeviceNetworkPanel.vue'
</section>
<section id="page-overview" class="page" hidden><div id="scope-overview"></div></section><section id="page-device" class="page" hidden><DeviceNetworkPanel /><div id="device-detail"></div></section><section id="page-heritage" class="page" hidden><div id="heritage-home"></div></section>
<section id="page-live" class="page"><div class="section-head"><h2 id="live-heading">本场所监控墙</h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label></div></div><p id="wall-context" class="description">这里汇总当前场所内已授权的镜头;点击摄像头详情可进入单台设备。</p><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
<section id="page-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>
<aside id="recording-health" class="panel" aria-label="本机录像状态" hidden><div class="section-head"><strong id="recording-health-text" role="status"></strong><button id="recording-configure" class="quiet" hidden>录像设置</button></div><p id="recording-policy-note" class="description"></p><p id="recording-start-note" class="description"></p></aside>
<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>
@@ -42,7 +43,7 @@ import DeviceNetworkPanel from './DeviceNetworkPanel.vue'
<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-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">默认最长保留 30 天,超过设定天数的录像会自动删除。容量上限由全部摄像头共用;达到上限或预留空间不足时暂停录像,空间恢复后继续。实际可留时长取决于码率和路数,未必存满设定天数。容量每 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>
+36
View File
@@ -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) };
}
+1
View File
@@ -4,6 +4,7 @@ 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')
+53
View File
@@ -1872,6 +1872,59 @@ html[data-font="serif"] .scope-intro h2 {
font-family: "Noto Serif SC", "Songti SC", "SimSun", serif !important;
font-weight: 600 !important;
}
.live-help { margin: 10px 0; color: var(--ui-muted); font-size: 13px; }
.live-help summary { cursor: pointer; padding: 8px 0; }
.live-help p { margin: 8px 0; }
#page-live.wall-expanded { position: fixed; inset: 0; z-index: 10000; margin: 0; }
#page-live:is(:fullscreen,.wall-expanded) > .live-help { display: none; }
#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-visible-count="1"] {
grid-template-columns: minmax(0,1fr); grid-template-rows: minmax(0,1fr);
}
.leadership-member-actions .btn { white-space: normal; }
#leadership-grant { align-items: flex-start; padding: 14px; }
#leadership-grant span { line-height: 1.7; }
@media (max-width: 767px) {
#app > main { padding-bottom: max(20px,env(safe-area-inset-bottom)) !important; }
main[data-page="live"] > header { margin-bottom: 12px !important; padding-bottom: 12px !important; }
main[data-page="live"] > header .eyebrow,
main[data-page="live"] #page-note,
main[data-page="live"] > .metrics { display: none !important; }
main[data-page="live"] .household-context { margin-bottom: 12px; flex-wrap: nowrap; }
main[data-page="live"] .household-context label { flex: 1; width: auto; font-size: 0; }
main[data-page="live"] .household-context select { font-size: 16px; width: 100%; }
main[data-page="live"] #scope-back { width: auto; min-width: 82px; }
main[data-page="live"] > .filter-row { margin: 12px 0; }
#page-live:not([hidden]) { display: flex; flex-direction: column; }
#page-live > .section-head { order: 0; margin: 6px 0 12px; }
#page-live > .live-grid { order: 1; }
#page-live > .wall-toolbar { order: 2; }
#page-live > .live-help { order: 3; }
#page-live:not(:fullscreen):not(.wall-expanded) .live-grid[data-layout="auto"] { grid-template-columns: minmax(0,1fr); }
.camera-actions { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); padding: 0 12px 12px; }
.camera-actions .btn { min-height: 44px; white-space: normal; font-size: 13px; }
.camera-meta { padding: 12px; }
.camera-meta strong { font-size: 15px !important; }
.wall-toolbar .wall-paging { flex: 1; justify-content: space-between; }
.wall-toolbar .btn { min-width: 44px; min-height: 44px; }
#page-live:is(:fullscreen,.wall-expanded) { padding: max(8px,env(safe-area-inset-top)) max(8px,env(safe-area-inset-right)) max(8px,env(safe-area-inset-bottom)) max(8px,env(safe-area-inset-left)); }
#page-live:is(:fullscreen,.wall-expanded) > .wall-toolbar { order: 0; }
#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-layout="auto"] { grid-template-columns: repeat(2,minmax(0,1fr)); }
#page-live:is(:fullscreen,.wall-expanded) .live-grid[data-visible-count="1"] { grid-template-columns: minmax(0,1fr); grid-template-rows: minmax(0,1fr); }
#page-live:is(:fullscreen,.wall-expanded) .camera-actions { display: none; }
.network-target { display: grid; grid-template-columns: 76px minmax(0,1fr) auto; font-size: 12px; gap: 8px; }
.network-target code { font-size: 13px !important; }
.network-check-time { font-size: 12px; line-height: 1.7; }
.management-links { display: grid; grid-template-columns: minmax(0,1fr); }
.management-links .btn { min-height: 44px !important; font-size: 13px !important; white-space: normal; }
dialog .description,dialog label { font-size: 14px !important; }
dialog .dialog-footer { position: sticky; bottom: -20px; background: var(--ui-surface); padding: 14px 0; margin-bottom: -10px; }
dialog .dialog-footer .btn { flex: 1; min-height: 44px; white-space: normal; }
.leadership-member-actions .btn { flex: 1; min-width: 120px; }
#leadership-grant { display: flex; gap: 10px; }
#leadership-grant[hidden] { display: none; }
}
html[data-backdrop="mountains"] #app > main {
background-image:
linear-gradient(150deg, transparent 70%, #8ba58f08 70%, transparent 85%),
+18 -4
View File
@@ -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}
+1 -1
View File
@@ -192,7 +192,7 @@ class Accounts:
state['sites'] = [s for s in state['sites'] if s['id'] in permitted]
state['assets'] = [s for s in state['assets'] if s['siteId'] in permitted]
state['devices'] = [s for s in state['devices'] if s['siteId'] in permitted]
fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'archiveSource')
fields = ('id', 'name', 'siteId', 'assetId', 'point', 'enabled', 'ready', 'recordingActive', 'recordingState', 'archiveSource')
state['cameras'] = [{k: c.get(k) for k in fields} for c in state['cameras'] if c['siteId'] in permitted]
state['recorders'] = []
state['scan'] = {}
+1
View File
@@ -32,6 +32,7 @@ def command(source, authorization, duration):
return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
'-threads', '1', '-filter_threads', '1'] + acceleration + [
'-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
'-analyzeduration', '1000000', '-probesize', '1000000',
'-readrate', '1', '-i', source, '-t', str(duration),
'-map', '0:v:0', '-map', '0:a:0?', '-sn', '-dn'] + video + [
'-c:a', 'aac', '-ar', '48000', '-ac', '1', '-b:a', '64k',
+59
View File
@@ -257,6 +257,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(command[command.index('-rc_mode') + 1], 'CQP')
self.assertIn('h264_vaapi', command)
self.assertIn('pipe:1', command)
self.assertEqual(command[command.index('-analyzeduration') + 1], '1000000')
def test_ptz_requires_both_space_and_control_permission(self):
app.save_object('sites', dict(id='permitted', name='Permitted'))
@@ -378,6 +379,64 @@ class WebTests(unittest.TestCase):
app.media_json('/list?path=example', playback=True)
self.assertTrue(error.fp.closed)
def test_first_recording_missing_directory_is_empty_only_for_requested_camera(self):
name = 'cam_' + 'a' * 16 + '_main'
path = '/list?path=' + name
expected = 'lstat ' + str(app.RECORDINGS / name) + ': no such file or directory'
for message, empty in [(expected, True), (expected.replace(name, 'other'), False),
(expected.replace('no such file or directory', 'permission denied'), False),
('invalid start timestamp', False)]:
error = app.urllib.error.HTTPError('http://localhost/list', 400, 'Bad Request', {},
io.BytesIO(json.dumps({'error': message}).encode()))
with self.subTest(message=message), patch('app.urllib.request.urlopen', side_effect=error):
if empty:
self.assertEqual(app.media_json(path, playback=True), [])
else:
with self.assertRaises(app.Problem):
app.media_json(path, playback=True)
self.assertTrue(error.fp.closed)
def test_playback_disables_reverse_proxy_buffering(self):
with patch.object(playback, 'stream', return_value=io.BytesIO(b'test-mp4')):
code, headers, body = self.request('/media/playback?camera='+'a'*16+'&start=2026-01-01T00:00:00Z&duration=10')
self.assertEqual(code, 200)
self.assertEqual(headers['X-Accel-Buffering'], 'no')
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertEqual(body, b'test-mp4')
def test_default_retention_is_30_days_and_keeps_space_guards(self):
self.assertEqual(app.DEFAULT_STORAGE, dict(retentionDays=30, maxGB=40, reserveGB=8))
camera = dict(id='e'*16, enabled=True, record=True)
def setting(key, default=None):
return 'test-secret' if key == 'mediaSecret' else default
with tempfile.TemporaryDirectory() as folder, patch.object(app, 'CONFIG', app.Path(folder)/'media.json'), \
patch.object(app, 'objects', return_value=[camera]), patch.object(app, 'setting', side_effect=setting), \
patch.object(app, 'source', return_value='rtsp://test/stream'), patch.dict(app.STORAGE, paused=False):
app.write_media_config()
config=json.loads(app.CONFIG.read_text())
main=config['paths'][app.stream_name(camera)]
self.assertEqual(main['recordDeleteAfter'], '720h')
self.assertTrue(main['record'])
self.assertFalse(config['paths'][app.stream_name(camera, 'sub')]['record'])
app.STORAGE['paused']=True
app.write_media_config()
self.assertFalse(json.loads(app.CONFIG.read_text())['paths'][app.stream_name(camera)]['record'])
def test_recording_status_requires_recent_writes_and_distinguishes_protection(self):
camera=dict(id='e'*16, enabled=True, record=True)
with patch.dict(app.STORAGE, paused=False), patch.dict(app.RECENT_RECORDS, {}, clear=True):
self.assertEqual(app.recording_state(camera, True, True), 'waiting')
app.RECENT_RECORDS[app.stream_name(camera)]=time.time()
self.assertEqual(app.recording_state(camera, True, True), 'recording')
self.assertEqual(app.recording_state(dict(camera,record=False), True, True), 'off')
self.assertEqual(app.recording_state(dict(camera,enabled=False), True, True), 'disabled')
self.assertEqual(app.recording_state(camera, False, True), 'offline')
self.assertEqual(app.recording_state(camera, True, False), 'unavailable')
app.RECENT_RECORDS[app.stream_name(camera)]=time.time()-60
self.assertEqual(app.recording_state(camera, True, True), 'waiting')
app.STORAGE['paused']=True
self.assertEqual(app.recording_state(camera, True, True), 'paused')
def family_save(self, kind, data):
return app.FAMILY.save(kind, dict(data, revision=app.setting('familyRevision', 0)), self.admin)
+22
View File
@@ -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))
+33
View File
@@ -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\(\)/);
});
+22
View File
@@ -4,6 +4,15 @@ test('ranges clip to selected day and merge overlaps without filling gaps',()=>{
const items=[{start:date(-10),duration:40},{start:date(20),duration:30},{start:date(60),duration:100},{start:'invalid',duration:20},{start:date(3),duration:-4},{start:date(3),duration:Infinity}];
assert.deepEqual(Ranges.normalize(items,date(0),date(100)),[{start:+date(0),end:+date(50)},{start:+date(60),end:+date(100)}]);
});
test('microsecond recording starts round inward and never seek into the preceding gap',()=>{
const raw='2026-10-04T00:00:00.340240Z';
const ranges=Ranges.normalize([{start:raw,duration:41.1355}],date(0),date(100));
assert.equal(new Date(ranges[0].start).toISOString(),'2026-10-04T00:00:00.341Z');
assert.equal(new Date(ranges[0].end).toISOString(),'2026-10-04T00:00:41.475Z');
const exact=Ranges.normalize([{start:'2026-10-04T00:00:00.340000Z',duration:10}],date(0),date(100));
assert.equal(exact[0].start,Date.parse(raw));
assert.deepEqual(Ranges.normalize([{start:raw,duration:0.0001}],date(0),date(1)),[]);
});
test('long recording continues after each one-hour session',()=>{
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(3600)});
assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null);
@@ -20,6 +29,19 @@ test('clip requires valid, positive, fully recorded interval up to one hour',()=
function section(file,a,b){const s=fs.readFileSync('web/'+file,'utf8'),i=s.indexOf(a),j=s.indexOf(b,i+a.length);assert.ok(i>=0&&j>i);return s.slice(i,j);}
function deferred(){let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};}
function ui(extra={}){const elements=new Map();const $=s=>{if(!elements.has(s))elements.set(s,{replaceChildren(){},reset(){},value:'',hidden:false});return elements.get(s);};return vm.createContext({$,Number,Date,URLSearchParams,loggedIn:true,activePage:'playback',RecordingRanges:Ranges,...extra});}
test('recording health distinguishes configured from writing and never changes the player',()=>{
const c=ui({state:{cameras:[{id:'camera',recordingState:'off'}],storage:{retentionDays:30,maxGB:40,reserveGB:8}},isAdmin:()=>true});
c.$('#play-camera').value='camera';c.$('#archive-source').value='local';c.$('#recording-configure').dataset={};
c.$('#play-video').src='keep-playing';
vm.runInContext(section('recording-ui.js','function renderRecordingStatus(','function localDateTime('),c);
for(const [status,text] of [['off','未开启'],['waiting','等待文件写入'],['recording','正在录像'],['paused','磁盘保护'],['offline','等待摄像头'],['unavailable','服务未连接'],['disabled','设备连接已停用']]){
c.state.cameras[0].recordingState=status;c.renderRecordingStatus();assert.ok(c.$('#recording-health-text').textContent.includes(text));
}
assert.match(c.$('#recording-policy-note').textContent,/30 天/);assert.equal(c.$('#recording-configure').dataset.editCamera,'camera');
assert.equal(c.$('#play-video').src,'keep-playing');
c.isAdmin=()=>false;c.state.storage={};c.renderRecordingStatus();assert.equal(c.$('#recording-configure').hidden,true);assert.doesNotMatch(c.$('#recording-policy-note').textContent,/undefined|40 GB/);
c.$('#archive-source').value='recorder';c.renderRecordingStatus();assert.equal(c.$('#recording-health').hidden,true);
});
test('auto-continue preserves user choice, stops short clips and ends at last segment',()=>{
const c=ui({playbackRange:{start:0,end:10000},spans:[{start:0,end:10000},{start:20000,end:30000}],playAt:d=>c.played=+d});
vm.runInContext(section('recording-ui.js','function continuePlayback(','function makeClip('),c);
+22
View File
@@ -21,6 +21,22 @@ test('Vue theme builds locally after Bootstrap without remote fonts', () => {
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 [
[token('ink'), token('paper')], [token('muted'), token('paper')],
@@ -58,3 +74,9 @@ test('recording timeline paints with theme tokens and keeps range geometry', ()
]);
assert.equal(ctx.strokeStyle, '#d3ccbc');
});
test('mobile playback filters keep full-width readable controls',()=>{
const css=fs.readFileSync('frontend/src/bootstrap-polish.css','utf8');
assert.match(css,/#page-playback \.playback-controls \{ display: grid; grid-template-columns: minmax\(0, 1fr\)/);
assert.match(css,/#page-playback \.playback-controls input, #page-playback \.playback-controls select \{ width: 100%/);
});
+19
View File
@@ -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,[]);
});
+2
View File
@@ -71,6 +71,7 @@ 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','device'].includes(page);
$('.filter-row').hidden=!['objects','sites','live','playback','cameras'].includes(page)||!!deviceContext;
@@ -106,6 +107,7 @@ function render() {
if (activePage === 'recorders') renderRecorders();
if (activePage === 'playback') {
setOptions($('#play-camera'), filtered().map(c=>`<option value="${c.id}">${esc(siteName(c.siteId)+' / '+c.name)}</option>`).join(''));
renderRecordingStatus();
drawTimeline();
}
if (activePage === 'storage') renderStorage();
+3 -2
View File
@@ -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();};
+22 -3
View File
@@ -66,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=>{
+21 -7
View File
@@ -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;
+7 -1
View File
@@ -1,9 +1,15 @@
'use strict';
// Pure range logic shared by the UI and regression tests. All ranges are [start,end).
const RecordingRanges=(()=>{
// Date truncates fractional milliseconds. Round the query start inward so
// MediaMTX never receives a time before this segment's microsecond timestamp.
function firstMillisecond(value){
const time=+new Date(value),fraction=typeof value==='string'&&value.match(/\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/);
return time+(fraction&&/[1-9]/.test(fraction[1].slice(3))?1:0);
}
function normalize(items,start,end){
const lo=+start,hi=+end;if(!Number.isFinite(lo)||!Number.isFinite(hi)||hi<=lo)return [];
const ranges=(Array.isArray(items)?items:[]).filter(s=>s&&Number.isFinite(+new Date(s.start))&&Number.isFinite(Number(s.duration))&&Number(s.duration)>0).map(s=>({start:Math.max(lo,+new Date(s.start)),end:Math.min(hi,+new Date(s.start)+Number(s.duration)*1000)}))
const ranges=(Array.isArray(items)?items:[]).filter(s=>s&&Number.isFinite(+new Date(s.start))&&Number.isFinite(Number(s.duration))&&Number(s.duration)>0).map(s=>({start:Math.max(lo,firstMillisecond(s.start)),end:Math.min(hi,Math.floor(+new Date(s.start)+Number(s.duration)*1000))}))
.filter(s=>Number.isFinite(s.start)&&Number.isFinite(s.end)&&s.end>s.start).sort((a,b)=>a.start-b.start);
const result=[];for(const r of ranges){const last=result.at(-1);if(last&&r.start<=last.end)last.end=Math.max(last.end,r.end);else result.push({...r});}return result;
}
+13 -2
View File
@@ -1,6 +1,17 @@
'use strict';
let playbackRange=null;
function playbackSourceHint(){if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
function playbackSourceHint(){renderRecordingStatus();if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
function renderRecordingStatus(){
const camera=state.cameras.find(c=>c.id===$('#play-camera').value),box=$('#recording-health');
box.hidden=!camera||$('#archive-source').value==='recorder';if(box.hidden)return;
const messages={recording:'正在录像 · 主码流持续保存',off:'未开启本机录像 · 实时画面接通不代表已保存录像',disabled:'录像已暂停 · 设备连接已停用',paused:'录像已暂停 · 已触发容量或磁盘保护',unavailable:'录像服务未连接 · 请联系管理员检查服务',offline:'等待摄像头接通 · 当前没有新录像写入',waiting:'已开启录像 · 等待文件写入,请稍候;若持续等待请检查存储'};
$('#recording-health-text').textContent=messages[camera.recordingState]||'正在读取录像状态';
const policy=state.storage||{};
$('#recording-policy-note').textContent=policy.retentionDays?`最长保留 ${policy.retentionDays} 天,过期自动清理;全平台录像上限 ${policy.maxGB} GB,磁盘预留 ${policy.reserveGB} GB。容量不足会提前暂停,实际可留时长取决于码率和路数。`:'录像由服务器按保留策略自动管理。';
$('#recording-start-note').textContent='本机只保存开启录像期间的画面。新录像按 5 分钟分段保存,回放查询可能稍有延迟。';
const button=$('#recording-configure');button.hidden=!isAdmin();button.dataset.editCamera=camera.id;
}
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
function resetPlayback(){
@@ -51,7 +62,7 @@ function makeClip(){
}catch(e){$('#clip-status').textContent=e.message;}
}
$('#query-recordings').onclick=queryRecordings;
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';};$('#archive-source').onchange=()=>{resetPlayback();playbackSourceHint();};$('#play-date').onchange=resetPlayback;
$('#play-camera').onchange=()=>{resetPlayback();const c=state.cameras.find(c=>c.id===$('#play-camera').value);$('#archive-source').value=c?.archiveSource||'local';playbackSourceHint();};$('#archive-source').onchange=()=>{resetPlayback();playbackSourceHint();};$('#play-date').onchange=resetPlayback;
$('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');return;}playAt(new Date($('#play-date').value+'T'+$('#seek-time').value));};
$('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
+3 -3
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-YWYfmYO_.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-DxbM73EL.css">
<!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-CWMAn71z.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-B5ZdIQcb.css">
</head><body><div id="app"></div></body></html>