docs: add settings tab to UI prototype

This commit is contained in:
ila
2026-07-20 22:50:57 +08:00
parent 718ed6e2ed
commit 46d021eba3
9 changed files with 756 additions and 20 deletions
+6 -2
View File
@@ -2,9 +2,9 @@
> **For agentic workers:** REQUIRED SKILL: Use `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create one offline HTML prototype that lets a customer switch between normal monitoring, confirmed-fall, and connection-error views without accessing a camera or model.
**Goal:** Create one offline HTML prototype with top-level monitoring and settings Tabs, while retaining the three monitoring demonstration states without accessing a camera or model.
**Architecture:** One self-contained HTML document holds semantic layout, CSS tokens, inline SVG scene, and a small state controller. The controller changes visible copy, status semantics, scene treatment, event evidence, and an idempotent demonstration dialog; it does not fetch data, access files, or claim to perform inference.
**Architecture:** One self-contained HTML document holds a semantic top Tab bar, monitoring and settings panels, CSS tokens, inline SVG scene, and a small controller. The controller changes visible copy, active Tab, status semantics, scene treatment, event evidence, configuration draft state, and an idempotent demonstration dialog; it does not fetch data, access files, or claim to perform inference.
**Tech Stack:** HTML5, CSS custom properties, inline SVG, vanilla JavaScript, Node.js 22 static syntax checks.
@@ -96,6 +96,10 @@ Expected: no whitespace errors and a commit containing only the prototype docume
## Self-review
## Tab revision
The revised prototype has a horizontal tablist with monitoring and settings tabs. The settings panel shows only an environment-variable readiness state for the source, read-only model metadata, editable non-sensitive event and reconnect fields, a current running configuration version, a saved draft version, and save/reset controls. Editing marks the draft dirty; saving says the draft takes effect on the next monitoring start and cannot change the monitoring panel's running configuration version.
- Spec coverage: Task 1 implements all specified views, dialog behavior, responsive breakpoints, keyboard paths, state text, offline limitation, and validation conditions.
- Placeholder scan: no unresolved placeholder markers or file paths appear in this plan.
- Consistency: the three controller state names are consistently `normal`, `confirmed`, and `offline`; the dialog and acknowledgement element IDs are fixed across layout and behavior.
+18 -7
View File
@@ -7,20 +7,19 @@
- 目标框架:Windows 上的 PyQt5;HTML 仅作离线交互视觉原型,不替代生产 UI。
- 默认窗口:1280 × 800 epx;支持最小 960 × 640、Windows Snap 宽度和 125%/150% 缩放。
- 语言:简体中文;数据均为虚构演示数据,不包含 RTSP 地址、账号、客户图像或真实事件。
- 范围:单路摄像头、实时监控、确认摔倒告警、连接异常提示和最近事件。
- 不在范围:真实 RTSP、模型推理、音频播放、文件系统写入、设置编辑、云端通知及历史事件库。
- 范围:单路摄像头、实时监控、确认摔倒告警、连接异常提示、最近事件和非敏感设置的交互原型。
- 不在范围:真实 RTSP、模型推理、音频播放、文件系统写入、真实配置持久化、云端通知及历史事件库。
## 信息架构与窗口模型
使用一个主窗口和一个只在首次确认事件出现时显示的警报弹窗。主窗口不使用多级导航:客户演示的主要任务是观看实时画面并理解事件证据,设置与历史管理不是此轮原型的重点。
使用一个主窗口和一个只在首次确认事件出现时显示的警报弹窗。主窗口以顶部水平双 Tab 在实时监控与设置之间切换;默认进入实时监控。当前只有两个固定目的地,顶部 Tab 比左侧导航更节省视频的横向空间。
```text
主窗口
├── 标题与来源栏:产品名、摄像头名称、连接状态、当前时间
├── 演示状态切换:正常监控 / 确认摔倒 / 连接异常
├── 实时画面:视频占位、人员 ID、骨架、每人状态与 FPS
├── 事件证据栏:状态说明、触发条件、报警副作用、截图路径
└── 最近事件:最近一次事件、保存结果和操作入口
├── 顶部 Tab:实时监控 / 设置
├── 实时监控:演示状态切换、视频占位、人员 ID、骨架、事件证据和最近事件
└── 设置:来源就绪状态、模型只读信息、事件参数、重连参数和配置应用状态
确认摔倒弹窗
├── 明确的“确认摔倒”标题和人员 ID
@@ -33,6 +32,7 @@
大于等于 1008 epx 时,内容采用 8:4 双栏:左侧为固定 16:9 比例的实时画面,右侧为状态和证据卡;最近事件横跨底部。641–1007 epx 时,右栏缩窄并保留画面优先级;小于等于 640 epx 时,右栏移至画面下方,顶部操作换行,保证状态与主操作仍可见。
- 顶部栏高度 56 epx;使用系统标题栏相近的紧凑密度。
- Tab 位于标题栏下方,活动 Tab 同时用底边、文字和 aria-selected 表示;键盘左右方向键可在两个 Tab 间移动。
- 内容间距使用 4 epx 网格,常用间距为 8、12、16、24 epx。
- 状态切换按钮显式带文字,普通鼠标、触摸、键盘均可触发。
- 确认状态在画面上放置红色边框、文字标签和事件编号,不仅改变颜色。
@@ -48,6 +48,14 @@
| 关闭告警弹窗 | “我已知晓” | `QDialogButtonBox` / `QPushButton` | 只关闭视图,不清除事件或停止报警记录。 |
| 最近事件 | 只读列表 | `QListView` 或轻量卡片 | 正式版读取 JSONL 摘要,不作为数据库表格。 |
## Tab 与设置控件
| 目的 | 原型控件 | 最终 PyQt5 对应 | 行为 |
| --- | --- | --- | --- |
| 切换顶层视图 | 两个顶部 Tab | QTabWidget 或 QToolButton 加 QStackedWidget | 实时监控默认显示;设置不抢占视频宽度。 |
| 编辑设置草稿 | 数字输入与只读状态卡 | QDoubleSpinBox、QSpinBox、QLabel | 修改立即标记为编辑中,不影响运行配置。 |
| 保存并用于下次启动 | 主操作按钮 | QPushButton | 校验通过后显示已保存待下次启动;不热改监控中的阈值。 |
## 视觉令牌
采用克制的深色控制台主题,以视频画面和告警信息为主。最终 PyQt5 应将这些令牌集中到 QSS/主题资源,不在控件中散落硬编码色值。
@@ -79,6 +87,8 @@
## 无障碍与输入
- 顶部 Tab 使用标准 tablist、tab 和 tabpanel 语义;左右方向键切换顶部 Tab,切换后焦点停在新 Tab。
- Tab 顺序:顶部 Tab → 监控状态切换 → 启动/停止 → 查看截图 → 最近事件;设置 Tab 中按视觉顺序经过来源、模型、事件参数、保存操作。
- 所有状态同时包含颜色、图标形状和中文文字;状态文字对比度不低于 4.5:1。
- Tab 顺序:状态切换 → 启动/停止 → 查看截图 → 最近事件 → 弹窗确认按钮。
- `1`、`2`、`3` 是原型专用演示快捷键;Escape 只关闭可关闭的弹窗。
@@ -96,3 +106,4 @@
3. 点击“连接异常”或按 `3` 后,仅显示重连状态,且明确说明未触发报警。
4. 宽屏、约 900 epx 宽和约 600 epx 宽均不出现横向溢出或无法触达的主操作。
5. 仅用键盘可切换三种状态、关闭弹窗并看到可见焦点。
6. 点击设置 Tab 或按左右方向键可切换视图;设置页不显示来源地址或密码,编辑参数后显示已修改,保存后显示下次启动生效。
+693 -8
View File
@@ -221,6 +221,76 @@
white-space: nowrap;
}
.top-tabs {
display: flex;
align-items: end;
gap: 4px;
margin-top: var(--space-4);
padding: 6px 8px 0;
border: 1px solid rgba(65, 93, 132, 0.75);
border-radius: var(--radius-card) var(--radius-card) 0 0;
background: rgba(19, 31, 50, 0.74);
box-shadow: var(--shadow-card);
}
.top-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 10px 14px;
border: 0;
border-radius: 8px 8px 0 0;
color: var(--text-secondary);
background: transparent;
font-size: 13px;
}
.top-tab::after {
position: absolute;
right: 12px;
bottom: 0;
left: 12px;
height: 3px;
border-radius: 3px 3px 0 0;
background: transparent;
content: "";
}
.top-tab:hover {
color: var(--text-primary);
background: rgba(52, 80, 118, 0.5);
transform: none;
}
.top-tab[aria-selected="true"] {
color: var(--text-primary);
background: rgba(31, 53, 82, 0.9);
}
.top-tab[aria-selected="true"]::after {
background: var(--accent);
}
.top-tab svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.workspace {
margin-top: 0;
}
.tab-panel[hidden] {
display: none;
}
.app-main {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(330px, 0.94fr);
@@ -229,7 +299,7 @@
"monitor details"
"events events";
gap: var(--space-4);
margin-top: var(--space-4);
padding-top: var(--space-4);
}
.toolbar,
@@ -251,6 +321,24 @@
padding: 10px 12px;
}
.monitor-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.version-chip {
padding: 6px 8px;
border: 1px solid var(--stroke);
border-radius: 999px;
color: var(--text-secondary);
background: rgba(8, 17, 30, 0.28);
font-family: var(--font-number);
font-size: 10px;
white-space: nowrap;
}
.switcher {
display: flex;
align-items: center;
@@ -1158,6 +1246,335 @@
box-shadow: none;
}
.settings-panel {
display: grid;
gap: var(--space-4);
padding-top: var(--space-4);
}
.settings-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: 18px 20px;
border: 1px solid rgba(65, 93, 132, 0.75);
border-radius: var(--radius-card);
background: rgba(19, 31, 50, 0.88);
box-shadow: var(--shadow-card);
}
.settings-intro h1 {
margin-top: 2px;
font-size: 20px;
}
.settings-intro p {
max-width: 690px;
margin-top: 7px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.55;
}
.config-state {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
padding: 9px 11px;
border: 1px solid rgba(73, 209, 125, 0.34);
border-radius: 9px;
color: #dfffea;
background: rgba(20, 66, 43, 0.48);
font-size: 12px;
font-weight: 650;
}
.config-state-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
}
.settings-panel[data-config-state="dirty"] .config-state {
border-color: rgba(244, 201, 93, 0.55);
color: #fff3cc;
background: rgba(86, 65, 21, 0.44);
}
.settings-panel[data-config-state="dirty"] .config-state-dot {
background: var(--caution);
}
.settings-panel[data-config-state="saved"] .config-state {
border-color: rgba(112, 165, 255, 0.56);
color: #e3edff;
background: rgba(32, 64, 111, 0.44);
}
.settings-panel[data-config-state="saved"] .config-state-dot {
background: var(--accent);
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-4);
}
.settings-card {
overflow: hidden;
border: 1px solid rgba(65, 93, 132, 0.75);
border-radius: var(--radius-card);
background: rgba(19, 31, 50, 0.88);
box-shadow: var(--shadow-card);
}
.settings-card.wide {
grid-column: 1 / -1;
}
.settings-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: 16px;
border-bottom: 1px solid rgba(65, 93, 132, 0.52);
}
.settings-card-header h2 {
margin-top: 2px;
}
.settings-card-header p {
margin-top: 4px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
}
.settings-card-body {
padding: 16px;
}
.readiness-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 11px;
border: 1px solid rgba(73, 209, 125, 0.3);
border-radius: 9px;
background: rgba(12, 47, 31, 0.38);
}
.readiness-icon {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: 50%;
color: var(--success);
background: rgba(73, 209, 125, 0.14);
}
.readiness-icon svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.readiness-row strong,
.readiness-row span {
display: block;
}
.readiness-row strong {
color: var(--text-primary);
font-size: 13px;
}
.readiness-row span {
margin-top: 3px;
color: var(--text-secondary);
font-family: var(--font-number);
font-size: 11px;
overflow-wrap: anywhere;
}
.settings-note {
margin-top: 10px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
.metadata-list {
display: grid;
gap: 9px;
}
.metadata-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding-bottom: 9px;
border-bottom: 1px solid rgba(65, 93, 132, 0.4);
font-size: 12px;
}
.metadata-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.metadata-row span {
color: var(--text-secondary);
}
.metadata-row strong {
overflow-wrap: anywhere;
color: var(--text-primary);
font-family: var(--font-number);
font-size: 11px;
font-weight: 650;
text-align: right;
}
.field-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.field label {
color: var(--text-primary);
font-size: 12px;
font-weight: 650;
}
.field-help {
min-height: 30px;
color: var(--text-subtle);
font-size: 11px;
line-height: 1.35;
}
.number-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
overflow: hidden;
border: 1px solid var(--stroke-strong);
border-radius: 8px;
background: rgba(8, 17, 30, 0.32);
}
.number-control:focus-within {
border-color: var(--focus);
box-shadow: 0 0 0 2px rgba(169, 201, 255, 0.24);
}
.number-control input {
min-width: 0;
height: 38px;
padding: 6px 9px;
color: var(--text-primary);
border: 0;
outline: 0;
background: transparent;
font: 600 13px var(--font-number);
}
.number-control span {
padding-right: 10px;
color: var(--text-secondary);
font-size: 11px;
}
.settings-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 14px 16px;
border: 1px solid rgba(65, 93, 132, 0.75);
border-radius: var(--radius-card);
background: rgba(19, 31, 50, 0.88);
box-shadow: var(--shadow-card);
}
.config-version {
display: grid;
gap: 3px;
}
.config-version span {
color: var(--text-secondary);
font-size: 12px;
}
.config-version strong {
color: var(--text-primary);
font-family: var(--font-number);
font-size: 12px;
}
.settings-button-group {
display: flex;
align-items: center;
gap: 10px;
}
.subtle-button {
border-color: var(--stroke-strong);
background: var(--surface-raised);
color: var(--text-primary);
font-size: 12px;
}
.subtle-button:hover {
border-color: var(--accent);
background: #213653;
}
.save-button {
display: inline-flex;
align-items: center;
gap: 8px;
border-color: #5488e3;
background: var(--accent);
color: #071224;
font-size: 12px;
}
.save-button:hover {
border-color: var(--accent-hover);
background: var(--accent-hover);
}
.save-button svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
@media (max-width: 1007px) {
.shell {
width: min(100%, calc(100% - 28px));
@@ -1188,7 +1605,9 @@
@media (max-width: 760px) {
.app-chrome,
.toolbar {
.toolbar,
.settings-intro,
.settings-actions {
align-items: flex-start;
flex-wrap: wrap;
}
@@ -1216,6 +1635,18 @@
.details .card:last-child {
grid-column: 1 / -1;
}
.settings-intro .config-state {
width: 100%;
}
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-actions {
align-items: stretch;
}
}
@media (max-width: 640px) {
@@ -1248,6 +1679,19 @@
gap: 10px;
}
.top-tabs {
margin-top: 10px;
padding-right: 5px;
padding-left: 5px;
overflow-x: auto;
}
.top-tab {
flex: 1 0 auto;
justify-content: center;
min-width: 150px;
}
.switcher {
width: 100%;
overflow-x: auto;
@@ -1265,6 +1709,15 @@
width: 100%;
}
.monitor-controls {
width: 100%;
flex-wrap: wrap;
}
.monitor-controls .primary-action {
flex: 1;
}
.monitor-header,
.card-header,
.event-log-header {
@@ -1322,6 +1775,34 @@
.event-row:last-child {
border-bottom: 0;
}
.settings-intro,
.settings-card-header,
.settings-card-body,
.settings-actions {
padding-right: 12px;
padding-left: 12px;
}
.settings-grid {
grid-template-columns: 1fr;
}
.settings-card.wide {
grid-column: auto;
}
.field-grid {
grid-template-columns: 1fr;
}
.settings-button-group {
width: 100%;
}
.settings-button-group button {
flex: 1;
}
}
@media (max-width: 600px) {
@@ -1400,7 +1881,19 @@
</div>
</header>
<main class="app-main">
<nav class="top-tabs" role="tablist" aria-label="Silver Pose 主界面">
<button id="monitor-tab" class="top-tab" type="button" role="tab" aria-selected="true" aria-controls="monitor-panel" tabindex="0" data-tab-target="monitor">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="14" rx="2"></rect><path d="m9 9 6 3-6 3Z"></path></svg>
<span>实时监控</span>
</button>
<button id="settings-tab" class="top-tab" type="button" role="tab" aria-selected="false" aria-controls="settings-panel" tabindex="-1" data-tab-target="settings">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.04 2.04-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.04 1.56v.1h-2.88v-.1a1.7 1.7 0 0 0-1.04-1.56 1.7 1.7 0 0 0-1.88.34l-.06.06-2.04-2.04.06-.06A1.7 1.7 0 0 0 7.28 15a1.7 1.7 0 0 0-1.56-1.04h-.1v-2.88h.1A1.7 1.7 0 0 0 7.28 10a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.04-2.04.06.06a1.7 1.7 0 0 0 1.88.34 1.7 1.7 0 0 0 1.04-1.56v-.1h2.88v.1A1.7 1.7 0 0 0 15.82 6.4a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.04 2.04-.06.06A1.7 1.7 0 0 0 19.4 10a1.7 1.7 0 0 0 1.56 1.04h.1v2.88h-.1A1.7 1.7 0 0 0 19.4 15Z"></path></svg>
<span>设置</span>
</button>
</nav>
<main class="workspace">
<section id="monitor-panel" class="tab-panel app-main" role="tabpanel" aria-labelledby="monitor-tab">
<section class="toolbar" aria-label="演示控制">
<div class="switcher" role="group" aria-label="演示状态切换">
<span class="switcher-label">演示状态</span>
@@ -1417,10 +1910,13 @@
<span>3 连接异常</span>
</button>
</div>
<button id="monitor-toggle" class="primary-action" type="button" aria-pressed="true">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="14" rx="2"></rect><path d="m9 9 6 3-6 3Z"></path></svg>
<span>停止模拟监控</span>
</button>
<div class="monitor-controls">
<span id="monitor-config-version" class="version-chip">运行配置 cfg-20260720-01</span>
<button id="monitor-toggle" class="primary-action" type="button" aria-pressed="true">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="14" rx="2"></rect><path d="m9 9 6 3-6 3Z"></path></svg>
<span>停止模拟监控</span>
</button>
</div>
</section>
<section class="monitor-card" aria-labelledby="monitor-title">
@@ -1563,6 +2059,106 @@
</div>
</div>
</section>
</section>
<section id="settings-panel" class="tab-panel settings-panel" role="tabpanel" aria-labelledby="settings-tab" hidden data-config-state="clean">
<header class="settings-intro">
<div>
<span class="eyebrow">CONFIGURATION · 非敏感演示设置</span>
<h1>设置</h1>
<p>编辑的是下一次开始监控时将使用的草稿配置。当前监控仍固定使用已显示的运行配置版本,以保证事件截图和日志可追溯。</p>
</div>
<span id="config-state" class="config-state" role="status"><span class="config-state-dot" aria-hidden="true"></span><span id="config-state-text">草稿与运行配置一致</span></span>
</header>
<form id="settings-form" novalidate>
<div class="settings-grid">
<section class="settings-card" aria-labelledby="source-settings-title">
<header class="settings-card-header">
<div><span class="eyebrow">SOURCE</span><h2 id="source-settings-title">来源状态</h2><p>安全配置只证明本机来源已就绪。</p></div>
<span class="quiet-badge">不回显真实来源</span>
</header>
<div class="settings-card-body">
<div class="readiness-row">
<span class="readiness-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 9.1 16.5 19 7"></path></svg></span>
<div><strong>来源环境变量已就绪</strong><span>SILVER_POSE_RTSP_URL · 仅显示变量名</span></div>
</div>
<p class="settings-note">正式应用从本机环境变量或未跟踪的本地配置读取来源信息;该页面不显示、记录或复制真实连接内容。</p>
</div>
</section>
<section class="settings-card" aria-labelledby="model-settings-title">
<header class="settings-card-header">
<div><span class="eyebrow">MODEL</span><h2 id="model-settings-title">模型信息</h2><p>仅供操作员确认受控工件状态。</p></div>
<span class="quiet-badge">只读</span>
</header>
<div class="settings-card-body">
<div class="metadata-list">
<div class="metadata-row"><span>任务</span><strong>person pose</strong></div>
<div class="metadata-row"><span>关键点格式</span><strong>17 × 3</strong></div>
<div class="metadata-row"><span>哈希摘要</span><strong>cd200948…bfa fdfc</strong></div>
<div class="metadata-row"><span>验证状态</span><strong>本机基线已检查</strong></div>
</div>
</div>
</section>
<section class="settings-card wide" aria-labelledby="event-settings-title">
<header class="settings-card-header">
<div><span class="eyebrow">EVENT LOGIC</span><h2 id="event-settings-title">事件参数草稿</h2><p>范围用于演示配置;最终数值必须通过正反例录像和现场机位验证。</p></div>
<span class="quiet-badge">下次启动生效</span>
</header>
<div class="settings-card-body">
<div class="field-grid">
<div class="field">
<label for="keypoint-threshold">关键点质量门槛</label>
<span id="keypoint-threshold-help" class="field-help">关节低于此值时不推进事件证据。</span>
<span class="number-control"><input id="keypoint-threshold" name="keypoint-threshold" type="number" min="0.10" max="0.90" step="0.05" value="0.40" aria-describedby="keypoint-threshold-help"><span>0–1</span></span>
</div>
<div class="field">
<label for="suspect-window">疑似持续时间</label>
<span class="field-help">倒地候选持续后保持疑似状态的时间窗。</span>
<span class="number-control"><input id="suspect-window" name="suspect-window" type="number" min="0.10" max="2.00" step="0.10" value="0.50"><span>秒</span></span>
</div>
<div class="field">
<label for="confirm-window">确认时间</label>
<span class="field-help">目标演示报警时延的一部分,需保持在 1–3 秒目标内。</span>
<span class="number-control"><input id="confirm-window" name="confirm-window" type="number" min="0.50" max="3.00" step="0.10" value="1.00"><span>秒</span></span>
</div>
<div class="field">
<label for="recovery-window">恢复稳定时间</label>
<span class="field-help">人员恢复后回到正常状态前的持续时间。</span>
<span class="number-control"><input id="recovery-window" name="recovery-window" type="number" min="0.50" max="10.00" step="0.10" value="2.00"><span>秒</span></span>
</div>
<div class="field">
<label for="cooldown-window">事件冷却时间</label>
<span class="field-help">同一人员恢复前不重复创建确认事件。</span>
<span class="number-control"><input id="cooldown-window" name="cooldown-window" type="number" min="1" max="60" step="1" value="10"><span>秒</span></span>
</div>
<div class="field">
<label for="reconnect-delay">重连间隔</label>
<span class="field-help">仅影响视频源重试节奏,不可作为事件证据。</span>
<span class="number-control"><input id="reconnect-delay" name="reconnect-delay" type="number" min="1" max="30" step="1" value="4"><span>秒</span></span>
</div>
</div>
</div>
</section>
</div>
<footer class="settings-actions">
<div class="config-version">
<span id="draft-version-label">草稿版本</span>
<strong id="draft-version">cfg-20260720-01 · 未修改</strong>
</div>
<div class="settings-button-group">
<button id="reset-settings" class="subtle-button" type="button">恢复演示默认值</button>
<button id="save-settings" class="save-button" type="submit">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h12l2 2v14H5Z"></path><path d="M8 4v6h8V4M8 19v-5h8v5"></path></svg>
<span>保存并用于下次启动</span>
</button>
</div>
</footer>
</form>
</section>
</main>
<p class="footer-note">
@@ -1599,15 +2195,30 @@
var app = document.querySelector("#app");
var stateButtons = Array.prototype.slice.call(document.querySelectorAll("[data-state-target]"));
var topTabs = Array.prototype.slice.call(document.querySelectorAll("[data-tab-target]"));
var monitorPanel = document.querySelector("#monitor-panel");
var settingsPanel = document.querySelector("#settings-panel");
var dialog = document.querySelector("#fall-dialog");
var acknowledgeButton = document.querySelector("#acknowledge-button");
var monitorToggle = document.querySelector("#monitor-toggle");
var artifactButton = document.querySelector("#artifact-button");
var settingsForm = document.querySelector("#settings-form");
var settingsInputs = Array.prototype.slice.call(settingsForm.querySelectorAll("input"));
var resetSettingsButton = document.querySelector("#reset-settings");
var configStateText = document.querySelector("#config-state-text");
var draftVersion = document.querySelector("#draft-version");
var toast = document.querySelector("#toast");
var liveAnnouncement = document.querySelector("#live-announcement");
var lastInvoker = null;
var confirmedDialogShown = false;
var toastTimer = null;
var activeTab = "monitor";
var draftRevision = 1;
var settingDefaults = {};
settingsInputs.forEach(function (input) {
settingDefaults[input.id] = input.value;
});
var views = {
normal: {
@@ -1739,6 +2350,49 @@
document.querySelector(id).textContent = value;
}
function setActiveTab(nextTab, moveFocus) {
if (nextTab !== "monitor" && nextTab !== "settings") {
return;
}
activeTab = nextTab;
topTabs.forEach(function (tab) {
var selected = tab.dataset.tabTarget === nextTab;
tab.setAttribute("aria-selected", String(selected));
tab.setAttribute("tabindex", selected ? "0" : "-1");
if (selected && moveFocus) {
tab.focus();
}
});
monitorPanel.hidden = nextTab !== "monitor";
settingsPanel.hidden = nextTab !== "settings";
liveAnnouncement.textContent = nextTab === "settings"
? "已切换至设置。编辑的是草稿配置,保存后在下一次开始监控时生效。"
: "已切换至实时监控。当前运行配置保持不变。";
}
function markConfigDirty() {
settingsPanel.dataset.configState = "dirty";
configStateText.textContent = "设置已修改,尚未保存";
draftVersion.textContent = "cfg-20260720-0" + (draftRevision + 1) + " · 编辑中";
}
function resetSettings() {
settingsInputs.forEach(function (input) {
input.value = settingDefaults[input.id];
});
markConfigDirty();
showToast("已恢复演示默认值;请保存后在下一次启动监控时应用。");
}
function saveSettings() {
draftRevision += 1;
settingsPanel.dataset.configState = "saved";
configStateText.textContent = "草稿已保存,下一次启动生效";
draftVersion.textContent = "cfg-20260720-0" + draftRevision + " · 已保存待应用";
liveAnnouncement.textContent = "设置草稿已保存。当前监控仍使用 cfg-20260720-01,下一次开始监控时才使用新版本。";
showToast("演示草稿已保存;运行配置未被热修改。");
}
function setDemoState(nextState, invoker) {
var view = views[nextState];
if (!view) {
@@ -1806,6 +2460,23 @@
setText("#scene-time", time);
}
topTabs.forEach(function (tab) {
tab.addEventListener("click", function () {
setActiveTab(tab.dataset.tabTarget, false);
});
});
settingsInputs.forEach(function (input) {
input.addEventListener("input", markConfigDirty);
});
resetSettingsButton.addEventListener("click", resetSettings);
settingsForm.addEventListener("submit", function (event) {
event.preventDefault();
saveSettings();
});
stateButtons.forEach(function (button) {
button.addEventListener("click", function () {
setDemoState(button.dataset.stateTarget, button);
@@ -1843,8 +2514,19 @@
if (event.altKey || event.ctrlKey || event.metaKey) {
return;
}
var tabIndex = topTabs.indexOf(document.activeElement);
if (tabIndex !== -1 && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
event.preventDefault();
var direction = event.key === "ArrowRight" ? 1 : -1;
var nextIndex = (tabIndex + direction + topTabs.length) % topTabs.length;
setActiveTab(topTabs[nextIndex].dataset.tabTarget, true);
return;
}
if (document.activeElement && document.activeElement.matches("input, textarea, select")) {
return;
}
var stateMap = { "1": "normal", "2": "confirmed", "3": "offline" };
if (stateMap[event.key]) {
if (activeTab === "monitor" && stateMap[event.key]) {
var targetButton = document.querySelector("[data-state-target='" + stateMap[event.key] + "']");
setDemoState(stateMap[event.key], targetButton);
}
@@ -1853,6 +2535,9 @@
}
});
if (window.location.hash === "#settings") {
setActiveTab("settings", false);
}
updateClock();
window.setInterval(updateClock, 1000);
}());