Compare commits

...

7 Commits

39 changed files with 854 additions and 52 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.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 实现。
+10
View File
@@ -0,0 +1,10 @@
# v0.1.40 · 家谱画布稳定性与连续回放修复
- 修复全屏家谱因小数像素取整而反复产生滚动条、重算居中的抖动。
- 合并画布尺寸通知,尺寸不变时不重复适配;手动缩放与拖动位置保持。
- 修复浏览器不支持原生全屏时,兼容全屏画布被侧栏遮挡的问题。
- 保留手机端家谱画布及人物档案的独立滚动区域。
- 修复录像回放以直播速度读取历史 HTTP 文件,造成长片段上游写入超时而提前截断的问题;保留转换并发、线程和生命周期上限。
- 新增画布小数像素、尺寸通知合并、手动视角、隐藏和重置回归测试。
录像保留策略仍以当前平台设置为准:30 天为最长保留时间;录像容量上限和磁盘预留可提前触发暂停,不能据此保证存满 30 天。
+10
View File
@@ -0,0 +1,10 @@
# v0.1.41 · 录像按需缓冲与连续回放
- 修复浏览器减慢读取时,长录像源 HTTP 连接超时而提前结束的问题。
- 回放以最多 5 分钟为一段,沿用自动续播和真实录像空档规则;原始片段下载仍可选择最多一小时。
- 每段录像在服务器私有临时目录缓冲,转换结束、失败或离开页面后清理;最多两个转换、每份缓存最大 256 MiB,受磁盘预留空间及下载时限保护。
- 转换进程仅访问临时文件,按播放速度读取并限制线程,不携带录像服务认证信息。
- 长时间暂停后从当前播放位置重新接入,无需刷新整个页面。
- 保留 v0.1.40 家谱全屏抖动、尺寸通知循环与侧栏遮挡修复。
30 天为录像最长保留时间。实际可保留时长仍受总录像容量上限、磁盘预留、码率和路数限制。
+5
View File
@@ -0,0 +1,5 @@
# v0.1.42 · 回放预缓冲调整
- 允许有限录像片段提前完成转换,减少逐秒限速造成的播放器缓冲等待。
- 继续使用最多 5 分钟的分段播放、自动续播、两路转换并发、单线程处理及有界临时缓存。
- 保留家谱全屏稳定性修复、30 天录像保留上限、磁盘容量保护和长暂停后的原位置恢复。
+1 -1
View File
@@ -1 +1 @@
0.1.36
0.1.42
+36 -9
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)
@@ -860,13 +885,15 @@ class Handler(BaseHTTPRequestHandler):
started = False
old_timeout = self.connection.gettimeout()
try:
with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration) as output:
reserve_bytes = int(setting('storage', DEFAULT_STORAGE).get('reserveGB', 8) * 1024**3)
with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration, reserve_bytes=reserve_bytes) as output:
first = output.read1(65536)
if not first:
raise Problem('录像转换失败,请检查录像是否完整及服务器视频驱动', 503)
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 +1134,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.42",
"type": "module",
"scripts": {
"build": "vite build",
+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.36';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.42';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"
+1 -1
View File
@@ -4,7 +4,7 @@
<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><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>
+2 -1
View File
@@ -34,6 +34,7 @@ import DeviceNetworkPanel from './DeviceNetworkPanel.vue'
<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')
+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'] = {}
+68 -7
View File
@@ -1,17 +1,60 @@
"""Bounded, ephemeral H.264 playback; original recordings remain untouched."""
from contextlib import contextmanager
import http.client
import os
import shutil
import subprocess
import threading
import tempfile
import time
import urllib.request
from pathlib import Path
SLOTS = threading.BoundedSemaphore(2)
MAX_SOURCE_BYTES = 256 * 1024 * 1024
MAX_SESSION_SECONDS = 300
class PlaybackError(Exception):
pass
@contextmanager
def cached_source(source, authorization, reserve_bytes):
# A private, bounded scratch file decouples MediaMTX's HTTP write timeout
# from browser buffering and pause. Use the data disk, not a small /tmp RAM disk.
base = Path(os.environ.get('VISION_DATA', tempfile.gettempdir())) / 'playback-tmp'
base.mkdir(mode=0o700, parents=True, exist_ok=True)
for old in base.glob('buffer-*'):
try:
if old.is_symlink() or not old.is_dir() or time.time() - old.stat().st_mtime < 3600:
continue
(old / 'source.mp4').unlink(missing_ok=True)
old.rmdir()
except OSError:
pass
with tempfile.TemporaryDirectory(prefix='buffer-', dir=base) as folder:
if shutil.disk_usage(folder).free < reserve_bytes + MAX_SOURCE_BYTES:
raise PlaybackError('磁盘剩余空间不足以缓冲回放,请先释放空间')
request = urllib.request.Request(source, headers={'Authorization': authorization})
target = Path(folder) / 'source.mp4'
deadline = time.monotonic() + 30
size = 0
with urllib.request.urlopen(request, timeout=10) as response, target.open('wb') as output:
while chunk := response.read(256 * 1024):
size += len(chunk)
if size > MAX_SOURCE_BYTES:
raise PlaybackError('该录像片段过大,请缩短回放时段')
if time.monotonic() > deadline:
raise PlaybackError('回放缓冲超时,请稍后重试')
if shutil.disk_usage(folder).free < reserve_bytes + len(chunk):
raise PlaybackError('磁盘预留空间不足,已停止缓冲回放')
output.write(chunk)
if not size:
raise PlaybackError('该时段没有可读取的录像')
yield str(target)
def command(source, authorization, duration):
executable = shutil.which('ffmpeg')
if not executable:
@@ -29,10 +72,14 @@ def command(source, authorization, duration):
'-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast',
'-tune', 'zerolatency', '-profile:v', 'baseline', '-pix_fmt', 'yuv420p',
'-crf', '26', '-g', '20', '-bf', '0']
input_options = (['-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n']
if authorization else [])
return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
'-threads', '1', '-filter_threads', '1'] + acceleration + [
'-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
'-readrate', '1', '-i', source, '-t', str(duration),
'-threads', '1', '-filter_threads', '1'] + acceleration + input_options + [
'-analyzeduration', '1000000', '-probesize', '1000000',
# Recordings are finite HTTP downloads. Pacing their input like a
# live source stalls upstream writes and truncates long responses.
'-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',
'-movflags', '+frag_keyframe+empty_moov+default_base_moof',
@@ -40,20 +87,34 @@ def command(source, authorization, duration):
@contextmanager
def stream(source, authorization, duration):
def stream(source, authorization, duration, reserve_bytes=8 * 1024**3):
if not 0 < duration <= MAX_SESSION_SECONDS:
raise PlaybackError('请刷新页面,使用每段最多 5 分钟的自动连续回放')
if not SLOTS.acquire(blocking=False):
raise PlaybackError('已有两个回放正在进行,请关闭其他回放后重试')
process = timer = None
try:
# No shell and no diagnostics containing authentication headers in logs.
process = subprocess.Popen(command(source, authorization, duration),
if not shutil.which('ffmpeg'):
raise PlaybackError('回放转换需要服务器安装 FFmpeg')
with cached_source(source, authorization, reserve_bytes) as local_source:
# Credentials never appear in the converter's process arguments.
process = subprocess.Popen(command(local_source, '', duration),
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
timer = threading.Timer(duration + 45, process.kill)
timer.daemon = True
timer.start()
try:
yield process.stdout
except OSError as exc:
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
except (OSError, http.client.HTTPException) as exc:
raise PlaybackError('录像转换服务暂不可用') from exc
finally:
if timer:
+61
View File
@@ -257,6 +257,9 @@ 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')
self.assertNotIn('-readrate', command)
self.assertNotIn('-re', command)
def test_ptz_requires_both_space_and_control_permission(self):
app.save_object('sites', dict(id='permitted', name='Permitted'))
@@ -378,6 +381,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)
+37
View File
@@ -0,0 +1,37 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
function fixture(){
const space={style:{}},surface={style:{}},label={},frames=new Map();let next=0,observer;
const viewport={clientWidth:1550,clientHeight:601,rect:{width:1550,height:600.5234375},scrollLeft:0,scrollTop:0,
addEventListener(){},getBoundingClientRect(){return this.rect;},
querySelector(s){return s==='.family-map-space'?space:surface;},replaceChildren(){},
scrollTo(x,y){this.scrollLeft=x;this.scrollTop=y;}};
const scope=vm.createContext({$:()=>label,ResizeObserver:class{constructor(fn){observer=fn;}observe(){}},
requestAnimationFrame(fn){frames.set(++next,fn);return next;},cancelAnimationFrame(id){frames.delete(id);}});
vm.runInContext(fs.readFileSync('web/family-map.js','utf8')+';this.MapClass=FamilyMap',scope);
const map=new scope.MapClass(viewport,()=>{});map.graph={width:400,height:264,nodes:[]};
return {map,viewport,space,surface,frames,resize:()=>observer(),flush(){const jobs=[...frames.values()];frames.clear();jobs.forEach(fn=>fn());}};
}
test('fractional fullscreen size never creates a too-tall scroll surface',()=>{
const f=fixture();for(let i=0;i<20;i++){f.map.fit();assert.equal(f.space.style.height,'600px');assert.ok(parseFloat(f.space.style.height)<=f.viewport.rect.height);}
assert.equal(f.surface.style.transform,'translate(575px,168px) scale(1)');
});
test('resize notifications coalesce and unchanged dimensions do not refit',()=>{
const f=fixture();let calls=0;const fit=f.map.fit.bind(f.map);f.map.fit=()=>{calls++;fit();};
f.resize();f.resize();assert.equal(f.frames.size,1);f.flush();assert.equal(calls,1);
f.resize();f.flush();assert.equal(calls,1);
f.viewport.rect.height=700.7;f.viewport.clientHeight=701;f.resize();f.flush();assert.equal(calls,2);
});
test('manual zoom and scroll survive a resize notification',()=>{
const f=fixture();f.map.autoFit=false;f.map.scale=1.5;f.viewport.scrollLeft=15;f.viewport.scrollTop=30;
f.resize();f.flush();assert.equal(f.map.scale,1.5);assert.equal(f.map.autoFit,false);
assert.equal(f.viewport.scrollLeft,15);assert.equal(f.viewport.scrollTop,30);
});
test('hidden canvas does not resize and pending work is cancelled on reset',()=>{
const f=fixture();f.viewport.clientWidth=0;f.resize();f.flush();assert.equal(f.space.style.width,undefined);
f.resize();f.map.reset();assert.equal(f.frames.size,0);assert.equal(f.map.graph,null);
});
test('scaled map clips unscaled overflow and fullscreen fallback clears the sidebar',()=>{
const css=fs.readFileSync('web/family-map.css','utf8');
assert.match(css,/\.family-map-space\{min-width:0;min-height:0;overflow:clip\}/);
assert.match(css,/scrollbar-gutter:stable/);assert.match(css,/\.family-workspace\.family-expanded\{z-index:1100\}/);
});
+74
View File
@@ -0,0 +1,74 @@
import io
import os
import time
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import playback
class PlaybackCacheTests(unittest.TestCase):
def test_private_cache_removed_after_use_and_abort(self):
for abort in (False, True):
with tempfile.TemporaryDirectory() as folder:
with patch.dict('os.environ', {'VISION_DATA':folder}), patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=10**12)), patch('playback.urllib.request.urlopen', return_value=io.BytesIO(b'video')) as opening:
try:
with playback.cached_source('http://127.0.0.1/source', 'Basic private', 8) as path:
self.assertEqual(Path(path).read_bytes(), b'video')
self.assertEqual(opening.call_args.args[0].get_header('Authorization'), 'Basic private')
if abort:
raise RuntimeError('browser left')
except RuntimeError:
self.assertTrue(abort)
self.assertEqual(list((Path(folder)/'playback-tmp').iterdir()), [])
def test_oversize_and_empty_sources_leave_no_cache(self):
for contents in (b'', b'123456789'):
with tempfile.TemporaryDirectory() as folder:
with patch.dict('os.environ', {'VISION_DATA':folder}), patch('playback.MAX_SOURCE_BYTES', 8), patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=1000)), patch('playback.urllib.request.urlopen', return_value=io.BytesIO(contents)):
with self.assertRaises(playback.PlaybackError):
with playback.cached_source('http://127.0.0.1/source', 'private', 8):
self.fail('invalid cache accepted')
self.assertEqual(list((Path(folder)/'playback-tmp').iterdir()), [])
def test_disk_reserve_checked_before_download(self):
with patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=1)), patch('playback.urllib.request.urlopen') as opening:
with self.assertRaises(playback.PlaybackError):
with playback.cached_source('unused', 'private', 8):
self.fail('low space accepted')
opening.assert_not_called()
def test_stale_buffers_are_removed_but_current_and_unrelated_files_remain(self):
with tempfile.TemporaryDirectory() as folder:
base=Path(folder)/'playback-tmp';base.mkdir()
old=base/'buffer-old';old.mkdir();(old/'source.mp4').write_bytes(b'old')
os.utime(old,(time.time()-7200,time.time()-7200))
fresh=base/'buffer-active';fresh.mkdir();(fresh/'source.mp4').write_bytes(b'active')
unrelated=base/'keep.txt';unrelated.write_text('keep')
with patch.dict('os.environ',{'VISION_DATA':folder}), patch('playback.shutil.disk_usage',return_value=SimpleNamespace(free=10**12)), patch('playback.urllib.request.urlopen',return_value=io.BytesIO(b'video')):
with playback.cached_source('http://127.0.0.1/source','private',8):
self.assertFalse(old.exists())
self.assertEqual((fresh/'source.mp4').read_bytes(),b'active')
self.assertEqual(unrelated.read_text(),'keep')
def test_local_converter_can_buffer_ahead_and_has_no_credentials(self):
with patch('playback.shutil.which', return_value='ffmpeg'), patch('playback.os.access', return_value=False):
cmd=playback.command('/tmp/private/source.mp4', '', 300)
self.assertNotIn('-readrate', cmd)
self.assertNotIn('-headers', cmd)
self.assertIn('libx264', cmd)
self.assertEqual(cmd[cmd.index('-threads')+1], '1')
def test_sessions_are_bounded_before_consuming_a_slot(self):
for duration in (0, 301, float('nan')):
with self.assertRaises(playback.PlaybackError):
with playback.stream('unused', 'private', duration):
self.fail('unbounded playback accepted')
self.assertTrue(playback.SLOTS.acquire(blocking=False))
playback.SLOTS.release()
if __name__ == '__main__':
unittest.main()
+33 -3
View File
@@ -4,9 +4,18 @@ 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('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);
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 in bounded five-minute sessions',()=>{
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(300)});
assert.deepEqual(Ranges.next(ranges,date(300)),{start:+date(300),end:+date(600)});assert.equal(Ranges.next(ranges,date(8000)),null);
});
test('next segment skips the actual gap and does not replay the previous end',()=>{
const ranges=[{start:+date(0),end:+date(10)},{start:+date(30),end:+date(40)}];assert.equal(Ranges.at(ranges,date(10)),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);
@@ -34,6 +56,14 @@ test('late recording list cannot replace another camera or a logged-out view',as
vm.runInContext(section('recording-ui.js','async function queryRecordings(','function drawTimeline('),c);
const work=c.queryRecordings();c.playbackGeneration++;p.resolve({items:[]});await work;assert.equal(c.spans,undefined);
});
test('resuming a long pause reopens the recorded position without a page refresh',()=>{
const c=ui({playbackRange:{start:10000,end:300000},playbackPausedAt:Date.now()-20000,playAt:(date,preserve)=>c.played={time:+date,preserve}});
c.$('#play-video').currentTime=45;
vm.runInContext(section('recording-ui.js','function pausePlayback(','function makeClip('),c);
c.resumePlayback();assert.deepEqual(c.played,{time:55000,preserve:true});assert.equal(c.playbackPausedAt,0);
c.played=null;c.playbackPausedAt=Date.now();c.resumePlayback();assert.equal(c.played,null);
c.playbackPausedAt=Date.now()-20000;c.loggedIn=false;c.resumePlayback();assert.equal(c.played,null);
});
test('download generation refuses gaps and invalidates old links first',()=>{
const c=ui({queryCamera:'camera',spans:[{start:+date(0),end:+date(60)}],playbackParams:()=>new URLSearchParams({start:'test'}),Error});
vm.runInContext(section('recording-ui.js','function makeClip(',"$('#query-recordings')"),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,[]);
});
+1
View File
@@ -107,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();
+12
View File
@@ -7,6 +7,18 @@
.family-workspace.family-expanded{position:fixed;inset:0;z-index:8;}
/* Keep scaled descendants and fractional fullscreen sizes out of the
scrollable overflow calculation. Reserve one gutter instead of alternating. */
.family-map-viewport{scrollbar-gutter:stable;overflow-anchor:none}
.family-map-space{min-width:0;min-height:0;overflow:clip}
.family-workspace:is(:fullscreen,.family-expanded){min-height:0;grid-template-rows:minmax(0,1fr)}
.family-workspace:is(:fullscreen,.family-expanded) .family-canvas-panel{min-height:0}
.family-workspace:is(:fullscreen,.family-expanded) .family-map-heading,
.family-workspace:is(:fullscreen,.family-expanded) .family-map-controls,
.family-workspace:is(:fullscreen,.family-expanded) .family-map-footer{flex-shrink:0}
.family-workspace.family-expanded{z-index:1100}
@media(max-width:1000px){.family-workspace:is(:fullscreen,.family-expanded) .family-canvas-panel{min-height:65vh}}
/* Versions remain readable on a small screen, including long family narratives. */
#history-dialog{width:min(1040px,94vw)}
#history-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;max-height:260px;overflow:auto;margin:16px 0}
+21 -6
View File
@@ -10,9 +10,24 @@ class FamilyMap {
viewport.addEventListener('pointermove',e=>{if(!this.drag)return;viewport.scrollLeft=this.drag.left+this.drag.x-e.clientX;viewport.scrollTop=this.drag.top+this.drag.y-e.clientY;});
const release=()=>{this.drag=null;viewport.classList.remove('panning');};
viewport.addEventListener('pointerup',release);viewport.addEventListener('pointercancel',release);viewport.addEventListener('lostpointercapture',release);
this.resize=new ResizeObserver(()=>{if(this.autoFit)this.fit();else this.applyScale();});this.resize.observe(viewport);
this.resizeFrame=0;this.resizeSize='';
this.resize=new ResizeObserver(()=>this.scheduleResize());this.resize.observe(viewport,{box:'border-box'});
}
reset(){cancelAnimationFrame(this.resizeFrame);this.resizeFrame=0;this.resizeSize='';this.key='';this.data=null;this.graph=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
viewportSize(){
const v=this.viewport,r=v.getBoundingClientRect();
// clientHeight rounds fractional pixels up; using it as a child height can
// create a scrollbar, remove it on the next fit, and repeat indefinitely.
return {width:Math.floor(Math.min(v.clientWidth,r.width)),height:Math.floor(Math.min(v.clientHeight,r.height))};
}
scheduleResize(){
if(this.resizeFrame)return;
this.resizeFrame=requestAnimationFrame(()=>{
this.resizeFrame=0;const {width,height}=this.viewportSize(),key=width+':'+height;
if(!width||!height||key===this.resizeSize)return;
this.resizeSize=key;if(this.autoFit)this.fit();else this.applyScale();
});
}
reset(){this.key='';this.data=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
render(data) {
this.data=data;
const key=JSON.stringify([data.people,data.links,data.showArchived,data.selfId]);
@@ -46,16 +61,16 @@ class FamilyMap {
}
applyScale() {
const v=this.viewport,g=this.graph,space=v.querySelector('.family-map-space'),surface=v.querySelector('.family-map-surface');
if(!g||!space||!v.clientWidth)return;
const w=Math.max(v.clientWidth,g.width*this.scale+32),h=Math.max(v.clientHeight,g.height*this.scale+32);
const {width,height}=this.viewportSize();if(!g||!space||!width||!height)return;
const w=Math.max(width,Math.ceil(g.width*this.scale+32)),h=Math.max(height,Math.ceil(g.height*this.scale+32));
space.style.width=w+'px';space.style.height=h+'px';
this.left=(w-g.width*this.scale)/2;this.top=(h-g.height*this.scale)/2;
surface.style.transform=`translate(${this.left}px,${this.top}px) scale(${this.scale})`;
$('#family-zoom-value').textContent=Math.round(this.scale*100)+'%';
}
fit() {
const v=this.viewport,g=this.graph;if(!g||!v.clientWidth||!v.clientHeight)return;
this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(v.clientWidth-40)/g.width,(v.clientHeight-40)/g.height));
const v=this.viewport,g=this.graph,{width,height}=this.viewportSize();if(!g||!width||!height)return;
this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(width-40)/g.width,(height-40)/g.height));
this.applyScale();v.scrollTo(0,0);
}
zoom(factor) {
+8 -2
View File
@@ -1,13 +1,19 @@
'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;
}
function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+3600000)}:null;}
function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+300000)}:null;}
function next(ranges,end){const s=ranges.find(s=>s.end>+end);return s?at(ranges,Math.max(s.start,+end)):null;}
function clip(ranges,start,end){
const a=+start,b=+end;
+27 -6
View File
@@ -1,10 +1,21 @@
'use strict';
let playbackRange=null;
function playbackSourceHint(){if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
let playbackRange=null,playbackPausedAt=0;
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(){
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
playbackGeneration++;playbackRange=null;playbackPausedAt=0;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
$('#play-empty').hidden=false;$('#download').hidden=true;$('#clip-download').hidden=true;$('#clip-status').textContent='';$('#clip-form').reset();
spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';$('#play-position').textContent='';drawTimeline();$('#recording-list').innerHTML='';
}
@@ -31,8 +42,8 @@ function drawTimeline(){
function playbackParams(range){return new URLSearchParams({camera:queryCamera,start:new Date(range.start).toISOString(),duration:String((range.end-range.start)/1000)});}
function playAt(date,preserveClip=false){
const range=RecordingRanges.at(spans,date);if(!range){toast('这个时间没有录像,请选择绿色时段');return;}
playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{});
$('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每次最多一小时,结束后按续播选项处理';$('#play-empty').hidden=true;
playbackPausedAt=0;playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{});
$('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每段最多 5 分钟,按续播选项连续播放';$('#play-empty').hidden=true;
$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;
if(!preserveClip){$('#clip-start').value=localDateTime(range.start);$('#clip-end').value=localDateTime(range.end);$('#clip-download').hidden=true;$('#clip-status').textContent='可修改起止时间后生成下载链接';}
}
@@ -44,6 +55,15 @@ function continuePlayback(){
if(!next){$('#play-info').textContent='所选日期的已查询录像播放完毕';return;}
playAt(new Date(next.start),true);if(next.start>end)$('#play-info').textContent=`已跳过无录像时段,续播 ${new Date(next.start).toLocaleString()}`;
}
function pausePlayback(){
const v=$('#play-video');if(playbackRange&&!v.ended)playbackPausedAt=Date.now();
}
function resumePlayback(){
const elapsed=playbackPausedAt?Date.now()-playbackPausedAt:0;playbackPausedAt=0;
if(elapsed<12000||!loggedIn||activePage!=='playback'||!playbackRange)return;
const time=playbackRange.start+$('#play-video').currentTime*1000;
if(time<playbackRange.end)playAt(new Date(time),true);
}
function makeClip(){
$('#clip-download').hidden=true;
try{if(!queryCamera)throw Error('请先查询录像');const range=RecordingRanges.clip(spans,new Date($('#clip-start').value),new Date($('#clip-end').value));
@@ -51,11 +71,12 @@ 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='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};$('#play-video').onended=continuePlayback;
$('#play-video').onpause=pausePlayback;$('#play-video').onplay=resumePlayback;
$('#play-video').ontimeupdate=()=>{if(playbackRange)$('#play-position').textContent='录像时间 '+new Date(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000)).toLocaleString();};
$('#clip-form').onsubmit=e=>{e.preventDefault();makeClip();};
for(const id of ['clip-start','clip-end'])$('#'+id).oninput=()=>{$('#clip-download').hidden=true;$('#clip-status').textContent='起止时间已修改,请重新生成链接';};
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-BbCjyE-W.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-r3VkLczt.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-CSPlB4s9.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-B2d9n-y0.css">
</head><body><div id="app"></div></body></html>