docs(design): apply Sense prototype review
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-04 10:05:56 +08:00
parent d36b785f67
commit e18a1ea83b
11 changed files with 444 additions and 130 deletions
+2
View File
@@ -28,10 +28,12 @@
### 3.1 接入与设备 ### 3.1 接入与设备
- 支持标准 ONVIF/RTSP,不绑定摄像头品牌。 - 支持标准 ONVIF/RTSP,不绑定摄像头品牌。
- 台账与管理端以“设备”为根实体,使用 `modality` 表达 video / radar / contact / button / wearable / other,并使用 `capabilities` 决定是否展示画面、媒体、空间配置、遥测等能力;M1~M5 只完整实现 video 适配器,M6 再接入非视频设备,但不得因此把数据模型和一级信息架构写死为摄像头。
- 支持 NAT 后的边缘主动推流;设备身份使用稳定序列号而非 IP。 - 支持 NAT 后的边缘主动推流;设备身份使用稳定序列号而非 IP。
- 批量开通不依赖逐路手工操作,支持待激活中间态。 - 批量开通不依赖逐路手工操作,支持待激活中间态。
- 探活、离线告警、开通校时、断线自动恢复。 - 探活、离线告警、开通校时、断线自动恢复。
- 容量写入时校验站点配额;配额服务不可用时拒绝新增/启用,但不影响已有流。 - 容量写入时校验站点配额;配额服务不可用时拒绝新增/启用,但不影响已有流。
- 隐私准入策略属于 Area,使用 `capture_policy = video_allowed | non_imaging_only`;Sense 在设备新增/启用时依据设备成像能力校验。策略读取失败时拒绝新的成像设备变更并告警,已有链路不静默停用或伪装为已收敛。
### 3.2 分析与规则 ### 3.2 分析与规则
+6 -5
View File
@@ -16,7 +16,7 @@ YoVision 使用“通用底座 + 场景包”,按变化频率分为:
| 系统 | 语言/状态 | 职责 | 不负责 | | 系统 | 语言/状态 | 职责 | 不负责 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Sense | Go,有状态 | 设备台账、ONVIF、MediaMTX 控制、对账、探活、隧道、设备型触发、流分片 | AI 判定、事件业务、预警 | | Sense | Go,有状态 | 设备台账(`modality + capabilities`)、ONVIF、MediaMTX 控制、对账、探活、隧道、设备型触发、流分片、隐私区域设备准入 | AI 判定、事件业务、预警 |
| Brain | Python/CUDA,业务无状态 | 解码/推理、检测/姿态/跟踪/ReID、时间窗判定、事件 mapper、像素级触发 | 设备真相源、告警升级、租户权限 | | Brain | Python/CUDA,业务无状态 | 解码/推理、检测/姿态/跟踪/ReID、时间窗判定、事件 mapper、像素级触发 | 设备真相源、告警升级、租户权限 |
| Bell | Go + Web,有状态 | 事件校验/存储、规则、预警状态机、投递、反馈、租户/RBAC、审计、配额真相源和管理端 | 媒体转发、模型执行 | | Bell | Go + Web,有状态 | 事件校验/存储、规则、预警状态机、投递、反馈、租户/RBAC、审计、配额真相源和管理端 | 媒体转发、模型执行 |
@@ -55,7 +55,7 @@ Sense ── 视频流/触发信号 ──> Brain
首个 M3 数据流部署在 S2 民办寄宿学校的 16 路高风险点位,只运行越线、危险区域和聚集等匿名规则,不加载人脸底库。 首个 M3 数据流部署在 S2 民办寄宿学校的 16 路高风险点位,只运行越线、危险区域和聚集等匿名规则,不加载人脸底库。
## 5. 九条不可越界的决定 ## 5. 十条不可越界的决定
1. MediaMTX 独立运行,Sense 管配置与生命周期。 1. MediaMTX 独立运行,Sense 管配置与生命周期。
2. 设备型触发源归 Sense;需要解码的像素级触发归 Brain。 2. 设备型触发源归 Sense;需要解码的像素级触发归 Brain。
@@ -66,6 +66,7 @@ Sense ── 视频流/触发信号 ──> Brain
7. Bell 拥有 `site.max_video_channels`,Sense 在设备写路径执行;不跨 schema 直接写。 7. Bell 拥有 `site.max_video_channels`,Sense 在设备写路径执行;不跨 schema 直接写。
8. 事件片段写入客户侧 MinIO/S3,常态录像留在客户 NVR;元数据/审计、人脸和训练样本使用独立生命周期。 8. 事件片段写入客户侧 MinIO/S3,常态录像留在客户 NVR;元数据/审计、人脸和训练样本使用独立生命周期。
9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。 9. 投递状态机只依赖 Bell provider 接口,不直接依赖某家短信或语音 SDK;生产前至少两条独立路径并能故障切换。
10. 设备领域模型使用 `modality + capabilities`,页面不以摄像头作为唯一根实体;隐私策略属于 Area,Sense 在新增/启用写路径按成像能力执行,策略不可用时只阻断新变更,不静默切断已有链路。
## 6. 容量架构 ## 6. 容量架构
@@ -88,7 +89,7 @@ Sense ── 视频流/触发信号 ──> Brain
## 8. 数据与契约 ## 8. 数据与契约
- 核心实体:Tenant → Site → Area/Device → StreamBinding/Zone;Rule → Event → Alert → DeliveryAttempt/Ack。 - 核心实体:Tenant → Site → Area(含 `capture_policy`)/Device(含 `modality + capabilities`)→ StreamBinding/Zone;Rule → Event → Alert → DeliveryAttempt/Ack。
- Event 与 Alert 不合并:一个事件可触发多次预警与投递,一次预警也可聚合多个事件。 - Event 与 Alert 不合并:一个事件可触发多次预警与投递,一次预警也可聚合多个事件。
- 事件 v0.1 以 `raw/contracts/event-v0.1.schema.json` 与 `raw/contracts/README.md` 为准;未知顶层字段拒绝,只允许通过 `ext` 扩展。 - 事件 v0.1 以 `raw/contracts/event-v0.1.schema.json` 与 `raw/contracts/README.md` 为准;未知顶层字段拒绝,只允许通过 `ext` 扩展。
- v0.1 还需代码校验时间自洽、`confidence` 当前为 null、证据文件名隐私、唯一 primary sensor 等跨字段约束。 - v0.1 还需代码校验时间自洽、`confidence` 当前为 null、证据文件名隐私、唯一 primary sensor 等跨字段约束。
@@ -108,10 +109,10 @@ Bell/{web,packs,contracts}
## 10. 开发顺序 ## 10. 开发顺序
- M0 不写生产代码。 - M0 不写生产代码。
- M1 只动 Sense,5 路接入骨架与 MediaMTX。 - M1 只动 Sense,5 路视频接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。
- M2 仍以 Sense 为主,完成 16 路开通/停用、对账、多租户投影与隧道。 - M2 仍以 Sense 为主,完成 16 路开通/停用、对账、多租户投影与隧道。
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。 - M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包。 - M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。 M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
+10 -2
View File
@@ -2,10 +2,10 @@
> 用户故事用于连接需求、页面交互和任务验收。当前 M0/M1 以设备与实施流程为主,最终用户界面在 M3/M4 才实现。 > 用户故事用于连接需求、页面交互和任务验收。当前 M0/M1 以设备与实施流程为主,最终用户界面在 M3/M4 才实现。
## US-001 批量开通摄像头 ## US-001 批量开通视频设备
- 角色:实施工程师。 - 角色:实施工程师。
- 目标:一次导入并验证一个默认 16 路站点,不逐路手工配置。 - 目标:一次导入并验证一个默认 16 路视频站点,不逐路手工配置;操作入口位于通用设备台账的视频模态筛选下。
- 价值:降低交付时间并为 128 路扩展保留操作效率。 - 价值:降低交付时间并为 128 路扩展保留操作效率。
- 验收:支持待激活、逐设备结果、失败可重试、超配额明确拒绝;128 路规模下仍使用分页/批量流程。 - 验收:支持待激活、逐设备结果、失败可重试、超配额明确拒绝;128 路规模下仍使用分页/批量流程。
- 关联:RQ-C-01~RQ-C-08,IX-001~IX-003。 - 关联:RQ-C-01~RQ-C-08,IX-001~IX-003。
@@ -58,6 +58,14 @@
- 验收:3–5 款设备的型号、固件、认证方式、Profiles/StreamUri/校时、主子码流、掉线恢复都有证据;不记录密码和真实客户信息。 - 验收:3–5 款设备的型号、固件、认证方式、Profiles/StreamUri/校时、主子码流、掉线恢复都有证据;不记录密码和真实客户信息。
- 关联:M0,T-001;无产品 UI,使用版本化测试文档。 - 关联:M0,T-001;无产品 UI,使用版本化测试文档。
## US-008 管理异构设备与隐私准入
- 角色:实施工程师/站点管理员。
- 目标:在统一设备台账中按模态和能力添加、筛选与查看视频、雷达、门磁、按钮、可穿戴等设备;隐私区域只允许非成像设备。
- 价值:M2 固化不会因 M6 异构设备接入而推倒重来的信息架构,同时把隐私要求落实为系统准入约束。
- 验收:一级入口使用“设备”;添加时先选模态,详情按能力显示页面;`non_imaging_only` Area 不允许新增或启用成像设备,策略读取失败时拒绝新变更并给出恢复路径;M2 完成信息架构与视频准入,M6 完成非视频适配器。
- 关联:RQ-S1-05、NFR-CMP-05,IX-014、IX-016。
## 追溯规则 ## 追溯规则
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。 新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
+9 -1
View File
@@ -17,6 +17,11 @@
| IX-011 | 规则试运行 | 明确“未正式生效”,展示命中样本与影响范围,支持取消/回滚 | US-005 | M4 | | IX-011 | 规则试运行 | 明确“未正式生效”,展示命中样本与影响范围,支持取消/回滚 | US-005 | M4 |
| IX-012 | 升级链/静默 | 联系人顺序、超时、双通道;静默最长 4h、显示自动恢复时间、无永久选项 | US-005 | M3/M4 | | IX-012 | 升级链/静默 | 联系人顺序、超时、双通道;静默最长 4h、显示自动恢复时间、无永久选项 | US-005 | M3/M4 |
| IX-013 | 权限与隐私 | 越权统一处理;无授权租户不展示人脸入口;不泄露流 URL/凭据 | US-006 | M3/M4 | | IX-013 | 权限与隐私 | 越权统一处理;无授权租户不展示人脸入口;不泄露流 URL/凭据 | US-006 | M3/M4 |
| IX-014 | 设备模态与能力 | 一级入口为“设备”;列表可按 `modality` 筛选,添加时先选模态,详情按 `capabilities` 渐进展示;视频设备显示画面/媒体/检测区域,非视频设备保留连接与健康并显示对应遥测,不用禁用的视频页签占位 | US-008 | M2/M6 |
| IX-015 | 停用与收敛 | 明确区分暂停推理、停用设备接入和仅踢当前会话;操作前展示对观看、推理、录制与证据回捞的影响并二次确认;期望态立即改变、实际态经对账收敛,部分失败可逐项重试;重复请求不产生重复副作用,但每次请求均留审计结果 | US-001、US-002 | M2 |
| IX-016 | 隐私区域设备准入 | `capture_policy = non_imaging_only` 的 Area 不允许新增/启用具有成像能力的设备;前端就地禁用并解释,后端返回稳定错误码;策略读取失败时拒绝新变更并告警,已有设备遇到区域策略变化时进入显式处置流程,不静默留存或停用 | US-008 | M2/M6 |
| IX-017 | 检测区域几何编辑 | 多边形和方向警戒线支持绘制、显式完成、撤销、清空、顶点编辑;警戒线在草稿/已保存态均显示方向箭头并可反转;鼠标与键盘坐标操作等效;画面参数变化后标记待校准且不自动改坐标;区域版本与规则版本独立 | US-005 | M3 |
| IX-018 | 草稿与跨系统上下文 | 几何草稿自动保存并可恢复;只有持久化失败或切换空间类型等会破坏草稿的动作才拦截确认;Sense↔Bell 双向携带并消费 `camera + zone + return_to` 上下文;返回后恢复草稿,版本已被他人更新时提示冲突且不覆盖 | US-005 | M3 |
## 全局状态 ## 全局状态
@@ -44,7 +49,10 @@
## 无障碍与安全 ## 无障碍与安全
- 键盘可完成主要 Web 流程,焦点清晰,表单错误关联到字段。 - 键盘可完成主要 Web 流程,焦点清晰,表单错误关联到字段。
- 文本和关键状态满足可读对比度;严重度同时用文字/图标表达。 - 正文、按钮文字和关键状态对比度不低于 4.5:1;大字号(不低于 24px,或不低于 18.66px 且粗体)不低于 3:1;非文本控件、状态边界与焦点指示器不低于 3:1。严重度同时用文字/图标表达。
- 当前导航项显式使用 `aria-current="page"`;响应式隐藏可见文字时,图标按钮仍保留稳定的可访问名。
- 使用 tab 模式时完整实现 `tablist` / `tab` / `tabpanel`、`aria-controls` / `aria-labelledby`、单一 Tab 停靠点和方向键导航;不能完整实现时不声明 tab 角色。
- 移动端主要操作目标不小于 44×44px,输入控件正文不小于 16px;绘制画布必须提供无需精确点击的键盘/表单等效路径。
- 删除、停用、踢流、批量覆盖和规则正式发布需要明确影响范围与二次确认。 - 删除、停用、踢流、批量覆盖和规则正式发布需要明确影响范围与二次确认。
- UI 不展示摄像头密码、完整连接串、token 或可复用的内部流地址。 - UI 不展示摄像头密码、完整连接串、token 或可复用的内部流地址。
+11 -7
View File
@@ -369,12 +369,12 @@
</section> </section>
<section class="view" data-page="rules"> <section class="view" data-page="rules">
<div class="rule-context" id="ruleContext" hidden><p><strong>已带入摄像头上下文:</strong>北围墙 01 · 检测区域 ZONE-007,可继续完成规则草稿。</p><button class="btn btn-primary" id="continueRuleDraft">继续配置</button></div> <div class="rule-context" id="ruleContext" hidden><p><strong>已带入摄像头与区域上下文:</strong><span id="ruleContextCamera">北围墙 01</span> · <span id="ruleContextZone">ZONE-007</span>,可继续完成规则草稿。</p><button class="btn btn-primary" id="continueRuleDraft">继续配置</button></div>
<div class="page-head"><div><h1>规则策略</h1><p>Bell 管理场景、范围、时段、持续时间、试运行与发布;检测区域来自对应摄像头的版本化空间配置。</p></div><div class="page-actions"><button class="btn btn-primary" id="openRuleDialog">新建规则</button></div></div> <div class="page-head"><div><h1>规则策略</h1><p>Bell 管理场景、范围、时段、持续时间、试运行与发布;检测区域来自对应摄像头的版本化空间配置。</p></div><div class="page-actions"><button class="btn btn-primary" id="openRuleDialog">新建规则</button></div></div>
<div class="grid rule-grid"> <div class="grid rule-grid">
<article class="card rule-card"><div class="rule-card-top"><span class="badge success">正式生效</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>非授权人员进入</h2><p>危险区域 × 全天 × 持续 3 秒</p><dl><dt>覆盖范围</dt><dd>4 个点位</dd><dt>检测区域</dt><dd>北围墙危险区域 v7</dd><dt>继承来源</dt><dd>站点模板</dd><dt>今日命中</dt><dd>8</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>编辑规则</button><a class="btn" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-001&amp;return=bell-rule">校准检测区域</a></div></article> <article class="card rule-card"><div class="rule-card-top"><span class="badge success">正式生效</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>非授权人员进入</h2><p>危险区域 × 全天 × 持续 3 秒</p><dl><dt>覆盖范围</dt><dd>4 个点位</dd><dt>检测区域</dt><dd>北围墙危险区域 v7</dd><dt>继承来源</dt><dd>站点模板</dd><dt>今日命中</dt><dd>8</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>编辑规则</button><a class="btn" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-001&amp;zone=ZONE-007&amp;return_to=bell-rule">校准检测区域</a></div></article>
<article class="card rule-card"><div class="rule-card-top"><span class="badge warning" id="trialBadge">试运行</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>区域内聚集</h2><p>人数 ≥ 6 × 持续 20 秒 × 夜间</p><dl><dt>覆盖范围</dt><dd>3 个点位</dd><dt>检测区域</dt><dd>3 个区域版本</dd><dt>继承来源</dt><dd>设备覆盖</dd><dt>样本命中</dt><dd>14</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>查看试运行</button><button class="btn btn-primary" id="publishTrialRule">正式发布</button></div></article> <article class="card rule-card"><div class="rule-card-top"><span class="badge warning" id="trialBadge">试运行</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>区域内聚集</h2><p>人数 ≥ 6 × 持续 20 秒 × 夜间</p><dl><dt>覆盖范围</dt><dd>3 个点位</dd><dt>检测区域</dt><dd>3 个区域版本</dd><dt>继承来源</dt><dd>设备覆盖</dd><dt>样本命中</dt><dd>14</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>查看试运行</button><button class="btn btn-primary" id="publishTrialRule">正式发布</button></div></article>
<article class="card rule-card"><div class="rule-card-top"><span class="badge violet">草稿</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>方向越线</h2><p>警戒线 A → B × 上学时段</p><dl><dt>覆盖范围</dt><dd>1 个点位</dd><dt>检测区域</dt><dd>宿舍东通道 · 警戒线 v3</dd><dt>继承来源</dt><dd>设备草稿</dd><dt>最近修改</dt><dd>2h 前</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>继续编辑</button><a class="btn" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-011&amp;return=bell-rule">查看警戒线</a></div></article> <article class="card rule-card"><div class="rule-card-top"><span class="badge violet">草稿</span><button class="btn btn-quiet icon-btn" aria-label="更多操作"><svg class="icon"><use href="#i-more"/></svg></button></div><h2>方向越线</h2><p>警戒线 A → B × 上学时段</p><dl><dt>覆盖范围</dt><dd>1 个点位</dd><dt>检测区域</dt><dd>宿舍东通道 · 警戒线 v3</dd><dt>继承来源</dt><dd>设备草稿</dd><dt>最近修改</dt><dd>2h 前</dd></dl><div class="rule-card-actions"><button class="btn" data-edit-rule>继续编辑</button><a class="btn" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-011&amp;zone=ZONE-011&amp;return_to=bell-rule">查看警戒线</a></div></article>
</div> </div>
</section> </section>
@@ -408,8 +408,8 @@
<div class="form-field"><label for="ruleScene">场景模板</label><select id="ruleScene" class="select"><option>非授权人员进入</option><option>区域内聚集</option><option>方向越线</option></select></div> <div class="form-field"><label for="ruleScene">场景模板</label><select id="ruleScene" class="select"><option>非授权人员进入</option><option>区域内聚集</option><option>方向越线</option></select></div>
<div class="form-field"><label for="ruleScope">继承与覆盖</label><select id="ruleScope" class="select"><option>当前设备覆盖</option><option>继承站点模板</option><option>覆盖站点模板</option></select></div> <div class="form-field"><label for="ruleScope">继承与覆盖</label><select id="ruleScope" class="select"><option>当前设备覆盖</option><option>继承站点模板</option><option>覆盖站点模板</option></select></div>
<div class="form-field"><label for="ruleCamera">摄像头</label><select id="ruleCamera" class="select"><option value="CAM-S2-001">北围墙 01</option><option value="CAM-S2-006">实验楼入口 02</option><option value="CAM-S2-011">宿舍东通道</option></select></div> <div class="form-field"><label for="ruleCamera">摄像头</label><select id="ruleCamera" class="select"><option value="CAM-S2-001">北围墙 01</option><option value="CAM-S2-006">实验楼入口 02</option><option value="CAM-S2-011">宿舍东通道</option></select></div>
<div class="form-field"><label for="ruleZone">检测区域</label><select id="ruleZone" class="select"><option value="ZONE-007">北围墙危险区域 · v7</option><option>北围墙方向警戒线 · v3</option></select></div> <div class="form-field"><label for="ruleZone">检测区域</label><select id="ruleZone" class="select"><option value="ZONE-007">北围墙危险区域 · v7</option><option value="ZONE-011">北围墙方向警戒线 · v3</option></select></div>
<div class="linked-zone"><span><strong>空间坐标由摄像头详情维护</strong><small>区域版本变化不会绕过规则试运行或自动正式发布。</small></span><a class="btn" id="editRuleZone" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-001&amp;return=bell-rule">编辑检测区域</a></div> <div class="linked-zone"><span><strong>空间坐标由摄像头详情维护</strong><small>区域版本变化不会绕过规则试运行或自动正式发布。</small></span><a class="btn" id="editRuleZone" href="../sense/index.html?view=device-detail&amp;camera=CAM-S2-001&amp;zone=ZONE-007&amp;return_to=bell-rule">编辑检测区域</a></div>
<div class="form-field"><label for="ruleSchedule">生效时段</label><select id="ruleSchedule" class="select"><option>全天</option><option>夜间 22:00–06:00</option><option>自定义课表</option></select></div> <div class="form-field"><label for="ruleSchedule">生效时段</label><select id="ruleSchedule" class="select"><option>全天</option><option>夜间 22:00–06:00</option><option>自定义课表</option></select></div>
<div class="form-field"><label for="ruleDuration">持续时间</label><select id="ruleDuration" class="select"><option>超过 3 秒</option><option>立即</option><option>超过 10 秒</option><option>超过 20 秒</option></select></div> <div class="form-field"><label for="ruleDuration">持续时间</label><select id="ruleDuration" class="select"><option>超过 3 秒</option><option>立即</option><option>超过 10 秒</option><option>超过 20 秒</option></select></div>
<div class="form-field full"><label><input id="startDryRun" type="checkbox" checked aria-label="保存后开始试运行"> 保存后开始试运行,不触发正式预警</label></div> <div class="form-field full"><label><input id="startDryRun" type="checkbox" checked aria-label="保存后开始试运行"> 保存后开始试运行,不触发正式预警</label></div>
@@ -463,7 +463,9 @@
$('#openRuleDialog').addEventListener('click', openRuleEditor); $('#openRuleDialog').addEventListener('click', openRuleEditor);
$('#continueRuleDraft').addEventListener('click', openRuleEditor); $('#continueRuleDraft').addEventListener('click', openRuleEditor);
$$('[data-edit-rule]').forEach(button=>button.addEventListener('click', openRuleEditor)); $$('[data-edit-rule]').forEach(button=>button.addEventListener('click', openRuleEditor));
$('#ruleCamera').addEventListener('change',e=>{ $('#editRuleZone').href=`../sense/index.html?view=device-detail&camera=${encodeURIComponent(e.target.value)}&return=bell-rule`; notify('已切换摄像头,请确认检测区域版本与画面仍匹配。'); }); function syncSenseZoneLink(){ $('#editRuleZone').href=`../sense/index.html?view=device-detail&camera=${encodeURIComponent($('#ruleCamera').value)}&zone=${encodeURIComponent($('#ruleZone').value)}&return_to=bell-rule`; }
$('#ruleCamera').addEventListener('change',()=>{ syncSenseZoneLink(); notify('已切换摄像头,请确认检测区域版本与画面仍匹配。'); });
$('#ruleZone').addEventListener('change',syncSenseZoneLink);
$('#saveRuleDraft').addEventListener('click',()=>notify($('#startDryRun').checked?'规则草稿已保存并进入试运行,不会触发正式预警。':'规则草稿已保存,尚未开始试运行。')); $('#saveRuleDraft').addEventListener('click',()=>notify($('#startDryRun').checked?'规则草稿已保存并进入试运行,不会触发正式预警。':'规则草稿已保存,尚未开始试运行。'));
$('#publishTrialRule').addEventListener('click',()=>{if(confirm('正式发布后将影响 3 个点位并启动预警链。确认发布当前试运行版本?')){$('#trialBadge').className='badge success';$('#trialBadge').textContent='正式生效';notify('规则版本已正式发布,变更已记录审计并可回滚。');}}); $('#publishTrialRule').addEventListener('click',()=>{if(confirm('正式发布后将影响 3 个点位并启动预警链。确认发布当前试运行版本?')){$('#trialBadge').className='badge success';$('#trialBadge').textContent='正式生效';notify('规则版本已正式发布,变更已记录审计并可回滚。');}});
@@ -474,7 +476,9 @@
if (routeParams.has('camera')) { if (routeParams.has('camera')) {
switchView('rules'); $('#ruleContext').hidden=false; switchView('rules'); $('#ruleContext').hidden=false;
const camera=routeParams.get('camera'); if([...$('#ruleCamera').options].some(option=>option.value===camera)) $('#ruleCamera').value=camera; const camera=routeParams.get('camera'); if([...$('#ruleCamera').options].some(option=>option.value===camera)) $('#ruleCamera').value=camera;
$('#editRuleZone').href=`../sense/index.html?view=device-detail&camera=${encodeURIComponent(camera)}&return=bell-rule`; const zone=routeParams.get('zone'); if(zone&&[...$('#ruleZone').options].some(option=>option.value===zone)) $('#ruleZone').value=zone;
$('#ruleContextCamera').textContent=$('#ruleCamera').selectedOptions[0].textContent; $('#ruleContextZone').textContent=zone||$('#ruleZone').value;
syncSenseZoneLink();
notify('已保留摄像头与检测区域上下文,可继续配置规则。'); notify('已保留摄像头与检测区域上下文,可继续配置规则。');
} }
</script> </script>
+227 -95
View File
@@ -14,10 +14,10 @@
--surface-3: #17263a; --surface-3: #17263a;
--text: #f2f7fb; --text: #f2f7fb;
--muted: #9fb0c5; --muted: #9fb0c5;
--subtle: #71849c; --subtle: #8498b0;
--line: #27384d; --line: #27384d;
--primary: #38bdf8; --primary: #38bdf8;
--primary-strong: #0ea5e9; --primary-strong: #0369a1;
--primary-soft: rgba(56, 189, 248, .14); --primary-soft: rgba(56, 189, 248, .14);
--success: #34d399; --success: #34d399;
--warning: #fbbf24; --warning: #fbbf24;
@@ -35,7 +35,9 @@
--surface-3: #eaf1f6; --surface-3: #eaf1f6;
--text: #112033; --text: #112033;
--muted: #50647b; --muted: #50647b;
--subtle: #718096; --subtle: #596c82;
--primary: #0369a1;
--primary-strong: #075985;
--line: #cfdae5; --line: #cfdae5;
--primary-soft: rgba(2, 132, 199, .11); --primary-soft: rgba(2, 132, 199, .11);
--shadow: 0 14px 34px rgba(38, 64, 92, .12); --shadow: 0 14px 34px rgba(38, 64, 92, .12);
@@ -93,19 +95,19 @@
.site-select span { color: var(--muted); font-size: 12px; } .site-select span { color: var(--muted); font-size: 12px; }
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; } .top-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.select, .field, .textarea { .select, .field, .textarea {
width: 100%; min-height: 40px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); padding: 8px 11px; width: 100%; min-height: 44px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); padding: 8px 11px;
} }
.textarea { min-height: 74px; resize: vertical; } .textarea { min-height: 74px; resize: vertical; }
.top-actions .select { width: auto; min-width: 126px; } .top-actions .select { width: auto; min-width: 126px; }
.btn { .btn {
min-height: 40px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 13px; background: var(--surface-2); color: var(--text); min-height: 44px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 13px; background: var(--surface-2); color: var(--text);
display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; text-decoration: none; transition: border-color .2s ease, background .2s ease, opacity .2s ease; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; text-decoration: none; transition: border-color .2s ease, background .2s ease, opacity .2s ease;
} }
.btn:hover { border-color: var(--primary); } .btn:hover { border-color: var(--primary); }
.btn-primary { background: var(--primary-strong); border-color: var(--primary-strong); color: #fff; } .btn-primary { background: var(--primary-strong); border-color: var(--primary-strong); color: #fff; }
.btn-danger { color: #ffd8df; border-color: rgba(251, 113, 133, .45); background: rgba(251, 113, 133, .1); } .btn-danger { color: #ffd8df; border-color: rgba(251, 113, 133, .45); background: rgba(251, 113, 133, .1); }
.btn-quiet { border-color: transparent; background: transparent; color: var(--muted); } .btn-quiet { border-color: transparent; background: transparent; color: var(--muted); }
.icon-btn { width: 42px; padding: 0; } .icon-btn { width: 44px; padding: 0; }
.content { padding: 22px; } .content { padding: 22px; }
.view { display: none; animation: reveal .22s ease-out; } .view { display: none; animation: reveal .22s ease-out; }
.view.active { display: block; } .view.active { display: block; }
@@ -141,14 +143,19 @@
.search .icon { position: absolute; left: 11px; top: 11px; color: var(--subtle); pointer-events: none; } .search .icon { position: absolute; left: 11px; top: 11px; color: var(--subtle); pointer-events: none; }
.search input { padding-left: 38px; } .search input { padding-left: 38px; }
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 960px; } table { width: 100%; border-collapse: collapse; min-width: 1040px; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; } th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
th { color: var(--muted); background: var(--surface-2); font-size: 12px; font-weight: 700; } th { color: var(--muted); background: var(--surface-2); font-size: 12px; font-weight: 700; }
td { font-variant-numeric: tabular-nums; } td { font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
tr:hover td { background: color-mix(in srgb, var(--primary-soft) 45%, transparent); } tr:hover td { background: color-mix(in srgb, var(--primary-soft) 45%, transparent); }
.device-name { display: flex; align-items: center; gap: 10px; } .device-name { display: flex; align-items: center; gap: 10px; }
.camera-icon { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface-3); color: var(--primary); } .camera-icon { width: 38px; height: 38px; border-radius: 9px; display: grid; place-items: center; background: var(--surface-3); color: var(--primary); }
.camera-icon.radar { color: var(--warning); }
.capabilities { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
.capability { color: var(--muted); font-size: 11px; }
.pagination { padding: 12px 14px; display: flex; justify-content: space-between; align-items: center; gap: 12px; border-top: 1px solid var(--line); }
.pagination-actions { display: flex; gap: 6px; }
.device-name small { display: block; color: var(--muted); } .device-name small { display: block; color: var(--muted); }
.camera-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .camera-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.camera-card { overflow: hidden; } .camera-card { overflow: hidden; }
@@ -158,10 +165,10 @@
.camera-meta { padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .camera-meta { padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.camera-meta small { color: var(--muted); } .camera-meta small { color: var(--muted); }
.breadcrumb { margin-bottom: 10px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; } .breadcrumb { margin-bottom: 10px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; }
.breadcrumb button { min-height: 32px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--primary); font-weight: 700; } .breadcrumb button { min-height: 44px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--primary); font-weight: 700; }
.breadcrumb button:hover { background: var(--primary-soft); } .breadcrumb button:hover { background: var(--primary-soft); }
.detail-tabs { margin-bottom: 12px; padding: 5px; display: flex; gap: 5px; overflow-x: auto; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); } .detail-tabs { margin-bottom: 12px; padding: 5px; display: flex; gap: 5px; overflow-x: auto; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); }
.detail-tabs button { min-height: 40px; padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); white-space: nowrap; } .detail-tabs button { min-height: 44px; padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); white-space: nowrap; }
.detail-tabs button.active { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 -2px var(--primary); } .detail-tabs button.active { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 -2px var(--primary); }
.detail-panel[hidden] { display: none; } .detail-panel[hidden] { display: none; }
.context-strip { margin-bottom: 12px; padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid rgba(167,139,250,.38); border-radius: 10px; background: rgba(167,139,250,.1); } .context-strip { margin-bottom: 12px; padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid rgba(167,139,250,.38); border-radius: 10px; background: rgba(167,139,250,.1); }
@@ -176,7 +183,7 @@
.mini-thumb { width: 54px; height: 34px; border-radius: 6px; background: linear-gradient(145deg, #24364c, #0a101a); position: relative; overflow: hidden; } .mini-thumb { width: 54px; height: 34px; border-radius: 6px; background: linear-gradient(145deg, #24364c, #0a101a); position: relative; overflow: hidden; }
.mini-thumb::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: 8px; height: 1px; background: var(--primary); transform: rotate(-12deg); } .mini-thumb::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: 8px; height: 1px; background: var(--primary); transform: rotate(-12deg); }
.editor-tools { padding: 10px; display: flex; gap: 6px; align-items: center; border-bottom: 1px solid var(--line); overflow-x: auto; } .editor-tools { padding: 10px; display: flex; gap: 6px; align-items: center; border-bottom: 1px solid var(--line); overflow-x: auto; }
.tool-btn { min-width: 42px; min-height: 40px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--muted); display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; } .tool-btn { min-width: 44px; min-height: 44px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--muted); display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
.tool-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--text); } .tool-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--text); }
.tool-divider { width: 1px; align-self: stretch; background: var(--line); margin: 0 3px; } .tool-divider { width: 1px; align-self: stretch; background: var(--line); margin: 0 3px; }
.editor-stage { position: relative; aspect-ratio: 16/9; min-height: 340px; background: #030a12; overflow: hidden; } .editor-stage { position: relative; aspect-ratio: 16/9; min-height: 340px; background: #030a12; overflow: hidden; }
@@ -198,6 +205,17 @@
.coordinate-box h3 { margin: 0 0 4px; font-size: 13px; } .coordinate-box h3 { margin: 0 0 4px; font-size: 13px; }
.coordinate-box p { margin: 0 0 10px; color: var(--muted); font-size: 12px; } .coordinate-box p { margin: 0 0 10px; color: var(--muted); font-size: 12px; }
.coordinate-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; } .coordinate-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; }
.point-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 6px; }
.point-item { display: grid; grid-template-columns: auto 1fr 1fr auto; align-items: center; gap: 6px; }
.point-item .field { min-width: 0; }
.point-index { color: var(--muted); font-size: 12px; }
.notice { margin: 12px 0 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--muted); }
.notice.warning { border-color: rgba(251,191,36,.38); background: rgba(251,191,36,.08); color: #ffe39a; }
.operation-grid { display: grid; gap: 8px; }
.operation-option { min-height: 72px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: start; cursor: pointer; }
.operation-option:has(input:checked) { border-color: var(--primary); background: var(--primary-soft); }
.operation-option strong, .operation-option small { display: block; }
.operation-option small { color: var(--muted); }
.linked-rules { margin-top: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); } .linked-rules { margin-top: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.linked-rules h3 { margin: 0 0 4px; font-size: 13px; } .linked-rules h3 { margin: 0 0 4px; font-size: 13px; }
.linked-rules p { margin: 0 0 10px; color: var(--muted); font-size: 12px; } .linked-rules p { margin: 0 0 10px; color: var(--muted); font-size: 12px; }
@@ -271,6 +289,9 @@
.editor-stage { min-height: 0; } .editor-stage { min-height: 0; }
.coordinate-row { grid-template-columns: 1fr 1fr; } .coordinate-row { grid-template-columns: 1fr 1fr; }
.coordinate-row .btn { grid-column: 1/-1; } .coordinate-row .btn { grid-column: 1/-1; }
.point-item { grid-template-columns: auto 1fr 1fr; }
.point-item .btn { grid-column: 2/-1; }
input, select, textarea { font-size: 16px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
@@ -282,6 +303,7 @@
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></symbol> <symbol id="i-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></symbol>
<symbol id="i-video" viewBox="0 0 24 24"><rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 10 4-2v8l-4-2z"/></symbol> <symbol id="i-video" viewBox="0 0 24 24"><rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 10 4-2v8l-4-2z"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M5 7h3l1.4-2h5.2L16 7h3a2 2 0 0 1 2 2v9H3V9a2 2 0 0 1 2-2Z"/><circle cx="12" cy="12.5" r="3"/></symbol> <symbol id="i-camera" viewBox="0 0 24 24"><path d="M5 7h3l1.4-2h5.2L16 7h3a2 2 0 0 1 2 2v9H3V9a2 2 0 0 1 2-2Z"/><circle cx="12" cy="12.5" r="3"/></symbol>
<symbol id="i-radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4m8.4-8.4a6 6 0 0 1 0 8.4M4.6 4.6a10.5 10.5 0 0 0 0 14.8m14.8-14.8a10.5 10.5 0 0 1 0 14.8"/></symbol>
<symbol id="i-zone" viewBox="0 0 24 24"><path d="m5 5 14 2-2 12-12-3Z"/><circle cx="5" cy="5" r="1.5"/><circle cx="19" cy="7" r="1.5"/><circle cx="17" cy="19" r="1.5"/><circle cx="5" cy="16" r="1.5"/></symbol> <symbol id="i-zone" viewBox="0 0 24 24"><path d="m5 5 14 2-2 12-12-3Z"/><circle cx="5" cy="5" r="1.5"/><circle cx="19" cy="7" r="1.5"/><circle cx="17" cy="19" r="1.5"/><circle cx="5" cy="16" r="1.5"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 16V4m0 0L7 9m5-5 5 5"/><path d="M4 15v5h16v-5"/></symbol> <symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 16V4m0 0L7 9m5-5 5 5"/><path d="M4 15v5h16v-5"/></symbol>
<symbol id="i-pulse" viewBox="0 0 24 24"><path d="M3 12h4l2-6 4 12 2-6h6"/></symbol> <symbol id="i-pulse" viewBox="0 0 24 24"><path d="M3 12h4l2-6 4 12 2-6h6"/></symbol>
@@ -307,18 +329,18 @@
<div class="brand"><div class="brand-mark">Y</div><div class="brand-copy"><strong>YoVision</strong><small>Sense · Edge Console</small></div></div> <div class="brand"><div class="brand-mark">Y</div><div class="brand-copy"><strong>YoVision</strong><small>Sense · Edge Console</small></div></div>
<div class="nav-label">Workspace</div> <div class="nav-label">Workspace</div>
<nav class="nav"> <nav class="nav">
<button data-view="overview"><svg class="icon"><use href="#i-grid"/></svg><span>运行总览</span></button> <button data-view="overview" aria-label="运行总览"><svg class="icon"><use href="#i-grid"/></svg><span>运行总览</span></button>
<button data-view="monitor"><svg class="icon"><use href="#i-video"/></svg><span>实时监控</span><span class="count">4</span></button> <button data-view="monitor" aria-label="实时监控,当前 4 路"><svg class="icon"><use href="#i-video"/></svg><span>实时监控</span><span class="count">4</span></button>
<button data-view="devices" aria-current="page"><svg class="icon"><use href="#i-camera"/></svg><span>摄像头</span><span class="count">16</span></button> <button data-view="devices" aria-current="page" aria-label="设备,共 17 台"><svg class="icon"><use href="#i-camera"/></svg><span>设备</span><span class="count">17</span></button>
<button data-view="onboarding"><svg class="icon"><use href="#i-upload"/></svg><span>接入任务</span><span class="count">1</span></button> <button data-view="onboarding" aria-label="接入任务,1 个进行中"><svg class="icon"><use href="#i-upload"/></svg><span>接入任务</span><span class="count">1</span></button>
<button data-view="system"><svg class="icon"><use href="#i-pulse"/></svg><span>系统状态</span></button> <button data-view="system" aria-label="系统状态"><svg class="icon"><use href="#i-pulse"/></svg><span>系统状态</span></button>
</nav> </nav>
<div class="sidebar-foot"><div class="online-row"><span class="dot"></span><strong>边缘节点在线</strong></div><div class="copy">sense-edge-01<br><span class="mono">v0.1-prototype</span></div></div> <div class="sidebar-foot"><div class="online-row"><span class="dot"></span><strong>边缘节点在线</strong></div><div class="copy">sense-edge-01<br><span class="mono">v0.1-prototype</span></div></div>
</aside> </aside>
<main class="main" id="main-content" tabindex="-1"> <main class="main" id="main-content" tabindex="-1">
<header class="topbar"> <header class="topbar">
<div class="site-select"><div><strong>青藤寄宿学校</strong><span> / 主校区 · 16 / 16 路</span></div><svg class="icon"><use href="#i-chevron"/></svg></div> <div class="site-select"><div><strong>青藤寄宿学校</strong><span> / 主校区 · 视频 16 / 16 路 · 设备 17 台</span></div><svg class="icon"><use href="#i-chevron"/></svg></div>
<div class="top-actions"> <div class="top-actions">
<select class="select" id="stateSelect" aria-label="原型状态演示"> <select class="select" id="stateSelect" aria-label="原型状态演示">
<option value="normal">正常状态</option><option value="loading">加载中</option><option value="empty">空数据</option><option value="error">连接失败</option> <option value="normal">正常状态</option><option value="loading">加载中</option><option value="empty">空数据</option><option value="error">连接失败</option>
@@ -334,16 +356,16 @@
<section class="view" data-page="overview"> <section class="view" data-page="overview">
<div class="page-head"><div><h1>运行总览</h1><p>先判断是否影响业务,再进入设备或分片定位原因。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-refresh"/></svg>立即对账</button></div></div> <div class="page-head"><div><h1>运行总览</h1><p>先判断是否影响业务,再进入设备或分片定位原因。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-refresh"/></svg>立即对账</button></div></div>
<div class="grid stats"> <div class="grid stats">
<article class="card stat"><div class="stat-top"><span>在线设备</span><svg class="icon"><use href="#i-camera"/></svg></div><strong>15 / 16</strong><span class="trend">93.8% · 1 路重连中</span></article> <article class="card stat"><div class="stat-top"><span>在线设备</span><svg class="icon"><use href="#i-camera"/></svg></div><strong>16 / 17</strong><span class="trend">94.1% · 1 路视频重连中</span></article>
<article class="card stat"><div class="stat-top"><span>活跃媒体流</span><svg class="icon"><use href="#i-video"/></svg></div><strong>15</strong><span class="trend">主/子码流均衡</span></article> <article class="card stat"><div class="stat-top"><span>活跃媒体流</span><svg class="icon"><use href="#i-video"/></svg></div><strong>15</strong><span class="trend">主/子码流均衡</span></article>
<article class="card stat"><div class="stat-top"><span>待收敛项</span><svg class="icon"><use href="#i-refresh"/></svg></div><strong>2</strong><span class="muted">配置 1 · 时间漂移 1</span></article> <article class="card stat"><div class="stat-top"><span>待收敛项</span><svg class="icon"><use href="#i-refresh"/></svg></div><strong>2</strong><span class="muted">配置 1 · 时间漂移 1</span></article>
<article class="card stat"><div class="stat-top"><span>今日设备告警</span><svg class="icon"><use href="#i-alert"/></svg></div><strong>3</strong><span class="muted">与业务预警分开</span></article> <article class="card stat"><div class="stat-top"><span>今日设备告警</span><svg class="icon"><use href="#i-alert"/></svg></div><strong>3</strong><span class="muted">与业务预警分开</span></article>
</div> </div>
<div class="grid split"> <div class="grid split">
<article class="card"><div class="card-head"><h2>分片健康</h2><span class="badge success">全部可用</span></div><div class="card-body health-list"> <article class="card"><div class="card-head"><h2>分片健康</h2><span class="badge success">全部可用</span></div><div class="card-body health-list">
<div class="health-row"><span>media-01</span><strong>8 / 32</strong><div class="meter"><i style="width:25%"></i></div></div> <div class="health-row"><span>当前媒体分片 media-01</span><strong>16 / 32 路</strong><div class="meter"><i style="width:50%"></i></div></div>
<div class="health-row"><span>media-02</span><strong>8 / 32</strong><div class="meter"><i style="width:25%"></i></div></div> <div class="health-row"><span>分片规模</span><strong>当前 1 / 最多 4</strong><div class="meter"><i style="width:25%"></i></div></div>
<div class="health-row"><span>推理绑定</span><strong>16 / 16</strong><div class="meter"><i style="width:100%;background:var(--violet)"></i></div></div> <div class="health-row"><span>推理配置收敛</span><strong>16 / 16 路</strong><div class="meter"><i style="width:100%;background:var(--success)"></i></div></div>
</div></article> </div></article>
<article class="card"><div class="card-head"><h2>需要处理</h2><span class="badge warning">2 项</span></div><div class="card-body"> <article class="card"><div class="card-head"><h2>需要处理</h2><span class="badge warning">2 项</span></div><div class="card-body">
<p><strong>北围墙 03</strong><br><span class="muted">断线 02:14 · 正在第 4 次重连</span></p> <p><strong>北围墙 03</strong><br><span class="muted">断线 02:14 · 正在第 4 次重连</span></p>
@@ -358,29 +380,30 @@
</section> </section>
<section class="view active" data-page="devices"> <section class="view active" data-page="devices">
<div class="page-head"><div><h1>摄像头</h1><p>期望态与实际态分开展示;连接地址和凭据始终脱敏。</p></div><div class="page-actions"><button class="btn">扫描设备</button><button class="btn btn-primary" id="openCameraDialog"><svg class="icon"><use href="#i-plus"/></svg>添加摄像头</button></div></div> <div class="page-head"><div><h1>设备</h1><p>统一设备台账按类型与能力组织;期望态、实际态和连接信息相互独立。</p></div><div class="page-actions"><button class="btn">发现设备</button><button class="btn btn-primary" id="openDeviceDialog"><svg class="icon"><use href="#i-plus"/></svg>添加设备</button></div></div>
<article class="card"> <article class="card">
<div class="card-head"><div class="toolbar"><div class="search"><svg class="icon"><use href="#i-search"/></svg><input class="field" aria-label="搜索摄像头" placeholder="名称、序列号或点位"></div><select class="select" aria-label="设备状态筛选"><option>全部状态</option><option>异常</option><option>重连中</option></select></div><span class="muted">已用 16 / 128</span></div> <div class="card-head"><div class="toolbar"><div class="search"><svg class="icon"><use href="#i-search"/></svg><input class="field" id="deviceSearch" aria-label="搜索设备" placeholder="名称、序列号或点位"></div><select class="select" id="modalityFilter" aria-label="设备类型筛选"><option value="all">全部类型</option><option value="video">视频</option><option value="radar">雷达</option></select><select class="select" id="statusFilter" aria-label="设备状态筛选"><option value="all">全部状态</option><option value="exception">异常</option><option value="online">在线</option></select></div><span class="muted">视频配额 16 / 128 · 其他设备不占视频路数</span></div>
<div class="table-wrap"><table><thead><tr><th><input type="checkbox" aria-label="选择全部设备"></th><th>设备</th><th>期望态</th><th>实际态</th><th>最后在线</th><th>时间漂移</th><th>媒体分片</th><th>检测区域</th><th>操作</th></tr></thead><tbody id="deviceRows"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th><input type="checkbox" aria-label="选择当前页全部设备"></th><th>设备</th><th>类型与能力</th><th>期望态</th><th>实际态</th><th>最后遥测</th><th>连接适配器</th><th>健康摘要</th><th>配置</th><th>操作</th></tr></thead><tbody id="deviceRows"></tbody></table></div>
<div class="pagination"><span id="devicePageSummary" class="muted">第 1 页 · 共 17 台设备</span><div class="pagination-actions"><button class="btn" id="previousDevicePage" disabled>上一页</button><button class="btn" id="nextDevicePage">下一页</button></div></div>
</article> </article>
</section> </section>
<section class="view" data-page="device-detail"> <section class="view" data-page="device-detail">
<div class="context-strip" id="ruleReturnContext" hidden><p><strong>来自 Bell 规则草稿:</strong>已保留“非授权人员进入”的摄像头上下文;保存检测区域后可返回继续配置。</p><a class="btn" id="returnToBell" href="../bell/index.html?view=rules&amp;camera=CAM-S2-001&amp;zone=ZONE-007">返回规则草稿</a></div> <div class="context-strip" id="ruleReturnContext" hidden><p><strong>来自 Bell 规则草稿:</strong>已保留摄像头与区域上下文;保存空间坐标后可返回继续配置规则。</p><a class="btn" id="returnToBell" href="../bell/index.html?view=rules&amp;camera=CAM-S2-001&amp;zone=ZONE-007&amp;return_to=sense-device">返回规则草稿</a></div>
<div class="breadcrumb"><button id="backToDevices">摄像头</button><span>/</span><span id="deviceBreadcrumb">北围墙 01</span></div> <div class="breadcrumb"><button id="backToDevices">设备</button><span>/</span><span id="deviceBreadcrumb">北围墙 01</span></div>
<div class="page-head"><div><h1 id="deviceDetailName">北围墙 01</h1><p><span id="deviceDetailSerial" class="mono">CAM-S2-001</span> · 当前摄像头的接入、画面与空间配置。</p></div><div class="page-actions"><span class="badge success" id="calibrationBadge">区域已校准</span><button class="btn" id="simulateCalibration">模拟画面参数变化</button></div></div> <div class="page-head"><div><h1 id="deviceDetailName">北围墙 01</h1><p><span id="deviceDetailSerial" class="mono">CAM-S2-001</span> · <span id="deviceDetailDescription">视频接入、画面与空间配置。</span></p></div><div class="page-actions"><span class="badge success" id="calibrationBadge">区域已校准</span><button class="btn" id="simulateCalibration">模拟画面参数变化</button><button class="btn btn-danger" id="openOperationDialog">停止或隔离…</button></div></div>
<div class="detail-tabs" role="tablist" aria-label="摄像头详情"> <div class="detail-tabs" role="tablist" aria-label="设备详情">
<button role="tab" data-detail-tab="info" aria-selected="false">基本信息</button> <button id="tab-info" role="tab" data-detail-tab="info" aria-controls="panel-info" aria-selected="false" tabindex="-1">基本信息</button>
<button class="active" role="tab" data-detail-tab="zones" aria-selected="true">画面与检测区域</button> <button id="tab-zones" class="active" role="tab" data-detail-tab="zones" aria-controls="panel-zones" aria-selected="true" tabindex="0">画面与检测区域</button>
<button role="tab" data-detail-tab="health" aria-selected="false">流与健康</button> <button id="tab-health" role="tab" data-detail-tab="health" aria-controls="panel-health" aria-selected="false" tabindex="-1">连接与健康</button>
<button role="tab" data-detail-tab="history" aria-selected="false">操作记录</button> <button id="tab-history" role="tab" data-detail-tab="history" aria-controls="panel-history" aria-selected="false" tabindex="-1">操作记录</button>
</div> </div>
<section class="detail-panel" data-detail-panel="info" hidden> <section id="panel-info" class="detail-panel" role="tabpanel" aria-labelledby="tab-info" data-detail-panel="info" hidden tabindex="0">
<div class="grid split"><article class="card"><div class="card-head"><h2>设备信息</h2><span class="badge success">在线</span></div><div class="card-body"><p><strong>点位:</strong>北围墙</p><p><strong>协议:</strong>ONVIF / RTSP</p><p><strong>凭据:</strong>已安全保存,页面不可见</p></div></article><article class="card"><div class="card-head"><h2>期望态</h2><span class="badge info">启用</span></div><div class="card-body"><p>媒体分片:media-01</p><p>Profile:子码流 704 × 576 · 5 FPS</p></div></article></div> <div class="grid split"><article class="card"><div class="card-head"><h2>设备信息</h2><span class="badge success" id="detailActualBadge">在线</span></div><div class="card-body"><p><strong>点位:</strong><span id="detailArea">北围墙</span></p><p><strong>类型:</strong><span id="detailModality">视频设备</span></p><p><strong>能力:</strong><span id="detailCapabilities">图像采集、视频流、区域坐标</span></p><p><strong>协议:</strong><span id="detailProtocol">ONVIF / RTSP</span></p><p><strong>凭据:</strong>已安全保存,页面不可见</p></div></article><article class="card"><div class="card-head"><h2>期望态</h2><span class="badge info">启用</span></div><div class="card-body"><p><strong>连接适配器:</strong><span id="detailAdapter">media-01</span></p><p id="detailProfile">Profile:子码流 704 × 576 · 5 FPS</p><p class="muted">设备类型决定可用功能,能力字段决定页面与操作,不按名称猜测。</p></div></article></div>
</section> </section>
<section class="detail-panel" data-detail-panel="zones"> <section id="panel-zones" class="detail-panel" role="tabpanel" aria-labelledby="tab-zones" data-detail-panel="zones" tabindex="0">
<div class="page-head"><div><h2 style="margin:0 0 4px">画面与检测区域</h2><p>区域依附当前摄像头坐标系;这里只保存空间几何,不发布告警规则。</p></div><div class="page-actions"><span class="badge info" id="draftBadge">区域 v7</span><button class="btn">区域版本记录</button></div></div> <div class="page-head"><div><h2 style="margin:0 0 4px">画面与检测区域</h2><p>区域依附当前摄像头坐标系;这里只保存空间几何,不发布告警规则。</p></div><div class="page-actions"><span class="badge info" id="draftBadge">区域 v7</span><button class="btn">区域版本记录</button></div></div>
<div class="zone-layout"> <div class="zone-layout">
<section class="card zone-workspace" aria-label="区域绘制工作区"> <section class="card zone-workspace" aria-label="区域绘制工作区">
@@ -390,18 +413,20 @@
<button class="tool-btn" data-tool="line" aria-pressed="false" title="绘制方向警戒线"><svg class="icon"><use href="#i-line"/></svg><span>警戒线</span></button> <button class="tool-btn" data-tool="line" aria-pressed="false" title="绘制方向警戒线"><svg class="icon"><use href="#i-line"/></svg><span>警戒线</span></button>
<span class="tool-divider"></span> <span class="tool-divider"></span>
<button class="tool-btn" id="undoPoint" title="撤销最后一个点"><svg class="icon"><use href="#i-undo"/></svg><span>撤销</span></button> <button class="tool-btn" id="undoPoint" title="撤销最后一个点"><svg class="icon"><use href="#i-undo"/></svg><span>撤销</span></button>
<button class="tool-btn" id="finishDrawing" title="显式完成当前图形"><svg class="icon"><use href="#i-check"/></svg><span>完成绘制</span></button>
<button class="tool-btn" id="reverseLine" title="反转警戒线方向" hidden><svg class="icon"><use href="#i-refresh"/></svg><span>反转方向</span></button>
<button class="tool-btn" id="clearPoints" title="清空当前草稿"><svg class="icon"><use href="#i-trash"/></svg><span>清空</span></button> <button class="tool-btn" id="clearPoints" title="清空当前草稿"><svg class="icon"><use href="#i-trash"/></svg><span>清空</span></button>
</div> </div>
<div class="editor-stage"> <div class="editor-stage">
<svg id="zoneCanvas" viewBox="0 0 960 540" role="img" aria-label="北围墙 01 子码流快照及区域叠加;可点击添加多边形顶点"> <svg id="zoneCanvas" viewBox="0 0 960 540" role="img" aria-label="北围墙 01 子码流快照及区域叠加;可点击添加多边形顶点">
<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#10283b"/><stop offset="1" stop-color="#07111d"/></linearGradient><pattern id="scan" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M0 8h8" stroke="#ffffff" stroke-opacity=".035"/></pattern></defs> <defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#10283b"/><stop offset="1" stop-color="#07111d"/></linearGradient><pattern id="scan" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M0 8h8" stroke="#ffffff" stroke-opacity=".035"/></pattern><marker id="draftArrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L0,6 L9,3 z" fill="#fbbf24"/></marker></defs>
<rect width="960" height="540" fill="url(#sky)"/><path d="M0 330 160 250 330 315 510 235 690 310 820 225 960 282V540H0Z" fill="#13283a"/><rect y="410" width="960" height="130" fill="#101a22"/><path d="M0 414h960M0 472h960" stroke="#5c7489" stroke-opacity=".45"/><path d="M170 300v240m230-245v245m250-250v250m190-280v280" stroke="#6f879b" stroke-width="4"/><path d="M0 345h960" stroke="#7890a4" stroke-width="5"/><rect width="960" height="540" fill="url(#scan)"/> <rect width="960" height="540" fill="url(#sky)"/><path d="M0 330 160 250 330 315 510 235 690 310 820 225 960 282V540H0Z" fill="#13283a"/><rect y="410" width="960" height="130" fill="#101a22"/><path d="M0 414h960M0 472h960" stroke="#5c7489" stroke-opacity=".45"/><path d="M170 300v240m230-245v245m250-250v250m190-280v280" stroke="#6f879b" stroke-width="4"/><path d="M0 345h960" stroke="#7890a4" stroke-width="5"/><rect width="960" height="540" fill="url(#scan)"/>
<polygon points="105,365 285,310 535,335 505,466 135,472" fill="#38bdf8" fill-opacity=".18" stroke="#38bdf8" stroke-width="4" stroke-dasharray="10 7"/> <polygon points="105,365 285,310 535,335 505,466 135,472" fill="#38bdf8" fill-opacity=".18" stroke="#38bdf8" stroke-width="4" stroke-dasharray="10 7"/>
<g fill="#07111d" stroke="#7dd3fc" stroke-width="4"><circle cx="105" cy="365" r="7"/><circle cx="285" cy="310" r="7"/><circle cx="535" cy="335" r="7"/><circle cx="505" cy="466" r="7"/><circle cx="135" cy="472" r="7"/></g> <g fill="#07111d" stroke="#7dd3fc" stroke-width="4"><circle cx="105" cy="365" r="7"/><circle cx="285" cy="310" r="7"/><circle cx="535" cy="335" r="7"/><circle cx="505" cy="466" r="7"/><circle cx="135" cy="472" r="7"/></g>
<line x1="650" y1="340" x2="855" y2="430" stroke="#fbbf24" stroke-width="5"/><path d="m760 382 30-2-18 24" fill="none" stroke="#fbbf24" stroke-width="5"/><g id="draftLayer"></g> <line x1="650" y1="340" x2="855" y2="430" stroke="#fbbf24" stroke-width="5"/><path d="m760 382 30-2-18 24" fill="none" stroke="#fbbf24" stroke-width="5"/><g id="draftLayer"></g>
<text x="26" y="38" fill="#d6e8f5" font-size="18" font-family="sans-serif">北围墙 01 · 子码流预览</text><text x="26" y="66" fill="#88a4b8" font-size="14" font-family="sans-serif">CAM-S2-001 · 704 × 576 · 5 FPS</text> <text x="26" y="38" fill="#d6e8f5" font-size="18" font-family="sans-serif">北围墙 01 · 子码流预览</text><text x="26" y="66" fill="#88a4b8" font-size="14" font-family="sans-serif">CAM-S2-001 · 704 × 576 · 5 FPS</text>
</svg> </svg>
<div class="editor-hint" id="editorHint">多边形:点击画面添加顶点,双击闭合;也可在右侧输入坐标。</div> <div class="editor-hint" id="editorHint">多边形:点击画面添加顶点,再选择“完成绘制”;也可在右侧输入坐标。</div>
</div> </div>
<div class="editor-status"><span id="pointStatus">当前草稿:0 个点</span><span>缩放 100% · 网格吸附关闭</span></div> <div class="editor-status"><span id="pointStatus">当前草稿:0 个点</span><span>缩放 100% · 网格吸附关闭</span></div>
</section> </section>
@@ -412,21 +437,22 @@
<div class="form-field"><label for="zoneType">空间类型</label><select id="zoneType" class="select"><option>禁入区域</option><option>方向警戒线</option><option>关注区域</option></select></div> <div class="form-field"><label for="zoneType">空间类型</label><select id="zoneType" class="select"><option>禁入区域</option><option>方向警戒线</option><option>关注区域</option></select></div>
<div class="form-field"><label for="zoneColor">叠加颜色</label><select id="zoneColor" class="select"><option>蓝色</option><option>琥珀色</option><option>红色</option></select></div> <div class="form-field"><label for="zoneColor">叠加颜色</label><select id="zoneColor" class="select"><option>蓝色</option><option>琥珀色</option><option>红色</option></select></div>
</div> </div>
<div class="coordinate-box"><h3>键盘坐标编辑</h3><p>坐标范围 0–100%,为鼠标绘制提供等效操作。</p><div class="coordinate-row"><input class="field" id="coordX" type="number" min="0" max="100" value="50" aria-label="横坐标百分比"><input class="field" id="coordY" type="number" min="0" max="100" value="50" aria-label="纵坐标百分比"><button class="btn" id="addCoordinate">添加点</button></div><ol id="pointList" class="muted" aria-live="polite"><li>尚未添加草稿点</li></ol></div> <div class="coordinate-box"><h3>键盘坐标编辑</h3><p>坐标范围 0–100%;每个顶点都可以修改或删除,作为鼠标绘制的等效操作。</p><div class="coordinate-row"><input class="field" id="coordX" type="number" min="0" max="100" value="50" aria-label="横坐标百分比"><input class="field" id="coordY" type="number" min="0" max="100" value="50" aria-label="纵坐标百分比"><button class="btn" id="addCoordinate">添加点</button></div><ol id="pointList" class="point-list" aria-live="polite"><li class="muted">尚未添加草稿点</li></ol></div>
<div class="linked-rules"><h3>关联的告警规则</h3><p>规则由 Bell 管理;Sense 只提供摄像头快照和版本化空间坐标。</p><ul><li>非授权人员进入 · 正式生效</li><li>区域内聚集 · 试运行</li></ul><a class="btn" id="configureRules" href="../bell/index.html?view=rules&amp;camera=CAM-S2-001&amp;zone=ZONE-007">在 Bell 配置规则</a></div> <div class="notice" id="autosaveStatus" role="status">草稿会自动保存在此浏览器会话中。</div>
<div class="settings-actions"><button class="btn" id="discardDraft">放弃修改</button><button class="btn btn-primary" id="saveDraft">保存区域版本</button></div> <div class="linked-rules"><h3>关联的告警规则</h3><p>规则由 Bell 管理;Sense 只提供摄像头快照和版本化空间坐标。</p><ul><li>非授权人员进入 · 正式生效</li><li>区域内聚集 · 试运行</li></ul><a class="btn" id="configureRules" href="../bell/index.html?view=rules&amp;camera=CAM-S2-001&amp;zone=ZONE-007&amp;return_to=sense-device">在 Bell 配置规则</a></div>
<div class="settings-actions"><button class="btn" id="simulateConflict">模拟版本冲突</button><button class="btn" id="discardDraft">放弃修改</button><button class="btn btn-primary" id="saveDraft">保存区域版本</button></div>
</div></aside> </div></aside>
</div> </div>
</section> </section>
<section class="detail-panel" data-detail-panel="health" hidden><div class="grid system-grid"><article class="card system-tile"><h3>媒体流</h3><dl><dt>实际态</dt><dd><span class="badge success">在线</span></dd><dt>分片</dt><dd>media-01</dd><dt>重连</dt><dd>0</dd></dl></article><article class="card system-tile"><h3>设备时间</h3><dl><dt>漂移</dt><dd>+0.2s</dd><dt>最近校时</dt><dd>5m 前</dd></dl></article><article class="card system-tile"><h3>区域校准</h3><dl><dt>坐标版本</dt><dd>v7</dd><dt>画面参数</dt><dd>匹配</dd></dl></article></div></section> <section id="panel-health" class="detail-panel" role="tabpanel" aria-labelledby="tab-health" data-detail-panel="health" hidden tabindex="0"><div class="grid system-grid"><article class="card system-tile"><h3 id="connectionHealthTitle">媒体连接</h3><dl><dt>实际态</dt><dd><span class="badge success">在线</span></dd><dt>适配器</dt><dd id="healthAdapter">media-01</dd><dt>重连</dt><dd>0</dd></dl></article><article class="card system-tile"><h3>设备时间</h3><dl><dt>漂移</dt><dd id="healthClock">+0.2s</dd><dt>最近校时</dt><dd>5m 前</dd></dl></article><article class="card system-tile" id="calibrationTile"><h3>区域校准</h3><dl><dt>坐标版本</dt><dd>v7</dd><dt>画面参数</dt><dd>匹配</dd></dl></article></div></section>
<section class="detail-panel" data-detail-panel="history" hidden><article class="card"><div class="card-head"><h2>操作记录</h2><span class="badge info">只读</span></div><div class="table-wrap"><table><thead><tr><th>时间</th><th>操作</th><th>对象</th><th>执行人</th><th>结果</th></tr></thead><tbody><tr><td>22:52</td><td>保存检测区域</td><td>ZONE-007 · v7</td><td>实施工程师</td><td><span class="badge success">成功</span></td></tr><tr><td>22:41</td><td>校验子码流参数</td><td>704 × 576</td><td>系统</td><td><span class="badge success">匹配</span></td></tr></tbody></table></div></article></section> <section id="panel-history" class="detail-panel" role="tabpanel" aria-labelledby="tab-history" data-detail-panel="history" hidden tabindex="0"><article class="card"><div class="card-head"><h2>操作记录</h2><span class="badge info">每次请求均审计</span></div><div class="table-wrap"><table><thead><tr><th>时间</th><th>操作</th><th>对象</th><th>执行人</th><th>结果</th></tr></thead><tbody id="historyRows"><tr><td>22:52</td><td>保存检测区域</td><td>ZONE-007 · v7</td><td>实施工程师</td><td><span class="badge success">成功</span></td></tr><tr><td>22:41</td><td>校验设备能力</td><td>CAM-S2-001</td><td>系统</td><td><span class="badge success">匹配</span></td></tr></tbody></table></div></article></section>
</section> </section>
<section class="view" data-page="onboarding"> <section class="view" data-page="onboarding">
<div class="page-head"><div><h1>接入任务</h1><p>默认 16 路也支持批量导入;128 路时不依赖逐路手工操作。</p></div><div class="page-actions"><button class="btn">下载 CSV 模板</button></div></div> <div class="page-head"><div><h1>接入任务</h1><p>默认 16 路视频也支持批量导入;扩至 128 路时不依赖逐路手工操作,非视频设备走对应适配器。</p></div><div class="page-actions"><button class="btn">下载 CSV 模板</button></div></div>
<div class="grid split"> <div class="grid split">
<article class="card"><div class="card-head"><h2>导入摄像头</h2><span class="badge info">配额 16 / 128</span></div><div class="card-body"><div class="drop-zone"><svg class="icon"><use href="#i-upload"/></svg><strong>选择 CSV 文件或拖到这里</strong><p>先本地校验列、重复序列号和配额,不上传明文密码。</p><button class="btn btn-primary" id="fakeUpload">选择文件</button></div></div></article> <article class="card"><div class="card-head"><h2>批量导入视频设备</h2><span class="badge info">视频配额 16 / 128</span></div><div class="card-body"><div class="drop-zone"><svg class="icon"><use href="#i-upload"/></svg><strong>选择 CSV 文件或拖到这里</strong><p>先本地校验列、重复序列号、区域隐私策略和配额,不上传明文密码。</p><button class="btn btn-primary" id="fakeUpload">选择文件</button></div></div></article>
<article class="card"><div class="card-head"><h2>当前任务</h2><span class="badge warning">进行中</span></div><div class="card-body"><p><strong>宿舍区第二批 · 8 路</strong></p><div class="meter"><i style="width:62%"></i></div><p class="muted">已验证 5 / 8 · 2 路待补凭据 · 1 路重试中</p><button class="btn">查看逐项结果</button></div></article> <article class="card"><div class="card-head"><h2>当前任务</h2><span class="badge warning">进行中</span></div><div class="card-body"><p><strong>宿舍区第二批 · 8 路</strong></p><div class="meter"><i style="width:62%"></i></div><p class="muted">已验证 5 / 8 · 2 路待补凭据 · 1 路重试中</p><button class="btn">查看逐项结果</button></div></article>
</div> </div>
</section> </section>
@@ -444,22 +470,42 @@
</div> </div>
<nav class="mobile-nav" aria-label="移动端主导航"> <nav class="mobile-nav" aria-label="移动端主导航">
<button data-view="overview">总览</button><button data-view="monitor">监控</button><button class="active" data-view="devices">设备</button><button data-view="onboarding">接入</button><button data-view="system">系统</button> <button data-view="overview">总览</button><button data-view="monitor">监控</button><button class="active" data-view="devices" aria-current="page">设备</button><button data-view="onboarding">接入</button><button data-view="system">系统</button>
</nav> </nav>
<dialog id="cameraDialog" aria-labelledby="cameraDialogTitle"> <dialog id="deviceDialog" aria-labelledby="deviceDialogTitle">
<form method="dialog"> <form method="dialog">
<div class="dialog-head"><h2 id="cameraDialogTitle">添加摄像头</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div> <div class="dialog-head"><h2 id="deviceDialogTitle">添加设备</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭添加设备对话框">×</button></div>
<div class="dialog-body"><div class="form-grid"> <div class="dialog-body"><div class="form-grid">
<div class="form-field"><label for="cameraName">名称</label><input class="field" id="cameraName" required value="北围墙 04"></div> <div class="form-field"><label for="deviceModality">设备类型</label><select class="select" id="deviceModality"><option value="video">视频设备</option><option value="radar">毫米波雷达</option></select></div>
<div class="form-field"><label for="cameraProtocol">协议</label><select class="select" id="cameraProtocol"><option>ONVIF</option><option>RTSP</option></select></div> <div class="form-field"><label for="deviceArea">所属区域</label><select class="select" id="deviceArea"><option value="north" data-capture-policy="video_allowed">北围墙 · 允许成像</option><option value="lab" data-capture-policy="video_allowed">实验楼 · 允许成像</option><option value="dorm" data-capture-policy="video_allowed">宿舍区 · 允许成像</option><option value="privacy" data-capture-policy="non_imaging_only">心理辅导室 · 仅非成像设备</option></select></div>
<div class="form-field full"><label for="cameraEndpoint">设备地址</label><input class="field" id="cameraEndpoint" value="192.0.2.24:80" aria-describedby="endpointHelp"><small id="endpointHelp" class="muted">原型使用保留地址;保存后列表仅显示脱敏端点。</small></div> <div class="form-field"><label for="deviceName">名称</label><input class="field" id="deviceName" required value="北围墙 04"></div>
<div class="form-field"><label for="cameraUser">用户名</label><input class="field" id="cameraUser" value="operator"></div> <div class="form-field"><label for="deviceSerial">序列号</label><input class="field" id="deviceSerial" value="CAM-DEMO-004"></div>
<div class="form-field"><label for="cameraPassword">密码</label><input class="field" id="cameraPassword" type="password" value="prototype-only"></div> <div class="form-field"><label for="deviceProtocol">协议</label><select class="select" id="deviceProtocol"><option value="onvif">ONVIF</option><option value="rtsp">RTSP</option><option value="mqtt">MQTT</option></select></div>
<div class="form-field"><label for="cameraArea">区域</label><select class="select" id="cameraArea"><option>北围墙</option><option>实验楼</option><option>宿舍区</option></select></div> <div class="form-field full"><label for="deviceEndpoint">设备地址</label><input class="field" id="deviceEndpoint" value="192.0.2.24:80" aria-describedby="endpointHelp"><small id="endpointHelp" class="muted">原型使用保留地址;保存后列表仅显示脱敏端点。</small></div>
<div class="form-field"><label for="cameraSerial">序列号</label><input class="field" id="cameraSerial" value="CAM-DEMO-004"></div> <div class="form-field video-field"><label for="deviceUser">用户名</label><input class="field" id="deviceUser" value="operator"></div>
</div><p><button type="button" class="btn" id="testConnection">测试连接</button> <span id="testResult" class="muted" aria-live="polite">保存前先验证 Profiles、StreamUri 与校时能力。</span></p></div> <div class="form-field video-field"><label for="devicePassword">密码</label><input class="field" id="devicePassword" type="password" value="prototype-only"></div>
<div class="dialog-foot"><button class="btn" value="cancel">取消</button><button class="btn btn-primary" id="saveCamera" value="default">保存为待激活</button></div> <div class="form-field radar-field" hidden><label for="radarTopic">遥测主题</label><input class="field" id="radarTopic" value="sense/radar/demo"></div>
<div class="form-field radar-field" hidden><label for="radarInterval">上报周期</label><select class="select" id="radarInterval"><option>1 秒</option><option>5 秒</option><option>10 秒</option></select></div>
</div><div class="notice" id="privacyAdmission" role="status">当前区域允许图像采集;保存时仍会再次校验区域策略。</div><p><button type="button" class="btn" id="testConnection">测试连接</button> <span id="testResult" class="muted" aria-live="polite">保存前验证设备声明能力、连接与校时。</span></p></div>
<div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveDevice">保存为待激活</button></div>
</form>
</dialog>
<dialog id="operationDialog" aria-labelledby="operationDialogTitle">
<form method="dialog">
<div class="dialog-head"><h2 id="operationDialogTitle">停止或隔离设备</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭停止或隔离对话框">×</button></div>
<div class="dialog-body">
<p>目标:<strong id="operationDeviceName">北围墙 01</strong> <span id="operationDeviceSerial" class="mono">CAM-S2-001</span></p>
<div class="operation-grid" role="radiogroup" aria-label="选择操作">
<label class="operation-option"><input type="radio" name="deviceOperation" value="pause-inference" checked><span><strong>暂停推理</strong><small>保留媒体接入与实时预览,只停止新推理任务;已产生事件不删除。</small></span></label>
<label class="operation-option"><input type="radio" name="deviceOperation" value="disable-ingest"><span><strong>停用接入</strong><small>写入期望态“停用”,等待适配器停止拉流或遥测;会影响实时监控。</small></span></label>
<label class="operation-option"><input type="radio" name="deviceOperation" value="kick-session"><span><strong>断开当前会话</strong><small>只终止当前连接,期望态仍为启用,设备可能自动重连。</small></span></label>
</div>
<div class="notice warning" id="operationImpact">影响:停止新推理;媒体连接保持在线。执行后将显示期望态与实际态的收敛过程。</div>
<div class="notice" id="operationProgress" role="status" aria-live="polite">尚未执行。重复提交不会产生重复副作用,但每次请求都会记录审计。</div>
</div>
<div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-danger" id="confirmOperation">确认执行</button></div>
</form> </form>
</dialog> </dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div> <div class="toast" id="toast" role="status" aria-live="polite"></div>
@@ -472,13 +518,45 @@
function notify(message) { toast.textContent = message; toast.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('show'), 3600); } function notify(message) { toast.textContent = message; toast.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('show'), 3600); }
const deviceData = [ const deviceData = [
['北围墙 01','CAM-S2-001','启用','在线','刚刚','+0.2s','media-01','success','2 个区域','success'], {name:'北围墙 01',serial:'CAM-S2-001',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'在线',status:'success',last:'刚刚',adapter:'media-01',health:'时钟 +0.2s',config:'2 个区域',area:'北围墙',protocol:'ONVIF / RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'+0.2s'},
['北围墙 03','CAM-S2-003','启用','重连中','2 分钟前','—','media-01','warning','待校准','warning'], {name:'北围墙 03',serial:'CAM-S2-003',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'重连中',status:'warning',last:'2 分钟前',adapter:'media-01',health:'第 4 次重连',config:'待校准',area:'北围墙',protocol:'ONVIF / RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'未知'},
['实验楼入口 02','CAM-S2-006','启用','在线','12 秒前','+3.8s','media-02','warning','1 个区域','success'], {name:'实验楼入口 02',serial:'CAM-S2-006',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'在线',status:'success',last:'12 秒前',adapter:'media-01',health:'时钟 +3.8s',config:'1 个区域',area:'实验楼',protocol:'ONVIF / RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'+3.8s'},
['宿舍东通道','CAM-S2-011','启用','在线','4 秒前','+0.1s','media-02','success','3 个区域','success'], {name:'宿舍东通道',serial:'CAM-S2-011',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'在线',status:'success',last:'4 秒前',adapter:'media-01',health:'时钟 +0.1s',config:'3 个区域',area:'宿舍区',protocol:'RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'+0.1s'},
['配电房门口','CAM-S2-015','停用','已停止','1 天前','—','未绑定','info','未配置','info'] {name:'操场东侧雷达',serial:'RAD-S2-001',modality:'radar',modalityLabel:'毫米波雷达',capabilities:['目标距离','速度','方位角'],expected:'启用',actual:'在线',status:'success',last:'1 秒前',adapter:'mqtt-01',health:'丢包 0.1%',config:'1 Hz 上报',area:'操场',protocol:'MQTT',profile:'遥测主题 sense/radar/east · 1 Hz',clock:'+0.3s'},
{name:'配电房门口',serial:'CAM-S2-015',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流'],expected:'停用',actual:'已停止',status:'info',last:'1 天前',adapter:'未绑定',health:'期望态已收敛',config:'未配置',area:'配电房',protocol:'ONVIF',profile:'尚未选择 Profile',clock:'—'},
...Array.from({length:11},(_,i)=>({name:`教学楼通道 ${String(i+1).padStart(2,'0')}`,serial:`CAM-S2-${String(i+20).padStart(3,'0')}`,modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'在线',status:'success',last:`${i+2} 秒前`,adapter:'media-01',health:'健康',config:i%3===0?'1 个区域':'未配置',area:'教学楼',protocol:'ONVIF / RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'+0.2s'}))
]; ];
$('#deviceRows').innerHTML = deviceData.map(d => `<tr><td><input type="checkbox" aria-label="选择 ${d[0]}"></td><td><div class="device-name"><span class="camera-icon"><svg class="icon"><use href="#i-camera"/></svg></span><span><strong>${d[0]}</strong><small>${d[1]}</small></span></div></td><td>${d[2]}</td><td><span class="badge ${d[7]}">${d[3]}</span></td><td>${d[4]}</td><td>${d[5]}</td><td>${d[6]}</td><td><span class="badge ${d[9]}">${d[8]}</span></td><td><button class="btn" data-open-camera="${d[1]}">查看详情</button></td></tr>`).join(''); const pageSize = 5;
let devicePage = 1;
let currentDevice = deviceData[0];
function filteredDevices() {
const term = $('#deviceSearch').value.trim().toLowerCase();
const modality = $('#modalityFilter').value;
const status = $('#statusFilter').value;
return deviceData.filter(device => {
const termMatch = !term || `${device.name} ${device.serial} ${device.area}`.toLowerCase().includes(term);
const modalityMatch = modality === 'all' || device.modality === modality;
const statusMatch = status === 'all' || (status === 'exception' ? device.status === 'warning' : device.actual === '在线');
return termMatch && modalityMatch && statusMatch;
});
}
function renderDevices() {
const filtered = filteredDevices();
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
devicePage = Math.min(devicePage, pages);
const start = (devicePage - 1) * pageSize;
const rows = filtered.slice(start, start + pageSize);
$('#deviceRows').innerHTML = rows.length ? rows.map(device => {
const icon = device.modality === 'radar' ? 'i-radar' : 'i-camera';
return `<tr><td><input type="checkbox" aria-label="选择 ${device.name}"></td><td><div class="device-name"><span class="camera-icon ${device.modality}"><svg class="icon"><use href="#${icon}"/></svg></span><span><strong>${device.name}</strong><small>${device.serial} · ${device.area}</small></span></div></td><td><span class="badge info">${device.modalityLabel}</span><div class="capabilities">${device.capabilities.map(c=>`<span class="capability">${c}</span>`).join('<span aria-hidden="true">·</span>')}</div></td><td>${device.expected}</td><td><span class="badge ${device.status}">${device.actual}</span></td><td>${device.last}</td><td>${device.adapter}</td><td>${device.health}</td><td>${device.config}</td><td><div class="toolbar"><button class="btn" data-open-device="${device.serial}">详情</button><button class="btn" data-device-operation="${device.serial}" aria-label="停止或隔离 ${device.name}">操作…</button></div></td></tr>`;
}).join('') : '<tr><td colspan="10"><div class="drop-zone"><strong>没有匹配的设备</strong><p>调整类型、状态或搜索条件。</p></div></td></tr>';
$('#devicePageSummary').textContent = `第 ${devicePage} / ${pages} 页 · 共 ${filtered.length} 台设备`;
$('#previousDevicePage').disabled = devicePage <= 1;
$('#nextDevicePage').disabled = devicePage >= pages;
}
['deviceSearch','modalityFilter','statusFilter'].forEach(id => $(`#${id}`).addEventListener(id === 'deviceSearch' ? 'input' : 'change', () => { devicePage=1; renderDevices(); }));
$('#previousDevicePage').addEventListener('click',()=>{ devicePage--; renderDevices(); });
$('#nextDevicePage').addEventListener('click',()=>{ devicePage++; renderDevices(); });
const cameraNames = ['北围墙 01','实验楼入口 02','宿舍东通道','配电房门口']; const cameraNames = ['北围墙 01','实验楼入口 02','宿舍东通道','配电房门口'];
$('#cameraGrid').innerHTML = cameraNames.map((name, i) => `<article class="card camera-card"><div class="camera-stage"><svg class="scene" viewBox="0 0 640 360" role="img" aria-label="${name} 模拟子码流画面"><rect width="640" height="360" fill="#07111d"/><path d="M0 ${250-i*12} 140 180 310 230 460 150 640 210V360H0Z" fill="#14283a"/><path d="M0 290h640M100 210v150m210-165v165m220-190v190" stroke="#647b8e" stroke-width="4"/><rect width="640" height="360" fill="none" stroke="#ffffff" stroke-opacity=".08"/><text x="18" y="28" fill="#d7e7f3" font-size="16">${name} · 子码流</text><circle cx="598" cy="27" r="5" fill="#34d399"/></svg></div><div class="camera-meta"><span><strong>${name}</strong><small>704 × 576 · 5 FPS</small></span><span class="badge success">在线</span></div></article>`).join(''); $('#cameraGrid').innerHTML = cameraNames.map((name, i) => `<article class="card camera-card"><div class="camera-stage"><svg class="scene" viewBox="0 0 640 360" role="img" aria-label="${name} 模拟子码流画面"><rect width="640" height="360" fill="#07111d"/><path d="M0 ${250-i*12} 140 180 310 230 460 150 640 210V360H0Z" fill="#14283a"/><path d="M0 290h640M100 210v150m210-165v165m220-190v190" stroke="#647b8e" stroke-width="4"/><rect width="640" height="360" fill="none" stroke="#ffffff" stroke-opacity=".08"/><text x="18" y="28" fill="#d7e7f3" font-size="16">${name} · 子码流</text><circle cx="598" cy="27" r="5" fill="#34d399"/></svg></div><div class="camera-meta"><span><strong>${name}</strong><small>704 × 576 · 5 FPS</small></span><span class="badge success">在线</span></div></article>`).join('');
@@ -486,25 +564,41 @@
function switchView(name) { function switchView(name) {
$$('.view').forEach(v => v.classList.toggle('active', v.dataset.page === name)); $$('.view').forEach(v => v.classList.toggle('active', v.dataset.page === name));
const navName = name === 'device-detail' ? 'devices' : name; const navName = name === 'device-detail' ? 'devices' : name;
$$('[data-view]').forEach(b => { const current = b.dataset.view === navName; b.toggleAttribute('aria-current', current); b.classList.toggle('active', current); }); $$('[data-view]').forEach(b => { const on = b.dataset.view === navName; if (on) b.setAttribute('aria-current','page'); else b.removeAttribute('aria-current'); b.classList.toggle('active', on); });
$('#stateSelect').value = 'normal'; renderState('normal'); $('#stateSelect').value = 'normal'; renderState('normal');
$('#main-content').focus({preventScroll:true}); $('#main-content').focus({preventScroll:true});
} }
$$('[data-view]').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view))); $$('[data-view]').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view)));
function activateDetailTab(name, focus = false) {
const visibleTabs = $$('.detail-tabs [role="tab"]:not([hidden])');
const selected = visibleTabs.find(tab => tab.dataset.detailTab === name) || visibleTabs[0];
$$('.detail-tabs [role="tab"]').forEach(tab => { const on = tab === selected; tab.classList.toggle('active', on); tab.setAttribute('aria-selected', String(on)); tab.tabIndex = on ? 0 : -1; });
$$('[data-detail-panel]').forEach(panel => panel.hidden = panel.dataset.detailPanel !== selected.dataset.detailTab);
if (focus) selected.focus();
}
function openDeviceDetail(serial = 'CAM-S2-001') { function openDeviceDetail(serial = 'CAM-S2-001') {
const device = deviceData.find(d => d[1] === serial) || deviceData[0]; const device = deviceData.find(d => d.serial === serial) || deviceData[0];
$('#deviceDetailName').textContent = device[0]; $('#deviceBreadcrumb').textContent = device[0]; $('#deviceDetailSerial').textContent = device[1]; currentDevice = device;
const bellHref = `../bell/index.html?view=rules&camera=${encodeURIComponent(device[1])}&zone=ZONE-007`; $('#deviceDetailName').textContent = device.name; $('#deviceBreadcrumb').textContent = device.name; $('#deviceDetailSerial').textContent = device.serial;
$('#deviceDetailDescription').textContent = device.modality === 'video' ? '视频接入、画面与空间配置。' : '遥测接入、能力与健康配置。';
$('#detailArea').textContent=device.area; $('#detailModality').textContent=device.modalityLabel; $('#detailCapabilities').textContent=device.capabilities.join('、'); $('#detailProtocol').textContent=device.protocol; $('#detailAdapter').textContent=device.adapter; $('#detailProfile').textContent=device.profile;
$('#detailActualBadge').textContent=device.actual; $('#detailActualBadge').className=`badge ${device.status}`; $('#healthAdapter').textContent=device.adapter; $('#healthClock').textContent=device.clock;
const video = device.capabilities.includes('图像采集');
$('#tab-zones').hidden=!video; $('#calibrationBadge').hidden=!video; $('#simulateCalibration').hidden=!video; $('#calibrationTile').hidden=!video;
$('#connectionHealthTitle').textContent = video ? '媒体连接' : '遥测连接';
const requestedZone = new URLSearchParams(location.search).get('zone') || 'ZONE-007';
const bellHref = `../bell/index.html?view=rules&camera=${encodeURIComponent(device.serial)}&zone=${encodeURIComponent(requestedZone)}&return_to=sense-device`;
$('#configureRules').href = bellHref; $('#returnToBell').href = bellHref; $('#configureRules').href = bellHref; $('#returnToBell').href = bellHref;
if (video) restoreDraft(device.serial); else { points.length=0; drawDraft({persist:false}); }
activateDetailTab(video ? 'zones' : 'info');
switchView('device-detail'); switchView('device-detail');
} }
$$('[data-open-camera]').forEach(b => b.addEventListener('click', () => openDeviceDetail(b.dataset.openCamera))); $('#deviceRows').addEventListener('click', event => { const detail=event.target.closest('[data-open-device]'); const operation=event.target.closest('[data-device-operation]'); if (detail) openDeviceDetail(detail.dataset.openDevice); if (operation) openOperationDialog(operation.dataset.deviceOperation); });
$('#backToDevices').addEventListener('click', () => switchView('devices')); $('#backToDevices').addEventListener('click', () => switchView('devices'));
$$('.detail-tabs button').forEach(button => button.addEventListener('click', () => { $$('.detail-tabs [role="tab"]').forEach(tab => {
const name = button.dataset.detailTab; tab.addEventListener('click',()=>activateDetailTab(tab.dataset.detailTab));
$$('.detail-tabs button').forEach(b => { const on = b === button; b.classList.toggle('active', on); b.setAttribute('aria-selected', on); }); tab.addEventListener('keydown',event=>{ const tabs=$$('.detail-tabs [role="tab"]:not([hidden])'); const index=tabs.indexOf(tab); let next=index; if(event.key==='ArrowRight') next=(index+1)%tabs.length; else if(event.key==='ArrowLeft') next=(index-1+tabs.length)%tabs.length; else if(event.key==='Home') next=0; else if(event.key==='End') next=tabs.length-1; else return; event.preventDefault(); activateDetailTab(tabs[next].dataset.detailTab,true); });
$$('[data-detail-panel]').forEach(panel => panel.hidden = panel.dataset.detailPanel !== name); });
}));
function renderState(state) { function renderState(state) {
const layer = $('#stateLayer'); const views = $$('.view'); const layer = $('#stateLayer'); const views = $$('.view');
@@ -512,7 +606,7 @@
if (state === 'normal') return; if (state === 'normal') return;
const configs = { const configs = {
loading: `<div><div class="skeleton" aria-label="正在加载"><i></i><i></i><i></i><i></i></div><h2>正在同步边缘状态</h2><p>超过 300ms 时显示进度;已有视频流不因控制面加载而中断。</p></div>`, loading: `<div><div class="skeleton" aria-label="正在加载"><i></i><i></i><i></i><i></i></div><h2>正在同步边缘状态</h2><p>超过 300ms 时显示进度;已有视频流不因控制面加载而中断。</p></div>`,
empty: `<div><svg class="icon"><use href="#i-camera"/></svg><h2>这个站点还没有摄像头</h2><p>先扫描 ONVIF 设备,或下载 CSV 模板进行批量导入。</p><button class="btn btn-primary" onclick="switchView('onboarding')">开始接入</button></div>`, empty: `<div><svg class="icon"><use href="#i-camera"/></svg><h2>这个站点还没有设备</h2><p>可发现视频设备、手动添加非视频设备,或下载 CSV 模板批量导入视频设备。</p><button class="btn btn-primary" onclick="switchView('onboarding')">开始接入</button></div>`,
error: `<div><svg class="icon" style="color:var(--danger)"><use href="#i-alert"/></svg><h2>无法读取最新状态</h2><p>边缘节点暂时不可达。当前显示可能已过期,系统不会把未知状态伪装为在线。</p><button class="btn" onclick="document.querySelector('#stateSelect').value='normal';renderState('normal')">重试</button></div>` error: `<div><svg class="icon" style="color:var(--danger)"><use href="#i-alert"/></svg><h2>无法读取最新状态</h2><p>边缘节点暂时不可达。当前显示可能已过期,系统不会把未知状态伪装为在线。</p><button class="btn" onclick="document.querySelector('#stateSelect').value='normal';renderState('normal')">重试</button></div>`
}; layer.innerHTML = configs[state]; }; layer.innerHTML = configs[state];
} }
@@ -520,37 +614,75 @@
$('#themeToggle').addEventListener('click', () => { const html = document.documentElement; html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark'; notify(`已切换为${html.dataset.theme === 'dark' ? '深色' : '浅色'}主题`); }); $('#themeToggle').addEventListener('click', () => { const html = document.documentElement; html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark'; notify(`已切换为${html.dataset.theme === 'dark' ? '深色' : '浅色'}主题`); });
const zoneCanvas = $('#zoneCanvas'), draftLayer = $('#draftLayer'), points = []; const zoneCanvas = $('#zoneCanvas'), draftLayer = $('#draftLayer'), points = [];
let activeTool = 'polygon'; let activeTool = 'polygon', drawingComplete = false, serverVersion = 7, clickTimer;
function drawDraft() { const draftKey = serial => `yovision-sense-zone-draft:${serial}`;
const coords = points.map(p => `${p.x},${p.y}`).join(' '); function safeSession(action) { try { return action(sessionStorage); } catch { return undefined; } }
const shape = activeTool === 'line' && points.length > 1 ? `<line x1="${points[0].x}" y1="${points[0].y}" x2="${points[1].x}" y2="${points[1].y}" stroke="#fbbf24" stroke-width="5"/>` : `<polyline points="${coords}" fill="${points.length > 2 ? 'rgba(167,139,250,.18)' : 'none'}" stroke="#a78bfa" stroke-width="4" stroke-dasharray="8 6"/>`; function persistDraft() {
draftLayer.innerHTML = shape + points.map((p,i) => `<circle cx="${p.x}" cy="${p.y}" r="8" fill="#08111f" stroke="#c4b5fd" stroke-width="4"><title>点 ${i+1}</title></circle>`).join(''); if (!currentDevice || currentDevice.modality !== 'video') return;
$('#pointStatus').textContent = `当前草稿:${points.length} 个点`; const saved=safeSession(store=>{ store.setItem(draftKey(currentDevice.serial),JSON.stringify({points,activeTool,drawingComplete,baseVersion:serverVersion})); return true; });
$('#pointList').innerHTML = points.length ? points.map((p,i) => `<li>点 ${i+1}:${(p.x/9.6).toFixed(1)}%, ${(p.y/5.4).toFixed(1)}%</li>`).join('') : '<li>尚未添加草稿点</li>'; $('#autosaveStatus').textContent = saved ? `草稿已自动保存 · ${new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}` : '浏览器禁止会话存储;草稿仅在当前页面内保留。';
$('#draftBadge').className = points.length ? 'badge warning' : 'badge info';
$('#draftBadge').textContent = points.length ? '区域有未保存修改' : '区域 v7';
} }
function addPoint(x,y) { if (activeTool === 'pointer') return; if (activeTool === 'line' && points.length >= 2) points.length = 0; points.push({x:Math.max(0,Math.min(960,x)),y:Math.max(0,Math.min(540,y))}); drawDraft(); } function clearStoredDraft() { if(currentDevice) safeSession(store=>store.removeItem(draftKey(currentDevice.serial))); }
zoneCanvas.addEventListener('click', e => { const r = zoneCanvas.getBoundingClientRect(); addPoint((e.clientX-r.left)/r.width*960,(e.clientY-r.top)/r.height*540); }); function restoreDraft(serial) {
zoneCanvas.addEventListener('dblclick', e => { e.preventDefault(); if (activeTool === 'polygon' && points.length >= 3) notify(`多边形已闭合,共 ${points.length} 个顶点;请保存草稿。`); }); points.length=0; activeTool='polygon'; drawingComplete=false; serverVersion=7;
$$('.tool-btn[data-tool]').forEach(btn => btn.addEventListener('click', () => { activeTool = btn.dataset.tool; $$('.tool-btn[data-tool]').forEach(b => { const on = b === btn; b.classList.toggle('active', on); b.setAttribute('aria-pressed', on); }); points.length = 0; drawDraft(); $('#editorHint').textContent = activeTool === 'line' ? '警戒线:依次添加起点与终点;箭头方向由起点指向终点。' : activeTool === 'pointer' ? '选择:查看已有区域;切换绘制工具可新建草稿。' : '多边形:点击画面添加顶点,双击闭合;也可在右侧输入坐标。'; })); const raw=safeSession(store=>store.getItem(draftKey(serial)));
$('#undoPoint').addEventListener('click', () => { points.pop(); drawDraft(); }); if(raw) { try { const draft=JSON.parse(raw); points.push(...(draft.points||[])); activeTool=draft.activeTool||'polygon'; drawingComplete=Boolean(draft.drawingComplete); serverVersion=Number(draft.baseVersion)||7; $('#autosaveStatus').textContent='已恢复此设备在当前浏览器会话中的未保存草稿。'; } catch { clearStoredDraft(); } }
$$('.tool-btn[data-tool]').forEach(button=>{ const on=button.dataset.tool===activeTool; button.classList.toggle('active',on); button.setAttribute('aria-pressed',String(on)); });
drawDraft({persist:false});
}
function drawDraft({persist=true} = {}) {
const coords = points.map(p => `${p.x},${p.y}`).join(' ');
const shape = activeTool === 'line' && points.length > 1 ? `<line x1="${points[0].x}" y1="${points[0].y}" x2="${points[1].x}" y2="${points[1].y}" stroke="#fbbf24" stroke-width="5" marker-end="url(#draftArrow)"/>` : drawingComplete && points.length>2 ? `<polygon points="${coords}" fill="rgba(167,139,250,.18)" stroke="#a78bfa" stroke-width="4"/>` : `<polyline points="${coords}" fill="none" stroke="#a78bfa" stroke-width="4" stroke-dasharray="8 6"/>`;
draftLayer.innerHTML = shape + points.map((p,i) => `<circle cx="${p.x}" cy="${p.y}" r="8" fill="#08111f" stroke="#c4b5fd" stroke-width="4"><title>点 ${i+1}</title></circle>`).join('');
$('#pointStatus').textContent = `当前草稿:${points.length} 个点${drawingComplete?' · 已完成绘制':' · 绘制中'}`;
$('#pointList').innerHTML = points.length ? points.map((p,i) => `<li class="point-item"><span class="point-index">点 ${i+1}</span><input class="field" type="number" min="0" max="100" step="0.1" value="${(p.x/9.6).toFixed(1)}" data-point-x="${i}" aria-label="点 ${i+1} 横坐标百分比"><input class="field" type="number" min="0" max="100" step="0.1" value="${(p.y/5.4).toFixed(1)}" data-point-y="${i}" aria-label="点 ${i+1} 纵坐标百分比"><button class="btn" data-delete-point="${i}" aria-label="删除点 ${i+1}">删除</button></li>`).join('') : '<li class="muted">尚未添加草稿点</li>';
$('#draftBadge').className = points.length ? 'badge warning' : 'badge info';
$('#draftBadge').textContent = points.length ? `区域有未保存修改 · 基于 v${serverVersion}` : `区域 v${serverVersion}`;
$('#reverseLine').hidden = activeTool !== 'line';
if(persist) persistDraft();
}
function addPoint(x,y) { if (activeTool === 'pointer') return; if (activeTool === 'line' && points.length >= 2) { notify('警戒线已有起点和终点;可反转方向、编辑坐标或清空重画。'); return; } drawingComplete=false; points.push({x:Math.max(0,Math.min(960,x)),y:Math.max(0,Math.min(540,y))}); drawDraft(); }
function finishDrawing() { const minimum=activeTool==='line'?2:3; if(activeTool==='pointer'){ notify('请先选择多边形或警戒线工具。'); return; } if(points.length<minimum){ notify(activeTool==='line'?'警戒线至少需要 2 个点。':'多边形至少需要 3 个点。'); return; } drawingComplete=true; drawDraft(); notify(`${activeTool==='line'?'方向警戒线':'多边形'}已完成绘制;保存后才会生成新版本。`); }
zoneCanvas.addEventListener('click', e => { clearTimeout(clickTimer); clickTimer=setTimeout(()=>{ const r=zoneCanvas.getBoundingClientRect(); addPoint((e.clientX-r.left)/r.width*960,(e.clientY-r.top)/r.height*540); },240); });
zoneCanvas.addEventListener('dblclick', e => { e.preventDefault(); clearTimeout(clickTimer); finishDrawing(); });
$$('.tool-btn[data-tool]').forEach(btn => btn.addEventListener('click', () => { if(btn.dataset.tool===activeTool) return; if(points.length && !confirm('切换绘制工具会放弃当前图形,是否继续?')) return; activeTool=btn.dataset.tool; points.length=0; drawingComplete=false; $$('.tool-btn[data-tool]').forEach(b=>{ const on=b===btn; b.classList.toggle('active',on); b.setAttribute('aria-pressed',String(on)); }); drawDraft(); $('#editorHint').textContent=activeTool==='line'?'警戒线:依次添加起点与终点,箭头显示方向;可使用“反转方向”。':activeTool==='pointer'?'选择:查看已有区域;切换绘制工具可新建草稿。':'多边形:点击画面添加顶点,再选择“完成绘制”;也可编辑右侧坐标。'; }));
$('#undoPoint').addEventListener('click', () => { points.pop(); drawingComplete=false; drawDraft(); });
$('#finishDrawing').addEventListener('click',finishDrawing);
$('#reverseLine').addEventListener('click',()=>{ if(points.length!==2){ notify('添加起点和终点后才能反转方向。'); return; } points.reverse(); drawDraft(); notify('警戒线方向已反转。'); });
$('#clearPoints').addEventListener('click', () => { if (points.length && confirm('清空当前未保存的绘制草稿?')) { points.length=0; drawDraft(); } }); $('#clearPoints').addEventListener('click', () => { if (points.length && confirm('清空当前未保存的绘制草稿?')) { points.length=0; drawDraft(); } });
$('#addCoordinate').addEventListener('click', () => { const x=Number($('#coordX').value), y=Number($('#coordY').value); if (!Number.isFinite(x)||!Number.isFinite(y)||x<0||x>100||y<0||y>100) { notify('坐标必须在 0–100% 之间。'); return; } addPoint(x*9.6,y*5.4); }); $('#addCoordinate').addEventListener('click', () => { const x=Number($('#coordX').value), y=Number($('#coordY').value); if (!Number.isFinite(x)||!Number.isFinite(y)||x<0||x>100||y<0||y>100) { notify('坐标必须在 0–100% 之间。'); return; } addPoint(x*9.6,y*5.4); });
$('#discardDraft').addEventListener('click', () => { if (!points.length || confirm('放弃未保存的区域修改?')) { points.length=0; drawDraft(); notify('已放弃本次修改,已保存区域和 Bell 规则均未改变。'); } }); $('#pointList').addEventListener('click',event=>{ const button=event.target.closest('[data-delete-point]'); if(!button)return; points.splice(Number(button.dataset.deletePoint),1); drawingComplete=false; drawDraft(); });
$('#saveDraft').addEventListener('click', () => { if ((activeTool==='polygon'&&points.length<3)||(activeTool==='line'&&points.length<2)) { notify(activeTool==='line'?'警戒线至少需要 2 个点。':'多边形至少需要 3 个点。'); return; } points.length=0; drawDraft(); $('#draftBadge').className='badge success'; $('#draftBadge').textContent='区域 v8 已保存'; notify('检测区域 v8 已保存;关联的 Bell 规则不会被自动发布。'); }); $('#pointList').addEventListener('change',event=>{ const xIndex=event.target.dataset.pointX, yIndex=event.target.dataset.pointY; if(xIndex===undefined&&yIndex===undefined)return; const index=Number(xIndex??yIndex), value=Number(event.target.value); if(!Number.isFinite(value)||value<0||value>100){ notify('坐标必须在 0–100% 之间。'); drawDraft({persist:false}); return; } if(xIndex!==undefined) points[index].x=value*9.6; else points[index].y=value*5.4; drawingComplete=false; drawDraft(); });
$('#discardDraft').addEventListener('click', () => { if (!points.length || confirm('放弃未保存的区域修改?')) { points.length=0; drawingComplete=false; clearStoredDraft(); drawDraft({persist:false}); $('#autosaveStatus').textContent='未保存草稿已清除;服务器上的区域版本未改变。'; notify('已放弃本次修改,已保存区域和 Bell 规则均未改变。'); } });
$('#simulateConflict').addEventListener('click',()=>{ serverVersion+=1; $('#autosaveStatus').className='notice warning'; $('#autosaveStatus').textContent=`服务器区域已更新为 v${serverVersion};当前草稿基线较旧,保存将被阻止。`; notify('已模拟另一位用户保存了新版区域。'); });
$('#saveDraft').addEventListener('click', () => { if ((activeTool==='polygon'&&points.length<3)||(activeTool==='line'&&points.length<2)) { notify(activeTool==='line'?'警戒线至少需要 2 个点。':'多边形至少需要 3 个点。'); return; } if(!drawingComplete){ notify('请先选择“完成绘制”,确认图形边界或警戒线方向。'); return; } const savedBase=JSON.parse(safeSession(store=>store.getItem(draftKey(currentDevice.serial)))||'{}').baseVersion; if(savedBase && savedBase!==serverVersion){ $('#autosaveStatus').className='notice warning'; $('#autosaveStatus').textContent=`保存被阻止:服务器为 v${serverVersion},草稿基于 v${savedBase}。请记录坐标后重新载入最新版本。`; notify('检测到版本冲突,未覆盖其他用户的修改。'); return; } serverVersion+=1; points.length=0; drawingComplete=false; clearStoredDraft(); drawDraft({persist:false}); $('#draftBadge').className='badge success'; $('#draftBadge').textContent=`区域 v${serverVersion} 已保存`; $('#autosaveStatus').className='notice'; $('#autosaveStatus').textContent='保存成功;当前没有未保存草稿。'; notify(`检测区域 v${serverVersion} 已保存;关联的 Bell 规则不会被自动发布。`); });
$('#simulateCalibration').addEventListener('click', () => { $('#calibrationBadge').className='badge warning'; $('#calibrationBadge').textContent='区域待校准'; notify('画面分辨率或旋转发生变化,现有区域已标记为待校准。'); }); $('#simulateCalibration').addEventListener('click', () => { $('#calibrationBadge').className='badge warning'; $('#calibrationBadge').textContent='区域待校准'; notify('画面分辨率或旋转发生变化,现有区域已标记为待校准。'); });
const dialog = $('#cameraDialog'); const deviceDialog = $('#deviceDialog');
$('#openCameraDialog').addEventListener('click', () => dialog.showModal()); function updatePrivacyAdmission() { const video=$('#deviceModality').value==='video'; const option=$('#deviceArea').selectedOptions[0]; const blocked=video&&option.dataset.capturePolicy==='non_imaging_only'; $('#privacyAdmission').className=`notice${blocked?' warning':''}`; $('#privacyAdmission').textContent=blocked?'该区域仅允许非成像设备:视频设备的新增或启用将被拒绝;已有关系不会被伪造为已停用。':video?'当前区域允许图像采集;保存时仍会再次校验区域策略。':'当前设备不采集图像,可进入“仅非成像设备”区域。'; $('#saveDevice').disabled=blocked; return !blocked; }
$('#testConnection').addEventListener('click', async e => { const b=e.currentTarget; b.disabled=true; b.textContent='测试中…'; $('#testResult').textContent='正在读取 Profiles 与设备时间'; await new Promise(r=>setTimeout(r,700)); b.disabled=false; b.textContent='重新测试'; $('#testResult').innerHTML='<span style="color:var(--success)">连接成功:2 个 Profile,时间漂移 +0.4s</span>'; }); function updateDeviceForm(){ const radar=$('#deviceModality').value==='radar'; $$('.video-field').forEach(field=>field.hidden=radar); $$('.radar-field').forEach(field=>field.hidden=!radar); $('#deviceProtocol').value=radar?'mqtt':'onvif'; $('#deviceName').value=radar?'操场西侧雷达':'北围墙 04'; $('#deviceSerial').value=radar?'RAD-DEMO-002':'CAM-DEMO-004'; $('#testResult').textContent=radar?'保存前验证遥测主题、声明能力与设备时间。':'保存前验证 Profiles、StreamUri、声明能力与设备时间。'; updatePrivacyAdmission(); }
$('#saveCamera').addEventListener('click', () => notify('摄像头已保存为“待激活”,凭据不会显示在列表中。')); $('#openDeviceDialog').addEventListener('click', () => { updateDeviceForm(); deviceDialog.showModal(); });
$('#deviceModality').addEventListener('change',updateDeviceForm); $('#deviceArea').addEventListener('change',updatePrivacyAdmission);
$('#testConnection').addEventListener('click', async e => { const b=e.currentTarget; const radar=$('#deviceModality').value==='radar'; b.disabled=true; b.textContent='测试中…'; $('#testResult').textContent=radar?'正在订阅测试遥测并读取能力声明':'正在读取 Profiles、StreamUri 与设备时间'; await new Promise(r=>setTimeout(r,700)); b.disabled=false; b.textContent='重新测试'; $('#testResult').innerHTML=radar?'<span style="color:var(--success)">连接成功:距离、速度、方位角 · 1 Hz</span>':'<span style="color:var(--success)">连接成功:2 个 Profile,时间漂移 +0.4s</span>'; });
$('#saveDevice').addEventListener('click', () => { if(!updatePrivacyAdmission()) { notify('隐私准入失败:此区域禁止新增图像采集设备。'); return; } const type=$('#deviceModality').value==='radar'?'毫米波雷达':'视频设备'; deviceDialog.close(); notify(`${type}已保存为“待激活”;能力与区域策略会在启用时再次校验。`); });
const operationDialog=$('#operationDialog');
const operationCopy={
'pause-inference':'影响:停止新推理;媒体连接保持在线。执行后将显示期望态与实际态的收敛过程。',
'disable-ingest':'影响:实时监控与新推理都将停止;历史事件和区域版本保留。',
'kick-session':'影响:当前连接立即断开;由于期望态仍为启用,适配器可能自动重连。'
};
function openOperationDialog(serial=currentDevice.serial){ const device=deviceData.find(item=>item.serial===serial)||currentDevice; currentDevice=device; $('#operationDeviceName').textContent=device.name; $('#operationDeviceSerial').textContent=device.serial; $('#operationProgress').textContent='尚未执行。重复提交不会产生重复副作用,但每次请求都会记录审计。'; operationDialog.showModal(); }
$('#openOperationDialog').addEventListener('click',()=>openOperationDialog());
$$('input[name="deviceOperation"]').forEach(input=>input.addEventListener('change',()=>$('#operationImpact').textContent=operationCopy[input.value]));
$('#confirmOperation').addEventListener('click',async event=>{ const button=event.currentTarget; const action=$('input[name="deviceOperation"]:checked').value; const expected=action==='disable-ingest'?'停用':action==='pause-inference'?'推理暂停':'启用'; button.disabled=true; $('#operationProgress').innerHTML=`期望态:<strong>${expected}</strong> · 实际态:执行中…`; await new Promise(resolve=>setTimeout(resolve,800)); const actual=action==='disable-ingest'?'已停止':action==='pause-inference'?'推理已暂停':'会话已断开,等待自动重连'; $('#operationProgress').innerHTML=`期望态:<strong>${expected}</strong> · 实际态:<strong>${actual}</strong> · 已记录本次审计`; button.disabled=false; notify(`${currentDevice.name}:${actual}。`); });
$('#fakeUpload').addEventListener('click', () => notify('演示:已选择 cameras-demo.csv,检测到 1 个重复序列号。')); $('#fakeUpload').addEventListener('click', () => notify('演示:已选择 cameras-demo.csv,检测到 1 个重复序列号。'));
drawDraft(); renderDevices();
drawDraft({persist:false});
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
if (query.get('view') === 'device-detail' || query.has('camera')) { if (query.get('view') === 'device-detail' || query.has('camera')) {
openDeviceDetail(query.get('camera') || 'CAM-S2-001'); openDeviceDetail(query.get('camera') || 'CAM-S2-001');
if (query.get('return') === 'bell-rule') $('#ruleReturnContext').hidden = false; if (query.get('return_to') === 'bell-rule') $('#ruleReturnContext').hidden = false;
} }
</script> </script>
</body> </body>
+7 -5
View File
@@ -125,8 +125,8 @@ erDiagram
| --- | --- | --- | | --- | --- | --- |
| **Tenant 租户** | 计费与数据隔离的最小单位 | 所有查询必须带 tenant_id,无例外 | | **Tenant 租户** | 计费与数据隔离的最小单位 | 所有查询必须带 tenant_id,无例外 |
| **Site 站点** | 一个物理场所(一户 / 一所学校 / 一个小区) | 升级链、时段策略挂在这一层 | | **Site 站点** | 一个物理场所(一户 / 一所学校 / 一个小区) | 升级链、时段策略挂在这一层 |
| **Area 区域** | 站点内的逻辑分区(教学楼 / 单元 / 客厅) | 用于权限范围与报表口径 | | **Area 区域** | 站点内的逻辑分区(教学楼 / 单元 / 客厅) | 用于权限范围、报表口径与设备准入;`capture_policy` 表达是否仅允许非成像设备 |
| **Device 设备** | **不叫 Camera**,`device_type` 取 camera/radar/door/button | 为异构传感器预留(继承参考项目 §4.5) | | **Device 设备** | **不叫 Camera**;`modality` 表达 video/radar/contact/button/wearable/other,`capabilities` 表达成像、媒体、遥测等具体能力 | 模态负责稳定分类,能力负责决定字段与操作,避免接入新设备时重做台账和页面 |
| **Zone 画面区域** | 画在某路画面上的多边形或警戒线 | 与 Area 是两回事:Area 是物理概念,Zone 是像素坐标 | | **Zone 画面区域** | 画在某路画面上的多边形或警戒线 | 与 Area 是两回事:Area 是物理概念,Zone 是像素坐标 |
| **Rule 规则** | 「主体 × 条件 × 时空 × 动作」的可配置组合 | 三级覆盖:租户默认 → 站点 → 设备 | | **Rule 规则** | 「主体 × 条件 × 时空 × 动作」的可配置组合 | 三级覆盖:租户默认 → 站点 → 设备 |
| **Event 事件** | 一次被判定成立的客观发生 | **不可变**。误判只能被标记,不能被删改 | | **Event 事件** | 一次被判定成立的客观发生 | **不可变**。误判只能被标记,不能被删改 |
@@ -612,17 +612,19 @@ GPU 型号和数量不在需求阶段写死。M1 用 5 路打通,M3 以 16 路
tenants(id, code, name, status, plan, created_at) tenants(id, code, name, status, plan, created_at)
sites(id, tenant_id, code, name, scene_pack, subnet CIDR, timezone, status, sites(id, tenant_id, code, name, scene_pack, subnet CIDR, timezone, status,
max_video_channels INT NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128)) max_video_channels INT NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128))
areas(id, site_id, name, parent_id) areas(id, site_id, name, parent_id,
capture_policy) -- video_allowed | non_imaging_only
-- ── 设备(不叫 cameras,为异构传感器预留)──── -- ── 设备(不叫 cameras,为异构传感器预留)────
devices( devices(
id, tenant_id, site_id, area_id, id, tenant_id, site_id, area_id,
device_type, -- camera | radar | door | button modality, -- video | radar | contact | button | wearable | other
device_kind, -- camera | mmwave_radar | door_contact | panic_button | ...
capabilities JSONB, -- captures_image / media_stream / telemetry / battery / spatial_config / ...
serial UNIQUE, -- ⚠️ 身份用序列号,不用 IP serial UNIQUE, -- ⚠️ 身份用序列号,不用 IP
vendor, model, vendor, model,
onvif_addr, onvif_user, onvif_secret, -- secret 加密存储 onvif_addr, onvif_user, onvif_secret, -- secret 加密存储
state, -- pending_activation | active | offline | disabled state, -- pending_activation | active | offline | disabled
privacy_flag, -- 隐私区域标记,为 true 时拒绝 camera 类型
last_seen_at last_seen_at
) )
stream_bindings(id, device_id, mtx_instance, inference_shard, path_name, rtsp_url, profile_token, enabled, stream_bindings(id, device_id, mtx_instance, inference_shard, path_name, rtsp_url, profile_token, enabled,
+2 -2
View File
@@ -921,7 +921,7 @@ scene_packs/campus@1.2/
| 约束 | 实现 | | 约束 | 实现 |
| --- | --- | | --- | --- |
| 卧室、卫生间**禁装摄像头** | `devices.privacy_flag = true` 时系统拒绝创建 `device_type = camera`(代码级硬约束) | | 卧室、卫生间**禁装摄像头** | `areas.capture_policy = non_imaging_only` 时,Sense 在设备新增/启用写路径拒绝具有 `captures_image` 能力的设备(代码级硬约束);策略读取失败时拒绝新变更并告警,不静默切断已有链路 |
| 平时不解码不上传 | 触发式推理 + `record: no` + 边缘环形缓冲 | | 平时不解码不上传 | 触发式推理 + `record: no` + 边缘环形缓冲 |
| 家属只能看自己家的告警关联片段 | RBAC:`family` 角色的证据访问范围 = `site_id` ∩ `alert_events` | | 家属只能看自己家的告警关联片段 | RBAC:`family` 角色的证据访问范围 = `site_id` ∩ `alert_events` |
| 3 分钟内必有真人接手 | 4 级升级链(30s/60s/90s → 呼叫中心) | | 3 分钟内必有真人接手 | 4 级升级链(30s/60s/90s → 呼叫中心) |
@@ -1228,7 +1228,7 @@ redis 消息总线
- [ ] 终止服务时的底库彻底删除义务与证明方式已约定 - [ ] 终止服务时的底库彻底删除义务与证明方式已约定
## 架构 ## 架构
- [ ] `devices` 表而非 `cameras`,含 `device_type` - [ ] `devices` 表而非 `cameras`,含 `modality + capabilities`;Area 持有 `capture_policy`
- [ ] 设备身份用序列号 - [ ] 设备身份用序列号
- [ ] 触发入口独立成 handler - [ ] 触发入口独立成 handler
- [ ] `sync_state` 每系统一行 - [ ] `sync_state` 每系统一行
+6 -5
View File
@@ -23,7 +23,7 @@
**Sense —— 把现场的流和信号稳定地拿进来并管住** **Sense —— 把现场的流和信号稳定地拿进来并管住**
- 设备台账(`devices`,含 `modality`:video / radar / contact / button / wearable) - 设备台账(`devices`,含 `modality`:video / radar / contact / button / wearable / other,以及决定成像、媒体、遥测、空间配置等页面与操作的 `capabilities`)
- ONVIF 客户端:连接、GetProfiles、GetStreamUri、SetSystemDateAndTime - ONVIF 客户端:连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
- mediamtx API 客户端(**自行用 oapi-codegen 从其 OpenAPI 生成**,不依赖第三方 SDK) - mediamtx API 客户端(**自行用 oapi-codegen 从其 OpenAPI 生成**,不依赖第三方 SDK)
- **对账器**:水平触发、`sync_state`、独立信号量、10% 孤儿删除安全闸、只收敛不回滚 - **对账器**:水平触发、`sync_state`、独立信号量、10% 孤儿删除安全闸、只收敛不回滚
@@ -80,7 +80,7 @@ Sense/
│ ├── sense-api/ 控制面服务:设备台账、鉴权回调、对账器 │ ├── sense-api/ 控制面服务:设备台账、鉴权回调、对账器
│ └── sense-agent/ 边缘节点:推流、环形缓冲、隧道 │ └── sense-agent/ 边缘节点:推流、环形缓冲、隧道
├── internal/ ├── internal/
│ ├── device/ 设备台账(devices,含 modality) │ ├── device/ 设备台账(devices,含 modality + capabilities)
│ ├── onvif/ 连接、GetProfiles、GetStreamUri、SetSystemDateAndTime │ ├── onvif/ 连接、GetProfiles、GetStreamUri、SetSystemDateAndTime
│ ├── mtx/ oapi-codegen 生成的 mediamtx 客户端 + 薄封装 │ ├── mtx/ oapi-codegen 生成的 mediamtx 客户端 + 薄封装
│ ├── reconcile/ 对账循环(幂等 + 退避 + 安全闸) │ ├── reconcile/ 对账循环(幂等 + 退避 + 安全闸)
@@ -138,7 +138,7 @@ Bell/
--- ---
## 3. 七条边界(不写死就会漂) ## 3. 八条边界(不写死就会漂)
| # | 边界 | 定论 | | # | 边界 | 定论 |
| --- | --- | --- | | --- | --- | --- |
@@ -149,8 +149,9 @@ Bell/
| 5 | 一个 PostgreSQL 还是三个 | **一个实例,schema 分离**(`sense` / `bell`),Brain 无 schema。"DB 是唯一真相源"是对账器成立的前提,拆库即失效 | | 5 | 一个 PostgreSQL 还是三个 | **一个实例,schema 分离**(`sense` / `bell`),Brain 无 schema。"DB 是唯一真相源"是对账器成立的前提,拆库即失效 |
| 6 | 16 路与 128 路分别指什么 | **16 路是默认交付规格,128 路是本阶段单逻辑站点上限**,都不是“单进程/单服务器保证值”。媒体与推理必须横向分片;单机承载量由分辨率、码率、帧率、模型和硬件基准测试决定 | | 6 | 16 路与 128 路分别指什么 | **16 路是默认交付规格,128 路是本阶段单逻辑站点上限**,都不是“单进程/单服务器保证值”。媒体与推理必须横向分片;单机承载量由分辨率、码率、帧率、模型和硬件基准测试决定 |
| 7 | 站点配额谁拥有、谁执行 | **Bell 拥有 `sites.max_video_channels`,Sense 在设备新增/启用写路径执行**。通过版本化内部 API 或只读投影同步,不允许 Sense 直接写 Bell schema;依赖暂时不可用时拒绝新变更,但已有视频链路继续运行 | | 7 | 站点配额谁拥有、谁执行 | **Bell 拥有 `sites.max_video_channels`,Sense 在设备新增/启用写路径执行**。通过版本化内部 API 或只读投影同步,不允许 Sense 直接写 Bell schema;依赖暂时不可用时拒绝新变更,但已有视频链路继续运行 |
| 8 | 设备模态与隐私准入怎么建模 | 台账使用 `modality + capabilities`,不以摄像头作为唯一根实体;**Area 拥有 `capture_policy`,Sense 按设备 `captures_image` 能力在新增/启用写路径执行**。策略不可用时拒绝新变更并告警,已有链路不静默停用;M1 固化模型,M6 再增加非视频适配器 |
第 2、3、6、7 条是本文档新定的,若实施中发现更合适的切法,改这里并同步《02》《03》。 第 2、3、6、7、8 条是本文档新定的,若实施中发现更合适的切法,改这里并同步《02》《03》。
--- ---
@@ -179,6 +180,6 @@ DB schema sense — bell
| **M3** 默认 16 路推理 + 规则引擎 + 事件预警 | **Brain + Bell 同时起步**,契约在此首次被真实使用 | | **M3** 默认 16 路推理 + 规则引擎 + 事件预警 | **Brain + Bell 同时起步**,契约在此首次被真实使用 |
| **M4** 64 路分片 + 灰度 + 管理系统 | Sense 与 Brain 完成横向分片,Bell 完成 128 路规模下的列表与批量交互;验证单分片故障隔离 | | **M4** 64 路分片 + 灰度 + 管理系统 | Sense 与 Brain 完成横向分片,Bell 完成 128 路规模下的列表与批量交互;验证单分片故障隔离 |
| **M5** 128 路容量验收 + 第二三场景包 + 触发式推理 + 人脸 | Sense/Brain 验证扩容不改业务代码;Bell 的 `packs/`(**纯配置**)+ Brain 的模型与触发 | | **M5** 128 路容量验收 + 第二三场景包 + 触发式推理 + 人脸 | Sense/Brain 验证扩容不改业务代码;Bell 的 `packs/`(**纯配置**)+ Brain 的模型与触发 |
| **M6** 异构传感器 + 两级判定 | Sense 的 `trigger/` 与 `device/`(modality 扩展)+ Brain 两级判定 | | **M6** 异构传感器 + 两级判定 | Sense 的 `trigger/` 与 `device/` 增加非视频协议适配器与能力实现(`modality + capabilities` 模型已从 M1 保留)+ Brain 两级判定 |
> M3 是契约第一次被真实使用的时刻。**在此之前契约允许原地修订,之后版本递增规则绝对生效**(契约 README §1)。 > M3 是契约第一次被真实使用的时刻。**在此之前契约允许原地修订,之后版本递增规则绝对生效**(契约 README §1)。
+147
View File
@@ -0,0 +1,147 @@
# Sense 原型评审 → IX 修订稿
> 评审对象:`docs/design/sense/index.html`(T-004)
> 初始输入:Claude 评审草稿,2026-08-04
> 复核:Codex 从产品、全栈架构与 UI/UX 角度对照《02》《03》《08-三系统职责划分》、US/IX 和当前原型核验
> 状态:项目负责人已确认按本修订稿继续;最终行为已同步到 `docs/08-interaction-checklist.md`
---
## 1. 评审结论
初始评审指出的五类缺口方向成立:设备模态、停用收敛、隐私准入、检测区域几何编辑、草稿与跨系统上下文都需要进入正式交互清单。但原稿不能直接合入,原因有三类:
1. 部分证据与当前代码不一致:Sense 页内导航不会清空几何点,Sense→Bell 已携带 `camera + zone`,单媒体分片 32 路已有架构来源。
2. 部分方案把后端字段直接等同产品能力:非视频设备仍需要连接与健康,隐私准入不能只看显示名称或单一模态。
3. 部分行为不符合审计和草稿体验:幂等不等于不记审计,避免丢草稿应优先自动保存,而不是每次导航都弹确认。
原产品边界保持不变:
- Sense 管设备接入、健康、媒体与设备空间配置。
- 视频设备的检测区域继续位于设备详情,不恢复一级“区域规则”。
- Bell 管场景、继承、时段、持续时间、试运行、发布和升级链。
- 两个系统通过稳定深链和可恢复草稿保持连续体验。
---
## 2. 最终新增 IX
| ID | 场景 | 最终约定 | 关联 US | 阶段 |
| --- | --- | --- | --- | --- |
| IX-014 | 设备模态与能力 | 一级入口为“设备”;列表可按 `modality` 筛选,添加时先选模态,详情按 `capabilities` 渐进展示;视频设备显示画面/媒体/检测区域,非视频设备保留连接与健康并显示对应遥测,不用禁用的视频页签占位 | US-008 | M2/M6 |
| IX-015 | 停用与收敛 | 明确区分暂停推理、停用设备接入和仅踢当前会话;操作前展示对观看、推理、录制与证据回捞的影响并二次确认;期望态立即改变、实际态经对账收敛,部分失败可逐项重试;重复请求不产生重复副作用,但每次请求均留审计结果 | US-001、US-002 | M2 |
| IX-016 | 隐私区域设备准入 | `capture_policy = non_imaging_only` 的 Area 不允许新增/启用具有成像能力的设备;前端就地禁用并解释,后端返回稳定错误码;策略读取失败时拒绝新变更并告警,已有设备遇到区域策略变化时进入显式处置流程,不静默留存或停用 | US-008 | M2/M6 |
| IX-017 | 检测区域几何编辑 | 多边形和方向警戒线支持绘制、显式完成、撤销、清空、顶点编辑;警戒线在草稿/已保存态均显示方向箭头并可反转;鼠标与键盘坐标操作等效;画面参数变化后标记待校准且不自动改坐标;区域版本与规则版本独立 | US-005 | M3 |
| IX-018 | 草稿与跨系统上下文 | 几何草稿自动保存并可恢复;只有持久化失败或切换空间类型等会破坏草稿的动作才拦截确认;Sense↔Bell 双向携带并消费 `camera + zone + return_to` 上下文;返回后恢复草稿,版本已被他人更新时提示冲突且不覆盖 | US-005 | M3 |
`IX-003` 已覆盖设备列表分页/筛选/批量选择和 128 路按需加载,因此分页属于原型漏画,不新增 IX。
---
## 3. 关键设计修订
### 3.1 设备使用 `modality + capabilities`
`modality` 用于稳定分类:video / radar / contact / button / wearable / other。`capabilities` 决定字段、页签和动作,例如:
- `captures_image`
- `media_stream`
- `telemetry`
- `battery`
- `spatial_config`
- `directional_line`
M2 的一级导航与台账统一使用“设备”,默认筛选视频设备;M1~M5 只完整实现 video 适配器。M6 增加非视频协议适配器,不重新设计根实体与页面层级。
非视频设备不展示画面、Profile 和媒体分片,但仍展示“连接与健康”,并根据能力显示最后上报、信号质量、电量、遥测或传感器校准。
### 3.2 隐私策略属于 Area
隐私是物理位置的准入策略,不是设备已经创建后的标签。统一模型为:
```text
Area.capture_policy = video_allowed | non_imaging_only
Device.modality
Device.capabilities.captures_image
```
Sense 在新增/启用写路径执行校验。后端返回稳定错误码,前端负责本地化说明。策略不可读取时对新成像变更 fail closed 并告警;已有链路保持当前状态并进入待处置/未收敛列表,不能因一次依赖失败被静默切断。
### 3.3 停用动作不能混为一个开关
| 用户动作 | 期望效果 |
| --- | --- |
| 暂停推理 | 卸载推理 source;接入和授权观看按策略保留 |
| 停用设备接入 | 踢当前发布会话、拒绝后续发布、卸载推理;期望态先变,实际态由对账收敛 |
| 仅踢当前会话 | 运维诊断动作;客户端可能重连,不改变持久期望态 |
每种动作展示独立影响范围。重复请求不得生成重复资源变更或状态副作用,但所有操作尝试都应进入审计并标明“已处于目标状态”或实际结果。
### 3.4 几何编辑提供显式完成与等效操作
- 多边形至少 3 点,方向警戒线恰好 2 点。
- 双击可作为快捷方式,但必须有可见的“完成绘制”按钮;双击事件不得额外追加顶点。
- 警戒线草稿和保存态都渲染方向箭头,并提供“反转方向”。
- 顶点列表支持添加、编辑、删除、撤销,键盘与鼠标结果等效。
- 画面分辨率或旋转变化后标记待校准,不自动修改坐标,也不自动发布 Bell 规则。
### 3.5 草稿优先自动保存
页内导航不应反复打断用户。几何草稿在本地/服务端自动保存并显示状态;只有自动保存失败、切换不兼容空间类型或明确放弃草稿时才确认。关闭标签页使用平台允许的离开保护作为兜底,不能把它当主保存机制。
深链统一携带并消费:
```text
camera=<stable-device-id>
zone=<zone-id>
return_to=<trusted-route-token>
```
`return_to` 只能使用受信路由 token,不能接受任意外部 URL。保存时使用区域版本做乐观并发校验;发现他人已保存新版本时提示重新加载或另存草稿,不覆盖。
---
## 4. 对初始评审事实的纠正
| 初始判断 | 核验结果 | 最终处理 |
| --- | --- | --- |
| 128 路列表缺分页需要新 IX | `IX-003` 已覆盖 | 只补原型分页 |
| 单媒体分片 32 路无出处 | 《03》§4.2 已定义 `media_shard_max_streams: 32`,128 路建议 4 个 32 路分片 | 保留 32;原型标明当前启用数与可扩展上限 |
| 侧栏/面包屑会清空草稿 | 当前 `switchView()` 不清空 `points` | 不作为现状缺陷;仍要求跨刷新/跨系统恢复 |
| Sense→Bell 缺少 camera/zone | Sense 已发送两者,但 Bell 未消费 `zone`;Bell→Sense 缺 `zone` | 修复双方生成与消费 |
| 非视频设备不展示健康页签 | 非视频设备仍有连接、上报、信号、电量与故障状态 | 使用通用“连接与健康”,能力驱动内容 |
| 幂等操作不生成重复审计 | 不符合审计目标 | 幂等只约束副作用,每次请求保留审计结果 |
---
## 5. 无障碍与安全收紧
- 正文、按钮文字和关键状态对比度不低于 4.5:1;大字号不低于 3:1;非文本控件、状态边界和焦点指示器不低于 3:1。
- 当前导航显式设置 `aria-current="page"`,不能用空值属性表示当前页。
- 响应式隐藏可见文字时,按钮仍保留 `aria-label` 等稳定可访问名。
- Tab 必须完整实现 `tablist` / `tab` / `tabpanel`、关联属性、单一 Tab 停靠点与方向键;否则不声明 tab 角色。
- 移动端主要操作目标不小于 44×44px,输入正文不小于 16px;几何绘制提供不依赖精确点击的表单等效路径。
---
## 6. 原型落地范围
Sense 原型重新生成并覆盖:
1. 一级“设备”入口、模态筛选、分页和视频/雷达示例。
2. 能力驱动详情:视频显示画面与检测区域,雷达显示遥测,二者都有连接与健康。
3. 添加设备先选模态,隐私 Area 对成像设备就地拒绝。
4. 暂停推理、停用接入、踢当前会话的独立影响确认与期望/实际收敛反馈。
5. 显式完成绘制、方向箭头/反转、顶点编辑、自动草稿与冲突状态。
6. Sense/Bell 双向 `camera + zone + return_to` 深链。
7. 对比度、可访问名、完整 Tab 模式、44px 移动触控目标。
Bell 原型只调整深链消费和返回上下文,不改变“规则业务归 Bell、空间几何归 Sense”的产品边界。
---
## 7. 后续兼容债务(不在本原型任务改契约)
事件 v0.1 的 schema 描述与契约 README 仍使用 `privacy_flag` 表示区域是否允许视频模态,而本次领域模型已收敛为 Area 的 `capture_policy`。两者当前语义可一一映射:`privacy_flag = true` 等价于 `capture_policy = non_imaging_only`,但不能长期保留两套真相源。
M2 开始 schema/API 实现前应单独建立契约任务,选择“保持 v0.1 线格式并在投影层兼容”或“按版本治理规则新增字段/版本”,同步《06》与 `docs/raw/contracts/` 后再写代码。T-004 只更新产品与架构决策,不修改已经声明为既有契约的 event v0.1。
+16 -7
View File
@@ -31,30 +31,31 @@ Sense 与 Bell 尚无可供产品确认的页面结构。生产 UI 开工前,
## 关联需求与交互(如适用) ## 关联需求与交互(如适用)
- 用户故事:Sense 关联 US-001、US-002,并为 US-005 提供摄像头空间配置;Bell 关联 US-003~US-006,并拥有 US-005 的业务规则工作流。 - 用户故事:Sense 关联 US-001、US-002、US-008,并为 US-005 提供视频设备空间配置;Bell 关联 US-003~US-006,并拥有 US-005 的业务规则工作流。
- 交互清单:Sense 关联 IX-001~IX-004,并提供 IX-010 所需的区域/警戒线几何;Bell 关联 IX-005~IX-013,负责 IX-010~IX-011 的规则继承、试运行和发布。 - 交互清单:Sense 关联 IX-001~IX-004、IX-014~IX-018,并提供 IX-010 所需的区域/警戒线几何;Bell 关联 IX-005~IX-013,负责 IX-010~IX-011 的规则继承、试运行和发布。
- 相关页面 / 路由:原型阶段为 `docs/design/sense/index.html` 与 `docs/design/bell/index.html`,不定义生产路由。 - 相关页面 / 路由:原型阶段为 `docs/design/sense/index.html` 与 `docs/design/bell/index.html`,不定义生产路由。
## 方案 ## 方案
1. 使用统一的 YoVision 深色安防控制台设计系统,Sense 与 Bell 保持品牌、状态色、控件和导航规律一致。 1. 使用统一的 YoVision 深色安防控制台设计系统,Sense 与 Bell 保持品牌、状态色、控件和导航规律一致。
2. Sense 制作为单页可交互工作台:总览、监控墙、摄像头、接入任务和系统状态;不设置一级“区域规则”。从摄像头列表进入详情,在“画面与检测区域”页签完成多边形、警戒线、撤销、清空、键盘替代、区域版本和待校准反馈。 2. Sense 制作为单页可交互工作台:总览、监控墙、设备、接入任务和系统状态;不设置一级“区域规则”。统一设备台账使用 `modality + capabilities` 决定详情页和操作,M1~M5 完整展示视频设备,非视频设备以雷达原型验证信息架构,实际适配器留到 M6。
3. Sense 只保存摄像头空间几何并只读显示关联规则,不提供业务规则绑定、时段、持续时间、试运行或正式发布;摄像头画面参数变化时将区域标记为待校准。 3. 视频设备在“画面与检测区域”页签完成多边形、方向警戒线、显式完成、方向反转、逐点编辑、会话草稿恢复、乐观并发冲突和待校准反馈;Sense 只保存空间几何并只读显示关联规则,不提供业务规则绑定、时段、持续时间、试运行或正式发布。
4. Bell 制作为单页可交互值班台:除预警队列、事件证据、ack/处置、升级时间线、误报反馈和升级链外,规则编辑必须选择摄像头与检测区域版本,并负责继承、时段、持续时间、试运行、发布和回滚。 4. Bell 制作为单页可交互值班台:除预警队列、事件证据、ack/处置、升级时间线、误报反馈和升级链外,规则编辑必须选择摄像头与检测区域版本,并负责继承、时段、持续时间、试运行、发布和回滚。
5. Sense 摄像头详情与 Bell 规则草稿通过可演示的相对深链互相进入,携带摄像头/区域上下文;原型可以跨文件,但生产产品不得要求用户重新查找同一摄像头或丢失未完成草稿。 5. Sense 摄像头详情与 Bell 规则草稿通过可演示的相对深链互相进入,携带摄像头/区域上下文;原型可以跨文件,但生产产品不得要求用户重新查找同一摄像头或丢失未完成草稿。
6. 两个原型均内联 CSS/JS,使用假数据,不加载真实视频、客户信息、生产地址或外部依赖;顶部固定标注原型用途。 6. 两个原型均内联 CSS/JS,使用假数据,不加载真实视频、客户信息、生产地址或外部依赖;顶部固定标注原型用途。
7. 完成桌面、窄屏、键盘焦点、加载/空态/错误切换和 `prefers-reduced-motion` 检查,提交人工评审;产品确认前不把任务标为 `DONE`。 7. 添加设备时先选择类型,依据能力渐进展示字段;区域的 `capture_policy` 负责隐私准入,视频设备在“仅非成像设备”区域中的新增或启用必须被阻止,策略不可用时对新写入失败关闭。
8. 完成桌面、窄屏、横屏、键盘焦点、加载/空态/错误切换和 `prefers-reduced-motion` 检查,提交人工评审;产品确认前不把任务标为 `DONE`。
## 不可变约束 ## 不可变约束
- 阈值 / 数值边界:默认 16 路、单站点上限 128 路;静默最长 4 小时;原型不得暗示 128 路同时播放主码流。 - 阈值 / 数值边界:默认 16 路视频、单站点视频上限 128 路;非视频设备不占视频路数;单媒体分片建议上限 32 路、最多 4 个分片;静默最长 4 小时;原型不得暗示 128 路同时播放主码流。
- 判定式 / 状态转换:事件事实不可被处置改写;Alert 的首次投递、送达、看到、ack 和升级必须分开展示;检测区域版本与告警规则版本是不同对象;规则正式发布与试运行必须可区分,保存区域不得自动发布规则。 - 判定式 / 状态转换:事件事实不可被处置改写;Alert 的首次投递、送达、看到、ack 和升级必须分开展示;检测区域版本与告警规则版本是不同对象;规则正式发布与试运行必须可区分,保存区域不得自动发布规则。
- 安全边界:不得展示摄像头密码、完整 RTSP/ONVIF 地址、token、真实客户/未成年人数据或人脸入口;破坏性动作须确认。 - 安全边界:不得展示摄像头密码、完整 RTSP/ONVIF 地址、token、真实客户/未成年人数据或人脸入口;破坏性动作须确认。
- 既有契约:原型不修改事件 v0.1、API、路由和生产技术栈,不作为生产实现代码。 - 既有契约:原型不修改事件 v0.1、API、路由和生产技术栈,不作为生产实现代码。
## 验收要点 ## 验收要点
- 任务相关验证:两个 HTML 可本地打开、无外部依赖;关键按钮和状态切换可用;Sense 一级导航无“区域规则”,摄像头详情可编辑检测区域;Bell 可选择摄像头/区域并演示试运行与正式发布;双向深链保留上下文;运行 HTML 结构检查、`git diff --check` 与 `./init.ps1`。 - 任务相关验证:两个 HTML 可本地打开、无外部依赖;关键按钮和状态切换可用;Sense 一级导航无“区域规则”,设备列表可按类型筛选/分页,视频详情可编辑检测区域,非视频详情不伪装成媒体流;Bell 可选择摄像头/区域并演示试运行与正式发布;双向深链保留 `camera + zone + return_to`;运行 HTML 结构检查、`git diff --check` 与 `./init.ps1`。
- 完整门禁:本任务不修改 schema/API/路由,因此不触发契约与导航实现门禁;若范围变化则先更新任务。 - 完整门禁:本任务不修改 schema/API/路由,因此不触发契约与导航实现门禁;若范围变化则先更新任务。
- 人工 / 设备验收:必需。项目负责人分别打开 Sense 与 Bell 原型,确认页面结构、控件集合和主要工作流;未确认前不得标记 `DONE` 或合并为最终设计。 - 人工 / 设备验收:必需。项目负责人分别打开 Sense 与 Bell 原型,确认页面结构、控件集合和主要工作流;未确认前不得标记 `DONE` 或合并为最终设计。
- 构建产物:`docs/design/sense/index.html`、`docs/design/bell/index.html`;双击浏览器打开,零构建依赖。 - 构建产物:`docs/design/sense/index.html`、`docs/design/bell/index.html`;双击浏览器打开,零构建依赖。
@@ -80,6 +81,14 @@ Sense 与 Bell 尚无可供产品确认的页面结构。生产 UI 开工前,
- T-004 继续保持唯一活跃任务;dispatcher 已在 Issue #6 发布 `CLAIM RENEWAL`,将本轮涉及的评审稿、US/IX、需求/架构与 Sense/Bell 原型加入 `write_paths`。当前没有其他活跃任务占用这些路径。 - T-004 继续保持唯一活跃任务;dispatcher 已在 Issue #6 发布 `CLAIM RENEWAL`,将本轮涉及的评审稿、US/IX、需求/架构与 Sense/Bell 原型加入 `write_paths`。当前没有其他活跃任务占用这些路径。
- 本轮按 `docs/design/README.md` 的显著改版规则重新生成 Sense 页面结构,不恢复一级“区域规则”;一级入口改为“设备”,详情按 `modality + capabilities` 渐进展示,视频设备仍在详情中维护画面检测区域,Bell 继续独占业务规则与发布。 - 本轮按 `docs/design/README.md` 的显著改版规则重新生成 Sense 页面结构,不恢复一级“区域规则”;一级入口改为“设备”,详情按 `modality + capabilities` 渐进展示,视频设备仍在详情中维护画面检测区域,Bell 继续独占业务规则与发布。
### 2026-08-04 Claude 评审续修实现与自测
- 将评审结论回写到需求、架构、用户故事和 IX:设备以 `modality + capabilities` 建模,隐私策略归属 Area;M2 冻结通用信息架构与视频准入,M6 再实现非视频适配器。明确幂等只消除重复副作用,每次请求仍写审计;暂停推理、停用接入和断开会话为三个不同动作。
- Sense 已重构为通用设备台账:17 台示例设备、5 条/页、类型/状态/搜索筛选,视频配额单独显示为 16 / 128;雷达详情展示遥测能力和通用健康且隐藏视频区域页签。添加设备对话框按能力切换字段,并验证视频设备会被“仅非成像设备”区域阻止、雷达仍可接入。
- 区域编辑补齐显式完成、警戒线箭头与方向反转、逐点键盘编辑/删除、双击防重复顶点、`sessionStorage` 会话草稿恢复、版本冲突阻止覆盖;工具切换会先确认,页内导航不清除草稿。Sense 与 Bell 双向链接统一携带 `camera + zone + return_to`,Bell 会读入并保留区域上下文。
- Node.js 内联脚本编译、唯一 ID/标签目标/外部资源静态审计均通过;关键前景/背景组合对比度为 5.93~16.43:1。Edge Headless/CDP 在 1440×900、375×812 与 812×375 实测设备筛选、雷达详情、隐私准入、区域方向与冲突、Tab 方向键、停止操作收敛和双向深链;页面级无横向溢出、移动端无低于 44px 的可见主要控件、运行时 0 exception。截图已人工检查且未写入仓库。
- 任务仍保持 `DOING`:需项目负责人打开两份原型确认最终信息架构与主要工作流,确认前不合并 PR、不标记 `DONE`。
### 2026-08-03 任务定义 ### 2026-08-03 任务定义
- 依据用户请求、US-001~US-006、IX-001~IX-013 和 MiBeeNvr 本地只读截图定义两个单文件原型范围。 - 依据用户请求、US-001~US-006、IX-001~IX-013 和 MiBeeNvr 本地只读截图定义两个单文件原型范围。