@@ -14,10 +14,10 @@
--surface-3 : #17263a ;
--text : #f2f7fb ;
--muted : #9fb0c5 ;
--subtle : #71 849c ;
--subtle : #8498b0 ;
--line : #27384d ;
--primary : #38bdf8 ;
--primary-strong : #0ea5e9 ;
--primary-strong : #0369a1 ;
--primary-soft : rgba ( 56 , 189 , 248 , .14 ) ;
--success : #34d399 ;
--warning : #fbbf24 ;
@@ -35,7 +35,9 @@
--surface-3 : #eaf1f6 ;
--text : #112033 ;
--muted : #50647b ;
--subtle : #718096 ;
--subtle : #596c82 ;
--primary : #0369a1 ;
--primary-strong : #075985 ;
--line : #cfdae5 ;
--primary-soft : rgba ( 2 , 132 , 199 , .11 ) ;
--shadow : 0 14 px 34 px rgba ( 38 , 64 , 92 , .12 ) ;
@@ -93,19 +95,19 @@
. site-select span { color : var ( - - muted ) ; font-size : 12 px ; }
. top-actions { margin-left : auto ; display : flex ; align-items : center ; gap : 8 px ; }
. select , . field , . textarea {
width : 100 % ; min-height : 40 px ; border : 1 px solid var ( - - line ) ; border-radius : 9 px ; background : var ( - - surface -2 ) ; color : var ( - - text ) ; padding : 8 px 11 px ;
width : 100 % ; min-height : 44 px ; border : 1 px solid var ( - - line ) ; border-radius : 9 px ; background : var ( - - surface -2 ) ; color : var ( - - text ) ; padding : 8 px 11 px ;
}
. textarea { min-height : 74 px ; resize : vertical ; }
. top-actions . select { width : auto ; min-width : 126 px ; }
. btn {
min-height : 40 px ; border : 1 px solid var ( - - line ) ; border-radius : 9 px ; padding : 8 px 13 px ; background : var ( - - surface -2 ) ; color : var ( - - text ) ;
min-height : 44 px ; border : 1 px solid var ( - - line ) ; border-radius : 9 px ; padding : 8 px 13 px ; background : var ( - - surface -2 ) ; color : var ( - - text ) ;
display : inline-flex ; align-items : center ; justify-content : center ; gap : 8 px ; font-weight : 700 ; text-decoration : none ; transition : border-color .2 s ease , background .2 s ease , opacity .2 s ease ;
}
. btn : hover { border-color : var ( - - primary ) ; }
. 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-quiet { border-color : transparent ; background : transparent ; color : var ( - - muted ) ; }
. icon-btn { width : 42 px ; padding : 0 ; }
. icon-btn { width : 44 px ; padding : 0 ; }
. content { padding : 22 px ; }
. view { display : none ; animation : reveal .22 s ease-out ; }
. view . active { display : block ; }
@@ -141,14 +143,19 @@
. search . icon { position : absolute ; left : 11 px ; top : 11 px ; color : var ( - - subtle ) ; pointer-events : none ; }
. search input { padding-left : 38 px ; }
. table-wrap { overflow-x : auto ; }
table { width : 100 % ; border-collapse : collapse ; min-width : 960 px ; }
table { width : 100 % ; border-collapse : collapse ; min-width : 1040 px ; }
th , td { padding : 12 px 14 px ; border-bottom : 1 px solid var ( - - line ) ; text-align : left ; vertical-align : middle ; }
th { color : var ( - - muted ) ; background : var ( - - surface -2 ) ; font-size : 12 px ; font-weight : 700 ; }
td { font-variant-numeric : tabular-nums ; }
tr : last-child td { border-bottom : 0 ; }
tr : hover td { background : color-mix ( in srgb , var ( - - primary - soft ) 45 % , transparent ) ; }
. device-name { display : flex ; align-items : center ; gap : 10 px ; }
. camera-icon { width : 34 px ; height : 34 px ; border-radius : 9 px ; display : grid ; place-items : center ; background : var ( - - surface -3 ) ; color : var ( - - primary ) ; }
. camera-icon { width : 38 px ; height : 38 px ; border-radius : 9 px ; 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 : 4 px ; margin-top : 3 px ; }
. capability { color : var ( - - muted ) ; font-size : 11 px ; }
. pagination { padding : 12 px 14 px ; display : flex ; justify-content : space-between ; align-items : center ; gap : 12 px ; border-top : 1 px solid var ( - - line ) ; }
. pagination-actions { display : flex ; gap : 6 px ; }
. device-name small { display : block ; color : var ( - - muted ) ; }
. camera-grid { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; }
. camera-card { overflow : hidden ; }
@@ -158,10 +165,10 @@
. camera-meta { padding : 12 px 14 px ; display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; }
. camera-meta small { color : var ( - - muted ) ; }
. breadcrumb { margin-bottom : 10 px ; display : flex ; align-items : center ; gap : 7 px ; color : var ( - - muted ) ; font-size : 12 px ; }
. breadcrumb button { min-height : 32 px ; padding : 4 px 8 px ; border : 0 ; border-radius : 7 px ; background : transparent ; color : var ( - - primary ) ; font-weight : 700 ; }
. breadcrumb button { min-height : 44 px ; padding : 4 px 8 px ; border : 0 ; border-radius : 7 px ; background : transparent ; color : var ( - - primary ) ; font-weight : 700 ; }
. breadcrumb button : hover { background : var ( - - primary - soft ) ; }
. detail-tabs { margin-bottom : 12 px ; padding : 5 px ; display : flex ; gap : 5 px ; overflow-x : auto ; border : 1 px solid var ( - - line ) ; border-radius : 11 px ; background : var ( - - surface ) ; }
. detail-tabs button { min-height : 40 px ; padding : 7 px 13 px ; border : 0 ; border-radius : 8 px ; background : transparent ; color : var ( - - muted ) ; white-space : nowrap ; }
. detail-tabs button { min-height : 44 px ; padding : 7 px 13 px ; border : 0 ; border-radius : 8 px ; background : transparent ; color : var ( - - muted ) ; white-space : nowrap ; }
. detail-tabs button . active { background : var ( - - primary - soft ) ; color : var ( - - text ) ; box-shadow : inset 0 -2 px var ( - - primary ) ; }
. detail-panel [ hidden ] { display : none ; }
. context-strip { margin-bottom : 12 px ; padding : 11 px 13 px ; display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; border : 1 px solid rgba ( 167 , 139 , 250 , .38 ) ; border-radius : 10 px ; background : rgba ( 167 , 139 , 250 , .1 ) ; }
@@ -176,7 +183,7 @@
. mini-thumb { width : 54 px ; height : 34 px ; border-radius : 6 px ; background : linear-gradient ( 145 deg , #24364c , #0a101a ) ; position : relative ; overflow : hidden ; }
. mini-thumb :: after { content : "" ; position : absolute ; left : 8 px ; right : 8 px ; bottom : 8 px ; height : 1 px ; background : var ( - - primary ) ; transform : rotate ( -12 deg ) ; }
. editor-tools { padding : 10 px ; display : flex ; gap : 6 px ; align-items : center ; border-bottom : 1 px solid var ( - - line ) ; overflow-x : auto ; }
. tool-btn { min-width : 42 px ; min-height : 40 px ; border : 1 px solid var ( - - line ) ; border-radius : 8 px ; background : var ( - - surface -2 ) ; color : var ( - - muted ) ; display : inline-flex ; align-items : center ; justify-content : center ; gap : 6 px ; white-space : nowrap ; }
. tool-btn { min-width : 44 px ; min-height : 44 px ; border : 1 px solid var ( - - line ) ; border-radius : 8 px ; background : var ( - - surface -2 ) ; color : var ( - - muted ) ; display : inline-flex ; align-items : center ; justify-content : center ; gap : 6 px ; white-space : nowrap ; }
. tool-btn . active { background : var ( - - primary - soft ) ; border-color : var ( - - primary ) ; color : var ( - - text ) ; }
. tool-divider { width : 1 px ; align-self : stretch ; background : var ( - - line ) ; margin : 0 3 px ; }
. editor-stage { position : relative ; aspect-ratio : 16 / 9 ; min-height : 340 px ; background : #030a12 ; overflow : hidden ; }
@@ -198,6 +205,17 @@
. coordinate-box h3 { margin : 0 0 4 px ; font-size : 13 px ; }
. coordinate-box p { margin : 0 0 10 px ; color : var ( - - muted ) ; font-size : 12 px ; }
. coordinate-row { display : grid ; grid-template-columns : 1 fr 1 fr auto ; gap : 6 px ; }
. point-list { list-style : none ; margin : 10 px 0 0 ; padding : 0 ; display : grid ; gap : 6 px ; }
. point-item { display : grid ; grid-template-columns : auto 1 fr 1 fr auto ; align-items : center ; gap : 6 px ; }
. point-item . field { min-width : 0 ; }
. point-index { color : var ( - - muted ) ; font-size : 12 px ; }
. notice { margin : 12 px 0 0 ; padding : 10 px 12 px ; border : 1 px solid var ( - - line ) ; border-radius : 9 px ; 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 : 8 px ; }
. operation-option { min-height : 72 px ; padding : 10 px 12 px ; border : 1 px solid var ( - - line ) ; border-radius : 10 px ; background : var ( - - surface -2 ) ; display : grid ; grid-template-columns : auto 1 fr ; gap : 10 px ; 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 : 12 px ; padding : 12 px ; border : 1 px solid var ( - - line ) ; border-radius : 10 px ; background : var ( - - surface -2 ) ; }
. linked-rules h3 { margin : 0 0 4 px ; font-size : 13 px ; }
. linked-rules p { margin : 0 0 10 px ; color : var ( - - muted ) ; font-size : 12 px ; }
@@ -271,6 +289,9 @@
. editor-stage { min-height : 0 ; }
. coordinate-row { grid-template-columns : 1 fr 1 fr ; }
. coordinate-row . btn { grid-column : 1 /- 1 ; }
. point-item { grid-template-columns : auto 1 fr 1 fr ; }
. point-item . btn { grid-column : 2 /- 1 ; }
input , select , textarea { font-size : 16 px ; }
}
@ media ( prefers-reduced-motion : reduce ) {
* , * :: before , * :: after { animation-duration : .01 ms !important ; animation-iteration-count : 1 !important ; transition-duration : .01 ms !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-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-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-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 >
@@ -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 = "nav-label" > Workspace< / div >
< nav class = "nav" >
< button data-view = "overview" > < 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 = "devices" aria-current = "page" > < svg class = "icon" > < use href = "#i-camera" / > < / svg > < span > 摄像头 < / span > < span class = "count" > 16 < / 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 = "system" > < svg class = "icon" > < use href = "#i-pulse" / > < / 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" 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" aria-label = "设备,共 17 台" > < svg class = "icon" > < use href = "#i-camera" / > < / svg > < span > 设备 < / span > < span class = "count" > 17 < / 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" aria-label = "系统状态" > < svg class = "icon" > < use href = "#i-pulse" / > < / svg > < span > 系统状态< / span > < / button >
< / 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 >
< / aside >
< main class = "main" id = "main-content" tabindex = "-1" >
< 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" >
< select class = "select" id = "stateSelect" aria-label = "原型状态演示" >
< 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" >
< 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" >
< 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-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 >
< / div >
< 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" >
< div class = "health-row" > < span > media-01< / span > < strong > 8 / 32< / strong > < div class = "meter" > < i style = "width:2 5%" > < / 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 > 16 / 16< / strong > < div class = "meter" > < i style = "width:100%;background:var(--violet )" > < / 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 > 分片规模 < / 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(--success )" > < / i > < / div > < / div >
< / div > < / article >
< 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 >
@@ -358,29 +380,30 @@
< / section >
< 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 = "openCamera Dialog" > < 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 = "openDevice Dialog" > < svg class = "icon" > < use href = "#i-plus" / > < / svg > 添加设备 < / button > < / div > < / div >
< 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 = "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 = "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 > < 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 >
< / section >
< 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&camera=CAM-S2-001&zone=ZONE-007" > 返回规则草稿< / a > < / 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 = "detail-tabs" role = "tablist" aria-label = "摄像头 详情" >
< button role = "tab" data-detail-tab = "info" aria-selected = "false "> 基本信息< / button >
< button class = "active" role = "tab" data-detail-tab = "zones" aria-selected = "true "> 画面与检测区域< / button >
< button role = "tab" data-detail-tab = "health" aria-selected = "false" > 流 与健康< / button >
< button role = "tab" data-detail-tab = "history" aria-selected = "false "> 操作记录< / button >
< div class = "context-strip" id = "ruleReturnContext" hidden > < p > < strong > 来自 Bell 规则草稿:< / strong > 已保留摄像头与区域 上下文;保存空间坐标 后可返回继续配置规则 。< / p > < a class = "btn" id = "returnToBell" href = "../bell/index.html?view=rules&camera=CAM-S2-001&zone=ZONE-007&return_to=sense-device " > 返回规则草稿< / a > < / 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 > · < 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 = "设备 详情" >
< button id = "tab-info" role = "tab" data-detail-tab = "info" aria-controls = "panel-info" aria-selected = "false" tabindex = "-1 "> 基本信息< / button >
< button id = "tab-zones" class = "active" role = "tab" data-detail-tab = "zones" aria-controls = "panel-zones" aria-selected = "true" tabindex = "0 "> 画面与检测区域< / button >
< button id = "tab-health" role = "tab" data-detail-tab = "health" aria-controls = "panel-health" aria-selected = "false" tabindex = "-1" > 连接 与健康< / button >
< button id = "tab-history" role = "tab" data-detail-tab = "history" aria-controls = "panel-history" aria-selected = "false" tabindex = "-1 "> 操作记录< / button >
< / div >
< section class = "detail-panel" data-detail-panel = "info" hidden >
< 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 >
< 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" 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 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 = "zone-layout" >
< 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 >
< 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 = "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 >
< / div >
< div class = "editor-stage" >
< 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)" / >
< 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 >
< 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 >
< / svg >
< div class = "editor-hint" id = "editorHint" > 多边形:点击画面添加顶点,双击闭合 ;也可在右侧输入坐标。< / div >
< div class = "editor-hint" id = "editorHint" > 多边形:点击画面添加顶点,再选择“完成绘制” ;也可在右侧输入坐标。< / div >
< / div >
< div class = "editor-status" > < span id = "pointStatus" > 当前草稿:0 个点< / span > < span > 缩放 100% · 网格吸附关闭< / span > < / div >
< / 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 = "zoneColor" > 叠加颜色< / label > < select id = "zoneColor" class = "select" > < option > 蓝色< / option > < option > 琥珀色< / option > < option > 红色< / option > < / select > < / 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 = "linked-rules" > < h3 > 关联的告警规则 < / h3 > < p > 规则由 Bell 管理;Sense 只提供摄像头快照和版本化空间坐标。 < / p > < ul > < li > 非授权人员进入 · 正式生效 < / li > < li > 区域内聚集 · 试运行 < / li > < / ul > < a class = "btn" id = "configureRule s" href = "../bell/index.html?view=rules&camera=CAM-S2-001&zone=ZONE-007" > 在 Bell 配置规则 < / a > < / div >
< div class = "settings-actions" > < button class = "btn" id = "discardDraft" > 放弃修改 < / button > < button class = "btn btn-primary " id = "saveDraft" > 保存区域版本 < / button > < / 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 = "notice" id = "autosaveStatu s" role = "status" > 草稿会自动保存在此浏览器会话中。 < / 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&camera=CAM-S2-001&zone=ZONE-007&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 >
< / 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 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-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 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 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" >
< 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 >
< / div >
< / section >
@@ -444,22 +470,42 @@
< / div >
< 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 >
< dialog id = "camera Dialog" aria-labelledby = "camera DialogTitle" >
< dialog id = "device Dialog" aria-labelledby = "device DialogTitle" >
< form method = "dialog" >
< div class = "dialog-head" > < h2 id = "camera DialogTitle" > 添加摄像头 < / h2 > < button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭对话框" > × < / button > < / div >
< div class = "dialog-head" > < h2 id = "device DialogTitle" > 添加设备 < / h2 > < button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭添加设备 对话框" > × < / button > < / div >
< 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 = "cameraProtocol " > 协议 < / label > < select class = "select" id = "cameraProtocol" > < option > ONVIF < / option > < option > RTSP < / 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 = "cameraUser " > 用户名 < / label > < input class = "field" id = "cameraUser" value = "operator "> < / 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 = "cameraArea " > 区域 < / label > < select class = "selec t" id = "cameraArea" > < option > 北围墙 < / option > < option > 实验楼 < / option > < option > 宿舍区 < / option > < / select > < / div >
< div class = "form-field" > < label for = "cameraSerial " > 序列号 < / label > < input class = "field" id = "cameraSerial" value = "CAM-DEMO-004 "> < / div >
< / div > < p > < button type = "button" class = "btn" id = "testConnection "> 测试连接 < / button > < span id = "testResult " class = "mute d" aria-live = "polite" > 保存前先验证 Profiles、StreamUri 与校时能力。 < / span > < / p > < / 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" > < 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 = "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" > < label for = "deviceName " > 名称 < / label > < input class = "field" id = "deviceName " required value = "北围墙 04" > < / 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 = "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 full " > < label for = "deviceEndpoint " > 设备地址 < / label > < input class = "field" id = "deviceEndpoin t" value = "192.0.2.24:80" aria-describedby = "endpointHelp" > < small id = "endpointHelp" class = "muted" > 原型使用保留地址;保存后列表仅显示脱敏端点。 < / small > < / div >
< div class = "form-field video-field " > < label for = "deviceUser " > 用户名 < / label > < input class = "field" id = "deviceUser" value = "operator "> < / div >
< div class = "form-field video-field" > < label for = "devicePassword "> 密码 < / label > < input class = "field " id = "devicePasswor d" type = "password" value = "prototype-only" > < / 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 >
< / dialog >
< 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 ) ; }
const deviceData = [
[ '北围墙 01' , 'CAM-S2-001' , '启用' , '在线' , '刚刚' , '+0.2s ', 'media-01' , 'succes s', '2 个区域' , 'succes s'] ,
[ '北围墙 03' , 'CAM-S2-003' , '启用' , '重连中' , '2 分钟前' , '— ', 'media-01' , 'warning ', '待校准' , 'warning' ] ,
[ '实验楼入口 02' , 'CAM-S2-006' , '启用' , '在线' , '12 秒前' , '+3.8s ', 'media-02 ' , 'warning ', '1 个区域' , 'succes s'] ,
[ '宿舍东通道' , 'CAM-S2-011' , '启用' , '在线' , '4 秒前' , '+0.1s ', 'media-02 ' , 'succes s', '3 个区域' , 'succes s'] ,
[ '配电房门口' , 'CAM-S2-015' , '停用' , '已停止' , '1 天 前' , '—' , '未绑定' , 'info' , '未配置' , 'info' ]
{ name : '北围墙 01' , serial : 'CAM-S2-001' , modality : 'video' , modalityLabel : '视频' , capabilities : [ '图像采集' , '视频流' , '区域坐标' ] , expected : '启用' , actual : '在线' , status : 'success' , last : '刚刚' , adapter : 'media-01' , health : '时钟 +0.2 s', config : '2 个区域' , area : '北围墙' , protocol : 'ONVIF / RTSP' , profile : '子码流 704 × 576 · 5 FPS' , clock : '+0.2 s'} ,
{ 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 : '未知' } ,
{ 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.8 s'} ,
{ name : '宿舍东通道' , serial : 'CAM-S2-011' , modality : 'video' , modalityLabel : '视频' , capabilities : [ '图像采集' , '视频流' , '区域坐标' ] , expected : '启用' , actual : '在线' , status : 'success' , last : '4 秒前' , adapter : 'media-01 ' , health : '时钟 +0.1 s', config : '3 个区域' , area : '宿舍区' , protocol : 'RTSP' , profile : '子码流 704 × 576 · 5 FPS' , clock : '+0.1 s'} ,
{ 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' , '宿舍东通道' , '配电房门口' ] ;
$ ( '#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 ) {
$$ ( '.view' ) . forEach ( v => v . classList . toggle ( 'active' , v . dataset . page === 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' ) ;
$ ( '#main-content' ) . focus ( { preventScroll : true } ) ;
}
$$ ( '[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' ) {
const device = deviceData . find ( d => d [ 1 ] === serial ) || deviceData [ 0 ] ;
$ ( '#deviceDetailName' ) . textContent = device [ 0 ] ; $ ( '#deviceBreadcrumb' ) . textContent = device [ 0 ] ; $ ( '#deviceDetailSerial' ) . textContent = device [ 1 ] ;
const bellHref = ` ../bell/index.html?view=rules&camera= ${ encodeURIComponent ( device [ 1 ] ) } &zone=ZONE-007 ` ;
const device = deviceData . find ( d => d . serial === serial ) || deviceData [ 0 ] ;
currentDevice = device ;
$ ( '#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 ;
if ( video ) restoreDraft ( device . serial ) ; else { points . length = 0 ; drawDraft ( { persist : false } ) ; }
activateDetailTab ( video ? 'zones' : 'info' ) ;
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' ) ) ;
$$ ( '.detail-tabs button' ) . forEach ( button => button . addEventListener ( 'click' , ( ) => {
const name = button . dataset . detailTab ;
$$ ( '.detail-tabs button' ) . forEach ( b => { const on = b === button ; b . classList . toggle ( 'active ', on ) ; b . setAttribute ( 'aria-selected' , on ) ; } ) ;
$$ ( '[data-detail-panel]' ) . forEach ( panel => panel . hidden = panel . dataset . detailPanel !== name ) ;
} ) ) ;
$$ ( '.detail-tabs [role="tab"]' ) . forEach ( tab => {
tab . addEventListener ( 'click' , ( ) => activateDetailTab ( tab . dataset . detailTab ) ) ;
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 ) ; } ) ;
} ) ;
function renderState ( state ) {
const layer = $ ( '#stateLayer' ) ; const views = $$ ( '.view' ) ;
@@ -512,7 +606,7 @@
if ( state === 'normal' ) return ;
const configs = {
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> `
} ; 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' ? '深色' : '浅色' } 主题 ` ) ; } ) ;
const zoneCanvas = $ ( '#zoneCanvas' ) , draftLayer = $ ( '#draftLayer' ) , points = [ ] ;
let activeTool = 'polygon' ;
function drawDraft ( ) {
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"/> ` : ` <polyline points=" ${ coords } " fill=" ${ points . length > 2 ? 'rgba(167,139,250,.18)' : '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 } 个点 ` ;
$ ( '#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> ';
$ ( '#draftBadge' ) . className = points . length ? 'badge warning' : 'badge info' ;
$ ( '#draftBadge' ) . textContent = points . length ? '区域有未保存修改' : '区域 v7' ;
let activeTool = 'polygon' , drawingComplete = false , serverVersion = 7 , clickTimer ;
const draftKey = serial => ` yovision-sense-zone-draft: ${ serial } ` ;
function safeSession ( action ) { try { return action ( sessionStorage ) ; } catch { return undefined ; } }
function persistDraft ( ) {
if ( ! currentDevice || currentDevice . modality !== 'video ') return ;
const saved = safeSession ( store => { store . setItem ( draftKey ( currentDevice . serial ) , JSON . stringify ( { points , activeTool , drawingComplete , baseVersion : serverVersion } ) ) ; return true ; } ) ;
$ ( '#autosaveStatus ' ) . textContent = saved ? ` 草稿已自动保存 · ${ new Date ( ) . toLocaleTimeString ( 'zh-CN' , { hour : '2-digit' , minute : '2-digit' } )} ` : '浏览器禁止会话存储;草稿仅在当前页面内保留。 ';
}
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 ( ) ; }
zoneCanvas . addEventListener ( 'click' , e => { const r = zoneCanvas . getBoundingClientRect ( ) ; addPoint ( ( e . clientX - r . left ) / r . width * 960 , ( e . clientY - r . top ) / r . height * 540 ) ; } ) ;
zoneCanvas . addEventListener ( 'dblclick' , e => { e . preventDefault ( ) ; if ( activeTool === 'polygon' && points . length >= 3 ) notify ( ` 多边形已闭合,共 ${ points . length } 个顶点;请保存草稿。 ` ) ; } ) ;
$$ ( '.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' ? '选择:查看已有区域;切换绘制工具可新建草稿。' : '多边形:点击画面添加顶点,双击闭合;也可在右侧输入坐标。' ; } ) ) ;
$ ( '#undoPoint' ) . addEventListener ( 'click ', ( ) => { points . pop ( ) ; drawDraft ( ) ; } ) ;
function clearStoredDraft ( ) { if ( currentDevice ) safeSession ( store => store . removeItem ( draftKey ( currentDevice . serial ) ) ) ; }
function restoreDraft ( serial ) {
points . length = 0 ; activeTool = 'polygon' ; drawingComplete = false ; serverVersion = 7 ;
const raw = safeSession ( store => store . getItem ( draftKey ( serial ) ) ) ;
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 ( ) ; } } ) ;
$ ( '#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 ) ; } ) ;
$ ( '#discardDraf t' ) . addEventListener ( 'click' , ( ) => { if ( ! points . length || confirm ( '放弃未保存的区域修改? ') ) { points . length = 0 ; drawDraft ( ) ; notify ( '已放弃本次修改,已保存区域和 Bell 规则均未改变。' ) ; } } ) ;
$ ( '#saveDraf t' ) . 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 规则不会被自动发布。' ) ; } ) ;
$ ( '#pointLis t' ) . addEventListener ( 'click' , event => { const button = event . target . closest ( '[data-delete-point] ') ; if ( ! button ) return ; points . splice ( Number ( button . dataset . deletePoint ) , 1 ) ; drawingComplete = false ; drawDraft ( ) ; } ) ;
$ ( '#pointLis t' ) . 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 ( '画面分辨率或旋转发生变化,现有区域已标记为待校准。' ) ; } ) ;
const dialog = $ ( '#camera Dialog' ) ;
$ ( '#openCameraDialog' ) . addEventListener ( 'click' , ( ) => dialog . showModal ( ) ) ;
$ ( '#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> '; } ) ;
$ ( '#saveCamera ' ) . addEventListener ( 'click' , ( ) => notify ( '摄像头已保存为“待激活”,凭据不会显示在列表中。' ) ) ;
const deviceDialog = $ ( '#device Dialog' ) ;
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 ; }
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 ( ) ; }
$ ( '#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 个重复序列号。' ) ) ;
drawDraft ( ) ;
renderDevices ( ) ;
drawDraft ( { persist : false } ) ;
const query = new URLSearchParams ( location . search ) ;
if ( query . get ( 'view' ) === 'device-detail' || query . has ( 'camera' ) ) {
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 >
< / body >